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; }; /** Survives MediaDrawer remounts within the same room/session. */ const mediaCache = new Map(); 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(() => 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 }; };