import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Box, Button, Chip, Header, Icon, IconButton, Icons, Overlay, OverlayBackdrop, Scroll, Spinner, Text, Tooltip, TooltipProvider, color, config, } from 'folds'; import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; import { useNearViewport } from '../../hooks/useNearViewport'; import { useZoom } from '../../hooks/useZoom'; import { usePan, Pan } from '../../hooks/usePan'; import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix'; import { AudioContent, FileDownloadButton } from '../../components/message'; import { MediaControl } from '../../components/media'; import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { stopPropagation } from '../../utils/keyboard'; import * as css from './MediaGallery.css'; type GalleryTab = 'image' | 'video' | 'file' | 'audio'; const TAB_LABELS: Record = { image: 'Images', video: 'Videos', audio: 'Audio', file: 'Files', }; const TAB_MSGTYPES: Record = { image: MsgType.Image, video: MsgType.Video, audio: MsgType.Audio, file: MsgType.File, }; // ── Decrypt hook ────────────────────────────────────────────────────────────── type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' }; function useDecryptedMediaUrl( mx: MatrixClient, mxcUrl: string | undefined, encInfo: IEncryptedFile | undefined, useAuthentication: boolean, mimeType?: string, enabled = true, ): DecryptState { const [state, setState] = useState({ status: 'loading' }); const prevBlobUrl = useRef(null); useEffect(() => { if (!enabled) return undefined; if (!mxcUrl) { setState({ status: 'error' }); return; } let cancelled = false; setState({ status: 'loading' }); const run = async () => { const httpUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication); if (!httpUrl) throw new Error('bad url'); if (encInfo) { const blob = await downloadEncryptedMedia(httpUrl, (buf) => decryptFile(buf, mimeType ?? 'application/octet-stream', encInfo), ); const blobUrl = URL.createObjectURL(blob); if (cancelled) { URL.revokeObjectURL(blobUrl); return; } if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current); prevBlobUrl.current = blobUrl; setState({ status: 'ok', url: blobUrl }); } else { setState({ status: 'ok', url: httpUrl }); } }; run().catch(() => { if (!cancelled) setState({ status: 'error' }); }); return () => { cancelled = true; }; }, [mx, mxcUrl, encInfo, useAuthentication, mimeType, enabled]); useEffect( () => () => { if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current); }, [], ); return state; } // ── Helpers ─────────────────────────────────────────────────────────────────── function formatRelativeDate(ts: number): string { const diff = Date.now() - ts; const mins = Math.floor(diff / 60000); if (mins < 2) return 'Just now'; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(diff / 3600000); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(diff / 86400000); if (days < 7) return `${days}d ago`; return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1048576).toFixed(1)} MB`; } // A sensible download filename: prefer the event body/filename; if it has no // plausible extension already, append one derived from the mimetype so the // saved file opens. "Plausible" = a short alphanumeric tail after the last dot, // so "Screenshot 2024.01.05" still gets a real extension appended. function hasFileExtension(name: string): boolean { const dot = name.lastIndexOf('.'); if (dot <= 0 || dot === name.length - 1) return false; return /^[a-z0-9]{1,5}$/i.test(name.slice(dot + 1)); } function mediaFilename(body: string, mimeType?: string): string { const name = body.trim() || 'media'; if (hasFileExtension(name)) return name; const ext = mimeType ? mimeTypeToExt(mimeType) : ''; return ext ? `${name}.${ext}` : name; } function monthLabel(ts: number): string { return new Date(ts).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); } function getSenderName(room: Room, userId: string): string { return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId; } // Resolve the thumbnail/display MXC for an image/video event, mirroring the // grid's preference order (encrypted thumb > file > thumbnail_url > url). Both // the grid and the lightbox must use this so their positional indices stay in // lockstep — otherwise a tile skipped for lack of a thumb would shift the // lightbox and open the wrong media. function getThumbMxc(mEvent: MatrixEvent): string | undefined { const c = mEvent.getContent(); const isEnc = !!c.file; const info: (IImageInfo & IThumbnailContent) | undefined = c.info; return isEnc ? (info?.thumbnail_file?.url ?? c.file?.url) : (info?.thumbnail_url ?? c.url); } // ── Lightbox ────────────────────────────────────────────────────────────────── type LightboxItem = { mxcUrl: string; encInfo?: IEncryptedFile; mimeType?: string; msgtype: MsgType.Image | MsgType.Video; body: string; sender: string; ts: number; eventId: string; }; function LightboxMedia({ item, useAuthentication, zoom, pan, cursor, onMouseDown, onImageDoubleClick, }: { item: LightboxItem; useAuthentication: boolean; zoom: number; pan: Pan; cursor: string; onMouseDown: React.MouseEventHandler; onImageDoubleClick: () => void; }) { const mx = useMatrixClient(); const media = useDecryptedMediaUrl( mx, item.mxcUrl, item.encInfo, useAuthentication, item.mimeType, ); return ( {media.status === 'loading' && ( {item.encInfo ? 'Decrypting…' : 'Loading…'} )} {media.status === 'error' && ( Failed to load )} {media.status === 'ok' && (item.msgtype === MsgType.Video ? ( ); } function Lightbox({ items, initialIndex, useAuthentication, onClose, onJump, }: { items: LightboxItem[]; initialIndex: number; useAuthentication: boolean; onClose: () => void; onJump: (eventId: string) => void; }) { const [index, setIndex] = useState(initialIndex); const item = items[index]; const isImage = item?.msgtype === MsgType.Image; const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); // Pan is only active for a zoomed-in image; usePan resets its offset when this // flips false (i.e. back to 1x, on navigation, or on a video). const { pan, cursor, onMouseDown } = usePan(isImage && zoom !== 1); const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]); // Reset zoom when navigating to another item (and thus pan, via usePan). useEffect(() => { setZoom(1); }, [index, setZoom]); const prev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []); const next = useCallback( () => setIndex((i) => Math.min(items.length - 1, i + 1)), [items.length], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'ArrowLeft') prev(); else if (e.key === 'ArrowRight') next(); else if (e.key === 'Escape') onClose(); else if (isImage && (e.key === '+' || e.key === '=')) zoomIn(); else if (isImage && e.key === '-') zoomOut(); else if (isImage && e.key === '0') setZoom(1); }, [prev, next, onClose, isImage, zoomIn, zoomOut, setZoom], ); const handleWheel = useCallback( (e: React.WheelEvent) => { if (!isImage) return; if (e.deltaY < 0) zoomIn(); else if (e.deltaY > 0) zoomOut(); }, [isImage, zoomIn, zoomOut], ); if (!item) return null; const dateStr = new Date(item.ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', }); return ( }>
{/* Header bar */} {item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')} {item.sender} · {dateStr} {index + 1} / {items.length} {isImage && ( {Math.round(zoom * 100)}% = 5} > )} {item.mxcUrl && ( Download } > {(ref) => ( )} )} {item.eventId && ( Go to message } > {(ref) => ( onJump(item.eventId)} > )} )} Close } > {(ref) => ( )} {/* Media area with nav arrows */} {index > 0 && ( )} {index < items.length - 1 && ( )}
); } // ── Gallery tile ────────────────────────────────────────────────────────────── function GalleryTile({ mxcUrl, encInfo, mimeType, isVideo, body, sender, ts, useAuthentication, onClick, downloadUrl, downloadEncInfo, downloadMimeType, downloadFilename, }: { mxcUrl: string; encInfo?: IEncryptedFile; mimeType?: string; isVideo: boolean; body: string; sender: string; ts: number; useAuthentication: boolean; onClick: () => void; downloadUrl?: string; downloadEncInfo?: IEncryptedFile; downloadMimeType?: string; downloadFilename: string; }) { const mx = useMatrixClient(); const tileRef = useRef(null); const nearViewport = useNearViewport(tileRef, 300); const media = useDecryptedMediaUrl( mx, mxcUrl, encInfo, useAuthentication, mimeType, nearViewport, ); const relDate = formatRelativeDate(ts); return (
{downloadUrl && (
)}
); } // ── Month separator ─────────────────────────────────────────────────────────── // ── Tab button ──────────────────────────────────────────────────────────────── function TabButton({ label, count, active, onClick, }: { label: string; count: number; active: boolean; onClick: () => void; }) { return ( ); } // ── Main component ──────────────────────────────────────────────────────────── type MediaGalleryProps = { room: Room; onClose: () => void; }; export function MediaGallery({ room, onClose }: MediaGalleryProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); // Close the drawer and land the timeline on the source message. const jumpToMessage = useCallback( (eventId: string) => { onClose(); navigateRoom(room.roomId, eventId); }, [onClose, navigateRoom, room.roomId], ); const [tab, setTab] = useState('image'); const [loading, setLoading] = useState(false); const [hasLoadedOnce, setHasLoadedOnce] = useState(false); const [canLoadMore, setCanLoadMore] = useState(true); const [loadError, setLoadError] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(null); const sentinelRef = useRef(null); const handleTabChange = useCallback((t: GalleryTab) => { setTab(t); setLightboxIndex(null); // stale index would open wrong item in new tab's lightboxItems }, []); // Escape closes the drawer — but only when the lightbox isn't open, since the // lightbox has its own Escape handler that should take precedence. useEffect(() => { if (lightboxIndex !== null) return undefined; const handleKeyDown = (evt: KeyboardEvent) => { if (evt.key === 'Escape') { stopPropagation(evt); onClose(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [lightboxIndex, onClose]); const msgtype = TAB_MSGTYPES[tab]; const getFilteredEvents = useCallback( (): MatrixEvent[] => room .getLiveTimeline() .getEvents() .filter((ev) => { if (ev.isRedacted()) return false; const c = ev.getContent(); return ev.getType() === EventType.RoomMessage && c.msgtype === msgtype; }) .slice() .reverse(), [room, msgtype], ); const [events, setEvents] = useState(() => getFilteredEvents()); useEffect(() => { setEvents(getFilteredEvents()); setCanLoadMore(true); setHasLoadedOnce(false); setLoadError(false); }, [getFilteredEvents]); const handleLoadMore = useCallback(async () => { if (loading || !canLoadMore) return; setLoading(true); setLoadError(false); try { const hasMore = await mx.paginateEventTimeline(room.getLiveTimeline(), { backwards: true, limit: 100, }); setEvents(getFilteredEvents()); setCanLoadMore(hasMore); setHasLoadedOnce(true); } catch { // Stop auto-retry: the sentinel would keep firing on every render otherwise. // The user can retry manually via the button shown in the error state. setLoadError(true); } finally { setLoading(false); } }, [loading, canLoadMore, mx, room, getFilteredEvents]); // Auto-load when sentinel scrolls into view useEffect(() => { const sentinel = sentinelRef.current; if (!sentinel || !canLoadMore || loading) return; const observer = new IntersectionObserver( ([entry]) => { if (entry?.isIntersecting) handleLoadMore(); }, { threshold: 0.1 }, ); observer.observe(sentinel); return () => observer.disconnect(); }, [canLoadMore, loading, handleLoadMore]); // Lightbox items (images + videos, flat) const lightboxItems: LightboxItem[] = events .filter((ev) => { const c = ev.getContent(); if (c.msgtype !== MsgType.Image && c.msgtype !== MsgType.Video) return false; // Match the grid's guard exactly: tiles without a thumb are not rendered, // so they must not occupy a lightbox slot either. return !!getThumbMxc(ev); }) .map((ev) => { const c = ev.getContent(); const isEnc = !!c.file; return { mxcUrl: c.file?.url ?? c.url ?? '', encInfo: isEnc ? c.file : undefined, mimeType: c.info?.mimetype, msgtype: c.msgtype as MsgType.Image | MsgType.Video, body: c.body ?? '', sender: getSenderName(room, ev.getSender() ?? ''), ts: ev.getTs(), eventId: ev.getId() ?? '', }; }); // Per-tab counts for the tab labels (single pass over loaded timeline) const tabCounts = useMemo(() => { const counts: Record = { image: 0, video: 0, audio: 0, file: 0 }; room .getLiveTimeline() .getEvents() .forEach((ev) => { if (ev.isRedacted() || ev.getType() !== EventType.RoomMessage) return; const mt = ev.getContent().msgtype; if (mt === MsgType.Image) counts.image += 1; else if (mt === MsgType.Video) counts.video += 1; else if (mt === MsgType.Audio) counts.audio += 1; else if (mt === MsgType.File) counts.file += 1; }); return counts; // `events` is intentional: it changes when more history is paginated in, so // the counts stay in sync with the loaded window (it isn't read directly). // eslint-disable-next-line react-hooks/exhaustive-deps }, [room, events]); // Group image/video events by month for the grid type MonthGroup = { label: string; events: MatrixEvent[] }; const monthGroups: MonthGroup[] = []; let currentLabel = ''; for (const ev of events) { const label = monthLabel(ev.getTs()); if (label !== currentLabel) { currentLabel = label; monthGroups.push({ label, events: [] }); } monthGroups[monthGroups.length - 1]!.events.push(ev); } return ( <> {/* Header */}
Media Gallery
{/* Tabs */} {(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => ( handleTabChange(t)} /> ))} {/* Content */} {/* ── Image / video grid ── */} {(tab === 'image' || tab === 'video') && ( <> {events.length === 0 && !loading && ( {hasLoadedOnce ? `No ${TAB_LABELS[tab].toLowerCase()} found.` : `No ${TAB_LABELS[tab].toLowerCase()} in recent history.`} )} {/* Month groups */} {(() => { let flatIdx = 0; return monthGroups.map((group) => ( {/* Month label — only shown when there are multiple groups */} {monthGroups.length > 1 && ( {group.label} )}
{group.events.map((mEvent) => { const c = mEvent.getContent(); const isEnc = !!c.file; const isVideo = c.msgtype === MsgType.Video; const info: (IImageInfo & IThumbnailContent) | undefined = c.info; // Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url const thumbMxc: string | undefined = getThumbMxc(mEvent); const thumbEnc: IEncryptedFile | undefined = isEnc ? (info?.thumbnail_file ?? c.file) : undefined; const thumbMime: string | undefined = info?.thumbnail_file != null ? (info.thumbnail_info?.mimetype ?? 'image/jpeg') : (info?.mimetype ?? 'image/jpeg'); // Guard before incrementing: skipped tiles must not consume a slot if (!thumbMxc) return null; const idx = flatIdx++; // Full-resolution source for download (not the thumb). const fullMxc: string | undefined = c.file?.url ?? c.url; const bodyStr: string = c.body ?? ''; return ( setLightboxIndex(idx)} downloadUrl={fullMxc} downloadEncInfo={isEnc ? c.file : undefined} downloadMimeType={info?.mimetype} downloadFilename={mediaFilename(bodyStr, info?.mimetype)} /> ); })}
)); })()} )} {/* ── File list ── */} {tab === 'file' && ( <> {events.length === 0 && !loading && ( {hasLoadedOnce ? 'No files found.' : 'No files in recent history.'} )} {events.map((mEvent) => { const c = mEvent.getContent(); const mxcUrl: string | undefined = c.file?.url ?? c.url; const body: string = c.body ?? 'Unnamed file'; const size: number | undefined = c.info?.size; const sender = getSenderName(room, mEvent.getSender() ?? ''); return ( {body} {sender} {size != null ? ` · ${formatBytes(size)}` : ''} { const id = mEvent.getId(); if (id) jumpToMessage(id); }} > {mxcUrl && ( )} ); })} )} {/* ── Audio / voice list ── */} {tab === 'audio' && ( <> {events.length === 0 && !loading && ( {hasLoadedOnce ? 'No audio found.' : 'No audio in recent history.'} )} {events.map((mEvent) => { const c = mEvent.getContent(); const url: string | undefined = c.file?.url ?? c.url; if (!url) return null; const body: string = c.body || 'Voice message'; const sender = getSenderName(room, mEvent.getSender() ?? ''); const relDate = formatRelativeDate(mEvent.getTs()); // Sanitize the mimetype the way MAudio does (e.g. application/ogg → // audio/ogg) so the decrypted blob actually plays. const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? 'audio/ogg'); const filename = body.includes('.') ? body : `${body}.${mimeTypeToExt(mimeType)}`; const waveform = ( c as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } } )['org.matrix.msc1767.audio']?.waveform; return ( {body} {sender} · {relDate} { const id = mEvent.getId(); if (id) jumpToMessage(id); }} > } /> ); })} )} {/* ── Pagination status / sentinel ── */} {loading && ( )} {loadError && !loading && ( Failed to load history. )} {!loading && !loadError && !canLoadMore && hasLoadedOnce && events.length > 0 && ( Beginning of history )} {/* IntersectionObserver sentinel — only rendered when safe to auto-trigger */} {canLoadMore && !loading && !loadError && (
)} {/* Lightbox */} {lightboxIndex !== null && ( setLightboxIndex(null)} onJump={jumpToMessage} /> )} ); }