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:
+7
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user