From 6957e890df421836612f5af7b347be563a9526e2 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 28 May 2026 22:07:53 -0400 Subject: [PATCH] feat: sender filter for message search with from:@user autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type 'from:@name' in the search box to filter by sender — a dropdown of matching users (avatar + display name + full ID) appears as you type and selecting one converts it into a removable sender chip in the filter bar. Multiple senders supported. Also works via manual entry on submit. - SearchInput: detects trailing 'from:@...' pattern on every keystroke, shows PopOut autocomplete from mx.getUsers(), onMouseDown prevents input blur when selecting, cleans up fragment after selection - SearchFilters: selectedSenders/onSelectedSendersChange props, sender chips rendered with user icon and X to remove - useLocalMessageSearch: filters cached events by sender set when senders param is provided (encrypted room search respects the filter too) - MessageSearch: handleSenderAdd deduplicates and writes to ?senders= URL param; localResult now passes senders to the local search Co-Authored-By: Claude Sonnet 4.6 --- .../features/message-search/MessageSearch.tsx | 40 ++- .../features/message-search/SearchFilters.tsx | 29 ++ .../features/message-search/SearchInput.tsx | 266 +++++++++++++++--- .../message-search/useLocalMessageSearch.ts | 10 +- 4 files changed, 300 insertions(+), 45 deletions(-) diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 21e3cc19f..9f6079a7c 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -214,9 +214,19 @@ export function MessageSearch({ // eslint-disable-next-line react-hooks/exhaustive-deps const localResult = useMemo(() => { if (!msgSearchParams.term) return null; - return searchLocalMessages(localSearchRooms, msgSearchParams.term); + return searchLocalMessages({ + term: msgSearchParams.term, + roomIds: localSearchRooms, + senders: msgSearchParams.senders, + }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchLocalMessages, localSearchRooms, msgSearchParams.term, cacheVersion]); + }, [ + searchLocalMessages, + localSearchRooms, + msgSearchParams.term, + msgSearchParams.senders, + cacheVersion, + ]); const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ enabled: !!msgSearchParams.term, @@ -297,6 +307,29 @@ export function MessageSearch({ }); }; + const handleSelectedSendersChange = useCallback( + (newSenders?: string[]) => { + setSearchParams((prevParams) => { + const p = new URLSearchParams(prevParams); + p.delete('senders'); + if (newSenders && newSenders.length > 0) { + p.append('senders', encodeSearchParamValueArray(newSenders)); + } + return p; + }); + }, + [setSearchParams], + ); + + const handleSenderAdd = useCallback( + (userId: string) => { + const current = searchParamsSenders ?? []; + if (current.includes(userId)) return; + handleSelectedSendersChange([...current, userId]); + }, + [searchParamsSenders, handleSelectedSendersChange], + ); + const lastVItem = vItems[vItems.length - 1]; const lastVItemIndex: number | undefined = lastVItem?.index; const lastGroupIndex = groups.length - 1; @@ -332,6 +365,7 @@ export function MessageSearch({ searchInputRef={searchInputRef} onSearch={handleSearch} onReset={handleSearchClear} + onSenderAdd={handleSenderAdd} /> diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx index 0762bcc38..c16f362cc 100644 --- a/src/app/features/message-search/SearchFilters.tsx +++ b/src/app/features/message-search/SearchFilters.tsx @@ -29,6 +29,7 @@ import { SearchOrderBy } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { getMxIdLocalPart } from '../../utils/matrix'; import { getRoomIconSrc } from '../../utils/room'; import { factoryRoomIdByAtoZ } from '../../utils/sort'; import { @@ -335,6 +336,8 @@ type SearchFiltersProps = { onGlobalChange: (global?: boolean) => void; order?: string; onOrderChange: (order?: string) => void; + selectedSenders?: string[]; + onSelectedSendersChange: (senders?: string[]) => void; }; export function SearchFilters({ defaultRoomsFilterName, @@ -346,6 +349,8 @@ export function SearchFilters({ order, onGlobalChange, onOrderChange, + selectedSenders, + onSelectedSendersChange, }: SearchFiltersProps) { const mx = useMatrixClient(); @@ -403,6 +408,30 @@ export function SearchFilters({ selectedRooms={selectedRooms} onChange={onSelectedRoomsChange} /> + {selectedSenders && selectedSenders.length > 0 && ( + + )} + {selectedSenders?.map((userId) => { + const user = mx.getUser(userId); + const name = user?.displayName ?? getMxIdLocalPart(userId) ?? userId; + return ( + } + after={} + onClick={() => onSelectedSendersChange(selectedSenders.filter((id) => id !== userId))} + > + {name} + + ); + })} diff --git a/src/app/features/message-search/SearchInput.tsx b/src/app/features/message-search/SearchInput.tsx index 533eb5fdb..2ff72faaf 100644 --- a/src/app/features/message-search/SearchInput.tsx +++ b/src/app/features/message-search/SearchInput.tsx @@ -1,67 +1,249 @@ -import React, { FormEventHandler, RefObject } from 'react'; -import { Box, Text, Input, Icon, Icons, Spinner, Chip, config } from 'folds'; +import React, { + ChangeEventHandler, + FormEventHandler, + RefObject, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; +import { + Avatar, + Box, + Chip, + config, + Icon, + Icons, + Input, + Menu, + MenuItem, + PopOut, + RectCords, + Spinner, + Text, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; +import { UserAvatar } from '../../components/user-avatar'; +import { stopPropagation } from '../../utils/keyboard'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -type SearchProps = { +// Matches "from:@anything" anywhere in the query (no trailing space required) +const FROM_REGEX = /from:@([^\s]*)/gi; +// For detecting active typing: matches the trailing "from:@..." at cursor end +const FROM_TYPING_REGEX = /from:@([^\s]*)$/i; + +type SearchInputProps = { active?: boolean; loading?: boolean; searchInputRef: RefObject; onSearch: (term: string) => void; onReset: () => void; + onSenderAdd?: (userId: string) => void; }; -export function SearchInput({ active, loading, searchInputRef, onSearch, onReset }: SearchProps) { +export function SearchInput({ + active, + loading, + searchInputRef, + onSearch, + onReset, + onSenderAdd, +}: SearchInputProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const inputWrapRef = useRef(null); + + const [fromQuery, setFromQuery] = useState(''); + const [autocompleteAnchor, setAutocompleteAnchor] = useState(); + + const allUsers = useMemo(() => mx.getUsers(), [mx]); + + const suggestedUsers = useMemo(() => { + const q = fromQuery.toLowerCase(); + return allUsers + .filter( + (u) => + u.userId !== mx.getUserId() && + (u.userId.toLowerCase().includes(q) || (u.displayName ?? '').toLowerCase().includes(q)), + ) + .slice(0, 8); + }, [allUsers, fromQuery, mx]); + + const closeAutocomplete = useCallback(() => { + setFromQuery(''); + setAutocompleteAnchor(undefined); + }, []); + + const handleUserSelect = useCallback( + (userId: string) => { + if (searchInputRef.current) { + searchInputRef.current.value = searchInputRef.current.value + .replace(FROM_TYPING_REGEX, '') + .replace(/\s+/g, ' ') + .trim(); + } + closeAutocomplete(); + onSenderAdd?.(userId); + // Return focus to input so user can keep typing + searchInputRef.current?.focus(); + }, + [searchInputRef, onSenderAdd, closeAutocomplete], + ); + + const handleChange: ChangeEventHandler = (evt) => { + const value = evt.target.value; + const match = FROM_TYPING_REGEX.exec(value); + if (match) { + setFromQuery(match[1]); + const rect = inputWrapRef.current?.getBoundingClientRect(); + if (rect) setAutocompleteAnchor(rect); + } else { + closeAutocomplete(); + } + }; + const handleSearchSubmit: FormEventHandler = (evt) => { evt.preventDefault(); const { searchInput } = evt.target as HTMLFormElement & { searchInput: HTMLInputElement; }; - const searchTerm = searchInput.value.trim() || undefined; - if (searchTerm) { - onSearch(searchTerm); + const rawValue = searchInput.value.trim(); + + // Extract any from:@userId fragments and turn them into sender filters + const fromMatches = [...rawValue.matchAll(FROM_REGEX)]; + fromMatches.forEach((match) => { + const raw = match[1]; + const userId = raw.startsWith('@') ? raw : `@${raw}`; + onSenderAdd?.(userId); + }); + + const searchTerm = rawValue.replace(FROM_REGEX, '').replace(/\s+/g, ' ').trim() || undefined; + + // Clean up the from: fragments from the visible input + if (fromMatches.length > 0 && searchInputRef.current) { + searchInputRef.current.value = searchTerm ?? ''; } + + if (searchTerm) onSearch(searchTerm); + closeAutocomplete(); }; return ( Search - - ) : ( - - ) - } - after={ - active ? ( - } - onClick={onReset} + 0 ? ( + - Clear - - ) : ( - - Enter - - ) + + + {suggestedUsers.map((user) => { + const displayName = user.displayName ?? getMxIdLocalPart(user.userId); + const avatarUrl = user.avatarUrl + ? (mxcUrlToHttp(mx, user.avatarUrl, useAuthentication, 32, 32, 'crop') ?? + undefined) + : undefined; + return ( + e.preventDefault()} + onClick={() => handleUserSelect(user.userId)} + before={ + + } + /> + + } + > + + + {displayName} + + + {user.userId} + + + + ); + })} + + + + ) : undefined } - /> + > + + + ) : ( + + ) + } + after={ + active ? ( + } + onClick={onReset} + > + Clear + + ) : ( + + Enter + + ) + } + /> + + ); } diff --git a/src/app/features/message-search/useLocalMessageSearch.ts b/src/app/features/message-search/useLocalMessageSearch.ts index 7fd40cdc8..b7340d269 100644 --- a/src/app/features/message-search/useLocalMessageSearch.ts +++ b/src/app/features/message-search/useLocalMessageSearch.ts @@ -3,6 +3,12 @@ import { useCallback } from 'react'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { ResultGroup, ResultItem } from './useMessageSearch'; +export type LocalSearchParams = { + term: string; + roomIds: string[]; + senders?: string[]; +}; + export type LocalSearchResult = { groups: ResultGroup[]; /** How many rooms in scope are encrypted */ @@ -23,13 +29,14 @@ export const useLocalMessageSearch = () => { const mx = useMatrixClient(); const search = useCallback( - (roomIds: string[], term: string): LocalSearchResult => { + ({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => { const trimmedTerm = term.trim(); if (!trimmedTerm) { return { groups: [], encryptedRoomsCount: 0, searchedRoomsCount: 0 }; } const termLower = trimmedTerm.toLowerCase(); + const senderSet = senders && senders.length > 0 ? new Set(senders) : null; const groups: ResultGroup[] = []; let encryptedRoomsCount = 0; let searchedRoomsCount = 0; @@ -56,6 +63,7 @@ export const useLocalMessageSearch = () => { if (event.getType() !== EventType.RoomMessage) continue; if (event.isDecryptionFailure()) continue; if (event.isRedacted()) continue; + if (senderSet && !senderSet.has(event.getSender() ?? '')) continue; // getContent() returns decrypted plaintext regardless of encryption const content = event.getContent();