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
@@ -1,6 +1,6 @@
import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem, Button } from 'folds';
import { useAtomValue } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
@@ -18,8 +18,14 @@ import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../
import { useRooms } from '../../state/hooks/roomList';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList';
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
import {
filterGroupsByMsgType,
MessageSearchParams,
MsgTypeFilter,
useMessageSearch,
} from './useMessageSearch';
import { useLocalMessageSearch } from './useLocalMessageSearch';
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput';
import { SearchFilters } from './SearchFilters';
@@ -167,6 +173,12 @@ export function MessageSearch({
const searchInputRef = useRef<HTMLInputElement>(null) as React.RefObject<HTMLInputElement>;
const scrollTopAnchorRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
// Client-side msgtype post-filter. Kept local — the Matrix search API cannot
// filter by msgtype server-side, so the server request is unaffected.
const [msgTypeFilters, setMsgTypeFilters] = useState<MsgTypeFilter[]>([]);
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
const [searchParams, setSearchParams] = useSearchParams();
const searchPathSearchParams = useSearchPathSearchParams(searchParams);
const { navigateRoom } = useRoomNavigate();
@@ -257,7 +269,10 @@ export function MessageSearch({
getNextPageParam: (lastPage) => lastPage.nextToken,
});
const groups = useMemo(() => data?.pages.flatMap((result) => result.groups) ?? [], [data]);
const groups = useMemo(() => {
const allGroups = data?.pages.flatMap((result) => result.groups) ?? [];
return filterGroupsByMsgType(allGroups, msgTypeFilters);
}, [data, msgTypeFilters]);
const highlights = useMemo(() => {
const mixed = data?.pages.flatMap((result) => result.highlights);
return Array.from(new Set(mixed));
@@ -278,7 +293,25 @@ export function MessageSearch({
newParams.append('term', term);
return newParams;
});
setRecentSearches((prev) => addRecentSearch(prev, term));
};
const handleRecentSearch = (term: string) => {
if (searchInputRef.current) {
searchInputRef.current.value = term;
}
handleSearch(term);
};
const handleToggleMsgTypeFilter = useCallback((msgType: MsgTypeFilter) => {
setMsgTypeFilters((prev) =>
prev.includes(msgType) ? prev.filter((t) => t !== msgType) : [...prev, msgType],
);
}, []);
const handleClearRecentSearches = useCallback(() => {
setRecentSearches([]);
}, [setRecentSearches]);
const handleSearchClear = () => {
if (searchInputRef.current) {
searchInputRef.current.value = '';
@@ -407,6 +440,9 @@ export function MessageSearch({
onSearch={handleSearch}
onReset={handleSearchClear}
onSenderAdd={handleSenderAdd}
recentSearches={recentSearches}
onRecentSearch={handleRecentSearch}
onClearRecentSearches={handleClearRecentSearches}
/>
<SearchFilters
defaultRoomsFilterName={defaultRoomsFilterName}
@@ -425,6 +461,8 @@ export function MessageSearch({
onDateRangeChange={handleDateRangeChange}
containsUrl={msgSearchParams.containsUrl}
onContainsUrlChange={handleContainsUrlChange}
msgTypeFilters={msgTypeFilters}
onToggleMsgTypeFilter={handleToggleMsgTypeFilter}
/>
</Box>