feat: encrypted room search via local cache scan

No Matrix web client supports E2EE message search server-side — the
homeserver only sees ciphertext. This is the same approach FluffyChat
takes: scan locally decrypted events already in the live timeline.

Changes:
- useLocalMessageSearch: searches getLiveTimeline().getEvents() in
  encrypted rooms using decrypted content (getContent(), not event.content)
- MessageSearch: runs client-side search in parallel with server search,
  shows results in a dedicated 'Encrypted Rooms' section with clear notice
  about scope (only cached/recently viewed messages)
- Encryption notice shown when encrypted rooms are in scope — explains
  why results may be missing and what to do
- Server result limit raised from 20 → 50

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 20:01:21 -04:00
parent 5957b1cf55
commit b13297ce3b
3 changed files with 204 additions and 11 deletions
@@ -19,6 +19,7 @@ import { useRooms } from '../../state/hooks/roomList';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList';
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
import { useLocalMessageSearch } from './useLocalMessageSearch';
import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput';
import { SearchFilters } from './SearchFilters';
@@ -95,6 +96,19 @@ export function MessageSearch({
}, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);
const searchMessages = useMessageSearch(msgSearchParams);
const searchLocalMessages = useLocalMessageSearch();
// The rooms actually in scope for this search (mirrors server-side logic)
const localSearchRooms = useMemo(
() => msgSearchParams.rooms ?? (searchPathSearchParams.global === 'true' ? allRooms : rooms),
[msgSearchParams.rooms, searchPathSearchParams.global, allRooms, rooms],
);
// Run synchronous client-side search over encrypted rooms immediately
const localResult = useMemo(() => {
if (!msgSearchParams.term) return null;
return searchLocalMessages(localSearchRooms, msgSearchParams.term);
}, [searchLocalMessages, localSearchRooms, msgSearchParams.term]);
const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
enabled: !!msgSearchParams.term,
@@ -237,16 +251,36 @@ export function MessageSearch({
)}
{msgSearchParams.term && groups.length === 0 && status === 'success' && (
<Box
className={ContainerColor({ variant: 'Warning' })}
style={{ padding: config.space.S300, borderRadius: config.radii.R400 }}
alignItems="Center"
gap="200"
>
<Icon size="200" src={Icons.Info} />
<Text>
No results found for <b>{`"${msgSearchParams.term}"`}</b>
</Text>
<Box direction="Column" gap="200">
<Box
className={ContainerColor({ variant: 'Warning' })}
style={{ padding: config.space.S300, borderRadius: config.radii.R400 }}
alignItems="Center"
gap="200"
>
<Icon size="200" src={Icons.Info} />
<Text>
No results found for <b>{`"${msgSearchParams.term}"`}</b>
</Text>
</Box>
{localResult &&
localResult.encryptedRoomsCount > 0 &&
localResult.groups.length === 0 && (
<Box
className={ContainerColor({ variant: 'Surface' })}
style={{ padding: config.space.S300, borderRadius: config.radii.R400 }}
alignItems="Center"
gap="200"
>
<Icon size="200" src={Icons.Lock} />
<Text size="T300" priority="300">
{`${localResult.encryptedRoomsCount} encrypted room${localResult.encryptedRoomsCount !== 1 ? 's' : ''} in scope — the server cannot search E2EE messages. `}
{localResult.searchedRoomsCount > 0
? `No matches found in your locally cached messages from ${localResult.searchedRoomsCount} room${localResult.searchedRoomsCount !== 1 ? 's' : ''}.`
: `Open those rooms to cache messages locally, then search again.`}
</Text>
</Box>
)}
</Box>
)}
@@ -259,6 +293,23 @@ export function MessageSearch({
</Box>
)}
{msgSearchParams.term &&
localResult &&
localResult.encryptedRoomsCount > 0 &&
vItems.length > 0 && (
<Box
className={ContainerColor({ variant: 'Surface' })}
style={{ padding: config.space.S300, borderRadius: config.radii.R400 }}
alignItems="Center"
gap="200"
>
<Icon size="200" src={Icons.Lock} />
<Text size="T300" priority="300">
{`${localResult.encryptedRoomsCount} encrypted room${localResult.encryptedRoomsCount !== 1 ? 's' : ''} in scope — server results are from unencrypted rooms only. Encrypted room results appear below from your local cache.`}
</Text>
</Box>
)}
{vItems.length > 0 && (
<Box direction="Column" gap="300">
<Box direction="Column" gap="200">
@@ -307,6 +358,41 @@ export function MessageSearch({
</Box>
)}
{localResult && localResult.groups.length > 0 && (
<Box direction="Column" gap="300">
<Box direction="Column" gap="200">
<Box alignItems="Center" gap="200">
<Icon size="200" src={Icons.Lock} />
<Text size="H5">Encrypted Rooms</Text>
</Box>
<Text size="T300" priority="300">
{`Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Only recently viewed messages are available.`}
</Text>
<Line size="300" variant="Surface" />
</Box>
<Box direction="Column" gap="300">
{localResult.groups.map((group) => {
const groupRoom = mx.getRoom(group.roomId);
if (!groupRoom) return null;
return (
<SearchResultGroup
key={group.roomId}
room={groupRoom}
highlights={[msgSearchParams.term ?? '']}
items={group.items}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
onOpen={navigateRoom}
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
})}
</Box>
</Box>
)}
{error && (
<Box
className={ContainerColor({ variant: 'Critical' })}