feat(media-gallery): add Audio/Voice tab + jump-to-message

- New Audio tab (m.audio) listing voice messages + audio files with an inline player
  (reuses AudioContent + MediaControl: play/seek/speed, MSC3245 voice waveform,
  decrypt-on-play), sender/date, and download. Added to the tab counts.
- 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; threaded eventId into LightboxItem.
- Update the (stale) LOTUS_FEATURES.md Media Gallery entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 21:26:02 -04:00
co-authored by Claude Opus 4.8
parent 0b06158477
commit 28cb004e80
2 changed files with 157 additions and 6 deletions
+7 -4
View File
@@ -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
+150 -2
View File
@@ -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<GalleryTab, string> = {
image: 'Images',
video: 'Videos',
audio: 'Audio',
file: 'Files',
};
const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
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({
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
{index + 1} / {items.length}
</Text>
{item.eventId && (
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Go to message</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton
ref={ref}
variant="Surface"
aria-label="Go to message"
onClick={() => onJump(item.eventId)}
>
<Icon src={Icons.Message} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
align="End"
@@ -503,6 +534,16 @@ type MediaGalleryProps = {
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate();
// Close the drawer and land the timeline on the source message.
const jumpToMessage = useCallback(
(eventId: string) => {
onClose();
navigateRoom(room.roomId, eventId);
},
[onClose, navigateRoom, room.roomId],
);
const [tab, setTab] = useState<GalleryTab>('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<GalleryTab, number> = { image: 0, video: 0, file: 0 };
const counts: Record<GalleryTab, number> = { 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)}` : ''}
</Text>
</Box>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Go to message"
onClick={() => {
const id = mEvent.getId();
if (id) jumpToMessage(id);
}}
>
<Icon size="200" src={Icons.Message} />
</IconButton>
<IconButton
variant="SurfaceVariant"
size="300"
@@ -835,6 +890,98 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
</>
)}
{/* ── Audio / voice list ── */}
{tab === 'audio' && (
<>
{events.length === 0 && !loading && (
<Box
direction="Column"
alignItems="Center"
gap="200"
style={{ padding: config.space.S400 }}
>
<Icon src={Icons.VolumeHigh} size="600" />
<Text size="T300" priority="300" align="Center">
{hasLoadedOnce ? 'No audio found.' : 'No audio in recent history.'}
</Text>
</Box>
)}
<Box direction="Column" gap="200">
{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 (
<Box
key={mEvent.getId()}
direction="Column"
gap="100"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
}}
>
<Box alignItems="Center" gap="200">
<Box
grow="Yes"
direction="Column"
style={{ overflow: 'hidden', gap: '2px' }}
>
<Text size="T300" truncate title={body}>
{body}
</Text>
<Text size="T200" priority="300">
{sender} · {relDate}
</Text>
</Box>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Go to message"
onClick={() => {
const id = mEvent.getId();
if (id) jumpToMessage(id);
}}
>
<Icon size="200" src={Icons.Message} />
</IconButton>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
aria-label={`Download ${body}`}
onClick={() => {
const a = document.createElement('a');
a.href = downloadUrl;
a.download = body;
a.target = '_blank';
a.rel = 'noreferrer';
a.click();
}}
>
<Icon size="200" src={Icons.Download} />
</IconButton>
</Box>
<AudioContent
mimeType={c.info?.mimetype ?? 'audio/ogg'}
url={url}
info={c.info ?? {}}
encInfo={c.file}
renderMediaControl={(p) => <MediaControl {...p} />}
/>
</Box>
);
})}
</Box>
</>
)}
{/* ── Pagination status / sentinel ── */}
{loading && (
<Box justifyContent="Center" style={{ padding: config.space.S300 }}>
@@ -888,6 +1035,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
initialIndex={lightboxIndex}
useAuthentication={useAuthentication}
onClose={() => setLightboxIndex(null)}
onJump={jumpToMessage}
/>
)}
</>