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.
|
**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.
|
**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`.
|
**What:** Improve search filter UX in `SearchFilters.tsx`.
|
||||||
**Approach:** Add UI components for easier filtering and a visual date-range picker that correctly maps to `fromTs` and `toTs`.
|
**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,
|
senders: searchParams.get('senders') ?? undefined,
|
||||||
fromTs: searchParams.get('fromTs') ?? undefined,
|
fromTs: searchParams.get('fromTs') ?? undefined,
|
||||||
toTs: searchParams.get('toTs') ?? undefined,
|
toTs: searchParams.get('toTs') ?? undefined,
|
||||||
|
containsUrl: searchParams.get('containsUrl') ?? undefined,
|
||||||
}),
|
}),
|
||||||
[searchParams],
|
[searchParams],
|
||||||
);
|
);
|
||||||
@@ -197,6 +198,7 @@ export function MessageSearch({
|
|||||||
senders: searchParamsSenders ?? senders,
|
senders: searchParamsSenders ?? senders,
|
||||||
fromTs: searchPathSearchParams.fromTs ? Number(searchPathSearchParams.fromTs) : undefined,
|
fromTs: searchPathSearchParams.fromTs ? Number(searchPathSearchParams.fromTs) : undefined,
|
||||||
toTs: searchPathSearchParams.toTs ? Number(searchPathSearchParams.toTs) : undefined,
|
toTs: searchPathSearchParams.toTs ? Number(searchPathSearchParams.toTs) : undefined,
|
||||||
|
containsUrl: searchPathSearchParams.containsUrl === 'true' ? true : undefined,
|
||||||
};
|
};
|
||||||
}, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);
|
}, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);
|
||||||
|
|
||||||
@@ -357,6 +359,18 @@ export function MessageSearch({
|
|||||||
[setSearchParams],
|
[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 lastVItem = vItems[vItems.length - 1];
|
||||||
const lastVItemIndex: number | undefined = lastVItem?.index;
|
const lastVItemIndex: number | undefined = lastVItem?.index;
|
||||||
const lastGroupIndex = groups.length - 1;
|
const lastGroupIndex = groups.length - 1;
|
||||||
@@ -409,6 +423,8 @@ export function MessageSearch({
|
|||||||
fromTs={msgSearchParams.fromTs}
|
fromTs={msgSearchParams.fromTs}
|
||||||
toTs={msgSearchParams.toTs}
|
toTs={msgSearchParams.toTs}
|
||||||
onDateRangeChange={handleDateRangeChange}
|
onDateRangeChange={handleDateRangeChange}
|
||||||
|
containsUrl={msgSearchParams.containsUrl}
|
||||||
|
onContainsUrlChange={handleContainsUrlChange}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import React, {
|
|||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} 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 = {
|
type DateRangeButtonProps = {
|
||||||
fromTs?: number;
|
fromTs?: number;
|
||||||
toTs?: number;
|
toTs?: number;
|
||||||
@@ -365,6 +526,33 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
|
|||||||
>
|
>
|
||||||
<Menu variant="Surface" style={{ padding: config.space.S300, minWidth: toRem(220) }}>
|
<Menu variant="Surface" style={{ padding: config.space.S300, minWidth: toRem(220) }}>
|
||||||
<Box direction="Column" gap="200">
|
<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">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">From</Text>
|
<Text size="L400">From</Text>
|
||||||
<input
|
<input
|
||||||
@@ -459,6 +647,8 @@ type SearchFiltersProps = {
|
|||||||
fromTs?: number;
|
fromTs?: number;
|
||||||
toTs?: number;
|
toTs?: number;
|
||||||
onDateRangeChange: (fromTs?: number, toTs?: number) => void;
|
onDateRangeChange: (fromTs?: number, toTs?: number) => void;
|
||||||
|
containsUrl?: boolean;
|
||||||
|
onContainsUrlChange: (value?: boolean) => void;
|
||||||
};
|
};
|
||||||
export function SearchFilters({
|
export function SearchFilters({
|
||||||
defaultRoomsFilterName,
|
defaultRoomsFilterName,
|
||||||
@@ -475,6 +665,8 @@ export function SearchFilters({
|
|||||||
fromTs,
|
fromTs,
|
||||||
toTs,
|
toTs,
|
||||||
onDateRangeChange,
|
onDateRangeChange,
|
||||||
|
containsUrl,
|
||||||
|
onContainsUrlChange,
|
||||||
}: SearchFiltersProps) {
|
}: SearchFiltersProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
@@ -532,14 +724,12 @@ export function SearchFilters({
|
|||||||
selectedRooms={selectedRooms}
|
selectedRooms={selectedRooms}
|
||||||
onChange={onSelectedRoomsChange}
|
onChange={onSelectedRoomsChange}
|
||||||
/>
|
/>
|
||||||
{selectedSenders && selectedSenders.length > 0 && (
|
<Line
|
||||||
<Line
|
style={{ margin: `${config.space.S100} 0` }}
|
||||||
style={{ margin: `${config.space.S100} 0` }}
|
direction="Vertical"
|
||||||
direction="Vertical"
|
variant="Surface"
|
||||||
variant="Surface"
|
size="300"
|
||||||
size="300"
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{selectedSenders?.map((userId) => {
|
{selectedSenders?.map((userId) => {
|
||||||
const user = mx.getUser(userId);
|
const user = mx.getUser(userId);
|
||||||
const name = user?.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
const name = user?.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
@@ -556,7 +746,29 @@ export function SearchFilters({
|
|||||||
</Chip>
|
</Chip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<SelectSenderButton
|
||||||
|
selectedSenders={selectedSenders}
|
||||||
|
onChange={onSelectedSendersChange}
|
||||||
|
/>
|
||||||
<Box grow="Yes" data-spacing-node />
|
<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} />
|
<DateRangeButton fromTs={fromTs} toTs={toTs} onChange={onDateRangeChange} />
|
||||||
<OrderButton order={order} onChange={onOrderChange} />
|
<OrderButton order={order} onChange={onOrderChange} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -70,10 +70,11 @@ export type MessageSearchParams = {
|
|||||||
senders?: string[];
|
senders?: string[];
|
||||||
fromTs?: number;
|
fromTs?: number;
|
||||||
toTs?: number;
|
toTs?: number;
|
||||||
|
containsUrl?: boolean;
|
||||||
};
|
};
|
||||||
export const useMessageSearch = (params: MessageSearchParams) => {
|
export const useMessageSearch = (params: MessageSearchParams) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const { term, order, rooms, senders, fromTs, toTs } = params;
|
const { term, order, rooms, senders, fromTs, toTs, containsUrl } = params;
|
||||||
|
|
||||||
const searchMessages = useCallback(
|
const searchMessages = useCallback(
|
||||||
async (nextBatch?: string) => {
|
async (nextBatch?: string) => {
|
||||||
@@ -96,9 +97,10 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
|||||||
limit,
|
limit,
|
||||||
rooms,
|
rooms,
|
||||||
senders,
|
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 }),
|
...(fromTs !== undefined && { from_ts: fromTs }),
|
||||||
...(toTs !== undefined && { to_ts: toTs }),
|
...(toTs !== undefined && { to_ts: toTs }),
|
||||||
|
...(containsUrl !== undefined && { contains_url: containsUrl }),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} as any,
|
} as any,
|
||||||
include_state: false,
|
include_state: false,
|
||||||
@@ -114,7 +116,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
|||||||
});
|
});
|
||||||
return parseSearchResult(r);
|
return parseSearchResult(r);
|
||||||
},
|
},
|
||||||
[mx, term, order, rooms, senders, fromTs, toTs],
|
[mx, term, order, rooms, senders, fromTs, toTs, containsUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
return searchMessages;
|
return searchMessages;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type _SearchPathSearchParams = {
|
|||||||
senders?: string;
|
senders?: string;
|
||||||
fromTs?: string;
|
fromTs?: string;
|
||||||
toTs?: string;
|
toTs?: string;
|
||||||
|
containsUrl?: string;
|
||||||
};
|
};
|
||||||
export const _SEARCH_PATH = 'search/';
|
export const _SEARCH_PATH = 'search/';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user