Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
00d8b00
feat(search): in-memory search for encrypted rooms
Just-Insane May 19, 2026
2230714
feat(search): experimental toggle and lock icon for encrypted room se…
Just-Insane May 19, 2026
0472f32
feat(search): show search button in encrypted room headers
Just-Insane May 19, 2026
1d9ed75
fix(search): hide search icon in encrypted rooms unless feature is en…
Just-Insane May 19, 2026
cb3557a
feat(search): fix DM rooms, Discord-style has:/from: filters
Just-Insane May 19, 2026
6a73846
feat: DM search page and has: filters work without text term
Just-Insane May 19, 2026
7cceb35
fix(search): fix DM route, results label, nav style; add member picke…
Just-Insane May 19, 2026
f8294ca
feat(search): scope member picker to search context; add avatars and …
Just-Insane May 19, 2026
182fdbd
fix(search): fix DM NavLink blue text; extend has: in-memory search t…
Just-Insane May 19, 2026
09e362f
fix(search): scope has: scan and room picker to context; add global t…
Just-Insane May 19, 2026
1843f9e
perf(avatar): share SVG blob cache between room and user avatars
Just-Insane May 19, 2026
3e53cd8
feat(search): add > prefix for message search in quick-switcher
Just-Insane May 19, 2026
e175441
feat(search): Phase 2 IDB-backed search index for encrypted rooms
Just-Insane May 19, 2026
a575e52
chore: add changeset
Just-Insane May 19, 2026
27f87ec
style: apply oxfmt formatting
Just-Insane May 19, 2026
544658d
feat(search): extend IDB index to all rooms; chip filters for unencry…
Just-Insane May 19, 2026
282e483
fix(search): limit backfill concurrency and yield to main sync
Just-Insane May 19, 2026
2dcc47c
fix(search): resolve image/media results via live room cache
Just-Insane May 19, 2026
92b9073
feat(search): store media content in IDB for full-history image/file …
Just-Insane May 19, 2026
caf37af
fix(search): restore backfill speed — unlimited concurrency where req…
Just-Insane May 19, 2026
a806f73
fix(encrypted-search-idb): lint/type fixes across message-search and …
Just-Insane May 20, 2026
bc202f0
fix(search): enqueue rooms added after initial backfill start\n\nSlid…
Just-Insane May 20, 2026
f63a3ed
style: fix formatting
Just-Insane May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/encrypted-search-idb.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,9 @@
"hashRouter": {
"enabled": false,
"basename": "/"
},

