feat: sender filter for message search with from:@user autocomplete
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:
@@ -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<HTMLInputElement>;
|
||||
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<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) => {
|
||||
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 (
|
||||
<Box as="form" direction="Column" gap="100" onSubmit={handleSearchSubmit}>
|
||||
<span data-spacing-node />
|
||||
<Text size="L400">Search</Text>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
style={{ paddingRight: config.space.S300 }}
|
||||
name="searchInput"
|
||||
autoFocus
|
||||
size="500"
|
||||
variant="Background"
|
||||
placeholder="Search for keyword"
|
||||
autoComplete="off"
|
||||
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}
|
||||
<PopOut
|
||||
anchor={autocompleteAnchor}
|
||||
position="Bottom"
|
||||
align="Start"
|
||||
offset={4}
|
||||
content={
|
||||
suggestedUsers.length > 0 ? (
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: closeAutocomplete,
|
||||
clickOutsideDeactivates: true,
|
||||
allowOutsideClick: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Text size="B300">Clear</Text>
|
||||
</Chip>
|
||||
) : (
|
||||
<Chip type="submit" variant="Primary" size="400" radii="Pill" outlined>
|
||||
<Text size="B300">Enter</Text>
|
||||
</Chip>
|
||||
)
|
||||
<Menu variant="Surface" style={{ minWidth: '240px', maxWidth: '320px' }}>
|
||||
<Box direction="Column" style={{ padding: `${config.space.S100} 0` }}>
|
||||
{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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user