From 00d8b003c5d6167c8841e62b71e785c9293f49ee Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 09:11:25 -0400 Subject: [PATCH 01/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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 e1754413dd2eac5147cfbee1eb312bce2610badb Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 14:34:34 -0400 Subject: [PATCH 13/25] feat(search): Phase 2 IDB-backed search index for encrypted rooms - Install MiniSearch 7.2.0 for TypeScript-native full-text search - Add idbSearchIndex and searchIndexMessageLimit to settings atom - Create SearchIndexToggle experimental toggle (second opt-in) - Add searchWorker.ts Web Worker owning MiniSearch index + IDB persistence - IDB schema: 'index' store (serialised index + room queues), 'backfill' store - Multi-tab write safety via navigator.locks - Debounced flush (5s) + beforeunload flush - Per-room LRU eviction when queue exceeds 110% of configured limit - Create useSearchIndex.tsx React context + hook - Live indexing via RoomEvent.Timeline listener - Headless EventTimelineSet backfill in idle callbacks - Query, getStats, clearIndex public API - Wrap ClientNonUIFeatures in SearchIndexProvider - Add SearchIndexCache to Developer Tools: stats, per-room limit selector, backfill progress, clear button (auto-refreshes every 5s) - Wire useMessageSearch to use IDB index when idbSearchIndex is enabled - Export EMPTY_CONTEXT from searchEncryptedRooms for reuse --- package.json | 1 + pnpm-lock.yaml | 8 + .../message-search/searchEncryptedRooms.ts | 2 +- .../message-search/useMessageSearch.ts | 51 +- .../settings/developer-tools/DevelopTools.tsx | 2 + .../developer-tools/SearchIndexCache.tsx | 117 +++++ .../settings/experimental/Experimental.tsx | 2 + .../experimental/SearchIndexToggle.tsx | 33 ++ src/app/features/settings/settingsLink.ts | 2 +- src/app/hooks/useSearchIndex.tsx | 436 ++++++++++++++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 5 +- src/app/plugins/search-worker/searchWorker.ts | 332 +++++++++++++ src/app/plugins/search-worker/types.ts | 79 ++++ src/app/state/settings.ts | 4 + 14 files changed, 1065 insertions(+), 9 deletions(-) create mode 100644 src/app/features/settings/developer-tools/SearchIndexCache.tsx create mode 100644 src/app/features/settings/experimental/SearchIndexToggle.tsx create mode 100644 src/app/hooks/useSearchIndex.tsx create mode 100644 src/app/plugins/search-worker/searchWorker.ts create mode 100644 src/app/plugins/search-worker/types.ts diff --git a/package.json b/package.json index 2d605aebc..1db4173a0 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "marked": "^18.0.2", "matrix-js-sdk": "^38.4.0", "matrix-widget-api": "^1.16.1", + "minisearch": "^7.2.0", "pdfjs-dist": "^5.4.624", "react": "^18.3.1", "react-aria": "^3.46.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee7836185..2d61b2251 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,9 @@ importers: matrix-widget-api: specifier: ^1.16.1 version: 1.17.0 + minisearch: + specifier: ^7.2.0 + version: 7.2.0 pdfjs-dist: specifier: ^5.4.624 version: 5.5.207 @@ -4223,6 +4226,9 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + mlly@1.8.1: resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} @@ -9462,6 +9468,8 @@ snapshots: minipass@7.1.3: {} + minisearch@7.2.0: {} + mlly@1.8.1: dependencies: acorn: 8.16.0 diff --git a/src/app/features/message-search/searchEncryptedRooms.ts b/src/app/features/message-search/searchEncryptedRooms.ts index 3586eaeab..92f6cb425 100644 --- a/src/app/features/message-search/searchEncryptedRooms.ts +++ b/src/app/features/message-search/searchEncryptedRooms.ts @@ -28,7 +28,7 @@ function mEventMatchesHasTypes(mEvent: MatrixEvent, hasTypes: SearchHasType[]): } // Shared empty context — in-memory results have no surrounding-event context. -const EMPTY_CONTEXT: IResultContext = { +export const EMPTY_CONTEXT: IResultContext = { events_before: [], events_after: [], profile_info: {}, diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index 783d0d1b9..ad3568332 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -15,11 +15,38 @@ import { searchEncryptedRoomsInMemory, partitionRoomsByEncryption, mergeSearchGroups, + EMPTY_CONTEXT, } from './searchEncryptedRooms'; import type { SearchHasType } from './searchEncryptedRooms'; +import type { IndexableEvent } from '$plugins/search-worker/types'; +import { useSearchIndex } from '$hooks/useSearchIndex'; export type { SearchHasType }; +/** Convert IDB-indexed events back to the ResultGroup format used by the search UI. */ +function idbEventsToGroups(events: IndexableEvent[]): ResultGroup[] { + const byRoom = new Map(); + for (const ev of events) { + const item: ResultItem = { + rank: 1, + event: { + event_id: ev.eventId, + room_id: ev.roomId, + sender: ev.sender, + origin_server_ts: ev.ts, + content: { msgtype: 'm.text', body: ev.body }, + type: 'm.room.message', + unsigned: {}, + } as IEventWithRoomId, + context: EMPTY_CONTEXT as IResultContext, + }; + const arr = byRoom.get(ev.roomId) ?? []; + arr.push(item); + byRoom.set(ev.roomId, arr); + } + return Array.from(byRoom.entries()).map(([roomId, items]) => ({ roomId, items })); +} + export type ResultItem = { rank: number; event: IEventWithRoomId; @@ -87,6 +114,7 @@ export const useMessageSearch = (params: MessageSearchParams) => { const mx = useMatrixClient(); const { features } = useClientConfig(); const settings = useAtomValue(settingsAtom); + const searchIndex = useSearchIndex(); const { term, order, rooms, senders, hasTypes } = params; const filterGroupsByHasType = useCallback( @@ -129,17 +157,28 @@ export const useMessageSearch = (params: MessageSearchParams) => { // Operator kill switch takes priority; user toggle controls the rest. const encryptedSearchEnabled = features?.encryptedSearch !== false && settings.encryptedSearch; + // Use IDB index when the user has enabled it and the index is ready. + const useIdbSearch = settings.idbSearchIndex && searchIndex?.isReady === true; 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, hasTypes) - : []; + // For IDB search: only run on first page (IDB has no pagination cursor here). + // Prefer IDB when available; fall back to in-memory live timeline. + let inMemoryGroups: ResultGroup[] = []; + if (encryptedSearchEnabled && isFirstPage && encryptedRoomIds.length > 0) { + if (useIdbSearch && term) { + const idbEvents = await searchIndex!.query(term, { + roomIds: encryptedRoomIds, + senders, + }); + inMemoryGroups = idbEventsToGroups(idbEvents); + } else { + inMemoryGroups = searchEncryptedRoomsInMemory(mx, term ?? '', encryptedRoomIds, senders, hasTypes); + } + } // When there's no text term, skip server search (server requires search_term). // For has: filters, scan all rooms' in-memory timelines (encrypted + unencrypted). @@ -230,6 +269,8 @@ export const useMessageSearch = (params: MessageSearchParams) => { mx, features, settings.encryptedSearch, + settings.idbSearchIndex, + searchIndex, term, order, rooms, diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 100119726..969d9fe1c 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -15,6 +15,7 @@ import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; import { DebugLogViewer } from './DebugLogViewer'; import { SentrySettings } from './SentrySettings'; +import { SearchIndexCache } from './SearchIndexCache'; type DeveloperToolsProps = { requestBack?: () => void; @@ -127,6 +128,7 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} + {developerTools && } diff --git a/src/app/features/settings/developer-tools/SearchIndexCache.tsx b/src/app/features/settings/developer-tools/SearchIndexCache.tsx new file mode 100644 index 000000000..28a2435b2 --- /dev/null +++ b/src/app/features/settings/developer-tools/SearchIndexCache.tsx @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Box, Button, Text } from 'folds'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { useSearchIndex } from '$hooks/useSearchIndex'; +import type { SearchIndexStats } from '$hooks/useSearchIndex'; +import { SequenceCardStyle } from '$features/settings/styles.css'; + +const LIMIT_OPTIONS: Array<{ label: string; value: number }> = [ + { label: '500 messages', value: 500 }, + { label: '1,000 messages', value: 1000 }, + { label: '2,000 messages (default)', value: 2000 }, + { label: '5,000 messages', value: 5000 }, + { label: 'Unlimited', value: Number.MAX_SAFE_INTEGER }, +]; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function SearchIndexCache() { + const [idbSearchIndex] = useSetting(settingsAtom, 'idbSearchIndex'); + const [searchIndexMessageLimit, setSearchIndexMessageLimit] = useSetting( + settingsAtom, + 'searchIndexMessageLimit' + ); + const searchIndex = useSearchIndex(); + + const [stats, setStats] = useState(null); + const [clearing, setClearing] = useState(false); + + const refreshStats = useCallback(async () => { + if (!searchIndex?.isReady) return; + const s = await searchIndex.getStats(); + setStats(s); + }, [searchIndex]); + + useEffect(() => { + void refreshStats(); + const id = window.setInterval(() => void refreshStats(), 5000); + return () => window.clearInterval(id); + }, [refreshStats]); + + const handleClear = useCallback(async () => { + if (!searchIndex) return; + setClearing(true); + await searchIndex.clearIndex(); + setStats(null); + setClearing(false); + }, [searchIndex]); + + if (!idbSearchIndex) return null; + + return ( + + Encrypted Search Index + + + {searchIndex?.isBackfilling && ( + + )} + setSearchIndexMessageLimit(Number(e.target.value))} + style={{ padding: '4px 8px', borderRadius: '4px' }} + > + {LIMIT_OPTIONS.map((opt) => ( + + ))} + + } + /> + void handleClear()} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + disabled={clearing || !searchIndex?.isReady} + > + {clearing ? 'Clearing…' : 'Clear'} + + } + /> + + + ); +} diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index ea72f042c..b993235cf 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -11,6 +11,7 @@ import { SettingsSectionPage } from '../SettingsSectionPage'; import { BandwidthSavingEmojis } from './BandwithSavingEmojis'; import { MSC4268HistoryShare } from './MSC4268HistoryShare'; import { EncryptedSearch } from './EncryptedSearch'; +import { SearchIndexToggle } from './SearchIndexToggle'; function PersonaToggle() { const [showPersonaSetting, setShowPersonaSetting] = useSetting( @@ -61,6 +62,7 @@ export function Experimental({ requestBack, requestClose }: Readonly + diff --git a/src/app/features/settings/experimental/SearchIndexToggle.tsx b/src/app/features/settings/experimental/SearchIndexToggle.tsx new file mode 100644 index 000000000..7d4e3dda0 --- /dev/null +++ b/src/app/features/settings/experimental/SearchIndexToggle.tsx @@ -0,0 +1,33 @@ +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { Box, Switch, Text } from 'folds'; +import { SequenceCardStyle } from '../styles.css'; + +export function SearchIndexToggle() { + const [idbSearchIndex, setIdbSearchIndex] = useSetting(settingsAtom, 'idbSearchIndex'); + + return ( + + Encrypted Search Index + + + } + /> + + + ); +} diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index 6cca050a5..04d20590d 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -181,7 +181,7 @@ const settingsLinkFocusIdsBySection: Record Promise; + /** Request current stats from the worker. */ + getStats: () => Promise; + /** Wipe the index + IDB. */ + clearIndex: () => Promise; + /** True once the worker has hydrated from IDB and is ready to accept queries. */ + isReady: boolean; + /** True while background backfill is actively running. */ + isBackfilling: boolean; +}; + +// ── Context ────────────────────────────────────────────────────────────────── + +const SearchIndexContext = createContext(null); + +export function useSearchIndex(): SearchIndexCtx | null { + return useContext(SearchIndexContext); +} + +// ── Idle scheduler ─────────────────────────────────────────────────────────── + +function scheduleIdle(cb: () => void): () => void { + if (typeof requestIdleCallback === 'function') { + const id = requestIdleCallback(cb, { timeout: 5000 }); + return () => cancelIdleCallback(id); + } + const id = setTimeout(cb, 200); + return () => clearTimeout(id); +} + +// ── Event conversion ────────────────────────────────────────────────────────── + +function toIndexableEvent(mEvent: MatrixEvent, roomId: string): IndexableEvent | null { + const eventId = mEvent.getId(); + if (!eventId) return null; + // Skip still-encrypted and redacted events + if (mEvent.getType() === 'm.room.encrypted') return null; + if (mEvent.getType() !== (EventType.RoomMessage as string)) return null; + if (mEvent.isRedacted()) return null; + const body: string = (mEvent.getContent<{ body?: string }>()).body ?? ''; + if (!body.trim()) return null; + const sender = mEvent.getSender(); + if (!sender) return null; + return { eventId, roomId, sender, body, ts: mEvent.getTs() }; +} + +// ── Provider ───────────────────────────────────────────────────────────────── + +const BACKFILL_PAGE_SIZE = 50; + +type PendingQuery = { + resolve: (events: IndexableEvent[]) => void; + reject: (err: unknown) => void; +}; + +type PendingStats = { + backfillingRoomCount: number; + resolve: (stats: SearchIndexStats) => void; +}; + +export function SearchIndexProvider({ children }: { children: ReactNode }) { + const mx = useMatrixClient(); + const [idbSearchIndex] = useSetting(settingsAtom, 'idbSearchIndex'); + const [searchIndexMessageLimit] = useSetting(settingsAtom, 'searchIndexMessageLimit'); + + const [isReady, setIsReady] = useState(false); + const [isBackfilling, setIsBackfilling] = useState(false); + + const workerRef = useRef(null); + const pendingQueriesRef = useRef>(new Map()); + const pendingStatsRef = useRef(null); + // Rooms whose backfill is actively scheduled (to avoid double-scheduling) + const backfillingRoomsRef = useRef>(new Set()); + // Store headless timeline sets per room for pagination continuity + const headlessSetsRef = useRef>(new Map()); + // cancellable idle callbacks for backfill + const cancelIdlesRef = useRef void>>([]); + + const postToWorker = useCallback((msg: WorkerInMessage) => { + // oxlint-disable-next-line require-post-message-target-origin -- Worker.postMessage has no targetOrigin + workerRef.current?.postMessage(msg); + }, []); + + // ── Live indexing ────────────────────────────────────────────────────────── + + const indexEvent = useCallback( + (mEvent: MatrixEvent, room: Room) => { + if (!mx.isRoomEncrypted(room.roomId)) return; + + const handleDecrypted = () => { + const ev = toIndexableEvent(mEvent, room.roomId); + if (ev) postToWorker({ type: 'INDEX_EVENTS', events: [ev] }); + }; + + if (mEvent.getType() === 'm.room.encrypted') { + // Still encrypted — wait for decryption + mEvent.once(MatrixEventEvent.Decrypted, handleDecrypted); + } else { + handleDecrypted(); + } + }, + [mx, postToWorker] + ); + + // ── Headless backfill ────────────────────────────────────────────────────── + + const backfillRoom = useCallback( + async (room: Room, state: BackfillState): Promise => { + if (state.done) return; + + // Get or create headless timeline set for this room + let headlessSet = headlessSetsRef.current.get(room.roomId); + if (!headlessSet) { + headlessSet = new EventTimelineSet(room, {}); + headlessSetsRef.current.set(room.roomId, headlessSet); + } + + const headlessTimeline = headlessSet.getLiveTimeline(); + + // Seed the backward token: from IDB state, or from the room's live timeline + const seedToken = + state.token ?? + room.getLiveTimeline().getPaginationToken(Direction.Backward); + if (!seedToken) { + // Room has no history to paginate — mark done + postToWorker({ + type: 'SET_BACKFILL_STATE', + roomId: room.roomId, + state: { ...state, done: true }, + }); + backfillingRoomsRef.current.delete(room.roomId); + return; + } + + headlessTimeline.setPaginationToken(seedToken, Direction.Backward); + + let hasMore = false; + try { + hasMore = await mx.paginateEventTimeline(headlessTimeline, { + backwards: true, + limit: BACKFILL_PAGE_SIZE, + }); + } catch { + // Pagination error — stop this room for now + backfillingRoomsRef.current.delete(room.roomId); + return; + } + + // Collect decrypted events from the headless timeline + const events = headlessTimeline + .getEvents() + .map((ev) => toIndexableEvent(ev, room.roomId)) + .filter((ev): ev is IndexableEvent => ev !== null); + + if (events.length > 0) { + postToWorker({ type: 'INDEX_EVENTS', events }); + } + + const nextToken = headlessTimeline.getPaginationToken(Direction.Backward); + const done = !hasMore || !nextToken; + + postToWorker({ + type: 'SET_BACKFILL_STATE', + roomId: room.roomId, + state: { + token: nextToken, + done, + indexedCount: state.indexedCount + events.length, + }, + }); + + if (!done) { + // Schedule next page in an idle callback + const cancel = scheduleIdle(() => { + void backfillRoom(room, { + token: nextToken, + done: false, + indexedCount: state.indexedCount + events.length, + }); + }); + cancelIdlesRef.current.push(cancel); + } else { + backfillingRoomsRef.current.delete(room.roomId); + // Update isBackfilling state + if (backfillingRoomsRef.current.size === 0) { + setIsBackfilling(false); + } + } + }, + [mx, postToWorker] + ); + + const startBackfill = useCallback( + (backfillStates: Record) => { + const encryptedRooms = mx + .getRooms() + .filter((r) => mx.isRoomEncrypted(r.roomId) && !r.isSpaceRoom()); + + let scheduled = 0; + for (const room of encryptedRooms) { + const state = backfillStates[room.roomId] ?? { + token: null, + done: false, + indexedCount: 0, + }; + if (state.done) continue; + if (backfillingRoomsRef.current.has(room.roomId)) continue; + + backfillingRoomsRef.current.add(room.roomId); + scheduled += 1; + + const cancel = scheduleIdle(() => { + void backfillRoom(room, state); + }); + cancelIdlesRef.current.push(cancel); + } + + if (scheduled > 0) setIsBackfilling(true); + }, + [mx, backfillRoom] + ); + + // ── Worker message handler ───────────────────────────────────────────────── + + const handleWorkerMessage = useCallback( + (event: MessageEvent) => { + const msg = event.data; + + switch (msg.type) { + case 'READY': + setIsReady(true); + // Request backfill states, then start background fill + postToWorker({ type: 'GET_BACKFILL_STATES' }); + break; + + case 'BACKFILL_STATES': + startBackfill(msg.states); + break; + + case 'QUERY_RESULT': { + const pending = pendingQueriesRef.current.get(msg.id); + if (pending) { + pendingQueriesRef.current.delete(msg.id); + pending.resolve(msg.events); + } + break; + } + + case 'STATS': { + const pending = pendingStatsRef.current; + if (pending) { + pendingStatsRef.current = null; + pending.resolve({ + indexedEventCount: msg.indexedEventCount, + roomCount: msg.roomCount, + estimatedBytes: msg.estimatedBytes, + backfillingRoomCount: pending.backfillingRoomCount, + }); + } + break; + } + + case 'ERROR': + // eslint-disable-next-line no-console + console.error('[SearchIndex worker error]', msg.message); + break; + + default: + break; + } + }, + [postToWorker, startBackfill] + ); + + // ── Worker lifecycle ─────────────────────────────────────────────────────── + + useEffect(() => { + if (!idbSearchIndex) { + setIsReady(false); + return; + } + + const userId = mx.getUserId(); + if (!userId) return; + + const worker = new Worker( + new URL('../plugins/search-worker/searchWorker.ts', import.meta.url), + { type: 'module' } + ); + workerRef.current = worker; + worker.addEventListener('message', handleWorkerMessage); + + // oxlint-disable-next-line require-post-message-target-origin -- Worker.postMessage has no targetOrigin + worker.postMessage({ + type: 'INIT', + userId, + maxMessagesPerRoom: searchIndexMessageLimit, + } satisfies WorkerInMessage); + + // Live indexing listener + const handleTimeline = ( + mEvent: MatrixEvent, + room: Room | undefined, + ) => { + if (!room) return; + indexEvent(mEvent, room); + }; + mx.on(RoomEvent.Timeline, handleTimeline as unknown as (...args: unknown[]) => void); + + return () => { + worker.removeEventListener('message', handleWorkerMessage); + worker.terminate(); + workerRef.current = null; + setIsReady(false); + setIsBackfilling(false); + mx.removeListener(RoomEvent.Timeline, handleTimeline as unknown as (...args: unknown[]) => void); + + // Cancel all pending idle callbacks + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutable non-DOM refs, current is intentional at cleanup time + const cancels = cancelIdlesRef.current; + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutable non-DOM refs, current is intentional at cleanup time + const backfillingRooms = backfillingRoomsRef.current; + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutable non-DOM refs, current is intentional at cleanup time + const headlessSets = headlessSetsRef.current; + for (const cancel of cancels) cancel(); + cancelIdlesRef.current = []; + backfillingRooms.clear(); + headlessSets.clear(); + }; + }, [idbSearchIndex, mx, searchIndexMessageLimit, handleWorkerMessage, indexEvent]); + + // ── Public API ───────────────────────────────────────────────────────────── + + const query = useCallback( + (term: string, opts?: { roomIds?: string[]; senders?: string[] }): Promise => { + if (!workerRef.current || !isReady) return Promise.resolve([]); + const id = crypto.randomUUID(); + return new Promise((resolve, reject) => { + pendingQueriesRef.current.set(id, { resolve, reject }); + postToWorker({ + type: 'QUERY', + id, + term, + roomIds: opts?.roomIds, + senders: opts?.senders, + }); + }); + }, + [isReady, postToWorker] + ); + + const getStats = useCallback((): Promise => { + if (!workerRef.current || !isReady) { + return Promise.resolve({ + indexedEventCount: 0, + roomCount: 0, + estimatedBytes: 0, + backfillingRoomCount: 0, + }); + } + return new Promise((resolve) => { + pendingStatsRef.current = { + backfillingRoomCount: backfillingRoomsRef.current.size, + resolve, + }; + postToWorker({ type: 'GET_STATS' }); + }); + }, [isReady, postToWorker]); + + const clearIndex = useCallback((): Promise => { + if (!workerRef.current) return Promise.resolve(); + postToWorker({ type: 'CLEAR_INDEX' }); + // Reset local state + headlessSetsRef.current.clear(); + for (const cancel of cancelIdlesRef.current) cancel(); + cancelIdlesRef.current = []; + backfillingRoomsRef.current.clear(); + setIsBackfilling(false); + return Promise.resolve(); + }, [postToWorker]); + + const ctx = useMemo( + () => ({ query, getStats, clearIndex, isReady, isBackfilling }), + [query, getStats, clearIndex, isReady, isBackfilling], + ); + + return ( + {children} + ); +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index f847e0856..0dd32cbcd 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/react'; import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; +import { SearchIndexProvider } from '$hooks/useSearchIndex'; import type { RoomEventHandlerMap } from '$types/matrix-sdk'; import { MatrixEvent, @@ -864,7 +865,7 @@ function SettingsSyncFeature() { export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { useCallSignaling(); return ( - <> + @@ -884,6 +885,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { {children} - + ); } diff --git a/src/app/plugins/search-worker/searchWorker.ts b/src/app/plugins/search-worker/searchWorker.ts new file mode 100644 index 000000000..d86aa04bb --- /dev/null +++ b/src/app/plugins/search-worker/searchWorker.ts @@ -0,0 +1,332 @@ +/** + * searchWorker.ts — Web Worker that owns the MiniSearch index and IndexedDB persistence. + * + * Vite instantiation (main thread): + * new Worker(new URL('./searchWorker.ts', import.meta.url), { type: 'module' }) + */ + +import MiniSearch from 'minisearch'; +import type { IndexableEvent, BackfillState, WorkerInMessage, WorkerOutMessage } from './types'; + +// ── IDB helpers ───────────────────────────────────────────────────────────── + +function openDb(dbName: string): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(dbName, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains('index')) { + db.createObjectStore('index'); + } + if (!db.objectStoreNames.contains('backfill')) { + db.createObjectStore('backfill'); + } + }; + req.addEventListener('success', () => resolve(req.result)); + req.addEventListener('error', () => reject(req.error)); + }); +} + +function idbGet(db: IDBDatabase, store: string, key: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readonly'); + const req = tx.objectStore(store).get(key); + req.addEventListener('success', () => resolve(req.result as T | undefined)); + req.addEventListener('error', () => reject(req.error)); + }); +} + +function idbPut(db: IDBDatabase, store: string, key: string, value: unknown): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readwrite'); + const req = tx.objectStore(store).put(value, key); + req.addEventListener('success', () => resolve()); + req.addEventListener('error', () => reject(req.error)); + }); +} + +function idbGetAll(db: IDBDatabase, store: string): Promise<{ key: string; value: unknown }[]> { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readonly'); + const results: { key: string; value: unknown }[] = []; + const keysReq = tx.objectStore(store).openCursor(); + keysReq.addEventListener('success', () => { + const cursor = keysReq.result; + if (cursor) { + results.push({ key: cursor.key as string, value: cursor.value as unknown }); + cursor.continue(); + } else { + resolve(results); + } + }); + keysReq.addEventListener('error', () => reject(keysReq.error)); + }); +} + +function idbClear(db: IDBDatabase, store: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readwrite'); + const req = tx.objectStore(store).clear(); + req.addEventListener('success', () => resolve()); + req.addEventListener('error', () => reject(req.error)); + }); +} + +// ── Worker state ───────────────────────────────────────────────────────────── + +let db: IDBDatabase | null = null; +let index: MiniSearch | null = null; +let maxMessagesPerRoom = 2000; + +/** + * Per-room queue of [eventId, ts] sorted ascending by ts. + * Used for LRU eviction when the per-room limit is exceeded. + */ +const roomQueues = new Map>(); + +/** Dirty flag — index changed since last flush */ +let dirty = false; + +const FLUSH_DEBOUNCE_MS = 5000; +let flushTimer: ReturnType | null = null; + +function makeIndex(): MiniSearch { + return new MiniSearch({ + idField: 'eventId', + fields: ['body', 'sender'], + storeFields: ['eventId', 'roomId', 'sender', 'ts', 'body'], + searchOptions: { + boost: { body: 2 }, + fuzzy: 0.2, + prefix: true, + combineWith: 'AND', + }, + }); +} + +function post(msg: WorkerOutMessage): void { + // oxlint-disable-next-line require-post-message-target-origin -- Worker.postMessage has no targetOrigin + self.postMessage(msg); +} + +function scheduleFlush(): void { + if (flushTimer !== null) clearTimeout(flushTimer); + flushTimer = setTimeout(() => { + flushTimer = null; + void flushIndex(); + }, FLUSH_DEBOUNCE_MS); +} + +async function flushIndex(): Promise { + if (!db || !index || !dirty) return; + try { + await navigator.locks.request('sable-search-index-writer', { mode: 'exclusive' }, async () => { + if (!db || !index) return; + // Persist serialized MiniSearch + await idbPut(db, 'index', 'v1', JSON.stringify(index)); + // Persist room queues for reconstruction on next load + const roomQueuesData: Record> = {}; + for (const [roomId, queue] of roomQueues.entries()) { + roomQueuesData[roomId] = queue; + } + await idbPut(db, 'index', 'rooms', roomQueuesData); + dirty = false; + }); + } catch { + // Non-fatal: will retry on next flush + } +} + +// ── Eviction (oldest-first per room) ────────────────────────────────────────── + +function evictOldestForRoom(roomId: string): void { + if (!index) return; + const queue = roomQueues.get(roomId); + if (!queue) return; + const excess = queue.length - maxMessagesPerRoom; + if (excess <= 0) return; + + const toRemove = queue.splice(0, excess); + for (const [eventId] of toRemove) { + index.discard(eventId); + } +} + +// ── Message handler ──────────────────────────────────────────────────────── + +async function handleInit(userId: string, maxPerRoom: number): Promise { + maxMessagesPerRoom = maxPerRoom; + const dbName = `sable-search-${userId}`; + + db = await openDb(dbName); + + const serialized = await idbGet(db, 'index', 'v1'); + if (serialized) { + try { + index = MiniSearch.loadJSON(serialized, { + idField: 'eventId', + fields: ['body', 'sender'], + storeFields: ['eventId', 'roomId', 'sender', 'ts', 'body'], + searchOptions: { + boost: { body: 2 }, + fuzzy: 0.2, + prefix: true, + combineWith: 'AND', + }, + }); + // Rebuild room queues from persisted data + const savedQueues = await idbGet>>(db, 'index', 'rooms'); + if (savedQueues) { + for (const [roomId, queue] of Object.entries(savedQueues)) { + roomQueues.set(roomId, queue); + } + } + } catch { + index = makeIndex(); + } + } else { + index = makeIndex(); + } + + post({ + type: 'READY', + indexedEventCount: index.documentCount, + roomCount: roomQueues.size, + }); +} + +function handleIndexEvents(events: IndexableEvent[]): void { + if (!index) return; + + for (const ev of events) { + if (!ev.eventId || !ev.body.trim()) continue; + + // Skip duplicates already in the index + if (index.has(ev.eventId)) continue; + + index.add(ev); + + let queue = roomQueues.get(ev.roomId); + if (!queue) { + queue = []; + roomQueues.set(ev.roomId, queue); + } + // Insert in ts-ascending order (most backfill arrives in order, so push is common) + const lastEntry = queue[queue.length - 1]; + if (queue.length === 0 || (lastEntry !== undefined && lastEntry[1] <= ev.ts)) { + queue.push([ev.eventId, ev.ts]); + } else { + // Binary search insertion for out-of-order events + let lo = 0; + let hi = queue.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + const midEntry = queue[mid]; + if (midEntry !== undefined && midEntry[1] <= ev.ts) lo = mid + 1; + else hi = mid; + } + queue.splice(lo, 0, [ev.eventId, ev.ts]); + } + + // Enforce per-room limit; amortise by only evicting when 10% over + if (queue.length > maxMessagesPerRoom * 1.1) { + evictOldestForRoom(ev.roomId); + } + } + + dirty = true; + scheduleFlush(); +} + +function handleQuery(id: string, term: string, roomIds?: string[], senders?: string[]): void { + if (!index) { + post({ type: 'QUERY_RESULT', id, events: [] }); + return; + } + + const rawResults = index.search(term, { + filter: (r) => { + const ev = r as unknown as IndexableEvent; + if (roomIds && roomIds.length > 0 && !roomIds.includes(ev.roomId)) return false; + if (senders && senders.length > 0 && !senders.includes(ev.sender)) return false; + return true; + }, + }) as unknown as IndexableEvent[]; + + post({ type: 'QUERY_RESULT', id, events: rawResults }); +} + +async function handleSetBackfillState(roomId: string, state: BackfillState): Promise { + if (!db) return; + await idbPut(db, 'backfill', roomId, state); +} + +async function handleGetBackfillStates(): Promise { + if (!db) { + post({ type: 'BACKFILL_STATES', states: {} }); + return; + } + const rows = await idbGetAll(db, 'backfill'); + const states: Record = {}; + for (const { key, value } of rows) { + states[key] = value as BackfillState; + } + post({ type: 'BACKFILL_STATES', states }); +} + +function handleGetStats(): void { + if (!index) { + post({ type: 'STATS', indexedEventCount: 0, roomCount: 0, estimatedBytes: 0 }); + return; + } + const serialized = JSON.stringify(index); + post({ + type: 'STATS', + indexedEventCount: index.documentCount, + roomCount: roomQueues.size, + estimatedBytes: serialized.length * 2, // UTF-16 approximation + }); +} + +async function handleClearIndex(): Promise { + if (!db) return; + index = makeIndex(); + roomQueues.clear(); + dirty = false; + await idbClear(db, 'index'); + await idbClear(db, 'backfill'); +} + +self.addEventListener('message', (event: MessageEvent) => { + const msg = event.data; + switch (msg.type) { + case 'INIT': + void handleInit(msg.userId, msg.maxMessagesPerRoom); + break; + case 'INDEX_EVENTS': + handleIndexEvents(msg.events); + break; + case 'QUERY': + handleQuery(msg.id, msg.term, msg.roomIds, msg.senders); + break; + case 'SET_BACKFILL_STATE': + void handleSetBackfillState(msg.roomId, msg.state); + break; + case 'GET_BACKFILL_STATES': + void handleGetBackfillStates(); + break; + case 'GET_STATS': + handleGetStats(); + break; + case 'CLEAR_INDEX': + void handleClearIndex(); + break; + default: + break; + } +}); + +// Flush on termination +self.addEventListener('beforeunload', () => { + void flushIndex(); +}); diff --git a/src/app/plugins/search-worker/types.ts b/src/app/plugins/search-worker/types.ts new file mode 100644 index 000000000..1ca684873 --- /dev/null +++ b/src/app/plugins/search-worker/types.ts @@ -0,0 +1,79 @@ +/** A plain, serializable event suitable for passing into/out of the search worker. */ +export type IndexableEvent = { + eventId: string; + roomId: string; + sender: string; + body: string; + ts: number; +}; + +export type BackfillState = { + /** Pagination token to resume backward pagination, or null when at the beginning. */ + token: string | null; + /** True once we've reached the beginning of the room history. */ + done: boolean; + /** How many events for this room are currently in the index. */ + indexedCount: number; +}; + +// ── Main → Worker ────────────────────────────────────────────────────────── + +export type WorkerInMessage = + | { + type: 'INIT'; + userId: string; + maxMessagesPerRoom: number; + } + | { + type: 'INDEX_EVENTS'; + events: IndexableEvent[]; + } + | { + type: 'QUERY'; + id: string; + term: string; + roomIds?: string[]; + senders?: string[]; + } + | { + type: 'SET_BACKFILL_STATE'; + roomId: string; + state: BackfillState; + } + | { + type: 'GET_BACKFILL_STATES'; + } + | { + type: 'GET_STATS'; + } + | { + type: 'CLEAR_INDEX'; + }; + +// ── Worker → Main ────────────────────────────────────────────────────────── + +export type WorkerOutMessage = + | { + type: 'READY'; + indexedEventCount: number; + roomCount: number; + } + | { + type: 'QUERY_RESULT'; + id: string; + events: IndexableEvent[]; + } + | { + type: 'BACKFILL_STATES'; + states: Record; + } + | { + type: 'STATS'; + indexedEventCount: number; + roomCount: number; + estimatedBytes: number; + } + | { + type: 'ERROR'; + message: string; + }; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index af2d077dc..d348e92a4 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -129,6 +129,8 @@ export interface Settings { enableMSC4268CMD: boolean; settingsSyncEnabled: boolean; encryptedSearch: boolean; + idbSearchIndex: boolean; + searchIndexMessageLimit: number; // Cosmetics! jumboEmojiSize: JumboEmojiSize; @@ -266,6 +268,8 @@ export const defaultSettings: Settings = { developerTools: false, settingsSyncEnabled: false, encryptedSearch: false, + idbSearchIndex: false, + searchIndexMessageLimit: 2000, // Cosmetics! jumboEmojiSize: 'normal', From a575e52267438fcf082136ca5113ac865ebfe858 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 14:43:40 -0400 Subject: [PATCH 14/25] chore: add changeset --- .changeset/encrypted-search-idb.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/encrypted-search-idb.md diff --git a/.changeset/encrypted-search-idb.md b/.changeset/encrypted-search-idb.md new file mode 100644 index 000000000..30f9990f1 --- /dev/null +++ b/.changeset/encrypted-search-idb.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add IndexedDB-backed persistent search index for encrypted rooms via a MiniSearch web worker with multi-tab write safety and LRU eviction. From 27f87ec150b49035a050bc1d969425a9afa1fb5c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 15:38:31 -0400 Subject: [PATCH 15/25] style: apply oxfmt formatting --- .../message-search/useMessageSearch.ts | 8 +++- .../developer-tools/SearchIndexCache.tsx | 7 ++- src/app/features/settings/settingsLink.ts | 8 +++- src/app/hooks/useSearchIndex.tsx | 43 +++++++++++-------- src/app/plugins/search-worker/searchWorker.ts | 6 ++- 5 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index ad3568332..9c08ba014 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -176,7 +176,13 @@ export const useMessageSearch = (params: MessageSearchParams) => { }); inMemoryGroups = idbEventsToGroups(idbEvents); } else { - inMemoryGroups = searchEncryptedRoomsInMemory(mx, term ?? '', encryptedRoomIds, senders, hasTypes); + inMemoryGroups = searchEncryptedRoomsInMemory( + mx, + term ?? '', + encryptedRoomIds, + senders, + hasTypes + ); } } diff --git a/src/app/features/settings/developer-tools/SearchIndexCache.tsx b/src/app/features/settings/developer-tools/SearchIndexCache.tsx index 28a2435b2..aa181a217 100644 --- a/src/app/features/settings/developer-tools/SearchIndexCache.tsx +++ b/src/app/features/settings/developer-tools/SearchIndexCache.tsx @@ -58,7 +58,12 @@ export function SearchIndexCache() { return ( Encrypted Search Index - + ()).body ?? ''; + const body: string = mEvent.getContent<{ body?: string }>().body ?? ''; if (!body.trim()) return null; const sender = mEvent.getSender(); if (!sender) return null; @@ -165,8 +170,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { // Seed the backward token: from IDB state, or from the room's live timeline const seedToken = - state.token ?? - room.getLiveTimeline().getPaginationToken(Direction.Backward); + state.token ?? room.getLiveTimeline().getPaginationToken(Direction.Backward); if (!seedToken) { // Room has no history to paginate — mark done postToWorker({ @@ -344,10 +348,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { } satisfies WorkerInMessage); // Live indexing listener - const handleTimeline = ( - mEvent: MatrixEvent, - room: Room | undefined, - ) => { + const handleTimeline = (mEvent: MatrixEvent, room: Room | undefined) => { if (!room) return; indexEvent(mEvent, room); }; @@ -359,14 +360,17 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { workerRef.current = null; setIsReady(false); setIsBackfilling(false); - mx.removeListener(RoomEvent.Timeline, handleTimeline as unknown as (...args: unknown[]) => void); + mx.removeListener( + RoomEvent.Timeline, + handleTimeline as unknown as (...args: unknown[]) => void + ); // Cancel all pending idle callbacks - // eslint-disable-next-line react-hooks/exhaustive-deps -- mutable non-DOM refs, current is intentional at cleanup time - const cancels = cancelIdlesRef.current; - // eslint-disable-next-line react-hooks/exhaustive-deps -- mutable non-DOM refs, current is intentional at cleanup time - const backfillingRooms = backfillingRoomsRef.current; - // eslint-disable-next-line react-hooks/exhaustive-deps -- mutable non-DOM refs, current is intentional at cleanup time + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutable non-DOM refs, current is intentional at cleanup time + const cancels = cancelIdlesRef.current; + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutable non-DOM refs, current is intentional at cleanup time + const backfillingRooms = backfillingRoomsRef.current; + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutable non-DOM refs, current is intentional at cleanup time const headlessSets = headlessSetsRef.current; for (const cancel of cancels) cancel(); cancelIdlesRef.current = []; @@ -378,7 +382,10 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { // ── Public API ───────────────────────────────────────────────────────────── const query = useCallback( - (term: string, opts?: { roomIds?: string[]; senders?: string[] }): Promise => { + ( + term: string, + opts?: { roomIds?: string[]; senders?: string[] } + ): Promise => { if (!workerRef.current || !isReady) return Promise.resolve([]); const id = crypto.randomUUID(); return new Promise((resolve, reject) => { @@ -427,10 +434,8 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { const ctx = useMemo( () => ({ query, getStats, clearIndex, isReady, isBackfilling }), - [query, getStats, clearIndex, isReady, isBackfilling], + [query, getStats, clearIndex, isReady, isBackfilling] ); - return ( - {children} - ); + return {children}; } diff --git a/src/app/plugins/search-worker/searchWorker.ts b/src/app/plugins/search-worker/searchWorker.ts index d86aa04bb..3dbeb6f30 100644 --- a/src/app/plugins/search-worker/searchWorker.ts +++ b/src/app/plugins/search-worker/searchWorker.ts @@ -175,7 +175,11 @@ async function handleInit(userId: string, maxPerRoom: number): Promise { }, }); // Rebuild room queues from persisted data - const savedQueues = await idbGet>>(db, 'index', 'rooms'); + const savedQueues = await idbGet>>( + db, + 'index', + 'rooms' + ); if (savedQueues) { for (const [roomId, queue] of Object.entries(savedQueues)) { roomQueues.set(roomId, queue); From 544658d6242151b741998d9e5252c47d2290be3b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 15:43:14 -0400 Subject: [PATCH 16/25] feat(search): extend IDB index to all rooms; chip filters for unencrypted; update UI text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove isRoomEncrypted guard from indexEvent and startBackfill so all non-space rooms are backfilled and live-indexed (not just encrypted ones) - Add IDB chip-only query path for unencrypted rooms in useMessageSearch (useIdbSearch flag, usedIdbForUnencrypted for accurate inMemoryRoomCount) - Rename 'Encrypted Search Index' → 'Message Search Index' throughout UI - Update SearchIndexToggle description to disclose plaintext IDB storage - Update EncryptedSearch description to clarify in-memory-only (no write) - Remove stale mx dep from indexEvent useCallback (isRoomEncrypted removed) --- .../message-search/useMessageSearch.ts | 44 ++++++++--- .../developer-tools/SearchIndexCache.tsx | 2 +- .../settings/experimental/EncryptedSearch.tsx | 2 +- .../experimental/SearchIndexToggle.tsx | 8 +- src/app/hooks/useSearchIndex.tsx | 17 ++-- src/app/plugins/search-worker/searchWorker.ts | 79 +++++++++++++++---- src/app/plugins/search-worker/types.ts | 4 + 7 files changed, 115 insertions(+), 41 deletions(-) diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index 9c08ba014..b56520f35 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -34,7 +34,7 @@ function idbEventsToGroups(events: IndexableEvent[]): ResultGroup[] { room_id: ev.roomId, sender: ev.sender, origin_server_ts: ev.ts, - content: { msgtype: 'm.text', body: ev.body }, + content: { msgtype: ev.msgtype, body: ev.body }, type: 'm.room.message', unsigned: {}, } as IEventWithRoomId, @@ -168,13 +168,16 @@ export const useMessageSearch = (params: MessageSearchParams) => { // For IDB search: only run on first page (IDB has no pagination cursor here). // Prefer IDB when available; fall back to in-memory live timeline. let inMemoryGroups: ResultGroup[] = []; + let usedIdb = false; if (encryptedSearchEnabled && isFirstPage && encryptedRoomIds.length > 0) { - if (useIdbSearch && term) { - const idbEvents = await searchIndex!.query(term, { + if (useIdbSearch && (term || hasHasTypes)) { + const idbEvents = await searchIndex!.query(term ?? '', { roomIds: encryptedRoomIds, senders, + hasTypes: hasHasTypes ? hasTypes : undefined, }); inMemoryGroups = idbEventsToGroups(idbEvents); + usedIdb = true; } else { inMemoryGroups = searchEncryptedRoomsInMemory( mx, @@ -187,10 +190,11 @@ export const useMessageSearch = (params: MessageSearchParams) => { } // When there's no text term, skip server search (server requires search_term). - // For has: filters, scan all rooms' in-memory timelines (encrypted + unencrypted). + // For has: filters, scan all rooms' timelines (encrypted + unencrypted). if (skipServerSearch || !term) { let unencryptedMemoryGroups: ResultGroup[] = []; let unencryptedRoomCount = 0; + let usedIdbForUnencrypted = false; if (hasHasTypes && isFirstPage) { // 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 @@ -204,13 +208,23 @@ export const useMessageSearch = (params: MessageSearchParams) => { .map((r) => r.roomId); unencryptedRoomCount = unencryptedRooms.length; if (unencryptedRooms.length > 0) { - unencryptedMemoryGroups = searchEncryptedRoomsInMemory( - mx, - '', - unencryptedRooms, - senders, - hasTypes - ); + if (useIdbSearch) { + const idbEvents = await searchIndex!.query('', { + roomIds: unencryptedRooms, + senders, + hasTypes, + }); + unencryptedMemoryGroups = idbEventsToGroups(idbEvents); + usedIdbForUnencrypted = true; + } else { + unencryptedMemoryGroups = searchEncryptedRoomsInMemory( + mx, + '', + unencryptedRooms, + senders, + hasTypes + ); + } } } return { @@ -220,7 +234,11 @@ export const useMessageSearch = (params: MessageSearchParams) => { unencryptedMemoryGroups, order ), - inMemoryRoomCount: encryptedRoomIds.length + unencryptedRoomCount, + // Only report local-cache count for rooms that were actually searched in-memory. + inMemoryRoomCount: + ((usedIdb ? 0 : encryptedRoomIds.length) + + (usedIdbForUnencrypted ? 0 : unencryptedRoomCount)) || + undefined, }; } @@ -268,7 +286,7 @@ export const useMessageSearch = (params: MessageSearchParams) => { order ), highlights: Array.from(new Set([...filteredServerResult.highlights, ...termWords])), - inMemoryRoomCount: encryptedRoomIds.length, + inMemoryRoomCount: usedIdb ? undefined : encryptedRoomIds.length, }; }, [ diff --git a/src/app/features/settings/developer-tools/SearchIndexCache.tsx b/src/app/features/settings/developer-tools/SearchIndexCache.tsx index aa181a217..c464a16c5 100644 --- a/src/app/features/settings/developer-tools/SearchIndexCache.tsx +++ b/src/app/features/settings/developer-tools/SearchIndexCache.tsx @@ -57,7 +57,7 @@ export function SearchIndexCache() { return ( - Encrypted Search Index + Message Search Index - Encrypted Search Index + Message Search Index } diff --git a/src/app/hooks/useSearchIndex.tsx b/src/app/hooks/useSearchIndex.tsx index b5ec826bd..dc840c07f 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -48,7 +48,7 @@ type SearchIndexCtx = { /** Query the IDB-backed index. Resolves to an empty array when the index is unavailable. */ query: ( term: string, - opts?: { roomIds?: string[]; senders?: string[] } + opts?: { roomIds?: string[]; senders?: string[]; hasTypes?: string[] } ) => Promise; /** Request current stats from the worker. */ getStats: () => Promise; @@ -88,11 +88,13 @@ function toIndexableEvent(mEvent: MatrixEvent, roomId: string): IndexableEvent | if (mEvent.getType() === 'm.room.encrypted') return null; if (mEvent.getType() !== (EventType.RoomMessage as string)) return null; if (mEvent.isRedacted()) return null; - const body: string = mEvent.getContent<{ body?: string }>().body ?? ''; + const content = mEvent.getContent<{ body?: string; msgtype?: string }>(); + const body: string = content.body ?? ''; if (!body.trim()) return null; const sender = mEvent.getSender(); if (!sender) return null; - return { eventId, roomId, sender, body, ts: mEvent.getTs() }; + const msgtype = content.msgtype ?? 'm.text'; + return { eventId, roomId, sender, msgtype, body, ts: mEvent.getTs() }; } // ── Provider ───────────────────────────────────────────────────────────────── @@ -136,8 +138,6 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { const indexEvent = useCallback( (mEvent: MatrixEvent, room: Room) => { - if (!mx.isRoomEncrypted(room.roomId)) return; - const handleDecrypted = () => { const ev = toIndexableEvent(mEvent, room.roomId); if (ev) postToWorker({ type: 'INDEX_EVENTS', events: [ev] }); @@ -150,7 +150,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { handleDecrypted(); } }, - [mx, postToWorker] + [postToWorker] ); // ── Headless backfill ────────────────────────────────────────────────────── @@ -244,7 +244,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { (backfillStates: Record) => { const encryptedRooms = mx .getRooms() - .filter((r) => mx.isRoomEncrypted(r.roomId) && !r.isSpaceRoom()); + .filter((r) => !r.isSpaceRoom()); let scheduled = 0; for (const room of encryptedRooms) { @@ -384,7 +384,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { const query = useCallback( ( term: string, - opts?: { roomIds?: string[]; senders?: string[] } + opts?: { roomIds?: string[]; senders?: string[]; hasTypes?: string[] } ): Promise => { if (!workerRef.current || !isReady) return Promise.resolve([]); const id = crypto.randomUUID(); @@ -396,6 +396,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { term, roomIds: opts?.roomIds, senders: opts?.senders, + hasTypes: opts?.hasTypes, }); }); }, diff --git a/src/app/plugins/search-worker/searchWorker.ts b/src/app/plugins/search-worker/searchWorker.ts index 3dbeb6f30..c11386385 100644 --- a/src/app/plugins/search-worker/searchWorker.ts +++ b/src/app/plugins/search-worker/searchWorker.ts @@ -12,13 +12,19 @@ import type { IndexableEvent, BackfillState, WorkerInMessage, WorkerOutMessage } function openDb(dbName: string): Promise { return new Promise((resolve, reject) => { - const req = indexedDB.open(dbName, 1); - req.onupgradeneeded = () => { + const req = indexedDB.open(dbName, 2); + req.onupgradeneeded = (event) => { const db = req.result; - if (!db.objectStoreNames.contains('index')) { + const oldVersion = event.oldVersion; + if (oldVersion < 1) { db.createObjectStore('index'); + db.createObjectStore('backfill'); } - if (!db.objectStoreNames.contains('backfill')) { + // v2: added msgtype to stored fields — clear persisted index/backfill so it rebuilds + if (oldVersion >= 1 && oldVersion < 2) { + db.deleteObjectStore('index'); + db.deleteObjectStore('backfill'); + db.createObjectStore('index'); db.createObjectStore('backfill'); } }; @@ -94,7 +100,7 @@ function makeIndex(): MiniSearch { return new MiniSearch({ idField: 'eventId', fields: ['body', 'sender'], - storeFields: ['eventId', 'roomId', 'sender', 'ts', 'body'], + storeFields: ['eventId', 'roomId', 'sender', 'msgtype', 'ts', 'body'], searchOptions: { boost: { body: 2 }, fuzzy: 0.2, @@ -154,6 +160,37 @@ function evictOldestForRoom(roomId: string): void { // ── Message handler ──────────────────────────────────────────────────────── +/** Iterate every document stored in the MiniSearch index (uses internal _storedFields — tied to MiniSearch v7). */ +function iterateStoredDocs(idx: MiniSearch): IterableIterator { + const internal = idx as unknown as { _storedFields: Map> }; + return (function* () { + for (const fields of internal._storedFields.values()) { + yield fields as IndexableEvent; + } + })(); +} + +/** Matrix msgtype for each SearchHasType chip. */ +const HAS_TYPE_TO_MSGTYPE: Record = { + image: 'm.image', + file: 'm.file', + audio: 'm.audio', + video: 'm.video', +}; + +function makeTypeFilter( + hasTypes: string[] | undefined +): ((ev: IndexableEvent) => boolean) | null { + if (!hasTypes || hasTypes.length === 0) return null; + const allowedMsgtypes = new Set(hasTypes.map((t) => HAS_TYPE_TO_MSGTYPE[t]).filter(Boolean)); + const needsLink = hasTypes.includes('link'); + return (ev: IndexableEvent) => { + if (allowedMsgtypes.has(ev.msgtype)) return true; + if (needsLink && /https?:\/\//i.test(ev.body)) return true; + return false; + }; +} + async function handleInit(userId: string, maxPerRoom: number): Promise { maxMessagesPerRoom = maxPerRoom; const dbName = `sable-search-${userId}`; @@ -166,7 +203,7 @@ async function handleInit(userId: string, maxPerRoom: number): Promise { index = MiniSearch.loadJSON(serialized, { idField: 'eventId', fields: ['body', 'sender'], - storeFields: ['eventId', 'roomId', 'sender', 'ts', 'body'], + storeFields: ['eventId', 'roomId', 'sender', 'msgtype', 'ts', 'body'], searchOptions: { boost: { body: 2 }, fuzzy: 0.2, @@ -242,19 +279,33 @@ function handleIndexEvents(events: IndexableEvent[]): void { scheduleFlush(); } -function handleQuery(id: string, term: string, roomIds?: string[], senders?: string[]): void { +function handleQuery(id: string, term: string, roomIds?: string[], senders?: string[], hasTypes?: string[]): void { if (!index) { post({ type: 'QUERY_RESULT', id, events: [] }); return; } + const typeFilter = makeTypeFilter(hasTypes); + + function matchesFilters(ev: IndexableEvent): boolean { + if (roomIds && roomIds.length > 0 && !roomIds.includes(ev.roomId)) return false; + if (senders && senders.length > 0 && !senders.includes(ev.sender)) return false; + if (typeFilter && !typeFilter(ev)) return false; + return true; + } + + if (!term) { + // Chip-only query: scan all stored documents — MiniSearch can't search an empty term. + const results: IndexableEvent[] = []; + for (const ev of iterateStoredDocs(index)) { + if (matchesFilters(ev)) results.push(ev); + } + post({ type: 'QUERY_RESULT', id, events: results }); + return; + } + const rawResults = index.search(term, { - filter: (r) => { - const ev = r as unknown as IndexableEvent; - if (roomIds && roomIds.length > 0 && !roomIds.includes(ev.roomId)) return false; - if (senders && senders.length > 0 && !senders.includes(ev.sender)) return false; - return true; - }, + filter: (r) => matchesFilters(r as unknown as IndexableEvent), }) as unknown as IndexableEvent[]; post({ type: 'QUERY_RESULT', id, events: rawResults }); @@ -311,7 +362,7 @@ self.addEventListener('message', (event: MessageEvent) => { handleIndexEvents(msg.events); break; case 'QUERY': - handleQuery(msg.id, msg.term, msg.roomIds, msg.senders); + handleQuery(msg.id, msg.term, msg.roomIds, msg.senders, msg.hasTypes); break; case 'SET_BACKFILL_STATE': void handleSetBackfillState(msg.roomId, msg.state); diff --git a/src/app/plugins/search-worker/types.ts b/src/app/plugins/search-worker/types.ts index 1ca684873..d98683c4e 100644 --- a/src/app/plugins/search-worker/types.ts +++ b/src/app/plugins/search-worker/types.ts @@ -3,6 +3,8 @@ export type IndexableEvent = { eventId: string; roomId: string; sender: string; + /** Matrix msgtype, e.g. 'm.text', 'm.image', 'm.file', 'm.audio', 'm.video'. */ + msgtype: string; body: string; ts: number; }; @@ -34,6 +36,8 @@ export type WorkerInMessage = term: string; roomIds?: string[]; senders?: string[]; + /** SearchHasType values to filter by, e.g. ['image', 'link']. */ + hasTypes?: string[]; } | { type: 'SET_BACKFILL_STATE'; From 282e483d82f8bea6b92ff5d38d3d9cf5df0760df Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 16:27:54 -0400 Subject: [PATCH 17/25] fix(search): limit backfill concurrency and yield to main sync - Cap simultaneous room backfills at 2 (MAX_CONCURRENT_BACKFILLS) so the HTTP connection pool is never saturated by pagination requests, keeping the /sync long-poll responsive on mobile. - Track Matrix sync state in syncStateRef; pause backfill when sync is unhealthy (Error / Reconnecting) and automatically resume via a ClientEvent.Sync listener when it recovers. - Raise the requestIdleCallback fallback delay from 200 ms to 1 s for environments (iOS Safari) that lack the API. - Replace the 'schedule all at once' loop in startBackfill with a proper queue (backfillQueueRef) drained by resumeBackfill(). --- src/app/hooks/useSearchIndex.tsx | 111 ++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 24 deletions(-) diff --git a/src/app/hooks/useSearchIndex.tsx b/src/app/hooks/useSearchIndex.tsx index dc840c07f..70f5c6fe7 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -15,11 +15,13 @@ import { type ReactNode, } from 'react'; import { + ClientEvent, Direction, EventTimelineSet, EventType, MatrixEventEvent, RoomEvent, + SyncState, type MatrixEvent, type Room, } from '$types/matrix-sdk'; @@ -70,12 +72,21 @@ export function useSearchIndex(): SearchIndexCtx | null { // ── Idle scheduler ─────────────────────────────────────────────────────────── +/** + * Maximum number of rooms whose backfill pagination may run concurrently. + * Keeping this small prevents flooding the HTTP connection pool (and starving + * the /sync long-poll) on low-bandwidth or constrained devices such as iOS. + */ +const MAX_CONCURRENT_BACKFILLS = 2; + function scheduleIdle(cb: () => void): () => void { if (typeof requestIdleCallback === 'function') { const id = requestIdleCallback(cb, { timeout: 5000 }); return () => cancelIdleCallback(id); } - const id = setTimeout(cb, 200); + // iOS Safari does not support requestIdleCallback — use a longer delay so the + // sync connection is not starved by rapid back-to-back pagination requests. + const id = setTimeout(cb, 1000); return () => clearTimeout(id); } @@ -128,6 +139,10 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { const headlessSetsRef = useRef>(new Map()); // cancellable idle callbacks for backfill const cancelIdlesRef = useRef void>>([]); + // Queue of rooms waiting to start backfill (for the concurrency limiter) + const backfillQueueRef = useRef>([]); + // Current Matrix sync state — used to pause backfill when sync is struggling + const syncStateRef = useRef(null); const postToWorker = useCallback((msg: WorkerInMessage) => { // oxlint-disable-next-line require-post-message-target-origin -- Worker.postMessage has no targetOrigin @@ -220,19 +235,36 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { }); if (!done) { - // Schedule next page in an idle callback + // Schedule next page — but yield to the main sync if it's struggling. + // resumeBackfill will restart this room once sync recovers. + const nextState: BackfillState = { + token: nextToken, + done: false, + indexedCount: state.indexedCount + events.length, + }; const cancel = scheduleIdle(() => { - void backfillRoom(room, { - token: nextToken, - done: false, - indexedCount: state.indexedCount + events.length, - }); + const s = syncStateRef.current; + if (s !== SyncState.Syncing && s !== SyncState.Prepared && s !== SyncState.Catchup) { + backfillingRoomsRef.current.delete(room.roomId); + backfillQueueRef.current.unshift({ room, state: nextState }); + return; + } + void backfillRoom(room, nextState); }); cancelIdlesRef.current.push(cancel); } else { backfillingRoomsRef.current.delete(room.roomId); - // Update isBackfilling state - if (backfillingRoomsRef.current.size === 0) { + // Dequeue the next room from the concurrency queue while under the limit + while ( + backfillingRoomsRef.current.size < MAX_CONCURRENT_BACKFILLS && + backfillQueueRef.current.length > 0 + ) { + const next = backfillQueueRef.current.shift()!; + backfillingRoomsRef.current.add(next.room.roomId); + const cancel = scheduleIdle(() => void backfillRoom(next.room, next.state)); + cancelIdlesRef.current.push(cancel); + } + if (backfillingRoomsRef.current.size === 0 && backfillQueueRef.current.length === 0) { setIsBackfilling(false); } } @@ -240,14 +272,32 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { [mx, postToWorker] ); + /** + * Dequeue rooms from the backfill queue up to the concurrency limit. + * Skips when the Matrix sync is not healthy so the /sync connection is + * never starved by background pagination requests. + */ + const resumeBackfill = useCallback(() => { + const s = syncStateRef.current; + if (s !== SyncState.Syncing && s !== SyncState.Prepared && s !== SyncState.Catchup) return; + + while ( + backfillingRoomsRef.current.size < MAX_CONCURRENT_BACKFILLS && + backfillQueueRef.current.length > 0 + ) { + const next = backfillQueueRef.current.shift()!; + backfillingRoomsRef.current.add(next.room.roomId); + const cancel = scheduleIdle(() => void backfillRoom(next.room, next.state)); + cancelIdlesRef.current.push(cancel); + } + }, [backfillRoom]); + const startBackfill = useCallback( (backfillStates: Record) => { - const encryptedRooms = mx - .getRooms() - .filter((r) => !r.isSpaceRoom()); + const rooms = mx.getRooms().filter((r) => !r.isSpaceRoom()); - let scheduled = 0; - for (const room of encryptedRooms) { + // Enqueue all unfinished rooms that are not already active + for (const room of rooms) { const state = backfillStates[room.roomId] ?? { token: null, done: false, @@ -255,19 +305,17 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { }; if (state.done) continue; if (backfillingRoomsRef.current.has(room.roomId)) continue; + if (backfillQueueRef.current.some((e) => e.room.roomId === room.roomId)) continue; - backfillingRoomsRef.current.add(room.roomId); - scheduled += 1; - - const cancel = scheduleIdle(() => { - void backfillRoom(room, state); - }); - cancelIdlesRef.current.push(cancel); + backfillQueueRef.current.push({ room, state }); } - if (scheduled > 0) setIsBackfilling(true); + if (backfillQueueRef.current.length > 0 || backfillingRoomsRef.current.size > 0) { + setIsBackfilling(true); + } + resumeBackfill(); }, - [mx, backfillRoom] + [mx, resumeBackfill] ); // ── Worker message handler ───────────────────────────────────────────────── @@ -347,6 +395,19 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { maxMessagesPerRoom: searchIndexMessageLimit, } satisfies WorkerInMessage); + // Seed sync state so backfill is correctly paused if the worker becomes + // ready before the first PREPARED/SYNCING event fires. + syncStateRef.current = mx.getSyncState(); + + // When sync recovers, restart any rooms that were paused mid-backfill. + const handleSync = (state: SyncState) => { + syncStateRef.current = state; + if (state === SyncState.Syncing || state === SyncState.Prepared || state === SyncState.Catchup) { + resumeBackfill(); + } + }; + mx.on(ClientEvent.Sync, handleSync as unknown as (...args: unknown[]) => void); + // Live indexing listener const handleTimeline = (mEvent: MatrixEvent, room: Room | undefined) => { if (!room) return; @@ -360,6 +421,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { workerRef.current = null; setIsReady(false); setIsBackfilling(false); + mx.removeListener(ClientEvent.Sync, handleSync as unknown as (...args: unknown[]) => void); mx.removeListener( RoomEvent.Timeline, handleTimeline as unknown as (...args: unknown[]) => void @@ -376,8 +438,9 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { cancelIdlesRef.current = []; backfillingRooms.clear(); headlessSets.clear(); + backfillQueueRef.current = []; }; - }, [idbSearchIndex, mx, searchIndexMessageLimit, handleWorkerMessage, indexEvent]); + }, [idbSearchIndex, mx, searchIndexMessageLimit, handleWorkerMessage, indexEvent, resumeBackfill]); // ── Public API ───────────────────────────────────────────────────────────── From 2dcc47c249eb6c9dc457f51122b00b6f32c7e458 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 16:49:41 -0400 Subject: [PATCH 18/25] fix(search): resolve image/media results via live room cache idbEventsToGroups now looks up each event via mx.getRoom().findEventById() and calls toSearchEvent() for full decrypted content (url, file, info). Falls back to msgtype m.text when the event is no longer in memory, preventing BrokenContent from showing 'Broken message: [filename]'. Regression introduced in 544658d62 which added ev.msgtype to the synthetic event without providing the media fields renderers require. --- .../message-search/useMessageSearch.ts | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index b56520f35..4fc481e3f 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -16,28 +16,43 @@ import { partitionRoomsByEncryption, mergeSearchGroups, EMPTY_CONTEXT, + toSearchEvent, } from './searchEncryptedRooms'; import type { SearchHasType } from './searchEncryptedRooms'; +import type { MatrixClient } from '$types/matrix-sdk'; import type { IndexableEvent } from '$plugins/search-worker/types'; import { useSearchIndex } from '$hooks/useSearchIndex'; export type { SearchHasType }; -/** Convert IDB-indexed events back to the ResultGroup format used by the search UI. */ -function idbEventsToGroups(events: IndexableEvent[]): ResultGroup[] { +/** + * Convert IDB-indexed events back to the ResultGroup format used by the search UI. + * + * Prefers the live MatrixEvent from the room cache so that media messages + * (m.image, m.file, m.audio, m.video) render with their full content + * (url, file, info, …). Falls back to a plain-text synthetic event showing + * the stored filename/body when the event is no longer in memory. + */ +function idbEventsToGroups(mx: Pick, events: IndexableEvent[]): ResultGroup[] { const byRoom = new Map(); for (const ev of events) { + const liveEvent = mx.getRoom(ev.roomId)?.findEventById(ev.eventId); + const eventData: IEventWithRoomId = liveEvent + ? toSearchEvent(liveEvent, ev.roomId) + : { + event_id: ev.eventId, + room_id: ev.roomId, + sender: ev.sender, + origin_server_ts: ev.ts, + // Fall back to m.text so media renderers don't show "Broken message" + // when the event has scrolled out of the in-memory timeline. + content: { msgtype: 'm.text', body: ev.body }, + type: 'm.room.message', + unsigned: {}, + } as IEventWithRoomId; const item: ResultItem = { rank: 1, - event: { - event_id: ev.eventId, - room_id: ev.roomId, - sender: ev.sender, - origin_server_ts: ev.ts, - content: { msgtype: ev.msgtype, body: ev.body }, - type: 'm.room.message', - unsigned: {}, - } as IEventWithRoomId, + event: eventData, context: EMPTY_CONTEXT as IResultContext, }; const arr = byRoom.get(ev.roomId) ?? []; @@ -176,7 +191,7 @@ export const useMessageSearch = (params: MessageSearchParams) => { senders, hasTypes: hasHasTypes ? hasTypes : undefined, }); - inMemoryGroups = idbEventsToGroups(idbEvents); + inMemoryGroups = idbEventsToGroups(mx, idbEvents); usedIdb = true; } else { inMemoryGroups = searchEncryptedRoomsInMemory( @@ -214,7 +229,7 @@ export const useMessageSearch = (params: MessageSearchParams) => { senders, hasTypes, }); - unencryptedMemoryGroups = idbEventsToGroups(idbEvents); + unencryptedMemoryGroups = idbEventsToGroups(mx, idbEvents); usedIdbForUnencrypted = true; } else { unencryptedMemoryGroups = searchEncryptedRoomsInMemory( From 92b9073fce23f04ca4ecd232bb63d06f1cde9490 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 17:03:05 -0400 Subject: [PATCH 19/25] feat(search): store media content in IDB for full-history image/file results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extended IndexableEvent with url/file/info/filename fields so media events render correctly from IDB without requiring the live room timeline cache. Changes: - types.ts: add optional url/file/info/filename to IndexableEvent - toIndexableEvent: extract media fields from getContent() for m.image, m.file, m.audio, m.video - searchWorker: add new fields to storeFields; bump IDB schema to v3 (clears old index so all rooms re-backfill with full media content) - idbEventsToGroups: reconstruct full content from stored IDB fields; only fall back to m.text for pre-v3 entries that lack media fields Previously only events still in the live timeline window rendered as images — all older history showed 'Broken message'. After re-backfill, all indexed media events will render with full thumbnails and previews. --- .../message-search/useMessageSearch.ts | 51 ++++++++++++++++--- src/app/hooks/useSearchIndex.tsx | 20 +++++++- src/app/plugins/search-worker/searchWorker.ts | 15 ++++-- src/app/plugins/search-worker/types.ts | 9 ++++ 4 files changed, 82 insertions(+), 13 deletions(-) diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index 4fc481e3f..d9ce0809c 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -32,8 +32,16 @@ export type { SearchHasType }; * (m.image, m.file, m.audio, m.video) render with their full content * (url, file, info, …). Falls back to a plain-text synthetic event showing * the stored filename/body when the event is no longer in memory. + * + * For "recent" order, items within each group and the groups themselves are + * sorted newest-first so that mergeSearchGroups' timestamp-based interleaving + * (and its single-source early-return fast paths) both produce correct order. */ -function idbEventsToGroups(mx: Pick, events: IndexableEvent[]): ResultGroup[] { +function idbEventsToGroups( + mx: Pick, + events: IndexableEvent[], + order?: string +): ResultGroup[] { const byRoom = new Map(); for (const ev of events) { const liveEvent = mx.getRoom(ev.roomId)?.findEventById(ev.eventId); @@ -44,9 +52,18 @@ function idbEventsToGroups(mx: Pick, events: IndexableE room_id: ev.roomId, sender: ev.sender, origin_server_ts: ev.ts, - // Fall back to m.text so media renderers don't show "Broken message" - // when the event has scrolled out of the in-memory timeline. - content: { msgtype: 'm.text', body: ev.body }, + // Reconstruct full content from IDB-stored fields so media events + // (m.image, m.file, m.audio, m.video) render correctly even when + // the event is no longer in the live timeline window. + // Fall back to m.text only for pre-v3 index entries that lack media fields. + content: { + msgtype: ev.url !== undefined || ev.file !== undefined ? ev.msgtype : 'm.text', + body: ev.body, + ...(ev.url !== undefined && { url: ev.url }), + ...(ev.file !== undefined && { file: ev.file }), + ...(ev.info !== undefined && { info: ev.info }), + ...(ev.filename !== undefined && { filename: ev.filename }), + }, type: 'm.room.message', unsigned: {}, } as IEventWithRoomId; @@ -59,7 +76,27 @@ function idbEventsToGroups(mx: Pick, events: IndexableE arr.push(item); byRoom.set(ev.roomId, arr); } - return Array.from(byRoom.entries()).map(([roomId, items]) => ({ roomId, items })); + + const groups = Array.from(byRoom.entries()).map(([roomId, items]) => ({ + roomId, + // Sort items newest-first so items[0] is always the most recent — required + // for mergeSearchGroups' timestamp comparisons to be correct. + items: + order !== 'rank' + ? items.toSorted( + (a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0) + ) + : items, + })); + + // Sort groups newest-first so single-source fast-paths in mergeSearchGroups + // (which return the array unchanged) still produce correct recent order. + return order !== 'rank' + ? groups.toSorted( + (a, b) => + (b.items[0]?.event.origin_server_ts ?? 0) - (a.items[0]?.event.origin_server_ts ?? 0) + ) + : groups; } export type ResultItem = { @@ -191,7 +228,7 @@ export const useMessageSearch = (params: MessageSearchParams) => { senders, hasTypes: hasHasTypes ? hasTypes : undefined, }); - inMemoryGroups = idbEventsToGroups(mx, idbEvents); + inMemoryGroups = idbEventsToGroups(mx, idbEvents, order); usedIdb = true; } else { inMemoryGroups = searchEncryptedRoomsInMemory( @@ -229,7 +266,7 @@ export const useMessageSearch = (params: MessageSearchParams) => { senders, hasTypes, }); - unencryptedMemoryGroups = idbEventsToGroups(mx, idbEvents); + unencryptedMemoryGroups = idbEventsToGroups(mx, idbEvents, order); usedIdbForUnencrypted = true; } else { unencryptedMemoryGroups = searchEncryptedRoomsInMemory( diff --git a/src/app/hooks/useSearchIndex.tsx b/src/app/hooks/useSearchIndex.tsx index 70f5c6fe7..3c5de66f4 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -92,6 +92,8 @@ function scheduleIdle(cb: () => void): () => void { // ── Event conversion ────────────────────────────────────────────────────────── +const MEDIA_MSGTYPES = new Set(['m.image', 'm.file', 'm.audio', 'm.video']); + function toIndexableEvent(mEvent: MatrixEvent, roomId: string): IndexableEvent | null { const eventId = mEvent.getId(); if (!eventId) return null; @@ -99,13 +101,27 @@ function toIndexableEvent(mEvent: MatrixEvent, roomId: string): IndexableEvent | if (mEvent.getType() === 'm.room.encrypted') return null; if (mEvent.getType() !== (EventType.RoomMessage as string)) return null; if (mEvent.isRedacted()) return null; - const content = mEvent.getContent<{ body?: string; msgtype?: string }>(); + const content = mEvent.getContent<{ + body?: string; + msgtype?: string; + url?: string; + file?: Record; + info?: Record; + filename?: string; + }>(); const body: string = content.body ?? ''; if (!body.trim()) return null; const sender = mEvent.getSender(); if (!sender) return null; const msgtype = content.msgtype ?? 'm.text'; - return { eventId, roomId, sender, msgtype, body, ts: mEvent.getTs() }; + const base: IndexableEvent = { eventId, roomId, sender, msgtype, body, ts: mEvent.getTs() }; + if (MEDIA_MSGTYPES.has(msgtype)) { + if (content.url !== undefined) base.url = content.url; + if (content.file !== undefined) base.file = content.file; + if (content.info !== undefined) base.info = content.info; + if (content.filename !== undefined) base.filename = content.filename; + } + return base; } // ── Provider ───────────────────────────────────────────────────────────────── diff --git a/src/app/plugins/search-worker/searchWorker.ts b/src/app/plugins/search-worker/searchWorker.ts index c11386385..73b3648ad 100644 --- a/src/app/plugins/search-worker/searchWorker.ts +++ b/src/app/plugins/search-worker/searchWorker.ts @@ -12,7 +12,7 @@ import type { IndexableEvent, BackfillState, WorkerInMessage, WorkerOutMessage } function openDb(dbName: string): Promise { return new Promise((resolve, reject) => { - const req = indexedDB.open(dbName, 2); + const req = indexedDB.open(dbName, 3); req.onupgradeneeded = (event) => { const db = req.result; const oldVersion = event.oldVersion; @@ -20,13 +20,20 @@ function openDb(dbName: string): Promise { db.createObjectStore('index'); db.createObjectStore('backfill'); } - // v2: added msgtype to stored fields — clear persisted index/backfill so it rebuilds + // v2: added msgtype to stored fields if (oldVersion >= 1 && oldVersion < 2) { db.deleteObjectStore('index'); db.deleteObjectStore('backfill'); db.createObjectStore('index'); db.createObjectStore('backfill'); } + // v3: added url/file/info/filename to stored fields for media events + if (oldVersion >= 2 && oldVersion < 3) { + db.deleteObjectStore('index'); + db.deleteObjectStore('backfill'); + db.createObjectStore('index'); + db.createObjectStore('backfill'); + } }; req.addEventListener('success', () => resolve(req.result)); req.addEventListener('error', () => reject(req.error)); @@ -100,7 +107,7 @@ function makeIndex(): MiniSearch { return new MiniSearch({ idField: 'eventId', fields: ['body', 'sender'], - storeFields: ['eventId', 'roomId', 'sender', 'msgtype', 'ts', 'body'], + storeFields: ['eventId', 'roomId', 'sender', 'msgtype', 'ts', 'body', 'url', 'file', 'info', 'filename'], searchOptions: { boost: { body: 2 }, fuzzy: 0.2, @@ -203,7 +210,7 @@ async function handleInit(userId: string, maxPerRoom: number): Promise { index = MiniSearch.loadJSON(serialized, { idField: 'eventId', fields: ['body', 'sender'], - storeFields: ['eventId', 'roomId', 'sender', 'msgtype', 'ts', 'body'], + storeFields: ['eventId', 'roomId', 'sender', 'msgtype', 'ts', 'body', 'url', 'file', 'info', 'filename'], searchOptions: { boost: { body: 2 }, fuzzy: 0.2, diff --git a/src/app/plugins/search-worker/types.ts b/src/app/plugins/search-worker/types.ts index d98683c4e..b9eaa2442 100644 --- a/src/app/plugins/search-worker/types.ts +++ b/src/app/plugins/search-worker/types.ts @@ -7,6 +7,15 @@ export type IndexableEvent = { msgtype: string; body: string; ts: number; + // ── Media fields (present for m.image / m.file / m.audio / m.video) ── + /** mxc:// URL for unencrypted media. */ + url?: string; + /** EncryptedFile descriptor for encrypted media (contains url + key material). */ + file?: Record; + /** Dimensions, mimetype, size, thumbnail info, etc. */ + info?: Record; + /** Original filename (m.file). */ + filename?: string; }; export type BackfillState = { From caf37afeb1343be0b7f01787a851fddae62620f3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 18:03:27 -0400 Subject: [PATCH 20/25] =?UTF-8?q?fix(search):=20restore=20backfill=20speed?= =?UTF-8?q?=20=E2=80=94=20unlimited=20concurrency=20where=20requestIdleCal?= =?UTF-8?q?lback=20is=20available?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAX_CONCURRENT_BACKFILLS is now Infinity on desktop/Android (where the browser's idle scheduler is the natural throttle) and 4 on iOS (where we cap concurrency to protect the HTTP connection pool). Also restores the iOS fallback delay to 150ms (was raised to 1000ms in bf4d8d6, making backfill ~5x slower with no benefit beyond caution). --- src/app/hooks/useSearchIndex.tsx | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/app/hooks/useSearchIndex.tsx b/src/app/hooks/useSearchIndex.tsx index 3c5de66f4..e30c4feeb 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -73,20 +73,24 @@ export function useSearchIndex(): SearchIndexCtx | null { // ── Idle scheduler ─────────────────────────────────────────────────────────── /** - * Maximum number of rooms whose backfill pagination may run concurrently. - * Keeping this small prevents flooding the HTTP connection pool (and starving - * the /sync long-poll) on low-bandwidth or constrained devices such as iOS. + * On systems with requestIdleCallback (desktop, Android Chrome) the browser's + * own idle scheduler provides natural backpressure, so we allow unlimited + * concurrent backfills — this restores the fast pre-bf4d8d6 behaviour. + * + * On iOS Safari (no requestIdleCallback) we cap concurrency to prevent the + * HTTP connection pool from being saturated and starving the /sync long-poll. */ -const MAX_CONCURRENT_BACKFILLS = 2; +const HAS_IDLE_CALLBACK = typeof requestIdleCallback === 'function'; +const MAX_CONCURRENT_BACKFILLS = HAS_IDLE_CALLBACK ? Infinity : 4; function scheduleIdle(cb: () => void): () => void { - if (typeof requestIdleCallback === 'function') { + if (HAS_IDLE_CALLBACK) { const id = requestIdleCallback(cb, { timeout: 5000 }); return () => cancelIdleCallback(id); } - // iOS Safari does not support requestIdleCallback — use a longer delay so the - // sync connection is not starved by rapid back-to-back pagination requests. - const id = setTimeout(cb, 1000); + // iOS Safari: no requestIdleCallback — use a short delay; the concurrency + // cap (MAX_CONCURRENT_BACKFILLS) prevents HTTP connection pool saturation. + const id = setTimeout(cb, 150); return () => clearTimeout(id); } From a806f736b978427457a2f5daf5f9670d72935142 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 20 May 2026 10:18:00 -0400 Subject: [PATCH 21/25] fix(encrypted-search-idb): lint/type fixes across message-search and useSearchIndex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MessageSearch.tsx: move VALID_HAS_TYPES to module scope as Set (prefer-set-has); remove redundant !! on isRoom() call (no-unnecessary-type-conversion). - useMessageSearch.ts: remove unnecessary 'as IResultContext' on EMPTY_CONTEXT; remove two unnecessary non-null assertions on searchIndex. - searchEncryptedRooms.ts: fix no-unsafe-enum-comparison — cast mEvent.getType() to EventType before comparing with EventType.RoomMessage. - useSearchIndex.tsx: replace multi-line worker.postMessage with postToWorker() (eliminates multi-line oxlint-disable-next-line scope issue); add postToWorker to useEffect dep array; return () => {} in early exits for consistent-return. --- src/app/features/message-search/MessageSearch.tsx | 8 +++++--- src/app/features/message-search/searchEncryptedRooms.ts | 2 +- src/app/features/message-search/useMessageSearch.ts | 6 +++--- src/app/hooks/useSearchIndex.tsx | 9 ++++----- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index ac70c68d6..9f758f8c6 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -48,6 +48,9 @@ type MessageSearchProps = { senders?: string[]; scrollRef: RefObject; }; + +const VALID_HAS_TYPES = new Set(['image', 'file', 'audio', 'video', 'link']); + export function MessageSearch({ defaultRoomsFilterName, allowGlobal, @@ -57,7 +60,7 @@ export function MessageSearch({ }: Readonly) { const mx = useMatrixClient(); const mDirects = useAtomValue(mDirectAtom); - const allRoomsSelector = useCallback((rId: string) => !!isRoom(mx.getRoom(rId)), [mx]); + 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'); @@ -87,11 +90,10 @@ 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) + (t): t is SearchHasType => VALID_HAS_TYPES.has(t as SearchHasType) ); return decoded.length > 0 ? decoded : undefined; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/app/features/message-search/searchEncryptedRooms.ts b/src/app/features/message-search/searchEncryptedRooms.ts index 92f6cb425..c73b3e4af 100644 --- a/src/app/features/message-search/searchEncryptedRooms.ts +++ b/src/app/features/message-search/searchEncryptedRooms.ts @@ -66,7 +66,7 @@ export function searchRoomTimeline( 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.getType() as EventType) !== EventType.RoomMessage) continue; if (mEvent.isRedacted()) continue; const sender = mEvent.getSender(); diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index d9ce0809c..1ca3db1d6 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -70,7 +70,7 @@ function idbEventsToGroups( const item: ResultItem = { rank: 1, event: eventData, - context: EMPTY_CONTEXT as IResultContext, + context: EMPTY_CONTEXT, }; const arr = byRoom.get(ev.roomId) ?? []; arr.push(item); @@ -223,7 +223,7 @@ export const useMessageSearch = (params: MessageSearchParams) => { let usedIdb = false; if (encryptedSearchEnabled && isFirstPage && encryptedRoomIds.length > 0) { if (useIdbSearch && (term || hasHasTypes)) { - const idbEvents = await searchIndex!.query(term ?? '', { + const idbEvents = await searchIndex.query(term ?? '', { roomIds: encryptedRoomIds, senders, hasTypes: hasHasTypes ? hasTypes : undefined, @@ -261,7 +261,7 @@ export const useMessageSearch = (params: MessageSearchParams) => { unencryptedRoomCount = unencryptedRooms.length; if (unencryptedRooms.length > 0) { if (useIdbSearch) { - const idbEvents = await searchIndex!.query('', { + const idbEvents = await searchIndex.query('', { roomIds: unencryptedRooms, senders, hasTypes, diff --git a/src/app/hooks/useSearchIndex.tsx b/src/app/hooks/useSearchIndex.tsx index e30c4feeb..bfad6e7d2 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -395,11 +395,11 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { useEffect(() => { if (!idbSearchIndex) { setIsReady(false); - return; + return () => {}; } const userId = mx.getUserId(); - if (!userId) return; + if (!userId) return () => {}; const worker = new Worker( new URL('../plugins/search-worker/searchWorker.ts', import.meta.url), @@ -408,8 +408,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { workerRef.current = worker; worker.addEventListener('message', handleWorkerMessage); - // oxlint-disable-next-line require-post-message-target-origin -- Worker.postMessage has no targetOrigin - worker.postMessage({ + postToWorker({ type: 'INIT', userId, maxMessagesPerRoom: searchIndexMessageLimit, @@ -460,7 +459,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { headlessSets.clear(); backfillQueueRef.current = []; }; - }, [idbSearchIndex, mx, searchIndexMessageLimit, handleWorkerMessage, indexEvent, resumeBackfill]); + }, [idbSearchIndex, mx, searchIndexMessageLimit, handleWorkerMessage, indexEvent, resumeBackfill, postToWorker]); // ── Public API ───────────────────────────────────────────────────────────── From bc202f0028dc63e49d4bb04be66aa18a54a687fc Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 20 May 2026 12:32:34 -0400 Subject: [PATCH 22/25] fix(search): enqueue rooms added after initial backfill start\n\nSliding sync begins with an initial window of 100 rooms, so\n`startBackfill` only sees those 100 when it first runs. Additional\nrooms are loaded progressively as the list window expands, firing\n`ClientEvent.Room` on the Matrix client. A new listener for that event\nenqueues each newly-discovered room (using its persisted backfill state\nif present, or a fresh default state) so all rooms are eventually\nindexed, not just the initial 100." --- src/app/hooks/useSearchIndex.tsx | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/app/hooks/useSearchIndex.tsx b/src/app/hooks/useSearchIndex.tsx index bfad6e7d2..34316c2b1 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -163,6 +163,10 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { const backfillQueueRef = useRef>([]); // Current Matrix sync state — used to pause backfill when sync is struggling const syncStateRef = useRef(null); + // Persisted backfill states received from the worker — used by the ClientEvent.Room + // listener to correctly handle rooms that are added after the initial startBackfill call + // (e.g. rooms loaded by sliding sync after the initial window of 100). + const backfillStatesRef = useRef>({}); const postToWorker = useCallback((msg: WorkerInMessage) => { // oxlint-disable-next-line require-post-message-target-origin -- Worker.postMessage has no targetOrigin @@ -314,6 +318,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { const startBackfill = useCallback( (backfillStates: Record) => { + backfillStatesRef.current = backfillStates; const rooms = mx.getRooms().filter((r) => !r.isSpaceRoom()); // Enqueue all unfinished rooms that are not already active @@ -434,6 +439,25 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { }; mx.on(RoomEvent.Timeline, handleTimeline as unknown as (...args: unknown[]) => void); + // Enqueue rooms added by sliding sync after the initial startBackfill call. + // Sliding sync starts with an initial window of 100 rooms; additional rooms + // are received progressively as the list expands, firing ClientEvent.Room. + const handleRoomAdded = (room: Room) => { + if (room.isSpaceRoom()) return; + if (backfillingRoomsRef.current.has(room.roomId)) return; + if (backfillQueueRef.current.some((e) => e.room.roomId === room.roomId)) return; + const state = backfillStatesRef.current[room.roomId] ?? { + token: null, + done: false, + indexedCount: 0, + }; + if (state.done) return; + backfillQueueRef.current.push({ room, state }); + setIsBackfilling(true); + resumeBackfill(); + }; + mx.on(ClientEvent.Room, handleRoomAdded as unknown as (...args: unknown[]) => void); + return () => { worker.removeEventListener('message', handleWorkerMessage); worker.terminate(); @@ -445,6 +469,10 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { RoomEvent.Timeline, handleTimeline as unknown as (...args: unknown[]) => void ); + mx.removeListener( + ClientEvent.Room, + handleRoomAdded as unknown as (...args: unknown[]) => void + ); // Cancel all pending idle callbacks // eslint-disable-next-line react-hooks/exhaustive-deps -- mutable non-DOM refs, current is intentional at cleanup time From f63a3edd088237fbb1521ae6c22f5be51ab141af Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 20 May 2026 13:26:31 -0400 Subject: [PATCH 23/25] style: fix formatting --- .../message-search/useMessageSearch.ts | 9 ++--- src/app/hooks/useSearchIndex.tsx | 16 +++++++- src/app/plugins/search-worker/searchWorker.ts | 38 ++++++++++++++++--- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index 1ca3db1d6..357a2d65c 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -47,7 +47,7 @@ function idbEventsToGroups( const liveEvent = mx.getRoom(ev.roomId)?.findEventById(ev.eventId); const eventData: IEventWithRoomId = liveEvent ? toSearchEvent(liveEvent, ev.roomId) - : { + : ({ event_id: ev.eventId, room_id: ev.roomId, sender: ev.sender, @@ -66,7 +66,7 @@ function idbEventsToGroups( }, type: 'm.room.message', unsigned: {}, - } as IEventWithRoomId; + } as IEventWithRoomId); const item: ResultItem = { rank: 1, event: eventData, @@ -288,9 +288,8 @@ export const useMessageSearch = (params: MessageSearchParams) => { ), // Only report local-cache count for rooms that were actually searched in-memory. inMemoryRoomCount: - ((usedIdb ? 0 : encryptedRoomIds.length) + - (usedIdbForUnencrypted ? 0 : unencryptedRoomCount)) || - undefined, + (usedIdb ? 0 : encryptedRoomIds.length) + + (usedIdbForUnencrypted ? 0 : unencryptedRoomCount) || undefined, }; } diff --git a/src/app/hooks/useSearchIndex.tsx b/src/app/hooks/useSearchIndex.tsx index 34316c2b1..dfeda0ece 100644 --- a/src/app/hooks/useSearchIndex.tsx +++ b/src/app/hooks/useSearchIndex.tsx @@ -426,7 +426,11 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { // When sync recovers, restart any rooms that were paused mid-backfill. const handleSync = (state: SyncState) => { syncStateRef.current = state; - if (state === SyncState.Syncing || state === SyncState.Prepared || state === SyncState.Catchup) { + if ( + state === SyncState.Syncing || + state === SyncState.Prepared || + state === SyncState.Catchup + ) { resumeBackfill(); } }; @@ -487,7 +491,15 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { headlessSets.clear(); backfillQueueRef.current = []; }; - }, [idbSearchIndex, mx, searchIndexMessageLimit, handleWorkerMessage, indexEvent, resumeBackfill, postToWorker]); + }, [ + idbSearchIndex, + mx, + searchIndexMessageLimit, + handleWorkerMessage, + indexEvent, + resumeBackfill, + postToWorker, + ]); // ── Public API ───────────────────────────────────────────────────────────── diff --git a/src/app/plugins/search-worker/searchWorker.ts b/src/app/plugins/search-worker/searchWorker.ts index 73b3648ad..1053f87ad 100644 --- a/src/app/plugins/search-worker/searchWorker.ts +++ b/src/app/plugins/search-worker/searchWorker.ts @@ -107,7 +107,18 @@ function makeIndex(): MiniSearch { return new MiniSearch({ idField: 'eventId', fields: ['body', 'sender'], - storeFields: ['eventId', 'roomId', 'sender', 'msgtype', 'ts', 'body', 'url', 'file', 'info', 'filename'], + storeFields: [ + 'eventId', + 'roomId', + 'sender', + 'msgtype', + 'ts', + 'body', + 'url', + 'file', + 'info', + 'filename', + ], searchOptions: { boost: { body: 2 }, fuzzy: 0.2, @@ -185,9 +196,7 @@ const HAS_TYPE_TO_MSGTYPE: Record = { video: 'm.video', }; -function makeTypeFilter( - hasTypes: string[] | undefined -): ((ev: IndexableEvent) => boolean) | null { +function makeTypeFilter(hasTypes: string[] | undefined): ((ev: IndexableEvent) => boolean) | null { if (!hasTypes || hasTypes.length === 0) return null; const allowedMsgtypes = new Set(hasTypes.map((t) => HAS_TYPE_TO_MSGTYPE[t]).filter(Boolean)); const needsLink = hasTypes.includes('link'); @@ -210,7 +219,18 @@ async function handleInit(userId: string, maxPerRoom: number): Promise { index = MiniSearch.loadJSON(serialized, { idField: 'eventId', fields: ['body', 'sender'], - storeFields: ['eventId', 'roomId', 'sender', 'msgtype', 'ts', 'body', 'url', 'file', 'info', 'filename'], + storeFields: [ + 'eventId', + 'roomId', + 'sender', + 'msgtype', + 'ts', + 'body', + 'url', + 'file', + 'info', + 'filename', + ], searchOptions: { boost: { body: 2 }, fuzzy: 0.2, @@ -286,7 +306,13 @@ function handleIndexEvents(events: IndexableEvent[]): void { scheduleFlush(); } -function handleQuery(id: string, term: string, roomIds?: string[], senders?: string[], hasTypes?: string[]): void { +function handleQuery( + id: string, + term: string, + roomIds?: string[], + senders?: string[], + hasTypes?: string[] +): void { if (!index) { post({ type: 'QUERY_RESULT', id, events: [] }); return; From d1a953fbe1806769fc4af9910211e9dc23fdfc07 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 20 May 2026 15:00:09 -0400 Subject: [PATCH 24/25] fix(settings-sync): exclude searchIndexMessageLimit from cross-device sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search index capacity varies by device — mobile capped at 50 MB, desktop at 300 MB — so each device should keep its own limit rather than inheriting a desktop value. Add to NON_SYNCABLE_KEYS alongside the setting definition. --- src/app/utils/settingsSync.test.ts | 1 + src/app/utils/settingsSync.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index 608a94343..9c91b3cdc 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -33,6 +33,7 @@ describe('NON_SYNCABLE_KEYS', () => { 'memberSortFilterIndex', 'developerTools', 'settingsSyncEnabled', + 'searchIndexMessageLimit', ] as const; expected.forEach((key) => { diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts index 83c8ff11f..8be66853c 100644 --- a/src/app/utils/settingsSync.ts +++ b/src/app/utils/settingsSync.ts @@ -18,6 +18,9 @@ export const NON_SYNCABLE_KEYS = new Set([ 'developerTools', // Sync toggle itself must never be uploaded (it's device-local) 'settingsSyncEnabled', + // Search index capacity varies by device (mobile is capped at 50 MB, desktop at 300 MB), + // so each device should keep its own limit rather than inheriting a desktop value. + 'searchIndexMessageLimit', ]); export const SETTINGS_SYNC_VERSION = 1; From c77280ab50bfab0d9b9f498aacfbb7e5ce2f09cb Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 20 May 2026 16:41:25 -0400 Subject: [PATCH 25/25] fix: address copilot review comments on feat/encrypted-search-idb - Fix O(n\u00b2) backfill: snapshot event count before pagination, only index newly-fetched events instead of re-sending all accumulated events each page - Forward still-encrypted backfill events through indexEvent() so they are indexed once decryption keys become available (mirrors live-indexing path) - Reject in-flight search/stats promises on worker unmount so callers don\u2019t hang - Guard navigator.locks.request with availability check; fall back to a direct write on environments without the Web Locks API - Replace beforeunload listener in worker (never fired) with a FLUSH message: worker flushes and replies FLUSH_DONE; host waits up to 2 s before terminate - Replace MiniSearch private _storedFields API with a parallel storedDocs Map maintained alongside index.add/discard to avoid version-tied internals - Update changeset description: index covers all rooms (not only encrypted) and explicitly notes plaintext storage in IndexedDB - Fix global search mode: pass allRooms to SearchFilters when global=true; hoist isGlobal to component scope so it can be used in JSX and useMemo --- .changeset/encrypted-search-idb.md | 2 +- .../features/message-search/MessageSearch.tsx | 8 ++- src/app/hooks/useSearchIndex.tsx | 62 ++++++++++++++++--- src/app/plugins/search-worker/searchWorker.ts | 43 ++++++++----- src/app/plugins/search-worker/types.ts | 6 ++ 5 files changed, 92 insertions(+), 29 deletions(-) diff --git a/.changeset/encrypted-search-idb.md b/.changeset/encrypted-search-idb.md index 30f9990f1..2b8b14a87 100644 --- a/.changeset/encrypted-search-idb.md +++ b/.changeset/encrypted-search-idb.md @@ -2,4 +2,4 @@ default: minor --- -Add IndexedDB-backed persistent search index for encrypted rooms via a MiniSearch web worker with multi-tab write safety and LRU eviction. +Add IndexedDB-backed persistent search index for all rooms via a MiniSearch web worker with multi-tab write safety and LRU eviction. Note: indexed message text is stored unencrypted in IndexedDB. diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 9f758f8c6..434a2bb16 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -99,8 +99,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 { @@ -111,6 +112,7 @@ export function MessageSearch({ hasTypes: searchParamHasTypes, }; }, [ + isGlobal, searchPathSearchParams, searchParamRooms, searchParamsSenders, @@ -265,8 +267,8 @@ export function MessageSearch({ toIndexableEvent(ev, room.roomId)) - .filter((ev): ev is IndexableEvent => ev !== null); + // Only process events added by this pagination pass. The headless timeline + // accumulates all paginated events, so slicing from prevEventCount avoids + // re-indexing already-seen events (O(n²) → O(n) per page). + const allEvents = headlessTimeline.getEvents(); + const newEvents = allEvents.slice(0, allEvents.length - prevEventCount); + + const events: IndexableEvent[] = []; + for (const ev of newEvents) { + if (ev.getType() === 'm.room.encrypted') { + // Still encrypted — re-use the live-indexing path which registers a + // Decrypted listener so the event is indexed once keys arrive. + indexEvent(ev, room); + } else { + const indexable = toIndexableEvent(ev, room.roomId); + if (indexable) events.push(indexable); + } + } if (events.length > 0) { postToWorker({ type: 'INDEX_EVENTS', events }); @@ -293,7 +308,7 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { } } }, - [mx, postToWorker] + [mx, indexEvent, postToWorker] ); /** @@ -463,9 +478,21 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { mx.on(ClientEvent.Room, handleRoomAdded as unknown as (...args: unknown[]) => void); return () => { + // Ask the worker to flush before terminating. We wait up to 2 s then + // force-terminate regardless so the cleanup never hangs. worker.removeEventListener('message', handleWorkerMessage); - worker.terminate(); - workerRef.current = null; + postToWorker({ type: 'FLUSH' }); + const terminateTimeout = setTimeout(() => { + worker.terminate(); + workerRef.current = null; + }, 2000); + worker.addEventListener('message', (ev: MessageEvent) => { + if (ev.data.type === 'FLUSH_DONE') { + clearTimeout(terminateTimeout); + worker.terminate(); + workerRef.current = null; + } + }, { once: true }); setIsReady(false); setIsBackfilling(false); mx.removeListener(ClientEvent.Sync, handleSync as unknown as (...args: unknown[]) => void); @@ -490,6 +517,25 @@ export function SearchIndexProvider({ children }: { children: ReactNode }) { backfillingRooms.clear(); headlessSets.clear(); backfillQueueRef.current = []; + + // Reject any in-flight search/stats promises so callers don't hang forever + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutable non-DOM refs, current is intentional at cleanup time + const pendingQueries = pendingQueriesRef.current; + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutable non-DOM refs, current is intentional at cleanup time + const pendingStats = pendingStatsRef.current; + for (const { reject } of pendingQueries.values()) { + reject(new Error('Search index unmounted')); + } + pendingQueries.clear(); + if (pendingStats) { + pendingStats.resolve({ + indexedEventCount: 0, + roomCount: 0, + estimatedBytes: 0, + backfillingRoomCount: 0, + }); + pendingStatsRef.current = null; + } }; }, [ idbSearchIndex, diff --git a/src/app/plugins/search-worker/searchWorker.ts b/src/app/plugins/search-worker/searchWorker.ts index 1053f87ad..c766b2af6 100644 --- a/src/app/plugins/search-worker/searchWorker.ts +++ b/src/app/plugins/search-worker/searchWorker.ts @@ -97,6 +97,13 @@ let maxMessagesPerRoom = 2000; */ const roomQueues = new Map>(); +/** + * Parallel store of all indexed documents, keyed by eventId. + * Maintained alongside the MiniSearch index to avoid relying on + * private MiniSearch internals (_storedFields) for full-scan queries. + */ +const storedDocs = new Map(); + /** Dirty flag — index changed since last flush */ let dirty = false; @@ -144,7 +151,7 @@ function scheduleFlush(): void { async function flushIndex(): Promise { if (!db || !index || !dirty) return; try { - await navigator.locks.request('sable-search-index-writer', { mode: 'exclusive' }, async () => { + const doFlush = async () => { if (!db || !index) return; // Persist serialized MiniSearch await idbPut(db, 'index', 'v1', JSON.stringify(index)); @@ -155,7 +162,12 @@ async function flushIndex(): Promise { } await idbPut(db, 'index', 'rooms', roomQueuesData); dirty = false; - }); + }; + if ('locks' in navigator) { + await navigator.locks.request('sable-search-index-writer', { mode: 'exclusive' }, doFlush); + } else { + await doFlush(); + } } catch { // Non-fatal: will retry on next flush } @@ -173,19 +185,15 @@ function evictOldestForRoom(roomId: string): void { const toRemove = queue.splice(0, excess); for (const [eventId] of toRemove) { index.discard(eventId); + storedDocs.delete(eventId); } } // ── Message handler ──────────────────────────────────────────────────────── -/** Iterate every document stored in the MiniSearch index (uses internal _storedFields — tied to MiniSearch v7). */ -function iterateStoredDocs(idx: MiniSearch): IterableIterator { - const internal = idx as unknown as { _storedFields: Map> }; - return (function* () { - for (const fields of internal._storedFields.values()) { - yield fields as IndexableEvent; - } - })(); +/** Iterate every document stored in the index using the parallel storedDocs map. */ +function iterateStoredDocs(): IterableIterator { + return storedDocs.values(); } /** Matrix msgtype for each SearchHasType chip. */ @@ -273,7 +281,7 @@ function handleIndexEvents(events: IndexableEvent[]): void { if (index.has(ev.eventId)) continue; index.add(ev); - + storedDocs.set(ev.eventId, ev); let queue = roomQueues.get(ev.roomId); if (!queue) { queue = []; @@ -330,7 +338,7 @@ function handleQuery( if (!term) { // Chip-only query: scan all stored documents — MiniSearch can't search an empty term. const results: IndexableEvent[] = []; - for (const ev of iterateStoredDocs(index)) { + for (const ev of iterateStoredDocs()) { if (matchesFilters(ev)) results.push(ev); } post({ type: 'QUERY_RESULT', id, events: results }); @@ -380,6 +388,7 @@ async function handleClearIndex(): Promise { if (!db) return; index = makeIndex(); roomQueues.clear(); + storedDocs.clear(); dirty = false; await idbClear(db, 'index'); await idbClear(db, 'backfill'); @@ -409,12 +418,12 @@ self.addEventListener('message', (event: MessageEvent) => { case 'CLEAR_INDEX': void handleClearIndex(); break; + case 'FLUSH': + void flushIndex().then(() => { + self.postMessage({ type: 'FLUSH_DONE' }); + }); + break; default: break; } }); - -// Flush on termination -self.addEventListener('beforeunload', () => { - void flushIndex(); -}); diff --git a/src/app/plugins/search-worker/types.ts b/src/app/plugins/search-worker/types.ts index b9eaa2442..f1cba3d66 100644 --- a/src/app/plugins/search-worker/types.ts +++ b/src/app/plugins/search-worker/types.ts @@ -61,6 +61,9 @@ export type WorkerInMessage = } | { type: 'CLEAR_INDEX'; + } + | { + type: 'FLUSH'; }; // ── Worker → Main ────────────────────────────────────────────────────────── @@ -89,4 +92,7 @@ export type WorkerOutMessage = | { type: 'ERROR'; message: string; + } + | { + type: 'FLUSH_DONE'; };