import { EventType, MatrixEvent } from 'matrix-js-sdk'; import { useCallback } from 'react'; import { useAtomValue } from 'jotai'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { ResultGroup, ResultItem } from './useMessageSearch'; import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled'; import { mergeSearchResults, queryRoom, saveRoomIndex, SearchCacheRow, } from '../../utils/searchCache'; export type LocalSearchParams = { term: string; roomIds: string[]; senders?: string[]; /** Optional date-range filter (ms). Applied to both memory and cached rows. */ fromTs?: number; toTs?: number; }; export type LocalSearchResult = { groups: ResultGroup[]; /** How many rooms in scope are encrypted */ encryptedRoomsCount: number; /** How many of those had locally cached events to scan */ searchedRoomsCount: number; }; /** Extracted, searchable plaintext for a single message event. */ type ExtractedText = { body: string; formattedBody: string; pollText: string; }; const POLL_START_TYPES = ['m.poll.start', 'org.matrix.msc3381.poll.start']; /** * Pull the text we index/search from a decrypted event's content. Returns * `null` for events that carry no searchable text (e.g. stickers). */ const extractText = (event: MatrixEvent): ExtractedText | null => { const evType = event.getType(); const content = event.getContent(); if (POLL_START_TYPES.includes(evType)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any; if (!poll) return null; const qBody = (poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ?? (poll.question?.body as string | undefined) ?? ''; const answerBodies = ((poll.answers ?? []) as Array>) .map( (a) => ((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ?? // eslint-disable-next-line @typescript-eslint/no-explicit-any (a['org.matrix.msc3381.poll.answer'] as any)?.body ?? '') as string, ) .join(' '); const pollText = `${qBody} ${answerBodies}`.trim(); return pollText ? { body: '', formattedBody: '', pollText } : null; } if (evType !== EventType.RoomMessage) return null; const body = (content.body as string | undefined) ?? ''; const formattedBody = (content.formatted_body as string | undefined) ?? ''; if (!body && !formattedBody) return null; return { body, formattedBody, pollText: '' }; }; /** Does the extracted text contain the (already-lowercased) term? */ const matchesTerm = (text: ExtractedText, termLower: string): boolean => text.body.toLowerCase().includes(termLower) || text.formattedBody.toLowerCase().includes(termLower) || text.pollText.toLowerCase().includes(termLower); const rowMatchesTerm = (row: SearchCacheRow, termLower: string): boolean => row.body.toLowerCase().includes(termLower) || (row.formattedBody ?? '').toLowerCase().includes(termLower) || (row.pollText ?? '').toLowerCase().includes(termLower); /** Build the synthetic result item a cached row renders as (text message). */ const rowToResultItem = (row: SearchCacheRow): ResultItem => { const bodyText = row.body || row.pollText || ''; const content: Record = { msgtype: 'm.text', body: bodyText }; if (row.formattedBody) { content.format = 'org.matrix.custom.html'; content.formatted_body = row.formattedBody; } const syntheticEvent = { room_id: row.roomId, event_id: row.eventId, type: EventType.RoomMessage, sender: row.sender, origin_server_ts: row.ts, content, unsigned: {}, }; return { rank: 0, // eslint-disable-next-line @typescript-eslint/no-explicit-any event: syntheticEvent as any, context: { events_before: [], events_after: [], profile_info: {} }, }; }; /** * Client-side full-text search over locally cached events in encrypted rooms. * The homeserver cannot search E2EE message content, so we scan whatever the * client has already received and decrypted in memory. * * When the persistent search cache is enabled (opt-in), the in-memory scan is * also persisted to IndexedDB (fire-and-forget) and merged with prior cached * coverage so results survive reloads. When disabled, zero cache reads/writes * occur. */ export const useLocalMessageSearch = () => { const mx = useMatrixClient(); const cacheEnabled = useAtomValue(searchCacheEnabledAtom); const search = useCallback( async ({ term, roomIds, senders, fromTs, toTs, }: LocalSearchParams): Promise => { const trimmedTerm = term.trim(); const senderSet = senders && senders.length > 0 ? new Set(senders) : null; // Sender-only mode: no text filter, search all rooms (server can't filter by sender alone) const senderOnlyMode = !trimmedTerm && !!senderSet; if (!trimmedTerm && !senderSet) { return { groups: [], encryptedRoomsCount: 0, searchedRoomsCount: 0 }; } const termLower = trimmedTerm.toLowerCase(); const inRange = (ts: number): boolean => (fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs); const groups: ResultGroup[] = []; let encryptedRoomsCount = 0; let searchedRoomsCount = 0; for (const roomId of roomIds) { const room = mx.getRoom(roomId); if (!room) continue; const isEncrypted = !!room.currentState.getStateEvents(EventType.RoomEncryption, ''); // Text search: encrypted rooms only — server already covers plaintext rooms // Sender-only: all rooms — server has no sender-only search if (!senderOnlyMode && !isEncrypted) continue; if (isEncrypted) encryptedRoomsCount += 1; const events = room .getUnfilteredTimelineSet() .getTimelines() .flatMap((tl) => tl.getEvents()); // eslint-disable-next-line no-await-in-loop const cachedRows = cacheEnabled ? await queryRoom(roomId) : []; if (events.length === 0 && cachedRows.length === 0) continue; searchedRoomsCount += 1; const memoryItems: ResultItem[] = []; const rowsToPersist: SearchCacheRow[] = []; for (let i = 0; i < events.length; i += 1) { const event = events[i]; if (event.isDecryptionFailure()) continue; if (event.isRedacted()) continue; const evType = event.getType(); const isSticker = evType === 'm.sticker'; const isMessageLike = evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType); // Sender-only mode indexes/returns all message types; text mode needs text. if (!senderOnlyMode && !isMessageLike && !isSticker) continue; const sender = event.getSender() ?? ''; const ts = event.getTs(); const text = extractText(event); // Persist every indexable (text-bearing) event we scanned, regardless // of whether it matches the current term — future searches benefit. if (cacheEnabled && text && event.getId()) { rowsToPersist.push({ roomId, eventId: event.getId() as string, ts, sender, body: text.body, ...(text.formattedBody ? { formattedBody: text.formattedBody } : {}), ...(text.pollText ? { pollText: text.pollText } : {}), }); } if (senderSet && !senderSet.has(sender)) continue; if (!inRange(ts)) continue; if (!senderOnlyMode) { if (!text || !matchesTerm(text, termLower)) continue; } const content = event.getContent(); const syntheticEvent = { room_id: roomId, event_id: event.getId() ?? '', type: evType, sender, origin_server_ts: ts, content, unsigned: event.getUnsigned(), }; memoryItems.push({ rank: 0, // eslint-disable-next-line @typescript-eslint/no-explicit-any event: syntheticEvent as any, context: { events_before: [], events_after: [], profile_info: {} }, }); } // Match cached rows (skip ids already present in memory happens in merge). const cachedItems: ResultItem[] = []; cachedRows.forEach((row) => { if (senderSet && !senderSet.has(row.sender)) return; if (!inRange(row.ts)) return; if (!senderOnlyMode && !rowMatchesTerm(row, termLower)) return; cachedItems.push(rowToResultItem(row)); }); const items = mergeSearchResults(memoryItems, cachedItems); if (items.length > 0) { groups.push({ roomId, items }); } // Fire-and-forget persist of freshly scanned rows + coverage. // saveRoomIndex swallows all errors internally, so a floating promise // here can never reject. if (cacheEnabled && rowsToPersist.length > 0) { saveRoomIndex(roomId, rowsToPersist); } } return { groups, encryptedRoomsCount, searchedRoomsCount }; }, [mx, cacheEnabled], ); return search; };