diff --git a/.changeset/encrypted-search-memory.md b/.changeset/encrypted-search-memory.md new file mode 100644 index 000000000..8b4a3ae00 --- /dev/null +++ b/.changeset/encrypted-search-memory.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add in-memory encrypted message search with room/DM scoping, member picker avatars, and > quick-switcher prefix. 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/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} diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 893ff00eb..c076c9cee 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,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'; @@ -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] ); @@ -54,7 +57,8 @@ export function MessageSearch({ }: 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,9 +87,19 @@ 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 isGlobal = searchPathSearchParams.global === 'true'; const msgSearchParams: MessageSearchParams = useMemo(() => { - const isGlobal = searchPathSearchParams.global === 'true'; const defaultRooms = isGlobal ? undefined : rooms; return { @@ -93,19 +107,34 @@ export function MessageSearch({ 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: [ 'search', msgSearchParams.term, msgSearchParams.order, msgSearchParams.rooms, msgSearchParams.senders, + msgSearchParams.hasTypes, ], queryFn: ({ pageParam }) => searchMessages(pageParam), initialPageParam: '', @@ -117,6 +146,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, @@ -177,6 +208,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; @@ -207,7 +260,7 @@ export function MessageSearch({ - {!msgSearchParams.term && status === 'pending' && ( + {inMemoryRoomCount > 0 && status !== 'pending' && ( + + + + {`${inMemoryRoomCount} ${inMemoryRoomCount === 1 ? 'room' : 'rooms'} searched from local cache only.`} + + + )} + + {!isSearching && status === 'pending' && ( )} - {msgSearchParams.term && groups.length === 0 && status === 'success' && ( + {isSearching && groups.length === 0 && status === 'success' && ( - No results found for {`"${msgSearchParams.term}"`} + {msgSearchParams.term ? ( + <>No results found for {`"${msgSearchParams.term}"`} + ) : ( + 'No results found.' + )} )} - {((msgSearchParams.term && status === 'pending') || + {((isSearching && status === 'pending') || (groups.length > 0 && vItems.length === 0)) && ( {Array.from({ length: 8 }).map(() => ( @@ -268,7 +344,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); @@ -269,6 +281,13 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto src={getRoomIconSrc(Icons, room.getType(), room.getJoinRule())} /> } + after={ + encryptedSearchActive && mx.isRoomEncrypted(roomId) ? ( + + + + ) : null + } > {room.name} @@ -317,28 +336,281 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto ); } +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 }, + { 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 = { + roomList: string[]; + selectedSenders?: string[]; + onChange: (senders?: string[]) => void; +}; + +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; +}; +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); + + 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) => + getMemberDisplayName(a).localeCompare(getMemberDisplayName(b)) + ); + }, [mx, roomList]); + + 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; + } + searchMembers(value); + }; + + 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 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 ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + + 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 ( + + + } + /> + + } + > + + + {getMemberDisplayName(member)} + + + {member.userId} + + + + + ); + })} +
+
+
+
+
+ + } + > + } + > + Add Sender + +
+ ); +} + type SearchFiltersProps = { defaultRoomsFilterName: string; allowGlobal?: boolean; roomList: string[]; + defaultRooms: string[]; selectedRooms?: string[]; onSelectedRoomsChange: (selectedRooms?: string[]) => void; global?: boolean; 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, allowGlobal, roomList, + defaultRooms, selectedRooms, onSelectedRoomsChange, global, order, onGlobalChange, onOrderChange, + hasTypes, + onHasTypesChange, + senders, + onSendersChange, }: SearchFiltersProps) { + const senderScope = selectedRooms && selectedRooms.length > 0 ? selectedRooms : defaultRooms; const mx = useMatrixClient(); return ( @@ -398,6 +670,41 @@ 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.test.ts b/src/app/features/message-search/searchEncryptedRooms.test.ts new file mode 100644 index 000000000..062e41273 --- /dev/null +++ b/src/app/features/message-search/searchEncryptedRooms.test.ts @@ -0,0 +1,301 @@ +import { describe, it, expect } from 'vitest'; +import { EventType } from '$types/matrix-sdk'; +import type { IEventWithRoomId, MatrixClient, 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; + msgtype?: string; + sender?: string; + ts?: number; + id?: string; + redacted?: boolean; +}): MatrixEvent { + return { + getType: () => overrides.type ?? EventType.RoomMessage, + getContent: () => ({ body: overrides.body ?? '', msgtype: overrides.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('hasTypes filter', () => { + it('returns image events when hasTypes includes "image"', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'photo.png', msgtype: 'm.image', id: '$img' }), + makeEvent({ body: 'hello', msgtype: 'm.text', id: '$txt' }), + ]); + const group = searchRoomTimeline(room, '', undefined, ['image']); + expect(group).toBeDefined(); + expect(group!.items).toHaveLength(1); + expect(group!.items[0]!.event.event_id).toBe('$img'); + }); + + it('returns file events when hasTypes includes "file"', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'doc.pdf', msgtype: 'm.file', id: '$file' }), + makeEvent({ body: 'hello', msgtype: 'm.text', id: '$txt' }), + ]); + const group = searchRoomTimeline(room, '', undefined, ['file']); + expect(group!.items).toHaveLength(1); + expect(group!.items[0]!.event.event_id).toBe('$file'); + }); + + it('returns audio events when hasTypes includes "audio"', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'voice.ogg', msgtype: 'm.audio', id: '$audio' }), + ]); + const group = searchRoomTimeline(room, '', undefined, ['audio']); + expect(group!.items).toHaveLength(1); + expect(group!.items[0]!.event.event_id).toBe('$audio'); + }); + + it('returns video events when hasTypes includes "video"', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'clip.mp4', msgtype: 'm.video', id: '$video' }), + ]); + const group = searchRoomTimeline(room, '', undefined, ['video']); + expect(group!.items).toHaveLength(1); + expect(group!.items[0]!.event.event_id).toBe('$video'); + }); + + it('returns events containing a URL when hasTypes includes "link"', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'check https://example.com out', msgtype: 'm.text', id: '$link' }), + makeEvent({ body: 'no url here', msgtype: 'm.text', id: '$nolink' }), + ]); + const group = searchRoomTimeline(room, '', undefined, ['link']); + expect(group!.items).toHaveLength(1); + expect(group!.items[0]!.event.event_id).toBe('$link'); + }); + + it('also matches http:// links for "link" type', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'see http://example.com', msgtype: 'm.text', id: '$http' }), + ]); + const group = searchRoomTimeline(room, '', undefined, ['link']); + expect(group!.items).toHaveLength(1); + expect(group!.items[0]!.event.event_id).toBe('$http'); + }); + + it('excludes non-matching message types', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'hello', msgtype: 'm.text', id: '$txt' }), + ]); + expect(searchRoomTimeline(room, '', undefined, ['image'])).toBeUndefined(); + }); + + it('matches multiple hasTypes in a single call (OR logic)', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'photo.png', msgtype: 'm.image', id: '$img' }), + makeEvent({ body: 'doc.pdf', msgtype: 'm.file', id: '$file' }), + makeEvent({ body: 'plain text', msgtype: 'm.text', id: '$txt' }), + ]); + const group = searchRoomTimeline(room, '', undefined, ['image', 'file']); + expect(group!.items).toHaveLength(2); + const ids = group!.items.map((i) => i.event.event_id); + expect(ids).toContain('$img'); + expect(ids).toContain('$file'); + }); + + it('can combine term and hasTypes filters', () => { + const room = makeRoom('!room:example.org', [ + makeEvent({ body: 'vacation photo', msgtype: 'm.image', id: '$match' }), + makeEvent({ body: 'random photo', msgtype: 'm.image', id: '$nomatch' }), + makeEvent({ body: 'vacation text', msgtype: 'm.text', id: '$text' }), + ]); + const group = searchRoomTimeline(room, 'vacation', undefined, ['image']); + expect(group!.items).toHaveLength(1); + expect(group!.items[0]!.event.event_id).toBe('$match'); + }); + }); +}); + +describe('searchEncryptedRoomsInMemory', () => { + 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 unknown as MatrixClient, '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 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 unknown as MatrixClient, '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 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 unknown as MatrixClient, [ + '!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 unknown as MatrixClient, ['!enc:example.org']); + expect(result.skipServerSearch).toBe(true); + expect(result.serverRooms).toBeUndefined(); + }); +}); + +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); + }); + + 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..a86118246 --- /dev/null +++ b/src/app/features/message-search/searchEncryptedRooms.ts @@ -0,0 +1,190 @@ +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: [], + 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[], + hasTypes?: SearchHasType[] +): 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; + + if (hasTypes && hasTypes.length > 0 && !mEventMatchesHasTypes(mEvent, hasTypes)) continue; + + if (!mEvent.getId()) continue; + + if (lowerTerm !== '') { + const body: string = mEvent.getContent().body ?? ''; + if (!body || !body.toLowerCase().includes(lowerTerm)) continue; + } + + 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[], + hasTypes?: SearchHasType[] +): 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, hasTypes); + 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..783d0d1b9 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -8,6 +8,17 @@ import type { } from '$types/matrix-sdk'; 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, + mergeSearchGroups, +} from './searchEncryptedRooms'; +import type { SearchHasType } from './searchEncryptedRooms'; + +export type { SearchHasType }; export type ResultItem = { rank: number; @@ -24,6 +35,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[] => { @@ -68,20 +81,104 @@ export type MessageSearchParams = { order?: string; rooms?: string[]; senders?: string[]; + hasTypes?: SearchHasType[]; }; export const useMessageSearch = (params: MessageSearchParams) => { const mx = useMatrixClient(); - const { term, order, rooms, senders } = params; + const { features } = useClientConfig(); + const settings = useAtomValue(settingsAtom); + 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) => { - if (!term) + const hasHasTypes = hasTypes && hasTypes.length > 0; + if (!term && !hasHasTypes) return { highlights: [], groups: [], }; const limit = 20; + // 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 + ? 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) + : []; + + // When there's no text term, skip server search (server requires search_term). + // For has: filters, scan all rooms' in-memory timelines (encrypted + unencrypted). + if (skipServerSearch || !term) { + let unencryptedMemoryGroups: ResultGroup[] = []; + let unencryptedRoomCount = 0; + 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 + // all non-encrypted joined rooms. + const unencryptedRooms = + rooms !== undefined + ? (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: [], + groups: mergeSearchGroups( + filterGroupsByHasType(inMemoryGroups), + unencryptedMemoryGroups, + order + ), + inMemoryRoomCount: encryptedRoomIds.length + unencryptedRoomCount, + }; + } + const requestBody: ISearchRequestBody = { search_categories: { room_events: { @@ -92,8 +189,9 @@ export const useMessageSearch = (params: MessageSearchParams) => { }, filter: { limit, - rooms, + rooms: serverRooms, senders, + ...(hasTypes?.includes('link') && { contains_url: true }), }, include_state: false, order_by: order as SearchOrderBy.Recent, @@ -106,9 +204,39 @@ export const useMessageSearch = (params: MessageSearchParams) => { body: requestBody, next_batch: nextBatch === '' ? undefined : nextBatch, }); - return parseSearchResult(r); + const serverResult = parseSearchResult(r); + const filteredServerResult = { + ...serverResult, + groups: filterGroupsByHasType(serverResult.groups), + }; + + if (inMemoryGroups.length === 0) { + return filteredServerResult; + } + + const termWords = term.split(/\s+/).filter(Boolean); + return { + ...filteredServerResult, + groups: mergeSearchGroups( + filteredServerResult.groups, + filterGroupsByHasType(inMemoryGroups), + order + ), + highlights: Array.from(new Set([...filteredServerResult.highlights, ...termWords])), + inMemoryRoomCount: encryptedRoomIds.length, + }; }, - [mx, term, order, rooms, senders] + [ + 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 f45e6172f..4aee4f277 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -45,8 +45,14 @@ 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 { + getDirectSearchPath, + getHomeSearchPath, + getSpaceSearchPath, + withSearchParam, +} from '$pages/pathUtils'; import { createLogger } from '$utils/debug'; import { getCanonicalAliasOrRoomId, @@ -371,6 +377,10 @@ 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); @@ -556,7 +566,9 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { }; const path = space ? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId)) - : getHomeSearchPath(); + : direct + ? getDirectSearchPath() + : getHomeSearchPath(); navigate(withSearchParam(path, searchParams)); }; @@ -670,13 +682,13 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { {(!room.isCallRoom() || chat) && ( <> - {!encryptedRoom && ( + {(!encryptedRoom || encryptedSearchEnabled) && ( - Search + {encryptedRoom ? 'Search (local cache)' : 'Search'} } > diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx index 0c928aa38..7d3cc5d8b 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,31 @@ 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 (selectedSpaceId) { + basePath = getSpaceSearchPath(selectedSpaceId); + } else { + basePath = getHomeSearchPath(); + } + + navigate(`${basePath}${qs}`); + requestClose(); + }, + [pathname, selectedSpaceId, navigate, requestClose] + ); + const queryHighlighRegex = result?.query ? makeHighlightRegex(result.query.split(' ')) : undefined; @@ -223,6 +250,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 +276,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 +380,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 +578,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 diff --git a/src/app/features/settings/experimental/EncryptedSearch.tsx b/src/app/features/settings/experimental/EncryptedSearch.tsx new file mode 100644 index 000000000..1091f72dc --- /dev/null +++ b/src/app/features/settings/experimental/EncryptedSearch.tsx @@ -0,0 +1,38 @@ +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 + 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/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 6cb2a9ad3..4eddfbb72 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -50,6 +50,16 @@ export type ClientConfig = { themeCatalogApprovedHostPrefixes?: string[]; settingsDefaults?: Partial; + + features?: { + polls?: boolean; + /** Operator kill-switch for in-memory search of encrypted rooms. + * Defaults to enabled (true) when not configured. + * Note: the user-facing setting (`encryptedSearch` in Settings) defaults to disabled, + * so users must opt in even when this flag is on. + */ + encryptedSearch?: boolean; + }; }; const ClientConfigContext = createContext(null); diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index aec62cc86..84d995558 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,6 +268,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) > {mobile ? null : } />} } /> + } /> - + {!hideText && ( @@ -298,6 +304,34 @@ 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..bc6eb4620 --- /dev/null +++ b/src/app/pages/client/direct/Search.tsx @@ -0,0 +1,54 @@ +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), diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 1ac57b756..046f7f0eb 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/'; @@ -57,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',