Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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-memory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add in-memory encrypted message search with room/DM scoping, member picker avatars, and > quick-switcher prefix.
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
}
}
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 thread
Just-Insane marked this conversation as resolved.

/**
* 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
110 changes: 96 additions & 14 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 { 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 @@
order: searchParams.get('order') ?? undefined,
rooms: searchParams.get('rooms') ?? undefined,
senders: searchParams.get('senders') ?? undefined,
has: searchParams.get('has') ?? undefined,
}),
[searchParams]
);
Expand All @@ -54,7 +57,8 @@
}: 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]);

Check warning on line 60 in src/app/features/message-search/MessageSearch.tsx

View workflow job for this annotation

GitHub Actions / Lint

typescript-eslint(no-unnecessary-type-conversion)

This type conversion does not change the type or value of the expression.
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,29 +87,54 @@
}
return undefined;
}, [searchPathSearchParams.senders]);
const VALID_HAS_TYPES: SearchHasType[] = ['image', 'file', 'audio', 'video', 'link'];

Check warning on line 90 in src/app/features/message-search/MessageSearch.tsx

View workflow job for this annotation

GitHub Actions / Lint

eslint-plugin-unicorn(prefer-set-has)

should be a `Set`, and use `.has()` to check existence or non-existence.
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 isGlobal = searchPathSearchParams.global === 'true';

const msgSearchParams: MessageSearchParams = useMemo(() => {
const isGlobal = searchPathSearchParams.global === 'true';
const defaultRooms = isGlobal ? undefined : rooms;

return {
term: searchPathSearchParams.term,
order: searchPathSearchParams.order ?? SearchOrderBy.Recent,
rooms: searchParamRooms ?? defaultRooms,
senders: searchParamsSenders ?? senders,
hasTypes: searchParamHasTypes,
};
}, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);
}, [
isGlobal,
searchPathSearchParams,
searchParamRooms,
searchParamsSenders,
searchParamHasTypes,
rooms,
senders,
]);

const isSearching =
!!msgSearchParams.term ||
(!!msgSearchParams.hasTypes && msgSearchParams.hasTypes.length > 0);

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: [
Comment thread
Just-Insane marked this conversation as resolved.
'search',
msgSearchParams.term,
msgSearchParams.order,
msgSearchParams.rooms,
msgSearchParams.senders,
msgSearchParams.hasTypes,
],
queryFn: ({ pageParam }) => searchMessages(pageParam),
initialPageParam: '',
Expand All @@ -117,6 +146,8 @@
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 +208,28 @@
});
};

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 @@ -207,7 +260,7 @@
</ScrollTopContainer>
<Box ref={scrollTopAnchorRef} direction="Column" gap="300">
<SearchInput
active={!!msgSearchParams.term}
active={isSearching}
loading={status === 'pending'}
searchInputRef={searchInputRef}
onSearch={handleSearch}
Expand All @@ -216,17 +269,36 @@
<SearchFilters
defaultRoomsFilterName={defaultRoomsFilterName}
allowGlobal={allowGlobal}
roomList={searchPathSearchParams.global === 'true' ? allRooms : rooms}
roomList={isGlobal ? allRooms : rooms}
defaultRooms={isGlobal ? allRooms : 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>

{!msgSearchParams.term && status === 'pending' && (
{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>
)}

{!isSearching && status === 'pending' && (
<PageHeroEmpty>
<PageHeroSection>
<PageHero
Expand All @@ -238,7 +310,7 @@
</PageHeroEmpty>
)}

{msgSearchParams.term && groups.length === 0 && status === 'success' && (
{isSearching && groups.length === 0 && status === 'success' && (
<Box
className={ContainerColor({ variant: 'Warning' })}
style={{ padding: config.space.S300, borderRadius: config.radii.R400 }}
Expand All @@ -247,12 +319,16 @@
>
<Icon size="200" src={Icons.Info} />
<Text>
No results found for <b>{`"${msgSearchParams.term}"`}</b>
{msgSearchParams.term ? (
<>No results found for <b>{`"${msgSearchParams.term}"`}</b></>
) : (
'No results found.'
)}
</Text>
</Box>
)}

{((msgSearchParams.term && status === 'pending') ||
{((isSearching && status === 'pending') ||
(groups.length > 0 && vItems.length === 0)) && (
<Box direction="Column" gap="100">
{Array.from({ length: 8 }).map(() => (
Expand All @@ -268,7 +344,13 @@
{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