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)
- **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
- **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
+21 -38
View File
@@ -24,8 +24,9 @@ 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 { AudioContent, FileDownloadButton } from '../../components/message';
import { MediaControl } from '../../components/media';
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { stopPropagation } from '../../utils/keyboard';
@@ -827,9 +828,6 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const body: string = c.body ?? 'Unnamed file';
const size: number | undefined = c.info?.size;
const sender = getSenderName(room, mEvent.getSender() ?? '');
const downloadUrl = mxcUrl
? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#')
: '#';
return (
<Box
key={mEvent.getId()}
@@ -867,22 +865,14 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
>
<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>
{mxcUrl && (
<FileDownloadButton
filename={body}
url={mxcUrl}
mimeType={c.info?.mimetype ?? 'application/octet-stream'}
encInfo={c.file}
/>
)}
</Box>
);
})}
@@ -914,7 +904,10 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const body: string = c.body || 'Voice message';
const sender = getSenderName(room, mEvent.getSender() ?? '');
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 (
<Box
key={mEvent.getId()}
@@ -951,25 +944,15 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
>
<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>
<FileDownloadButton
filename={filename}
url={url}
mimeType={mimeType}
encInfo={c.file}
/>
</Box>
<AudioContent
mimeType={c.info?.mimetype ?? 'audio/ogg'}
mimeType={mimeType}
url={url}
info={c.info ?? {}}
encInfo={c.file}