diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index b35bbb17b..fd1f1b234 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -11,6 +11,8 @@ import { Line, toRem, Button, + Switch, + Chip, } from 'folds'; import { useAtom, useAtomValue } from 'jotai'; import { useVirtualizer } from '@tanstack/react-virtual'; @@ -41,7 +43,9 @@ import { ResultGroup, useMessageSearch, } from './useMessageSearch'; -import { useLocalMessageSearch } from './useLocalMessageSearch'; +import { LocalSearchResult, useLocalMessageSearch } from './useLocalMessageSearch'; +import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled'; +import { clearAll as clearSearchCache } from '../../utils/searchCache'; import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches'; import { SearchResultGroup } from './SearchResultGroup'; import { SearchInput } from './SearchInput'; @@ -240,6 +244,10 @@ export function MessageSearch({ // Bump this whenever more messages are loaded so localResult re-computes const [cacheVersion, setCacheVersion] = useState(0); const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []); + // Explicit wipe of the persistent on-disk index, then re-run the merge. + const handleClearSearchCache = useCallback(() => { + clearSearchCache().then(() => setCacheVersion((v) => v + 1)); + }, []); // The rooms actually in scope for this search (mirrors server-side logic) const localSearchRooms = useMemo( @@ -253,24 +261,43 @@ export function MessageSearch({ const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length; const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length; - // Run synchronous client-side search immediately. + // Run the client-side search whenever inputs change. // In text-search mode: covers encrypted rooms only (server handles plaintext). // In sender-only mode: covers all rooms (server has no sender-only search). - // cacheVersion in deps so it re-runs after "Load more" paginates new events. - const localResult = useMemo(() => { - if (!hasActiveSearch) return null; - return searchLocalMessages({ + // The scan is async because — when the persistent cache is enabled — it also + // reads cached rows from IndexedDB and merges them with the in-memory hits. + // cacheVersion in deps so it re-runs after "Load more" paginates new events; + // searchCacheEnabled so toggling the cache re-runs the merge. + const [searchCacheEnabled, setSearchCacheEnabled] = useAtom(searchCacheEnabledAtom); + const [localResult, setLocalResult] = useState(null); + useEffect(() => { + if (!hasActiveSearch) { + setLocalResult(null); + return undefined; + } + let cancelled = false; + searchLocalMessages({ term: msgSearchParams.term ?? '', roomIds: localSearchRooms, senders: msgSearchParams.senders, + fromTs: msgSearchParams.fromTs, + toTs: msgSearchParams.toTs, + }).then((result) => { + if (!cancelled) setLocalResult(result); }); - // eslint-disable-next-line react-hooks/exhaustive-deps + return () => { + cancelled = true; + }; }, [ searchLocalMessages, localSearchRooms, msgSearchParams.term, msgSearchParams.senders, + msgSearchParams.fromTs, + msgSearchParams.toTs, + hasActiveSearch, cacheVersion, + searchCacheEnabled, ]); const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ @@ -668,6 +695,37 @@ export function MessageSearch({ ? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.` : `No matches in your local cache. Load messages below to search further back.`} + + + + Persist search index on this device + + Stores decrypted text on this device + + + {searchCacheEnabled && ( + } + > + Clear cached index + + )} + {localGroups.length > 0 && ( diff --git a/src/app/features/message-search/useLocalMessageSearch.ts b/src/app/features/message-search/useLocalMessageSearch.ts index 80501bd08..e0a9799e1 100644 --- a/src/app/features/message-search/useLocalMessageSearch.ts +++ b/src/app/features/message-search/useLocalMessageSearch.ts @@ -1,12 +1,23 @@ -import { EventType } from 'matrix-js-sdk'; +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 = { @@ -17,19 +28,110 @@ export type LocalSearchResult = { 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. * - * Limitation: only messages present in the live timeline window are covered. - * Rooms that haven't been opened yet will return no results. + * 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( - ({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => { + async ({ + term, + roomIds, + senders, + fromTs, + toTs, + }: LocalSearchParams): Promise => { const trimmedTerm = term.trim(); const senderSet = senders && senders.length > 0 ? new Set(senders) : null; @@ -41,6 +143,9 @@ export const useLocalMessageSearch = () => { } 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; @@ -61,106 +166,99 @@ export const useLocalMessageSearch = () => { .getUnfilteredTimelineSet() .getTimelines() .flatMap((tl) => tl.getEvents()); - if (events.length === 0) continue; + + // 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 items: ResultItem[] = []; + const memoryItems: ResultItem[] = []; + const rowsToPersist: SearchCacheRow[] = []; 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(); + const evType = event.getType(); + const isSticker = evType === 'm.sticker'; + const isMessageLike = + evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType); - // 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'; + // Sender-only mode indexes/returns all message types; text mode needs text. + if (!senderOnlyMode && !isMessageLike && !isSticker) continue; - 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(); - } - } + const sender = event.getSender() ?? ''; + const ts = event.getTs(); + const text = extractText(event); - if ( - !body.toLowerCase().includes(termLower) && - !formattedBody.toLowerCase().includes(termLower) - ) - continue; + // 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 } : {}), + }); } - // Build a synthetic IEventWithRoomId using decrypted content so the - // existing SearchResultGroup renderer works without modification. + 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: event.getType(), - sender: event.getSender() ?? '', - origin_server_ts: event.getTs(), + type: evType, + sender, + origin_server_ts: ts, content, unsigned: event.getUnsigned(), }; - - items.push({ + memoryItems.push({ rank: 0, // eslint-disable-next-line @typescript-eslint/no-explicit-any event: syntheticEvent as any, - context: { - events_before: [], - events_after: [], - profile_info: {}, - }, + 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) { - items.sort((a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 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], + [mx, cacheEnabled], ); return search; diff --git a/src/app/state/searchCacheEnabled.ts b/src/app/state/searchCacheEnabled.ts new file mode 100644 index 000000000..455cdb534 --- /dev/null +++ b/src/app/state/searchCacheEnabled.ts @@ -0,0 +1,24 @@ +import { + atomWithLocalStorage, + getLocalStorageItem, + setLocalStorageItem, +} from './utils/atomWithLocalStorage'; + +const SEARCH_CACHE_ENABLED = 'searchCacheEnabled'; + +/** + * P4-8 — persistent encrypted-search cache opt-in flag (default `false`). + * + * Standalone, `localStorage`-backed boolean atom kept separate from + * `state/settings.ts` on purpose. When `true`, encrypted-room search persists a + * decrypted plaintext index to IndexedDB (`lotus-search-cache`) so coverage + * survives reloads. Because this writes decrypted plaintext at rest it must be + * explicitly opted into; the cache is clearable from the search UI and wiped on + * logout. Toggling this atom off stops all reads/writes but does NOT wipe + * existing data — that is the explicit "Clear cached index" button / logout. + */ +export const searchCacheEnabledAtom = atomWithLocalStorage( + SEARCH_CACHE_ENABLED, + (key) => getLocalStorageItem(key, false), + (key, value) => setLocalStorageItem(key, value), +); diff --git a/src/app/utils/searchCache.test.ts b/src/app/utils/searchCache.test.ts new file mode 100644 index 000000000..ee82afaab --- /dev/null +++ b/src/app/utils/searchCache.test.ts @@ -0,0 +1,130 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + computeCoverage, + mergeSearchResults, + putRows, + queryRoom, + getCoverage, + saveRoomIndex, + clearRoom, + clearAll, + deleteSearchCacheDatabase, + SearchCacheRow, +} from './searchCache'; + +// --- Pure helpers: mergeSearchResults --------------------------------------- + +type Item = { event: { event_id: string; origin_server_ts?: number } }; +const item = (eventId: string, ts?: number): Item => ({ + event: { event_id: eventId, origin_server_ts: ts }, +}); + +test('mergeSearchResults: sorts by origin_server_ts descending', () => { + const out = mergeSearchResults([item('$a', 10), item('$b', 30), item('$c', 20)], []); + assert.deepEqual( + out.map((i) => i.event.event_id), + ['$b', '$c', '$a'], + ); +}); + +test('mergeSearchResults: dedupes by event_id with in-memory winning', () => { + const memory = [{ event: { event_id: '$dup', origin_server_ts: 5 }, tag: 'memory' }]; + const cached = [ + { event: { event_id: '$dup', origin_server_ts: 5 }, tag: 'cached' }, + { event: { event_id: '$only', origin_server_ts: 9 }, tag: 'cached' }, + ]; + const out = mergeSearchResults(memory, cached); + assert.equal(out.length, 2); + const dup = out.find((i) => i.event.event_id === '$dup'); + assert.equal(dup?.tag, 'memory'); +}); + +test('mergeSearchResults: cached-only hits are included', () => { + const out = mergeSearchResults([], [item('$c1', 1), item('$c2', 2)]); + assert.equal(out.length, 2); +}); + +test('mergeSearchResults: missing ts sorts as 0 (last)', () => { + const out = mergeSearchResults([item('$noTs'), item('$withTs', 100)], []); + assert.deepEqual( + out.map((i) => i.event.event_id), + ['$withTs', '$noTs'], + ); +}); + +// --- Pure helpers: computeCoverage ------------------------------------------ + +const row = (ts: number): Pick => ({ ts }); + +test('computeCoverage: derives oldest/newest from rows', () => { + const cov = computeCoverage('!r', [row(30), row(10), row(20)], 3); + assert.deepEqual(cov, { roomId: '!r', oldestTs: 10, newestTs: 30, count: 3 }); +}); + +test('computeCoverage: widens the window against previous coverage', () => { + const prev = { roomId: '!r', oldestTs: 5, newestTs: 25, count: 2 }; + const cov = computeCoverage('!r', [row(15), row(40)], 4, prev); + assert.equal(cov.oldestTs, 5); // previous oldest kept + assert.equal(cov.newestTs, 40); // batch newest wins + assert.equal(cov.count, 4); // authoritative count from caller +}); + +test('computeCoverage: empty rows with no previous yields zeroed window', () => { + const cov = computeCoverage('!r', [], 0); + assert.deepEqual(cov, { roomId: '!r', oldestTs: 0, newestTs: 0, count: 0 }); +}); + +// --- IDB round-trip: skip when IndexedDB is unavailable (e.g. node --test) --- + +const hasIdb = typeof indexedDB !== 'undefined'; + +test('searchCache IDB round-trip', { skip: !hasIdb }, async () => { + await clearAll(); + const rows: SearchCacheRow[] = [ + { roomId: '!r1', eventId: '$1', ts: 100, sender: '@a', body: 'hello world' }, + { + roomId: '!r1', + eventId: '$2', + ts: 200, + sender: '@b', + body: 'goodbye', + formattedBody: 'x', + }, + { roomId: '!r2', eventId: '$3', ts: 300, sender: '@a', body: 'other room' }, + ]; + await putRows(rows); + + const r1 = await queryRoom('!r1'); + assert.equal(r1.length, 2); + assert.deepEqual(r1.map((x) => x.eventId).sort(), ['$1', '$2']); + + await saveRoomIndex( + '!r1', + rows.filter((x) => x.roomId === '!r1'), + ); + const cov = await getCoverage('!r1'); + assert.equal(cov?.count, 2); + assert.equal(cov?.oldestTs, 100); + assert.equal(cov?.newestTs, 200); + + await clearRoom('!r1'); + assert.equal((await queryRoom('!r1')).length, 0); + assert.equal((await queryRoom('!r2')).length, 1); + + await deleteSearchCacheDatabase(); +}); + +test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb }, async () => { + // In this environment IndexedDB is absent; every call must degrade to a + // cache-miss rather than throwing. + await assert.doesNotReject( + putRows([{ roomId: '!r', eventId: '$1', ts: 1, sender: '@a', body: 'x' }]), + ); + assert.deepEqual(await queryRoom('!r'), []); + assert.equal(await getCoverage('!r'), null); + await assert.doesNotReject(saveRoomIndex('!r', [])); + await assert.doesNotReject(clearRoom('!r')); + await assert.doesNotReject(clearAll()); + await assert.doesNotReject(deleteSearchCacheDatabase()); +}); diff --git a/src/app/utils/searchCache.ts b/src/app/utils/searchCache.ts new file mode 100644 index 000000000..6f06dbf48 --- /dev/null +++ b/src/app/utils/searchCache.ts @@ -0,0 +1,308 @@ +/** + * P4-8 — persistent encrypted-search cache (raw IndexedDB, no new deps). + * + * The homeserver cannot search E2EE message content, so encrypted-room search + * only ever covers what the client has paginated + decrypted this session. This + * module persists a local plaintext index so coverage survives reloads. + * + * PRIVACY: this stores decrypted plaintext at rest. It is opt-in (default OFF), + * clearable, and wiped on logout via `deleteSearchCacheDatabase()`. + * + * Resilience contract: every entry point swallows IndexedDB errors and behaves + * as a cache-miss. Nothing here ever throws to the UI. + */ + +const DB_NAME = 'lotus-search-cache'; +const DB_VERSION = 1; +const MESSAGES_STORE = 'messages'; +const COVERAGE_STORE = 'coverage'; +const ROOM_TS_INDEX = 'roomTs'; + +/** A single cached, decrypted message row. Keyed on `[roomId, eventId]`. */ +export type SearchCacheRow = { + roomId: string; + eventId: string; + ts: number; + sender: string; + body: string; + formattedBody?: string; + pollText?: string; +}; + +/** Per-room coverage stats for the "X / Y cached" UI counters. */ +export type SearchCacheCoverage = { + roomId: string; + oldestTs: number; + newestTs: number; + count: number; +}; + +// A key range that matches every `[roomId, *]` entry in a composite-key store +// or `[roomId, ts]` index: an empty array sorts after all other key types, so +// `[roomId]` .. `[roomId, []]` brackets the whole room partition. +const roomRange = (roomId: string): IDBKeyRange => IDBKeyRange.bound([roomId], [roomId, []]); + +let dbPromise: Promise | null = null; + +const openDb = (): Promise => { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve) => { + try { + if (typeof indexedDB === 'undefined') { + resolve(null); + return; + } + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(MESSAGES_STORE)) { + const store = db.createObjectStore(MESSAGES_STORE, { + keyPath: ['roomId', 'eventId'], + }); + store.createIndex(ROOM_TS_INDEX, ['roomId', 'ts']); + } + if (!db.objectStoreNames.contains(COVERAGE_STORE)) { + db.createObjectStore(COVERAGE_STORE, { keyPath: 'roomId' }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => { + dbPromise = null; // allow a later retry + resolve(null); + }; + req.onblocked = () => { + dbPromise = null; + resolve(null); + }; + } catch { + dbPromise = null; + resolve(null); + } + }); + return dbPromise; +}; + +/** Resolve once a write transaction commits (or reject/abort → caller swallows). */ +const awaitTx = (tx: IDBTransaction): Promise => + new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + +/** Upsert message rows. No-op on empty input or when IDB is unavailable. */ +export const putRows = async (rows: SearchCacheRow[]): Promise => { + if (rows.length === 0) return; + const db = await openDb(); + if (!db) return; + try { + const tx = db.transaction(MESSAGES_STORE, 'readwrite'); + const store = tx.objectStore(MESSAGES_STORE); + rows.forEach((row) => store.put(row)); + await awaitTx(tx); + } catch { + // Cache write failures must never surface to the UI. + } +}; + +/** All cached rows for a room, ordered oldest→newest by the `[roomId, ts]` index. */ +export const queryRoom = async (roomId: string): Promise => { + const db = await openDb(); + if (!db) return []; + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction(MESSAGES_STORE, 'readonly'); + const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX); + const req = index.getAll(roomRange(roomId)); + req.onsuccess = () => resolve((req.result as SearchCacheRow[]) ?? []); + req.onerror = () => reject(req.error); + }); + } catch { + return []; + } +}; + +/** Cursor variant: stream a room's rows through a matcher, collecting hits. */ +export const searchRoom = async ( + roomId: string, + matcher: (row: SearchCacheRow) => boolean, +): Promise => { + const db = await openDb(); + if (!db) return []; + try { + return await new Promise((resolve, reject) => { + const hits: SearchCacheRow[] = []; + const tx = db.transaction(MESSAGES_STORE, 'readonly'); + const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX); + const req = index.openCursor(roomRange(roomId)); + req.onsuccess = () => { + const cursor = req.result; + if (!cursor) { + resolve(hits); + return; + } + const row = cursor.value as SearchCacheRow; + if (matcher(row)) hits.push(row); + cursor.continue(); + }; + req.onerror = () => reject(req.error); + }); + } catch { + return []; + } +}; + +/** Number of cached rows for a room. */ +export const countRoom = async (roomId: string): Promise => { + const db = await openDb(); + if (!db) return 0; + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction(MESSAGES_STORE, 'readonly'); + const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX); + const req = index.count(roomRange(roomId)); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + } catch { + return 0; + } +}; + +export const getCoverage = async (roomId: string): Promise => { + const db = await openDb(); + if (!db) return null; + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction(COVERAGE_STORE, 'readonly'); + const req = tx.objectStore(COVERAGE_STORE).get(roomId); + req.onsuccess = () => resolve((req.result as SearchCacheCoverage) ?? null); + req.onerror = () => reject(req.error); + }); + } catch { + return null; + } +}; + +export const putCoverage = async (coverage: SearchCacheCoverage): Promise => { + const db = await openDb(); + if (!db) return; + try { + const tx = db.transaction(COVERAGE_STORE, 'readwrite'); + tx.objectStore(COVERAGE_STORE).put(coverage); + await awaitTx(tx); + } catch { + // ignore + } +}; + +/** + * Pure helper: fold a batch of rows into a coverage record, widening the + * `oldestTs`/`newestTs` window against any previous coverage. `count` is + * supplied by the caller (authoritative store count) so dedup across sessions + * is handled correctly. Exported for testing without IDB. + */ +export const computeCoverage = ( + roomId: string, + rows: ReadonlyArray>, + count: number, + previous?: SearchCacheCoverage | null, +): SearchCacheCoverage => { + let oldestTs = previous?.oldestTs ?? Number.POSITIVE_INFINITY; + let newestTs = previous?.newestTs ?? Number.NEGATIVE_INFINITY; + rows.forEach((row) => { + if (row.ts < oldestTs) oldestTs = row.ts; + if (row.ts > newestTs) newestTs = row.ts; + }); + if (!Number.isFinite(oldestTs)) oldestTs = 0; + if (!Number.isFinite(newestTs)) newestTs = 0; + return { roomId, oldestTs, newestTs, count }; +}; + +/** + * Convenience persist path used by the search hook: upsert a batch of rows for + * a room, then recompute + store the room's coverage from the authoritative + * store count. Fire-and-forget; never throws. + */ +export const saveRoomIndex = async (roomId: string, rows: SearchCacheRow[]): Promise => { + if (rows.length === 0) return; + await putRows(rows); + const [count, previous] = await Promise.all([countRoom(roomId), getCoverage(roomId)]); + await putCoverage(computeCoverage(roomId, rows, count, previous)); +}; + +/** + * Pure helper: merge in-memory result items with cache-derived result items, + * deduping by `event.event_id` (in-memory wins), sorted by `origin_server_ts` + * descending. Generic over the minimal shape it reads so it is fully testable + * without matrix-js-sdk types. Exported for testing. + */ +export const mergeSearchResults = < + T extends { event: { event_id: string; origin_server_ts?: number } }, +>( + memory: ReadonlyArray, + cached: ReadonlyArray, +): T[] => { + const byId = new Map(); + // Seed with cached, then let in-memory overwrite so in-memory always wins. + cached.forEach((item) => byId.set(item.event.event_id, item)); + memory.forEach((item) => byId.set(item.event.event_id, item)); + return Array.from(byId.values()).sort( + (a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0), + ); +}; + +export const clearRoom = async (roomId: string): Promise => { + const db = await openDb(); + if (!db) return; + try { + const tx = db.transaction([MESSAGES_STORE, COVERAGE_STORE], 'readwrite'); + tx.objectStore(MESSAGES_STORE).delete(roomRange(roomId)); + tx.objectStore(COVERAGE_STORE).delete(roomId); + await awaitTx(tx); + } catch { + // ignore + } +}; + +export const clearAll = async (): Promise => { + const db = await openDb(); + if (!db) return; + try { + const tx = db.transaction([MESSAGES_STORE, COVERAGE_STORE], 'readwrite'); + tx.objectStore(MESSAGES_STORE).clear(); + tx.objectStore(COVERAGE_STORE).clear(); + await awaitTx(tx); + } catch { + // ignore + } +}; + +/** + * Drop the entire on-disk database. Wired into the logout path by the + * coordinator (initMatrix) so no decrypted plaintext lingers after sign-out. + * Closes any open handle first so the delete is not blocked. Never throws. + */ +export const deleteSearchCacheDatabase = async (): Promise => { + try { + const existing = dbPromise ? await dbPromise : null; + if (existing) existing.close(); + } catch { + // ignore + } + dbPromise = null; + return new Promise((resolve) => { + try { + if (typeof indexedDB === 'undefined') { + resolve(); + return; + } + const req = indexedDB.deleteDatabase(DB_NAME); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + } catch { + resolve(); + } + }); +}; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 9b27c1642..fe5258bee 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -6,6 +6,7 @@ import { getFallbackSession, removeFallbackSession, Session } from '../app/state import { LotusOidcTokenRefresher } from './oidcTokenRefresher'; import { revokeOidcTokens } from './oidcLogout'; import { pushSessionToSW } from '../sw-session'; +import { deleteSearchCacheDatabase } from '../app/utils/searchCache'; // Thrown when the local IndexedDB has a higher schema version than this SDK expects. // This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted). @@ -87,6 +88,9 @@ export const logoutClient = async (mx: MatrixClient) => { // ignore if failed to logout } await mx.clearStores(); + // The opt-in local search index stores decrypted plaintext — always wipe it + // on logout. (clearLoginData below nukes all IDB databases, covering it too.) + await deleteSearchCacheDatabase(); // Remove only the session credential keys, preserving user preferences and // unsent drafts (N98). The factory-reset path is clearLoginData() below. removeFallbackSession();