feat(search): in-memory encrypted message search#871
Open
Just-Insane wants to merge 13 commits into
Open
Conversation
Split the search room list into encrypted / plaintext buckets. Server search covers plaintext rooms unchanged. Encrypted rooms are searched synchronously against their in-memory live timeline so decrypted content is always available. Key details: - partitionRoomsByEncryption() splits the room filter; for global search (rooms=undefined) all joined encrypted rooms are scanned - In-memory results are merged into the first page only (no pagination token for local results) - For 'recent' order, groups are interleaved by timestamp; for 'rank' order, server results come first - An info banner is shown when encrypted rooms were searched so users know coverage is limited to cached messages - Controlled by features.encryptedSearch in config.json (default true) - 18 unit tests covering matching, filtering, partitioning, merging
…arch - Adds 'Encrypted Room Search' toggle to Settings > Experimental - Setting defaults to true; operator can hard-disable via config.json features.encryptedSearch = false - Lock icon shown next to encrypted rooms in the search room picker when the feature is active, indicating local-cache coverage - useMessageSearch now checks both the operator flag and user setting
Removes the guard that hid the search icon in encrypted room headers. Encrypted rooms now navigate to message search pre-filtered to that room, showing in-memory results when the feature is enabled. Tooltip reads "Search (local cache)" for encrypted rooms.
- Fix DM rooms missing from search: replace useRooms (excludes DMs) with useSelectedRooms+isRoom selector so DM room IDs pass URL param validation; room picker always uses the full allRooms list - Add SearchHasType (image/file/audio/video/link) to searchEncryptedRooms.ts with mEventMatchesHasTypes filtering in in-memory timeline search - Add hasTypes to MessageSearchParams; pass contains_url:true for has:link on server requests; post-filter server results by msgtype/URL pattern - Add HasFilterChips and SelectSenderButton components to SearchFilters; new has: row with Image/File/Audio/Video/Link toggles plus From: sender chips with Matrix ID input popup - Wire has URL param through MessageSearch: parse, encode, pass to SearchFilters and msgSearchParams; add handleHasTypesChange/handleSendersChange
- Fix mDirects undefined crash in MessageSearch (re-add atom import) - Allow has: filters to trigger search without a text term - searchEncryptedRooms: skip body text check when lowerTerm is empty - useMessageSearch: only early-return when both term and hasTypes are absent - When no term: skip server search (server requires search_term), in-memory only - MessageSearch: enable query when hasTypes is set even without a term - Add DM search page at /direct/search/ - DIRECT_SEARCH_PATH constant in paths.ts - getDirectSearchPath() helper in pathUtils.ts - useDirectSearchSelected() hook in useDirectSelected.ts - DirectSearch component (scoped to DM rooms) - Route registered in Router.tsx - 'Message Search' nav item added to Direct Messages panel - RoomViewHeader: clicking search in a DM navigates to DM search
…r for from: filter
…display names; fix DM create button alignment
…o unencrypted rooms
Typing > in the room search modal switches to message search mode. A 'Search messages: <query>' item appears; pressing Enter or clicking it navigates to the context-appropriate message search page with the term pre-filled: - /direct/ context → DM message search - /:spaceIdOrAlias/ context → space message search - /home/ or other → home message search The hint text is updated to include > for messages. The prefix is disabled when the modal is used for room-picking (forwarding).
11 tasks
Contributor
There was a problem hiding this comment.
Pull request overview
Adds an in-memory message search backend for encrypted rooms (using locally-decrypted timeline events), expands message-search filtering (room scope, has: media types, and sender selection with avatars), and wires message search into navigation (Direct-search route + > quick switcher).
Changes:
- Add encrypted-room in-memory search (plus merging with server search for unencrypted rooms) and expose it behind an operator feature flag + experimental user setting.
- Add message-search UX enhancements:
has:chips, sender picker, and>prefix in the room switcher to jump to message search. - Add Direct Messages message-search route/page and navigation affordances.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/app/state/settings.ts | Adds encryptedSearch setting with default value. |
| src/app/pages/Router.tsx | Registers Direct message search route. |
| src/app/pages/pathUtils.ts | Adds getDirectSearchPath() helper. |
| src/app/pages/paths.ts | Adds DIRECT_SEARCH_PATH and has search param support. |
| src/app/pages/client/direct/Search.tsx | New DM-scoped Message Search page wrapper. |
| src/app/pages/client/direct/index.ts | Exports DirectSearch. |
| src/app/pages/client/direct/Direct.tsx | Adds sidebar nav entry linking to DM message search. |
| src/app/hooks/useClientConfig.ts | Adds features.encryptedSearch operator flag. |
| src/app/hooks/router/useDirectSelected.ts | Adds route selection hook for Direct search. |
| src/app/features/settings/experimental/Experimental.tsx | Surfaces encrypted-search toggle in Experimental settings. |
| src/app/features/settings/experimental/EncryptedSearch.tsx | Implements encrypted-search setting tile gated by operator flag. |
| src/app/features/search/Search.tsx | Adds > quick-switcher mode to navigate to message search. |
| src/app/features/room/RoomViewHeader.tsx | Enables search button in encrypted rooms when encrypted-search is enabled; routes Direct rooms to Direct search path. |
| src/app/features/message-search/useMessageSearch.ts | Adds hasTypes, in-memory encrypted search, and server+memory merge logic. |
| src/app/features/message-search/SearchFilters.tsx | Adds has: chips and sender picker with avatars; marks encrypted rooms in room picker. |
| src/app/features/message-search/searchEncryptedRooms.ts | Implements timeline scanning + encryption partitioning + merge helpers. |
| src/app/features/message-search/searchEncryptedRooms.test.ts | Adds unit tests for new encrypted search helper module. |
| src/app/features/message-search/MessageSearch.tsx | Adds has parsing, query enabling without term, and local-cache search messaging; passes new filter props. |
| src/app/components/user-avatar/UserAvatar.tsx | Reuses shared avatar SVG processing hook for user avatars. |
| src/app/components/room-avatar/AvatarImage.tsx | Introduces module-level SVG blob URL cache + shared processing hook. |
| config.json | Enables operator feature flag for encrypted search. |
| .changeset/encrypted-search-memory.md | Changeset entry for the new feature. |
Comments suppressed due to low confidence (1)
src/app/features/message-search/MessageSearch.tsx:299
- The empty-state condition only checks
!msgSearchParams.term. If a search is running withhas:filters but no term, this will show the "Search Messages" hero while results are loading. Update the empty/loading conditions to account forhasTypes(and other non-term filters) so the UI reflects that a search is in progress.
{!msgSearchParams.term && status === 'pending' && (
<PageHeroEmpty>
<PageHeroSection>
<PageHero
icon={<Icon size="600" src={Icons.Message} />}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+42
to
+46
| export function toSearchEvent(mEvent: MatrixEvent, roomId: string): IEventWithRoomId { | ||
| return { | ||
| event_id: mEvent.getId() ?? '', | ||
| room_id: roomId, | ||
| sender: mEvent.getSender() ?? '', |
Comment on lines
+9
to
+24
| // Module-level cache: maps a Matrix media URL → processed blob URL so that | ||
| // SVG processing only runs once per unique image, even as virtual-list items | ||
| // unmount and remount. MXC URLs are content-addressed and never change, so | ||
| // the mapping is stable for the lifetime of the page. | ||
| const svgBlobCache = new Map<string, string>(); | ||
|
|
||
| export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProps) { | ||
| const [uniformIconsSetting] = useSetting(settingsAtom, 'uniformIcons'); | ||
| const [image, setImage] = useState<HTMLImageElement | undefined>(undefined); | ||
| const [processedSrc, setProcessedSrc] = useState<string>(src); | ||
| /** Number of SVG blob URLs currently held in the module-level cache. */ | ||
| export function getSvgCacheSize(): number { | ||
| return svgBlobCache.size; | ||
| } | ||
|
|
||
| const useUniformIcons = uniformIconsSetting && uniformIcons === true; | ||
| const normalizedBg = useUniformIcons && image ? bgColorImg(image) : undefined; | ||
| /** Revoke all cached SVG blob URLs and clear the cache to free memory. */ | ||
| export function clearSvgBlobCache(): void { | ||
| svgBlobCache.forEach((url) => URL.revokeObjectURL(url)); | ||
| svgBlobCache.clear(); | ||
| } |
Comment on lines
+138
to
+142
| // In-memory search only runs on the first page — encrypted rooms have no pagination. | ||
| const inMemoryGroups = | ||
| encryptedSearchEnabled && isFirstPage && encryptedRoomIds.length > 0 | ||
| ? searchEncryptedRoomsInMemory(mx, term ?? '', encryptedRoomIds, senders, hasTypes) | ||
| : []; |
Comment on lines
+266
to
+267
| roomList={rooms} | ||
| defaultRooms={rooms} |
Comment on lines
+219
to
+223
| if (pathname.startsWith('/direct/')) { | ||
| basePath = getDirectSearchPath(); | ||
| } else if (!pathname.startsWith('/home/')) { | ||
| const spaceIdOrAlias = decodeURIComponent(pathname.split('/').find(Boolean) ?? ''); | ||
| basePath = spaceIdOrAlias ? getSpaceSearchPath(spaceIdOrAlias) : getHomeSearchPath(); |
|
|
||
| features?: { | ||
| polls?: boolean; | ||
| /** Enable in-memory search for encrypted rooms (default: true). */ |
Comment on lines
+13
to
+25
| const HAS_TYPE_TO_MSGTYPE: Partial<Record<SearchHasType, string>> = { | ||
| image: 'm.image', | ||
| file: 'm.file', | ||
| audio: 'm.audio', | ||
| video: 'm.video', | ||
| }; | ||
|
|
||
| function mEventMatchesHasTypes(mEvent: MatrixEvent, hasTypes: SearchHasType[]): boolean { | ||
| const content = mEvent.getContent() as { msgtype?: string; body?: string }; | ||
| for (const type of hasTypes) { | ||
| const msgtype = HAS_TYPE_TO_MSGTYPE[type]; | ||
| if (msgtype && content.msgtype === msgtype) return true; | ||
| if (type === 'link' && /https?:\/\//i.test(content.body ?? '')) return true; |
Comment on lines
122
to
125
| const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ | ||
| enabled: !!msgSearchParams.term, | ||
| enabled: | ||
| !!msgSearchParams.term || (!!msgSearchParams.hasTypes && msgSearchParams.hasTypes.length > 0), | ||
| queryKey: [ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Add a search backend that scans in-memory timelines for encrypted rooms, with room/DM scope filtering, member picker with avatars and display names, and a
>quick-switcher prefix to trigger message search from the room switcher.Fixes #
Type of change
Checklist:
AI disclosure:
The search hook iterates the room's loaded in-memory timeline, filters to decrypted
m.room.messageevents, and runs a case-insensitiveincludescheck on the plaintext body. Results are paged client-side by slicing the filtered array. The member picker fetchesm.room.memberstate events for the selected room and maps them to{ userId, displayName, avatarUrl }entries. Typing>in the room quick-switcher input switches the autocomplete mode to message search and scopes results to the currently active room.