feat(media-gallery): download images/videos from the viewer and grid tiles

The gallery's File and Audio tabs already had download buttons, but images and
videos could only be saved by jumping to the source message. Add:

- a Download button in the lightbox header (full-resolution source), and
- a hover/focus download button on each image/video grid tile

Both reuse the shared FileDownloadButton (decrypts E2EE media client-side, saves
via useSaveFile, spinner/check/retry states). The tile download control is a
sibling of the tile button (not nested — avoids interactive-in-interactive) and
stays visible on touch (hover:none) devices. Download always targets the
full-res file/url, not the thumbnail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 15:18:55 -04:00
co-authored by Claude Opus 4.8
parent 82eb65b822
commit 68a88e84b8
3 changed files with 139 additions and 46 deletions
+1 -1
View File
@@ -1107,7 +1107,7 @@ 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.
- 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). Each grid tile has a hover/focus **download** button, and the lightbox header has a **Download** action — both reuse the shared `FileDownloadButton` (full-resolution source, decrypts E2EE media client-side, spinner/✓/retry states), so images and videos can be saved without jumping to the message. On touch (no-hover) devices the tile download button stays visible.
- **Audio** — voice messages + audio files (`m.audio`) with an inline player (reuses `AudioContent`: **waveform scrubbing** for voice messages, play/seek/**speed control**; decrypts on play) - **Audio** — voice messages + audio files (`m.audio`) with an inline player (reuses `AudioContent`: **waveform scrubbing** for voice messages, 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
+30
View File
@@ -52,8 +52,38 @@ export const MediaGalleryGrid = style({
gap: config.space.S100, gap: config.space.S100,
}); });
// Wraps a tile + its floating download button so the download control is a
// sibling of the tile <button> (never nested inside it — that would be invalid
// interactive-in-interactive markup). The wrapper is the grid cell; the tile
// button fills it via aspect-ratio.
export const GalleryTileWrap = style({
position: 'relative',
display: 'flex',
});
export const GalleryTileDownload = style({
position: 'absolute',
top: config.space.S100,
right: config.space.S100,
zIndex: 1,
opacity: 0,
transition: 'opacity 100ms ease-in-out',
selectors: {
[`${GalleryTileWrap}:hover &, ${GalleryTileWrap}:focus-within &`]: {
opacity: 1,
},
},
// Touch devices have no hover; keep the control reachable there.
'@media': {
'(hover: none)': {
opacity: 1,
},
},
});
export const GalleryTile = style({ export const GalleryTile = style({
position: 'relative', position: 'relative',
width: '100%',
aspectRatio: '1', aspectRatio: '1',
overflow: 'hidden', overflow: 'hidden',
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
+108 -45
View File
@@ -131,6 +131,15 @@ function formatBytes(bytes: number): string {
return `${(bytes / 1048576).toFixed(1)} MB`; return `${(bytes / 1048576).toFixed(1)} MB`;
} }
// A sensible download filename: prefer the event body/filename; if it has no
// extension, append one derived from the mimetype so the saved file opens.
function mediaFilename(body: string, mimeType?: string): string {
const name = body.trim() || 'media';
if (name.includes('.')) return name;
const ext = mimeType ? mimeTypeToExt(mimeType) : '';
return ext ? `${name}.${ext}` : name;
}
function monthLabel(ts: number): string { function monthLabel(ts: number): string {
return new Date(ts).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); return new Date(ts).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
} }
@@ -318,6 +327,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.mxcUrl && (
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Download</Text>
</Tooltip>
}
>
{(ref) => (
<span ref={ref}>
<FileDownloadButton
filename={mediaFilename(item.body, item.mimeType)}
url={item.mxcUrl}
mimeType={item.mimeType ?? 'application/octet-stream'}
encInfo={item.encInfo}
/>
</span>
)}
</TooltipProvider>
)}
{item.eventId && ( {item.eventId && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"
@@ -417,6 +449,10 @@ function GalleryTile({
ts, ts,
useAuthentication, useAuthentication,
onClick, onClick,
downloadUrl,
downloadEncInfo,
downloadMimeType,
downloadFilename,
}: { }: {
mxcUrl: string; mxcUrl: string;
encInfo?: IEncryptedFile; encInfo?: IEncryptedFile;
@@ -427,6 +463,10 @@ function GalleryTile({
ts: number; ts: number;
useAuthentication: boolean; useAuthentication: boolean;
onClick: () => void; onClick: () => void;
downloadUrl?: string;
downloadEncInfo?: IEncryptedFile;
downloadMimeType?: string;
downloadFilename: string;
}) { }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const tileRef = useRef<HTMLButtonElement>(null); const tileRef = useRef<HTMLButtonElement>(null);
@@ -442,55 +482,71 @@ function GalleryTile({
const relDate = formatRelativeDate(ts); const relDate = formatRelativeDate(ts);
return ( return (
<button <div className={css.GalleryTileWrap}>
ref={tileRef} <button
type="button" ref={tileRef}
aria-label={body || (isVideo ? 'Video' : 'Image')} type="button"
onClick={onClick} aria-label={body || (isVideo ? 'Video' : 'Image')}
className={css.GalleryTile} onClick={onClick}
> className={css.GalleryTile}
{media.status === 'loading' && <Spinner size="200" />} >
{media.status === 'error' && ( {media.status === 'loading' && <Spinner size="200" />}
<Box {media.status === 'error' && (
direction="Column" <Box
alignItems="Center" direction="Column"
gap="100" alignItems="Center"
style={{ padding: config.space.S100 }} gap="100"
> style={{ padding: config.space.S100 }}
<Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />
<Text
size="T200"
truncate
priority="300"
style={{ maxWidth: '100%', textAlign: 'center' }}
> >
{body} <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />
</Text> <Text
</Box> size="T200"
)} truncate
{media.status === 'ok' && <img src={media.url} alt={body} className={css.GalleryTileImg} />} priority="300"
style={{ maxWidth: '100%', textAlign: 'center' }}
{/* Video play badge */} >
{isVideo && media.status === 'ok' && ( {body}
<div className={css.GalleryVideoBadge}>
<Icon src={Icons.Play} size="200" />
</div>
)}
{/* Hover/focus caption overlay (CSS-driven) */}
{media.status === 'ok' && (
<div className={css.GalleryTileOverlay}>
<div className={css.GalleryTileCaption}>
<Text size="T200" truncate style={{ color: '#fff', display: 'block', lineHeight: 1.3 }}>
{sender}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.65)' }}>
{relDate}
</Text> </Text>
</Box>
)}
{media.status === 'ok' && <img src={media.url} alt={body} className={css.GalleryTileImg} />}
{/* Video play badge */}
{isVideo && media.status === 'ok' && (
<div className={css.GalleryVideoBadge}>
<Icon src={Icons.Play} size="200" />
</div> </div>
)}
{/* Hover/focus caption overlay (CSS-driven) */}
{media.status === 'ok' && (
<div className={css.GalleryTileOverlay}>
<div className={css.GalleryTileCaption}>
<Text
size="T200"
truncate
style={{ color: '#fff', display: 'block', lineHeight: 1.3 }}
>
{sender}
</Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.65)' }}>
{relDate}
</Text>
</div>
</div>
)}
</button>
{downloadUrl && (
<div className={css.GalleryTileDownload}>
<FileDownloadButton
filename={downloadFilename}
url={downloadUrl}
mimeType={downloadMimeType ?? 'application/octet-stream'}
encInfo={downloadEncInfo}
/>
</div> </div>
)} )}
</button> </div>
); );
} }
@@ -783,6 +839,9 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
// Guard before incrementing: skipped tiles must not consume a slot // Guard before incrementing: skipped tiles must not consume a slot
if (!thumbMxc) return null; if (!thumbMxc) return null;
const idx = flatIdx++; const idx = flatIdx++;
// Full-resolution source for download (not the thumb).
const fullMxc: string | undefined = c.file?.url ?? c.url;
const bodyStr: string = c.body ?? '';
return ( return (
<GalleryTile <GalleryTile
key={mEvent.getId()} key={mEvent.getId()}
@@ -790,11 +849,15 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
encInfo={thumbEnc} encInfo={thumbEnc}
mimeType={thumbMime} mimeType={thumbMime}
isVideo={isVideo} isVideo={isVideo}
body={c.body ?? ''} body={bodyStr}
sender={getSenderName(room, mEvent.getSender() ?? '')} sender={getSenderName(room, mEvent.getSender() ?? '')}
ts={mEvent.getTs()} ts={mEvent.getTs()}
useAuthentication={useAuthentication} useAuthentication={useAuthentication}
onClick={() => setLightboxIndex(idx)} onClick={() => setLightboxIndex(idx)}
downloadUrl={fullMxc}
downloadEncInfo={isEnc ? c.file : undefined}
downloadMimeType={info?.mimetype}
downloadFilename={mediaFilename(bodyStr, info?.mimetype)}
/> />
); );
})} })}