From 1ee0f0b57a2e3303a06ddb6557063c48c7cb5747 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 28 Jun 2026 14:22:08 -0400 Subject: [PATCH] feat(search): has:image/file/video filters + recent searches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../features/message-search/MessageSearch.tsx | 44 ++++++++++- .../features/message-search/SearchFilters.tsx | 40 ++++++++++ .../features/message-search/SearchInput.tsx | 73 ++++++++++++++++++- .../message-search/useMessageSearch.ts | 25 +++++++ src/app/state/recentSearches.ts | 38 ++++++++++ 5 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 src/app/state/recentSearches.ts diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index d1c72ed7a..5782409a4 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -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(null) as React.RefObject; const scrollTopAnchorRef = useRef(null) as React.RefObject; + + // 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([]); + 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} /> diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx index 864a77a22..284982d5e 100644 --- a/src/app/features/message-search/SearchFilters.tsx +++ b/src/app/features/message-search/SearchFilters.tsx @@ -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({ > Has link + {MSG_TYPE_FILTER_OPTIONS.map(({ msgType, label, icon }) => { + const active = msgTypeFilters.includes(msgType); + return ( + } + after={ + active ? ( + { + e.stopPropagation(); + onToggleMsgTypeFilter(msgType); + }} + /> + ) : undefined + } + onClick={() => onToggleMsgTypeFilter(msgType)} + > + {label} + + ); + })} diff --git a/src/app/features/message-search/SearchInput.tsx b/src/app/features/message-search/SearchInput.tsx index d30e2bd1f..c06d8cbfd 100644 --- a/src/app/features/message-search/SearchInput.tsx +++ b/src/app/features/message-search/SearchInput.tsx @@ -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 = (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 = (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({ )} + {focused && + inputEmpty && + suggestedUsers.length === 0 && + recentSearches && + recentSearches.length > 0 && ( +
+ + + + Recent searches + e.preventDefault()} + onClick={() => onClearRecentSearches?.()} + > + Clear + + + + {recentSearches.map((term) => ( + } + onMouseDown={(e: React.MouseEvent) => e.preventDefault()} + onClick={() => handleRecentClick(term)} + > + {term} + + ))} + + + +
+ )} ); diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index 40bb2187d..7c7826fc5 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -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(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[] = []; diff --git a/src/app/state/recentSearches.ts b/src/app/state/recentSearches.ts new file mode 100644 index 000000000..5f29ee900 --- /dev/null +++ b/src/app/state/recentSearches.ts @@ -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( + 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); +};