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
+30
View File
@@ -52,8 +52,38 @@ export const MediaGalleryGrid = style({
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({
position: 'relative',
width: '100%',
aspectRatio: '1',
overflow: 'hidden',
borderRadius: config.radii.R300,