feat(search): sender picker, date presets, has:link filter

- Add SelectSenderButton: clickable people picker for the From filter
  replacing the text-only from:@user syntax
- Add date preset shortcuts in DateRangeButton (Today, Last week,
  Last month, Last year) for one-click range selection
- Add Has link chip backed by Matrix contains_url API filter; toggle
  removes cleanly with X badge
- Wire containsUrl through URL params, useMessageSearch hook, and
  SearchFilters props

UNTESTED — verify at chat.lotusguild.org post-deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 19:14:42 -04:00
parent 44e36f7dd2
commit abf15391f6
5 changed files with 254 additions and 14 deletions
+12 -3
View File
@@ -188,10 +188,19 @@ Features:
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms.
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
### [ ] P4-9 · Advanced Search Filter UI
### [x] P4-9 · Advanced Search Filter UI — PARTIALLY DONE (UNTESTED)
**What:** Introduce a more robust search filter UI in `SearchFilters.tsx`.
**Approach:** Add UI components for easier filtering and a visual date-range picker that correctly maps to `fromTs` and `toTs`.
**What:** Improve search filter UX in `SearchFilters.tsx`.
**Completed 2026-06-18:**
-`SelectSenderButton` — picker UI for sender filter (previously required typing `from:@user` by hand)
-`DateRangeButton` — quick-pick presets: Today / Last week / Last month / Last year
-`Has link` chip — `contains_url: true` filter, wired to Matrix API and URL param
**UNTESTED** — needs verification at chat.lotusguild.org.
**Remaining for parity with Discord/Slack:**
- [ ] `has:image` / `has:file` / `has:video` — msgtype filters (require client-side post-filtering, no server API)
- [ ] Pinned messages filter
- [ ] Saved searches / search history
---
@@ -35,6 +35,7 @@ const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSe
senders: searchParams.get('senders') ?? undefined,
fromTs: searchParams.get('fromTs') ?? undefined,
toTs: searchParams.get('toTs') ?? undefined,
containsUrl: searchParams.get('containsUrl') ?? undefined,
}),
[searchParams],
);
@@ -197,6 +198,7 @@ export function MessageSearch({
senders: searchParamsSenders ?? senders,
fromTs: searchPathSearchParams.fromTs ? Number(searchPathSearchParams.fromTs) : undefined,
toTs: searchPathSearchParams.toTs ? Number(searchPathSearchParams.toTs) : undefined,
containsUrl: searchPathSearchParams.containsUrl === 'true' ? true : undefined,
};
}, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);
@@ -357,6 +359,18 @@ export function MessageSearch({
[setSearchParams],
);
const handleContainsUrlChange = useCallback(
(value?: boolean) => {
setSearchParams((prevParams) => {
const p = new URLSearchParams(prevParams);
p.delete('containsUrl');
if (value) p.append('containsUrl', 'true');
return p;
});
},
[setSearchParams],
);
const lastVItem = vItems[vItems.length - 1];
const lastVItemIndex: number | undefined = lastVItem?.index;
const lastGroupIndex = groups.length - 1;
@@ -409,6 +423,8 @@ export function MessageSearch({
fromTs={msgSearchParams.fromTs}
toTs={msgSearchParams.toTs}
onDateRangeChange={handleDateRangeChange}
containsUrl={msgSearchParams.containsUrl}
onContainsUrlChange={handleContainsUrlChange}
/>
</Box>
@@ -3,6 +3,7 @@ import React, {
MouseEventHandler,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -327,6 +328,166 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
);
}
type SelectSenderButtonProps = {
selectedSenders?: string[];
onChange: (senders?: string[]) => void;
};
function SelectSenderButton({ selectedSenders, onChange }: SelectSenderButtonProps) {
const mx = useMatrixClient();
const scrollRef = useRef<HTMLDivElement>(null);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [localSelected, setLocalSelected] = useState(selectedSenders);
const knownUsers = useMemo(() => {
const ids = new Set<string>();
mx.getRooms().forEach((room) => {
room.getJoinedMembers().forEach((m) => {
if (m.userId !== mx.getSafeUserId()) ids.add(m.userId);
});
});
return Array.from(ids).sort();
}, [mx]);
const getUserDisplayStr: SearchItemStrGetter<string> = useCallback(
(userId) => {
const user = mx.getUser(userId);
return user?.displayName ?? getMxIdLocalPart(userId) ?? userId;
},
[mx],
);
const [searchResult, _searchUser, resetSearch] = useAsyncSearch(
knownUsers,
getUserDisplayStr,
SEARCH_OPTS,
);
const users = searchResult?.items ?? knownUsers;
const virtualizer = useVirtualizer({
count: users.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 32,
overscan: 5,
});
const vItems = virtualizer.getVirtualItems();
const searchUser = useDebounce(_searchUser, SEARCH_DEBOUNCE_OPTS);
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const value = evt.currentTarget.value.trim();
if (!value) { resetSearch(); return; }
searchUser(value);
};
const handleUserClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) return;
if (localSelected?.includes(userId)) {
setLocalSelected(localSelected.filter((id) => id !== userId));
return;
}
setLocalSelected([...(localSelected ?? []), userId]);
};
const handleSave = () => {
setMenuAnchor(undefined);
onChange(localSelected && localSelected.length > 0 ? localSelected : undefined);
};
const handleDeselectAll = () => {
setMenuAnchor(undefined);
onChange(undefined);
};
useEffect(() => {
setLocalSelected(selectedSenders);
resetSearch();
}, [menuAnchor, selectedSenders, resetSearch]);
return (
<PopOut
anchor={menuAnchor}
align="Center"
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu variant="Surface" style={{ width: toRem(250) }}>
<Box direction="Column" style={{ maxHeight: toRem(400), maxWidth: toRem(300) }}>
<Box shrink="No" direction="Column" gap="100" style={{ padding: config.space.S200, paddingBottom: 0 }}>
<Text size="L400">From</Text>
<Input onChange={handleSearchChange} size="300" radii="300" placeholder="Search people..." />
</Box>
<Scroll ref={scrollRef} size="300" hideTrack>
<Box direction="Column" gap="100" style={{ padding: config.space.S200, paddingRight: 0 }}>
{users.length === 0 && (
<Text style={{ padding: config.space.S400 }} size="T300" align="Center">No match found!</Text>
)}
<div style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
{vItems.map((vItem) => {
const userId = users[vItem.index];
const user = mx.getUser(userId);
const name = user?.displayName ?? getMxIdLocalPart(userId) ?? userId;
const selected = localSelected?.includes(userId);
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingBottom: config.space.S100 }}
ref={virtualizer.measureElement}
key={vItem.index}
>
<MenuItem
data-user-id={userId}
onClick={handleUserClick}
variant={selected ? 'Success' : 'Surface'}
size="300"
radii="300"
aria-pressed={selected}
before={<Icon size="50" src={Icons.User} />}
>
<Text truncate size="T300">{name}</Text>
</MenuItem>
</VirtualTile>
);
})}
</div>
</Box>
</Scroll>
<Line variant="Surface" size="300" />
<Box shrink="No" direction="Column" gap="100" style={{ padding: config.space.S200 }}>
<Button size="300" variant="Secondary" radii="300" onClick={handleSave}>
{localSelected && localSelected.length > 0 ? (
<Text size="B300">Save ({localSelected.length})</Text>
) : (
<Text size="B300">Save</Text>
)}
</Button>
<Button size="300" radii="300" variant="Secondary" fill="Soft" onClick={handleDeselectAll} disabled={!localSelected || localSelected.length === 0}>
<Text size="B300">Deselect All</Text>
</Button>
</Box>
</Box>
</Menu>
</FocusTrap>
}
>
<Chip
onClick={(e: React.MouseEvent<HTMLButtonElement>) => setMenuAnchor(e.currentTarget.getBoundingClientRect())}
variant="SurfaceVariant"
radii="Pill"
before={<Icon size="100" src={Icons.User} />}
>
<Text size="T200">From</Text>
</Chip>
</PopOut>
);
}
type DateRangeButtonProps = {
fromTs?: number;
toTs?: number;
@@ -365,6 +526,33 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
>
<Menu variant="Surface" style={{ padding: config.space.S300, minWidth: toRem(220) }}>
<Box direction="Column" gap="200">
<Box direction="Column" gap="100">
<Text size="L400">Quick pick</Text>
<Box gap="100" wrap="Wrap">
{([
{ label: 'Today', days: 0 },
{ label: 'Last week', days: 7 },
{ label: 'Last month', days: 30 },
{ label: 'Last year', days: 365 },
] as const).map(({ label: l, days }) => {
const now = Date.now();
const from = days === 0
? new Date().setHours(0, 0, 0, 0)
: now - days * 24 * 60 * 60 * 1000;
return (
<Chip
key={l}
radii="Pill"
variant="SurfaceVariant"
onClick={() => { onChange(from, now); setMenuAnchor(undefined); }}
>
<Text size="T200">{l}</Text>
</Chip>
);
})}
</Box>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100">
<Text size="L400">From</Text>
<input
@@ -459,6 +647,8 @@ type SearchFiltersProps = {
fromTs?: number;
toTs?: number;
onDateRangeChange: (fromTs?: number, toTs?: number) => void;
containsUrl?: boolean;
onContainsUrlChange: (value?: boolean) => void;
};
export function SearchFilters({
defaultRoomsFilterName,
@@ -475,6 +665,8 @@ export function SearchFilters({
fromTs,
toTs,
onDateRangeChange,
containsUrl,
onContainsUrlChange,
}: SearchFiltersProps) {
const mx = useMatrixClient();
@@ -532,14 +724,12 @@ export function SearchFilters({
selectedRooms={selectedRooms}
onChange={onSelectedRoomsChange}
/>
{selectedSenders && selectedSenders.length > 0 && (
<Line
style={{ margin: `${config.space.S100} 0` }}
direction="Vertical"
variant="Surface"
size="300"
/>
)}
<Line
style={{ margin: `${config.space.S100} 0` }}
direction="Vertical"
variant="Surface"
size="300"
/>
{selectedSenders?.map((userId) => {
const user = mx.getUser(userId);
const name = user?.displayName ?? getMxIdLocalPart(userId) ?? userId;
@@ -556,7 +746,29 @@ export function SearchFilters({
</Chip>
);
})}
<SelectSenderButton
selectedSenders={selectedSenders}
onChange={onSelectedSendersChange}
/>
<Box grow="Yes" data-spacing-node />
<Chip
variant={containsUrl ? 'Primary' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={!!containsUrl}
before={<Icon size="100" src={Icons.Link} />}
after={
containsUrl ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => { e.stopPropagation(); onContainsUrlChange(undefined); }}
/>
) : undefined
}
onClick={() => onContainsUrlChange(containsUrl ? undefined : true)}
>
<Text size="T200">Has link</Text>
</Chip>
<DateRangeButton fromTs={fromTs} toTs={toTs} onChange={onDateRangeChange} />
<OrderButton order={order} onChange={onOrderChange} />
</Box>
@@ -70,10 +70,11 @@ export type MessageSearchParams = {
senders?: string[];
fromTs?: number;
toTs?: number;
containsUrl?: boolean;
};
export const useMessageSearch = (params: MessageSearchParams) => {
const mx = useMatrixClient();
const { term, order, rooms, senders, fromTs, toTs } = params;
const { term, order, rooms, senders, fromTs, toTs, containsUrl } = params;
const searchMessages = useCallback(
async (nextBatch?: string) => {
@@ -96,9 +97,10 @@ export const useMessageSearch = (params: MessageSearchParams) => {
limit,
rooms,
senders,
// from_ts / to_ts are valid Matrix spec fields not yet in SDK types
// from_ts / to_ts and contains_url are valid Matrix spec fields not yet in SDK types
...(fromTs !== undefined && { from_ts: fromTs }),
...(toTs !== undefined && { to_ts: toTs }),
...(containsUrl !== undefined && { contains_url: containsUrl }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
include_state: false,
@@ -114,7 +116,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
});
return parseSearchResult(r);
},
[mx, term, order, rooms, senders, fromTs, toTs],
[mx, term, order, rooms, senders, fromTs, toTs, containsUrl],
);
return searchMessages;
+1
View File
@@ -35,6 +35,7 @@ export type _SearchPathSearchParams = {
senders?: string;
fromTs?: string;
toTs?: string;
containsUrl?: string;
};
export const _SEARCH_PATH = 'search/';