fix(media-gallery): address review — proper decrypt-download + mimetype sanitize

Review of 28cb004e (3 agents) surfaced two real issues:
- Downloads used a raw mxc→http anchor, so encrypted media (voice messages are
  almost always in E2EE DMs) downloaded ciphertext. Reuse FileDownloadButton for
  both the audio AND file rows — it decrypts before saving, adds loading/success
  state + a toast, and derives a filename extension.
- Audio playback bypassed MAudio's mimetype sanitization; pass the value through
  getBlobSafeMimeType (e.g. application/ogg → audio/ogg) so encrypted/odd-mimetype
  audio actually plays. Give voice messages a real filename+extension.

Also corrected the docs: AudioContent renders a seek bar, not an MSC3245 waveform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 21:35:35 -04:00
co-authored by Claude Opus 4.8
parent 28cb004e80
commit 2d0c804abc
2 changed files with 22 additions and 39 deletions
+1 -1
View File
@@ -1084,7 +1084,7 @@ A toggle in **Settings → Privacy** switches between sending `m.read` (public r
- Four tabs: **Images**, **Videos**, **Audio**, **Files** (each with a live count) - 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) - **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) - **Audio** — voice messages + audio files (`m.audio`) with an inline player (reuses `AudioContent`: play/seek/**speed control**; decrypts on play)
- **Files** — name/size/sender rows with download - **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 - **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 - Encrypted media is decrypted client-side on demand (no lock placeholder); download works for all types
+21 -38
View File
@@ -24,8 +24,9 @@ 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 { AudioContent, FileDownloadButton } from '../../components/message';
import { MediaControl } from '../../components/media'; import { MediaControl } from '../../components/media';
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
import { useRoomNavigate } from '../../hooks/useRoomNavigate'; 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';
@@ -827,9 +828,6 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const body: string = c.body ?? 'Unnamed file'; const body: string = c.body ?? 'Unnamed file';
const size: number | undefined = c.info?.size; const size: number | undefined = c.info?.size;
const sender = getSenderName(room, mEvent.getSender() ?? ''); const sender = getSenderName(room, mEvent.getSender() ?? '');
const downloadUrl = mxcUrl
? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#')
: '#';
return ( return (
<Box <Box
key={mEvent.getId()} key={mEvent.getId()}
@@ -867,22 +865,14 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
> >
<Icon size="200" src={Icons.Message} /> <Icon size="200" src={Icons.Message} />
</IconButton> </IconButton>
<IconButton {mxcUrl && (
variant="SurfaceVariant" <FileDownloadButton
size="300" filename={body}
radii="300" url={mxcUrl}
aria-label={`Download ${body}`} mimeType={c.info?.mimetype ?? 'application/octet-stream'}
onClick={() => { encInfo={c.file}
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> </Box>
); );
})} })}
@@ -914,7 +904,10 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const body: string = c.body || 'Voice message'; const body: string = c.body || 'Voice message';
const sender = getSenderName(room, mEvent.getSender() ?? ''); const sender = getSenderName(room, mEvent.getSender() ?? '');
const relDate = formatRelativeDate(mEvent.getTs()); const relDate = formatRelativeDate(mEvent.getTs());
const downloadUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? '#'; // Sanitize the mimetype the way MAudio does (e.g. application/ogg →
// audio/ogg) so the decrypted blob actually plays.
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? 'audio/ogg');
const filename = body.includes('.') ? body : `${body}.${mimeTypeToExt(mimeType)}`;
return ( return (
<Box <Box
key={mEvent.getId()} key={mEvent.getId()}
@@ -951,25 +944,15 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
> >
<Icon size="200" src={Icons.Message} /> <Icon size="200" src={Icons.Message} />
</IconButton> </IconButton>
<IconButton <FileDownloadButton
variant="SurfaceVariant" filename={filename}
size="300" url={url}
radii="300" mimeType={mimeType}
aria-label={`Download ${body}`} encInfo={c.file}
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> </Box>
<AudioContent <AudioContent
mimeType={c.info?.mimetype ?? 'audio/ogg'} mimeType={mimeType}
url={url} url={url}
info={c.info ?? {}} info={c.info ?? {}}
encInfo={c.file} encInfo={c.file}