CI / Build & Quality Checks (push) Successful in 4m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 11m58s
CI / Trigger Desktop Build (push) Successful in 8s
Thread images opened the room media lightbox, which looks the event up in the room's detached media timeline. Thread replies never reach that timeline, so a thread image always showed "1 / 1" with no prev/next, after paging the room's media up to six times. The thread panel now builds the viewer's items from its own root + loaded replies, and "Go to message" scrolls the thread panel instead of the room. The viewer gains a "Copy image" button: fetches the displayed media (blob URL for E2EE, authenticated URL otherwise), re-encodes to PNG when needed, and writes it via ClipboardItem with a promise so Safari keeps the click's user activation. Hidden where ClipboardItem is missing. No "open in new tab": an E2EE blob URL is revoked when the viewer closes and authenticated media 401s in a bare tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
1219 lines
44 KiB
TypeScript
1219 lines
44 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
Header,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Overlay,
|
|
OverlayBackdrop,
|
|
Scroll,
|
|
Spinner,
|
|
Text,
|
|
Tooltip,
|
|
TooltipProvider,
|
|
color,
|
|
config,
|
|
} from 'folds';
|
|
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
|
|
import FocusTrap from 'focus-trap-react';
|
|
import classNames from 'classnames';
|
|
import { useNearViewport } from '../../hooks/useNearViewport';
|
|
import { useZoom } from '../../hooks/useZoom';
|
|
import { usePan, Pan } from '../../hooks/usePan';
|
|
import { useSwipeNav } from '../../hooks/useSwipeNav';
|
|
import { usePinchZoom } from '../../hooks/usePinchZoom';
|
|
import { useMediaQuery } from '../../hooks/useMediaQuery';
|
|
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
import { useDecryptedMediaUrl } from '../../hooks/useDecryptedMediaUrl';
|
|
import { getThumbMxc } from '../../utils/mediaThumb';
|
|
import { AudioContent, FileDownloadButton } from '../../components/message';
|
|
import { MediaControl } from '../../components/media';
|
|
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
|
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|
import { useRoomMediaTimeline } from '../../hooks/useRoomMediaTimeline';
|
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
|
import { stopPropagation } from '../../utils/keyboard';
|
|
import * as css from './MediaGallery.css';
|
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
|
import { formatRelativeAge } from '../../utils/formatTimestamp';
|
|
import { canCopyImage, copyImageFromUrl } from '../../utils/copyImage';
|
|
|
|
type GalleryTab = 'image' | 'video' | 'file' | 'audio';
|
|
|
|
const TAB_LABELS: Record<GalleryTab, string> = {
|
|
image: 'Images',
|
|
video: 'Videos',
|
|
audio: 'Audio',
|
|
file: 'Files',
|
|
};
|
|
|
|
const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
|
image: MsgType.Image,
|
|
video: MsgType.Video,
|
|
audio: MsgType.Audio,
|
|
file: MsgType.File,
|
|
};
|
|
|
|
// ── Decrypt hook ──────────────────────────────────────────────────────────────
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
}
|
|
|
|
// A sensible download filename: prefer the event body/filename; if it has no
|
|
// plausible extension already, append one derived from the mimetype so the
|
|
// saved file opens. "Plausible" = a short alphanumeric tail after the last dot,
|
|
// so "Screenshot 2024.01.05" still gets a real extension appended.
|
|
function hasFileExtension(name: string): boolean {
|
|
const dot = name.lastIndexOf('.');
|
|
if (dot <= 0 || dot === name.length - 1) return false;
|
|
return /^[a-z0-9]{1,5}$/i.test(name.slice(dot + 1));
|
|
}
|
|
function mediaFilename(body: string, mimeType?: string): string {
|
|
const name = body.trim() || 'media';
|
|
if (hasFileExtension(name)) 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' });
|
|
}
|
|
|
|
function getSenderName(room: Room, userId: string): string {
|
|
return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId;
|
|
}
|
|
|
|
// Resolve the thumbnail/display MXC for an image/video event, mirroring the
|
|
// grid's preference order (encrypted thumb > file > thumbnail_url > url). Both
|
|
// the grid and the lightbox must use this so their positional indices stay in
|
|
// lockstep — otherwise a tile skipped for lack of a thumb would shift the
|
|
// lightbox and open the wrong media.
|
|
|
|
// ── Lightbox ──────────────────────────────────────────────────────────────────
|
|
|
|
export type LightboxItem = {
|
|
mxcUrl: string;
|
|
encInfo?: IEncryptedFile;
|
|
mimeType?: string;
|
|
msgtype: MsgType.Image | MsgType.Video;
|
|
body: string;
|
|
/** The file's name when the message also carries a caption (MSC2530). */
|
|
filename?: string;
|
|
/** Caption text: `body` when it differs from `filename`. */
|
|
caption?: string;
|
|
sender: string;
|
|
ts: number;
|
|
eventId: string;
|
|
};
|
|
|
|
/**
|
|
* Images + videos of a media timeline as lightbox slots. Shared with the
|
|
* timeline's viewer (#219) so both open the same items in the same order.
|
|
*/
|
|
export function toLightboxItems(room: Room, events: MatrixEvent[]): LightboxItem[] {
|
|
return events
|
|
.filter((ev) => {
|
|
const c = ev.getContent();
|
|
if (c.msgtype !== MsgType.Image && c.msgtype !== MsgType.Video) return false;
|
|
// Match the grid's guard exactly: tiles without a thumb are not rendered,
|
|
// so they must not occupy a lightbox slot either.
|
|
return !!getThumbMxc(ev);
|
|
})
|
|
.map((ev) => {
|
|
const c = ev.getContent();
|
|
const isEnc = !!c.file;
|
|
const filename = typeof c.filename === 'string' && c.filename ? c.filename : undefined;
|
|
const caption =
|
|
filename && typeof c.body === 'string' && c.body && c.body !== filename
|
|
? c.body
|
|
: undefined;
|
|
return {
|
|
mxcUrl: c.file?.url ?? c.url ?? '',
|
|
encInfo: isEnc ? c.file : undefined,
|
|
mimeType: c.info?.mimetype,
|
|
msgtype: c.msgtype as MsgType.Image | MsgType.Video,
|
|
body: c.body ?? '',
|
|
filename,
|
|
caption,
|
|
sender: getSenderName(room, ev.getSender() ?? ''),
|
|
ts: ev.getTs(),
|
|
eventId: ev.getId() ?? '',
|
|
};
|
|
});
|
|
}
|
|
|
|
function LightboxMedia({
|
|
item,
|
|
useAuthentication,
|
|
zoom,
|
|
pan,
|
|
cursor,
|
|
onMouseDown,
|
|
onTouchStart,
|
|
onImageDoubleClick,
|
|
onUrl,
|
|
}: {
|
|
item: LightboxItem;
|
|
useAuthentication: boolean;
|
|
zoom: number;
|
|
pan: Pan;
|
|
cursor: string;
|
|
onMouseDown: React.MouseEventHandler<HTMLElement>;
|
|
onTouchStart: React.TouchEventHandler<HTMLElement>;
|
|
onImageDoubleClick: () => void;
|
|
onUrl: (url: string | undefined) => void;
|
|
}) {
|
|
const mx = useMatrixClient();
|
|
const media = useDecryptedMediaUrl(
|
|
mx,
|
|
item.mxcUrl,
|
|
item.encInfo,
|
|
useAuthentication,
|
|
item.mimeType,
|
|
);
|
|
const readyUrl = media.status === 'ok' ? media.url : undefined;
|
|
useEffect(() => {
|
|
onUrl(readyUrl);
|
|
return () => onUrl(undefined);
|
|
}, [readyUrl, onUrl]);
|
|
|
|
return (
|
|
<Box
|
|
direction="Column"
|
|
alignItems="Center"
|
|
justifyContent="Center"
|
|
style={{ height: '100%', gap: config.space.S200 }}
|
|
>
|
|
{media.status === 'loading' && (
|
|
<Box direction="Column" alignItems="Center" gap="200">
|
|
<Spinner size="600" />
|
|
<Text size="T300" priority="300">
|
|
{item.encInfo ? 'Decrypting…' : 'Loading…'}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
{media.status === 'error' && (
|
|
<Box direction="Column" alignItems="Center" gap="200">
|
|
<Icon src={Icons.Warning} size="600" />
|
|
<Text size="T300" priority="300">
|
|
Failed to load
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
{media.status === 'ok' &&
|
|
(item.msgtype === MsgType.Video ? (
|
|
<video
|
|
aria-label="Video attachment"
|
|
src={media.url}
|
|
controls
|
|
autoPlay
|
|
style={{
|
|
maxWidth: '100%',
|
|
maxHeight: 'calc(100vh - 120px)',
|
|
borderRadius: config.radii.R300,
|
|
display: 'block',
|
|
background: '#000',
|
|
}}
|
|
/>
|
|
) : (
|
|
<img
|
|
src={media.url}
|
|
alt={item.caption ?? item.body}
|
|
draggable={false}
|
|
onMouseDown={onMouseDown}
|
|
onTouchStart={onTouchStart}
|
|
onDoubleClick={onImageDoubleClick}
|
|
style={{
|
|
maxWidth: '100%',
|
|
maxHeight: 'calc(100vh - 120px)',
|
|
objectFit: 'contain',
|
|
borderRadius: config.radii.R300,
|
|
display: 'block',
|
|
cursor,
|
|
// translate is nested inside scale(), so it runs in scaled space —
|
|
// divide by zoom so a dragged pixel moves the image one screen pixel
|
|
// (1:1 with the cursor) rather than `zoom` pixels.
|
|
transform: `scale(${zoom}) translate(${pan.translateX / zoom}px, ${
|
|
pan.translateY / zoom
|
|
}px)`,
|
|
transition: cursor === 'grabbing' ? 'none' : 'transform 120ms ease-out',
|
|
willChange: 'transform',
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function Lightbox({
|
|
items,
|
|
initialIndex,
|
|
useAuthentication,
|
|
onClose,
|
|
onJump,
|
|
}: {
|
|
items: LightboxItem[];
|
|
initialIndex: number;
|
|
useAuthentication: boolean;
|
|
onClose: () => void;
|
|
onJump: (eventId: string) => void;
|
|
}) {
|
|
const [index, setIndex] = useState(initialIndex);
|
|
const { format } = useTimestampFormatter();
|
|
|
|
const item = items[index];
|
|
const isImage = item?.msgtype === MsgType.Image;
|
|
|
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
|
// Pan is only active for a zoomed-in image; usePan resets its offset when this
|
|
// flips false (i.e. back to 1x, on navigation, or on a video).
|
|
const zoomedIn = isImage && zoom !== 1;
|
|
const { pan, cursor, onMouseDown, onTouchStart } = usePan(zoomedIn);
|
|
// [Gitea #164] Swipe between items on touch — only while not zoomed in,
|
|
// where the same gesture pans the image instead.
|
|
const swipe = useSwipeNav(
|
|
!zoomedIn,
|
|
useCallback(() => setIndex((i) => Math.min(items.length - 1, i + 1)), [items.length]),
|
|
useCallback(() => setIndex((i) => Math.max(0, i - 1)), []),
|
|
);
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
const onPinchStart = usePinchZoom(isImage, zoom, setZoom);
|
|
// [Gitea #164] 44 px prev/next targets on touch screens (32 px otherwise).
|
|
const coarsePointer = useMediaQuery('(pointer: coarse)');
|
|
const navSize = coarsePointer ? '500' : '400';
|
|
const onMediaTouchStart = useCallback<React.TouchEventHandler<HTMLElement>>(
|
|
(e) => {
|
|
swipe.onTouchStart(e);
|
|
onPinchStart(e);
|
|
},
|
|
[swipe, onPinchStart],
|
|
);
|
|
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
|
|
|
|
// [Gitea #164] Copy image. No "open in new tab": an E2EE image is a blob URL
|
|
// revoked when the viewer closes, and authenticated media 401s in a bare tab.
|
|
const [mediaUrl, setMediaUrl] = useState<string>();
|
|
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
|
|
useEffect(() => {
|
|
if (copyState === 'idle') return undefined;
|
|
const t = window.setTimeout(() => setCopyState('idle'), 2000);
|
|
return () => window.clearTimeout(t);
|
|
}, [copyState]);
|
|
useEffect(() => setCopyState('idle'), [index]);
|
|
const handleCopyImage = useCallback(() => {
|
|
if (!mediaUrl) return;
|
|
copyImageFromUrl(mediaUrl).then(
|
|
() => setCopyState('copied'),
|
|
() => setCopyState('failed'),
|
|
);
|
|
}, [mediaUrl]);
|
|
|
|
// Reset zoom when navigating to another item (and thus pan, via usePan).
|
|
useEffect(() => {
|
|
setZoom(1);
|
|
}, [index, setZoom]);
|
|
|
|
const prev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []);
|
|
const next = useCallback(
|
|
() => setIndex((i) => Math.min(items.length - 1, i + 1)),
|
|
[items.length],
|
|
);
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (e.key === 'ArrowLeft') prev();
|
|
else if (e.key === 'ArrowRight') next();
|
|
else if (e.key === 'Escape') onClose();
|
|
else if (isImage && (e.key === '+' || e.key === '=')) zoomIn();
|
|
else if (isImage && e.key === '-') zoomOut();
|
|
else if (isImage && e.key === '0') setZoom(1);
|
|
},
|
|
[prev, next, onClose, isImage, zoomIn, zoomOut, setZoom],
|
|
);
|
|
const handleWheel = useCallback(
|
|
(e: React.WheelEvent) => {
|
|
if (!isImage) return;
|
|
if (e.deltaY < 0) zoomIn();
|
|
else if (e.deltaY > 0) zoomOut();
|
|
},
|
|
[isImage, zoomIn, zoomOut],
|
|
);
|
|
|
|
if (!item) return null;
|
|
|
|
const dateStr = format(item.ts, 'date');
|
|
|
|
return (
|
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
// Focus the dialog itself on open: with `initialFocus: false` focus
|
|
// stayed on the gallery tile behind the overlay, so ←/→/Esc/+/-
|
|
// (handled by onKeyDown below) did nothing until the user clicked
|
|
// inside the viewer (Gitea #164).
|
|
initialFocus: () => dialogRef.current ?? false,
|
|
clickOutsideDeactivates: false,
|
|
escapeDeactivates: false,
|
|
}}
|
|
>
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal
|
|
aria-label="Media viewer"
|
|
onKeyDown={handleKeyDown}
|
|
tabIndex={-1}
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
zIndex: 1000,
|
|
background: 'rgba(0,0,0,0.92)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
{/* Header bar */}
|
|
<Box
|
|
alignItems="Center"
|
|
gap="200"
|
|
style={{
|
|
padding: `${config.space.S200} ${config.space.S300}`,
|
|
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
|
|
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
|
|
{item.filename || item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
|
|
</Text>
|
|
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
|
|
{item.sender} · {dateStr}
|
|
</Text>
|
|
</Box>
|
|
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
|
|
{index + 1} / {items.length}
|
|
</Text>
|
|
{isImage && (
|
|
<Box
|
|
shrink="No"
|
|
alignItems="Center"
|
|
gap="100"
|
|
role="group"
|
|
aria-label="Zoom controls"
|
|
>
|
|
<IconButton
|
|
variant="Surface"
|
|
size="300"
|
|
radii="300"
|
|
aria-label="Zoom out"
|
|
onClick={zoomOut}
|
|
disabled={zoom <= 0.2}
|
|
>
|
|
<Icon size="50" src={Icons.Minus} />
|
|
</IconButton>
|
|
<Chip variant="Surface" radii="Pill" onClick={toggleZoom} aria-label="Reset zoom">
|
|
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
|
</Chip>
|
|
<IconButton
|
|
variant="Surface"
|
|
size="300"
|
|
radii="300"
|
|
aria-label="Zoom in"
|
|
onClick={zoomIn}
|
|
disabled={zoom >= 5}
|
|
>
|
|
<Icon size="50" src={Icons.Plus} />
|
|
</IconButton>
|
|
</Box>
|
|
)}
|
|
{isImage && canCopyImage() && (
|
|
<TooltipProvider
|
|
position="Bottom"
|
|
align="End"
|
|
offset={4}
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text>
|
|
{copyState === 'copied' && 'Copied'}
|
|
{copyState === 'failed' && 'Could not copy image'}
|
|
{copyState === 'idle' && 'Copy image'}
|
|
</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(ref) => (
|
|
<IconButton
|
|
ref={ref}
|
|
variant="Surface"
|
|
aria-label={copyState === 'copied' ? 'Image copied' : 'Copy image'}
|
|
onClick={handleCopyImage}
|
|
disabled={!mediaUrl}
|
|
>
|
|
<Icon
|
|
src={
|
|
(copyState === 'copied' && Icons.Check) ||
|
|
(copyState === 'failed' && Icons.Warning) ||
|
|
Icons.Photo
|
|
}
|
|
/>
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
)}
|
|
{item.mxcUrl && (
|
|
<TooltipProvider
|
|
position="Bottom"
|
|
align="End"
|
|
offset={4}
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text>Download</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(ref) => (
|
|
<span ref={ref}>
|
|
<FileDownloadButton
|
|
filename={mediaFilename(item.filename ?? item.body, item.mimeType)}
|
|
url={item.mxcUrl}
|
|
mimeType={item.mimeType ?? 'application/octet-stream'}
|
|
encInfo={item.encInfo}
|
|
/>
|
|
</span>
|
|
)}
|
|
</TooltipProvider>
|
|
)}
|
|
{item.eventId && (
|
|
<TooltipProvider
|
|
position="Bottom"
|
|
align="End"
|
|
offset={4}
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text>Go to message</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(ref) => (
|
|
<IconButton
|
|
ref={ref}
|
|
variant="Surface"
|
|
aria-label="Go to message"
|
|
onClick={() => onJump(item.eventId)}
|
|
>
|
|
<Icon src={Icons.Message} />
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
)}
|
|
<TooltipProvider
|
|
position="Bottom"
|
|
align="End"
|
|
offset={4}
|
|
tooltip={
|
|
<Tooltip>
|
|
<Text>Close</Text>
|
|
</Tooltip>
|
|
}
|
|
>
|
|
{(ref) => (
|
|
<IconButton ref={ref} variant="Surface" aria-label="Close" onClick={onClose}>
|
|
<Icon src={Icons.Cross} />
|
|
</IconButton>
|
|
)}
|
|
</TooltipProvider>
|
|
</Box>
|
|
|
|
{/* Media area with nav arrows */}
|
|
<Box
|
|
grow="Yes"
|
|
alignItems="Center"
|
|
justifyContent="Center"
|
|
onWheel={handleWheel}
|
|
onTouchStart={onMediaTouchStart}
|
|
onTouchEnd={swipe.onTouchEnd}
|
|
// Our own swipe/pinch/pan handle touch here; stop the browser from
|
|
// zooming or scrolling the page underneath instead.
|
|
style={{ overflow: 'hidden', padding: config.space.S400, touchAction: 'none' }}
|
|
>
|
|
{index > 0 && (
|
|
<IconButton
|
|
variant="Surface"
|
|
size={navSize}
|
|
aria-label="Previous"
|
|
onClick={prev}
|
|
style={{ flexShrink: 0, marginRight: config.space.S200 }}
|
|
>
|
|
<Icon src={Icons.ArrowLeft} />
|
|
</IconButton>
|
|
)}
|
|
<Box
|
|
grow="Yes"
|
|
alignItems="Center"
|
|
justifyContent="Center"
|
|
style={{ overflow: 'hidden', height: '100%' }}
|
|
>
|
|
<LightboxMedia
|
|
key={`${item.mxcUrl}-${item.ts}`}
|
|
item={item}
|
|
useAuthentication={useAuthentication}
|
|
zoom={zoom}
|
|
pan={pan}
|
|
cursor={cursor}
|
|
onMouseDown={onMouseDown}
|
|
onTouchStart={onTouchStart}
|
|
onImageDoubleClick={toggleZoom}
|
|
onUrl={setMediaUrl}
|
|
/>
|
|
</Box>
|
|
{index < items.length - 1 && (
|
|
<IconButton
|
|
variant="Surface"
|
|
size={navSize}
|
|
aria-label="Next"
|
|
onClick={next}
|
|
style={{ flexShrink: 0, marginLeft: config.space.S200 }}
|
|
>
|
|
<Icon src={Icons.ArrowRight} />
|
|
</IconButton>
|
|
)}
|
|
</Box>
|
|
{item.caption && (
|
|
// [Gitea #164] The full caption, readable — the header only has room
|
|
// for one truncated line.
|
|
<Box
|
|
shrink="No"
|
|
justifyContent="Center"
|
|
style={{
|
|
padding: `${config.space.S200} ${config.space.S400} ${config.space.S400}`,
|
|
maxHeight: '25vh',
|
|
overflowY: 'auto',
|
|
}}
|
|
>
|
|
<Text
|
|
size="T300"
|
|
style={{
|
|
color: 'rgba(255,255,255,0.88)',
|
|
whiteSpace: 'pre-wrap',
|
|
overflowWrap: 'anywhere',
|
|
maxWidth: '70ch',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{item.caption}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</div>
|
|
</FocusTrap>
|
|
</Overlay>
|
|
);
|
|
}
|
|
|
|
// ── Gallery tile ──────────────────────────────────────────────────────────────
|
|
|
|
function GalleryTile({
|
|
mxcUrl,
|
|
encInfo,
|
|
mimeType,
|
|
isVideo,
|
|
body,
|
|
sender,
|
|
ts,
|
|
useAuthentication,
|
|
onClick,
|
|
downloadUrl,
|
|
downloadEncInfo,
|
|
downloadMimeType,
|
|
downloadFilename,
|
|
}: {
|
|
mxcUrl: string;
|
|
encInfo?: IEncryptedFile;
|
|
mimeType?: string;
|
|
isVideo: boolean;
|
|
body: string;
|
|
sender: string;
|
|
ts: number;
|
|
useAuthentication: boolean;
|
|
onClick: () => void;
|
|
downloadUrl?: string;
|
|
downloadEncInfo?: IEncryptedFile;
|
|
downloadMimeType?: string;
|
|
downloadFilename: string;
|
|
}) {
|
|
const mx = useMatrixClient();
|
|
const tileRef = useRef<HTMLButtonElement>(null);
|
|
const nearViewport = useNearViewport(tileRef, 300);
|
|
const media = useDecryptedMediaUrl(
|
|
mx,
|
|
mxcUrl,
|
|
encInfo,
|
|
useAuthentication,
|
|
mimeType,
|
|
nearViewport,
|
|
);
|
|
const { prefs } = useTimestampFormatter();
|
|
const relDate = formatRelativeAge(ts, prefs);
|
|
|
|
return (
|
|
<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 }}
|
|
>
|
|
<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>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Month separator ───────────────────────────────────────────────────────────
|
|
|
|
// ── Tab button ────────────────────────────────────────────────────────────────
|
|
|
|
function TabButton({
|
|
label,
|
|
count,
|
|
active,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
count: number;
|
|
active: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<Button
|
|
size="300"
|
|
variant={active ? 'Primary' : 'Secondary'}
|
|
fill={active ? 'Solid' : 'Soft'}
|
|
radii="300"
|
|
onClick={onClick}
|
|
>
|
|
<Text size="B300">
|
|
{label}
|
|
{count > 0 ? ` (${count})` : ''}
|
|
</Text>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
// ── Main component ────────────────────────────────────────────────────────────
|
|
|
|
type MediaGalleryProps = {
|
|
room: Room;
|
|
onClose: () => void;
|
|
};
|
|
|
|
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|
const { prefs } = useTimestampFormatter();
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const { navigateRoom } = useRoomNavigate();
|
|
|
|
// Close the drawer and land the timeline on the source message.
|
|
const jumpToMessage = useCallback(
|
|
(eventId: string) => {
|
|
onClose();
|
|
navigateRoom(room.roomId, eventId);
|
|
},
|
|
[onClose, navigateRoom, room.roomId],
|
|
);
|
|
|
|
const [tab, setTab] = useState<GalleryTab>('image');
|
|
// [Gitea #163] Media is paginated on its own timeline set — never on
|
|
// `room.getLiveTimeline()`, which the message list behind this drawer is
|
|
// rendering by index.
|
|
const {
|
|
events: mediaEvents,
|
|
loadMore: handleLoadMore,
|
|
loading,
|
|
loadError,
|
|
canLoadMore,
|
|
hasLoadedOnce,
|
|
} = useRoomMediaTimeline(mx, room);
|
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
|
|
const handleTabChange = useCallback((t: GalleryTab) => {
|
|
setTab(t);
|
|
setLightboxIndex(null); // stale index would open wrong item in new tab's lightboxItems
|
|
}, []);
|
|
|
|
// Escape closes the drawer — but only when the lightbox isn't open, since the
|
|
// lightbox has its own Escape handler that should take precedence.
|
|
useEffect(() => {
|
|
if (lightboxIndex !== null) return undefined;
|
|
const handleKeyDown = (evt: KeyboardEvent) => {
|
|
if (evt.key === 'Escape') {
|
|
stopPropagation(evt);
|
|
onClose();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [lightboxIndex, onClose]);
|
|
|
|
const msgtype = TAB_MSGTYPES[tab];
|
|
|
|
const events = useMemo(
|
|
() => mediaEvents.filter((ev) => ev.getContent().msgtype === msgtype),
|
|
[mediaEvents, msgtype],
|
|
);
|
|
|
|
// Auto-load when sentinel scrolls into view
|
|
useEffect(() => {
|
|
const sentinel = sentinelRef.current;
|
|
if (!sentinel || !canLoadMore || loading) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry?.isIntersecting) handleLoadMore();
|
|
},
|
|
{ threshold: 0.1 },
|
|
);
|
|
observer.observe(sentinel);
|
|
return () => observer.disconnect();
|
|
}, [canLoadMore, loading, handleLoadMore]);
|
|
|
|
// Lightbox items (images + videos, flat)
|
|
const lightboxItems: LightboxItem[] = toLightboxItems(room, events);
|
|
|
|
// Per-tab counts for the tab labels (single pass over the loaded media)
|
|
const tabCounts = useMemo(() => {
|
|
const counts: Record<GalleryTab, number> = { image: 0, video: 0, audio: 0, file: 0 };
|
|
mediaEvents.forEach((ev) => {
|
|
const mt = ev.getContent().msgtype;
|
|
if (mt === MsgType.Image) counts.image += 1;
|
|
else if (mt === MsgType.Video) counts.video += 1;
|
|
else if (mt === MsgType.Audio) counts.audio += 1;
|
|
else if (mt === MsgType.File) counts.file += 1;
|
|
});
|
|
return counts;
|
|
}, [mediaEvents]);
|
|
|
|
// Group image/video events by month for the grid
|
|
type MonthGroup = { label: string; events: MatrixEvent[] };
|
|
const monthGroups: MonthGroup[] = [];
|
|
let currentLabel = '';
|
|
for (const ev of events) {
|
|
const label = monthLabel(ev.getTs());
|
|
if (label !== currentLabel) {
|
|
currentLabel = label;
|
|
monthGroups.push({ label, events: [] });
|
|
}
|
|
monthGroups[monthGroups.length - 1]!.events.push(ev);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Box
|
|
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))}
|
|
shrink="No"
|
|
direction="Column"
|
|
role="region"
|
|
aria-labelledby="media-gallery-title"
|
|
>
|
|
{/* Header */}
|
|
<Header variant="Background" size="600" className={css.MediaGalleryHeader}>
|
|
<Box grow="Yes" alignItems="Center" gap="200">
|
|
<Icon size="200" src={Icons.Photo} />
|
|
<Box grow="Yes">
|
|
<Text id="media-gallery-title" size="H4" truncate>
|
|
Media Gallery
|
|
</Text>
|
|
</Box>
|
|
<IconButton size="300" radii="300" aria-label="Close media gallery" onClick={onClose}>
|
|
<Icon src={Icons.Cross} />
|
|
</IconButton>
|
|
</Box>
|
|
</Header>
|
|
|
|
{/* Tabs */}
|
|
<Box className={css.MediaGalleryTabs} shrink="No">
|
|
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
|
|
<TabButton
|
|
key={t}
|
|
label={TAB_LABELS[t]}
|
|
count={tabCounts[t]}
|
|
active={tab === t}
|
|
onClick={() => handleTabChange(t)}
|
|
/>
|
|
))}
|
|
</Box>
|
|
|
|
{/* Content */}
|
|
<Box grow="Yes" style={{ position: 'relative', overflow: 'hidden' }}>
|
|
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
|
|
<Box className={css.MediaGalleryContent} direction="Column">
|
|
{/* ── Image / video grid ── */}
|
|
{(tab === 'image' || tab === 'video') && (
|
|
<>
|
|
{events.length === 0 && !loading && (
|
|
<Box
|
|
direction="Column"
|
|
alignItems="Center"
|
|
gap="200"
|
|
style={{ padding: config.space.S400 }}
|
|
>
|
|
<Icon src={tab === 'video' ? Icons.Play : Icons.Photo} size="600" />
|
|
<Text size="T300" priority="300" align="Center">
|
|
{hasLoadedOnce
|
|
? `No ${TAB_LABELS[tab].toLowerCase()} found.`
|
|
: `No ${TAB_LABELS[tab].toLowerCase()} in recent history.`}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Month groups */}
|
|
{(() => {
|
|
let flatIdx = 0;
|
|
return monthGroups.map((group) => (
|
|
<Box key={group.label} direction="Column" className={css.MediaGalleryGroup}>
|
|
{/* Month label — only shown when there are multiple groups */}
|
|
{monthGroups.length > 1 && (
|
|
<Text className={css.MediaGalleryGroupLabel} size="L400" priority="300">
|
|
{group.label}
|
|
</Text>
|
|
)}
|
|
<div className={css.MediaGalleryGrid}>
|
|
{group.events.map((mEvent) => {
|
|
const c = mEvent.getContent();
|
|
const isEnc = !!c.file;
|
|
const isVideo = c.msgtype === MsgType.Video;
|
|
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
|
|
|
// Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url
|
|
const thumbMxc: string | undefined = getThumbMxc(mEvent);
|
|
const thumbEnc: IEncryptedFile | undefined = isEnc
|
|
? (info?.thumbnail_file ?? c.file)
|
|
: undefined;
|
|
const thumbMime: string | undefined =
|
|
info?.thumbnail_file != null
|
|
? (info.thumbnail_info?.mimetype ?? 'image/jpeg')
|
|
: (info?.mimetype ?? 'image/jpeg');
|
|
|
|
// 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()}
|
|
mxcUrl={thumbMxc}
|
|
encInfo={thumbEnc}
|
|
mimeType={thumbMime}
|
|
isVideo={isVideo}
|
|
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)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</Box>
|
|
));
|
|
})()}
|
|
</>
|
|
)}
|
|
|
|
{/* ── File list ── */}
|
|
{tab === 'file' && (
|
|
<>
|
|
{events.length === 0 && !loading && (
|
|
<Box
|
|
direction="Column"
|
|
alignItems="Center"
|
|
gap="200"
|
|
style={{ padding: config.space.S400 }}
|
|
>
|
|
<Icon src={Icons.File} size="600" />
|
|
<Text size="T300" priority="300" align="Center">
|
|
{hasLoadedOnce ? 'No files found.' : 'No files in recent history.'}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
<Box direction="Column" gap="100">
|
|
{events.map((mEvent) => {
|
|
const c = mEvent.getContent();
|
|
const mxcUrl: string | undefined = c.file?.url ?? c.url;
|
|
const body: string = c.body ?? 'Unnamed file';
|
|
const size: number | undefined = c.info?.size;
|
|
const sender = getSenderName(room, mEvent.getSender() ?? '');
|
|
return (
|
|
<Box
|
|
key={mEvent.getId()}
|
|
alignItems="Center"
|
|
gap="200"
|
|
style={{
|
|
padding: `${config.space.S200} ${config.space.S300}`,
|
|
borderRadius: config.radii.R300,
|
|
background: color.SurfaceVariant.Container,
|
|
}}
|
|
>
|
|
<Icon size="300" src={Icons.File} />
|
|
<Box
|
|
grow="Yes"
|
|
direction="Column"
|
|
style={{ overflow: 'hidden', gap: '2px' }}
|
|
>
|
|
<Text size="T300" truncate title={body}>
|
|
{body}
|
|
</Text>
|
|
<Text size="T200" priority="300">
|
|
{sender}
|
|
{size != null ? ` · ${formatBytes(size)}` : ''}
|
|
</Text>
|
|
</Box>
|
|
<IconButton
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
aria-label="Go to message"
|
|
onClick={() => {
|
|
const id = mEvent.getId();
|
|
if (id) jumpToMessage(id);
|
|
}}
|
|
>
|
|
<Icon size="200" src={Icons.Message} />
|
|
</IconButton>
|
|
{mxcUrl && (
|
|
<FileDownloadButton
|
|
filename={body}
|
|
url={mxcUrl}
|
|
mimeType={c.info?.mimetype ?? 'application/octet-stream'}
|
|
encInfo={c.file}
|
|
/>
|
|
)}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
</>
|
|
)}
|
|
|
|
{/* ── Audio / voice list ── */}
|
|
{tab === 'audio' && (
|
|
<>
|
|
{events.length === 0 && !loading && (
|
|
<Box
|
|
direction="Column"
|
|
alignItems="Center"
|
|
gap="200"
|
|
style={{ padding: config.space.S400 }}
|
|
>
|
|
<Icon src={Icons.VolumeHigh} size="600" />
|
|
<Text size="T300" priority="300" align="Center">
|
|
{hasLoadedOnce ? 'No audio found.' : 'No audio in recent history.'}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
<Box direction="Column" gap="200">
|
|
{events.map((mEvent) => {
|
|
const c = mEvent.getContent();
|
|
const url: string | undefined = c.file?.url ?? c.url;
|
|
if (!url) return null;
|
|
const body: string = c.body || 'Voice message';
|
|
const sender = getSenderName(room, mEvent.getSender() ?? '');
|
|
const relDate = formatRelativeAge(mEvent.getTs(), prefs);
|
|
// 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)}`;
|
|
const waveform = (
|
|
c as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } }
|
|
)['org.matrix.msc1767.audio']?.waveform;
|
|
return (
|
|
<Box
|
|
key={mEvent.getId()}
|
|
direction="Column"
|
|
gap="100"
|
|
style={{
|
|
padding: `${config.space.S200} ${config.space.S300}`,
|
|
borderRadius: config.radii.R300,
|
|
background: color.SurfaceVariant.Container,
|
|
}}
|
|
>
|
|
<Box alignItems="Center" gap="200">
|
|
<Box
|
|
grow="Yes"
|
|
direction="Column"
|
|
style={{ overflow: 'hidden', gap: '2px' }}
|
|
>
|
|
<Text size="T300" truncate title={body}>
|
|
{body}
|
|
</Text>
|
|
<Text size="T200" priority="300">
|
|
{sender} · {relDate}
|
|
</Text>
|
|
</Box>
|
|
<IconButton
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
aria-label="Go to message"
|
|
onClick={() => {
|
|
const id = mEvent.getId();
|
|
if (id) jumpToMessage(id);
|
|
}}
|
|
>
|
|
<Icon size="200" src={Icons.Message} />
|
|
</IconButton>
|
|
<FileDownloadButton
|
|
filename={filename}
|
|
url={url}
|
|
mimeType={mimeType}
|
|
encInfo={c.file}
|
|
/>
|
|
</Box>
|
|
<AudioContent
|
|
mimeType={mimeType}
|
|
url={url}
|
|
info={c.info ?? {}}
|
|
encInfo={c.file}
|
|
waveform={waveform}
|
|
renderMediaControl={(p) => <MediaControl {...p} />}
|
|
/>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
</>
|
|
)}
|
|
|
|
{/* ── Pagination status / sentinel ── */}
|
|
{loading && (
|
|
<Box justifyContent="Center" style={{ padding: config.space.S300 }}>
|
|
<Spinner />
|
|
</Box>
|
|
)}
|
|
{loadError && !loading && (
|
|
<Box
|
|
direction="Column"
|
|
alignItems="Center"
|
|
gap="200"
|
|
style={{ padding: config.space.S300 }}
|
|
>
|
|
<Text size="T200" priority="300" align="Center">
|
|
Failed to load history.
|
|
</Text>
|
|
<Button
|
|
size="300"
|
|
variant="Secondary"
|
|
fill="Soft"
|
|
radii="300"
|
|
onClick={handleLoadMore}
|
|
>
|
|
<Text size="B300">Retry</Text>
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
{!loading && !loadError && !canLoadMore && hasLoadedOnce && events.length > 0 && (
|
|
<Text
|
|
size="T200"
|
|
priority="300"
|
|
align="Center"
|
|
style={{ padding: `${config.space.S200} 0` }}
|
|
>
|
|
Beginning of history
|
|
</Text>
|
|
)}
|
|
{/* IntersectionObserver sentinel — only rendered when safe to auto-trigger */}
|
|
{canLoadMore && !loading && !loadError && (
|
|
<div ref={sentinelRef} style={{ height: 1 }} />
|
|
)}
|
|
</Box>
|
|
</Scroll>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Lightbox */}
|
|
{lightboxIndex !== null && (
|
|
<Lightbox
|
|
items={lightboxItems}
|
|
initialIndex={lightboxIndex}
|
|
useAuthentication={useAuthentication}
|
|
onClose={() => setLightboxIndex(null)}
|
|
onJump={jumpToMessage}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|