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 { 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[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user