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:
@@ -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' })}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
import { useCallback } from 'react';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { ResultGroup, ResultItem } from './useMessageSearch';
|
||||
|
||||
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(
|
||||
(roomIds: string[], term: string): LocalSearchResult => {
|
||||
const trimmedTerm = term.trim();
|
||||
if (!trimmedTerm) {
|
||||
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, '');
|
||||
if (!isEncrypted) continue;
|
||||
|
||||
encryptedRoomsCount += 1;
|
||||
|
||||
const events = room.getLiveTimeline().getEvents();
|
||||
if (events.length === 0) continue;
|
||||
|
||||
searchedRoomsCount += 1;
|
||||
|
||||
const items: ResultItem[] = [];
|
||||
|
||||
for (let i = 0; i < events.length; i += 1) {
|
||||
const event = events[i];
|
||||
|
||||
if (event.getType() !== EventType.RoomMessage) continue;
|
||||
if (event.isDecryptionFailure()) continue;
|
||||
if (event.isRedacted()) continue;
|
||||
|
||||
// getContent() returns decrypted plaintext regardless of encryption
|
||||
const content = event.getContent();
|
||||
const body = (content.body as string | undefined) ?? '';
|
||||
const formattedBody = (content.formatted_body as string | undefined) ?? '';
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -80,7 +80,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
highlights: [],
|
||||
groups: [],
|
||||
};
|
||||
const limit = 20;
|
||||
const limit = 50;
|
||||
|
||||
const requestBody: ISearchRequestBody = {
|
||||
search_categories: {
|
||||
|
||||
Reference in New Issue
Block a user