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:
@@ -16,7 +16,6 @@ import {
|
||||
} from 'folds';
|
||||
import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
|
||||
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
|
||||
import { FALLBACK_MIMETYPE } from '../../utils/mimeTypes';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
|
||||
@@ -36,7 +35,7 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
||||
file: MsgType.File,
|
||||
};
|
||||
|
||||
// ── decrypt hook ──────────────────────────────────────────────────────────────
|
||||
// ── Decrypt hook ──────────────────────────────────────────────────────────────
|
||||
|
||||
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
|
||||
|
||||
@@ -55,18 +54,15 @@ function useDecryptedMediaUrl(
|
||||
setState({ status: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
setState({ status: 'loading' });
|
||||
|
||||
const run = async () => {
|
||||
const httpUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication);
|
||||
if (!httpUrl) throw new Error('bad url');
|
||||
|
||||
if (encInfo) {
|
||||
const blob = await downloadEncryptedMedia(httpUrl, (buf) =>
|
||||
decryptFile(buf, mimeType ?? FALLBACK_MIMETYPE, encInfo),
|
||||
decryptFile(buf, mimeType ?? 'application/octet-stream', encInfo),
|
||||
);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
if (cancelled) {
|
||||
@@ -90,7 +86,6 @@ function useDecryptedMediaUrl(
|
||||
};
|
||||
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType]);
|
||||
|
||||
// Revoke blob URL when the component unmounts
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
|
||||
@@ -101,18 +96,47 @@ function useDecryptedMediaUrl(
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type LightboxItem = {
|
||||
mxcUrl: string;
|
||||
encInfo?: IEncryptedFile;
|
||||
mimeType?: string;
|
||||
msgtype: MsgType.Image | MsgType.Video;
|
||||
body: string;
|
||||
sender: string;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
function LightboxImage({
|
||||
function LightboxMedia({
|
||||
item,
|
||||
useAuthentication,
|
||||
}: {
|
||||
@@ -135,7 +159,14 @@ function LightboxImage({
|
||||
justifyContent="Center"
|
||||
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' && (
|
||||
<Box direction="Column" alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Warning} size="600" />
|
||||
@@ -144,19 +175,34 @@ function LightboxImage({
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{media.status === 'ok' && (
|
||||
<img
|
||||
src={media.url}
|
||||
alt={item.body}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 120px)',
|
||||
objectFit: 'contain',
|
||||
borderRadius: config.radii.R300,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{media.status === 'ok' &&
|
||||
(item.msgtype === MsgType.Video ? (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
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.body}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 120px)',
|
||||
objectFit: 'contain',
|
||||
borderRadius: config.radii.R300,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -191,8 +237,7 @@ function Lightbox({
|
||||
const item = items[index];
|
||||
if (!item) return null;
|
||||
|
||||
const date = new Date(item.ts);
|
||||
const dateStr = date.toLocaleDateString(undefined, {
|
||||
const dateStr = new Date(item.ts).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
@@ -203,7 +248,7 @@ function Lightbox({
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal
|
||||
aria-label="Image viewer"
|
||||
aria-label="Media viewer"
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
@@ -215,19 +260,19 @@ function Lightbox({
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Top bar */}
|
||||
{/* Header bar */}
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<Box grow="Yes" direction="Column" style={{ overflow: 'hidden' }}>
|
||||
<Text size="T400" truncate style={{ color: '#fff', fontWeight: 500 }}>
|
||||
{item.body || 'Image'}
|
||||
{item.body || (item.msgtype === MsgType.Video ? 'Video' : 'Image')}
|
||||
</Text>
|
||||
<Text size="T200" style={{ color: 'rgba(255,255,255,0.5)' }}>
|
||||
{item.sender} · {dateStr}
|
||||
@@ -247,14 +292,14 @@ function Lightbox({
|
||||
}
|
||||
>
|
||||
{(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} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</Box>
|
||||
|
||||
{/* Image area */}
|
||||
{/* Media area with nav arrows */}
|
||||
<Box
|
||||
grow="Yes"
|
||||
alignItems="Center"
|
||||
@@ -264,7 +309,7 @@ function Lightbox({
|
||||
{index > 0 && (
|
||||
<IconButton
|
||||
variant="Surface"
|
||||
aria-label="Previous image"
|
||||
aria-label="Previous"
|
||||
onClick={prev}
|
||||
style={{ flexShrink: 0, marginRight: config.space.S200 }}
|
||||
>
|
||||
@@ -277,7 +322,7 @@ function Lightbox({
|
||||
justifyContent="Center"
|
||||
style={{ overflow: 'hidden', height: '100%' }}
|
||||
>
|
||||
<LightboxImage
|
||||
<LightboxMedia
|
||||
key={`${item.mxcUrl}-${item.ts}`}
|
||||
item={item}
|
||||
useAuthentication={useAuthentication}
|
||||
@@ -286,7 +331,7 @@ function Lightbox({
|
||||
{index < items.length - 1 && (
|
||||
<IconButton
|
||||
variant="Surface"
|
||||
aria-label="Next image"
|
||||
aria-label="Next"
|
||||
onClick={next}
|
||||
style={{ flexShrink: 0, marginLeft: config.space.S200 }}
|
||||
>
|
||||
@@ -298,12 +343,13 @@ function Lightbox({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Thumbnail tile ─────────────────────────────────────────────────────────────
|
||||
// ── Gallery tile ──────────────────────────────────────────────────────────────
|
||||
|
||||
function GalleryTile({
|
||||
mxcUrl,
|
||||
encInfo,
|
||||
mimeType,
|
||||
isVideo,
|
||||
body,
|
||||
sender,
|
||||
ts,
|
||||
@@ -313,6 +359,7 @@ function GalleryTile({
|
||||
mxcUrl: string;
|
||||
encInfo?: IEncryptedFile;
|
||||
mimeType?: string;
|
||||
isVideo: boolean;
|
||||
body: string;
|
||||
sender: string;
|
||||
ts: number;
|
||||
@@ -320,17 +367,14 @@ function GalleryTile({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
// For thumbnails: prefer encrypted thumbnail if present, else use full image
|
||||
const media = useDecryptedMediaUrl(mx, mxcUrl, encInfo, useAuthentication, mimeType);
|
||||
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const relDate = formatRelativeDate(ts);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={body || 'Image'}
|
||||
aria-label={body || (isVideo ? 'Video' : 'Image')}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
@@ -357,13 +401,13 @@ function GalleryTile({
|
||||
gap="100"
|
||||
style={{ padding: config.space.S100 }}
|
||||
>
|
||||
<Icon src={Icons.Photo} size="300" />
|
||||
<Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />
|
||||
<Text
|
||||
size="T200"
|
||||
truncate
|
||||
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.5 }}
|
||||
>
|
||||
{body || 'Image'}
|
||||
{body}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
@@ -374,13 +418,36 @@ function GalleryTile({
|
||||
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 */}
|
||||
{hovered && media.status === 'ok' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
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',
|
||||
}}
|
||||
>
|
||||
@@ -406,28 +473,46 @@ function GalleryTile({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
// ── Month separator ───────────────────────────────────────────────────────────
|
||||
|
||||
function formatRelativeDate(ts: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - ts;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 60) return mins <= 1 ? 'Just now' : `${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 MonthSeparator({ label }: { label: string }) {
|
||||
return (
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ padding: `${config.space.S100} 0`, gridColumn: '1 / -1' }}
|
||||
>
|
||||
<div style={{ flex: 1, height: 1, background: color.Surface.ContainerLine }} />
|
||||
<Text size="T200" priority="300" style={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
|
||||
{label}
|
||||
</Text>
|
||||
<div style={{ flex: 1, height: 1, background: color.Surface.ContainerLine }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
// ── Tab button ────────────────────────────────────────────────────────────────
|
||||
|
||||
function getSenderDisplayName(room: Room, userId: string): string {
|
||||
return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId;
|
||||
function TabButton({
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
@@ -445,22 +530,31 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||
const [canLoadMore, setCanLoadMore] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
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 getFilteredEvents = useCallback((): MatrixEvent[] => {
|
||||
const timeline = room.getLiveTimeline();
|
||||
return timeline
|
||||
.getEvents()
|
||||
.filter((ev) => {
|
||||
if (ev.isRedacted()) return false;
|
||||
const c = ev.getContent();
|
||||
return ev.getType() === EventType.RoomMessage && c.msgtype === msgtype;
|
||||
})
|
||||
.slice()
|
||||
.reverse();
|
||||
}, [room, msgtype]);
|
||||
const getFilteredEvents = useCallback(
|
||||
(): MatrixEvent[] =>
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
.filter((ev) => {
|
||||
if (ev.isRedacted()) return false;
|
||||
const c = ev.getContent();
|
||||
return ev.getType() === EventType.RoomMessage && c.msgtype === msgtype;
|
||||
})
|
||||
.slice()
|
||||
.reverse(),
|
||||
[room, msgtype],
|
||||
);
|
||||
|
||||
const [events, setEvents] = useState<MatrixEvent[]>(() => getFilteredEvents());
|
||||
|
||||
@@ -468,24 +562,46 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
setEvents(getFilteredEvents());
|
||||
setCanLoadMore(true);
|
||||
setHasLoadedOnce(false);
|
||||
setLoadError(false);
|
||||
}, [getFilteredEvents]);
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (loading || !canLoadMore) return;
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
try {
|
||||
const timeline = room.getLiveTimeline();
|
||||
const hasMore = await mx.paginateEventTimeline(timeline, { backwards: true, limit: 100 });
|
||||
const hasMore = await mx.paginateEventTimeline(room.getLiveTimeline(), {
|
||||
backwards: true,
|
||||
limit: 100,
|
||||
});
|
||||
setEvents(getFilteredEvents());
|
||||
setCanLoadMore(hasMore);
|
||||
setHasLoadedOnce(true);
|
||||
} 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 {
|
||||
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
|
||||
.filter((ev) => {
|
||||
const c = ev.getContent();
|
||||
@@ -494,18 +610,30 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
.map((ev) => {
|
||||
const c = ev.getContent();
|
||||
const isEnc = !!c.file;
|
||||
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
||||
const mxcUrl: string = c.file?.url ?? c.url ?? '';
|
||||
return {
|
||||
mxcUrl,
|
||||
mxcUrl: c.file?.url ?? c.url ?? '',
|
||||
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 ?? '',
|
||||
sender: getSenderDisplayName(room, ev.getSender() ?? ''),
|
||||
sender: getSenderName(room, ev.getSender() ?? ''),
|
||||
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 (
|
||||
<>
|
||||
<Box
|
||||
@@ -564,31 +692,27 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
{/* Tab bar */}
|
||||
{/* Tabs */}
|
||||
<Box
|
||||
shrink="No"
|
||||
gap="100"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
|
||||
>
|
||||
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
|
||||
<Button
|
||||
<TabButton
|
||||
key={t}
|
||||
size="300"
|
||||
variant={tab === t ? 'Primary' : 'Secondary'}
|
||||
fill={tab === t ? 'Soft' : 'None'}
|
||||
radii="300"
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
<Text size="B300">{TAB_LABELS[t]}</Text>
|
||||
</Button>
|
||||
label={TAB_LABELS[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 direction="Column" gap="200" style={{ padding: config.space.S200 }}>
|
||||
{/* Image / video grid */}
|
||||
<Box direction="Column" style={{ padding: config.space.S200, gap: 0 }}>
|
||||
{/* ── Image / video grid ── */}
|
||||
{(tab === 'image' || tab === 'video') && (
|
||||
<>
|
||||
{events.length === 0 && !loading && (
|
||||
@@ -598,7 +722,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
gap="200"
|
||||
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">
|
||||
{hasLoadedOnce
|
||||
? `No ${TAB_LABELS[tab].toLowerCase()} found.`
|
||||
@@ -606,50 +730,69 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{events.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: config.space.S100,
|
||||
}}
|
||||
>
|
||||
{events.map((mEvent, idx) => {
|
||||
const c = mEvent.getContent();
|
||||
const isEnc = !!c.file;
|
||||
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
||||
// For thumbnails: prefer thumbnail_file > file; for non-enc: use thumbnail URL
|
||||
const thumbMxc: string | undefined = isEnc
|
||||
? (info?.thumbnail_file?.url ?? c.file?.url)
|
||||
: (info?.thumbnail_url ?? c.url);
|
||||
const thumbEncInfo: IEncryptedFile | undefined = isEnc
|
||||
? (info?.thumbnail_file ?? c.file)
|
||||
: undefined;
|
||||
const mimeType: string | undefined =
|
||||
info?.thumbnail_file != null
|
||||
? (info.thumbnail_info?.mimetype ?? 'image/jpeg')
|
||||
: (info?.mimetype ?? c.info?.mimetype);
|
||||
if (!thumbMxc) return null;
|
||||
return (
|
||||
<GalleryTile
|
||||
key={mEvent.getId()}
|
||||
mxcUrl={thumbMxc}
|
||||
encInfo={thumbEncInfo}
|
||||
mimeType={mimeType}
|
||||
body={c.body ?? ''}
|
||||
sender={getSenderDisplayName(room, mEvent.getSender() ?? '')}
|
||||
ts={mEvent.getTs()}
|
||||
useAuthentication={useAuthentication}
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Month groups */}
|
||||
{(() => {
|
||||
let flatIdx = 0;
|
||||
return monthGroups.map((group) => (
|
||||
<Box
|
||||
key={group.label}
|
||||
direction="Column"
|
||||
style={{ marginBottom: config.space.S200 }}
|
||||
>
|
||||
{/* Month header — only shown when there are multiple groups */}
|
||||
{monthGroups.length > 1 && <MonthSeparator label={group.label} />}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: config.space.S100,
|
||||
}}
|
||||
>
|
||||
{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 = isEnc
|
||||
? (info?.thumbnail_file?.url ?? c.file?.url)
|
||||
: (info?.thumbnail_url ?? c.url);
|
||||
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++;
|
||||
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' && (
|
||||
<>
|
||||
{events.length === 0 && !loading && (
|
||||
@@ -671,7 +814,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
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 = getSenderDisplayName(room, mEvent.getSender() ?? '');
|
||||
const sender = getSenderName(room, mEvent.getSender() ?? '');
|
||||
const downloadUrl = mxcUrl
|
||||
? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#')
|
||||
: '#';
|
||||
@@ -724,14 +867,22 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Load more / spinner */}
|
||||
{/* ── Pagination status / sentinel ── */}
|
||||
{loading && (
|
||||
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
|
||||
<Box justifyContent="Center" style={{ padding: config.space.S300 }}>
|
||||
<Spinner />
|
||||
</Box>
|
||||
)}
|
||||
{!loading && canLoadMore && (
|
||||
<Box justifyContent="Center" style={{ padding: `${config.space.S100} 0` }}>
|
||||
{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"
|
||||
@@ -739,20 +890,24 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
radii="300"
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
<Text size="B300">Load More History</Text>
|
||||
<Text size="B300">Retry</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{!loading && !canLoadMore && hasLoadedOnce && (
|
||||
{!loading && !loadError && !canLoadMore && hasLoadedOnce && events.length > 0 && (
|
||||
<Text
|
||||
size="T200"
|
||||
priority="300"
|
||||
align="Center"
|
||||
style={{ padding: `${config.space.S100} 0` }}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user