diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 861474af..cb51917c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -20,6 +20,7 @@ import { settingsRoutes } from './routes/settings.js'; import { utilRoutes } from './routes/utils.js'; import { instanceRoutes } from './routes/instance.js'; import { exploreRoutes } from './routes/explore.js'; +import { searchRoutes } from './routes/search.js'; import { registerWebSocket } from './ws/handler.js'; import path from 'path'; import fs from 'fs'; @@ -79,6 +80,7 @@ async function main(): Promise { await app.register(utilRoutes); await app.register(instanceRoutes); await app.register(exploreRoutes); + await app.register(searchRoutes); await app.register(registerWebSocket); app.get('/api/health', async () => { diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts index 442599a2..d0e618a3 100644 --- a/packages/server/src/routes/messages.ts +++ b/packages/server/src/routes/messages.ts @@ -18,7 +18,7 @@ import { sanitizeUser } from '../utils/sanitize.js'; * Fetch reactions for a set of message IDs. * Returns a map from messageId to Reaction[]. */ -function fetchReactionsForMessages(messageIds: string[]): Map { +export function fetchReactionsForMessages(messageIds: string[]): Map { if (messageIds.length === 0) return new Map(); const db = getDb(); const reactionRows = db.select() @@ -56,7 +56,7 @@ function fetchReactionsForMessages(messageIds: string[]): Map { +export function fetchReplyToMessages(messages: (typeof schema.messages.$inferSelect)[]): Map { const replyToIds = messages .map(m => m.replyToId) .filter((id): id is string => id !== null && id !== undefined); @@ -119,7 +119,7 @@ function fetchReplyToMessages(messages: (typeof schema.messages.$inferSelect)[]) return map; } -function buildMessageWithUser( +export function buildMessageWithUser( message: typeof schema.messages.$inferSelect, user: typeof schema.users.$inferSelect, attachmentRows: (typeof schema.attachments.$inferSelect)[], diff --git a/packages/server/src/routes/search.ts b/packages/server/src/routes/search.ts new file mode 100644 index 00000000..475fd3eb --- /dev/null +++ b/packages/server/src/routes/search.ts @@ -0,0 +1,547 @@ +import type { FastifyInstance } from 'fastify'; +import { eq, and, desc, lt, gt, like, inArray, sql, asc } from 'drizzle-orm'; +import { getDb, schema } from '../db/index.js'; +import { authenticate } from '../utils/auth.js'; +import { hasPermission, getChannelSpaceId, PermissionBits, isDmMember } from '../utils/permissions.js'; +import { fetchReactionsForMessages, fetchReplyToMessages, buildMessageWithUser } from './messages.js'; +import { fetchDmReactionsForMessages, buildDmMessageWithUser } from './dm.js'; +import { sanitizeUser } from '../utils/sanitize.js'; +import type { MessageWithUser, DmMessageWithUser } from '@backspace/shared'; + +interface SearchQuery { + q?: string; + from?: string; + has?: string; + before?: string; + after?: string; + offset?: string; + limit?: string; +} + +interface AroundQuery { + messageId: string; + limit?: string; +} + +export async function searchRoutes(app: FastifyInstance): Promise { + // GET /api/channels/:id/search — Search messages in a space channel + app.get<{ Params: { id: string }; Querystring: SearchQuery }>('/api/channels/:id/search', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { q, from, has, before, after } = request.query; + const offset = Math.max(Number(request.query.offset) || 0, 0); + const limit = Math.min(Math.max(Number(request.query.limit) || 25, 1), 50); + + const spaceId = getChannelSpaceId(id); + if (!spaceId) { + return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); + } + + if (!hasPermission(request.userId, spaceId, PermissionBits.VIEW_CHANNEL | PermissionBits.READ_MESSAGE_HISTORY, id)) { + return reply.code(403).send({ error: 'Missing permissions', statusCode: 403 }); + } + + const db = getDb(); + const conditions: ReturnType[] = [eq(schema.messages.channelId, id)]; + + if (q && q.trim()) { + conditions.push(like(schema.messages.content, `%${q.trim()}%`)); + } + + if (from && from.trim()) { + const user = db.select().from(schema.users) + .where(like(schema.users.username, from.trim())) + .get(); + if (user) { + conditions.push(eq(schema.messages.userId, user.id)); + } else { + return reply.code(200).send({ results: [], totalCount: 0 }); + } + } + + if (before) { + const ts = new Date(before).getTime(); + if (!isNaN(ts)) { + conditions.push(lt(schema.messages.createdAt, ts)); + } + } + + if (after) { + const ts = new Date(after).getTime(); + if (!isNaN(ts)) { + conditions.push(gt(schema.messages.createdAt, ts)); + } + } + + const whereClause = and(...conditions)!; + + // Handle has: filter with subqueries + let hasFilter: ReturnType | null = null; + if (has === 'file' || has === 'image') { + hasFilter = sql`EXISTS (SELECT 1 FROM attachments WHERE attachments.message_id = messages.id${ + has === 'image' ? sql` AND attachments.mimetype LIKE 'image/%'` : sql`` + })`; + } else if (has === 'link') { + conditions.push(like(schema.messages.content, '%http%')); + } + + // Count total + let countQuery; + if (hasFilter) { + countQuery = db.select({ count: sql`count(*)` }) + .from(schema.messages) + .where(and(whereClause, hasFilter)) + .get(); + } else { + countQuery = db.select({ count: sql`count(*)` }) + .from(schema.messages) + .where(whereClause) + .get(); + } + const totalCount = countQuery?.count ?? 0; + + // Fetch results + let messageRows: (typeof schema.messages.$inferSelect)[]; + if (hasFilter) { + messageRows = db.select() + .from(schema.messages) + .where(and(whereClause, hasFilter)) + .orderBy(desc(schema.messages.createdAt)) + .limit(limit) + .offset(offset) + .all(); + } else { + messageRows = db.select() + .from(schema.messages) + .where(whereClause) + .orderBy(desc(schema.messages.createdAt)) + .limit(limit) + .offset(offset) + .all(); + } + + if (messageRows.length === 0) { + return reply.code(200).send({ results: [], totalCount }); + } + + // Hydrate results + const userIds = [...new Set(messageRows.map(m => m.userId))]; + const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all(); + const userMap = new Map(users.map(u => [u.id, u])); + + const messageIds = messageRows.map(m => m.id); + const allAttachments = db.select() + .from(schema.attachments) + .where(inArray(schema.attachments.messageId, messageIds)) + .all(); + const attachmentMap = new Map(); + for (const att of allAttachments) { + const mid = att.messageId ?? ''; + if (!attachmentMap.has(mid)) attachmentMap.set(mid, []); + attachmentMap.get(mid)!.push(att); + } + + const reactionsMap = fetchReactionsForMessages(messageIds); + const replyToMap = fetchReplyToMessages(messageRows); + + const results: MessageWithUser[] = messageRows + .map(m => { + const user = userMap.get(m.userId); + if (!user) return null; + const reactions = reactionsMap.get(m.id) ?? []; + const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null; + return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo); + }) + .filter((m): m is MessageWithUser => m !== null); + + return reply.code(200).send({ results, totalCount }); + }); + + // GET /api/dm/:id/search — Search messages in a DM channel + app.get<{ Params: { id: string }; Querystring: SearchQuery }>('/api/dm/:id/search', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { q, from, has, before, after } = request.query; + const offset = Math.max(Number(request.query.offset) || 0, 0); + const limit = Math.min(Math.max(Number(request.query.limit) || 25, 1), 50); + + if (!isDmMember(id, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); + } + + const db = getDb(); + const conditions: ReturnType[] = [eq(schema.dmMessages.dmChannelId, id)]; + + if (q && q.trim()) { + conditions.push(like(schema.dmMessages.content, `%${q.trim()}%`)); + } + + if (from && from.trim()) { + const user = db.select().from(schema.users) + .where(like(schema.users.username, from.trim())) + .get(); + if (user) { + conditions.push(eq(schema.dmMessages.userId, user.id)); + } else { + return reply.code(200).send({ results: [], totalCount: 0 }); + } + } + + if (before) { + const ts = new Date(before).getTime(); + if (!isNaN(ts)) { + conditions.push(lt(schema.dmMessages.createdAt, ts)); + } + } + + if (after) { + const ts = new Date(after).getTime(); + if (!isNaN(ts)) { + conditions.push(gt(schema.dmMessages.createdAt, ts)); + } + } + + const whereClause = and(...conditions)!; + + let hasFilter: ReturnType | null = null; + if (has === 'file' || has === 'image') { + hasFilter = sql`EXISTS (SELECT 1 FROM attachments WHERE attachments.dm_message_id = dm_messages.id${ + has === 'image' ? sql` AND attachments.mimetype LIKE 'image/%'` : sql`` + })`; + } else if (has === 'link') { + conditions.push(like(schema.dmMessages.content, '%http%')); + } + + let countQuery; + if (hasFilter) { + countQuery = db.select({ count: sql`count(*)` }) + .from(schema.dmMessages) + .where(and(whereClause, hasFilter)) + .get(); + } else { + countQuery = db.select({ count: sql`count(*)` }) + .from(schema.dmMessages) + .where(whereClause) + .get(); + } + const totalCount = countQuery?.count ?? 0; + + let messageRows: (typeof schema.dmMessages.$inferSelect)[]; + if (hasFilter) { + messageRows = db.select() + .from(schema.dmMessages) + .where(and(whereClause, hasFilter)) + .orderBy(desc(schema.dmMessages.createdAt)) + .limit(limit) + .offset(offset) + .all(); + } else { + messageRows = db.select() + .from(schema.dmMessages) + .where(whereClause) + .orderBy(desc(schema.dmMessages.createdAt)) + .limit(limit) + .offset(offset) + .all(); + } + + if (messageRows.length === 0) { + return reply.code(200).send({ results: [], totalCount }); + } + + // Hydrate + const userIds = [...new Set(messageRows.map(m => m.userId))]; + const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all(); + const userMap = new Map(users.map(u => [u.id, u])); + + const messageIds = messageRows.map(m => m.id); + const allAttachments = db.select() + .from(schema.attachments) + .where(inArray(schema.attachments.dmMessageId, messageIds)) + .all(); + const attachmentMap = new Map(); + for (const att of allAttachments) { + const mid = att.dmMessageId ?? ''; + if (!attachmentMap.has(mid)) attachmentMap.set(mid, []); + attachmentMap.get(mid)!.push(att); + } + + const reactionsMap = fetchDmReactionsForMessages(messageIds); + + // Fetch reply-to messages for DMs + const replyToIds = messageRows + .map(m => m.replyToId) + .filter((rid): rid is string => rid !== null && rid !== undefined); + const uniqueReplyIds = [...new Set(replyToIds)]; + const replyToMap = new Map(); + if (uniqueReplyIds.length > 0) { + const replyMessages = db.select() + .from(schema.dmMessages) + .where(inArray(schema.dmMessages.id, uniqueReplyIds)) + .all(); + const replyUserIds = [...new Set(replyMessages.map(m => m.userId))]; + const replyUsers = replyUserIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, replyUserIds)).all() + : []; + const replyUserMap = new Map(replyUsers.map(u => [u.id, u])); + for (const rm of replyMessages) { + const rUser = replyUserMap.get(rm.userId); + if (!rUser) continue; + replyToMap.set(rm.id, { + id: rm.id, + dmChannelId: rm.dmChannelId, + userId: rm.userId, + replyToId: rm.replyToId, + content: rm.content, + editedAt: rm.editedAt, + createdAt: rm.createdAt, + user: sanitizeUser(rUser), + attachments: [], + reactions: [], + }); + } + } + + const results: DmMessageWithUser[] = messageRows + .map(m => { + const user = userMap.get(m.userId); + if (!user) return null; + const reactions = reactionsMap.get(m.id) ?? []; + const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null; + return buildDmMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo); + }) + .filter((m): m is DmMessageWithUser => m !== null); + + return reply.code(200).send({ results, totalCount }); + }); + + // GET /api/channels/:id/messages/around — Load messages around a target message + app.get<{ Params: { id: string }; Querystring: AroundQuery }>('/api/channels/:id/messages/around', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { messageId } = request.query; + const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100); + const half = Math.floor(limit / 2); + + if (!messageId) { + return reply.code(400).send({ error: 'messageId is required', statusCode: 400 }); + } + + const spaceId = getChannelSpaceId(id); + if (!spaceId) { + return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); + } + + if (!hasPermission(request.userId, spaceId, PermissionBits.VIEW_CHANNEL | PermissionBits.READ_MESSAGE_HISTORY, id)) { + return reply.code(403).send({ error: 'Missing permissions', statusCode: 403 }); + } + + const db = getDb(); + + // Get the target message to know its timestamp + const target = db.select().from(schema.messages) + .where(and(eq(schema.messages.id, messageId), eq(schema.messages.channelId, id))) + .get(); + if (!target) { + return reply.code(404).send({ error: 'Message not found', statusCode: 404 }); + } + + // Messages before (inclusive of target) + const beforeRows = db.select() + .from(schema.messages) + .where(and( + eq(schema.messages.channelId, id), + sql`${schema.messages.id} <= ${messageId}`, + )) + .orderBy(desc(schema.messages.createdAt)) + .limit(half + 1) + .all(); + + // Messages after + const afterRows = db.select() + .from(schema.messages) + .where(and( + eq(schema.messages.channelId, id), + gt(schema.messages.id, messageId), + )) + .orderBy(asc(schema.messages.createdAt)) + .limit(half) + .all(); + + // Combine in chronological order + beforeRows.reverse(); + const messageRows = [...beforeRows, ...afterRows]; + + // Deduplicate (target message appears in both queries) + const seen = new Set(); + const uniqueRows = messageRows.filter(m => { + if (seen.has(m.id)) return false; + seen.add(m.id); + return true; + }); + + if (uniqueRows.length === 0) { + return reply.code(200).send([]); + } + + // Hydrate + const userIds = [...new Set(uniqueRows.map(m => m.userId))]; + const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all(); + const userMap = new Map(users.map(u => [u.id, u])); + + const msgIds = uniqueRows.map(m => m.id); + const allAttachments = db.select() + .from(schema.attachments) + .where(inArray(schema.attachments.messageId, msgIds)) + .all(); + const attachmentMap = new Map(); + for (const att of allAttachments) { + const mid = att.messageId ?? ''; + if (!attachmentMap.has(mid)) attachmentMap.set(mid, []); + attachmentMap.get(mid)!.push(att); + } + + const reactionsMap = fetchReactionsForMessages(msgIds); + const replyToMap = fetchReplyToMessages(uniqueRows); + + const messages: MessageWithUser[] = uniqueRows + .map(m => { + const user = userMap.get(m.userId); + if (!user) return null; + const reactions = reactionsMap.get(m.id) ?? []; + const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null; + return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo); + }) + .filter((m): m is MessageWithUser => m !== null); + + return reply.code(200).send(messages); + }); + + // GET /api/dm/:id/messages/around — Load DM messages around a target message + app.get<{ Params: { id: string }; Querystring: AroundQuery }>('/api/dm/:id/messages/around', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { messageId } = request.query; + const limit = Math.min(Math.max(Number(request.query.limit) || 50, 1), 100); + const half = Math.floor(limit / 2); + + if (!messageId) { + return reply.code(400).send({ error: 'messageId is required', statusCode: 400 }); + } + + if (!isDmMember(id, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); + } + + const db = getDb(); + + const target = db.select().from(schema.dmMessages) + .where(and(eq(schema.dmMessages.id, messageId), eq(schema.dmMessages.dmChannelId, id))) + .get(); + if (!target) { + return reply.code(404).send({ error: 'Message not found', statusCode: 404 }); + } + + const beforeRows = db.select() + .from(schema.dmMessages) + .where(and( + eq(schema.dmMessages.dmChannelId, id), + sql`${schema.dmMessages.id} <= ${messageId}`, + )) + .orderBy(desc(schema.dmMessages.createdAt)) + .limit(half + 1) + .all(); + + const afterRows = db.select() + .from(schema.dmMessages) + .where(and( + eq(schema.dmMessages.dmChannelId, id), + gt(schema.dmMessages.id, messageId), + )) + .orderBy(asc(schema.dmMessages.createdAt)) + .limit(half) + .all(); + + beforeRows.reverse(); + const messageRows = [...beforeRows, ...afterRows]; + + const seen = new Set(); + const uniqueRows = messageRows.filter(m => { + if (seen.has(m.id)) return false; + seen.add(m.id); + return true; + }); + + if (uniqueRows.length === 0) { + return reply.code(200).send([]); + } + + // Hydrate + const userIds = [...new Set(uniqueRows.map(m => m.userId))]; + const users = db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all(); + const userMap = new Map(users.map(u => [u.id, u])); + + const msgIds = uniqueRows.map(m => m.id); + const allAttachments = db.select() + .from(schema.attachments) + .where(inArray(schema.attachments.dmMessageId, msgIds)) + .all(); + const attachmentMap = new Map(); + for (const att of allAttachments) { + const mid = att.dmMessageId ?? ''; + if (!attachmentMap.has(mid)) attachmentMap.set(mid, []); + attachmentMap.get(mid)!.push(att); + } + + const reactionsMap = fetchDmReactionsForMessages(msgIds); + + const replyToIds = uniqueRows + .map(m => m.replyToId) + .filter((rid): rid is string => rid !== null && rid !== undefined); + const uniqueReplyIds = [...new Set(replyToIds)]; + const replyToMap = new Map(); + if (uniqueReplyIds.length > 0) { + const replyMessages = db.select() + .from(schema.dmMessages) + .where(inArray(schema.dmMessages.id, uniqueReplyIds)) + .all(); + const replyUserIds = [...new Set(replyMessages.map(m => m.userId))]; + const replyUsers = replyUserIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, replyUserIds)).all() + : []; + const replyUserMap = new Map(replyUsers.map(u => [u.id, u])); + for (const rm of replyMessages) { + const rUser = replyUserMap.get(rm.userId); + if (!rUser) continue; + replyToMap.set(rm.id, { + id: rm.id, + dmChannelId: rm.dmChannelId, + userId: rm.userId, + replyToId: rm.replyToId, + content: rm.content, + editedAt: rm.editedAt, + createdAt: rm.createdAt, + user: sanitizeUser(rUser), + attachments: [], + reactions: [], + }); + } + } + + const messages: DmMessageWithUser[] = uniqueRows + .map(m => { + const user = userMap.get(m.userId); + if (!user) return null; + const reactions = reactionsMap.get(m.id) ?? []; + const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null; + return buildDmMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo); + }) + .filter((m): m is DmMessageWithUser => m !== null); + + return reply.code(200).send(messages); + }); +} diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index a69a9763..ed4a3c39 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -71,6 +71,7 @@ export class BackspaceApiClient { update: (id: string, data: UpdateChannelRequest) => Promise; delete: (id: string) => Promise<{ success: boolean }>; messages: (id: string, before?: string, limit?: number) => Promise; + messagesAround: (id: string, messageId: string) => Promise; sendMessage: (channelId: string, data: CreateMessageRequest) => Promise; getOverrides: (channelId: string) => Promise<{ channelId: string; targetType: string; targetId: string; allow: string; deny: string }[]>; putOverride: (channelId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => Promise<{ success: boolean }>; @@ -92,6 +93,7 @@ export class BackspaceApiClient { create: (data: CreateDmRequest) => Promise; close: (id: string) => Promise<{ success: boolean }>; messages: (id: string, before?: string, limit?: number) => Promise; + messagesAround: (id: string, messageId: string) => Promise; sendMessage: (id: string, data: CreateDmMessageRequest) => Promise; updateMessage: (id: string, data: UpdateMessageRequest) => Promise; deleteMessage: (id: string) => Promise<{ success: boolean }>; @@ -131,6 +133,11 @@ export class BackspaceApiClient { delete: (spaceId: string, roleId: string) => Promise<{ success: boolean }>; }; + readonly search: { + channel: (channelId: string, params: { q?: string; from?: string; has?: string; before?: string; after?: string; offset?: number; limit?: number }) => Promise<{ results: MessageWithUser[]; totalCount: number }>; + dm: (dmChannelId: string, params: { q?: string; from?: string; has?: string; before?: string; after?: string; offset?: number; limit?: number }) => Promise<{ results: DmMessageWithUser[]; totalCount: number }>; + }; + readonly explore: { list: (q?: string, limit?: number, offset?: number) => Promise<{ spaces: ExploreSpace[]; total: number; totalAll: number; discoveryEnabled: boolean }>; publicJoin: (spaceId: string) => Promise; @@ -255,6 +262,11 @@ export class BackspaceApiClient { params.set('limit', String(limit)); return request('GET', `/channels/${id}/messages?${params}`); }, + messagesAround: (id: string, messageId: string) => { + const params = new URLSearchParams(); + params.set('messageId', messageId); + return request('GET', `/channels/${id}/messages/around?${params}`); + }, sendMessage: (channelId: string, data: CreateMessageRequest) => request('POST', `/channels/${channelId}/messages`, data), getOverrides: (channelId: string) => @@ -287,6 +299,11 @@ export class BackspaceApiClient { params.set('limit', String(limit)); return request('GET', `/dm/${id}/messages?${params}`); }, + messagesAround: (id: string, messageId: string) => { + const params = new URLSearchParams(); + params.set('messageId', messageId); + return request('GET', `/dm/${id}/messages/around?${params}`); + }, sendMessage: (id: string, data: CreateDmMessageRequest) => request('POST', `/dm/${id}/messages`, data), updateMessage: (id: string, data: UpdateMessageRequest) => @@ -339,6 +356,31 @@ export class BackspaceApiClient { request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/roles/${roleId}`), }; + this.search = { + channel: (channelId: string, params: { q?: string; from?: string; has?: string; before?: string; after?: string; offset?: number; limit?: number }) => { + const qs = new URLSearchParams(); + if (params.q) qs.set('q', params.q); + if (params.from) qs.set('from', params.from); + if (params.has) qs.set('has', params.has); + if (params.before) qs.set('before', params.before); + if (params.after) qs.set('after', params.after); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + return request<{ results: MessageWithUser[]; totalCount: number }>('GET', `/channels/${channelId}/search?${qs}`); + }, + dm: (dmChannelId: string, params: { q?: string; from?: string; has?: string; before?: string; after?: string; offset?: number; limit?: number }) => { + const qs = new URLSearchParams(); + if (params.q) qs.set('q', params.q); + if (params.from) qs.set('from', params.from); + if (params.has) qs.set('has', params.has); + if (params.before) qs.set('before', params.before); + if (params.after) qs.set('after', params.after); + if (params.offset !== undefined) qs.set('offset', String(params.offset)); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + return request<{ results: DmMessageWithUser[]; totalCount: number }>('GET', `/dm/${dmChannelId}/search?${qs}`); + }, + }; + this.explore = { list: (q?: string, limit = 50, offset = 0) => { const params = new URLSearchParams(); diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index 12421b20..dd2e81bf 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -155,6 +155,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { const content = (
void; } function isSameGroup(prev: MessageWithUser, curr: MessageWithUser): boolean { @@ -38,10 +40,11 @@ function shouldShowDateDivider(prev: MessageWithUser | undefined, curr: MessageW return prevDate !== currDate; } -export function MessageList({ channelId }: MessageListProps) { +export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: MessageListProps) { const messages = useChatStore((s) => s.messages.get(channelId)) ?? EMPTY_MESSAGES; const loadMessages = useChatStore((s) => s.loadMessages); const loadMoreMessages = useChatStore((s) => s.loadMoreMessages); + const loadMessagesAround = useChatStore((s) => s.loadMessagesAround); const isLoading = useChatStore((s) => s.isLoading); const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true); const ackChannel = useChatStore((s) => s.ackChannel); @@ -118,6 +121,36 @@ export function MessageList({ channelId }: MessageListProps) { return () => observer.disconnect(); }, [hasMessages, channelId]); + // Jump-to-message: scroll to target and highlight + useEffect(() => { + if (!jumpToMessageId) return; + + const scrollToMessage = () => { + const el = document.getElementById(`msg-${jumpToMessageId}`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('search-highlight'); + setTimeout(() => el.classList.remove('search-highlight'), 2000); + onJumpComplete?.(); + return true; + } + return false; + }; + + // Check if the message is already in the cache + if (scrollToMessage()) return; + + // Not in cache — load messages around the target + loadMessagesAround(channelId, jumpToMessageId).then(() => { + // Wait for React to render the new messages + requestAnimationFrame(() => { + requestAnimationFrame(() => { + scrollToMessage(); + }); + }); + }); + }, [jumpToMessageId, channelId, loadMessagesAround, onJumpComplete]); + const handleScroll = useCallback(async () => { const container = containerRef.current; if (!container) return; diff --git a/packages/web/src/components/chat/SearchPopover.tsx b/packages/web/src/components/chat/SearchPopover.tsx new file mode 100644 index 00000000..bbe99443 --- /dev/null +++ b/packages/web/src/components/chat/SearchPopover.tsx @@ -0,0 +1,333 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { useFloatingPosition } from '../../hooks/useFloatingPosition'; +import { isDmChannel, getChannelOrigin, getApiForOrigin } from '../../stores/spaceStore'; +import { Avatar } from '../ui/Avatar'; +import type { MessageWithUser, DmMessageWithUser } from '@backspace/shared'; + +type AnyMessage = MessageWithUser | DmMessageWithUser; + +interface SearchPopoverProps { + open: boolean; + onClose: () => void; + anchorRef: React.RefObject; + channelId: string; + isDm: boolean; + onJumpToMessage: (messageId: string) => void; +} + +function formatTime(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + const isYesterday = date.toDateString() === yesterday.toDateString(); + const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + if (isToday) return `Today at ${time}`; + if (isYesterday) return `Yesterday at ${time}`; + return `${date.toLocaleDateString()} ${time}`; +} + +function highlightMatch(text: string, query: string): React.ReactNode { + if (!query.trim() || !text) return text; + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const parts = text.split(new RegExp(`(${escaped})`, 'gi')); + return parts.map((part, i) => + part.toLowerCase() === query.toLowerCase() + ? {part} + : part + ); +} + +export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJumpToMessage }: SearchPopoverProps) { + const popoverRef = useRef(null); + const inputRef = useRef(null); + const { style } = useFloatingPosition(anchorRef, popoverRef, { + placement: 'bottom', + offset: 8, + enabled: open, + }); + + const [query, setQuery] = useState(''); + const [fromFilter, setFromFilter] = useState(''); + const [hasFilter, setHasFilter] = useState(''); + const [beforeFilter, setBeforeFilter] = useState(''); + const [afterFilter, setAfterFilter] = useState(''); + const [showFilters, setShowFilters] = useState(false); + const [results, setResults] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [isSearching, setIsSearching] = useState(false); + const [offset, setOffset] = useState(0); + const debounceRef = useRef>(); + + // Reset state when channel changes or popover opens + useEffect(() => { + if (open) { + setQuery(''); + setFromFilter(''); + setHasFilter(''); + setBeforeFilter(''); + setAfterFilter(''); + setResults([]); + setTotalCount(0); + setOffset(0); + setShowFilters(false); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [open, channelId]); + + // Click-outside handler + useEffect(() => { + if (!open) return; + const handleClick = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open, onClose]); + + // Escape handler + useEffect(() => { + if (!open) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [open, onClose]); + + const doSearch = useCallback(async (searchOffset = 0) => { + const trimmed = query.trim(); + if (!trimmed && !fromFilter && !hasFilter && !beforeFilter && !afterFilter) { + setResults([]); + setTotalCount(0); + return; + } + + setIsSearching(true); + try { + const origin = getChannelOrigin(channelId); + const client = getApiForOrigin(origin); + const params = { + q: trimmed || undefined, + from: fromFilter || undefined, + has: hasFilter || undefined, + before: beforeFilter || undefined, + after: afterFilter || undefined, + offset: searchOffset, + limit: 25, + }; + + const data = isDm + ? await client.search.dm(channelId, params) + : await client.search.channel(channelId, params); + + if (searchOffset === 0) { + setResults(data.results as AnyMessage[]); + } else { + setResults(prev => [...prev, ...(data.results as AnyMessage[])]); + } + setTotalCount(data.totalCount); + setOffset(searchOffset + data.results.length); + } catch (err) { + console.error('Search failed:', err); + } finally { + setIsSearching(false); + } + }, [query, fromFilter, hasFilter, beforeFilter, afterFilter, channelId, isDm]); + + // Debounced search on query/filter change + useEffect(() => { + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + doSearch(0); + }, 300); + return () => clearTimeout(debounceRef.current); + }, [doSearch]); + + const handleLoadMore = () => { + doSearch(offset); + }; + + if (!open) return null; + + return createPortal( +
+ {/* Search input */} +
+
+ + + + setQuery(e.target.value)} + placeholder="Search messages..." + className="flex-1 bg-transparent text-txt-primary text-[14px] placeholder-txt-tertiary outline-none" + /> + {query && ( + + )} +
+ + {/* Filter toggle */} + + + {/* Filters row */} + {showFilters && ( +
+
+ + setFromFilter(e.target.value)} + placeholder="username" + className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary placeholder-txt-tertiary outline-none" + /> +
+
+ + +
+
+ + setBeforeFilter(e.target.value)} + className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary outline-none" + /> +
+
+ + setAfterFilter(e.target.value)} + className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary outline-none" + /> +
+
+ )} +
+ + {/* Results */} +
+ {isSearching && results.length === 0 && ( +
+
+
+ )} + + {!isSearching && results.length === 0 && (query || fromFilter || hasFilter || beforeFilter || afterFilter) && ( +
+ + + + No results found +
+ )} + + {!query && !fromFilter && !hasFilter && !beforeFilter && !afterFilter && results.length === 0 && ( +
+ Type to search messages in this channel +
+ )} + + {results.length > 0 && ( + <> +
+ {totalCount} result{totalCount !== 1 ? 's' : ''} +
+ {results.map((msg) => ( + + ))} + {results.length < totalCount && ( +
+ +
+ )} + + )} +
+
, + document.body, + ); +} diff --git a/packages/web/src/components/layout/MainContent.tsx b/packages/web/src/components/layout/MainContent.tsx index a68f6929..3fc95f14 100644 --- a/packages/web/src/components/layout/MainContent.tsx +++ b/packages/web/src/components/layout/MainContent.tsx @@ -17,6 +17,8 @@ import { wsSend } from '../../hooks/useWebSocket'; import { MemberListToggleButton } from './MemberListToggleButton'; import { isSelf } from '../../utils/identity'; import { joinVoiceChannel } from '../../utils/voice'; +import { SearchPopover } from '../chat/SearchPopover'; +import { isDmChannel } from '../../stores/spaceStore'; export function MainContent() { // 1. ALL HOOKS AT THE TOP @@ -40,6 +42,14 @@ export function MainContent() { const openModal = useUIStore((s) => s.openModal); const voiceContainerRef = useRef(null); + const searchButtonRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); + const [jumpToMessageId, setJumpToMessageId] = useState(null); + + // Reset search when channel changes + useEffect(() => { + setSearchOpen(false); + }, [currentChannelId]); // Handle actual browser fullscreen API useEffect(() => { @@ -198,25 +208,28 @@ export function MainContent() {
- - -
- + setJumpToMessageId(null)} /> + setSearchOpen(false)} + anchorRef={searchButtonRef} + channelId={currentChannelId} + isDm={true} + onJumpToMessage={(id) => { setJumpToMessageId(id); setSearchOpen(false); }} + />
); } @@ -324,11 +337,6 @@ export function MainContent() { )}
-
- - -
- + setJumpToMessageId(null)} /> + setSearchOpen(false)} + anchorRef={searchButtonRef} + channelId={currentChannelId} + isDm={false} + onJumpToMessage={(id) => { setJumpToMessageId(id); setSearchOpen(false); }} + /> ); } diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 94a3994f..9ce462b2 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -48,6 +48,7 @@ interface ChatState { removeReaction: (messageId: string, emoji: string) => void; onReactionAdded: (messageId: string, reaction: any) => void; onReactionRemoved: (messageId: string, userId: string, emoji: string) => void; + loadMessagesAround: (channelId: string, messageId: string) => Promise; setTyping: (channelId: string, userId: string, username: string) => void; clearTyping: (channelId: string, userId: string) => void; getMessages: (channelId: string) => MessageWithUser[]; @@ -198,6 +199,34 @@ export const useChatStore = create((set, get) => ({ } }, + loadMessagesAround: async (channelId: string, messageId: string) => { + const isDm = isDmChannel(channelId); + if (!isDm && !useSpaceStore.getState().channelOriginMap.has(channelId)) return; + try { + const origin = getChannelOrigin(channelId); + const client = getApiForOrigin(origin); + const messages = isDm + ? await client.dm.messagesAround(channelId, messageId) + : await client.channels.messagesAround(channelId, messageId); + + if (origin) { + for (const msg of messages) normalizeMessageAssets(msg, origin); + } + + set((state) => { + const newMessages = new Map(state.messages); + newMessages.set(channelId, messages as MessageWithUser[]); + const newHasMore = new Map(state.hasMore); + newHasMore.set(channelId, true); + const newAccessTimes = new Map(state.channelAccessTimes); + newAccessTimes.set(channelId, Date.now()); + return { messages: newMessages, hasMore: newHasMore, channelAccessTimes: newAccessTimes }; + }); + } catch (err) { + console.error('Failed to load messages around:', err); + } + }, + sendMessage: async (channelId: string, content: string, attachmentIds?: string[]) => { const replyToId = get().replyTo?.id; const isDm = isDmChannel(channelId); diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css index 6cdcf376..a91a9c77 100644 --- a/packages/web/src/styles/globals.css +++ b/packages/web/src/styles/globals.css @@ -254,3 +254,9 @@ .animate-gradient-pulse { animation: gradientPulse 4s ease-in-out infinite; } + +@keyframes search-flash { + 0% { background-color: rgba(124, 108, 246, 0.2); } + 100% { background-color: transparent; } +} +.search-highlight { animation: search-flash 2s ease-out; }