Files
cinny/src/app/features/room/MediaGallery.tsx
T
Lotus CIandClaude Opus 5.5 c088b4c0e6 feat(media): show the full caption under the media in the viewer (#164)
A captioned image/video (MSC2530: `filename` set and `body` differs) put the
caption in the header, truncated to one line, so a long caption was
unreadable. The header now shows the file name; the caption is shown in full
under the media (wrapped, scrolls past 25vh). The image alt text uses the
caption, and Download now saves under the real file name instead of the
caption.

Verified in Chromium: header "lake-sunset.png", the 150-character caption
fully visible below the image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:51:33 -04:00

1158 lines
41 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';
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,
}: {
item: LightboxItem;
useAuthentication: boolean;
zoom: number;
pan: Pan;
cursor: string;
onMouseDown: React.MouseEventHandler<HTMLElement>;
onTouchStart: React.TouchEventHandler<HTMLElement>;
onImageDoubleClick: () => void;
}) {
const mx = useMatrixClient();
const media = useDecryptedMediaUrl(
mx,
item.mxcUrl,
item.encInfo,
useAuthentication,
item.mimeType,
);
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]);
// 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>
)}
{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}
/>
</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}
/>
)}
</>
);
}