From 02592ed43cd4ba8ebc1b899ea38e6c044204221b Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 12 Sep 2026 14:48:35 -0400 Subject: [PATCH] fix(search): apply the date range to server results client-side from_ts/to_ts are not Matrix filter fields; the server dropped them, so the range only worked for the local encrypted-room search. Stop sending them and post-filter server results by origin_server_ts with the same inclusive predicate. Unit-tested; docs corrected. Fixes #13 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- LOTUS_FEATURES.md | 2 +- .../features/message-search/MessageSearch.tsx | 20 +++++++--- .../message-search/useMessageSearch.test.ts | 39 ++++++++++++++++++- .../message-search/useMessageSearch.ts | 38 +++++++++++++++--- 4 files changed, 87 insertions(+), 12 deletions(-) diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 6411e424d..c9d89443c 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -742,7 +742,7 @@ never leaves it. ### Message Search Date Range -- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API +- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds); server results are filtered client-side by `origin_server_ts` (they are not Matrix filter fields), matching the local encrypted-room search - A chip shows the active date range with an **×** button to clear it ### Encrypted Search Cache (P4-8, opt-in) diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index fd1f1b234..e27a14976 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -36,6 +36,7 @@ import { mDirectAtom } from '../../state/mDirectList'; import { getStateEvent } from '../../utils/room'; import { StateEvent } from '../../../types/matrix/room'; import { + filterGroupsByDateRange, filterGroupsByMsgType, filterGroupsByPinned, MessageSearchParams, @@ -316,12 +317,21 @@ export function MessageSearch({ getNextPageParam: (lastPage) => lastPage.nextToken, }); - // Shared client-side post-filter (msgtype + pinned) applied to BOTH the - // server results and the local/encrypted-cache results, so the filter chips - // narrow the whole UI consistently rather than only the server section. + // Shared client-side post-filter (date range + msgtype + pinned) applied to + // BOTH the server results and the local/encrypted-cache results, so the + // filter chips narrow the whole UI consistently rather than only the + // server section. The date range must be enforced here because the Matrix + // search API has no timestamp filter fields (see useMessageSearch.ts); the + // local/encrypted path already filters in-range before this runs, so this + // is a no-op there and only actually trims the server section. const applyResultFilters = useCallback( (allGroups: ResultGroup[]): ResultGroup[] => { - const byMsgType = filterGroupsByMsgType(allGroups, msgTypeFilters); + const inDateRange = filterGroupsByDateRange( + allGroups, + msgSearchParams.fromTs, + msgSearchParams.toTs, + ); + const byMsgType = filterGroupsByMsgType(inDateRange, msgTypeFilters); if (!pinnedOnly) return byMsgType; // Build a per-room pinned-event lookup. Heavy Matrix reads stay here // (where `mx` is available); the pure helper only consumes the predicate. @@ -343,7 +353,7 @@ export function MessageSearch({ }; return filterGroupsByPinned(byMsgType, pinnedOnly, isPinned); }, - [msgTypeFilters, pinnedOnly, mx], + [msgSearchParams.fromTs, msgSearchParams.toTs, msgTypeFilters, pinnedOnly, mx], ); const groups = useMemo(() => { diff --git a/src/app/features/message-search/useMessageSearch.test.ts b/src/app/features/message-search/useMessageSearch.test.ts index c6d16d47b..c57b6a484 100644 --- a/src/app/features/message-search/useMessageSearch.test.ts +++ b/src/app/features/message-search/useMessageSearch.test.ts @@ -1,6 +1,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { filterGroupsByMsgType, filterGroupsByPinned, ResultGroup } from './useMessageSearch'; +import { + filterGroupsByDateRange, + filterGroupsByMsgType, + filterGroupsByPinned, + ResultGroup, +} from './useMessageSearch'; // Minimal ResultGroup/ResultItem fixtures — only the fields the filters read // (event.content.msgtype, event.event_id, group.roomId). @@ -9,6 +14,11 @@ const item = (msgtype: string | undefined, eventId: string) => ({ event: { event_id: eventId, content: msgtype === undefined ? {} : { msgtype } }, context: {}, }); +const tsItem = (eventId: string, ts: number) => ({ + rank: 1, + event: { event_id: eventId, origin_server_ts: ts, content: {} }, + context: {}, +}); const mkGroups = ( ...groups: { roomId: string; items: ReturnType[] }[] ): ResultGroup[] => groups as unknown as ResultGroup[]; @@ -48,6 +58,33 @@ test('filterGroupsByMsgType: ignores items with a non-string msgtype', () => { assert.equal(out[0].items[0].event.event_id, '$2'); }); +test('filterGroupsByDateRange: no bounds returns groups unchanged', () => { + const groups = mkGroups({ roomId: '!r1', items: [tsItem('$1', 100)] }); + assert.equal(filterGroupsByDateRange(groups, undefined, undefined), groups); +}); + +test('filterGroupsByDateRange: keeps only items within an inclusive range', () => { + const groups = mkGroups({ + roomId: '!r1', + items: [tsItem('$1', 50), tsItem('$2', 100), tsItem('$3', 150), tsItem('$4', 200)], + }); + const out = filterGroupsByDateRange(groups, 100, 150); + assert.deepEqual( + out[0].items.map((i) => i.event.event_id), + ['$2', '$3'], + ); +}); + +test('filterGroupsByDateRange: drops groups left empty and supports one-sided bounds', () => { + const groups = mkGroups( + { roomId: '!r1', items: [tsItem('$1', 50)] }, + { roomId: '!r2', items: [tsItem('$2', 500)] }, + ); + const out = filterGroupsByDateRange(groups, 100, undefined); + assert.equal(out.length, 1); + assert.equal(out[0].roomId, '!r2'); +}); + test('filterGroupsByPinned: disabled returns groups unchanged', () => { const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] }); assert.equal( diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index 7e7e0644c..be105f7b0 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -71,6 +71,31 @@ export const filterGroupsByPinned = ( .filter((group) => group.items.length > 0); }; +/** Inclusive-range predicate, mirrored from `inRange` in useLocalMessageSearch.ts. */ +export const inTsRange = (ts: number, fromTs?: number, toTs?: number): boolean => + (fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs); + +/** + * Filter result groups to items whose `origin_server_ts` falls within + * [fromTs, toTs] (inclusive, either bound optional). The Matrix search API + * has no timestamp filter fields, so server results must be post-filtered + * here — the same predicate the local/encrypted search already applies. + * Now-empty groups are dropped. + */ +export const filterGroupsByDateRange = ( + groups: ResultGroup[], + fromTs?: number, + toTs?: number, +): ResultGroup[] => { + if (fromTs === undefined && toTs === undefined) return groups; + return groups + .map((group) => ({ + ...group, + items: group.items.filter((item) => inTsRange(item.event.origin_server_ts, fromTs, toTs)), + })) + .filter((group) => group.items.length > 0); +}; + const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => { const groups: ResultGroup[] = []; @@ -119,7 +144,9 @@ export type MessageSearchParams = { }; export const useMessageSearch = (params: MessageSearchParams) => { const mx = useMatrixClient(); - const { term, order, rooms, senders, fromTs, toTs, containsUrl } = params; + // fromTs/toTs are intentionally not sent to the server (see comment below) — + // callers post-filter results with filterGroupsByDateRange instead. + const { term, order, rooms, senders, containsUrl } = params; const searchMessages = useCallback( async (nextBatch?: string) => { @@ -142,9 +169,10 @@ export const useMessageSearch = (params: MessageSearchParams) => { limit, rooms, senders, - // 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 }), + // `RoomEventFilter` has no timestamp bounds — from_ts/to_ts are not + // Matrix filter fields and the homeserver silently drops them, so the + // date range is instead enforced client-side (see filterGroupsByDateRange). + // contains_url is a valid spec field not yet in SDK types. ...(containsUrl !== undefined && { contains_url: containsUrl }), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, @@ -161,7 +189,7 @@ export const useMessageSearch = (params: MessageSearchParams) => { }); return parseSearchResult(r); }, - [mx, term, order, rooms, senders, fromTs, toTs, containsUrl], + [mx, term, order, rooms, senders, containsUrl], ); return searchMessages;