import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Box, Button, Header, Icon, IconButton, Icons, Scroll, Spinner, Text, Tooltip, TooltipProvider, color, config, } from 'folds'; import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk'; 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 { ContainerColor } from '../../styles/ContainerColor.css'; type GalleryTab = 'image' | 'video' | 'file'; const TAB_LABELS: Record = { image: 'Images', video: 'Videos', file: 'Files', }; const TAB_MSGTYPES: Record = { image: MsgType.Image, video: MsgType.Video, 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, ): DecryptState { const [state, setState] = useState({ status: 'loading' }); const prevBlobUrl = useRef(null); useEffect(() => { 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]); 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`; } 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; } // ── Lightbox ────────────────────────────────────────────────────────────────── type LightboxItem = { mxcUrl: string; encInfo?: IEncryptedFile; mimeType?: string; msgtype: MsgType.Image | MsgType.Video; body: string; sender: string; ts: number; }; function LightboxMedia({ item, useAuthentication, }: { item: LightboxItem; useAuthentication: boolean; }) { 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 ? ( // eslint-disable-next-line jsx-a11y/media-has-caption ); } function Lightbox({ items, initialIndex, useAuthentication, onClose, }: { items: LightboxItem[]; initialIndex: number; useAuthentication: boolean; onClose: () => void; }) { const [index, setIndex] = useState(initialIndex); 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(); }, [prev, next, onClose], ); const item = items[index]; if (!item) return null; const dateStr = new Date(item.ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', }); return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions
{/* Header bar */} {item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')} {item.sender} · {dateStr} {index + 1} / {items.length} 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, }: { mxcUrl: string; encInfo?: IEncryptedFile; mimeType?: string; isVideo: boolean; body: string; sender: string; ts: number; useAuthentication: boolean; onClick: () => void; }) { const mx = useMatrixClient(); const media = useDecryptedMediaUrl(mx, mxcUrl, encInfo, useAuthentication, mimeType); const [hovered, setHovered] = useState(false); const relDate = formatRelativeDate(ts); return ( ); } // ── Month separator ─────────────────────────────────────────────────────────── function MonthSeparator({ label }: { label: string }) { return (
{label}
); } // ── Tab button ──────────────────────────────────────────────────────────────── function TabButton({ label, active, onClick, }: { label: string; 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 [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 }, []); 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(); return c.msgtype === MsgType.Image || c.msgtype === MsgType.Video; }) .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(), }; }); // 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 {events.length > 0 && ( {events.length} )} Close } > {(ref) => ( )}
{/* 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 header — only shown when there are multiple groups */} {monthGroups.length > 1 && }
{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 = isEnc ? (info?.thumbnail_file?.url ?? c.file?.url) : (info?.thumbnail_url ?? c.url); 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++; return ( setLightboxIndex(idx)} /> ); })}
)); })()} )} {/* ── 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() ?? ''); const downloadUrl = mxcUrl ? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#') : '#'; return ( {body} {sender} {size != null ? ` · ${formatBytes(size)}` : ''} { const a = document.createElement('a'); a.href = downloadUrl; a.download = body; a.target = '_blank'; a.rel = 'noreferrer'; a.click(); }} > ); })} )} {/* ── 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)} /> )} ); }