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,
|
||||
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<LocalSearchResult | null>(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.`}
|
||||
</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" />
|
||||
</Box>
|
||||
{localGroups.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user