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:
+12
-3
@@ -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;
|
||||
|
||||
@@ -35,6 +35,7 @@ export type _SearchPathSearchParams = {
|
||||
senders?: string;
|
||||
fromTs?: string;
|
||||
toTs?: string;
|
||||
containsUrl?: string;
|
||||
};
|
||||
export const _SEARCH_PATH = 'search/';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user