feat: gallery — video lightbox, auto-scroll load, month separators; fix 3 review bugs

New features:
- Video playback: lightbox renders <video controls autoPlay> for MsgType.Video;
  thumbnail tiles show a play-button badge overlay; LightboxImage renamed to
  LightboxMedia to handle both types.
- Auto-load on scroll: IntersectionObserver on a sentinel div replaces the
  manual "Load More" button. Detaches while loading or when history is exhausted.
- Month separators: image/video grid grouped by month ("June 2026", etc.) with
  a hairline divider; separator only shown when more than one month is present.

Bugs fixed by code review:
- flatIdx++: index was incremented before the !thumbMxc null-guard, causing
  tiles rendered after a skipped event to open the wrong lightbox item. Guard
  is now checked first; flatIdx only increments when a tile actually renders.
- lightboxIndex never reset on tab switch: stale index kept the lightbox open
  (or opened the wrong item) after switching tabs. handleTabChange() now calls
  setLightboxIndex(null) alongside setTab().
- Silent catch retry storm: pagination errors left canLoadMore=true, causing
  the IntersectionObserver to re-fire handleLoadMore on every render cycle
  when the sentinel was still visible. Error state now sets loadError=true,
  removes the sentinel, and shows a manual Retry button instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 15:38:53 -04:00
