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(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); }; /** * [Gitea #288] Server search also returns `m.replace` events, so an edited * message showed once per version. Fold each edit into its message: one row, * at the first position any version ranked, showing the latest text and * pointing at the original event (so "jump" lands on the real message). */ export const foldEdits = (results: ISearchResult[]): ISearchResult[] => { const targetOf = (r: ISearchResult): string => { const rel = r.result.content?.['m.relates_to']; return rel?.rel_type === 'm.replace' && typeof rel.event_id === 'string' ? rel.event_id : r.result.event_id; }; const isEdit = (r: ISearchResult) => targetOf(r) !== r.result.event_id; const order: string[] = []; const originals = new Map(); const latestEdit = new Map(); results.forEach((r) => { const target = targetOf(r); if (!originals.has(target) && !latestEdit.has(target)) order.push(target); if (!isEdit(r)) { originals.set(target, r); return; } const prev = latestEdit.get(target); if (!prev || r.result.origin_server_ts > prev.result.origin_server_ts) latestEdit.set(target, r); }); return order.map((target) => { const original = originals.get(target); const edit = latestEdit.get(target); const newContent = edit?.result.content?.['m.new_content']; if (original && !newContent) return original; if (original) { return { ...original, result: { ...original.result, content: { ...original.result.content, 'm.new_content': newContent }, }, }; } // Only an edit matched: show it as its message. const only = edit as ISearchResult; return { ...only, result: { ...only.result, event_id: target } }; }); }; const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => { const groups: ResultGroup[] = []; foldEdits(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 }), }, 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; };