CI / Build & Quality Checks (pull_request) Successful in 2m57s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m21s
Server search also returns the m.replace events, so one message edited twice came back as three rows. Fold edits into their message: one row at the best-ranked position, showing the latest m.new_content and pointing at the original event so Open jumps to the real message. Fixes #288 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
244 lines
7.5 KiB
TypeScript
244 lines
7.5 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);
|
|
};
|
|
|
|
/**
|
|
* [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<string, ISearchResult>();
|
|
const latestEdit = new Map<string, ISearchResult>();
|
|
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;
|
|
};
|