feat(search): opt-in persistent index for encrypted-room search (P4-8)
Raw-IndexedDB cache (lotus-search-cache: messages keyed [roomId,eventId] + per-room coverage) merged into local search with in-memory-wins dedupe. OPT-IN (default off) via a standalone atom — stores decrypted text at rest, so it ships with a privacy note, a Clear button, and an unconditional wipe on logout (initMatrix). All IDB errors degrade to cache-miss. +8 tests (1 IDB skip in node). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
|||||||
Line,
|
Line,
|
||||||
toRem,
|
toRem,
|
||||||
Button,
|
Button,
|
||||||
|
Switch,
|
||||||
|
Chip,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
@@ -41,7 +43,9 @@ import {
|
|||||||
ResultGroup,
|
ResultGroup,
|
||||||
useMessageSearch,
|
useMessageSearch,
|
||||||
} from './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 { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
|
||||||
import { SearchResultGroup } from './SearchResultGroup';
|
import { SearchResultGroup } from './SearchResultGroup';
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
@@ -240,6 +244,10 @@ export function MessageSearch({
|
|||||||
// Bump this whenever more messages are loaded so localResult re-computes
|
// Bump this whenever more messages are loaded so localResult re-computes
|
||||||
const [cacheVersion, setCacheVersion] = useState(0);
|
const [cacheVersion, setCacheVersion] = useState(0);
|
||||||
const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []);
|
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)
|
// The rooms actually in scope for this search (mirrors server-side logic)
|
||||||
const localSearchRooms = useMemo(
|
const localSearchRooms = useMemo(
|
||||||
@@ -253,24 +261,43 @@ export function MessageSearch({
|
|||||||
const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
|
const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
|
||||||
const senderOnlyMode = !msgSearchParams.term && !!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 text-search mode: covers encrypted rooms only (server handles plaintext).
|
||||||
// In sender-only mode: covers all rooms (server has no sender-only search).
|
// 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.
|
// The scan is async because — when the persistent cache is enabled — it also
|
||||||
const localResult = useMemo(() => {
|
// reads cached rows from IndexedDB and merges them with the in-memory hits.
|
||||||
if (!hasActiveSearch) return null;
|
// cacheVersion in deps so it re-runs after "Load more" paginates new events;
|
||||||
return searchLocalMessages({
|
// searchCacheEnabled so toggling the cache re-runs the merge.
|
||||||
|
const [searchCacheEnabled, setSearchCacheEnabled] = useAtom(searchCacheEnabledAtom);
|
||||||
|
const [localResult, setLocalResult] = useState<LocalSearchResult | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasActiveSearch) {
|
||||||
|
setLocalResult(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
searchLocalMessages({
|
||||||
term: msgSearchParams.term ?? '',
|
term: msgSearchParams.term ?? '',
|
||||||
roomIds: localSearchRooms,
|
roomIds: localSearchRooms,
|
||||||
senders: msgSearchParams.senders,
|
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,
|
searchLocalMessages,
|
||||||
localSearchRooms,
|
localSearchRooms,
|
||||||
msgSearchParams.term,
|
msgSearchParams.term,
|
||||||
msgSearchParams.senders,
|
msgSearchParams.senders,
|
||||||
|
msgSearchParams.fromTs,
|
||||||
|
msgSearchParams.toTs,
|
||||||
|
hasActiveSearch,
|
||||||
cacheVersion,
|
cacheVersion,
|
||||||
|
searchCacheEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
|
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.`
|
? `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.`}
|
: `No matches in your local cache. Load messages below to search further back.`}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Box
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S200,
|
||||||
|
background: color.SurfaceVariant.Container,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={searchCacheEnabled}
|
||||||
|
onChange={setSearchCacheEnabled}
|
||||||
|
/>
|
||||||
|
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
|
||||||
|
<Text size="T300">Persist search index on this device</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Stores decrypted text on this device
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{searchCacheEnabled && (
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={handleClearSearchCache}
|
||||||
|
before={<Icon size="100" src={Icons.Delete} />}
|
||||||
|
>
|
||||||
|
<Text size="T200">Clear cached index</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
<Line size="300" variant="Surface" />
|
<Line size="300" variant="Surface" />
|
||||||
</Box>
|
</Box>
|
||||||
{localGroups.length > 0 && (
|
{localGroups.length > 0 && (
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { EventType } from 'matrix-js-sdk';
|
import { EventType, MatrixEvent } from 'matrix-js-sdk';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { ResultGroup, ResultItem } from './useMessageSearch';
|
import { ResultGroup, ResultItem } from './useMessageSearch';
|
||||||
|
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
|
||||||
|
import {
|
||||||
|
mergeSearchResults,
|
||||||
|
queryRoom,
|
||||||
|
saveRoomIndex,
|
||||||
|
SearchCacheRow,
|
||||||
|
} from '../../utils/searchCache';
|
||||||
|
|
||||||
export type LocalSearchParams = {
|
export type LocalSearchParams = {
|
||||||
term: string;
|
term: string;
|
||||||
roomIds: string[];
|
roomIds: string[];
|
||||||
senders?: string[];
|
senders?: string[];
|
||||||
|
/** Optional date-range filter (ms). Applied to both memory and cached rows. */
|
||||||
|
fromTs?: number;
|
||||||
|
toTs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LocalSearchResult = {
|
export type LocalSearchResult = {
|
||||||
@@ -17,19 +28,110 @@ export type LocalSearchResult = {
|
|||||||
searchedRoomsCount: number;
|
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<Record<string, unknown>>)
|
||||||
|
.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<string, unknown> = { 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.
|
* Client-side full-text search over locally cached events in encrypted rooms.
|
||||||
* The homeserver cannot search E2EE message content, so we scan whatever the
|
* The homeserver cannot search E2EE message content, so we scan whatever the
|
||||||
* client has already received and decrypted in memory.
|
* client has already received and decrypted in memory.
|
||||||
*
|
*
|
||||||
* Limitation: only messages present in the live timeline window are covered.
|
* When the persistent search cache is enabled (opt-in), the in-memory scan is
|
||||||
* Rooms that haven't been opened yet will return no results.
|
* 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 = () => {
|
export const useLocalMessageSearch = () => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const cacheEnabled = useAtomValue(searchCacheEnabledAtom);
|
||||||
|
|
||||||
const search = useCallback(
|
const search = useCallback(
|
||||||
({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => {
|
async ({
|
||||||
|
term,
|
||||||
|
roomIds,
|
||||||
|
senders,
|
||||||
|
fromTs,
|
||||||
|
toTs,
|
||||||
|
}: LocalSearchParams): Promise<LocalSearchResult> => {
|
||||||
const trimmedTerm = term.trim();
|
const trimmedTerm = term.trim();
|
||||||
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
|
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
|
||||||
|
|
||||||
@@ -41,6 +143,9 @@ export const useLocalMessageSearch = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const termLower = trimmedTerm.toLowerCase();
|
const termLower = trimmedTerm.toLowerCase();
|
||||||
|
const inRange = (ts: number): boolean =>
|
||||||
|
(fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs);
|
||||||
|
|
||||||
const groups: ResultGroup[] = [];
|
const groups: ResultGroup[] = [];
|
||||||
let encryptedRoomsCount = 0;
|
let encryptedRoomsCount = 0;
|
||||||
let searchedRoomsCount = 0;
|
let searchedRoomsCount = 0;
|
||||||
@@ -61,106 +166,99 @@ export const useLocalMessageSearch = () => {
|
|||||||
.getUnfilteredTimelineSet()
|
.getUnfilteredTimelineSet()
|
||||||
.getTimelines()
|
.getTimelines()
|
||||||
.flatMap((tl) => tl.getEvents());
|
.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;
|
searchedRoomsCount += 1;
|
||||||
|
|
||||||
const items: ResultItem[] = [];
|
const memoryItems: ResultItem[] = [];
|
||||||
|
const rowsToPersist: SearchCacheRow[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < events.length; i += 1) {
|
for (let i = 0; i < events.length; i += 1) {
|
||||||
const event = events[i];
|
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.isDecryptionFailure()) continue;
|
||||||
if (event.isRedacted()) 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 evType = event.getType();
|
||||||
const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
|
const isSticker = evType === 'm.sticker';
|
||||||
|
const isMessageLike =
|
||||||
|
evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType);
|
||||||
|
|
||||||
let body = '';
|
// Sender-only mode indexes/returns all message types; text mode needs text.
|
||||||
let formattedBody = '';
|
if (!senderOnlyMode && !isMessageLike && !isSticker) continue;
|
||||||
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<Record<string, unknown>>)
|
|
||||||
.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 (
|
const sender = event.getSender() ?? '';
|
||||||
!body.toLowerCase().includes(termLower) &&
|
const ts = event.getTs();
|
||||||
!formattedBody.toLowerCase().includes(termLower)
|
const text = extractText(event);
|
||||||
)
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a synthetic IEventWithRoomId using decrypted content so the
|
// Persist every indexable (text-bearing) event we scanned, regardless
|
||||||
// existing SearchResultGroup renderer works without modification.
|
// of whether it matches the current term — future searches benefit.
|
||||||
const syntheticEvent = {
|
if (cacheEnabled && text && event.getId()) {
|
||||||
room_id: roomId,
|
rowsToPersist.push({
|
||||||
event_id: event.getId() ?? '',
|
roomId,
|
||||||
type: event.getType(),
|
eventId: event.getId() as string,
|
||||||
sender: event.getSender() ?? '',
|
ts,
|
||||||
origin_server_ts: event.getTs(),
|
sender,
|
||||||
content,
|
body: text.body,
|
||||||
unsigned: event.getUnsigned(),
|
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
|
||||||
};
|
...(text.pollText ? { pollText: text.pollText } : {}),
|
||||||
|
|
||||||
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 (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) {
|
if (items.length > 0) {
|
||||||
items.sort((a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0));
|
|
||||||
groups.push({ roomId, items });
|
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 };
|
return { groups, encryptedRoomsCount, searchedRoomsCount };
|
||||||
},
|
},
|
||||||
[mx],
|
[mx, cacheEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
return search;
|
return search;
|
||||||
|
|||||||
@@ -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<boolean>(
|
||||||
|
SEARCH_CACHE_ENABLED,
|
||||||
|
(key) => getLocalStorageItem<boolean>(key, false),
|
||||||
|
(key, value) => setLocalStorageItem(key, value),
|
||||||
|
);
|
||||||
@@ -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>([], [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<SearchCacheRow, 'ts'> => ({ 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: '<b>x</b>',
|
||||||
|
},
|
||||||
|
{ 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());
|
||||||
|
});
|
||||||
@@ -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<IDBDatabase | null> | null = null;
|
||||||
|
|
||||||
|
const openDb = (): Promise<IDBDatabase | null> => {
|
||||||
|
if (dbPromise) return dbPromise;
|
||||||
|
dbPromise = new Promise<IDBDatabase | null>((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<void> =>
|
||||||
|
new Promise<void>((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<void> => {
|
||||||
|
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<SearchCacheRow[]> => {
|
||||||
|
const db = await openDb();
|
||||||
|
if (!db) return [];
|
||||||
|
try {
|
||||||
|
return await new Promise<SearchCacheRow[]>((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<SearchCacheRow[]> => {
|
||||||
|
const db = await openDb();
|
||||||
|
if (!db) return [];
|
||||||
|
try {
|
||||||
|
return await new Promise<SearchCacheRow[]>((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<number> => {
|
||||||
|
const db = await openDb();
|
||||||
|
if (!db) return 0;
|
||||||
|
try {
|
||||||
|
return await new Promise<number>((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<SearchCacheCoverage | null> => {
|
||||||
|
const db = await openDb();
|
||||||
|
if (!db) return null;
|
||||||
|
try {
|
||||||
|
return await new Promise<SearchCacheCoverage | null>((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<void> => {
|
||||||
|
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<Pick<SearchCacheRow, 'ts'>>,
|
||||||
|
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<void> => {
|
||||||
|
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<T>,
|
||||||
|
cached: ReadonlyArray<T>,
|
||||||
|
): T[] => {
|
||||||
|
const byId = new Map<string, T>();
|
||||||
|
// 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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
try {
|
||||||
|
const existing = dbPromise ? await dbPromise : null;
|
||||||
|
if (existing) existing.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
dbPromise = null;
|
||||||
|
return new Promise<void>((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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { getFallbackSession, removeFallbackSession, Session } from '../app/state
|
|||||||
import { LotusOidcTokenRefresher } from './oidcTokenRefresher';
|
import { LotusOidcTokenRefresher } from './oidcTokenRefresher';
|
||||||
import { revokeOidcTokens } from './oidcLogout';
|
import { revokeOidcTokens } from './oidcLogout';
|
||||||
import { pushSessionToSW } from '../sw-session';
|
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.
|
// 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).
|
// 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
|
// ignore if failed to logout
|
||||||
}
|
}
|
||||||
await mx.clearStores();
|
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
|
// Remove only the session credential keys, preserving user preferences and
|
||||||
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
||||||
removeFallbackSession();
|
removeFallbackSession();
|
||||||
|
|||||||
Reference in New Issue
Block a user