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:
@@ -1,6 +1,6 @@
|
|||||||
import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem, Button } from 'folds';
|
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 { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
@@ -18,8 +18,14 @@ import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../
|
|||||||
import { useRooms } from '../../state/hooks/roomList';
|
import { useRooms } from '../../state/hooks/roomList';
|
||||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
import {
|
||||||
|
filterGroupsByMsgType,
|
||||||
|
MessageSearchParams,
|
||||||
|
MsgTypeFilter,
|
||||||
|
useMessageSearch,
|
||||||
|
} from './useMessageSearch';
|
||||||
import { useLocalMessageSearch } from './useLocalMessageSearch';
|
import { useLocalMessageSearch } from './useLocalMessageSearch';
|
||||||
|
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
|
||||||
import { SearchResultGroup } from './SearchResultGroup';
|
import { SearchResultGroup } from './SearchResultGroup';
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
import { SearchFilters } from './SearchFilters';
|
import { SearchFilters } from './SearchFilters';
|
||||||
@@ -167,6 +173,12 @@ export function MessageSearch({
|
|||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null) as React.RefObject<HTMLInputElement>;
|
const searchInputRef = useRef<HTMLInputElement>(null) as React.RefObject<HTMLInputElement>;
|
||||||
const scrollTopAnchorRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
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 [searchParams, setSearchParams] = useSearchParams();
|
||||||
const searchPathSearchParams = useSearchPathSearchParams(searchParams);
|
const searchPathSearchParams = useSearchPathSearchParams(searchParams);
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
@@ -257,7 +269,10 @@ export function MessageSearch({
|
|||||||
getNextPageParam: (lastPage) => lastPage.nextToken,
|
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 highlights = useMemo(() => {
|
||||||
const mixed = data?.pages.flatMap((result) => result.highlights);
|
const mixed = data?.pages.flatMap((result) => result.highlights);
|
||||||
return Array.from(new Set(mixed));
|
return Array.from(new Set(mixed));
|
||||||
@@ -278,7 +293,25 @@ export function MessageSearch({
|
|||||||
newParams.append('term', term);
|
newParams.append('term', term);
|
||||||
return newParams;
|
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 = () => {
|
const handleSearchClear = () => {
|
||||||
if (searchInputRef.current) {
|
if (searchInputRef.current) {
|
||||||
searchInputRef.current.value = '';
|
searchInputRef.current.value = '';
|
||||||
@@ -407,6 +440,9 @@ export function MessageSearch({
|
|||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
onReset={handleSearchClear}
|
onReset={handleSearchClear}
|
||||||
onSenderAdd={handleSenderAdd}
|
onSenderAdd={handleSenderAdd}
|
||||||
|
recentSearches={recentSearches}
|
||||||
|
onRecentSearch={handleRecentSearch}
|
||||||
|
onClearRecentSearches={handleClearRecentSearches}
|
||||||
/>
|
/>
|
||||||
<SearchFilters
|
<SearchFilters
|
||||||
defaultRoomsFilterName={defaultRoomsFilterName}
|
defaultRoomsFilterName={defaultRoomsFilterName}
|
||||||
@@ -425,6 +461,8 @@ export function MessageSearch({
|
|||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
containsUrl={msgSearchParams.containsUrl}
|
containsUrl={msgSearchParams.containsUrl}
|
||||||
onContainsUrlChange={handleContainsUrlChange}
|
onContainsUrlChange={handleContainsUrlChange}
|
||||||
|
msgTypeFilters={msgTypeFilters}
|
||||||
|
onToggleMsgTypeFilter={handleToggleMsgTypeFilter}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Badge,
|
Badge,
|
||||||
RectCords,
|
RectCords,
|
||||||
|
IconSrc,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { SearchOrderBy } from 'matrix-js-sdk';
|
import { SearchOrderBy } from 'matrix-js-sdk';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
@@ -41,6 +42,13 @@ import {
|
|||||||
import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
|
import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
|
||||||
import { VirtualTile } from '../../components/virtualizer';
|
import { VirtualTile } from '../../components/virtualizer';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
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 = {
|
type OrderButtonProps = {
|
||||||
order?: string;
|
order?: string;
|
||||||
@@ -674,6 +682,8 @@ type SearchFiltersProps = {
|
|||||||
onDateRangeChange: (fromTs?: number, toTs?: number) => void;
|
onDateRangeChange: (fromTs?: number, toTs?: number) => void;
|
||||||
containsUrl?: boolean;
|
containsUrl?: boolean;
|
||||||
onContainsUrlChange: (value?: boolean) => void;
|
onContainsUrlChange: (value?: boolean) => void;
|
||||||
|
msgTypeFilters: MsgTypeFilter[];
|
||||||
|
onToggleMsgTypeFilter: (msgType: MsgTypeFilter) => void;
|
||||||
};
|
};
|
||||||
export function SearchFilters({
|
export function SearchFilters({
|
||||||
defaultRoomsFilterName,
|
defaultRoomsFilterName,
|
||||||
@@ -692,6 +702,8 @@ export function SearchFilters({
|
|||||||
onDateRangeChange,
|
onDateRangeChange,
|
||||||
containsUrl,
|
containsUrl,
|
||||||
onContainsUrlChange,
|
onContainsUrlChange,
|
||||||
|
msgTypeFilters,
|
||||||
|
onToggleMsgTypeFilter,
|
||||||
}: SearchFiltersProps) {
|
}: SearchFiltersProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
@@ -795,6 +807,34 @@ export function SearchFilters({
|
|||||||
>
|
>
|
||||||
<Text size="T200">Has link</Text>
|
<Text size="T200">Has link</Text>
|
||||||
</Chip>
|
</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} />
|
<DateRangeButton fromTs={fromTs} toTs={toTs} onChange={onDateRangeChange} />
|
||||||
<OrderButton order={order} onChange={onOrderChange} />
|
<OrderButton order={order} onChange={onOrderChange} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ type SearchInputProps = {
|
|||||||
onSearch: (term: string) => void;
|
onSearch: (term: string) => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onSenderAdd?: (userId: string) => void;
|
onSenderAdd?: (userId: string) => void;
|
||||||
|
recentSearches?: string[];
|
||||||
|
onRecentSearch?: (term: string) => void;
|
||||||
|
onClearRecentSearches?: () => void;
|
||||||
};
|
};
|
||||||
export function SearchInput({
|
export function SearchInput({
|
||||||
active,
|
active,
|
||||||
@@ -51,6 +54,9 @@ export function SearchInput({
|
|||||||
onSearch,
|
onSearch,
|
||||||
onReset,
|
onReset,
|
||||||
onSenderAdd,
|
onSenderAdd,
|
||||||
|
recentSearches,
|
||||||
|
onRecentSearch,
|
||||||
|
onClearRecentSearches,
|
||||||
}: SearchInputProps) {
|
}: SearchInputProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
@@ -58,6 +64,8 @@ export function SearchInput({
|
|||||||
|
|
||||||
const [fromQuery, setFromQuery] = useState('');
|
const [fromQuery, setFromQuery] = useState('');
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [inputEmpty, setInputEmpty] = useState(true);
|
||||||
|
|
||||||
// Collect users from room member lists, scored for relevance.
|
// Collect users from room member lists, scored for relevance.
|
||||||
// Score: same homeserver → +1000, each shared room → +1.
|
// Score: same homeserver → +1000, each shared room → +1.
|
||||||
@@ -121,6 +129,7 @@ export function SearchInput({
|
|||||||
|
|
||||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
const value = evt.target.value;
|
const value = evt.target.value;
|
||||||
|
setInputEmpty(value.trim() === '');
|
||||||
const match = FROM_TYPING_REGEX.exec(value);
|
const match = FROM_TYPING_REGEX.exec(value);
|
||||||
if (match) {
|
if (match) {
|
||||||
setFromQuery(match[1]);
|
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
|
// Close autocomplete when input loses focus — delay so item clicks fire first
|
||||||
const handleBlur = () => {
|
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) => {
|
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
@@ -181,6 +205,7 @@ export function SearchInput({
|
|||||||
placeholder="Search messages or type from:@user to filter by sender"
|
placeholder="Search messages or type from:@user to filter by sender"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
before={
|
before={
|
||||||
active && loading ? (
|
active && loading ? (
|
||||||
@@ -267,6 +292,52 @@ export function SearchInput({
|
|||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</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>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,31 @@ export type SearchResult = {
|
|||||||
groups: ResultGroup[];
|
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 groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||||
const groups: ResultGroup[] = [];
|
const groups: ResultGroup[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user