Widen the media panel, support skinny full-page mode, keep jump-to-latest and return-to-previous reliable, and stop same-room event jumps from remounting the outlet or yanking the sidebar.
253 lines
6.6 KiB
TypeScript
253 lines
6.6 KiB
TypeScript
import {
|
|
IEventWithRoomId,
|
|
IResultContext,
|
|
ISearchRequestBody,
|
|
ISearchResponse,
|
|
ISearchResult,
|
|
MatrixClient,
|
|
MatrixEvent,
|
|
Room,
|
|
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[];
|
|
};
|
|
|
|
const EMPTY_CONTEXT: IResultContext = {
|
|
events_before: [],
|
|
events_after: [],
|
|
profile_info: {},
|
|
};
|
|
|
|
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
|
const groups: ResultGroup[] = [];
|
|
|
|
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;
|
|
|
|
return {
|
|
nextToken: roomEvents?.next_batch,
|
|
highlights: roomEvents?.highlights ?? [],
|
|
groups: groupSearchResult(roomEvents?.results ?? []),
|
|
};
|
|
};
|
|
|
|
const eventToSearchEvent = (event: MatrixEvent, roomId: string): IEventWithRoomId | undefined => {
|
|
const eventId = event.getId();
|
|
if (!eventId) return undefined;
|
|
|
|
const content = event.getClearContent() ?? event.getContent();
|
|
return {
|
|
event_id: eventId,
|
|
type: event.getWireType() === 'm.room.encrypted' ? 'm.room.message' : event.getType(),
|
|
sender: event.getSender() ?? '',
|
|
origin_server_ts: event.getTs(),
|
|
content,
|
|
room_id: roomId,
|
|
unsigned: event.getUnsigned(),
|
|
};
|
|
};
|
|
|
|
const getSearchableBody = (event: MatrixEvent): string | undefined => {
|
|
if (event.isRedacted()) return undefined;
|
|
|
|
// After decryption, clear content is available even if wire type was encrypted
|
|
const content = event.getClearContent() ?? event.getContent();
|
|
if (!content || typeof content !== 'object') return undefined;
|
|
|
|
// Skip still-encrypted payloads
|
|
if (event.isEncrypted() && !event.isDecryptionFailure() && !event.getClearContent()) {
|
|
return undefined;
|
|
}
|
|
|
|
const msgType = content.msgtype;
|
|
if (msgType && msgType !== 'm.text' && msgType !== 'm.notice' && msgType !== 'm.emote') {
|
|
// Still allow filename / body on media
|
|
}
|
|
|
|
const body = typeof content.body === 'string' ? content.body : undefined;
|
|
const formatted =
|
|
typeof content.formatted_body === 'string' ? content.formatted_body : undefined;
|
|
return [body, formatted].filter(Boolean).join('\n') || undefined;
|
|
};
|
|
|
|
const highlightsFromTerm = (term: string): string[] =>
|
|
term
|
|
.split(/\s+/)
|
|
.map((part) => part.trim())
|
|
.filter((part) => part.length > 1);
|
|
|
|
/**
|
|
* Search decrypted timeline events already loaded for a room.
|
|
* Server-side search cannot match E2EE message bodies.
|
|
*/
|
|
const searchLocalRoomTimeline = (room: Room, term: string): ResultItem[] => {
|
|
const needle = term.trim().toLowerCase();
|
|
if (!needle) return [];
|
|
|
|
const events = room.getLiveTimeline().getEvents();
|
|
const matches: ResultItem[] = [];
|
|
|
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
const event = events[i];
|
|
const haystack = getSearchableBody(event);
|
|
if (!haystack || !haystack.toLowerCase().includes(needle)) continue;
|
|
|
|
const searchEvent = eventToSearchEvent(event, room.roomId);
|
|
if (!searchEvent) continue;
|
|
|
|
matches.push({
|
|
rank: 1,
|
|
event: searchEvent,
|
|
context: EMPTY_CONTEXT,
|
|
});
|
|
}
|
|
|
|
return matches;
|
|
};
|
|
|
|
const isEncryptedRoom = (mx: MatrixClient, roomId: string): boolean => {
|
|
const room = mx.getRoom(roomId);
|
|
return !!room?.hasEncryptionStateEvent();
|
|
};
|
|
|
|
const searchLocalRooms = (mx: MatrixClient, roomIds: string[], term: string): SearchResult => {
|
|
const groups: ResultGroup[] = [];
|
|
|
|
roomIds.forEach((roomId) => {
|
|
const room = mx.getRoom(roomId);
|
|
if (!room) return;
|
|
const items = searchLocalRoomTimeline(room, term);
|
|
if (items.length > 0) {
|
|
groups.push({ roomId, items });
|
|
}
|
|
});
|
|
|
|
return {
|
|
highlights: highlightsFromTerm(term),
|
|
groups,
|
|
};
|
|
};
|
|
|
|
export type MessageSearchParams = {
|
|
term?: string;
|
|
order?: string;
|
|
rooms?: string[];
|
|
senders?: string[];
|
|
};
|
|
|
|
export const useMessageSearch = (params: MessageSearchParams) => {
|
|
const mx = useMatrixClient();
|
|
const { term, order, rooms, senders } = params;
|
|
|
|
const searchMessages = useCallback(
|
|
async (nextBatch?: string) => {
|
|
if (!term) {
|
|
return {
|
|
highlights: [],
|
|
groups: [],
|
|
};
|
|
}
|
|
|
|
const scopedRooms = rooms?.filter(Boolean) ?? [];
|
|
const encryptedScoped =
|
|
scopedRooms.length > 0 && scopedRooms.every((roomId) => isEncryptedRoom(mx, roomId));
|
|
|
|
// Encrypted-only scope: server cannot see bodies — search loaded timeline locally
|
|
if (encryptedScoped && !nextBatch) {
|
|
return searchLocalRooms(mx, scopedRooms, term);
|
|
}
|
|
|
|
const limit = 20;
|
|
const requestBody: ISearchRequestBody = {
|
|
search_categories: {
|
|
room_events: {
|
|
event_context: {
|
|
before_limit: 0,
|
|
after_limit: 0,
|
|
include_profile: false,
|
|
},
|
|
filter: {
|
|
limit,
|
|
rooms,
|
|
senders,
|
|
},
|
|
include_state: false,
|
|
order_by: order as SearchOrderBy.Recent,
|
|
search_term: term,
|
|
},
|
|
},
|
|
};
|
|
|
|
try {
|
|
const r = await mx.search({
|
|
body: requestBody,
|
|
next_batch: nextBatch === '' ? undefined : nextBatch,
|
|
});
|
|
const parsed = parseSearchResult(r);
|
|
|
|
if (
|
|
parsed.groups.length === 0 &&
|
|
!nextBatch &&
|
|
scopedRooms.length > 0 &&
|
|
scopedRooms.some((roomId) => isEncryptedRoom(mx, roomId))
|
|
) {
|
|
return searchLocalRooms(
|
|
mx,
|
|
scopedRooms.filter((id) => isEncryptedRoom(mx, id)),
|
|
term
|
|
);
|
|
}
|
|
|
|
return parsed;
|
|
} catch {
|
|
if (!nextBatch && scopedRooms.length > 0) {
|
|
return searchLocalRooms(mx, scopedRooms, term);
|
|
}
|
|
throw new Error('Message search failed');
|
|
}
|
|
},
|
|
[mx, term, order, rooms, senders]
|
|
);
|
|
|
|
return searchMessages;
|
|
};
|