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:
2026-07-23 14:52:01 +10:00
parent 61d41900cc
commit 9509a9705e
30 changed files with 1749 additions and 168 deletions

View File

@@ -1,5 +1,5 @@
import { useMatch } from 'react-router-dom';
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
import { getDirectCreatePath, getDirectPath, getDirectSearchPath } from '../../pages/pathUtils';
export const useDirectSelected = (): boolean => {
const directMatch = useMatch({
@@ -20,3 +20,13 @@ export const useDirectCreateSelected = (): boolean => {
return !!match;
};
export const useDirectSearchSelected = (): boolean => {
const match = useMatch({
path: getDirectSearchPath(),
caseSensitive: true,
end: false,
});
return !!match;
};

View File

@@ -0,0 +1,256 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import to from 'await-to-js';
import {
Direction,
IRoomEvent,
MatrixEvent,
MsgType,
RelationType,
Room,
} from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { useMatrixClient } from './useMatrixClient';
import { IEncryptedFile } from '../../types/matrix/common';
export type RoomMediaItem = {
eventId: string;
msgtype: typeof MsgType.Image | typeof MsgType.Video;
/** MXC used for an image thumbnail when available. */
thumbMxc?: string;
thumbMimeType?: string;
thumbEncInfo?: EncryptedAttachmentInfo;
/** Full media MXC — used to extract a video frame when no image thumb exists. */
fullMxc: string;
fullMimeType?: string;
fullEncInfo?: EncryptedAttachmentInfo;
body: string;
ts: number;
sender: string;
};
const PAGE_SIZE = 50;
const TARGET_BATCH = 24;
const MAX_PAGES_PER_LOAD = 8;
type RoomMediaCache = {
items: RoomMediaItem[];
hasMore: boolean;
nextToken: string | null;
seenIds: Set<string>;
};
/** Survives MediaDrawer remounts within the same room/session. */
const mediaCache = new Map<string, RoomMediaCache>();
const getOrCreateCache = (roomId: string): RoomMediaCache => {
const existing = mediaCache.get(roomId);
if (existing) return existing;
const created: RoomMediaCache = {
items: [],
hasMore: true,
nextToken: null,
seenIds: new Set(),
};
mediaCache.set(roomId, created);
return created;
};
const toMediaItem = (mEvent: MatrixEvent): RoomMediaItem | undefined => {
if (mEvent.isRedacted()) return undefined;
const relation = mEvent.getRelation();
if (relation?.rel_type === RelationType.Replace) return undefined;
const content = mEvent.getContent();
const msgtype = content.msgtype;
if (msgtype !== MsgType.Image && msgtype !== MsgType.Video) return undefined;
const file = content.file as IEncryptedFile | undefined;
const info = content.info as
| {
mimetype?: string;
thumbnail_url?: string;
thumbnail_info?: { mimetype?: string };
thumbnail_file?: IEncryptedFile;
}
| undefined;
const fullMxc =
(typeof content.url === 'string' && content.url) ||
(typeof file?.url === 'string' && file.url) ||
undefined;
if (!fullMxc) return undefined;
const thumbFile = info?.thumbnail_file;
const thumbMxc =
(typeof info?.thumbnail_url === 'string' && info.thumbnail_url) ||
(typeof thumbFile?.url === 'string' && thumbFile.url) ||
undefined;
const eventId = mEvent.getId();
const sender = mEvent.getSender();
if (!eventId || !sender) return undefined;
const usingThumbEnc = !!thumbMxc && thumbMxc === thumbFile?.url;
return {
eventId,
msgtype,
thumbMxc,
thumbMimeType: usingThumbEnc
? info?.thumbnail_info?.mimetype
: thumbMxc
? info?.thumbnail_info?.mimetype
: undefined,
thumbEncInfo: usingThumbEnc ? thumbFile : undefined,
fullMxc,
fullMimeType: info?.mimetype,
fullEncInfo: file,
body: typeof content.body === 'string' ? content.body : msgtype,
ts: mEvent.getTs(),
sender,
};
};
/**
* Paginates room history for image/video messages without mutating the live timeline.
*/
export const useRoomMediaEvents = (room: Room) => {
const mx = useMatrixClient();
const roomId = room.roomId;
const cacheRef = useRef(getOrCreateCache(roomId));
if (cacheRef.current !== mediaCache.get(roomId)) {
cacheRef.current = getOrCreateCache(roomId);
}
const [items, setItems] = useState<RoomMediaItem[]>(() => cacheRef.current.items);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(() => cacheRef.current.hasMore);
const loadingRef = useRef(false);
const decryptEvent = useCallback(
async (raw: IRoomEvent | MatrixEvent) => {
const mEvent = raw instanceof MatrixEvent ? raw : new MatrixEvent(raw);
if (mEvent.isEncrypted() && mx.getCrypto()) {
await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
}
return mEvent;
},
[mx]
);
const fetchPages = useCallback(async () => {
const cache = cacheRef.current;
const collected: RoomMediaItem[] = [];
for (let page = 0; page < MAX_PAGES_PER_LOAD; page += 1) {
const response = await mx.createMessagesRequest(
roomId,
cache.nextToken,
PAGE_SIZE,
Direction.Backward
);
cache.nextToken = response.end ?? null;
if (!response.end) {
cache.hasMore = false;
setHasMore(false);
}
for (const raw of response.chunk ?? []) {
const mEvent = await decryptEvent(raw);
const item = toMediaItem(mEvent);
if (!item || cache.seenIds.has(item.eventId)) continue;
cache.seenIds.add(item.eventId);
collected.push(item);
}
if (collected.length >= TARGET_BATCH || !response.end) break;
}
return collected;
}, [mx, roomId, decryptEvent]);
const loadMore = useCallback(async () => {
const cache = cacheRef.current;
if (loadingRef.current || !cache.hasMore) return;
loadingRef.current = true;
setLoading(true);
try {
const collected = await fetchPages();
if (collected.length > 0) {
cache.items = [...cache.items, ...collected];
setItems(cache.items);
}
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [fetchPages]);
useEffect(() => {
const cache = getOrCreateCache(roomId);
cacheRef.current = cache;
// Already loaded for this room — restore without re-fetching.
if (cache.items.length > 0 || cache.nextToken !== null || !cache.hasMore) {
setItems(cache.items);
setHasMore(cache.hasMore);
return undefined;
}
let cancelled = false;
const bootstrap = async () => {
cache.seenIds = new Set();
cache.nextToken = null;
cache.hasMore = true;
cache.items = [];
setItems([]);
setHasMore(true);
setLoading(true);
loadingRef.current = true;
try {
const localFound: RoomMediaItem[] = [];
const localEvents = room.getLiveTimeline().getEvents();
for (let i = localEvents.length - 1; i >= 0; i -= 1) {
const mEvent = await decryptEvent(localEvents[i]);
const item = toMediaItem(mEvent);
if (!item || cache.seenIds.has(item.eventId)) continue;
cache.seenIds.add(item.eventId);
localFound.push(item);
}
if (cancelled) return;
if (localFound.length > 0) {
cache.items = localFound;
setItems(localFound);
}
const collected = await fetchPages();
if (cancelled) return;
if (collected.length > 0) {
cache.items = [...cache.items, ...collected];
setItems(cache.items);
}
} finally {
if (!cancelled) {
loadingRef.current = false;
setLoading(false);
}
}
};
bootstrap();
return () => {
cancelled = true;
};
// Only re-bootstrap when switching rooms — not when callback identities change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomId]);
return { items, loading, hasMore, loadMore };
};

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { NavigateOptions, useNavigate } from 'react-router-dom';
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
import {
@@ -7,21 +7,25 @@ import {
getHomeRoomPath,
getSpacePath,
getSpaceRoomPath,
withRoomEventId,
} from '../pages/pathUtils';
import { useMatrixClient } from './useMatrixClient';
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
import { roomToParentsAtom } from '../state/room/roomToParents';
import { mDirectAtom } from '../state/mDirectList';
import { useSelectedRoom } from './router/useSelectedRoom';
import { useSelectedSpace } from './router/useSelectedSpace';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
export const useRoomNavigate = () => {
const navigate = useNavigate();
const location = useLocation();
const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
const spaceSelectedId = useSelectedSpace();
const selectedRoomId = useSelectedRoom();
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const navigateSpace = useCallback(
@@ -34,6 +38,14 @@ export const useRoomNavigate = () => {
const navigateRoom = useCallback(
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
// Already viewing this room — keep the current home/direct/space URL and only
// change the event id. Otherwise guessPerfectParent can yank the sidebar to
// wherever the room "belongs".
if (selectedRoomId === roomId) {
navigate(withRoomEventId(location.pathname, eventId), opts);
return;
}
const room = mx.getRoom(roomId);
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
@@ -68,7 +80,16 @@ export const useRoomNavigate = () => {
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
},
[mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools]
[
mx,
navigate,
location.pathname,
selectedRoomId,
spaceSelectedId,
roomToParents,
mDirects,
developerTools,
]
);
return {

View File

@@ -0,0 +1,60 @@
import { useCallback } from 'react';
import { useSetAtom } from 'jotai';
import { Room } from 'matrix-js-sdk';
import {
roomIdToUploadItemsAtomFamily,
TUploadItem,
} from '../state/room/roomInputDrafts';
import { encryptFile } from '../utils/matrix';
import { safeFile } from '../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../utils/common';
import { useFilePicker } from './useFilePicker';
/**
* Enqueues files into the room composer upload board (shared by RoomInput and header media).
*/
export const useRoomUploadFiles = (room: Room) => {
const setSelectedFiles = useSetAtom(roomIdToUploadItemsAtomFamily(room.roomId));
const handleFiles = useCallback(
async (files: File[]) => {
const safeFiles = files.map(safeFile);
const fileItems: TUploadItem[] = [];
if (room.hasEncryptionStateEvent()) {
const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
);
encryptFiles.forEach((ef) =>
fileItems.push({
...ef,
metadata: {
markedAsSpoiler: false,
},
})
);
} else {
safeFiles.forEach((f) =>
fileItems.push({
file: f,
originalFile: f,
encInfo: undefined,
metadata: {
markedAsSpoiler: false,
},
})
);
}
setSelectedFiles({
type: 'PUT',
item: fileItems,
});
},
[setSelectedFiles, room]
);
const pickFiles = useFilePicker(handleFiles, true);
return { handleFiles, pickFiles };
};