feat: sender filter for message search with from:@user autocomplete
CI / Build & Quality Checks (push) Successful in 10m17s

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 22:07:53 -04:00
parent ec110d4ef7
commit 6957e890df
4 changed files with 300 additions and 45 deletions
@@ -214,9 +214,19 @@ export function MessageSearch({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const localResult = useMemo(() => { const localResult = useMemo(() => {
if (!msgSearchParams.term) return null; 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 // 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({ const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
enabled: !!msgSearchParams.term, 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 lastVItem = vItems[vItems.length - 1];
const lastVItemIndex: number | undefined = lastVItem?.index; const lastVItemIndex: number | undefined = lastVItem?.index;
const lastGroupIndex = groups.length - 1; const lastGroupIndex = groups.length - 1;
@@ -332,6 +365,7 @@ export function MessageSearch({
searchInputRef={searchInputRef} searchInputRef={searchInputRef}
onSearch={handleSearch} onSearch={handleSearch}
onReset={handleSearchClear} onReset={handleSearchClear}
onSenderAdd={handleSenderAdd}
/> />
<SearchFilters <SearchFilters
defaultRoomsFilterName={defaultRoomsFilterName} defaultRoomsFilterName={defaultRoomsFilterName}
@@ -343,6 +377,8 @@ export function MessageSearch({
onGlobalChange={handleGlobalChange} onGlobalChange={handleGlobalChange}
order={msgSearchParams.order} order={msgSearchParams.order}
onOrderChange={handleOrderChange} onOrderChange={handleOrderChange}
selectedSenders={searchParamsSenders}
onSelectedSendersChange={handleSelectedSendersChange}
/> />
</Box> </Box>
@@ -29,6 +29,7 @@ import { SearchOrderBy } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart } from '../../utils/matrix';
import { getRoomIconSrc } from '../../utils/room'; import { getRoomIconSrc } from '../../utils/room';
import { factoryRoomIdByAtoZ } from '../../utils/sort'; import { factoryRoomIdByAtoZ } from '../../utils/sort';
import { import {
@@ -335,6 +336,8 @@ type SearchFiltersProps = {
onGlobalChange: (global?: boolean) => void; onGlobalChange: (global?: boolean) => void;
order?: string; order?: string;
onOrderChange: (order?: string) => void; onOrderChange: (order?: string) => void;
selectedSenders?: string[];
onSelectedSendersChange: (senders?: string[]) => void;
}; };
export function SearchFilters({ export function SearchFilters({
defaultRoomsFilterName, defaultRoomsFilterName,
@@ -346,6 +349,8 @@ export function SearchFilters({
order, order,
onGlobalChange, onGlobalChange,
onOrderChange, onOrderChange,
selectedSenders,
onSelectedSendersChange,
}: SearchFiltersProps) { }: SearchFiltersProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
@@ -403,6 +408,30 @@ export function SearchFilters({
selectedRooms={selectedRooms} selectedRooms={selectedRooms}
onChange={onSelectedRoomsChange} onChange={onSelectedRoomsChange}
/> />
{selectedSenders && selectedSenders.length > 0 && (
<Line
style={{ margin: `${config.space.S100} 0` }}
direction="Vertical"
variant="Surface"
size="300"
/>
)}
{selectedSenders?.map((userId) => {
const user = mx.getUser(userId);
const name = user?.displayName ?? getMxIdLocalPart(userId) ?? userId;
return (
<Chip
key={userId}
variant="Primary"
radii="Pill"
before={<Icon size="50" src={Icons.User} />}
after={<Icon size="50" src={Icons.Cross} />}
onClick={() => onSelectedSendersChange(selectedSenders.filter((id) => id !== userId))}
>
<Text size="T200">{name}</Text>
</Chip>
);
})}
<Box grow="Yes" data-spacing-node /> <Box grow="Yes" data-spacing-node />
<OrderButton order={order} onChange={onOrderChange} /> <OrderButton order={order} onChange={onOrderChange} />
</Box> </Box>
+224 -42
View File
@@ -1,67 +1,249 @@
import React, { FormEventHandler, RefObject } from 'react'; import React, {
import { Box, Text, Input, Icon, Icons, Spinner, Chip, config } from 'folds'; 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; active?: boolean;
loading?: boolean; loading?: boolean;
searchInputRef: RefObject<HTMLInputElement>; searchInputRef: RefObject<HTMLInputElement>;
onSearch: (term: string) => void; onSearch: (term: string) => void;
onReset: () => 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<HTMLDivElement>(null);
const [fromQuery, setFromQuery] = useState('');
const [autocompleteAnchor, setAutocompleteAnchor] = useState<RectCords>();
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<HTMLInputElement> = (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<HTMLFormElement> = (evt) => { const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault(); evt.preventDefault();
const { searchInput } = evt.target as HTMLFormElement & { const { searchInput } = evt.target as HTMLFormElement & {
searchInput: HTMLInputElement; searchInput: HTMLInputElement;
}; };
const searchTerm = searchInput.value.trim() || undefined; const rawValue = searchInput.value.trim();
if (searchTerm) {
onSearch(searchTerm); // 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 ( return (
<Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}> <Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
<span data-spacing-node /> <span data-spacing-node />
<Text size="L400">Search</Text> <Text size="L400">Search</Text>
<Input <PopOut
ref={searchInputRef} anchor={autocompleteAnchor}
style={{ paddingRight: config.space.S300 }} position="Bottom"
name="searchInput" align="Start"
autoFocus offset={4}
size="500" content={
variant="Background" suggestedUsers.length > 0 ? (
placeholder="Search for keyword" <FocusTrap
autoComplete="off" focusTrapOptions={{
before={ initialFocus: false,
active && loading ? ( onDeactivate: closeAutocomplete,
<Spinner variant="Secondary" size="200" /> clickOutsideDeactivates: true,
) : ( allowOutsideClick: true,
<Icon size="200" src={Icons.Search} /> escapeDeactivates: stopPropagation,
) }}
}
after={
active ? (
<Chip
key="resetButton"
type="reset"
variant="Secondary"
size="400"
radii="Pill"
outlined
after={<Icon size="50" src={Icons.Cross} />}
onClick={onReset}
> >
<Text size="B300">Clear</Text> <Menu variant="Surface" style={{ minWidth: '240px', maxWidth: '320px' }}>
</Chip> <Box direction="Column" style={{ padding: `${config.space.S100} 0` }}>
) : ( {suggestedUsers.map((user) => {
<Chip type="submit" variant="Primary" size="400" radii="Pill" outlined> const displayName = user.displayName ?? getMxIdLocalPart(user.userId);
<Text size="B300">Enter</Text> const avatarUrl = user.avatarUrl
</Chip> ? (mxcUrlToHttp(mx, user.avatarUrl, useAuthentication, 32, 32, 'crop') ??
) undefined)
: undefined;
return (
<MenuItem
key={user.userId}
variant="Surface"
size="300"
radii="0"
// Prevent blur on the search input when clicking
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onClick={() => handleUserSelect(user.userId)}
before={
<Avatar size="200" radii="Pill">
<UserAvatar
userId={user.userId}
src={avatarUrl}
alt={displayName ?? user.userId}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Box direction="Column" style={{ minWidth: 0 }}>
<Text size="T300" truncate>
{displayName}
</Text>
<Text
size="T200"
truncate
style={{
opacity: 0.6,
fontFamily: 'monospace',
fontSize: '0.75em',
}}
>
{user.userId}
</Text>
</Box>
</MenuItem>
);
})}
</Box>
</Menu>
</FocusTrap>
) : undefined
} }
/> >
<Box ref={inputWrapRef}>
<Input
ref={searchInputRef}
style={{ paddingRight: config.space.S300 }}
name="searchInput"
autoFocus
size="500"
variant="Background"
placeholder="Search messages or type from:@user to filter by sender"
autoComplete="off"
onChange={handleChange}
before={
active && loading ? (
<Spinner variant="Secondary" size="200" />
) : (
<Icon size="200" src={Icons.Search} />
)
}
after={
active ? (
<Chip
key="resetButton"
type="reset"
variant="Secondary"
size="400"
radii="Pill"
outlined
after={<Icon size="50" src={Icons.Cross} />}
onClick={onReset}
>
<Text size="B300">Clear</Text>
</Chip>
) : (
<Chip type="submit" variant="Primary" size="400" radii="Pill" outlined>
<Text size="B300">Enter</Text>
</Chip>
)
}
/>
</Box>
</PopOut>
</Box> </Box>
); );
} }
@@ -3,6 +3,12 @@ import { useCallback } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { ResultGroup, ResultItem } from './useMessageSearch'; import { ResultGroup, ResultItem } from './useMessageSearch';
export type LocalSearchParams = {
term: string;
roomIds: string[];
senders?: string[];
};
export type LocalSearchResult = { export type LocalSearchResult = {
groups: ResultGroup[]; groups: ResultGroup[];
/** How many rooms in scope are encrypted */ /** How many rooms in scope are encrypted */
@@ -23,13 +29,14 @@ export const useLocalMessageSearch = () => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const search = useCallback( const search = useCallback(
(roomIds: string[], term: string): LocalSearchResult => { ({ term, roomIds, senders }: LocalSearchParams): LocalSearchResult => {
const trimmedTerm = term.trim(); const trimmedTerm = term.trim();
if (!trimmedTerm) { if (!trimmedTerm) {
return { groups: [], encryptedRoomsCount: 0, searchedRoomsCount: 0 }; return { groups: [], encryptedRoomsCount: 0, searchedRoomsCount: 0 };
} }
const termLower = trimmedTerm.toLowerCase(); const termLower = trimmedTerm.toLowerCase();
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
const groups: ResultGroup[] = []; const groups: ResultGroup[] = [];
let encryptedRoomsCount = 0; let encryptedRoomsCount = 0;
let searchedRoomsCount = 0; let searchedRoomsCount = 0;
@@ -56,6 +63,7 @@ export const useLocalMessageSearch = () => {
if (event.getType() !== EventType.RoomMessage) continue; if (event.getType() !== EventType.RoomMessage) continue;
if (event.isDecryptionFailure()) continue; if (event.isDecryptionFailure()) continue;
if (event.isRedacted()) continue; if (event.isRedacted()) continue;
if (senderSet && !senderSet.has(event.getSender() ?? '')) continue;
// getContent() returns decrypted plaintext regardless of encryption // getContent() returns decrypted plaintext regardless of encryption
const content = event.getContent(); const content = event.getContent();