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:
@@ -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;
|
||||
};
|
||||
|
||||
256
src/app/hooks/useRoomMediaEvents.ts
Normal file
256
src/app/hooks/useRoomMediaEvents.ts
Normal 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 };
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
60
src/app/hooks/useRoomUploadFiles.ts
Normal file
60
src/app/hooks/useRoomUploadFiles.ts
Normal 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 };
|
||||
};
|
||||
Reference in New Issue
Block a user