"features": {
"encryptedSearch": true
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

87 changes: 69 additions & 18 deletions src/app/components/room-avatar/AvatarImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,56 @@
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<string, string>();

export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProps) {
const [uniformIconsSetting] = useSetting(settingsAtom, 'uniformIcons');
const [image, setImage] = useState<HTMLImageElement | undefined>(undefined);
const [processedSrc, setProcessedSrc] = useState<string>(src);
/** Number of SVG blob URLs currently held in the module-level cache. */
export function getSvgCacheSize(): number {
return svgBlobCache.size;
}

const useUniformIcons = uniformIconsSetting && uniformIcons === true;
const normalizedBg = useUniformIcons && image ? bgColorImg(image) : undefined;
/** Revoke all cached SVG blob URLs and clear the cache to free memory. */
export function clearSvgBlobCache(): void {
svgBlobCache.forEach((url) => URL.revokeObjectURL(url));
svgBlobCache.clear();
}
Comment on lines +9 to +24

/**
* 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<string | undefined>(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;
};

Check warning on line 57 in src/app/components/room-avatar/AvatarImage.tsx

View workflow job for this annotation

GitHub Actions / Lint

typescript-eslint(consistent-return)

Function expected no return value.
}

const processImage = async () => {
try {
Expand All @@ -46,8 +78,10 @@
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);
Expand All @@ -56,14 +90,31 @@

processImage();

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.
};

Check warning on line 97 in src/app/components/room-avatar/AvatarImage.tsx

View workflow job for this annotation

GitHub Actions / Lint

typescript-eslint(consistent-return)

Function expected no return value.
}, [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<HTMLImageElement | undefined>(undefined);
const processedSrc = useProcessedAvatarSrc(src) ?? src;

const useUniformIcons = uniformIconsSetting && uniformIcons === true;
const normalizedBg = useUniformIcons && image ? bgColorImg(image) : undefined;

const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true');
setImage(evt.currentTarget);
Expand Down
6 changes: 4 additions & 2 deletions src/app/components/user-avatar/UserAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -19,12 +20,13 @@ const handleImageLoad: ReactEventHandler<HTMLImageElement> = (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 (
<AvatarFallback
style={{ backgroundColor: colorMXID(userId), color: color.Surface.Container }}
Expand All @@ -38,7 +40,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
return (
<AvatarImage
className={classNames(css.UserAvatar, className)}
src={src}
src={processedSrc}
alt={alt}
onError={() => setError(true)}
onLoad={handleImageLoad}
Expand Down
90 changes: 82 additions & 8 deletions src/app/features/message-search/MessageSearch.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,12 +15,15 @@ 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 { 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';
import type { SearchHasType } from './useMessageSearch';
import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput';
import { SearchFilters } from './SearchFilters';
Expand All @@ -34,6 +36,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]
);
Expand All @@ -45,6 +48,9 @@ type MessageSearchProps = {
senders?: string[];
scrollRef: RefObject<HTMLDivElement | null>;
};

const VALID_HAS_TYPES = new Set<SearchHasType>(['image', 'file', 'audio', 'video', 'link']);

export function MessageSearch({
defaultRoomsFilterName,
allowGlobal,
Expand All @@ -54,7 +60,8 @@ export function MessageSearch({
}: Readonly<MessageSearchProps>) {
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');
Expand Down Expand Up @@ -83,6 +90,14 @@ export function MessageSearch({
}
return undefined;
}, [searchPathSearchParams.senders]);
const searchParamHasTypes = useMemo(() => {
if (!searchPathSearchParams.has) return undefined;
const decoded = decodeSearchParamValueArray(searchPathSearchParams.has).filter(
(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
}, [searchPathSearchParams.has]);

const msgSearchParams: MessageSearchParams = useMemo(() => {
const isGlobal = searchPathSearchParams.global === 'true';
Expand All @@ -93,19 +108,29 @@ 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);

const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
enabled: !!msgSearchParams.term,
enabled:
!!msgSearchParams.term || (!!msgSearchParams.hasTypes && msgSearchParams.hasTypes.length > 0),
queryKey: [
'search',
msgSearchParams.term,
msgSearchParams.order,
msgSearchParams.rooms,
msgSearchParams.senders,
msgSearchParams.hasTypes,
],
queryFn: ({ pageParam }) => searchMessages(pageParam),
initialPageParam: '',
Expand All @@ -117,6 +142,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,
Expand Down Expand Up @@ -177,6 +204,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;
Expand Down Expand Up @@ -216,16 +265,35 @@ export function MessageSearch({
<SearchFilters
defaultRoomsFilterName={defaultRoomsFilterName}
allowGlobal={allowGlobal}
roomList={searchPathSearchParams.global === 'true' ? allRooms : rooms}
roomList={rooms}
defaultRooms={rooms}
selectedRooms={searchParamRooms}
onSelectedRoomsChange={handleSelectedRoomsChange}
global={searchPathSearchParams.global === 'true'}
onGlobalChange={handleGlobalChange}
order={msgSearchParams.order}
onOrderChange={handleOrderChange}
hasTypes={searchParamHasTypes}
onHasTypesChange={handleHasTypesChange}
senders={searchParamsSenders ?? senders}
onSendersChange={handleSendersChange}
/>
</Box>

{inMemoryRoomCount > 0 && status !== 'pending' && (
<Box
className={ContainerColor({ variant: 'Secondary' })}
style={{ padding: config.space.S300, borderRadius: config.radii.R400 }}
alignItems="Center"
gap="200"
>
<Icon size="200" src={Icons.Info} />
<Text size="T300">
{`${inMemoryRoomCount} ${inMemoryRoomCount === 1 ? 'room' : 'rooms'} searched from local cache only.`}
</Text>
</Box>
)}

{!msgSearchParams.term && status === 'pending' && (
<PageHeroEmpty>
<PageHeroSection>
Expand Down Expand Up @@ -268,7 +336,13 @@ export function MessageSearch({
{vItems.length > 0 && (
<Box direction="Column" gap="300">
<Box direction="Column" gap="200">
<Text size="H5">{`Results for "${msgSearchParams.term}"`}</Text>
<Text size="H5">
{msgSearchParams.term
? `Results for "${msgSearchParams.term}"`
: msgSearchParams.hasTypes && msgSearchParams.hasTypes.length > 0
? `Results for ${msgSearchParams.hasTypes.join(', ')}`
: 'Results'}
</Text>
<Line size="300" variant="Surface" />
</Box>
<div
Expand Down
Loading
Loading