Add Shared Media drawer and harden same-room navigation.
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.
This commit is contained in:
@@ -16,7 +16,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
|
||||
import { useRooms } from '../../state/hooks/roomList';
|
||||
import { useRooms, useDirects } from '../../state/hooks/roomList';
|
||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
||||
@@ -53,7 +53,13 @@ export function MessageSearch({
|
||||
}: MessageSearchProps) {
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const allRooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||
const nonDirectRooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||
const directRooms = useDirects(mx, allRoomsAtom, mDirects);
|
||||
// Include DMs — previously useRooms alone stripped them and broke DM search
|
||||
const allRooms = useMemo(
|
||||
() => [...nonDirectRooms, ...directRooms],
|
||||
[nonDirectRooms, directRooms]
|
||||
);
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
ISearchRequestBody,
|
||||
ISearchResponse,
|
||||
ISearchResult,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
SearchOrderBy,
|
||||
} from 'matrix-js-sdk';
|
||||
import { useCallback } from 'react';
|
||||
@@ -26,6 +29,12 @@ export type SearchResult = {
|
||||
groups: ResultGroup[];
|
||||
};
|
||||
|
||||
const EMPTY_CONTEXT: IResultContext = {
|
||||
events_before: [],
|
||||
events_after: [],
|
||||
profile_info: {},
|
||||
};
|
||||
|
||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||
const groups: ResultGroup[] = [];
|
||||
|
||||
@@ -54,13 +63,108 @@ const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||
const parseSearchResult = (result: ISearchResponse): SearchResult => {
|
||||
const roomEvents = result.search_categories.room_events;
|
||||
|
||||
const searchResult: SearchResult = {
|
||||
return {
|
||||
nextToken: roomEvents?.next_batch,
|
||||
highlights: roomEvents?.highlights ?? [],
|
||||
groups: groupSearchResult(roomEvents?.results ?? []),
|
||||
};
|
||||
};
|
||||
|
||||
return searchResult;
|
||||
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 = {
|
||||
@@ -69,19 +173,30 @@ export type MessageSearchParams = {
|
||||
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)
|
||||
if (!term) {
|
||||
return {
|
||||
highlights: [],
|
||||
groups: [],
|
||||
};
|
||||
const limit = 20;
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -102,11 +217,33 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
},
|
||||
};
|
||||
|
||||
const r = await mx.search({
|
||||
body: requestBody,
|
||||
next_batch: nextBatch === '' ? undefined : nextBatch,
|
||||
});
|
||||
return parseSearchResult(r);
|
||||
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]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user