feat(search): has:image/file/video filters + recent searches

- Add three msgtype toggle chips (Images/Files/Video) to the search filter
  bar, mirroring the existing "Has link" chip. The Matrix search API can't
  filter by msgtype server-side, so results are post-filtered client-side
  (union match on event.content.msgtype, dropping now-empty groups); the
  server request is unchanged. Visible count may be lower than the server
  total — inherent to client-side filtering.
- Recent searches: last 10 distinct terms persisted via a new
  state/recentSearches.ts (atomWithStorage, error-safe, mirrors
  scheduledMessages). Shown as clickable chips when the search input is
  focused + empty, with a Clear affordance; clicking re-runs the search.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 14:22:08 -04:00
parent 4fbbd9680b
commit 1ee0f0b57a
5 changed files with 216 additions and 4 deletions
@@ -43,6 +43,9 @@ type SearchInputProps = {
onSearch: (term: string) => void;
onReset: () => void;
onSenderAdd?: (userId: string) => void;
recentSearches?: string[];
onRecentSearch?: (term: string) => void;
onClearRecentSearches?: () => void;
};
export function SearchInput({
active,
@@ -51,6 +54,9 @@ export function SearchInput({
onSearch,
onReset,
onSenderAdd,
recentSearches,
onRecentSearch,
onClearRecentSearches,
}: SearchInputProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
@@ -58,6 +64,8 @@ export function SearchInput({
const [fromQuery, setFromQuery] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false);
const [focused, setFocused] = useState(false);
const [inputEmpty, setInputEmpty] = useState(true);
// Collect users from room member lists, scored for relevance.
// Score: same homeserver → +1000, each shared room → +1.
@@ -121,6 +129,7 @@ export function SearchInput({
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const value = evt.target.value;
setInputEmpty(value.trim() === '');
const match = FROM_TYPING_REGEX.exec(value);
if (match) {
setFromQuery(match[1]);
@@ -130,9 +139,24 @@ export function SearchInput({
}
};
const handleFocus = () => {
setFocused(true);
};
// Close autocomplete when input loses focus — delay so item clicks fire first
const handleBlur = () => {
setTimeout(closeAutocomplete, 150);
setTimeout(() => {
closeAutocomplete();
setFocused(false);
}, 150);
};
const handleRecentClick = (term: string) => {
if (searchInputRef.current) {
searchInputRef.current.value = term;
}
setInputEmpty(false);
onRecentSearch?.(term);
};
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
@@ -181,6 +205,7 @@ export function SearchInput({
placeholder="Search messages or type from:@user to filter by sender"
autoComplete="off"
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
before={
active && loading ? (
@@ -267,6 +292,52 @@ export function SearchInput({
</Menu>
</div>
)}
{focused &&
inputEmpty &&
suggestedUsers.length === 0 &&
recentSearches &&
recentSearches.length > 0 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 999,
marginTop: config.space.S100,
}}
>
<Menu variant="Surface" style={{ width: '100%' }}>
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Recent searches</Text>
<Chip
variant="SurfaceVariant"
radii="Pill"
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onClick={() => onClearRecentSearches?.()}
>
<Text size="T200">Clear</Text>
</Chip>
</Box>
<Box gap="200" wrap="Wrap">
{recentSearches.map((term) => (
<Chip
key={term}
variant="SurfaceVariant"
radii="Pill"
before={<Icon size="50" src={Icons.RecentClock} />}
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onClick={() => handleRecentClick(term)}
>
<Text size="T200">{term}</Text>
</Chip>
))}
</Box>
</Box>
</Menu>
</div>
)}
</Box>
</Box>
);