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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 14:48:35 -04:00
co-authored by Claude Opus 5
parent 6bd2903de1
commit 02592ed43c
4 changed files with 87 additions and 12 deletions
+1 -1
View File
@@ -742,7 +742,7 @@ never leaves it.
### Message Search Date Range ### 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 - A chip shows the active date range with an **×** button to clear it
### Encrypted Search Cache (P4-8, opt-in) ### Encrypted Search Cache (P4-8, opt-in)
@@ -36,6 +36,7 @@ import { mDirectAtom } from '../../state/mDirectList';
import { getStateEvent } from '../../utils/room'; import { getStateEvent } from '../../utils/room';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { import {
filterGroupsByDateRange,
filterGroupsByMsgType, filterGroupsByMsgType,
filterGroupsByPinned, filterGroupsByPinned,
MessageSearchParams, MessageSearchParams,
@@ -316,12 +317,21 @@ export function MessageSearch({
getNextPageParam: (lastPage) => lastPage.nextToken, getNextPageParam: (lastPage) => lastPage.nextToken,
}); });
// Shared client-side post-filter (msgtype + pinned) applied to BOTH the // Shared client-side post-filter (date range + msgtype + pinned) applied to
// server results and the local/encrypted-cache results, so the filter chips // BOTH the server results and the local/encrypted-cache results, so the
// narrow the whole UI consistently rather than only the server section. // 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( const applyResultFilters = useCallback(
(allGroups: ResultGroup[]): ResultGroup[] => { (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; if (!pinnedOnly) return byMsgType;
// Build a per-room pinned-event lookup. Heavy Matrix reads stay here // Build a per-room pinned-event lookup. Heavy Matrix reads stay here
// (where `mx` is available); the pure helper only consumes the predicate. // (where `mx` is available); the pure helper only consumes the predicate.
@@ -343,7 +353,7 @@ export function MessageSearch({
}; };
return filterGroupsByPinned(byMsgType, pinnedOnly, isPinned); return filterGroupsByPinned(byMsgType, pinnedOnly, isPinned);
}, },
[msgTypeFilters, pinnedOnly, mx], [msgSearchParams.fromTs, msgSearchParams.toTs, msgTypeFilters, pinnedOnly, mx],
); );
const groups = useMemo(() => { const groups = useMemo(() => {
@@ -1,6 +1,11 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; 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 // Minimal ResultGroup/ResultItem fixtures — only the fields the filters read
// (event.content.msgtype, event.event_id, group.roomId). // (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 } }, event: { event_id: eventId, content: msgtype === undefined ? {} : { msgtype } },
context: {}, context: {},
}); });
const tsItem = (eventId: string, ts: number) => ({
rank: 1,
event: { event_id: eventId, origin_server_ts: ts, content: {} },
context: {},
});
const mkGroups = ( const mkGroups = (
...groups: { roomId: string; items: ReturnType<typeof item>[] }[] ...groups: { roomId: string; items: ReturnType<typeof item>[] }[]
): ResultGroup[] => groups as unknown as ResultGroup[]; ): 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'); 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', () => { test('filterGroupsByPinned: disabled returns groups unchanged', () => {
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] }); const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] });
assert.equal( assert.equal(
@@ -71,6 +71,31 @@ export const filterGroupsByPinned = (
.filter((group) => group.items.length > 0); .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 groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = []; const groups: ResultGroup[] = [];
@@ -119,7 +144,9 @@ export type MessageSearchParams = {
}; };
export const useMessageSearch = (params: MessageSearchParams) => { export const useMessageSearch = (params: MessageSearchParams) => {
const mx = useMatrixClient(); 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( const searchMessages = useCallback(
async (nextBatch?: string) => { async (nextBatch?: string) => {
@@ -142,9 +169,10 @@ export const useMessageSearch = (params: MessageSearchParams) => {
limit, limit,
rooms, rooms,
senders, senders,
// from_ts / to_ts and contains_url are valid Matrix spec fields not yet in SDK types // `RoomEventFilter` has no timestamp bounds — from_ts/to_ts are not
...(fromTs !== undefined && { from_ts: fromTs }), // Matrix filter fields and the homeserver silently drops them, so the
...(toTs !== undefined && { to_ts: toTs }), // 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 }), ...(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,
@@ -161,7 +189,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
}); });
return parseSearchResult(r); return parseSearchResult(r);
}, },
[mx, term, order, rooms, senders, fromTs, toTs, containsUrl], [mx, term, order, rooms, senders, containsUrl],
); );
return searchMessages; return searchMessages;