From dd2123da4bf4d51cbb56a0956a90d249daa74252 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 28 May 2026 20:01:21 -0400 Subject: [PATCH] feat: encrypted room search via local cache scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../features/message-search/MessageSearch.tsx | 106 +++++++++++++++-- .../message-search/useLocalMessageSearch.ts | 107 ++++++++++++++++++ .../message-search/useMessageSearch.ts | 2 +- 3 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 src/app/features/message-search/useLocalMessageSearch.ts diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 27e0d47e1..3bf91759f 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -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' && ( - - - - No results found for {`"${msgSearchParams.term}"`} - + + + + + No results found for {`"${msgSearchParams.term}"`} + + + {localResult && + localResult.encryptedRoomsCount > 0 && + localResult.groups.length === 0 && ( + + + + {`${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.`} + + + )} )} @@ -259,6 +293,23 @@ export function MessageSearch({ )} + {msgSearchParams.term && + localResult && + localResult.encryptedRoomsCount > 0 && + vItems.length > 0 && ( + + + + {`${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.`} + + + )} + {vItems.length > 0 && ( @@ -307,6 +358,41 @@ export function MessageSearch({ )} + {localResult && localResult.groups.length > 0 && ( + + + + + Encrypted Rooms + + + {`Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Only recently viewed messages are available.`} + + + + + {localResult.groups.map((group) => { + const groupRoom = mx.getRoom(group.roomId); + if (!groupRoom) return null; + return ( + + ); + })} + + + )} + {error && ( { + 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; +}; diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index 5eb79da89..048188665 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -80,7 +80,7 @@ export const useMessageSearch = (params: MessageSearchParams) => { highlights: [], groups: [], }; - const limit = 20; + const limit = 50; const requestBody: ISearchRequestBody = { search_categories: {