108 lines
3.4 KiB
TypeScript
108 lines
3.4 KiB
TypeScript
|
|
import { EventType } from 'matrix-js-sdk';
|
||
|
|
import { useCallback } from 'react';
|
||
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||
|
|
import { ResultGroup, ResultItem } from './useMessageSearch';
|
||
|
|
|
||
|
|
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(
|
||
|
|
(roomIds: string[], term: string): LocalSearchResult => {
|
||
|
|
const trimmedTerm = term.trim();
|
||
|
|
if (!trimmedTerm) {
|
||
|
|
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, '');
|
||
|
|
if (!isEncrypted) continue;
|
||
|
|
|
||
|
|
encryptedRoomsCount += 1;
|
||
|
|
|
||
|
|
const events = room.getLiveTimeline().getEvents();
|
||
|
|
if (events.length === 0) continue;
|
||
|
|
|
||
|
|
searchedRoomsCount += 1;
|
||
|
|
|
||
|
|
const items: ResultItem[] = [];
|
||
|
|
|
||
|
|
for (let i = 0; i < events.length; i += 1) {
|
||
|
|
const event = events[i];
|
||
|
|
|
||
|
|
if (event.getType() !== EventType.RoomMessage) continue;
|
||
|
|
if (event.isDecryptionFailure()) continue;
|
||
|
|
if (event.isRedacted()) continue;
|
||
|
|
|
||
|
|
// getContent() returns decrypted plaintext regardless of encryption
|
||
|
|
const content = event.getContent();
|
||
|
|
const body = (content.body as string | undefined) ?? '';
|
||
|
|
const formattedBody = (content.formatted_body as string | undefined) ?? '';
|
||
|
|
|
||
|
|
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;
|
||
|
|
};
|