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 3485a4c118
commit dd2123da4b
3 changed files with 204 additions and 11 deletions
@@ -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;
};