import { EventType } from 'matrix-js-sdk'; import { useCallback } from 'react'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { ResultGroup, ResultItem } from './useMessageSearch'; export type LocalSearchParams = { term: string; roomIds: string[]; senders?: string[]; }; 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; }; /** * 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. * * Limitation: only messages present in the live timeline window are covered. * Rooms that haven't been opened yet will return no results. */ export const useLocalMessageSearch = () => { const mx = useMatrixClient(); const search = useCallback( ({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => { 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 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()); if (events.length === 0) continue; searchedRoomsCount += 1; const items: ResultItem[] = []; for (let i = 0; i < events.length; i += 1) { const event = events[i]; // In sender-only mode: include all message types; skip non-message events if (event.getType() !== EventType.RoomMessage) { if (senderOnlyMode) continue; const evType = event.getType(); const isSticker = evType === 'm.sticker'; const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start'; if (!isSticker && !isPoll) continue; } if (event.isDecryptionFailure()) continue; if (event.isRedacted()) continue; if (senderSet && !senderSet.has(event.getSender() ?? '')) continue; // getContent() returns decrypted plaintext regardless of encryption const content = event.getContent(); // Sender-only mode: no text filter needed if (!senderOnlyMode) { const evType = event.getType(); const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start'; let body = ''; let formattedBody = ''; if (!isPoll) { body = (content.body as string | undefined) ?? ''; formattedBody = (content.formatted_body as string | undefined) ?? ''; } else { // Poll — index question text and all answer options const poll = (content['m.poll'] ?? // eslint-disable-next-line @typescript-eslint/no-explicit-any content['org.matrix.msc3381.poll.start']) as any; if (poll) { const qBody = // eslint-disable-next-line @typescript-eslint/no-explicit-any (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) => // eslint-disable-next-line @typescript-eslint/no-explicit-any ((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(' '); body = `${qBody} ${answerBodies}`.trim(); } } if ( !body.toLowerCase().includes(termLower) && !formattedBody.toLowerCase().includes(termLower) ) continue; } // Build a synthetic IEventWithRoomId using decrypted content so the // existing SearchResultGroup renderer works without modification. const syntheticEvent = { room_id: roomId, event_id: event.getId() ?? '', type: event.getType(), sender: event.getSender() ?? '', origin_server_ts: event.getTs(), content, unsigned: event.getUnsigned(), }; items.push({ rank: 0, // eslint-disable-next-line @typescript-eslint/no-explicit-any event: syntheticEvent as any, context: { events_before: [], events_after: [], profile_info: {}, }, }); } if (items.length > 0) { items.sort((a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0)); groups.push({ roomId, items }); } } return { groups, encryptedRoomsCount, searchedRoomsCount }; }, [mx], ); return search; };