diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx index 6c9c3546e..71ab8182f 100644 --- a/src/app/features/room/MediaGallery.tsx +++ b/src/app/features/room/MediaGallery.tsx @@ -16,7 +16,6 @@ import { } from 'folds'; 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 { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix'; @@ -36,7 +35,7 @@ const TAB_MSGTYPES: Record = { file: MsgType.File, }; -// ── decrypt hook ────────────────────────────────────────────────────────────── +// ── Decrypt hook ────────────────────────────────────────────────────────────── type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' }; @@ -55,18 +54,15 @@ function useDecryptedMediaUrl( 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), + decryptFile(buf, mimeType ?? 'application/octet-stream', encInfo), ); const blobUrl = URL.createObjectURL(blob); if (cancelled) { @@ -90,7 +86,6 @@ function useDecryptedMediaUrl( }; }, [mx, mxcUrl, encInfo, useAuthentication, mimeType]); - // Revoke blob URL when the component unmounts useEffect( () => () => { if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current); @@ -101,18 +96,47 @@ function useDecryptedMediaUrl( 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 LightboxImage({ +function LightboxMedia({ item, useAuthentication, }: { @@ -135,7 +159,14 @@ function LightboxImage({ justifyContent="Center" style={{ height: '100%', gap: config.space.S200 }} > - {media.status === 'loading' && } + {media.status === 'loading' && ( + + + + {item.encInfo ? 'Decrypting…' : 'Loading…'} + + + )} {media.status === 'error' && ( @@ -144,19 +175,34 @@ function LightboxImage({ )} - {media.status === 'ok' && ( - {item.body} - )} + {media.status === 'ok' && + (item.msgtype === MsgType.Video ? ( + // eslint-disable-next-line jsx-a11y/media-has-caption +