diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx index e4b949e48..6c9c3546e 100644 --- a/src/app/features/room/MediaGallery.tsx +++ b/src/app/features/room/MediaGallery.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Box, Button, @@ -14,19 +14,16 @@ import { color, config, } from 'folds'; -import { EventType, MsgType, Room } from 'matrix-js-sdk'; +import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk'; +import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common'; +import { FALLBACK_MIMETYPE } from '../../utils/mimeTypes'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { mxcUrlToHttp } from '../../utils/matrix'; +import { decryptFile, downloadEncryptedMedia, 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', @@ -39,132 +36,438 @@ const TAB_MSGTYPES: Record = { file: MsgType.File, }; -type ThumbState = 'loading' | 'error' | 'ok'; +// ── decrypt hook ────────────────────────────────────────────────────────────── -function ImageTile({ - thumbUrl, - fullUrl, - body, - isEncrypted, -}: { - thumbUrl: string | null; - fullUrl: string; +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 ?? FALLBACK_MIMETYPE, 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]); + + // Revoke blob URL when the component unmounts + useEffect( + () => () => { + if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current); + }, + [], + ); + + return state; +} + +// ── Lightbox ────────────────────────────────────────────────────────────────── + +type LightboxItem = { + mxcUrl: string; + encInfo?: IEncryptedFile; + mimeType?: string; body: string; - isEncrypted: boolean; + sender: string; + ts: number; +}; + +function LightboxImage({ + item, + useAuthentication, +}: { + item: LightboxItem; + useAuthentication: boolean; }) { - const [thumbState, setThumbState] = useState(thumbUrl ? 'loading' : 'error'); + const mx = useMatrixClient(); + const media = useDecryptedMediaUrl( + mx, + item.mxcUrl, + item.encInfo, + useAuthentication, + item.mimeType, + ); return ( - + {media.status === 'loading' && } + {media.status === 'error' && ( + + + + Failed to load + + + )} + {media.status === 'ok' && ( + {item.body} + )} + + ); +} + +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 date = new Date(item.ts); + const dateStr = date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+ {/* Top bar */} + + + + {item.body || 'Image'} + + + {item.sender} · {dateStr} + + + + {index + 1} / {items.length} + + + Close + + } + > + {(ref) => ( + + + + )} + + + + {/* Image area */} + + {index > 0 && ( + + + + )} + + + + {index < items.length - 1 && ( + + + + )} + +
+ ); +} + +// ── Thumbnail tile ───────────────────────────────────────────────────────────── + +function GalleryTile({ + mxcUrl, + encInfo, + mimeType, + body, + sender, + ts, + useAuthentication, + onClick, +}: { + mxcUrl: string; + encInfo?: IEncryptedFile; + mimeType?: string; + body: string; + sender: string; + ts: number; + useAuthentication: boolean; + onClick: () => void; +}) { + const mx = useMatrixClient(); + + // For thumbnails: prefer encrypted thumbnail if present, else use full image + const media = useDecryptedMediaUrl(mx, mxcUrl, encInfo, useAuthentication, mimeType); + + const [hovered, setHovered] = useState(false); + const relDate = formatRelativeDate(ts); + + return ( +
); } -function TabButton({ - label, - active, - onClick, -}: { - label: string; - active: boolean; - onClick: () => void; -}) { - return ( - - ); +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function formatRelativeDate(ts: number): string { + const now = Date.now(); + const diff = now - ts; + const mins = Math.floor(diff / 60000); + if (mins < 60) return mins <= 1 ? 'Just now' : `${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 getSenderDisplayName(room: Room, userId: string): string { + return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId; +} + +// ── 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 [lightboxIndex, setLightboxIndex] = useState(null); const msgtype = TAB_MSGTYPES[tab]; - const getFilteredEvents = useCallback(() => { + const getFilteredEvents = useCallback((): MatrixEvent[] => { 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; + const c = ev.getContent(); + return ev.getType() === EventType.RoomMessage && c.msgtype === msgtype; }) .slice() .reverse(); }, [room, msgtype]); - const [events, setEvents] = useState(() => getFilteredEvents()); + const [events, setEvents] = useState(() => getFilteredEvents()); useEffect(() => { setEvents(getFilteredEvents()); setCanLoadMore(true); + setHasLoadedOnce(false); }, [getFilteredEvents]); const handleLoadMore = useCallback(async () => { @@ -174,6 +477,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { const hasMore = await mx.paginateEventTimeline(timeline, { backwards: true, limit: 100 }); setEvents(getFilteredEvents()); setCanLoadMore(hasMore); + setHasLoadedOnce(true); } catch { // silently swallow } finally { @@ -181,187 +485,288 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { } }, [mx, room, getFilteredEvents]); + // Build lightbox items from current image/video events + 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; + const info: (IImageInfo & IThumbnailContent) | undefined = c.info; + const mxcUrl: string = c.file?.url ?? c.url ?? ''; + return { + mxcUrl, + encInfo: isEnc ? c.file : undefined, + mimeType: info?.mimetype ?? c.info?.mimetype, + body: c.body ?? '', + sender: getSenderDisplayName(room, ev.getSender() ?? ''), + ts: ev.getTs(), + }; + }); + return ( - -
+ - - - - - Media Gallery - + {/* Header */} +
+ + + + + Media Gallery + + + {events.length > 0 && ( + + {events.length} + + )} + + Close + + } + > + {(ref) => ( + + + + )} + - - Close - - } - > - {(triggerRef) => ( - - - - )} - +
+ + {/* Tab bar */} + + {(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => ( + + ))} -
- - {(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => ( - setTab(t)} /> - ))} - - - - - - {loading && events.length === 0 && ( - - - - )} - - {!loading && events.length === 0 && ( - - - {`No ${TAB_LABELS[tab].toLowerCase()} in loaded history. Use Load More to search further back.`} - - - )} - - {(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 ?? ''; - const thumbUrl = isEncrypted - ? null - : (mxcUrlToHttp(mx, mxcUrl, useAuthentication, 120, 120, 'crop') ?? null); - const fullUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#'; - return ( - - ); - })} -
- )} - - {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 ( + {/* 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.`} + + + )} + {events.length > 0 && ( +
- - - - {body} - - - { - const anchor = document.createElement('a'); - anchor.href = downloadUrl; - anchor.download = body; - anchor.target = '_blank'; - anchor.rel = 'noreferrer'; - anchor.click(); - }} - > - - + {events.map((mEvent, idx) => { + const c = mEvent.getContent(); + const isEnc = !!c.file; + const info: (IImageInfo & IThumbnailContent) | undefined = c.info; + // For thumbnails: prefer thumbnail_file > file; for non-enc: use thumbnail URL + const thumbMxc: string | undefined = isEnc + ? (info?.thumbnail_file?.url ?? c.file?.url) + : (info?.thumbnail_url ?? c.url); + const thumbEncInfo: IEncryptedFile | undefined = isEnc + ? (info?.thumbnail_file ?? c.file) + : undefined; + const mimeType: string | undefined = + info?.thumbnail_file != null + ? (info.thumbnail_info?.mimetype ?? 'image/jpeg') + : (info?.mimetype ?? c.info?.mimetype); + if (!thumbMxc) return null; + 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 = getSenderDisplayName(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(); + }} + > + + + + ); + })} + + + )} - {canLoadMore && !loading && ( - - + + )} + {!loading && !canLoadMore && hasLoadedOnce && ( + - Load More History - -
- )} - - {loading && events.length > 0 && ( - - - - )} -
-
+ Beginning of history + + )} +
+ +
- + + {/* Lightbox */} + {lightboxIndex !== null && ( + setLightboxIndex(null)} + /> + )} + ); }