import React, { useCallback, useEffect, useState } from 'react'; import { Box, Button, Header, Icon, IconButton, Icons, Scroll, Spinner, Text, Tooltip, TooltipProvider, config, } from 'folds'; import { EventType, MsgType, Room } from 'matrix-js-sdk'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { mxcUrlToHttp } from '../../utils/matrix'; import { ContainerColor } from '../../styles/ContainerColor.css'; type GalleryTab = 'image' | 'video' | 'file'; type MediaGalleryProps = { room: Room; onClose: () => void; }; const TAB_LABELS: Record = { image: 'Images', video: 'Videos', file: 'Files', }; const TAB_MSGTYPES: Record = { image: MsgType.Image, video: MsgType.Video, file: MsgType.File, }; function TabButton({ label, active, onClick, }: { label: string; active: boolean; onClick: () => void; }) { return ( ); } export function MediaGallery({ room, onClose }: MediaGalleryProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [tab, setTab] = useState('image'); const [loading, setLoading] = useState(false); const [canLoadMore, setCanLoadMore] = useState(true); const msgtype = TAB_MSGTYPES[tab]; // Read already-decrypted events from the live timeline (works for E2EE rooms) const getFilteredEvents = useCallback(() => { const timeline = room.getLiveTimeline(); return timeline .getEvents() .filter((ev) => { if (ev.isRedacted()) return false; const content = ev.getContent(); return ev.getType() === EventType.RoomMessage && content.msgtype === msgtype; }) .slice() .reverse(); // newest first }, [room, msgtype]); const [events, setEvents] = useState(() => getFilteredEvents()); useEffect(() => { setEvents(getFilteredEvents()); setCanLoadMore(true); }, [getFilteredEvents]); const handleLoadMore = useCallback(async () => { setLoading(true); try { const timeline = room.getLiveTimeline(); const hasMore = await mx.paginateEventTimeline(timeline, { backwards: true, limit: 100 }); setEvents(getFilteredEvents()); setCanLoadMore(hasMore); } catch { // silently swallow } finally { setLoading(false); } }, [mx, room, getFilteredEvents]); return ( {/* Header */}
Media Close } > {(triggerRef) => ( )}
{/* Tab bar */} {(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => ( setTab(t)} /> ))} {/* Content */} {loading && events.length === 0 && ( )} {!loading && events.length === 0 && ( {`No ${TAB_LABELS[tab].toLowerCase()} found in this room.`} )} {/* Image/Video grid */} {(tab === 'image' || tab === 'video') && events.length > 0 && (
{events.map((mEvent) => { const content = mEvent.getContent(); const isEncrypted = !!content.file; const mxcUrl: string | undefined = content.url ?? content.file?.url; if (!mxcUrl) return null; const body: string = content.body ?? ''; // Use unauthenticated thumbnail URL — the v1 authenticated endpoint adds // allow_redirect=true which Synapse rejects with 400. const thumbUrl = isEncrypted ? null : (mxcUrlToHttp(mx, mxcUrl, false, 120, 120, 'crop') ?? null); const fullUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#'; return ( {thumbUrl ? ( {body} { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', }} /> ) : ( {body || (isEncrypted ? 'Encrypted' : 'Image')} )} ); })}
)} {/* File list */} {tab === 'file' && events.length > 0 && ( {events.map((mEvent) => { const content = mEvent.getContent(); const mxcUrl: string | undefined = content.url ?? content.file?.url; const body: string = content.body ?? 'Unnamed file'; const downloadUrl = mxcUrl ? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#') : '#'; return ( {body} { const anchor = document.createElement('a'); anchor.href = downloadUrl; anchor.download = body; anchor.target = '_blank'; anchor.rel = 'noreferrer'; anchor.click(); }} > ); })} )} {/* Load more */} {canLoadMore && !loading && events.length > 0 && ( )} {/* Loading more spinner */} {loading && events.length > 0 && ( )}
); }