From 00d8b003c5d6167c8841e62b71e785c9293f49ee Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 09:11:25 -0400 Subject: [PATCH 01/14] feat(search): in-memory search for encrypted rooms 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 --- config.json | 4 + .../features/message-search/MessageSearch.tsx | 16 ++ .../searchEncryptedRooms.test.ts | 210 ++++++++++++++++++ .../message-search/searchEncryptedRooms.ts | 157 +++++++++++++ .../message-search/useMessageSearch.ts | 48 +++- src/app/hooks/useClientConfig.ts | 6 + 6 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 src/app/features/message-search/searchEncryptedRooms.test.ts create mode 100644 src/app/features/message-search/searchEncryptedRooms.ts diff --git a/config.json b/config.json index 2809e4f68..c18f788f4 100644 --- a/config.json +++ b/config.json @@ -44,5 +44,9 @@ "hashRouter": { "enabled": false, "basename": "/" + }, + + "features": { + "encryptedSearch": true } } diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 893ff00eb..999820c76 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -117,6 +117,8 @@ export function MessageSearch({ const mixed = data?.pages.flatMap((result) => result.highlights); return Array.from(new Set(mixed)); }, [data]); + // Only the first page carries in-memory results (no pagination for encrypted rooms) + const inMemoryRoomCount = data?.pages[0]?.inMemoryRoomCount ?? 0; const virtualizer = useVirtualizer({ count: groups.length, @@ -226,6 +228,20 @@ export function MessageSearch({ /> + {inMemoryRoomCount > 0 && status !== 'pending' && ( + + + + {`${inMemoryRoomCount} encrypted ${inMemoryRoomCount === 1 ? 'room' : 'rooms'} searched from local cache only.`} + + + )} + {!msgSearchParams.term && status === 'pending' && ( diff --git a/src/app/features/message-search/searchEncryptedRooms.test.ts b/src/app/features/message-search/searchEncryptedRooms.test.ts new file mode 100644 index 000000000..c1b2a68dc --- /dev/null +++ b/src/app/features/message-search/searchEncryptedRooms.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect } from 'vitest'; +import { EventType } from '$types/matrix-sdk'; +import type { MatrixEvent } from '$types/matrix-sdk'; +import { + searchRoomTimeline, + searchEncryptedRoomsInMemory, + partitionRoomsByEncryption, + mergeSearchGroups, +} from './searchEncryptedRooms'; +import type { ResultGroup } from './useMessageSearch'; + +// Minimal MatrixEvent stub — only the methods used by searchRoomTimeline +function makeEvent(overrides: { + type?: string; + body?: string; + sender?: string; + ts?: number; + id?: string; + redacted?: boolean; +}): MatrixEvent { + return { + getType: () => overrides.type ?? EventType.RoomMessage, + getContent: () => ({ body: overrides.body ?? '', msgtype: 'm.text' }), + getSender: () => overrides.sender ?? '@alice:example.org', + getTs: () => overrides.ts ?? 1000, + getId: () => overrides.id ?? '$event1', + isRedacted: () => overrides.redacted ?? false, + getUnsigned: () => ({}), + event: {}, + } as unknown as MatrixEvent; +} + +function makeRoom(roomId: string, events: MatrixEvent[]) { + return { + roomId, + getLiveTimeline: () => ({ getEvents: () => events }), + }; +} + +describe('searchRoomTimeline', () => { + it('returns undefined when no events match', () => { + const room = makeRoom('!room:example.org', [makeEvent({ body: 'hello world' })]); + expect(searchRoomTimeline(room, 'goodbye')).toBeUndefined(); + }); + + it('matches a simple substring (case-insensitive)', () => { + const room = makeRoom('!room:example.org', [makeEvent({ body: 'Hello World', id: '$e1' })]); + const group = searchRoomTimeline(room, 'hello world'); + expect(group).toBeDefined(); + expect(group!.items).toHaveLength(1); + expect(group!.items[0].event.event_id).toBe('$e1'); + }); + + it('is case-insensitive', () => { + const room = makeRoom('!room:example.org', [makeEvent({ body: 'MATRIX ENCRYPTED' })]); + expect(searchRoomTimeline(room, 'matrix encrypted')).toBeDefined(); + }); + + it('skips non-message events', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ type: 'm.room.encrypted', body: 'search me' }), + makeEvent({ type: 'm.room.member', body: 'search me' }), + ]); + expect(searchRoomTimeline(room, 'search me')).toBeUndefined(); + }); + + it('skips redacted events', () => { + const room = makeRoom('!room:example.org', [makeEvent({ body: 'match', redacted: true })]); + expect(searchRoomTimeline(room, 'match')).toBeUndefined(); + }); + + it('filters by sender when senders list is provided', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'match', sender: '@alice:example.org', id: '$e1' }), + makeEvent({ body: 'match', sender: '@bob:example.org', id: '$e2' }), + ]); + const group = searchRoomTimeline(room, 'match', ['@alice:example.org']); + expect(group!.items).toHaveLength(1); + expect(group!.items[0].event.sender).toBe('@alice:example.org'); + }); + + it('sorts results most-recent-first', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'match', ts: 1000, id: '$old' }), + makeEvent({ body: 'match', ts: 3000, id: '$new' }), + makeEvent({ body: 'match', ts: 2000, id: '$mid' }), + ]); + const group = searchRoomTimeline(room, 'match'); + expect(group!.items.map((i) => i.event.event_id)).toEqual(['$new', '$mid', '$old']); + }); + + it('uses decrypted event type and content (getType/getContent)', () => { + // Simulates an e2ee event: underlying type is m.room.encrypted but + // getType()/getContent() return the decrypted values. + const room = makeRoom('!room:example.org', [ + makeEvent({ type: EventType.RoomMessage, body: 'secret message', id: '$enc' }), + ]); + const group = searchRoomTimeline(room, 'secret'); + expect(group!.items[0].event.type).toBe(EventType.RoomMessage); + expect(group!.items[0].event.content.body).toBe('secret message'); + }); +}); + +describe('searchEncryptedRoomsInMemory', () => { + it('searches across multiple rooms and returns matching groups', () => { + const mx = { + getRoom: (id: string) => { + const rooms = [ + makeRoom('!room1:example.org', [makeEvent({ body: 'hello', id: '$e1' })]), + makeRoom('!room2:example.org', [makeEvent({ body: 'goodbye', id: '$e2' })]), + ]; + return rooms.find((r) => r.roomId === id) ?? null; + }, + }; + const groups = searchEncryptedRoomsInMemory( + mx as any, + 'hello', + ['!room1:example.org', '!room2:example.org'] + ); + expect(groups).toHaveLength(1); + expect(groups[0].roomId).toBe('!room1:example.org'); + }); + + it('returns empty array when no rooms match', () => { + const mx = { + getRoom: () => + makeRoom('!room:example.org', [makeEvent({ body: 'unrelated content' })]), + }; + const groups = searchEncryptedRoomsInMemory(mx as any, 'notfound', ['!room:example.org']); + expect(groups).toHaveLength(0); + }); + + it('skips rooms not found in the client', () => { + const mx = { getRoom: () => null }; + const groups = searchEncryptedRoomsInMemory(mx as any, 'match', ['!ghost:example.org']); + expect(groups).toHaveLength(0); + }); +}); + +describe('partitionRoomsByEncryption', () => { + const mx = { + getRooms: () => [ + { roomId: '!enc:example.org' }, + { roomId: '!plain:example.org' }, + ], + isRoomEncrypted: (id: string) => id === '!enc:example.org', + }; + + it('returns all encrypted rooms and undefined serverRooms for global search', () => { + const result = partitionRoomsByEncryption(mx as any, undefined); + expect(result.encryptedRoomIds).toEqual(['!enc:example.org']); + expect(result.serverRooms).toBeUndefined(); + expect(result.skipServerSearch).toBe(false); + }); + + it('splits a mixed room list correctly', () => { + const result = partitionRoomsByEncryption(mx as any, [ + '!enc:example.org', + '!plain:example.org', + ]); + expect(result.encryptedRoomIds).toEqual(['!enc:example.org']); + expect(result.serverRooms).toEqual(['!plain:example.org']); + expect(result.skipServerSearch).toBe(false); + }); + + it('sets skipServerSearch when all specified rooms are encrypted', () => { + const result = partitionRoomsByEncryption(mx as any, ['!enc:example.org']); + expect(result.skipServerSearch).toBe(true); + expect(result.serverRooms).toBeUndefined(); + }); +}); + +describe('mergeSearchGroups', () => { + const makeGroup = (roomId: string, ts: number): ResultGroup => ({ + roomId, + items: [ + { + rank: 1, + event: { room_id: roomId, origin_server_ts: ts } as any, + context: { events_before: [], events_after: [], profile_info: {} }, + }, + ], + }); + + it('returns server groups unchanged when there are no in-memory groups', () => { + const server = [makeGroup('!a:x', 2000)]; + expect(mergeSearchGroups(server, [])).toBe(server); + }); + + it('returns in-memory groups unchanged when there are no server groups', () => { + const mem = [makeGroup('!b:x', 1000)]; + expect(mergeSearchGroups([], mem)).toBe(mem); + }); + + it('sorts by timestamp for recent order', () => { + const server = [makeGroup('!a:x', 1000)]; + const mem = [makeGroup('!b:x', 3000)]; + const merged = mergeSearchGroups(server, mem, 'recent'); + expect(merged[0].roomId).toBe('!b:x'); + expect(merged[1].roomId).toBe('!a:x'); + }); + + it('puts server results first for rank order', () => { + const server = [makeGroup('!a:x', 1000)]; + const mem = [makeGroup('!b:x', 3000)]; + const merged = mergeSearchGroups(server, mem, 'rank'); + expect(merged[0].roomId).toBe('!a:x'); + expect(merged[1].roomId).toBe('!b:x'); + }); +}); diff --git a/src/app/features/message-search/searchEncryptedRooms.ts b/src/app/features/message-search/searchEncryptedRooms.ts new file mode 100644 index 000000000..60ed9237a --- /dev/null +++ b/src/app/features/message-search/searchEncryptedRooms.ts @@ -0,0 +1,157 @@ +import { EventType } from '$types/matrix-sdk'; +import type { IEventWithRoomId, IResultContext, MatrixClient, MatrixEvent } from '$types/matrix-sdk'; +import type { ResultGroup, ResultItem } from './useMessageSearch'; + +// Shared empty context — in-memory results have no surrounding-event context. +const EMPTY_CONTEXT: IResultContext = { + events_before: [], + events_after: [], + profile_info: {}, +}; + +/** + * Builds an IEventWithRoomId from a live MatrixEvent, using the decrypted + * content and event type. This is what makes encrypted-room search work: + * getContent() returns plaintext even for e2ee events that have been decrypted. + */ +export function toSearchEvent(mEvent: MatrixEvent, roomId: string): IEventWithRoomId { + return { + event_id: mEvent.getId() ?? '', + room_id: roomId, + sender: mEvent.getSender() ?? '', + origin_server_ts: mEvent.getTs(), + content: mEvent.getContent(), // decrypted content for e2ee events + type: mEvent.getType(), // decrypted event type (e.g. m.room.message, not m.room.encrypted) + unsigned: mEvent.getUnsigned(), + } as IEventWithRoomId; +} + +/** + * Searches a single room's live timeline for message events that contain + * `lowerTerm` in their body. Returns a ResultGroup or undefined if no matches. + */ +export function searchRoomTimeline( + room: { roomId: string; getLiveTimeline: () => { getEvents: () => MatrixEvent[] } }, + lowerTerm: string, + senders?: string[] +): ResultGroup | undefined { + const events = room.getLiveTimeline().getEvents(); + const items: ResultItem[] = []; + + for (const mEvent of events) { + // Skip non-message events and still-encrypted events (decryption failed or not yet decrypted) + if (mEvent.getType() !== EventType.RoomMessage) continue; + if (mEvent.isRedacted()) continue; + + const sender = mEvent.getSender(); + if (!sender) continue; + if (senders && !senders.includes(sender)) continue; + + const body: string = mEvent.getContent().body ?? ''; + if (!body || !body.toLowerCase().includes(lowerTerm)) continue; + + items.push({ + rank: 1, + event: toSearchEvent(mEvent, room.roomId), + context: EMPTY_CONTEXT, + }); + } + + if (items.length === 0) return undefined; + + // Most recent first, consistent with server "recent" ordering + items.sort((a, b) => b.event.origin_server_ts - a.event.origin_server_ts); + + return { roomId: room.roomId, items }; +} + +/** + * Searches the in-memory live timeline of each listed encrypted room. + * Returns one ResultGroup per room that has at least one match. + */ +export function searchEncryptedRoomsInMemory( + mx: Pick, + term: string, + encryptedRoomIds: string[], + senders?: string[] +): ResultGroup[] { + const lowerTerm = term.toLowerCase(); + const groups: ResultGroup[] = []; + + for (const roomId of encryptedRoomIds) { + const room = mx.getRoom(roomId); + if (!room) continue; + + const group = searchRoomTimeline(room, lowerTerm, senders); + if (group) groups.push(group); + } + + return groups; +} + +/** + * Splits the user's room filter into encrypted (in-memory) and plaintext (server) buckets. + * + * - When `rooms` is undefined (global search), the server handles plaintext rooms and + * we additionally scan all joined encrypted rooms in memory. + * - When `rooms` is defined, each room is routed to the appropriate search path. + */ +export function partitionRoomsByEncryption( + mx: Pick, + rooms?: string[] +): { encryptedRoomIds: string[]; serverRooms: string[] | undefined; skipServerSearch: boolean } { + if (rooms === undefined) { + // Global: server handles everything it can; we supplement with all encrypted rooms + const encryptedRoomIds = mx + .getRooms() + .filter((r) => mx.isRoomEncrypted(r.roomId)) + .map((r) => r.roomId); + return { encryptedRoomIds, serverRooms: undefined, skipServerSearch: false }; + } + + const encryptedRoomIds: string[] = []; + const serverRooms: string[] = []; + + for (const roomId of rooms) { + if (mx.isRoomEncrypted(roomId)) { + encryptedRoomIds.push(roomId); + } else { + serverRooms.push(roomId); + } + } + + return { + encryptedRoomIds, + serverRooms: serverRooms.length > 0 ? serverRooms : undefined, + // All specified rooms are encrypted — skip the server call entirely + skipServerSearch: rooms.length > 0 && serverRooms.length === 0, + }; +} + +/** + * Merges server-side and in-memory ResultGroups. + * For "recent" order: interleaved by each group's most recent event timestamp. + * For "rank" order: server results first (real relevance scores), then in-memory. + */ +export function mergeSearchGroups( + serverGroups: ResultGroup[], + inMemoryGroups: ResultGroup[], + order?: string +): ResultGroup[] { + if (inMemoryGroups.length === 0) return serverGroups; + if (serverGroups.length === 0) return inMemoryGroups; + + const all = [...serverGroups, ...inMemoryGroups]; + + if (order === 'rank') { + // Keep server results first — they have real rank scores + return all; + } + + // Recent order: sort groups by the most recent event in each + return all.toSorted((a, b) => { + const aTs = a.items[0]?.event.origin_server_ts ?? 0; + const bTs = b.items[0]?.event.origin_server_ts ?? 0; + return bTs - aTs; + }); +} diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index dfe205f69..1bb135479 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -8,6 +8,12 @@ import type { } from '$types/matrix-sdk'; import { useCallback } from 'react'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { + searchEncryptedRoomsInMemory, + partitionRoomsByEncryption, + mergeSearchGroups, +} from './searchEncryptedRooms'; export type ResultItem = { rank: number; @@ -24,6 +30,8 @@ export type SearchResult = { nextToken?: string; highlights: string[]; groups: ResultGroup[]; + /** Number of encrypted rooms whose in-memory timeline was searched. */ + inMemoryRoomCount?: number; }; const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => { @@ -71,6 +79,7 @@ export type MessageSearchParams = { }; export const useMessageSearch = (params: MessageSearchParams) => { const mx = useMatrixClient(); + const { features } = useClientConfig(); const { term, order, rooms, senders } = params; const searchMessages = useCallback( @@ -82,6 +91,27 @@ export const useMessageSearch = (params: MessageSearchParams) => { }; const limit = 20; + const encryptedSearchEnabled = features?.encryptedSearch !== false; + const isFirstPage = !nextBatch || nextBatch === ''; + + const { encryptedRoomIds, serverRooms, skipServerSearch } = encryptedSearchEnabled + ? partitionRoomsByEncryption(mx, rooms) + : { encryptedRoomIds: [], serverRooms: rooms, skipServerSearch: false }; + + // 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) + : []; + + if (skipServerSearch) { + return { + highlights: term.split(/\s+/).filter(Boolean), + groups: inMemoryGroups, + inMemoryRoomCount: encryptedRoomIds.length, + }; + } + const requestBody: ISearchRequestBody = { search_categories: { room_events: { @@ -92,7 +122,7 @@ export const useMessageSearch = (params: MessageSearchParams) => { }, filter: { limit, - rooms, + rooms: serverRooms, senders, }, include_state: false, @@ -106,9 +136,21 @@ export const useMessageSearch = (params: MessageSearchParams) => { body: requestBody, next_batch: nextBatch === '' ? undefined : nextBatch, }); - return parseSearchResult(r); + const serverResult = parseSearchResult(r); + + if (inMemoryGroups.length === 0) { + return serverResult; + } + + const termWords = term.split(/\s+/).filter(Boolean); + return { + ...serverResult, + groups: mergeSearchGroups(serverResult.groups, inMemoryGroups, order), + highlights: Array.from(new Set([...serverResult.highlights, ...termWords])), + inMemoryRoomCount: encryptedRoomIds.length, + }; }, - [mx, term, order, rooms, senders] + [mx, features, term, order, rooms, senders] ); return searchMessages; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 6cb2a9ad3..94a6473bb 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -50,6 +50,12 @@ export type ClientConfig = { themeCatalogApprovedHostPrefixes?: string[]; settingsDefaults?: Partial; + + features?: { + polls?: boolean; + /** Enable in-memory search for encrypted rooms (default: true). */ + encryptedSearch?: boolean; + }; }; const ClientConfigContext = createContext(null); From 2230714653372bec8cf4a550e63ef3c5cbbefd1e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 09:31:26 -0400 Subject: [PATCH 02/14] feat(search): experimental toggle and lock icon for encrypted room search - 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 --- .../features/message-search/SearchFilters.tsx | 16 +++++++++ .../message-search/useMessageSearch.ts | 9 +++-- .../settings/experimental/EncryptedSearch.tsx | 36 +++++++++++++++++++ .../settings/experimental/Experimental.tsx | 2 ++ 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 src/app/features/settings/experimental/EncryptedSearch.tsx diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx index 7a5706ab1..f57ab7d48 100644 --- a/src/app/features/message-search/SearchFilters.tsx +++ b/src/app/features/message-search/SearchFilters.tsx @@ -23,6 +23,9 @@ import { SearchOrderBy } from '$types/matrix-sdk'; import FocusTrap from 'focus-trap-react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useAtomValue } from 'jotai'; +import { settingsAtom } from '$state/settings'; +import { useClientConfig } from '$hooks/useClientConfig'; import { getRoomIconSrc } from '$utils/room'; import { factoryRoomIdByAtoZ } from '$utils/sort'; import type { SearchItemStrGetter, UseAsyncSearchOptions } from '$hooks/useAsyncSearch'; @@ -120,6 +123,10 @@ type SelectRoomButtonProps = { }; function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) { const mx = useMatrixClient(); + const { features } = useClientConfig(); + const settings = useAtomValue(settingsAtom); + const encryptedSearchActive = + features?.encryptedSearch !== false && settings.encryptedSearch; const scrollRef = useRef(null); const [menuAnchor, setMenuAnchor] = useState(); const [localSelected, setLocalSelected] = useState(selectedRooms); @@ -269,6 +276,15 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())} /> } + after={ + encryptedSearchActive && mx.isRoomEncrypted(roomId) ? ( + + ) : null + } > {room.name} diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index 1bb135479..a55090f39 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -9,6 +9,8 @@ import type { import { useCallback } from 'react'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useClientConfig } from '$hooks/useClientConfig'; +import { useAtomValue } from 'jotai'; +import { settingsAtom } from '$state/settings'; import { searchEncryptedRoomsInMemory, partitionRoomsByEncryption, @@ -80,6 +82,7 @@ export type MessageSearchParams = { export const useMessageSearch = (params: MessageSearchParams) => { const mx = useMatrixClient(); const { features } = useClientConfig(); + const settings = useAtomValue(settingsAtom); const { term, order, rooms, senders } = params; const searchMessages = useCallback( @@ -91,7 +94,9 @@ export const useMessageSearch = (params: MessageSearchParams) => { }; const limit = 20; - const encryptedSearchEnabled = features?.encryptedSearch !== false; + // Operator kill switch takes priority; user toggle controls the rest. + const encryptedSearchEnabled = + features?.encryptedSearch !== false && settings.encryptedSearch; const isFirstPage = !nextBatch || nextBatch === ''; const { encryptedRoomIds, serverRooms, skipServerSearch } = encryptedSearchEnabled @@ -150,7 +155,7 @@ export const useMessageSearch = (params: MessageSearchParams) => { inMemoryRoomCount: encryptedRoomIds.length, }; }, - [mx, features, term, order, rooms, senders] + [mx, features, settings.encryptedSearch, term, order, rooms, senders] ); return searchMessages; diff --git a/src/app/features/settings/experimental/EncryptedSearch.tsx b/src/app/features/settings/experimental/EncryptedSearch.tsx new file mode 100644 index 000000000..262e2ec5b --- /dev/null +++ b/src/app/features/settings/experimental/EncryptedSearch.tsx @@ -0,0 +1,36 @@ +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { Box, Switch, Text } from 'folds'; +import { SequenceCardStyle } from '../styles.css'; + +export function EncryptedSearch() { + const { features } = useClientConfig(); + const [encryptedSearch, setEncryptedSearch] = useSetting(settingsAtom, 'encryptedSearch'); + + // If the operator has explicitly disabled this in config.json, hide the toggle. + if (features?.encryptedSearch === false) return null; + + return ( + + Encrypted Room Search + + + } + /> + + + ); +} diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index 330412185..ea72f042c 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -10,6 +10,7 @@ import { Sync } from '../general'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { BandwidthSavingEmojis } from './BandwithSavingEmojis'; import { MSC4268HistoryShare } from './MSC4268HistoryShare'; +import { EncryptedSearch } from './EncryptedSearch'; function PersonaToggle() { const [showPersonaSetting, setShowPersonaSetting] = useSetting( @@ -59,6 +60,7 @@ export function Experimental({ requestBack, requestClose }: Readonly + From 0472f326f2bdbc75657dd5cb7892e3874d7324e2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 09:35:36 -0400 Subject: [PATCH 03/14] feat(search): show search button in encrypted room headers 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. --- src/app/features/room/RoomViewHeader.tsx | 32 +++++++++++------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index f45e6172f..aac2158b6 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -670,23 +670,21 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { {(!room.isCallRoom() || chat) && ( <> - {!encryptedRoom && ( - - Search - - } - > - {(triggerRef) => ( - - - - )} - - )} + + {encryptedRoom ? 'Search (local cache)' : 'Search'} + + } + > + {(triggerRef) => ( + + + + )} + Date: Tue, 19 May 2026 09:36:32 -0400 Subject: [PATCH 04/14] fix(search): hide search icon in encrypted rooms unless feature is enabled --- src/app/features/room/RoomViewHeader.tsx | 38 ++++++++++++++---------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index aac2158b6..78034ff23 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -45,6 +45,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { useIsDirectRoom, useRoom } from '$hooks/useRoom'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; +import { useClientConfig } from '$hooks/useClientConfig'; import { useSpaceOptionally } from '$hooks/useSpace'; import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '$pages/pathUtils'; import { createLogger } from '$utils/debug'; @@ -371,6 +372,11 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { const encryptionEvent = useStateEvent(room, EventType.RoomEncryption); const encryptedRoom = !!encryptionEvent; + + const { features } = useClientConfig(); + const settings = useAtomValue(settingsAtom); + const encryptedSearchEnabled = + features?.encryptedSearch !== false && settings.encryptedSearch; const avatarMxc = useRoomAvatar(room, direct && !customDMCards); const name = useRoomName(room); const topic = useRoomTopic(room); @@ -670,21 +676,23 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { {(!room.isCallRoom() || chat) && ( <> - - {encryptedRoom ? 'Search (local cache)' : 'Search'} - - } - > - {(triggerRef) => ( - - - - )} - + {(!encryptedRoom || encryptedSearchEnabled) && ( + + {encryptedRoom ? 'Search (local cache)' : 'Search'} + + } + > + {(triggerRef) => ( + + + + )} + + )} Date: Tue, 19 May 2026 10:06:00 -0400 Subject: [PATCH 05/14] feat(search): fix DM rooms, Discord-style has:/from: filters - 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 --- .../features/message-search/MessageSearch.tsx | 54 +++++- .../features/message-search/SearchFilters.tsx | 163 +++++++++++++++++- .../message-search/searchEncryptedRooms.ts | 30 +++- .../message-search/useMessageSearch.ts | 52 +++++- src/app/pages/paths.ts | 1 + 5 files changed, 280 insertions(+), 20 deletions(-) diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 999820c76..7504d71ac 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -1,7 +1,6 @@ import type { RefObject } from 'react'; -import { useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem } from 'folds'; -import { useAtomValue } from 'jotai'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useInfiniteQuery } from '@tanstack/react-query'; import { useSearchParams } from 'react-router-dom'; @@ -16,12 +15,13 @@ import { useRoomNavigate } from '$hooks/useRoomNavigate'; import { ScrollTopContainer } from '$components/scroll-top-container'; import { ContainerColor } from '$styles/ContainerColor.css'; import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '$pages/pathUtils'; -import { useRooms } from '$state/hooks/roomList'; +import { useSelectedRooms } from '$state/hooks/roomList'; import { allRoomsAtom } from '$state/room-list/roomList'; -import { mDirectAtom } from '$state/mDirectList'; +import { isRoom } from '$utils/room'; import { VirtualTile } from '$components/virtualizer'; import type { MessageSearchParams } from './useMessageSearch'; import { useMessageSearch } from './useMessageSearch'; +import type { SearchHasType } from './useMessageSearch'; import { SearchResultGroup } from './SearchResultGroup'; import { SearchInput } from './SearchInput'; import { SearchFilters } from './SearchFilters'; @@ -34,6 +34,7 @@ const useSearchPathSearchParams = (searchParams: URLSearchParams): SearchPathSea order: searchParams.get('order') ?? undefined, rooms: searchParams.get('rooms') ?? undefined, senders: searchParams.get('senders') ?? undefined, + has: searchParams.get('has') ?? undefined, }), [searchParams] ); @@ -53,8 +54,8 @@ export function MessageSearch({ scrollRef, }: Readonly) { const mx = useMatrixClient(); - const mDirects = useAtomValue(mDirectAtom); - const allRooms = useRooms(mx, allRoomsAtom, mDirects); + const allRoomsSelector = useCallback((rId: string) => !!isRoom(mx.getRoom(rId)), [mx]); + const allRooms = useSelectedRooms(allRoomsAtom, allRoomsSelector); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); @@ -83,6 +84,15 @@ export function MessageSearch({ } return undefined; }, [searchPathSearchParams.senders]); + const VALID_HAS_TYPES: SearchHasType[] = ['image', 'file', 'audio', 'video', 'link']; + const searchParamHasTypes = useMemo(() => { + if (!searchPathSearchParams.has) return undefined; + const decoded = decodeSearchParamValueArray(searchPathSearchParams.has).filter( + (t): t is SearchHasType => VALID_HAS_TYPES.includes(t as SearchHasType) + ); + return decoded.length > 0 ? decoded : undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchPathSearchParams.has]); const msgSearchParams: MessageSearchParams = useMemo(() => { const isGlobal = searchPathSearchParams.global === 'true'; @@ -93,8 +103,9 @@ export function MessageSearch({ order: searchPathSearchParams.order ?? SearchOrderBy.Recent, rooms: searchParamRooms ?? defaultRooms, senders: searchParamsSenders ?? senders, + hasTypes: searchParamHasTypes, }; - }, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]); + }, [searchPathSearchParams, searchParamRooms, searchParamsSenders, searchParamHasTypes, rooms, senders]); const searchMessages = useMessageSearch(msgSearchParams); @@ -106,6 +117,7 @@ export function MessageSearch({ msgSearchParams.order, msgSearchParams.rooms, msgSearchParams.senders, + msgSearchParams.hasTypes, ], queryFn: ({ pageParam }) => searchMessages(pageParam), initialPageParam: '', @@ -179,6 +191,28 @@ export function MessageSearch({ }); }; + const handleHasTypesChange = (hasTypes?: SearchHasType[]) => { + setSearchParams((prevParams) => { + const newParams = new URLSearchParams(prevParams); + newParams.delete('has'); + if (hasTypes && hasTypes.length > 0) { + newParams.append('has', encodeSearchParamValueArray(hasTypes)); + } + return newParams; + }); + }; + + const handleSendersChange = (newSenders?: string[]) => { + setSearchParams((prevParams) => { + const newParams = new URLSearchParams(prevParams); + newParams.delete('senders'); + if (newSenders && newSenders.length > 0) { + newParams.append('senders', encodeSearchParamValueArray(newSenders)); + } + return newParams; + }); + }; + const lastVItem = vItems.at(-1); const lastVItemIndex: number | undefined = lastVItem?.index; const lastGroupIndex = groups.length - 1; @@ -218,13 +252,17 @@ export function MessageSearch({ diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx index f57ab7d48..bc5aa0693 100644 --- a/src/app/features/message-search/SearchFilters.tsx +++ b/src/app/features/message-search/SearchFilters.tsx @@ -1,4 +1,4 @@ -import type { ChangeEventHandler, MouseEventHandler } from 'react'; +import type { ChangeEventHandler, KeyboardEvent, MouseEventHandler } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { RectCords } from 'folds'; import { @@ -34,6 +34,7 @@ import type { DebounceOptions } from '$hooks/useDebounce'; import { useDebounce } from '$hooks/useDebounce'; import { VirtualTile } from '$components/virtualizer'; import { stopPropagation } from '$utils/keyboard'; +import type { SearchHasType } from './useMessageSearch'; type OrderButtonProps = { order?: string; @@ -333,6 +334,127 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto ); } +const HAS_FILTER_OPTIONS: { type: SearchHasType; label: string; icon: string }[] = [ + { type: 'image', label: 'Image', icon: Icons.Photo }, + { type: 'file', label: 'File', icon: Icons.File }, + { type: 'audio', label: 'Audio', icon: Icons.VolumeHigh }, + { type: 'video', label: 'Video', icon: Icons.Play }, + { type: 'link', label: 'Link', icon: Icons.Link }, +]; + +type HasFilterChipsProps = { + hasTypes?: SearchHasType[]; + onChange: (hasTypes?: SearchHasType[]) => void; +}; +function HasFilterChips({ hasTypes, onChange }: HasFilterChipsProps) { + const toggle = (type: SearchHasType) => { + if (hasTypes?.includes(type)) { + const next = hasTypes.filter((t) => t !== type); + onChange(next.length > 0 ? next : undefined); + } else { + onChange([...(hasTypes ?? []), type]); + } + }; + + return ( + <> + {HAS_FILTER_OPTIONS.map(({ type, label, icon }) => { + const active = hasTypes?.includes(type); + return ( + : } + outlined + onClick={() => toggle(type)} + > + {label} + + ); + })} + + ); +} + +type SelectSenderButtonProps = { + selectedSenders?: string[]; + onChange: (senders?: string[]) => void; +}; +function SelectSenderButton({ selectedSenders, onChange }: SelectSenderButtonProps) { + const [menuAnchor, setMenuAnchor] = useState(); + const [inputValue, setInputValue] = useState(''); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }; + + const addSender = () => { + const value = inputValue.trim(); + if (!value) return; + if (!selectedSenders?.includes(value)) { + onChange([...(selectedSenders ?? []), value]); + } + setInputValue(''); + setMenuAnchor(undefined); + }; + + const handleKeyDown = (evt: KeyboardEvent) => { + if (evt.key === 'Enter') addSender(); + }; + + const handleInputChange: ChangeEventHandler = (evt) => { + setInputValue(evt.currentTarget.value); + }; + + return ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + From (Matrix ID) + + + + + + + + } + > + } + > + Add Sender + + + ); +} + type SearchFiltersProps = { defaultRoomsFilterName: string; allowGlobal?: boolean; @@ -343,6 +465,10 @@ type SearchFiltersProps = { onGlobalChange: (global?: boolean) => void; order?: string; onOrderChange: (order?: string) => void; + hasTypes?: SearchHasType[]; + onHasTypesChange: (hasTypes?: SearchHasType[]) => void; + senders?: string[]; + onSendersChange: (senders?: string[]) => void; }; export function SearchFilters({ defaultRoomsFilterName, @@ -354,6 +480,10 @@ export function SearchFilters({ order, onGlobalChange, onOrderChange, + hasTypes, + onHasTypesChange, + senders, + onSendersChange, }: SearchFiltersProps) { const mx = useMatrixClient(); @@ -414,6 +544,37 @@ export function SearchFilters({ + + + Has: + + + + + From: + + {senders?.map((sender) => ( + { + const next = senders.filter((s) => s !== sender); + onSendersChange(next.length > 0 ? next : undefined); + }} + radii="Pill" + before={} + after={} + > + {mx.getUser(sender)?.displayName ?? sender} + + ))} + + ); } diff --git a/src/app/features/message-search/searchEncryptedRooms.ts b/src/app/features/message-search/searchEncryptedRooms.ts index 60ed9237a..ee3ccc1b4 100644 --- a/src/app/features/message-search/searchEncryptedRooms.ts +++ b/src/app/features/message-search/searchEncryptedRooms.ts @@ -2,6 +2,26 @@ import { EventType } from '$types/matrix-sdk'; import type { IEventWithRoomId, IResultContext, MatrixClient, MatrixEvent } from '$types/matrix-sdk'; import type { ResultGroup, ResultItem } from './useMessageSearch'; +/** Media / content type filters — mirrors Discord's `has:` filter. */ +export type SearchHasType = 'image' | 'file' | 'audio' | 'video' | 'link'; + +const HAS_TYPE_TO_MSGTYPE: Partial> = { + 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; + } + return false; +} + // Shared empty context — in-memory results have no surrounding-event context. const EMPTY_CONTEXT: IResultContext = { events_before: [], @@ -33,7 +53,8 @@ export function toSearchEvent(mEvent: MatrixEvent, roomId: string): IEventWithRo export function searchRoomTimeline( room: { roomId: string; getLiveTimeline: () => { getEvents: () => MatrixEvent[] } }, lowerTerm: string, - senders?: string[] + senders?: string[], + hasTypes?: SearchHasType[] ): ResultGroup | undefined { const events = room.getLiveTimeline().getEvents(); const items: ResultItem[] = []; @@ -47,6 +68,8 @@ export function searchRoomTimeline( if (!sender) continue; if (senders && !senders.includes(sender)) continue; + if (hasTypes && hasTypes.length > 0 && !mEventMatchesHasTypes(mEvent, hasTypes)) continue; + const body: string = mEvent.getContent().body ?? ''; if (!body || !body.toLowerCase().includes(lowerTerm)) continue; @@ -73,7 +96,8 @@ export function searchEncryptedRoomsInMemory( mx: Pick, term: string, encryptedRoomIds: string[], - senders?: string[] + senders?: string[], + hasTypes?: SearchHasType[] ): ResultGroup[] { const lowerTerm = term.toLowerCase(); const groups: ResultGroup[] = []; @@ -82,7 +106,7 @@ export function searchEncryptedRoomsInMemory( const room = mx.getRoom(roomId); if (!room) continue; - const group = searchRoomTimeline(room, lowerTerm, senders); + const group = searchRoomTimeline(room, lowerTerm, senders, hasTypes); if (group) groups.push(group); } diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index a55090f39..7e2905117 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -16,6 +16,9 @@ import { partitionRoomsByEncryption, mergeSearchGroups, } from './searchEncryptedRooms'; +import type { SearchHasType } from './searchEncryptedRooms'; + +export type { SearchHasType }; export type ResultItem = { rank: number; @@ -78,12 +81,40 @@ export type MessageSearchParams = { order?: string; rooms?: string[]; senders?: string[]; + hasTypes?: SearchHasType[]; }; export const useMessageSearch = (params: MessageSearchParams) => { const mx = useMatrixClient(); const { features } = useClientConfig(); const settings = useAtomValue(settingsAtom); - const { term, order, rooms, senders } = params; + const { term, order, rooms, senders, hasTypes } = params; + + const filterGroupsByHasType = useCallback( + (grps: ResultGroup[]): ResultGroup[] => { + if (!hasTypes || hasTypes.length === 0) return grps; + const withMsgtype = hasTypes.filter((t) => t !== 'link'); + return grps + .map((g) => ({ + ...g, + items: g.items.filter((item) => { + const content = item.event.content as { msgtype?: string; body?: string }; + if (withMsgtype.length > 0) { + const msgtypeMap: Record = { + image: 'm.image', + file: 'm.file', + audio: 'm.audio', + video: 'm.video', + }; + if (withMsgtype.some((t) => content.msgtype === msgtypeMap[t])) return true; + } + if (hasTypes.includes('link') && /https?:\/\//i.test(content.body ?? '')) return true; + return false; + }), + })) + .filter((g) => g.items.length > 0); + }, + [hasTypes] + ); const searchMessages = useCallback( async (nextBatch?: string) => { @@ -106,13 +137,13 @@ export const useMessageSearch = (params: MessageSearchParams) => { // 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) + ? searchEncryptedRoomsInMemory(mx, term, encryptedRoomIds, senders, hasTypes) : []; if (skipServerSearch) { return { highlights: term.split(/\s+/).filter(Boolean), - groups: inMemoryGroups, + groups: filterGroupsByHasType(inMemoryGroups), inMemoryRoomCount: encryptedRoomIds.length, }; } @@ -129,6 +160,7 @@ export const useMessageSearch = (params: MessageSearchParams) => { limit, rooms: serverRooms, senders, + ...(hasTypes?.includes('link') && { contains_url: true }), }, include_state: false, order_by: order as SearchOrderBy.Recent, @@ -142,20 +174,24 @@ export const useMessageSearch = (params: MessageSearchParams) => { next_batch: nextBatch === '' ? undefined : nextBatch, }); const serverResult = parseSearchResult(r); + const filteredServerResult = { + ...serverResult, + groups: filterGroupsByHasType(serverResult.groups), + }; if (inMemoryGroups.length === 0) { - return serverResult; + return filteredServerResult; } const termWords = term.split(/\s+/).filter(Boolean); return { - ...serverResult, - groups: mergeSearchGroups(serverResult.groups, inMemoryGroups, order), - highlights: Array.from(new Set([...serverResult.highlights, ...termWords])), + ...filteredServerResult, + groups: mergeSearchGroups(filteredServerResult.groups, filterGroupsByHasType(inMemoryGroups), order), + highlights: Array.from(new Set([...filteredServerResult.highlights, ...termWords])), inMemoryRoomCount: encryptedRoomIds.length, }; }, - [mx, features, settings.encryptedSearch, term, order, rooms, senders] + [mx, features, settings.encryptedSearch, term, order, rooms, senders, hasTypes, filterGroupsByHasType] ); return searchMessages; diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 1ac57b756..8e83d0a7a 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -37,6 +37,7 @@ export type SearchPathSearchParams = { order?: string; rooms?: string; senders?: string; + has?: string; }; export const SEARCH_PATH_SEGMENT = 'search/'; From 6a738467e2d88a7e97e97eb162899de426f450b6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 10:29:25 -0400 Subject: [PATCH 06/14] feat: DM search page and has: filters work without text term - 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 --- .../features/message-search/MessageSearch.tsx | 5 +- .../message-search/searchEncryptedRooms.ts | 6 ++- .../message-search/useMessageSearch.ts | 11 ++-- src/app/features/room/RoomViewHeader.tsx | 6 ++- src/app/hooks/router/useDirectSelected.ts | 12 ++++- src/app/pages/Router.tsx | 4 +- src/app/pages/client/direct/Direct.tsx | 31 +++++++++-- src/app/pages/client/direct/Search.tsx | 53 +++++++++++++++++++ src/app/pages/client/direct/index.ts | 1 + src/app/pages/pathUtils.ts | 2 + 10 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 src/app/pages/client/direct/Search.tsx diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 7504d71ac..03b53ece0 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -18,6 +18,8 @@ import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '$pages import { useSelectedRooms } from '$state/hooks/roomList'; import { allRoomsAtom } from '$state/room-list/roomList'; import { isRoom } from '$utils/room'; +import { useAtomValue } from 'jotai'; +import { mDirectAtom } from '$state/mDirectList'; import { VirtualTile } from '$components/virtualizer'; import type { MessageSearchParams } from './useMessageSearch'; import { useMessageSearch } from './useMessageSearch'; @@ -54,6 +56,7 @@ export function MessageSearch({ scrollRef, }: Readonly) { const mx = useMatrixClient(); + const mDirects = useAtomValue(mDirectAtom); const allRoomsSelector = useCallback((rId: string) => !!isRoom(mx.getRoom(rId)), [mx]); const allRooms = useSelectedRooms(allRoomsAtom, allRoomsSelector); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); @@ -110,7 +113,7 @@ export function MessageSearch({ const searchMessages = useMessageSearch(msgSearchParams); const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ - enabled: !!msgSearchParams.term, + enabled: !!msgSearchParams.term || (!!msgSearchParams.hasTypes && msgSearchParams.hasTypes.length > 0), queryKey: [ 'search', msgSearchParams.term, diff --git a/src/app/features/message-search/searchEncryptedRooms.ts b/src/app/features/message-search/searchEncryptedRooms.ts index ee3ccc1b4..1cc900b17 100644 --- a/src/app/features/message-search/searchEncryptedRooms.ts +++ b/src/app/features/message-search/searchEncryptedRooms.ts @@ -70,8 +70,10 @@ export function searchRoomTimeline( if (hasTypes && hasTypes.length > 0 && !mEventMatchesHasTypes(mEvent, hasTypes)) continue; - const body: string = mEvent.getContent().body ?? ''; - if (!body || !body.toLowerCase().includes(lowerTerm)) continue; + if (lowerTerm !== '') { + const body: string = mEvent.getContent().body ?? ''; + if (!body || !body.toLowerCase().includes(lowerTerm)) continue; + } items.push({ rank: 1, diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index 7e2905117..ebebe1894 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -118,7 +118,8 @@ export const useMessageSearch = (params: MessageSearchParams) => { const searchMessages = useCallback( async (nextBatch?: string) => { - if (!term) + const hasHasTypes = hasTypes && hasTypes.length > 0; + if (!term && !hasHasTypes) return { highlights: [], groups: [], @@ -137,12 +138,14 @@ export const useMessageSearch = (params: MessageSearchParams) => { // 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) + ? searchEncryptedRoomsInMemory(mx, term ?? '', encryptedRoomIds, senders, hasTypes) : []; - if (skipServerSearch) { + // When there's no text term, skip server search (server requires search_term). + // Only in-memory encrypted rooms are searchable by has: type alone. + if (skipServerSearch || !term) { return { - highlights: term.split(/\s+/).filter(Boolean), + highlights: term ? term.split(/\s+/).filter(Boolean) : [], groups: filterGroupsByHasType(inMemoryGroups), inMemoryRoomCount: encryptedRoomIds.length, }; diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 78034ff23..1ab7d8bdf 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -47,7 +47,7 @@ import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { useClientConfig } from '$hooks/useClientConfig'; import { useSpaceOptionally } from '$hooks/useSpace'; -import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '$pages/pathUtils'; +import { getDirectSearchPath, getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '$pages/pathUtils'; import { createLogger } from '$utils/debug'; import { getCanonicalAliasOrRoomId, @@ -562,7 +562,9 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { }; const path = space ? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId)) - : getHomeSearchPath(); + : isDirectConversation + ? getDirectSearchPath() + : getHomeSearchPath(); navigate(withSearchParam(path, searchParams)); }; diff --git a/src/app/hooks/router/useDirectSelected.ts b/src/app/hooks/router/useDirectSelected.ts index 07605bbe3..2ee8bbdc4 100644 --- a/src/app/hooks/router/useDirectSelected.ts +++ b/src/app/hooks/router/useDirectSelected.ts @@ -1,5 +1,5 @@ import { useMatch } from 'react-router-dom'; -import { getDirectCreatePath, getDirectPath } from '$pages/pathUtils'; +import { getDirectCreatePath, getDirectPath, getDirectSearchPath } from '$pages/pathUtils'; export const useDirectSelected = (): boolean => { const directMatch = useMatch({ @@ -20,3 +20,13 @@ export const useDirectCreateSelected = (): boolean => { return !!match; }; + +export const useDirectSearchSelected = (): boolean => { + const match = useMatch({ + path: getDirectSearchPath(), + caseSensitive: true, + end: false, + }); + + return !!match; +}; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index aec62cc86..90bb242da 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -66,7 +66,7 @@ import { import { ClientBindAtoms, ClientLayout, ClientRoot, ClientRouteOutlet } from './client'; import { HandleNotificationClick, ClientNonUIFeatures } from './client/ClientNonUIFeatures'; import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; -import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; +import { Direct, DirectCreate, DirectRouteRoomProvider, DirectSearch } from './client/direct'; import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; import { Notifications, Inbox, Invites } from './client/inbox'; @@ -268,8 +268,8 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) > {mobile ? null : } />} } /> + } /> diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 2a81b849d..ed25a0859 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -18,7 +18,7 @@ import { } from 'folds'; import { useVirtualizer } from '@tanstack/react-virtual'; import FocusTrap from 'focus-trap-react'; -import { useNavigate } from 'react-router-dom'; +import { NavLink, useNavigate } from 'react-router-dom'; import { RoomEvent } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { factoryRoomIdByActivity } from '$utils/sort'; @@ -31,7 +31,7 @@ import { NavItem, NavItemContent, } from '$components/nav'; -import { getDirectCreatePath, getDirectRoomPath } from '$pages/pathUtils'; +import { getDirectCreatePath, getDirectRoomPath, getDirectSearchPath } from '$pages/pathUtils'; import { getCanonicalAliasOrRoomId } from '$utils/matrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { VirtualTile } from '$components/virtualizer'; @@ -51,7 +51,7 @@ import { getRoomNotificationMode, useRoomsNotificationPreferencesContext, } from '$hooks/useRoomsNotificationPreferences'; -import { useDirectCreateSelected } from '$hooks/router/useDirectSelected'; +import { useDirectCreateSelected, useDirectSearchSelected } from '$hooks/router/useDirectSelected'; import { useDirectRooms } from './useDirectRooms'; import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer'; import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize'; @@ -198,6 +198,7 @@ export function Direct() { const [joinCallOnSingleClick] = useSetting(settingsAtom, 'joinCallOnSingleClick'); const createDirectSelected = useDirectCreateSelected(); + const searchSelected = useDirectSearchSelected(); const selectedRoomId = useSelectedRoom(); const noRoomToDisplay = directs.length === 0; @@ -298,6 +299,30 @@ export function Direct() { + + + + + + + + {!hideText && ( + + + Message Search + + + )} + + + + diff --git a/src/app/pages/client/direct/Search.tsx b/src/app/pages/client/direct/Search.tsx new file mode 100644 index 000000000..9c980ae79 --- /dev/null +++ b/src/app/pages/client/direct/Search.tsx @@ -0,0 +1,53 @@ +import { useRef } from 'react'; +import { Box, Icon, Icons, Text, Scroll, IconButton } from 'folds'; +import { Page, PageContent, PageContentCenter, PageHeader } from '$components/page'; +import { MessageSearch } from '$features/message-search'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { BackRouteHandler } from '$components/BackRouteHandler'; +import { useDirectRooms } from './useDirectRooms'; + +export function DirectSearch() { + const scrollRef = useRef(null); + const rooms = useDirectRooms(); + const screenSize = useScreenSizeContext(); + + return ( + + + + + {screenSize === ScreenSize.Mobile && ( + + {(onBack) => ( + + + + )} + + )} + + + {screenSize !== ScreenSize.Mobile && } + + Message Search + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/pages/client/direct/index.ts b/src/app/pages/client/direct/index.ts index d247bbc03..770aa28f2 100644 --- a/src/app/pages/client/direct/index.ts +++ b/src/app/pages/client/direct/index.ts @@ -1,3 +1,4 @@ export * from './Direct'; export * from './RoomProvider'; export * from './DirectCreate'; +export * from './Search'; diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 4a95f47fc..9d2ef00b9 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -7,6 +7,7 @@ import { DIRECT_CREATE_PATH, DIRECT_PATH, DIRECT_ROOM_PATH, + DIRECT_SEARCH_PATH, EXPLORE_FEATURED_PATH, EXPLORE_PATH, EXPLORE_SERVER_PATH, @@ -102,6 +103,7 @@ export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string export const getDirectPath = (): string => DIRECT_PATH; export const getDirectCreatePath = (): string => DIRECT_CREATE_PATH; +export const getDirectSearchPath = (): string => DIRECT_SEARCH_PATH; export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): string => { const params = { roomIdOrAlias: encodeURIComponent(roomIdOrAlias), From 7cceb35f6b8264aef5947d56867c6ac0a6373218 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 11:33:48 -0400 Subject: [PATCH 07/14] fix(search): fix DM route, results label, nav style; add member picker for from: filter --- .../features/message-search/MessageSearch.tsx | 20 +- .../features/message-search/SearchFilters.tsx | 175 ++++++++++++++---- .../searchEncryptedRooms.test.ts | 73 ++++---- .../message-search/searchEncryptedRooms.ts | 7 +- .../message-search/useMessageSearch.ts | 18 +- src/app/features/room/RoomViewHeader.tsx | 12 +- .../settings/experimental/EncryptedSearch.tsx | 4 +- src/app/pages/Router.tsx | 1 + src/app/pages/client/direct/Direct.tsx | 8 +- src/app/pages/paths.ts | 1 + src/app/state/settings.ts | 2 + 11 files changed, 233 insertions(+), 88 deletions(-) diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 03b53ece0..b629d35fb 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -108,12 +108,20 @@ export function MessageSearch({ senders: searchParamsSenders ?? senders, hasTypes: searchParamHasTypes, }; - }, [searchPathSearchParams, searchParamRooms, searchParamsSenders, searchParamHasTypes, rooms, senders]); + }, [ + searchPathSearchParams, + searchParamRooms, + searchParamsSenders, + searchParamHasTypes, + rooms, + senders, + ]); const searchMessages = useMessageSearch(msgSearchParams); const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ - enabled: !!msgSearchParams.term || (!!msgSearchParams.hasTypes && msgSearchParams.hasTypes.length > 0), + enabled: + !!msgSearchParams.term || (!!msgSearchParams.hasTypes && msgSearchParams.hasTypes.length > 0), queryKey: [ 'search', msgSearchParams.term, @@ -325,7 +333,13 @@ export function MessageSearch({ {vItems.length > 0 && ( - {`Results for "${msgSearchParams.term}"`} + + {msgSearchParams.term + ? `Results for "${msgSearchParams.term}"` + : msgSearchParams.hasTypes && msgSearchParams.hasTypes.length > 0 + ? `Results for ${msgSearchParams.hasTypes.join(', ')}` + : 'Results'} +
(null); const [menuAnchor, setMenuAnchor] = useState(); const [localSelected, setLocalSelected] = useState(selectedRooms); @@ -279,11 +280,9 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto } after={ encryptedSearchActive && mx.isRoomEncrypted(roomId) ? ( - + + + ) : null } > @@ -334,7 +333,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto ); } -const HAS_FILTER_OPTIONS: { type: SearchHasType; label: string; icon: string }[] = [ +const HAS_FILTER_OPTIONS: { type: SearchHasType; label: string; icon: IconSrc }[] = [ { type: 'image', label: 'Image', icon: Icons.Photo }, { type: 'file', label: 'File', icon: Icons.File }, { type: 'audio', label: 'Audio', icon: Icons.VolumeHigh }, @@ -378,33 +377,77 @@ function HasFilterChips({ hasTypes, onChange }: HasFilterChipsProps) { } type SelectSenderButtonProps = { + roomList: string[]; selectedSenders?: string[]; onChange: (senders?: string[]) => void; }; -function SelectSenderButton({ selectedSenders, onChange }: SelectSenderButtonProps) { + +const SENDER_SEARCH_OPTS: UseAsyncSearchOptions = { limit: 50, matchOptions: { contain: true } }; +const SENDER_DEBOUNCE_OPTS: DebounceOptions = { wait: 200 }; +const getMemberStr: SearchItemStrGetter = (member, query) => { + const name = member.name ?? member.userId; + return query ? [name, member.userId] : name; +}; +function SelectSenderButton({ roomList, selectedSenders, onChange }: SelectSenderButtonProps) { + const mx = useMatrixClient(); const [menuAnchor, setMenuAnchor] = useState(); - const [inputValue, setInputValue] = useState(''); + const scrollRef = useRef(null); - const handleOpenMenu: MouseEventHandler = (evt) => { - setMenuAnchor(evt.currentTarget.getBoundingClientRect()); - }; + const members = useMemo(() => { + const seen = new Set(); + const result: RoomMember[] = []; + const scope = roomList.length > 0 ? roomList : []; + for (const roomId of scope) { + const room = mx.getRoom(roomId); + if (!room) continue; + for (const m of room.getMembers()) { + if (!seen.has(m.userId)) { + seen.add(m.userId); + result.push(m); + } + } + } + return result.toSorted((a, b) => (a.name ?? a.userId).localeCompare(b.name ?? b.userId)); + }, [mx, roomList]); - const addSender = () => { - const value = inputValue.trim(); - if (!value) return; - if (!selectedSenders?.includes(value)) { - onChange([...(selectedSenders ?? []), value]); + const [searchState, searchMembersRaw, resetSearch] = useAsyncSearch( + members, + getMemberStr, + SENDER_SEARCH_OPTS + ); + const searchMembers = useDebounce(searchMembersRaw, SENDER_DEBOUNCE_OPTS); + const handleSearchChange: ChangeEventHandler = (evt) => { + const value = evt.currentTarget.value.trim(); + if (!value) { + resetSearch(); + return; } - setInputValue(''); - setMenuAnchor(undefined); + searchMembers(value); }; - const handleKeyDown = (evt: KeyboardEvent) => { - if (evt.key === 'Enter') addSender(); + const displayMembers = searchState?.items ?? members; + + const virtualizer = useVirtualizer({ + count: displayMembers.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 32 + 4, + overscan: 10, + }); + const vItems = virtualizer.getVirtualItems(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; - const handleInputChange: ChangeEventHandler = (evt) => { - setInputValue(evt.currentTarget.value); + const handleMemberClick: MouseEventHandler = (evt) => { + const userId = evt.currentTarget.getAttribute('data-user-id'); + if (!userId) return; + if (selectedSenders?.includes(userId)) { + const next = selectedSenders.filter((s) => s !== userId); + onChange(next.length > 0 ? next : undefined); + } else { + onChange([...(selectedSenders ?? []), userId]); + } }; return ( @@ -422,22 +465,74 @@ function SelectSenderButton({ selectedSenders, onChange }: SelectSenderButtonPro }} > - - From (Matrix ID) - + + + From 0 ? ( + + {searchState.items.length} + + ) : null + } /> - + + + {displayMembers.length === 0 && ( + + No members found + + )} +
+ {vItems.map((vItem) => { + const member = displayMembers[vItem.index]!; + const selected = selectedSenders?.includes(member.userId); + return ( + + } + > + + + {member.name ?? member.userId} + + + {member.userId} + + + + + ); + })} +
+
+
@@ -573,7 +668,11 @@ export function SearchFilters({ {mx.getUser(sender)?.displayName ?? sender} ))} - + ); diff --git a/src/app/features/message-search/searchEncryptedRooms.test.ts b/src/app/features/message-search/searchEncryptedRooms.test.ts index c1b2a68dc..aac0f2f90 100644 --- a/src/app/features/message-search/searchEncryptedRooms.test.ts +++ b/src/app/features/message-search/searchEncryptedRooms.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { EventType } from '$types/matrix-sdk'; -import type { MatrixEvent } from '$types/matrix-sdk'; +import type { IEventWithRoomId, MatrixClient, MatrixEvent } from '$types/matrix-sdk'; import { searchRoomTimeline, searchEncryptedRoomsInMemory, @@ -48,7 +48,7 @@ describe('searchRoomTimeline', () => { const group = searchRoomTimeline(room, 'hello world'); expect(group).toBeDefined(); expect(group!.items).toHaveLength(1); - expect(group!.items[0].event.event_id).toBe('$e1'); + expect(group!.items[0]!.event.event_id).toBe('$e1'); }); it('is case-insensitive', () => { @@ -76,7 +76,7 @@ describe('searchRoomTimeline', () => { ]); const group = searchRoomTimeline(room, 'match', ['@alice:example.org']); expect(group!.items).toHaveLength(1); - expect(group!.items[0].event.sender).toBe('@alice:example.org'); + expect(group!.items[0]!.event.sender).toBe('@alice:example.org'); }); it('sorts results most-recent-first', () => { @@ -96,8 +96,8 @@ describe('searchRoomTimeline', () => { makeEvent({ type: EventType.RoomMessage, body: 'secret message', id: '$enc' }), ]); const group = searchRoomTimeline(room, 'secret'); - expect(group!.items[0].event.type).toBe(EventType.RoomMessage); - expect(group!.items[0].event.content.body).toBe('secret message'); + expect(group!.items[0]!.event.type).toBe(EventType.RoomMessage); + expect(group!.items[0]!.event.content.body).toBe('secret message'); }); }); @@ -112,49 +112,48 @@ describe('searchEncryptedRoomsInMemory', () => { return rooms.find((r) => r.roomId === id) ?? null; }, }; - const groups = searchEncryptedRoomsInMemory( - mx as any, - 'hello', - ['!room1:example.org', '!room2:example.org'] - ); + const groups = searchEncryptedRoomsInMemory(mx as unknown as MatrixClient, 'hello', [ + '!room1:example.org', + '!room2:example.org', + ]); expect(groups).toHaveLength(1); - expect(groups[0].roomId).toBe('!room1:example.org'); + expect(groups[0]!.roomId).toBe('!room1:example.org'); }); it('returns empty array when no rooms match', () => { const mx = { - getRoom: () => - makeRoom('!room:example.org', [makeEvent({ body: 'unrelated content' })]), + getRoom: () => makeRoom('!room:example.org', [makeEvent({ body: 'unrelated content' })]), }; - const groups = searchEncryptedRoomsInMemory(mx as any, 'notfound', ['!room:example.org']); + const groups = searchEncryptedRoomsInMemory(mx as unknown as MatrixClient, 'notfound', [ + '!room:example.org', + ]); expect(groups).toHaveLength(0); }); it('skips rooms not found in the client', () => { const mx = { getRoom: () => null }; - const groups = searchEncryptedRoomsInMemory(mx as any, 'match', ['!ghost:example.org']); + const groups = searchEncryptedRoomsInMemory(mx as unknown as MatrixClient, 'match', [ + '!ghost:example.org', + ]); expect(groups).toHaveLength(0); }); }); describe('partitionRoomsByEncryption', () => { const mx = { - getRooms: () => [ - { roomId: '!enc:example.org' }, - { roomId: '!plain:example.org' }, - ], + getRooms: () => [{ roomId: '!enc:example.org' }, { roomId: '!plain:example.org' }], isRoomEncrypted: (id: string) => id === '!enc:example.org', }; it('returns all encrypted rooms and undefined serverRooms for global search', () => { - const result = partitionRoomsByEncryption(mx as any, undefined); + const result = partitionRoomsByEncryption(mx as unknown as MatrixClient, undefined); expect(result.encryptedRoomIds).toEqual(['!enc:example.org']); expect(result.serverRooms).toBeUndefined(); expect(result.skipServerSearch).toBe(false); }); it('splits a mixed room list correctly', () => { - const result = partitionRoomsByEncryption(mx as any, [ + const result = partitionRoomsByEncryption(mx as unknown as MatrixClient, [ '!enc:example.org', '!plain:example.org', ]); @@ -164,24 +163,24 @@ describe('partitionRoomsByEncryption', () => { }); it('sets skipServerSearch when all specified rooms are encrypted', () => { - const result = partitionRoomsByEncryption(mx as any, ['!enc:example.org']); + const result = partitionRoomsByEncryption(mx as unknown as MatrixClient, ['!enc:example.org']); expect(result.skipServerSearch).toBe(true); expect(result.serverRooms).toBeUndefined(); }); }); -describe('mergeSearchGroups', () => { - const makeGroup = (roomId: string, ts: number): ResultGroup => ({ - roomId, - items: [ - { - rank: 1, - event: { room_id: roomId, origin_server_ts: ts } as any, - context: { events_before: [], events_after: [], profile_info: {} }, - }, - ], - }); +const makeGroup = (roomId: string, ts: number): ResultGroup => ({ + roomId, + items: [ + { + rank: 1, + event: { room_id: roomId, origin_server_ts: ts } as unknown as IEventWithRoomId, + context: { events_before: [], events_after: [], profile_info: {} }, + }, + ], +}); +describe('mergeSearchGroups', () => { it('returns server groups unchanged when there are no in-memory groups', () => { const server = [makeGroup('!a:x', 2000)]; expect(mergeSearchGroups(server, [])).toBe(server); @@ -196,15 +195,15 @@ describe('mergeSearchGroups', () => { const server = [makeGroup('!a:x', 1000)]; const mem = [makeGroup('!b:x', 3000)]; const merged = mergeSearchGroups(server, mem, 'recent'); - expect(merged[0].roomId).toBe('!b:x'); - expect(merged[1].roomId).toBe('!a:x'); + expect(merged[0]!.roomId).toBe('!b:x'); + expect(merged[1]!.roomId).toBe('!a:x'); }); it('puts server results first for rank order', () => { const server = [makeGroup('!a:x', 1000)]; const mem = [makeGroup('!b:x', 3000)]; const merged = mergeSearchGroups(server, mem, 'rank'); - expect(merged[0].roomId).toBe('!a:x'); - expect(merged[1].roomId).toBe('!b:x'); + expect(merged[0]!.roomId).toBe('!a:x'); + expect(merged[1]!.roomId).toBe('!b:x'); }); }); diff --git a/src/app/features/message-search/searchEncryptedRooms.ts b/src/app/features/message-search/searchEncryptedRooms.ts index 1cc900b17..3586eaeab 100644 --- a/src/app/features/message-search/searchEncryptedRooms.ts +++ b/src/app/features/message-search/searchEncryptedRooms.ts @@ -1,5 +1,10 @@ import { EventType } from '$types/matrix-sdk'; -import type { IEventWithRoomId, IResultContext, MatrixClient, MatrixEvent } from '$types/matrix-sdk'; +import type { + IEventWithRoomId, + IResultContext, + MatrixClient, + MatrixEvent, +} from '$types/matrix-sdk'; import type { ResultGroup, ResultItem } from './useMessageSearch'; /** Media / content type filters — mirrors Discord's `has:` filter. */ diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index ebebe1894..d0c4bc092 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -189,12 +189,26 @@ export const useMessageSearch = (params: MessageSearchParams) => { const termWords = term.split(/\s+/).filter(Boolean); return { ...filteredServerResult, - groups: mergeSearchGroups(filteredServerResult.groups, filterGroupsByHasType(inMemoryGroups), order), + groups: mergeSearchGroups( + filteredServerResult.groups, + filterGroupsByHasType(inMemoryGroups), + order + ), highlights: Array.from(new Set([...filteredServerResult.highlights, ...termWords])), inMemoryRoomCount: encryptedRoomIds.length, }; }, - [mx, features, settings.encryptedSearch, term, order, rooms, senders, hasTypes, filterGroupsByHasType] + [ + mx, + features, + settings.encryptedSearch, + term, + order, + rooms, + senders, + hasTypes, + filterGroupsByHasType, + ] ); return searchMessages; diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 1ab7d8bdf..4aee4f277 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -47,7 +47,12 @@ import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { useClientConfig } from '$hooks/useClientConfig'; import { useSpaceOptionally } from '$hooks/useSpace'; -import { getDirectSearchPath, getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '$pages/pathUtils'; +import { + getDirectSearchPath, + getHomeSearchPath, + getSpaceSearchPath, + withSearchParam, +} from '$pages/pathUtils'; import { createLogger } from '$utils/debug'; import { getCanonicalAliasOrRoomId, @@ -375,8 +380,7 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { const { features } = useClientConfig(); const settings = useAtomValue(settingsAtom); - const encryptedSearchEnabled = - features?.encryptedSearch !== false && settings.encryptedSearch; + const encryptedSearchEnabled = features?.encryptedSearch !== false && settings.encryptedSearch; const avatarMxc = useRoomAvatar(room, direct && !customDMCards); const name = useRoomName(room); const topic = useRoomTopic(room); @@ -562,7 +566,7 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { }; const path = space ? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId)) - : isDirectConversation + : direct ? getDirectSearchPath() : getHomeSearchPath(); navigate(withSearchParam(path, searchParams)); diff --git a/src/app/features/settings/experimental/EncryptedSearch.tsx b/src/app/features/settings/experimental/EncryptedSearch.tsx index 262e2ec5b..1091f72dc 100644 --- a/src/app/features/settings/experimental/EncryptedSearch.tsx +++ b/src/app/features/settings/experimental/EncryptedSearch.tsx @@ -26,7 +26,9 @@ export function EncryptedSearch() { variant="Primary" value={encryptedSearch} onChange={setEncryptedSearch} - title={encryptedSearch ? 'Disable encrypted room search' : 'Enable encrypted room search'} + title={ + encryptedSearch ? 'Disable encrypted room search' : 'Enable encrypted room search' + } /> } /> diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 90bb242da..84d995558 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -270,6 +270,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> } /> diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index ed25a0859..db2aa14ba 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -306,10 +306,14 @@ export function Direct() { as="span" grow="Yes" alignItems="Center" + justifyContent="Start" gap="200" - justifyContent="Center" > - + {!hideText && ( diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 8e83d0a7a..046f7f0eb 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -58,6 +58,7 @@ export type DirectCreateSearchParams = { userId?: string; }; export const DIRECT_CREATE_PATH = `/direct/${CREATE_PATH_SEGMENT}`; +export const DIRECT_SEARCH_PATH = `/direct/${SEARCH_PATH_SEGMENT}`; export const DIRECT_ROOM_PATH = `/direct/${ROOM_PATH_SEGMENT}`; export const SPACE_PATH = '/:spaceIdOrAlias/'; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 5efe57552..af2d077dc 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -128,6 +128,7 @@ export interface Settings { developerTools: boolean; enableMSC4268CMD: boolean; settingsSyncEnabled: boolean; + encryptedSearch: boolean; // Cosmetics! jumboEmojiSize: JumboEmojiSize; @@ -264,6 +265,7 @@ export const defaultSettings: Settings = { developerTools: false, settingsSyncEnabled: false, + encryptedSearch: false, // Cosmetics! jumboEmojiSize: 'normal', From f8294cac3442f9b5b14c4ff163bbc1c9e6eddb62 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 12:06:09 -0400 Subject: [PATCH 08/14] feat(search): scope member picker to search context; add avatars and display names; fix DM create button alignment --- .../features/message-search/MessageSearch.tsx | 1 + .../features/message-search/SearchFilters.tsx | 39 +++++++++++++++++-- src/app/pages/client/direct/Direct.tsx | 8 +++- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index b629d35fb..95e0e79b2 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -264,6 +264,7 @@ export function MessageSearch({ defaultRoomsFilterName={defaultRoomsFilterName} allowGlobal={allowGlobal} roomList={allRooms} + defaultRooms={rooms} selectedRooms={searchParamRooms} onSelectedRoomsChange={handleSelectedRoomsChange} global={searchPathSearchParams.global === 'true'} diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx index bb018da72..d7550161b 100644 --- a/src/app/features/message-search/SearchFilters.tsx +++ b/src/app/features/message-search/SearchFilters.tsx @@ -2,6 +2,7 @@ import type { ChangeEventHandler, MouseEventHandler } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { RectCords } from 'folds'; import { + Avatar, Box, Chip, Text, @@ -36,6 +37,8 @@ import type { DebounceOptions } from '$hooks/useDebounce'; import { useDebounce } from '$hooks/useDebounce'; import { VirtualTile } from '$components/virtualizer'; import { stopPropagation } from '$utils/keyboard'; +import { UserAvatar } from '$components/user-avatar'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import type { SearchHasType } from './useMessageSearch'; type OrderButtonProps = { @@ -388,8 +391,10 @@ const getMemberStr: SearchItemStrGetter = (member, query) => { const name = member.name ?? member.userId; return query ? [name, member.userId] : name; }; +const getMemberDisplayName = (member: RoomMember): string => member.name ?? member.userId; function SelectSenderButton({ roomList, selectedSenders, onChange }: SelectSenderButtonProps) { const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); const [menuAnchor, setMenuAnchor] = useState(); const scrollRef = useRef(null); @@ -407,7 +412,9 @@ function SelectSenderButton({ roomList, selectedSenders, onChange }: SelectSende } } } - return result.toSorted((a, b) => (a.name ?? a.userId).localeCompare(b.name ?? b.userId)); + return result.toSorted((a, b) => + getMemberDisplayName(a).localeCompare(getMemberDisplayName(b)) + ); }, [mx, roomList]); const [searchState, searchMembersRaw, resetSearch] = useAsyncSearch( @@ -516,11 +523,32 @@ function SelectSenderButton({ roomList, selectedSenders, onChange }: SelectSende size="300" radii="300" aria-pressed={selected} - before={} + before={ + + } + /> + + } > - {member.name ?? member.userId} + {getMemberDisplayName(member)} {member.userId} @@ -554,6 +582,7 @@ type SearchFiltersProps = { defaultRoomsFilterName: string; allowGlobal?: boolean; roomList: string[]; + defaultRooms: string[]; selectedRooms?: string[]; onSelectedRoomsChange: (selectedRooms?: string[]) => void; global?: boolean; @@ -569,6 +598,7 @@ export function SearchFilters({ defaultRoomsFilterName, allowGlobal, roomList, + defaultRooms, selectedRooms, onSelectedRoomsChange, global, @@ -580,6 +610,7 @@ export function SearchFilters({ senders, onSendersChange, }: SearchFiltersProps) { + const senderScope = selectedRooms && selectedRooms.length > 0 ? selectedRooms : defaultRooms; const mx = useMatrixClient(); return ( @@ -669,7 +700,7 @@ export function SearchFilters({ ))} diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index db2aa14ba..06eeab1a7 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -282,10 +282,14 @@ export function Direct() { as="span" grow="Yes" alignItems="Center" + justifyContent="Start" gap="200" - justifyContent="Center" > - + {!hideText && ( From 182fdbde88a4a49f51390fa8ac09aea40b892501 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 12:15:47 -0400 Subject: [PATCH 09/14] fix(search): fix DM NavLink blue text; extend has: in-memory search to unencrypted rooms --- .../features/message-search/MessageSearch.tsx | 2 +- .../message-search/useMessageSearch.ts | 33 ++++++++++++++++--- src/app/pages/client/direct/Direct.tsx | 3 +- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 95e0e79b2..ac4884b05 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -287,7 +287,7 @@ export function MessageSearch({ > - {`${inMemoryRoomCount} encrypted ${inMemoryRoomCount === 1 ? 'room' : 'rooms'} searched from local cache only.`} + {`${inMemoryRoomCount} ${inMemoryRoomCount === 1 ? 'room' : 'rooms'} searched from local cache only.`} )} diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index d0c4bc092..e12be80b6 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -142,12 +142,37 @@ export const useMessageSearch = (params: MessageSearchParams) => { : []; // When there's no text term, skip server search (server requires search_term). - // Only in-memory encrypted rooms are searchable by has: type alone. + // For has: filters, scan all rooms' in-memory timelines (encrypted + unencrypted). if (skipServerSearch || !term) { + let unencryptedMemoryGroups: ResultGroup[] = []; + let unencryptedRoomCount = 0; + if (hasHasTypes && isFirstPage) { + // For global search (serverRooms undefined), gather all non-encrypted joined rooms. + const unencryptedRooms = + serverRooms ?? + mx + .getRooms() + .filter((r) => !mx.isRoomEncrypted(r.roomId)) + .map((r) => r.roomId); + unencryptedRoomCount = unencryptedRooms.length; + if (unencryptedRooms.length > 0) { + unencryptedMemoryGroups = searchEncryptedRoomsInMemory( + mx, + '', + unencryptedRooms, + senders, + hasTypes + ); + } + } return { - highlights: term ? term.split(/\s+/).filter(Boolean) : [], - groups: filterGroupsByHasType(inMemoryGroups), - inMemoryRoomCount: encryptedRoomIds.length, + highlights: [], + groups: mergeSearchGroups( + filterGroupsByHasType(inMemoryGroups), + unencryptedMemoryGroups, + order + ), + inMemoryRoomCount: encryptedRoomIds.length + unencryptedRoomCount, }; } diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 06eeab1a7..58e11ea26 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -18,7 +18,7 @@ import { } from 'folds'; import { useVirtualizer } from '@tanstack/react-virtual'; import FocusTrap from 'focus-trap-react'; -import { NavLink, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { RoomEvent } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { factoryRoomIdByActivity } from '$utils/sort'; @@ -30,6 +30,7 @@ import { NavEmptyLayout, NavItem, NavItemContent, + NavLink, } from '$components/nav'; import { getDirectCreatePath, getDirectRoomPath, getDirectSearchPath } from '$pages/pathUtils'; import { getCanonicalAliasOrRoomId } from '$utils/matrix'; From 09e362f9ae89e64743f6d0edd85d544a0fbd2296 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 13:02:18 -0400 Subject: [PATCH 10/14] fix(search): scope has: scan and room picker to context; add global to DM search --- src/app/features/message-search/MessageSearch.tsx | 2 +- .../features/message-search/useMessageSearch.ts | 15 +++++++++------ src/app/pages/client/direct/Search.tsx | 1 + 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index ac4884b05..ac70c68d6 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -263,7 +263,7 @@ export function MessageSearch({ { let unencryptedMemoryGroups: ResultGroup[] = []; let unencryptedRoomCount = 0; if (hasHasTypes && isFirstPage) { - // For global search (serverRooms undefined), gather all non-encrypted joined rooms. + // When scoped (rooms defined), use only unencrypted rooms within scope (may be empty + // when all scoped rooms are encrypted). When global (rooms undefined), fall back to + // all non-encrypted joined rooms. const unencryptedRooms = - serverRooms ?? - mx - .getRooms() - .filter((r) => !mx.isRoomEncrypted(r.roomId)) - .map((r) => r.roomId); + rooms !== undefined + ? (serverRooms ?? []) + : mx + .getRooms() + .filter((r) => !mx.isRoomEncrypted(r.roomId)) + .map((r) => r.roomId); unencryptedRoomCount = unencryptedRooms.length; if (unencryptedRooms.length > 0) { unencryptedMemoryGroups = searchEncryptedRoomsInMemory( diff --git a/src/app/pages/client/direct/Search.tsx b/src/app/pages/client/direct/Search.tsx index 9c980ae79..bc6eb4620 100644 --- a/src/app/pages/client/direct/Search.tsx +++ b/src/app/pages/client/direct/Search.tsx @@ -41,6 +41,7 @@ export function DirectSearch() { From 1843f9e1684dd193d343f9baf753e53008f1af8a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 13:14:44 -0400 Subject: [PATCH 11/14] perf(avatar): share SVG blob cache between room and user avatars --- .../components/room-avatar/AvatarImage.tsx | 87 +++++++++++++++---- src/app/components/user-avatar/UserAvatar.tsx | 6 +- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/app/components/room-avatar/AvatarImage.tsx b/src/app/components/room-avatar/AvatarImage.tsx index f322bce0e..df29fd263 100644 --- a/src/app/components/room-avatar/AvatarImage.tsx +++ b/src/app/components/room-avatar/AvatarImage.tsx @@ -6,24 +6,56 @@ import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import * as css from './RoomAvatar.css'; -type AvatarImageProps = { - src: string; - alt?: string; - uniformIcons?: boolean; - onError: () => void; -}; +// 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(); -export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProps) { - const [uniformIconsSetting] = useSetting(settingsAtom, 'uniformIcons'); - const [image, setImage] = useState(undefined); - const [processedSrc, setProcessedSrc] = useState(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(); +} + +/** + * Resolves an avatar HTTP URL through the SVG blob cache. + * - If `src` is already cached as a processed blob URL, returns it immediately. + * - If `src` is an SVG, fetches, sanitises animations, stores in cache, and + * returns the blob URL (falls back to raw `src` on error). + * - For non-SVG images, returns `src` unchanged (no extra processing needed). + * - If `src` is `undefined`, returns `undefined`. + * + * Sharing this hook between `AvatarImage` (room avatars) and `UserAvatar` + * (user avatars) means SVG avatars are processed and cached only once, + * regardless of which component first encounters them. + */ +export function useProcessedAvatarSrc(src: string | undefined): string | undefined { + const [processedSrc, setProcessedSrc] = useState(src); useEffect(() => { + if (!src) { + setProcessedSrc(undefined); + return; + } + let isMounted = true; - let objectUrl: string | null = null; + + // Reset to raw src while we check/process, so stale blob URLs never linger. + setProcessedSrc(src); + + const cachedBlobUrl = svgBlobCache.get(src); + if (cachedBlobUrl) { + setProcessedSrc(cachedBlobUrl); + return () => { + isMounted = false; + }; + } const processImage = async () => { try { @@ -46,8 +78,10 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp const newSvgString = serializer.serializeToString(doc); const blob = new Blob([newSvgString], { type: 'image/svg+xml' }); - objectUrl = URL.createObjectURL(blob); - if (isMounted) setProcessedSrc(objectUrl); + const blobUrl = URL.createObjectURL(blob); + // Store in module cache so future remounts skip processing. + svgBlobCache.set(src, blobUrl); + if (isMounted) setProcessedSrc(blobUrl); } else if (isMounted) setProcessedSrc(src); } catch { if (isMounted) setProcessedSrc(src); @@ -58,12 +92,29 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp return () => { isMounted = false; - if (objectUrl) { - URL.revokeObjectURL(objectUrl); - } + // Blob URLs are retained in svgBlobCache — do not revoke them here so + // that subsequent remounts can use the cached result without re-fetching. }; }, [src]); + return processedSrc; +} + +type AvatarImageProps = { + src: string; + alt?: string; + uniformIcons?: boolean; + onError: () => void; +}; + +export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProps) { + const [uniformIconsSetting] = useSetting(settingsAtom, 'uniformIcons'); + const [image, setImage] = useState(undefined); + const processedSrc = useProcessedAvatarSrc(src) ?? src; + + const useUniformIcons = uniformIconsSetting && uniformIcons === true; + const normalizedBg = useUniformIcons && image ? bgColorImg(image) : undefined; + const handleLoad: ReactEventHandler = (evt) => { evt.currentTarget.setAttribute('data-image-loaded', 'true'); setImage(evt.currentTarget); diff --git a/src/app/components/user-avatar/UserAvatar.tsx b/src/app/components/user-avatar/UserAvatar.tsx index 78288d393..2a0c9fd2d 100644 --- a/src/app/components/user-avatar/UserAvatar.tsx +++ b/src/app/components/user-avatar/UserAvatar.tsx @@ -3,6 +3,7 @@ import type { ReactEventHandler, ReactNode } from 'react'; import { useEffect, useState } from 'react'; import classNames from 'classnames'; import colorMXID from '$utils/colorMXID'; +import { useProcessedAvatarSrc } from '$components/room-avatar/AvatarImage'; import * as css from './UserAvatar.css'; type UserAvatarProps = { @@ -19,12 +20,13 @@ const handleImageLoad: ReactEventHandler = (evt) => { export function UserAvatar({ className, userId, src, alt, renderFallback }: UserAvatarProps) { const [error, setError] = useState(false); + const processedSrc = useProcessedAvatarSrc(src); useEffect(() => { setError(false); }, [src]); - if (!src || error) { + if (!processedSrc || error) { return ( setError(true)} onLoad={handleImageLoad} From 3e53cd82968256b47d17e9a9fe0e018a51265925 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 13:51:29 -0400 Subject: [PATCH 12/14] feat(search): add > prefix for message search in quick-switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing > in the room search modal switches to message search mode. A 'Search messages: ' 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). --- src/app/features/search/Search.tsx | 361 ++++++++++++++++++----------- 1 file changed, 220 insertions(+), 141 deletions(-) diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx index 0c928aa38..3e514d836 100644 --- a/src/app/features/search/Search.tsx +++ b/src/app/features/search/Search.tsx @@ -49,8 +49,10 @@ import { useKeyDown } from '$hooks/useKeyDown'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { KeySymbol } from '$utils/key-symbol'; import { isMacOS } from '$utils/user-agent'; +import { useNavigate, useLocation } from 'react-router-dom'; import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; import { getMxIdServer } from '$utils/mxIdHelper'; +import { getHomeSearchPath, getDirectSearchPath, getSpaceSearchPath } from '$pages/pathUtils'; enum SearchRoomType { Rooms = '#', @@ -203,6 +205,32 @@ export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps const listFocus = useListFocusIndex(roomsToRender.length, 0); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const [messageSearchQuery, setMessageSearchQuery] = useState(null); + + const handleNavigateMessageSearch = useCallback( + (query: string) => { + const sp = new URLSearchParams(); + if (query) sp.set('term', query); + const qs = sp.toString() ? `?${sp.toString()}` : ''; + + let basePath: string; + if (pathname.startsWith('/direct/')) { + basePath = getDirectSearchPath(); + } else if (!pathname.startsWith('/home/')) { + const spaceIdOrAlias = decodeURIComponent(pathname.split('/').find(Boolean) ?? ''); + basePath = spaceIdOrAlias ? getSpaceSearchPath(spaceIdOrAlias) : getHomeSearchPath(); + } else { + basePath = getHomeSearchPath(); + } + + navigate(`${basePath}${qs}`); + requestClose(); + }, + [pathname, navigate, requestClose] + ); + const queryHighlighRegex = result?.query ? makeHighlightRegex(result.query.split(' ')) : undefined; @@ -223,6 +251,15 @@ export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps const target = evt.currentTarget; let value = target.value.trim(); + + if (!pickRoom && value.startsWith('>')) { + setMessageSearchQuery(value.slice(1).trimStart()); + setSearchRoomType(undefined); + resetSearch(); + return; + } + + setMessageSearchQuery(null); const prefix = value.match(/^[#@*]/)?.[0]; const searchType = typeof prefix === 'string' && getSearchPrefixToRoomType(prefix); if (searchType) { @@ -240,6 +277,10 @@ export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps }; const handleInputKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('enter', evt) && messageSearchQuery !== null) { + handleNavigateMessageSearch(messageSearchQuery); + return; + } const roomId = roomsToRender[listFocus.index]; if (isKeyHotkey('enter', evt) && roomId) { handleActivateRoom(roomId, spaces.includes(roomId)); @@ -340,40 +381,7 @@ export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps /> - {roomsToRender.length === 0 && ( - - - {pickRoom - ? result - ? 'No Match Found' - : pickRoom.eligibleRoomIds.length === 0 - ? 'No rooms to forward to' - : 'No rooms match this filter' - : result - ? 'No Match Found' - : 'No Rooms'} - - - {pickRoom - ? result - ? `No match found for "${result.query}".` - : pickRoom.eligibleRoomIds.length === 0 - ? 'You cannot send messages in any joined room yet.' - : 'Try another search, or use # for group rooms and @ for direct messages.' - : result - ? `No match found for "${result.query}".` - : 'You do not have any Rooms to display yet.'} - - - )} - {roomsToRender.length > 0 && ( + {messageSearchQuery !== null && !pickRoom ? (
- {roomsToRender.map((roomId, index) => { - const room = getRoom(roomId); - if (!room) return null; - - const dm = mDirects.has(roomId); - const dmUserId = dm && getDmUserId(roomId, getRoom, mx.getSafeUserId()); - const dmUsername = dmUserId && getMxIdLocalPart(dmUserId); - const dmUserServer = dmUserId && getMxIdServer(dmUserId); - - const allParents = getAllParents(roomToParents, roomId); - const orphanParents = - allParents && orphanSpaces.filter((o) => allParents.has(o)); - const perfectOrphanParent = - orphanParents && guessPerfectParent(mx, roomId, orphanParents); - - const exactParents = roomToParents.get(roomId); - const perfectParent = - exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents)); - - const unread = roomToUnread.get(roomId); - - return ( - - {dmUserServer && ( - - {dmUserServer} - - )} - {!dm && perfectOrphanParent && ( - - {getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent} - - )} - {unread && ( - - 0} - count={unread.highlight > 0 ? unread.highlight : unread.total} - /> - - )} - - } - before={ - - {dm || room.isSpaceRoom() ? ( - ( - - {nameInitials(room.name)} - - )} - /> - ) : ( - - )} - - } - > - - - {queryHighlighRegex - ? highlightText(queryHighlighRegex, [room.name]) - : room.name} - - {dmUsername && ( - - @ - {queryHighlighRegex - ? highlightText(queryHighlighRegex, [dmUsername]) - : dmUsername} - - )} - {!dm && perfectParent && perfectParent !== perfectOrphanParent && ( - - — {getRoom(perfectParent)?.name ?? perfectParent} - - )} - - - ); - })} + handleNavigateMessageSearch(messageSearchQuery)} + before={ + + + + } + > + + + {messageSearchQuery + ? `Search messages: "${messageSearchQuery}"` + : 'Search messages'} + + +
+ ) : ( + <> + {roomsToRender.length === 0 && ( + + + {pickRoom + ? result + ? 'No Match Found' + : pickRoom.eligibleRoomIds.length === 0 + ? 'No rooms to forward to' + : 'No rooms match this filter' + : result + ? 'No Match Found' + : 'No Rooms'} + + + {pickRoom + ? result + ? `No match found for "${result.query}".` + : pickRoom.eligibleRoomIds.length === 0 + ? 'You cannot send messages in any joined room yet.' + : 'Try another search, or use # for group rooms and @ for direct messages.' + : result + ? `No match found for "${result.query}".` + : 'You do not have any Rooms to display yet.'} + + + )} + {roomsToRender.length > 0 && ( + +
+ {roomsToRender.map((roomId, index) => { + const room = getRoom(roomId); + if (!room) return null; + + const dm = mDirects.has(roomId); + const dmUserId = dm && getDmUserId(roomId, getRoom, mx.getSafeUserId()); + const dmUsername = dmUserId && getMxIdLocalPart(dmUserId); + const dmUserServer = dmUserId && getMxIdServer(dmUserId); + + const allParents = getAllParents(roomToParents, roomId); + const orphanParents = + allParents && orphanSpaces.filter((o) => allParents.has(o)); + const perfectOrphanParent = + orphanParents && guessPerfectParent(mx, roomId, orphanParents); + + const exactParents = roomToParents.get(roomId); + const perfectParent = + exactParents && + guessPerfectParent(mx, roomId, Array.from(exactParents)); + + const unread = roomToUnread.get(roomId); + + return ( + + {dmUserServer && ( + + {dmUserServer} + + )} + {!dm && perfectOrphanParent && ( + + + {getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent} + + + )} + {unread && ( + + 0} + count={ + unread.highlight > 0 ? unread.highlight : unread.total + } + /> + + )} + + } + before={ + + {dm || room.isSpaceRoom() ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + } + > + + + {queryHighlighRegex + ? highlightText(queryHighlighRegex, [room.name]) + : room.name} + + {dmUsername && ( + + @ + {queryHighlighRegex + ? highlightText(queryHighlighRegex, [dmUsername]) + : dmUsername} + + )} + {!dm && perfectParent && perfectParent !== perfectOrphanParent && ( + + — {getRoom(perfectParent)?.name ?? perfectParent} + + )} + + + ); + })} +
+
+ )} + )}
@@ -500,8 +579,8 @@ export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps ) : ( <> - Type # for rooms, @ for DMs and * for spaces. Hotkey:{' '} - {isMacOS() ? KeySymbol.Command : 'Ctrl'} + k + Type # for rooms, @ for DMs, * for spaces and {'>'}{' '} + for messages. Hotkey: {isMacOS() ? KeySymbol.Command : 'Ctrl'} + k {' / '} {isMacOS() ? KeySymbol.Command : 'Ctrl'} + f From 04927761a7a2cb25b4682fc8cf2101b973c6be1d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 14:25:02 -0400 Subject: [PATCH 13/14] chore: add changeset --- .changeset/encrypted-search-memory.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/encrypted-search-memory.md diff --git a/.changeset/encrypted-search-memory.md b/.changeset/encrypted-search-memory.md new file mode 100644 index 000000000..8b4a3ae00 --- /dev/null +++ b/.changeset/encrypted-search-memory.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add in-memory encrypted message search with room/DM scoping, member picker avatars, and > quick-switcher prefix. From 82db5a3bee1fc60b89cb5e65221cf08e7be57e67 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 20 May 2026 16:35:49 -0400 Subject: [PATCH 14/14] fix: address copilot review comments on feat/encrypted-search-memory - Skip events without a valid event_id in searchRoomTimeline instead of using an empty-string fallback (avoids key collisions and non-clickable results) - Fix global search mode to pass allRooms to SearchFilters instead of the scoped rooms list (allows selecting any joined room when global=true) - Use useSelectedSpace() for message-search URL inference in Search.tsx (avoids misidentifying /explore, /inbox, etc. as space IDs) - Clarify useClientConfig.ts JSDoc: encryptedSearch is an operator kill-switch defaulting to enabled; the user setting defaults to disabled - Derive isSearching from term||hasTypes for consistent UI active/loading states - Add hasTypes filter unit tests covering all has: types (image, file, audio, video, link regex), OR logic, combined term+hasTypes, and exclusion --- .../features/message-search/MessageSearch.tsx | 26 +++-- .../searchEncryptedRooms.test.ts | 94 ++++++++++++++++++- .../message-search/searchEncryptedRooms.ts | 2 + src/app/features/search/Search.tsx | 7 +- src/app/hooks/useClientConfig.ts | 6 +- 5 files changed, 121 insertions(+), 14 deletions(-) diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index ac70c68d6..c076c9cee 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -97,8 +97,9 @@ export function MessageSearch({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchPathSearchParams.has]); + const isGlobal = searchPathSearchParams.global === 'true'; + const msgSearchParams: MessageSearchParams = useMemo(() => { - const isGlobal = searchPathSearchParams.global === 'true'; const defaultRooms = isGlobal ? undefined : rooms; return { @@ -109,6 +110,7 @@ export function MessageSearch({ hasTypes: searchParamHasTypes, }; }, [ + isGlobal, searchPathSearchParams, searchParamRooms, searchParamsSenders, @@ -117,6 +119,10 @@ export function MessageSearch({ senders, ]); + const isSearching = + !!msgSearchParams.term || + (!!msgSearchParams.hasTypes && msgSearchParams.hasTypes.length > 0); + const searchMessages = useMessageSearch(msgSearchParams); const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ @@ -254,7 +260,7 @@ export function MessageSearch({ )} - {!msgSearchParams.term && status === 'pending' && ( + {!isSearching && status === 'pending' && ( )} - {msgSearchParams.term && groups.length === 0 && status === 'success' && ( + {isSearching && groups.length === 0 && status === 'success' && ( - No results found for {`"${msgSearchParams.term}"`} + {msgSearchParams.term ? ( + <>No results found for {`"${msgSearchParams.term}"`} + ) : ( + 'No results found.' + )} )} - {((msgSearchParams.term && status === 'pending') || + {((isSearching && status === 'pending') || (groups.length > 0 && vItems.length === 0)) && ( {Array.from({ length: 8 }).map(() => ( diff --git a/src/app/features/message-search/searchEncryptedRooms.test.ts b/src/app/features/message-search/searchEncryptedRooms.test.ts index aac0f2f90..062e41273 100644 --- a/src/app/features/message-search/searchEncryptedRooms.test.ts +++ b/src/app/features/message-search/searchEncryptedRooms.test.ts @@ -13,6 +13,7 @@ import type { ResultGroup } from './useMessageSearch'; function makeEvent(overrides: { type?: string; body?: string; + msgtype?: string; sender?: string; ts?: number; id?: string; @@ -20,7 +21,7 @@ function makeEvent(overrides: { }): MatrixEvent { return { getType: () => overrides.type ?? EventType.RoomMessage, - getContent: () => ({ body: overrides.body ?? '', msgtype: 'm.text' }), + getContent: () => ({ body: overrides.body ?? '', msgtype: overrides.msgtype ?? 'm.text' }), getSender: () => overrides.sender ?? '@alice:example.org', getTs: () => overrides.ts ?? 1000, getId: () => overrides.id ?? '$event1', @@ -99,6 +100,97 @@ describe('searchRoomTimeline', () => { expect(group!.items[0]!.event.type).toBe(EventType.RoomMessage); expect(group!.items[0]!.event.content.body).toBe('secret message'); }); + + describe('hasTypes filter', () => { + it('returns image events when hasTypes includes "image"', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'photo.png', msgtype: 'm.image', id: '$img' }), + makeEvent({ body: 'hello', msgtype: 'm.text', id: '$txt' }), + ]); + const group = searchRoomTimeline(room, '', undefined, ['image']); + expect(group).toBeDefined(); + expect(group!.items).toHaveLength(1); + expect(group!.items[0]!.event.event_id).toBe('$img'); + }); + + it('returns file events when hasTypes includes "file"', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'doc.pdf', msgtype: 'm.file', id: '$file' }), + makeEvent({ body: 'hello', msgtype: 'm.text', id: '$txt' }), + ]); + const group = searchRoomTimeline(room, '', undefined, ['file']); + expect(group!.items).toHaveLength(1); + expect(group!.items[0]!.event.event_id).toBe('$file'); + }); + + it('returns audio events when hasTypes includes "audio"', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'voice.ogg', msgtype: 'm.audio', id: '$audio' }), + ]); + const group = searchRoomTimeline(room, '', undefined, ['audio']); + expect(group!.items).toHaveLength(1); + expect(group!.items[0]!.event.event_id).toBe('$audio'); + }); + + it('returns video events when hasTypes includes "video"', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'clip.mp4', msgtype: 'm.video', id: '$video' }), + ]); + const group = searchRoomTimeline(room, '', undefined, ['video']); + expect(group!.items).toHaveLength(1); + expect(group!.items[0]!.event.event_id).toBe('$video'); + }); + + it('returns events containing a URL when hasTypes includes "link"', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'check https://example.com out', msgtype: 'm.text', id: '$link' }), + makeEvent({ body: 'no url here', msgtype: 'm.text', id: '$nolink' }), + ]); + const group = searchRoomTimeline(room, '', undefined, ['link']); + expect(group!.items).toHaveLength(1); + expect(group!.items[0]!.event.event_id).toBe('$link'); + }); + + it('also matches http:// links for "link" type', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'see http://example.com', msgtype: 'm.text', id: '$http' }), + ]); + const group = searchRoomTimeline(room, '', undefined, ['link']); + expect(group!.items).toHaveLength(1); + expect(group!.items[0]!.event.event_id).toBe('$http'); + }); + + it('excludes non-matching message types', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'hello', msgtype: 'm.text', id: '$txt' }), + ]); + expect(searchRoomTimeline(room, '', undefined, ['image'])).toBeUndefined(); + }); + + it('matches multiple hasTypes in a single call (OR logic)', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'photo.png', msgtype: 'm.image', id: '$img' }), + makeEvent({ body: 'doc.pdf', msgtype: 'm.file', id: '$file' }), + makeEvent({ body: 'plain text', msgtype: 'm.text', id: '$txt' }), + ]); + const group = searchRoomTimeline(room, '', undefined, ['image', 'file']); + expect(group!.items).toHaveLength(2); + const ids = group!.items.map((i) => i.event.event_id); + expect(ids).toContain('$img'); + expect(ids).toContain('$file'); + }); + + it('can combine term and hasTypes filters', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'vacation photo', msgtype: 'm.image', id: '$match' }), + makeEvent({ body: 'random photo', msgtype: 'm.image', id: '$nomatch' }), + makeEvent({ body: 'vacation text', msgtype: 'm.text', id: '$text' }), + ]); + const group = searchRoomTimeline(room, 'vacation', undefined, ['image']); + expect(group!.items).toHaveLength(1); + expect(group!.items[0]!.event.event_id).toBe('$match'); + }); + }); }); describe('searchEncryptedRoomsInMemory', () => { diff --git a/src/app/features/message-search/searchEncryptedRooms.ts b/src/app/features/message-search/searchEncryptedRooms.ts index 3586eaeab..a86118246 100644 --- a/src/app/features/message-search/searchEncryptedRooms.ts +++ b/src/app/features/message-search/searchEncryptedRooms.ts @@ -75,6 +75,8 @@ export function searchRoomTimeline( if (hasTypes && hasTypes.length > 0 && !mEventMatchesHasTypes(mEvent, hasTypes)) continue; + if (!mEvent.getId()) continue; + if (lowerTerm !== '') { const body: string = mEvent.getContent().body ?? ''; if (!body || !body.toLowerCase().includes(lowerTerm)) continue; diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx index 3e514d836..7d3cc5d8b 100644 --- a/src/app/features/search/Search.tsx +++ b/src/app/features/search/Search.tsx @@ -218,9 +218,8 @@ export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps let basePath: string; if (pathname.startsWith('/direct/')) { basePath = getDirectSearchPath(); - } else if (!pathname.startsWith('/home/')) { - const spaceIdOrAlias = decodeURIComponent(pathname.split('/').find(Boolean) ?? ''); - basePath = spaceIdOrAlias ? getSpaceSearchPath(spaceIdOrAlias) : getHomeSearchPath(); + } else if (selectedSpaceId) { + basePath = getSpaceSearchPath(selectedSpaceId); } else { basePath = getHomeSearchPath(); } @@ -228,7 +227,7 @@ export function RoomSearchModal({ requestClose, pickRoom }: RoomSearchModalProps navigate(`${basePath}${qs}`); requestClose(); }, - [pathname, navigate, requestClose] + [pathname, selectedSpaceId, navigate, requestClose] ); const queryHighlighRegex = result?.query diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 94a6473bb..4eddfbb72 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -53,7 +53,11 @@ export type ClientConfig = { features?: { polls?: boolean; - /** Enable in-memory search for encrypted rooms (default: true). */ + /** Operator kill-switch for in-memory search of encrypted rooms. + * Defaults to enabled (true) when not configured. + * Note: the user-facing setting (`encryptedSearch` in Settings) defaults to disabled, + * so users must opt in even when this flag is on. + */ encryptedSearch?: boolean; }; };