diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 11f4e8992..af08ccce3 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -1082,10 +1082,13 @@ A toggle in **Settings → Privacy** switches between sending `m.read` (public r `MediaGallery.tsx` — a right-side drawer for browsing room media. -- Three tabs: **Images**, **Videos**, **Files** -- Reads already-decrypted events from the room timeline -- Encrypted images show a lock placeholder rather than an error -- "Load More" button triggers `mx.paginateEventTimeline()` to fetch older media +- Four tabs: **Images**, **Videos**, **Audio**, **Files** (each with a live count) +- **Images/Videos** — a month-grouped grid; tiles decrypt on demand (lazy, near-viewport), open a keyboard-navigable **lightbox** (←/→/Esc, prev/next) +- **Audio** — voice messages + audio files (`m.audio`) with an inline player (reuses `AudioContent`: play/seek/**speed control**, MSC3245 voice waveform; decrypts on play) +- **Files** — name/size/sender rows with download +- **Jump to message** — a "Go to message" action on file rows, audio rows, and in the lightbox navigates the timeline to the source event (`useRoomNavigate`) and closes the drawer +- Encrypted media is decrypted client-side on demand (no lock placeholder); download works for all types +- **Auto-pagination** — an `IntersectionObserver` sentinel calls `mx.paginateEventTimeline()` to pull older media as you scroll (manual retry on error) ### Knock-to-Join diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx index d5890e01f..9c4b8a521 100644 --- a/src/app/features/room/MediaGallery.tsx +++ b/src/app/features/room/MediaGallery.tsx @@ -24,21 +24,26 @@ import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/ma import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix'; +import { AudioContent } from '../../components/message'; +import { MediaControl } from '../../components/media'; +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'; +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, }; @@ -155,6 +160,7 @@ type LightboxItem = { body: string; sender: string; ts: number; + eventId: string; }; function LightboxMedia({ @@ -233,11 +239,13 @@ function Lightbox({ initialIndex, useAuthentication, onClose, + onJump, }: { items: LightboxItem[]; initialIndex: number; useAuthentication: boolean; onClose: () => void; + onJump: (eventId: string) => void; }) { const [index, setIndex] = useState(initialIndex); @@ -309,6 +317,29 @@ function Lightbox({ {index + 1} / {items.length} + {item.eventId && ( + + Go to message + + } + > + {(ref) => ( + onJump(item.eventId)} + > + + + )} + + )} { + onClose(); + navigateRoom(room.roomId, eventId); + }, + [onClose, navigateRoom, room.roomId], + ); const [tab, setTab] = useState('image'); const [loading, setLoading] = useState(false); @@ -613,12 +654,13 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { 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, file: 0 }; + const counts: Record = { image: 0, video: 0, audio: 0, file: 0 }; room .getLiveTimeline() .getEvents() @@ -627,6 +669,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { 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; @@ -812,6 +855,18 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { {size != null ? ` · ${formatBytes(size)}` : ''} + { + const id = mEvent.getId(); + if (id) jumpToMessage(id); + }} + > + + )} + {/* ── 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()); + const downloadUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? '#'; + return ( + + + + + {body} + + + {sender} · {relDate} + + + { + const id = mEvent.getId(); + if (id) jumpToMessage(id); + }} + > + + + { + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = body; + a.target = '_blank'; + a.rel = 'noreferrer'; + a.click(); + }} + > + + + + } + /> + + ); + })} + + + )} + {/* ── Pagination status / sentinel ── */} {loading && ( @@ -888,6 +1035,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { initialIndex={lightboxIndex} useAuthentication={useAuthentication} onClose={() => setLightboxIndex(null)} + onJump={jumpToMessage} /> )}