Files
cinny/src/app/features/message-search/useLocalMessageSearch.ts
T

168 lines
6.1 KiB
TypeScript
Raw Normal View History

import { EventType } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { ResultGroup, ResultItem } from './useMessageSearch';
export type LocalSearchParams = {
term: string;
roomIds: string[];
senders?: string[];
};
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;
};
/**
* 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.
*
* Limitation: only messages present in the live timeline window are covered.
* Rooms that haven't been opened yet will return no results.
*/
export const useLocalMessageSearch = () => {
const mx = useMatrixClient();
const search = useCallback(
({ term, roomIds, senders }: LocalSearchParams): 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 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());
if (events.length === 0) continue;
searchedRoomsCount += 1;
const items: ResultItem[] = [];
for (let i = 0; i < events.length; i += 1) {
const event = events[i];
// In sender-only mode: include all message types; skip non-message events
if (event.getType() !== EventType.RoomMessage) {
if (senderOnlyMode) continue;
const evType = event.getType();
const isSticker = evType === 'm.sticker';
const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
if (!isSticker && !isPoll) continue;
}
2026-06-15 00:09:54 -04:00
if (event.isDecryptionFailure()) continue;
if (event.isRedacted()) continue;
if (senderSet && !senderSet.has(event.getSender() ?? '')) continue;
// getContent() returns decrypted plaintext regardless of encryption
const content = event.getContent();
2026-06-15 00:09:54 -04:00
// Sender-only mode: no text filter needed
if (!senderOnlyMode) {
const evType = event.getType();
const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
let body = '';
let formattedBody = '';
if (!isPoll) {
body = (content.body as string | undefined) ?? '';
formattedBody = (content.formatted_body as string | undefined) ?? '';
} else {
// Poll — index question text and all answer options
const poll = (content['m.poll'] ??
2026-06-15 00:09:54 -04:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any
content['org.matrix.msc3381.poll.start']) as any;
if (poll) {
const qBody =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(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) =>
2026-06-15 00:09:54 -04:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any
((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(' ');
body = `${qBody} ${answerBodies}`.trim();
}
2026-06-15 00:09:54 -04:00
}
if (
!body.toLowerCase().includes(termLower) &&
!formattedBody.toLowerCase().includes(termLower)
)
continue;
}
// Build a synthetic IEventWithRoomId using decrypted content so the
// existing SearchResultGroup renderer works without modification.
const syntheticEvent = {
room_id: roomId,
event_id: event.getId() ?? '',
type: event.getType(),
sender: event.getSender() ?? '',
origin_server_ts: event.getTs(),
content,
unsigned: event.getUnsigned(),
};
items.push({
rank: 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: syntheticEvent as any,
context: {
events_before: [],
events_after: [],
profile_info: {},
},
});
}
if (items.length > 0) {
items.sort((a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0));
groups.push({ roomId, items });
}
}
return { groups, encryptedRoomsCount, searchedRoomsCount };
},
[mx],
);
return search;
};