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