2026-07-01 21:19:02 -04:00
|
|
|
import { EventType, MatrixEvent } from 'matrix-js-sdk';
|
2026-05-28 20:01:21 -04:00
|
|
|
import { useCallback } from 'react';
|
2026-07-01 21:19:02 -04:00
|
|
|
import { useAtomValue } from 'jotai';
|
2026-05-28 20:01:21 -04:00
|
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
|
|
|
import { ResultGroup, ResultItem } from './useMessageSearch';
|
2026-07-01 21:19:02 -04:00
|
|
|
import { searchCacheEnabledAtom } from '../../state/searchCacheEnabled';
|
|
|
|
|
import {
|
|
|
|
|
mergeSearchResults,
|
|
|
|
|
queryRoom,
|
|
|
|
|
saveRoomIndex,
|
|
|
|
|
SearchCacheRow,
|
|
|
|
|
} from '../../utils/searchCache';
|
2026-05-28 20:01:21 -04:00
|
|
|
|
2026-05-28 22:07:53 -04:00
|
|
|
export type LocalSearchParams = {
|
|
|
|
|
term: string;
|
|
|
|
|
roomIds: string[];
|
|
|
|
|
senders?: string[];
|
2026-07-01 21:19:02 -04:00
|
|
|
/** Optional date-range filter (ms). Applied to both memory and cached rows. */
|
|
|
|
|
fromTs?: number;
|
|
|
|
|
toTs?: number;
|
2026-05-28 22:07:53 -04:00
|
|
|
};
|
|
|
|
|
|
2026-05-28 20:01:21 -04:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-01 21:19:02 -04:00
|
|
|
/** 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 a decrypted event's content. Returns
|
|
|
|
|
* `null` for events that carry no searchable text (e.g. stickers).
|
|
|
|
|
*/
|
|
|
|
|
const extractText = (event: MatrixEvent): ExtractedText | null => {
|
|
|
|
|
const evType = event.getType();
|
|
|
|
|
const content = event.getContent();
|
|
|
|
|
|
|
|
|
|
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: '' };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** 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). */
|
|
|
|
|
const rowToResultItem = (row: SearchCacheRow): 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: {},
|
|
|
|
|
};
|
|
|
|
|
return {
|
|
|
|
|
rank: 0,
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
event: syntheticEvent as any,
|
|
|
|
|
context: { events_before: [], events_after: [], profile_info: {} },
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-28 20:01:21 -04:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*
|
2026-07-01 21:19:02 -04:00
|
|
|
* 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.
|
2026-05-28 20:01:21 -04:00
|
|
|
*/
|
|
|
|
|
export const useLocalMessageSearch = () => {
|
|
|
|
|
const mx = useMatrixClient();
|
2026-07-01 21:19:02 -04:00
|
|
|
const cacheEnabled = useAtomValue(searchCacheEnabledAtom);
|
2026-05-28 20:01:21 -04:00
|
|
|
|
|
|
|
|
const search = useCallback(
|
2026-07-01 21:19:02 -04:00
|
|
|
async ({
|
|
|
|
|
term,
|
|
|
|
|
roomIds,
|
|
|
|
|
senders,
|
|
|
|
|
fromTs,
|
|
|
|
|
toTs,
|
|
|
|
|
}: LocalSearchParams): Promise<LocalSearchResult> => {
|
2026-05-28 20:01:21 -04:00
|
|
|
const trimmedTerm = term.trim();
|
2026-06-15 00:16:24 -04:00
|
|
|
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) {
|
2026-05-28 20:01:21 -04:00
|
|
|
return { groups: [], encryptedRoomsCount: 0, searchedRoomsCount: 0 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const termLower = trimmedTerm.toLowerCase();
|
2026-07-01 21:19:02 -04:00
|
|
|
const inRange = (ts: number): boolean =>
|
|
|
|
|
(fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs);
|
|
|
|
|
|
2026-05-28 20:01:21 -04:00
|
|
|
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, '');
|
|
|
|
|
|
2026-06-15 00:16:24 -04:00
|
|
|
// 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;
|
2026-05-28 20:01:21 -04:00
|
|
|
|
2026-06-09 22:56:06 -04:00
|
|
|
const events = room
|
|
|
|
|
.getUnfilteredTimelineSet()
|
|
|
|
|
.getTimelines()
|
|
|
|
|
.flatMap((tl) => tl.getEvents());
|
2026-07-01 21:19:02 -04:00
|
|
|
|
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
|
|
|
const cachedRows = cacheEnabled ? await queryRoom(roomId) : [];
|
|
|
|
|
|
|
|
|
|
if (events.length === 0 && cachedRows.length === 0) continue;
|
2026-05-28 20:01:21 -04:00
|
|
|
|
|
|
|
|
searchedRoomsCount += 1;
|
|
|
|
|
|
2026-07-01 21:19:02 -04:00
|
|
|
const memoryItems: ResultItem[] = [];
|
|
|
|
|
const rowsToPersist: SearchCacheRow[] = [];
|
2026-05-28 20:01:21 -04:00
|
|
|
|
|
|
|
|
for (let i = 0; i < events.length; i += 1) {
|
|
|
|
|
const event = events[i];
|
|
|
|
|
if (event.isDecryptionFailure()) continue;
|
|
|
|
|
if (event.isRedacted()) continue;
|
|
|
|
|
|
2026-07-01 21:19:02 -04:00
|
|
|
const evType = event.getType();
|
|
|
|
|
const isSticker = evType === 'm.sticker';
|
|
|
|
|
const isMessageLike =
|
|
|
|
|
evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType);
|
|
|
|
|
|
|
|
|
|
// Sender-only mode indexes/returns all message types; text mode needs text.
|
|
|
|
|
if (!senderOnlyMode && !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 && text && event.getId()) {
|
|
|
|
|
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;
|
2026-06-15 00:09:54 -04:00
|
|
|
|
2026-06-15 00:16:24 -04:00
|
|
|
if (!senderOnlyMode) {
|
2026-07-01 21:19:02 -04:00
|
|
|
if (!text || !matchesTerm(text, termLower)) continue;
|
2026-06-15 00:16:24 -04:00
|
|
|
}
|
2026-05-28 20:01:21 -04:00
|
|
|
|
2026-07-01 21:19:02 -04:00
|
|
|
const content = event.getContent();
|
2026-05-28 20:01:21 -04:00
|
|
|
const syntheticEvent = {
|
|
|
|
|
room_id: roomId,
|
|
|
|
|
event_id: event.getId() ?? '',
|
2026-07-01 21:19:02 -04:00
|
|
|
type: evType,
|
|
|
|
|
sender,
|
|
|
|
|
origin_server_ts: ts,
|
2026-05-28 20:01:21 -04:00
|
|
|
content,
|
|
|
|
|
unsigned: event.getUnsigned(),
|
|
|
|
|
};
|
2026-07-01 21:19:02 -04:00
|
|
|
memoryItems.push({
|
2026-05-28 20:01:21 -04:00
|
|
|
rank: 0,
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
event: syntheticEvent as any,
|
2026-07-01 21:19:02 -04:00
|
|
|
context: { events_before: [], events_after: [], profile_info: {} },
|
2026-05-28 20:01:21 -04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 21:19:02 -04:00
|
|
|
// 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;
|
|
|
|
|
cachedItems.push(rowToResultItem(row));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const items = mergeSearchResults(memoryItems, cachedItems);
|
|
|
|
|
|
2026-05-28 20:01:21 -04:00
|
|
|
if (items.length > 0) {
|
|
|
|
|
groups.push({ roomId, items });
|
|
|
|
|
}
|
2026-07-01 21:19:02 -04:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
2026-05-28 20:01:21 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { groups, encryptedRoomsCount, searchedRoomsCount };
|
|
|
|
|
},
|
2026-07-01 21:19:02 -04:00
|
|
|
[mx, cacheEnabled],
|
2026-05-28 20:01:21 -04:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return search;
|
|
|
|
|
};
|