Files
cinny/src/app/features/room/MediaGallery.tsx
T
Lotus CIandClaude Opus 5.5 f5d3c43b6c
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
fix(viewer): thread media steps through the thread; add Copy image (#164)
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
2026-09-26 19:02:49 -04:00

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}
/>
)}
</>
);
}