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:
@@ -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,
|
||||
|
||||
@@ -131,6 +131,15 @@ function formatBytes(bytes: number): string {
|
||||
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 {
|
||||
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 }}>
|
||||
{index + 1} / {items.length}
|
||||
</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 && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
@@ -417,6 +449,10 @@ function GalleryTile({
|
||||
ts,
|
||||
useAuthentication,
|
||||
onClick,
|
||||
downloadUrl,
|
||||
downloadEncInfo,
|
||||
downloadMimeType,
|
||||
downloadFilename,
|
||||
}: {
|
||||
mxcUrl: string;
|
||||
encInfo?: IEncryptedFile;
|
||||
@@ -427,6 +463,10 @@ function GalleryTile({
|
||||
ts: number;
|
||||
useAuthentication: boolean;
|
||||
onClick: () => void;
|
||||
downloadUrl?: string;
|
||||
downloadEncInfo?: IEncryptedFile;
|
||||
downloadMimeType?: string;
|
||||
downloadFilename: string;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const tileRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -442,55 +482,71 @@ function GalleryTile({
|
||||
const relDate = formatRelativeDate(ts);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={tileRef}
|
||||
type="button"
|
||||
aria-label={body || (isVideo ? 'Video' : 'Image')}
|
||||
onClick={onClick}
|
||||
className={css.GalleryTile}
|
||||
>
|
||||
{media.status === 'loading' && <Spinner size="200" />}
|
||||
{media.status === 'error' && (
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
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' }}
|
||||
<div className={css.GalleryTileWrap}>
|
||||
<button
|
||||
ref={tileRef}
|
||||
type="button"
|
||||
aria-label={body || (isVideo ? 'Video' : 'Image')}
|
||||
onClick={onClick}
|
||||
className={css.GalleryTile}
|
||||
>
|
||||
{media.status === 'loading' && <Spinner size="200" />}
|
||||
{media.status === 'error' && (
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
style={{ padding: config.space.S100 }}
|
||||
>
|
||||
{body}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
<Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />
|
||||
<Text
|
||||
size="T200"
|
||||
truncate
|
||||
priority="300"
|
||||
style={{ maxWidth: '100%', textAlign: 'center' }}
|
||||
>
|
||||
{body}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -783,6 +839,9 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
// Guard before incrementing: skipped tiles must not consume a slot
|
||||
if (!thumbMxc) return null;
|
||||
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 (
|
||||
<GalleryTile
|
||||
key={mEvent.getId()}
|
||||
@@ -790,11 +849,15 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
encInfo={thumbEnc}
|
||||
mimeType={thumbMime}
|
||||
isVideo={isVideo}
|
||||
body={c.body ?? ''}
|
||||
body={bodyStr}
|
||||
sender={getSenderName(room, mEvent.getSender() ?? '')}
|
||||
ts={mEvent.getTs()}
|
||||
useAuthentication={useAuthentication}
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
downloadUrl={fullMxc}
|
||||
downloadEncInfo={isEnc ? c.file : undefined}
|
||||
downloadMimeType={info?.mimetype}
|
||||
downloadFilename={mediaFilename(bodyStr, info?.mimetype)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user