parent face24f2f4
commit ba659bc157
+298 -143
View File
@@ -16,7 +16,6 @@ import {
} from 'folds'; } from 'folds';
import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk'; import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common'; import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix'; import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
@@ -36,7 +35,7 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
file: MsgType.File, file: MsgType.File,
}; };
// ── decrypt hook ────────────────────────────────────────────────────────────── // ── Decrypt hook ──────────────────────────────────────────────────────────────
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' }; type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
@@ -55,18 +54,15 @@ function useDecryptedMediaUrl(
setState({ status: 'error' }); setState({ status: 'error' });
return; return;
} }
let cancelled = false; let cancelled = false;
setState({ status: 'loading' }); setState({ status: 'loading' });
const run = async () => { const run = async () => {
const httpUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication); const httpUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication);
if (!httpUrl) throw new Error('bad url'); if (!httpUrl) throw new Error('bad url');
if (encInfo) { if (encInfo) {
const blob = await downloadEncryptedMedia(httpUrl, (buf) => const blob = await downloadEncryptedMedia(httpUrl, (buf) =>
decryptFile(buf, mimeType ?? FALLBACK_MIMETYPE, encInfo), decryptFile(buf, mimeType ?? 'application/octet-stream', encInfo),
); );
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
if (cancelled) { if (cancelled) {
@@ -90,7 +86,6 @@ function useDecryptedMediaUrl(
}; };
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType]); }, [mx, mxcUrl, encInfo, useAuthentication, mimeType]);
// Revoke blob URL when the component unmounts
useEffect( useEffect(
() => () => { () => () => {
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current); if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
@@ -101,18 +96,47 @@ function useDecryptedMediaUrl(
return state; return state;
} }
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatRelativeDate(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 2) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(diff / 3600000);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(diff / 86400000);
if (days < 7) return `${days}d ago`;
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
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`;
}
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;
}
// ── Lightbox ────────────────────────────────────────────────────────────────── // ── Lightbox ──────────────────────────────────────────────────────────────────
type LightboxItem = { type LightboxItem = {
mxcUrl: string; mxcUrl: string;
encInfo?: IEncryptedFile; encInfo?: IEncryptedFile;
mimeType?: string; mimeType?: string;
msgtype: MsgType.Image | MsgType.Video;
body: string; body: string;
sender: string; sender: string;
ts: number; ts: number;
}; };
function LightboxImage({ function LightboxMedia({
item, item,
useAuthentication, useAuthentication,
}: { }: {
@@ -135,7 +159,14 @@ function LightboxImage({
justifyContent="Center" justifyContent="Center"
style={{ height: '100%', gap: config.space.S200 }} style={{ height: '100%', gap: config.space.S200 }}
> >
{media.status === 'loading' && <Spinner size="600" />} {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' && ( {media.status === 'error' && (
<Box direction="Column" alignItems="Center" gap="200"> <Box direction="Column" alignItems="Center" gap="200">
<Icon src={Icons.Warning} size="600" /> <Icon src={Icons.Warning} size="600" />
@@ -144,19 +175,34 @@ function LightboxImage({
</Text> </Text>
</Box> </Box>
)} )}
{media.status === 'ok' && ( {media.status === 'ok' &&
<img (item.msgtype === MsgType.Video ? (
src={media.url} // eslint-disable-next-line jsx-a11y/media-has-caption
alt={item.body} <video
style={{ src={media.url}
maxWidth: '100%', controls
maxHeight: 'calc(100vh - 120px)', autoPlay
objectFit: 'contain', style={{
borderRadius: config.radii.R300, maxWidth: '100%',
display: 'block', maxHeight: 'calc(100vh - 120px)',
}} borderRadius: config.radii.R300,
/> display: 'block',
)} background: '#000',
}}
/>
) : (
<img
src={media.url}
alt={item.body}
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 120px)',
objectFit: 'contain',
borderRadius: config.radii.R300,
display: 'block',
}}
/>
))}
</Box> </Box>
); );
} }
@@ -191,8 +237,7 @@ function Lightbox({
const item = items[index]; const item = items[index];
if (!item) return null; if (!item) return null;
const date = new Date(item.ts); const dateStr = new Date(item.ts).toLocaleDateString(undefined, {
const dateStr = date.toLocaleDateString(undefined, {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
@@ -203,7 +248,7 @@ function Lightbox({
<div <div
role="dialog" role="dialog"
aria-modal aria-modal
aria-label="Image viewer" aria-label="Media viewer"
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
tabIndex={-1} tabIndex={-1}
style={{ style={{
@@ -215,19 +260,19 @@ function Lightbox({
flexDirection: 'column', flexDirection: 'column',
}} }}
> >
{/* Top bar */} {/* Header bar */}
<Box <Box
alignItems="Center" alignItems="Center"
gap="200" gap="200"
style={{ style={{
padding: `${config.space.S200} ${config.space.S300}`, padding: `${config.space.S200} ${config.space.S300}`,
borderBottom: `1px solid rgba(255,255,255,0.08)`, borderBottom: '1px solid rgba(255,255,255,0.08)',
flexShrink: 0, flexShrink: 0,
}} }}
> >
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}> <Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}> <Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
{item.body || 'Image'} {item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
</Text> </Text>
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}> <Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
{item.sender} · {dateStr} {item.sender} · {dateStr}
@@ -247,14 +292,14 @@ function Lightbox({
} }
> >
{(ref) => ( {(ref) => (
<IconButton ref={ref} variant="Surface" aria-label="Close lightbox" onClick={onClose}> <IconButton ref={ref} variant="Surface" aria-label="Close" onClick={onClose}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
</Box> </Box>
{/* Image area */} {/* Media area with nav arrows */}
<Box <Box
grow="Yes" grow="Yes"
alignItems="Center" alignItems="Center"
@@ -264,7 +309,7 @@ function Lightbox({
{index > 0 && ( {index > 0 && (
<IconButton <IconButton
variant="Surface" variant="Surface"
aria-label="Previous image" aria-label="Previous"
onClick={prev} onClick={prev}
style={{ flexShrink: 0, marginRight: config.space.S200 }} style={{ flexShrink: 0, marginRight: config.space.S200 }}
> >
@@ -277,7 +322,7 @@ function Lightbox({
justifyContent="Center" justifyContent="Center"
style={{ overflow: 'hidden', height: '100%' }} style={{ overflow: 'hidden', height: '100%' }}
> >
<LightboxImage <LightboxMedia
key={`${item.mxcUrl}-${item.ts}`} key={`${item.mxcUrl}-${item.ts}`}
item={item} item={item}
useAuthentication={useAuthentication} useAuthentication={useAuthentication}
@@ -286,7 +331,7 @@ function Lightbox({
{index < items.length - 1 && ( {index < items.length - 1 && (
<IconButton <IconButton
variant="Surface" variant="Surface"
aria-label="Next image" aria-label="Next"
onClick={next} onClick={next}
style={{ flexShrink: 0, marginLeft: config.space.S200 }} style={{ flexShrink: 0, marginLeft: config.space.S200 }}
> >
@@ -298,12 +343,13 @@ function Lightbox({
); );
} }
// ── Thumbnail tile ───────────────────────────────────────────────────────────── // ── Gallery tile ─────────────────────────────────────────────────────────────
function GalleryTile({ function GalleryTile({
mxcUrl, mxcUrl,
encInfo, encInfo,
mimeType, mimeType,
isVideo,
body, body,
sender, sender,
ts, ts,
@@ -313,6 +359,7 @@ function GalleryTile({
mxcUrl: string; mxcUrl: string;
encInfo?: IEncryptedFile; encInfo?: IEncryptedFile;
mimeType?: string; mimeType?: string;
isVideo: boolean;
body: string; body: string;
sender: string; sender: string;
ts: number; ts: number;
@@ -320,17 +367,14 @@ function GalleryTile({
onClick: () => void; onClick: () => void;
}) { }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
// For thumbnails: prefer encrypted thumbnail if present, else use full image
const media = useDecryptedMediaUrl(mx, mxcUrl, encInfo, useAuthentication, mimeType); const media = useDecryptedMediaUrl(mx, mxcUrl, encInfo, useAuthentication, mimeType);
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const relDate = formatRelativeDate(ts); const relDate = formatRelativeDate(ts);
return ( return (
<button <button
type="button" type="button"
aria-label={body || 'Image'} aria-label={body || (isVideo ? 'Video' : 'Image')}
onClick={onClick} onClick={onClick}
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
@@ -357,13 +401,13 @@ function GalleryTile({
gap="100" gap="100"
style={{ padding: config.space.S100 }} style={{ padding: config.space.S100 }}
> >
<Icon src={Icons.Photo} size="300" /> <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />
<Text <Text
size="T200" size="T200"
truncate truncate
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.5 }} style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.5 }}
> >
{body || 'Image'} {body}
</Text> </Text>
</Box> </Box>
)} )}
@@ -374,13 +418,36 @@ function GalleryTile({
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/> />
)} )}
{/* Video play badge */}
{isVideo && media.status === 'ok' && (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 28,
height: 28,
borderRadius: '50%',
background: 'rgba(0,0,0,0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<Icon src={Icons.Play} size="200" />
</div>
)}
{/* Hover overlay */} {/* Hover overlay */}
{hovered && media.status === 'ok' && ( {hovered && media.status === 'ok' && (
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
inset: 0, inset: 0,
background: 'linear-gradient(to top, rgba(0,0,0,0.75) 0%, transparent 50%)', background: 'linear-gradient(to top, rgba(0,0,0,0.72) 0%, transparent 55%)',
pointerEvents: 'none', pointerEvents: 'none',
}} }}
> >
@@ -406,28 +473,46 @@ function GalleryTile({
); );
} }
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Month separator ───────────────────────────────────────────────────────────
function formatRelativeDate(ts: number): string { function MonthSeparator({ label }: { label: string }) {
const now = Date.now(); return (
const diff = now - ts; <Box
const mins = Math.floor(diff / 60000); alignItems="Center"
if (mins < 60) return mins <= 1 ? 'Just now' : `${mins}m ago`; gap="200"
const hrs = Math.floor(diff / 3600000); style={{ padding: `${config.space.S100} 0`, gridColumn: '1 / -1' }}
if (hrs < 24) return `${hrs}h ago`; >
const days = Math.floor(diff / 86400000); <div style={{ flex: 1, height: 1, background: color.Surface.ContainerLine }} />
if (days < 7) return `${days}d ago`; <Text size="T200" priority="300" style={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); {label}
</Text>
<div style={{ flex: 1, height: 1, background: color.Surface.ContainerLine }} />
</Box>
);
} }
function formatBytes(bytes: number): string { // ── Tab button ────────────────────────────────────────────────────────────────
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
}
function getSenderDisplayName(room: Room, userId: string): string { function TabButton({
return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId; label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<Button
size="300"
variant={active ? 'Primary' : 'Secondary'}
fill={active ? 'Soft' : 'None'}
radii="300"
onClick={onClick}
>
<Text size="B300">{label}</Text>
</Button>
);
} }
// ── Main component ──────────────────────────────────────────────────────────── // ── Main component ────────────────────────────────────────────────────────────
@@ -445,22 +530,31 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [hasLoadedOnce, setHasLoadedOnce] = useState(false); const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const [canLoadMore, setCanLoadMore] = useState(true); const [canLoadMore, setCanLoadMore] = useState(true);
const [loadError, setLoadError] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); 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
}, []);
const msgtype = TAB_MSGTYPES[tab]; const msgtype = TAB_MSGTYPES[tab];
const getFilteredEvents = useCallback((): MatrixEvent[] => { const getFilteredEvents = useCallback(
const timeline = room.getLiveTimeline(); (): MatrixEvent[] =>
return timeline room
.getEvents() .getLiveTimeline()
.filter((ev) => { .getEvents()
if (ev.isRedacted()) return false; .filter((ev) => {
const c = ev.getContent(); if (ev.isRedacted()) return false;
return ev.getType() === EventType.RoomMessage && c.msgtype === msgtype; const c = ev.getContent();
}) return ev.getType() === EventType.RoomMessage && c.msgtype === msgtype;
.slice() })
.reverse(); .slice()
}, [room, msgtype]); .reverse(),
[room, msgtype],
);
const [events, setEvents] = useState<MatrixEvent[]>(() => getFilteredEvents()); const [events, setEvents] = useState<MatrixEvent[]>(() => getFilteredEvents());
@@ -468,24 +562,46 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
setEvents(getFilteredEvents()); setEvents(getFilteredEvents());
setCanLoadMore(true); setCanLoadMore(true);
setHasLoadedOnce(false); setHasLoadedOnce(false);
setLoadError(false);
}, [getFilteredEvents]); }, [getFilteredEvents]);
const handleLoadMore = useCallback(async () => { const handleLoadMore = useCallback(async () => {
if (loading || !canLoadMore) return;
setLoading(true); setLoading(true);
setLoadError(false);
try { try {
const timeline = room.getLiveTimeline(); const hasMore = await mx.paginateEventTimeline(room.getLiveTimeline(), {
const hasMore = await mx.paginateEventTimeline(timeline, { backwards: true, limit: 100 }); backwards: true,
limit: 100,
});
setEvents(getFilteredEvents()); setEvents(getFilteredEvents());
setCanLoadMore(hasMore); setCanLoadMore(hasMore);
setHasLoadedOnce(true); setHasLoadedOnce(true);
} catch { } catch {
// silently swallow // Stop auto-retry: the sentinel would keep firing on every render otherwise.
// The user can retry manually via the button shown in the error state.
setLoadError(true);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [mx, room, getFilteredEvents]); }, [loading, canLoadMore, mx, room, getFilteredEvents]);
// Build lightbox items from current image/video events // 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[] = events const lightboxItems: LightboxItem[] = events
.filter((ev) => { .filter((ev) => {
const c = ev.getContent(); const c = ev.getContent();
@@ -494,18 +610,30 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
.map((ev) => { .map((ev) => {
const c = ev.getContent(); const c = ev.getContent();
const isEnc = !!c.file; const isEnc = !!c.file;
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
const mxcUrl: string = c.file?.url ?? c.url ?? '';
return { return {
mxcUrl, mxcUrl: c.file?.url ?? c.url ?? '',
encInfo: isEnc ? c.file : undefined, encInfo: isEnc ? c.file : undefined,
mimeType: info?.mimetype ?? c.info?.mimetype, mimeType: c.info?.mimetype,
msgtype: c.msgtype as MsgType.Image | MsgType.Video,
body: c.body ?? '', body: c.body ?? '',
sender: getSenderDisplayName(room, ev.getSender() ?? ''), sender: getSenderName(room, ev.getSender() ?? ''),
ts: ev.getTs(), ts: ev.getTs(),
}; };
}); });
// 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 ( return (
<> <>
<Box <Box
@@ -564,31 +692,27 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
</Box> </Box>
</Header> </Header>
{/* Tab bar */} {/* Tabs */}
<Box <Box
shrink="No" shrink="No"
gap="100" gap="100"
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }} style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
> >
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => ( {(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
<Button <TabButton
key={t} key={t}
size="300" label={TAB_LABELS[t]}
variant={tab === t ? 'Primary' : 'Secondary'} active={tab === t}
fill={tab === t ? 'Soft' : 'None'} onClick={() => handleTabChange(t)}
radii="300" />
onClick={() => setTab(t)}
>
<Text size="B300">{TAB_LABELS[t]}</Text>
</Button>
))} ))}
</Box> </Box>
{/* Content */} {/* Content */}
<Box grow="Yes" style={{ position: 'relative', overflow: 'hidden' }}> <Box grow="Yes" style={{ position: 'relative', overflow: 'hidden' }}>
<Scroll variant="Background" size="300" visibility="Hover" hideTrack> <Scroll variant="Background" size="300" visibility="Hover" hideTrack>
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}> <Box direction="Column" style={{ padding: config.space.S200, gap: 0 }}>
{/* Image / video grid */} {/* ── Image / video grid ── */}
{(tab === 'image' || tab === 'video') && ( {(tab === 'image' || tab === 'video') && (
<> <>
{events.length === 0 && !loading && ( {events.length === 0 && !loading && (
@@ -598,7 +722,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
gap="200" gap="200"
style={{ padding: config.space.S400 }} style={{ padding: config.space.S400 }}
> >
<Icon src={Icons.Photo} size="600" /> <Icon src={tab === 'video' ? Icons.Play : Icons.Photo} size="600" />
<Text size="T300" priority="300" align="Center"> <Text size="T300" priority="300" align="Center">
{hasLoadedOnce {hasLoadedOnce
? `No ${TAB_LABELS[tab].toLowerCase()} found.` ? `No ${TAB_LABELS[tab].toLowerCase()} found.`
@@ -606,50 +730,69 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
</Text> </Text>
</Box> </Box>
)} )}
{events.length > 0 && (
<div {/* Month groups */}
style={{ {(() => {
display: 'grid', let flatIdx = 0;
gridTemplateColumns: 'repeat(3, 1fr)', return monthGroups.map((group) => (
gap: config.space.S100, <Box
}} key={group.label}
> direction="Column"
{events.map((mEvent, idx) => { style={{ marginBottom: config.space.S200 }}
const c = mEvent.getContent(); >
const isEnc = !!c.file; {/* Month header — only shown when there are multiple groups */}
const info: (IImageInfo & IThumbnailContent) | undefined = c.info; {monthGroups.length > 1 && <MonthSeparator label={group.label} />}
// For thumbnails: prefer thumbnail_file > file; for non-enc: use thumbnail URL <div
const thumbMxc: string | undefined = isEnc style={{
? (info?.thumbnail_file?.url ?? c.file?.url) display: 'grid',
: (info?.thumbnail_url ?? c.url); gridTemplateColumns: 'repeat(3, 1fr)',
const thumbEncInfo: IEncryptedFile | undefined = isEnc gap: config.space.S100,
? (info?.thumbnail_file ?? c.file) }}
: undefined; >
const mimeType: string | undefined = {group.events.map((mEvent) => {
info?.thumbnail_file != null const c = mEvent.getContent();
? (info.thumbnail_info?.mimetype ?? 'image/jpeg') const isEnc = !!c.file;
: (info?.mimetype ?? c.info?.mimetype); const isVideo = c.msgtype === MsgType.Video;
if (!thumbMxc) return null; const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
return (
<GalleryTile // Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url
key={mEvent.getId()} const thumbMxc: string | undefined = isEnc
mxcUrl={thumbMxc} ? (info?.thumbnail_file?.url ?? c.file?.url)
encInfo={thumbEncInfo} : (info?.thumbnail_url ?? c.url);
mimeType={mimeType} const thumbEnc: IEncryptedFile | undefined = isEnc
body={c.body ?? ''} ? (info?.thumbnail_file ?? c.file)
sender={getSenderDisplayName(room, mEvent.getSender() ?? '')} : undefined;
ts={mEvent.getTs()} const thumbMime: string | undefined =
useAuthentication={useAuthentication} info?.thumbnail_file != null
onClick={() => setLightboxIndex(idx)} ? (info.thumbnail_info?.mimetype ?? 'image/jpeg')
/> : (info?.mimetype ?? 'image/jpeg');
);
})} // Guard before incrementing: skipped tiles must not consume a slot
</div> if (!thumbMxc) return null;
)} const idx = flatIdx++;
return (
<GalleryTile
key={mEvent.getId()}
mxcUrl={thumbMxc}
encInfo={thumbEnc}
mimeType={thumbMime}
isVideo={isVideo}
body={c.body ?? ''}
sender={getSenderName(room, mEvent.getSender() ?? '')}
ts={mEvent.getTs()}
useAuthentication={useAuthentication}
onClick={() => setLightboxIndex(idx)}
/>
);
})}
</div>
</Box>
));
})()}
</> </>
)} )}
{/* File list */} {/* ── File list ── */}
{tab === 'file' && ( {tab === 'file' && (
<> <>
{events.length === 0 && !loading && ( {events.length === 0 && !loading && (
@@ -671,7 +814,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const mxcUrl: string | undefined = c.file?.url ?? c.url; const mxcUrl: string | undefined = c.file?.url ?? c.url;
const body: string = c.body ?? 'Unnamed file'; const body: string = c.body ?? 'Unnamed file';
const size: number | undefined = c.info?.size; const size: number | undefined = c.info?.size;
const sender = getSenderDisplayName(room, mEvent.getSender() ?? ''); const sender = getSenderName(room, mEvent.getSender() ?? '');
const downloadUrl = mxcUrl const downloadUrl = mxcUrl
? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#') ? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#')
: '#'; : '#';
@@ -724,14 +867,22 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
</> </>
)} )}
{/* Load more / spinner */} {/* ── Pagination status / sentinel ── */}
{loading && ( {loading && (
<Box justifyContent="Center" style={{ padding: config.space.S200 }}> <Box justifyContent="Center" style={{ padding: config.space.S300 }}>
<Spinner /> <Spinner />
</Box> </Box>
)} )}
{!loading && canLoadMore && ( {loadError && !loading && (
<Box justifyContent="Center" style={{ padding: `${config.space.S100} 0` }}> <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 <Button
size="300" size="300"
variant="Secondary" variant="Secondary"
@@ -739,20 +890,24 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
radii="300" radii="300"
onClick={handleLoadMore} onClick={handleLoadMore}
> >
<Text size="B300">Load More History</Text> <Text size="B300">Retry</Text>
</Button> </Button>
</Box> </Box>
)} )}
{!loading && !canLoadMore && hasLoadedOnce && ( {!loading && !loadError && !canLoadMore && hasLoadedOnce && events.length > 0 && (
<Text <Text
size="T200" size="T200"
priority="300" priority="300"
align="Center" align="Center"
style={{ padding: `${config.space.S100} 0` }} style={{ padding: `${config.space.S200} 0` }}
> >
Beginning of history Beginning of history
</Text> </Text>
)} )}
{/* IntersectionObserver sentinel — only rendered when safe to auto-trigger */}
{canLoadMore && !loading && !loadError && (
<div ref={sentinelRef} style={{ height: 1 }} />
)}
</Box> </Box>
</Scroll> </Scroll>
</Box> </Box>