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>
@@ -25,6 +25,7 @@ import {
Input,
Badge,
RectCords,
IconSrc,
} from 'folds';
import { SearchOrderBy } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
@@ -41,6 +42,13 @@ import {
import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
import { VirtualTile } from '../../components/virtualizer';
import { stopPropagation } from '../../utils/keyboard';
import { MsgTypeFilter } from './useMessageSearch';
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo },
{ msgType: 'm.file', label: 'Files', icon: Icons.File },
{ msgType: 'm.video', label: 'Video', icon: Icons.VideoCamera },
];
type OrderButtonProps = {
order?: string;
@@ -674,6 +682,8 @@ type SearchFiltersProps = {
onDateRangeChange: (fromTs?: number, toTs?: number) => void;
containsUrl?: boolean;
onContainsUrlChange: (value?: boolean) => void;
msgTypeFilters: MsgTypeFilter[];
onToggleMsgTypeFilter: (msgType: MsgTypeFilter) => void;
};
export function SearchFilters({
defaultRoomsFilterName,
@@ -692,6 +702,8 @@ export function SearchFilters({
onDateRangeChange,
containsUrl,
onContainsUrlChange,
msgTypeFilters,
onToggleMsgTypeFilter,
}: SearchFiltersProps) {
const mx = useMatrixClient();
@@ -795,6 +807,34 @@ export function SearchFilters({
>
<Text size="T200">Has link</Text>
</Chip>
{MSG_TYPE_FILTER_OPTIONS.map(({ msgType, label, icon }) => {
const active = msgTypeFilters.includes(msgType);
return (
<Chip
key={msgType}
variant={active ? 'Success' : 'SurfaceVariant'}
outlined={active}
radii="Pill"
aria-pressed={active}
before={<Icon size="100" src={icon} />}
after={
active ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onToggleMsgTypeFilter(msgType);
}}
/>
) : undefined
}
onClick={() => onToggleMsgTypeFilter(msgType)}
>
<Text size="T200">{label}</Text>
</Chip>
);
})}
<DateRangeButton fromTs={fromTs} toTs={toTs} onChange={onDateRangeChange} />
<OrderButton order={order} onChange={onOrderChange} />
</Box>
@@ -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>
);
@@ -26,6 +26,31 @@ export type SearchResult = {
groups: ResultGroup[];
};
// Client-side msgtype post-filter. The Matrix search API cannot filter by
// msgtype server-side, so this is applied to already-returned results.
export type MsgTypeFilter = 'm.image' | 'm.file' | 'm.video';
/**
* Filter result groups to items whose event msgtype is in `msgTypes` (OR/union).
* Empty/absent filter returns groups unchanged. Now-empty groups are dropped.
*/
export const filterGroupsByMsgType = (
groups: ResultGroup[],
msgTypes: MsgTypeFilter[],
): ResultGroup[] => {
if (msgTypes.length === 0) return groups;
const allowed = new Set<string>(msgTypes);
return groups
.map((group) => ({
...group,
items: group.items.filter((item) => {
const msgtype = item.event.content?.msgtype;
return typeof msgtype === 'string' && allowed.has(msgtype);
}),
}))
.filter((group) => group.items.length > 0);
};
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = [];
+38
View File
@@ -0,0 +1,38 @@
import { atom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
const STORAGE_KEY = 'cinny_recent_searches_v1';
const MAX_RECENT_SEARCHES = 10;
// Internal atom persists as a plain string[] (JSON-serializable).
const internalAtom = atomWithStorage<string[]>(
STORAGE_KEY,
[],
createJSONStorage(() => localStorage),
);
/**
* Global atom: string[] of the most recent distinct, non-empty search terms.
* Most-recent first, deduped, capped at MAX_RECENT_SEARCHES.
* Backed by localStorage so recent searches survive page refreshes.
*/
export const recentSearchesAtom = atom(
(get): string[] => get(internalAtom),
(_get, set, updater: string[] | ((prev: string[]) => string[])) => {
set(internalAtom, (prev) => {
const prevList = Array.isArray(prev) ? prev : [];
const next = typeof updater === 'function' ? updater(prevList) : updater;
return next;
});
},
);
/**
* Prepend a search term: dedupes (case-sensitive), drops empties, caps at 10.
*/
export const addRecentSearch = (prev: string[], term: string): string[] => {
const trimmed = term.trim();
if (!trimmed) return prev;
const withoutDupe = prev.filter((t) => t !== trimmed);
return [trimmed, ...withoutDupe].slice(0, MAX_RECENT_SEARCHES);
};