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.
|
`MediaGallery.tsx` — a right-side drawer for browsing room media.
|
||||||
|
|
||||||
- Three tabs: **Images**, **Videos**, **Files**
|
- Four tabs: **Images**, **Videos**, **Audio**, **Files** (each with a live count)
|
||||||
- Reads already-decrypted events from the room timeline
|
- **Images/Videos** — a month-grouped grid; tiles decrypt on demand (lazy, near-viewport), open a keyboard-navigable **lightbox** (←/→/Esc, prev/next)
|
||||||
- Encrypted images show a lock placeholder rather than an error
|
- **Audio** — voice messages + audio files (`m.audio`) with an inline player (reuses `AudioContent`: play/seek/**speed control**, MSC3245 voice waveform; decrypts on play)
|
||||||
- "Load More" button triggers `mx.paginateEventTimeline()` to fetch older media
|
- **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
|
### Knock-to-Join
|
||||||
|
|
||||||
|
|||||||
@@ -24,21 +24,26 @@ import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/ma
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
|
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 { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import * as css from './MediaGallery.css';
|
import * as css from './MediaGallery.css';
|
||||||
|
|
||||||
type GalleryTab = 'image' | 'video' | 'file';
|
type GalleryTab = 'image' | 'video' | 'file' | 'audio';
|
||||||
|
|
||||||
const TAB_LABELS: Record<GalleryTab, string> = {
|
const TAB_LABELS: Record<GalleryTab, string> = {
|
||||||
image: 'Images',
|
image: 'Images',
|
||||||
video: 'Videos',
|
video: 'Videos',
|
||||||
|
audio: 'Audio',
|
||||||
file: 'Files',
|
file: 'Files',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
||||||
image: MsgType.Image,
|
image: MsgType.Image,
|
||||||
video: MsgType.Video,
|
video: MsgType.Video,
|
||||||
|
audio: MsgType.Audio,
|
||||||
file: MsgType.File,
|
file: MsgType.File,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -155,6 +160,7 @@ type LightboxItem = {
|
|||||||
body: string;
|
body: string;
|
||||||
sender: string;
|
sender: string;
|
||||||
ts: number;
|
ts: number;
|
||||||
|
eventId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function LightboxMedia({
|
function LightboxMedia({
|
||||||
@@ -233,11 +239,13 @@ function Lightbox({
|
|||||||
initialIndex,
|
initialIndex,
|
||||||
useAuthentication,
|
useAuthentication,
|
||||||
onClose,
|
onClose,
|
||||||
|
onJump,
|
||||||
}: {
|
}: {
|
||||||
items: LightboxItem[];
|
items: LightboxItem[];
|
||||||
initialIndex: number;
|
initialIndex: number;
|
||||||
useAuthentication: boolean;
|
useAuthentication: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onJump: (eventId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [index, setIndex] = useState(initialIndex);
|
const [index, setIndex] = useState(initialIndex);
|
||||||
|
|
||||||
@@ -309,6 +317,29 @@ function Lightbox({
|
|||||||
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
|
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
|
||||||
{index + 1} / {items.length}
|
{index + 1} / {items.length}
|
||||||
</Text>
|
</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
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
align="End"
|
align="End"
|
||||||
@@ -503,6 +534,16 @@ type MediaGalleryProps = {
|
|||||||
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
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 [tab, setTab] = useState<GalleryTab>('image');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -613,12 +654,13 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
body: c.body ?? '',
|
body: c.body ?? '',
|
||||||
sender: getSenderName(room, ev.getSender() ?? ''),
|
sender: getSenderName(room, ev.getSender() ?? ''),
|
||||||
ts: ev.getTs(),
|
ts: ev.getTs(),
|
||||||
|
eventId: ev.getId() ?? '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Per-tab counts for the tab labels (single pass over loaded timeline)
|
// Per-tab counts for the tab labels (single pass over loaded timeline)
|
||||||
const tabCounts = useMemo(() => {
|
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
|
room
|
||||||
.getLiveTimeline()
|
.getLiveTimeline()
|
||||||
.getEvents()
|
.getEvents()
|
||||||
@@ -627,6 +669,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
const mt = ev.getContent().msgtype;
|
const mt = ev.getContent().msgtype;
|
||||||
if (mt === MsgType.Image) counts.image += 1;
|
if (mt === MsgType.Image) counts.image += 1;
|
||||||
else if (mt === MsgType.Video) counts.video += 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;
|
else if (mt === MsgType.File) counts.file += 1;
|
||||||
});
|
});
|
||||||
return counts;
|
return counts;
|
||||||
@@ -812,6 +855,18 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
{size != null ? ` · ${formatBytes(size)}` : ''}
|
{size != null ? ` · ${formatBytes(size)}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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
|
<IconButton
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
size="300"
|
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 ── */}
|
{/* ── Pagination status / sentinel ── */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<Box justifyContent="Center" style={{ padding: config.space.S300 }}>
|
<Box justifyContent="Center" style={{ padding: config.space.S300 }}>
|
||||||
@@ -888,6 +1035,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
initialIndex={lightboxIndex}
|
initialIndex={lightboxIndex}
|
||||||
useAuthentication={useAuthentication}
|
useAuthentication={useAuthentication}
|
||||||
onClose={() => setLightboxIndex(null)}
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
onJump={jumpToMessage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user