Fixes #62 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
import { EventType, MatrixEvent, RelationType } from 'matrix-js-sdk';
|
|
import { useCallback } from 'react';
|
|
import { useAtomValue } from 'jotai';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { ResultGroup, ResultItem } from './useMessageSearch';
|
|
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
|
|
import {
|
|
mergeSearchResults,
|
|
queryRoom,
|
|
saveRoomIndex,
|
|
SearchCacheRow,
|
|
} from '../../utils/searchCache';
|
|
|
|
export type LocalSearchParams = {
|
|
term: string;
|
|
roomIds: string[];
|
|
senders?: string[];
|
|
/** Optional date-range filter (ms). Applied to both memory and cached rows. */
|
|
fromTs?: number;
|
|
toTs?: number;
|
|
};
|
|
|
|
export type LocalSearchResult = {
|
|
groups: ResultGroup[];
|
|
/** How many rooms in scope are encrypted */
|
|
encryptedRoomsCount: number;
|
|
/** How many of those had locally cached events to scan */
|
|
searchedRoomsCount: number;
|
|
};
|
|
|
|
/** Extracted, searchable plaintext for a single message event. */
|
|
type ExtractedText = {
|
|
body: string;
|
|
formattedBody: string;
|
|
pollText: string;
|
|
};
|
|
|
|
const POLL_START_TYPES = ['m.poll.start', 'org.matrix.msc3381.poll.start'];
|
|
|
|
/**
|
|
* Pull the text we index/search from an event type + content pair. Returns
|
|
* `null` when there's no searchable text (e.g. stickers). Split out from
|
|
* `extractText` so an edit's `m.new_content` can be run through the same
|
|
* logic as a normal event's content.
|
|
*/
|
|
const extractTextFromContent = (
|
|
evType: string,
|
|
content: Record<string, unknown>,
|
|
): ExtractedText | null => {
|
|
if (POLL_START_TYPES.includes(evType)) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any;
|
|
if (!poll) return null;
|
|
const qBody =
|
|
(poll.question?.['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
|
(poll.question?.body as string | undefined) ??
|
|
'';
|
|
const answerBodies = ((poll.answers ?? []) as Array<Record<string, unknown>>)
|
|
.map(
|
|
(a) =>
|
|
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
|
|
'') as string,
|
|
)
|
|
.join(' ');
|
|
const pollText = `${qBody} ${answerBodies}`.trim();
|
|
return pollText ? { body: '', formattedBody: '', pollText } : null;
|
|
}
|
|
|
|
if (evType !== EventType.RoomMessage) return null;
|
|
|
|
const body = (content.body as string | undefined) ?? '';
|
|
const formattedBody = (content.formatted_body as string | undefined) ?? '';
|
|
if (!body && !formattedBody) return null;
|
|
return { body, formattedBody, pollText: '' };
|
|
};
|
|
|
|
/**
|
|
* Pull the text we index/search from a decrypted event's content. Returns
|
|
* `null` for events that carry no searchable text (e.g. stickers).
|
|
*/
|
|
const extractText = (event: MatrixEvent): ExtractedText | null =>
|
|
extractTextFromContent(event.getType(), event.getContent());
|
|
|
|
/** Does the extracted text contain the (already-lowercased) term? */
|
|
const matchesTerm = (text: ExtractedText, termLower: string): boolean =>
|
|
text.body.toLowerCase().includes(termLower) ||
|
|
text.formattedBody.toLowerCase().includes(termLower) ||
|
|
text.pollText.toLowerCase().includes(termLower);
|
|
|
|
const rowMatchesTerm = (row: SearchCacheRow, termLower: string): boolean =>
|
|
row.body.toLowerCase().includes(termLower) ||
|
|
(row.formattedBody ?? '').toLowerCase().includes(termLower) ||
|
|
(row.pollText ?? '').toLowerCase().includes(termLower);
|
|
|
|
/**
|
|
* Build the synthetic result item a cached row renders as (text message).
|
|
*
|
|
* `redacted` marks a row whose event we can tell, from the local timeline,
|
|
* has since been redacted (the async cache-delete listener in
|
|
* `searchCacheInvalidation.ts` may not have caught up yet). It carries a
|
|
* `redacted_because` marker on `unsigned` so `SearchResultGroup`'s existing
|
|
* guard renders the "message deleted" placeholder instead of the stale
|
|
* plaintext (Gitea #14).
|
|
*/
|
|
export const rowToResultItem = (row: SearchCacheRow, redacted = false): ResultItem => {
|
|
const bodyText = row.body || row.pollText || '';
|
|
const content: Record<string, unknown> = { msgtype: 'm.text', body: bodyText };
|
|
if (row.formattedBody) {
|
|
content.format = 'org.matrix.custom.html';
|
|
content.formatted_body = row.formattedBody;
|
|
}
|
|
const syntheticEvent = {
|
|
room_id: row.roomId,
|
|
event_id: row.eventId,
|
|
type: EventType.RoomMessage,
|
|
sender: row.sender,
|
|
origin_server_ts: row.ts,
|
|
content,
|
|
unsigned: redacted ? { redacted_because: { content: {} } } : {},
|
|
};
|
|
return {
|
|
rank: 0,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
event: syntheticEvent as any,
|
|
context: { events_before: [], events_after: [], profile_info: {} },
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Client-side full-text search over locally cached events in encrypted rooms.
|
|
* The homeserver cannot search E2EE message content, so we scan whatever the
|
|
* client has already received and decrypted in memory.
|
|
*
|
|
* When the persistent search cache is enabled (opt-in), the in-memory scan is
|
|
* also persisted to IndexedDB (fire-and-forget) and merged with prior cached
|
|
* coverage so results survive reloads. When disabled, zero cache reads/writes
|
|
* occur.
|
|
*/
|
|
export const useLocalMessageSearch = () => {
|
|
const mx = useMatrixClient();
|
|
const cacheEnabled = useAtomValue(searchCacheEnabledAtom);
|
|
|
|
const search = useCallback(
|
|
async ({
|
|
term,
|
|
roomIds,
|
|
senders,
|
|
fromTs,
|
|
toTs,
|
|
}: LocalSearchParams): Promise<LocalSearchResult> => {
|
|
const trimmedTerm = term.trim();
|
|
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
|
|
|
|
// Sender-only mode: no text filter, search all rooms (server can't filter by sender alone)
|
|
const senderOnlyMode = !trimmedTerm && !!senderSet;
|
|
|
|
if (!trimmedTerm && !senderSet) {
|
|
return { groups: [], encryptedRoomsCount: 0, searchedRoomsCount: 0 };
|
|
}
|
|
|
|
const termLower = trimmedTerm.toLowerCase();
|
|
const inRange = (ts: number): boolean =>
|
|
(fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs);
|
|
|
|
const groups: ResultGroup[] = [];
|
|
let encryptedRoomsCount = 0;
|
|
let searchedRoomsCount = 0;
|
|
|
|
for (const roomId of roomIds) {
|
|
const room = mx.getRoom(roomId);
|
|
if (!room) continue;
|
|
|
|
const isEncrypted = !!room.currentState.getStateEvents(EventType.RoomEncryption, '');
|
|
|
|
// Text search: encrypted rooms only — server already covers plaintext rooms
|
|
// Sender-only: all rooms — server has no sender-only search
|
|
if (!senderOnlyMode && !isEncrypted) continue;
|
|
|
|
if (isEncrypted) encryptedRoomsCount += 1;
|
|
|
|
const events = room
|
|
.getUnfilteredTimelineSet()
|
|
.getTimelines()
|
|
.flatMap((tl) => tl.getEvents());
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const cachedRows = cacheEnabled ? await queryRoom(roomId) : [];
|
|
|
|
if (events.length === 0 && cachedRows.length === 0) continue;
|
|
|
|
searchedRoomsCount += 1;
|
|
|
|
const memoryItems: ResultItem[] = [];
|
|
const rowsToPersist: SearchCacheRow[] = [];
|
|
|
|
for (let i = 0; i < events.length; i += 1) {
|
|
const event = events[i];
|
|
if (event.isDecryptionFailure()) continue;
|
|
if (event.isRedacted()) continue;
|
|
|
|
const evType = event.getType();
|
|
const isSticker = evType === 'm.sticker';
|
|
const isMessageLike =
|
|
evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType);
|
|
|
|
// Both modes are restricted to message-like events/stickers — sender-only
|
|
// mode must not surface membership/state/reaction/redaction events (Gitea #62).
|
|
if (!isMessageLike && !isSticker) continue;
|
|
|
|
const sender = event.getSender() ?? '';
|
|
const ts = event.getTs();
|
|
const text = extractText(event);
|
|
|
|
// Persist every indexable (text-bearing) event we scanned, regardless
|
|
// of whether it matches the current term — future searches benefit.
|
|
if (cacheEnabled && event.getId()) {
|
|
// An edit (`m.replace`) event's own body is just a "* new text"
|
|
// fallback. Indexing it under its own event id would leave two
|
|
// separate matching rows (the stale pre-edit text and the edit
|
|
// fallback) searchable forever. Instead, upsert the *original*
|
|
// event's row with the edit's `m.new_content` (Gitea #14).
|
|
const editTargetId =
|
|
event.getRelation()?.rel_type === RelationType.Replace
|
|
? event.getRelation()?.event_id
|
|
: undefined;
|
|
if (editTargetId) {
|
|
const newContent = (event.getContent()['m.new_content'] ?? {}) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
const editedText = extractTextFromContent(EventType.RoomMessage, newContent);
|
|
if (editedText) {
|
|
rowsToPersist.push({
|
|
roomId,
|
|
eventId: editTargetId,
|
|
ts: room.findEventById(editTargetId)?.getTs() ?? ts,
|
|
sender,
|
|
body: editedText.body,
|
|
...(editedText.formattedBody ? { formattedBody: editedText.formattedBody } : {}),
|
|
});
|
|
}
|
|
} else if (text) {
|
|
rowsToPersist.push({
|
|
roomId,
|
|
eventId: event.getId() as string,
|
|
ts,
|
|
sender,
|
|
body: text.body,
|
|
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
|
|
...(text.pollText ? { pollText: text.pollText } : {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (senderSet && !senderSet.has(sender)) continue;
|
|
if (!inRange(ts)) continue;
|
|
|
|
if (!senderOnlyMode) {
|
|
if (!text || !matchesTerm(text, termLower)) continue;
|
|
}
|
|
|
|
const content = event.getContent();
|
|
const syntheticEvent = {
|
|
room_id: roomId,
|
|
event_id: event.getId() ?? '',
|
|
type: evType,
|
|
sender,
|
|
origin_server_ts: ts,
|
|
content,
|
|
unsigned: event.getUnsigned(),
|
|
};
|
|
memoryItems.push({
|
|
rank: 0,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
event: syntheticEvent as any,
|
|
context: { events_before: [], events_after: [], profile_info: {} },
|
|
});
|
|
}
|
|
|
|
// Match cached rows (skip ids already present in memory happens in merge).
|
|
const cachedItems: ResultItem[] = [];
|
|
cachedRows.forEach((row) => {
|
|
if (senderSet && !senderSet.has(row.sender)) return;
|
|
if (!inRange(row.ts)) return;
|
|
if (!senderOnlyMode && !rowMatchesTerm(row, termLower)) return;
|
|
// The cache-delete listener (searchCacheInvalidation.ts) removes a
|
|
// row on redaction asynchronously; if the event is still around
|
|
// locally we can check for certain and must not surface stale
|
|
// plaintext in the meantime (Gitea #14).
|
|
const localEvent = room.findEventById(row.eventId);
|
|
cachedItems.push(rowToResultItem(row, localEvent?.isRedacted()));
|
|
});
|
|
|
|
const items = mergeSearchResults(memoryItems, cachedItems);
|
|
|
|
if (items.length > 0) {
|
|
groups.push({ roomId, items });
|
|
}
|
|
|
|
// Fire-and-forget persist of freshly scanned rows + coverage.
|
|
// saveRoomIndex swallows all errors internally, so a floating promise
|
|
// here can never reject.
|
|
if (cacheEnabled && rowsToPersist.length > 0) {
|
|
saveRoomIndex(roomId, rowsToPersist);
|
|
}
|
|
}
|
|
|
|
return { groups, encryptedRoomsCount, searchedRoomsCount };
|
|
},
|
|
[mx, cacheEnabled],
|
|
);
|
|
|
|
return search;
|
|
};
|