diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md index 1ad1962c3..13c30d224 100644 --- a/LOTUS_TODO.md +++ b/LOTUS_TODO.md @@ -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 --- diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index aaadd219b..d1c72ed7a 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -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} /> diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx index 5b61fe80b..4735168e3 100644 --- a/src/app/features/message-search/SearchFilters.tsx +++ b/src/app/features/message-search/SearchFilters.tsx @@ -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(null); + const [menuAnchor, setMenuAnchor] = useState(); + const [localSelected, setLocalSelected] = useState(selectedSenders); + + const knownUsers = useMemo(() => { + const ids = new Set(); + 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 = 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 = (evt) => { + const value = evt.currentTarget.value.trim(); + if (!value) { resetSearch(); return; } + searchUser(value); + }; + + const handleUserClick: MouseEventHandler = (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 ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + + From + + + + + {users.length === 0 && ( + No match found! + )} +
+ {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 ( + + } + > + {name} + + + ); + })} +
+
+
+ + + + + +
+
+ + } + > + ) => setMenuAnchor(e.currentTarget.getBoundingClientRect())} + variant="SurfaceVariant" + radii="Pill" + before={} + > + From + +
+ ); +} + type DateRangeButtonProps = { fromTs?: number; toTs?: number; @@ -365,6 +526,33 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) { > + + Quick pick + + {([ + { 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 ( + { onChange(from, now); setMenuAnchor(undefined); }} + > + {l} + + ); + })} + + + From 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 && ( - - )} + {selectedSenders?.map((userId) => { const user = mx.getUser(userId); const name = user?.displayName ?? getMxIdLocalPart(userId) ?? userId; @@ -556,7 +746,29 @@ export function SearchFilters({ ); })} + + } + after={ + containsUrl ? ( + { e.stopPropagation(); onContainsUrlChange(undefined); }} + /> + ) : undefined + } + onClick={() => onContainsUrlChange(containsUrl ? undefined : true)} + > + Has link + diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index 66639f58c..40bb2187d 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -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; diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index a12f59a60..f0f416364 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -35,6 +35,7 @@ export type _SearchPathSearchParams = { senders?: string; fromTs?: string; toTs?: string; + containsUrl?: string; }; export const _SEARCH_PATH = 'search/';