Files
cinny/src/app/features/message-search/useMessageSearch.ts
T
jaredandClaude Opus 5 02592ed43c 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
2026-09-12 14:48:35 -04:00

197 lines
5.8 KiB
TypeScript

import {
IEventWithRoomId,
IResultContext,
ISearchRequestBody,
ISearchResponse,
ISearchResult,
SearchOrderBy,
} from 'matrix-js-sdk';
import { useCallback } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
export type ResultItem = {
rank: number;
event: IEventWithRoomId;
context: IResultContext;
};
export type ResultGroup = {
roomId: string;
items: ResultItem[];
};
export type SearchResult = {
nextToken?: string;
highlights: string[];
groups: ResultGroup[];
};
// Client-side msgtype post-filter. The Matrix search API cannot filter by
// msgtype server-side, so this is applied to already-returned results.
export type MsgTypeFilter = 'm.image' | 'm.file' | 'm.video';
/**
* Filter result groups to items whose event msgtype is in `msgTypes` (OR/union).
* Empty/absent filter returns groups unchanged. Now-empty groups are dropped.
*/
export const filterGroupsByMsgType = (
groups: ResultGroup[],
msgTypes: MsgTypeFilter[],
): ResultGroup[] => {
if (msgTypes.length === 0) return groups;
const allowed = new Set<string>(msgTypes);
return groups
.map((group) => ({
...group,
items: group.items.filter((item) => {
const msgtype = item.event.content?.msgtype;
return typeof msgtype === 'string' && allowed.has(msgtype);
}),
}))
.filter((group) => group.items.length > 0);
};
/**
* Filter result groups to items whose event is currently pinned in its room.
* `isPinned(roomId, eventId)` returns whether the event is in the room's
* `m.room.pinned_events` set. When `enabled` is false, groups are returned
* unchanged. Now-empty groups are dropped.
*/
export const filterGroupsByPinned = (
groups: ResultGroup[],
enabled: boolean,
isPinned: (roomId: string, eventId: string) => boolean,
): ResultGroup[] => {
if (!enabled) return groups;
return groups
.map((group) => ({
...group,
items: group.items.filter((item) => isPinned(group.roomId, item.event.event_id)),
}))
.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[] = [];
results.forEach((item) => {
const roomId = item.result.room_id;
const resultItem: ResultItem = {
rank: item.rank,
event: item.result,
context: item.context,
};
const lastAddedGroup: ResultGroup | undefined = groups[groups.length - 1];
if (lastAddedGroup && roomId === lastAddedGroup.roomId) {
lastAddedGroup.items.push(resultItem);
return;
}
groups.push({
roomId,
items: [resultItem],
});
});
return groups;
};
const parseSearchResult = (result: ISearchResponse): SearchResult => {
const roomEvents = result.search_categories.room_events;
const searchResult: SearchResult = {
nextToken: roomEvents?.next_batch,
highlights: roomEvents?.highlights ?? [],
groups: groupSearchResult(roomEvents?.results ?? []),
};
return searchResult;
};
export type MessageSearchParams = {
term?: string;
order?: string;
rooms?: string[];
senders?: string[];
fromTs?: number;
toTs?: number;
containsUrl?: boolean;
};
export const useMessageSearch = (params: MessageSearchParams) => {
const mx = useMatrixClient();
// 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) => {
if (!term)
return {
highlights: [],
groups: [],
};
const limit = 50;
const requestBody: ISearchRequestBody = {
search_categories: {
room_events: {
event_context: {
before_limit: 0,
after_limit: 0,
include_profile: false,
},
filter: {
limit,
rooms,
senders,
// `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,
include_state: false,
order_by: order as SearchOrderBy.Recent,
search_term: term,
},
},
};
const r = await mx.search({
body: requestBody,
next_batch: nextBatch === '' ? undefined : nextBatch,
});
return parseSearchResult(r);
},
[mx, term, order, rooms, senders, containsUrl],
);
return searchMessages;
};