feat: gallery — decrypt E2EE thumbnails, lightbox, hover overlays

Root cause of padlock: all images in E2EE rooms have content.file so the
gallery skipped thumbnails for all of them. Fix:

- useDecryptedMediaUrl hook: downloads + decrypts encrypted media using
  downloadEncryptedMedia/decryptFile, creates a blob URL, revokes on
  unmount. For unencrypted media returns the HTTP URL directly.
- GalleryTile: prefers content.info.thumbnail_file (smaller encrypted
  thumb) over content.file; falls back gracefully. Shows spinner while
  decrypting, broken-image icon on error. Hover overlay shows sender
  name + relative date with a gradient.
- Lightbox: full-screen overlay with ← → keyboard/button navigation,
  filename/sender/date header, image counter. Full-res decryption done
  in LightboxImage (separate component per item so keys reset the hook).
- File list: shows sender name + file size (formatted KB/MB).
- Empty states: distinct messages for "nothing in recent events" vs
  "nothing found after loading more". "Beginning of history" shown when
  pagination exhausts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 13:14:28 -04:00
parent 0f4f33119b
commit face24f2f4
+650 -245
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Box,
Button,
@@ -14,19 +14,16 @@ import {
color,
config,
} from 'folds';
import { EventType, 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 { FALLBACK_MIMETYPE } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../utils/matrix';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
import { ContainerColor } from '../../styles/ContainerColor.css';
type GalleryTab = 'image' | 'video' | 'file';
type MediaGalleryProps = {
room: Room;
onClose: () => void;
};
const TAB_LABELS: Record<GalleryTab, string> = {
image: 'Images',
video: 'Videos',
@@ -39,132 +36,438 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
file: MsgType.File,
};
type ThumbState = 'loading' | 'error' | 'ok';
// ── decrypt hook ──────────────────────────────────────────────────────────────
function ImageTile({
thumbUrl,
fullUrl,
body,
isEncrypted,
}: {
thumbUrl: string | null;
fullUrl: string;
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
function useDecryptedMediaUrl(
mx: MatrixClient,
mxcUrl: string | undefined,
encInfo: IEncryptedFile | undefined,
useAuthentication: boolean,
mimeType?: string,
): DecryptState {
const [state, setState] = useState<DecryptState>({ status: 'loading' });
const prevBlobUrl = useRef<string | null>(null);
useEffect(() => {
if (!mxcUrl) {
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),
);
const blobUrl = URL.createObjectURL(blob);
if (cancelled) {
URL.revokeObjectURL(blobUrl);
return;
}
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
prevBlobUrl.current = blobUrl;
setState({ status: 'ok', url: blobUrl });
} else {
setState({ status: 'ok', url: httpUrl });
}
};
run().catch(() => {
if (!cancelled) setState({ status: 'error' });
});
return () => {
cancelled = true;
};
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType]);
// Revoke blob URL when the component unmounts
useEffect(
() => () => {
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
},
[],
);
return state;
}
// ── Lightbox ──────────────────────────────────────────────────────────────────
type LightboxItem = {
mxcUrl: string;
encInfo?: IEncryptedFile;
mimeType?: string;
body: string;
isEncrypted: boolean;
sender: string;
ts: number;
};
function LightboxImage({
item,
useAuthentication,
}: {
item: LightboxItem;
useAuthentication: boolean;
}) {
const [thumbState, setThumbState] = useState<ThumbState>(thumbUrl ? 'loading' : 'error');
const mx = useMatrixClient();
const media = useDecryptedMediaUrl(
mx,
item.mxcUrl,
item.encInfo,
useAuthentication,
item.mimeType,
);
return (
<a
href={isEncrypted ? undefined : fullUrl}
target={isEncrypted ? undefined : '_blank'}
rel="noreferrer"
title={body}
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
style={{ height: '100%', gap: config.space.S200 }}
>
{media.status === 'loading' && <Spinner size="600" />}
{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' && (
<img
src={media.url}
alt={item.body}
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 120px)',
objectFit: 'contain',
borderRadius: config.radii.R300,
display: 'block',
}}
/>
)}
</Box>
);
}
function Lightbox({
items,
initialIndex,
useAuthentication,
onClose,
}: {
items: LightboxItem[];
initialIndex: number;
useAuthentication: boolean;
onClose: () => void;
}) {
const [index, setIndex] = useState(initialIndex);
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();
},
[prev, next, onClose],
);
const item = items[index];
if (!item) return null;
const date = new Date(item.ts);
const dateStr = date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
role="dialog"
aria-modal
aria-label="Image viewer"
onKeyDown={handleKeyDown}
tabIndex={-1}
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: 'rgba(0,0,0,0.92)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
}}
>
{/* Top 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.body || '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>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton ref={ref} variant="Surface" aria-label="Close lightbox" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
{/* Image area */}
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
style={{ overflow: 'hidden', padding: config.space.S400 }}
>
{index > 0 && (
<IconButton
variant="Surface"
aria-label="Previous image"
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%' }}
>
<LightboxImage
key={`${item.mxcUrl}-${item.ts}`}
item={item}
useAuthentication={useAuthentication}
/>
</Box>
{index < items.length - 1 && (
<IconButton
variant="Surface"
aria-label="Next image"
onClick={next}
style={{ flexShrink: 0, marginLeft: config.space.S200 }}
>
<Icon src={Icons.ArrowRight} />
</IconButton>
)}
</Box>
</div>
);
}
// ── Thumbnail tile ─────────────────────────────────────────────────────────────
function GalleryTile({
mxcUrl,
encInfo,
mimeType,
body,
sender,
ts,
useAuthentication,
onClick,
}: {
mxcUrl: string;
encInfo?: IEncryptedFile;
mimeType?: string;
body: string;
sender: string;
ts: number;
useAuthentication: boolean;
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'}
onClick={onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
position: 'relative',
aspectRatio: '1',
overflow: 'hidden',
borderRadius: config.radii.R300,
background: color.SurfaceVariant.Container,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
cursor: isEncrypted ? 'default' : 'pointer',
textDecoration: 'none',
position: 'relative',
border: `1px solid ${hovered ? color.Primary.Main : color.SurfaceVariant.ContainerLine}`,
cursor: 'pointer',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'border-color 0.15s',
}}
>
{thumbUrl && thumbState !== 'error' && (
<img
src={thumbUrl}
alt={body}
onLoad={() => setThumbState('ok')}
onError={() => setThumbState('error')}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
opacity: thumbState === 'ok' ? 1 : 0,
}}
/>
)}
{(thumbState === 'error' || !thumbUrl) && (
{media.status === 'loading' && <Spinner size="200" />}
{media.status === 'error' && (
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{
padding: config.space.S100,
position: 'absolute',
inset: 0,
justifyContent: 'center',
}}
style={{ padding: config.space.S100 }}
>
<Icon src={isEncrypted ? Icons.Lock : Icons.Photo} size="400" />
<Icon src={Icons.Photo} size="300" />
<Text
size="T200"
truncate
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.7 }}
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.5 }}
>
{body || (isEncrypted ? 'Encrypted' : 'Image')}
{body || 'Image'}
</Text>
</Box>
)}
</a>
{media.status === 'ok' && (
<img
src={media.url}
alt={body}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
)}
{/* 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%)',
pointerEvents: 'none',
}}
>
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: `${config.space.S100} ${config.space.S200}`,
}}
>
<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)', opacity: 0.8 }}>
{relDate}
</Text>
</div>
</div>
)}
</button>
);
}
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>
);
// ── Helpers ───────────────────────────────────────────────────────────────────
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 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 getSenderDisplayName(room: Room, userId: string): string {
return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId;
}
// ── Main component ────────────────────────────────────────────────────────────
type MediaGalleryProps = {
room: Room;
onClose: () => void;
};
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [tab, setTab] = useState<GalleryTab>('image');
const [loading, setLoading] = useState(false);
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
const [canLoadMore, setCanLoadMore] = useState(true);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const msgtype = TAB_MSGTYPES[tab];
const getFilteredEvents = useCallback(() => {
const getFilteredEvents = useCallback((): MatrixEvent[] => {
const timeline = room.getLiveTimeline();
return timeline
.getEvents()
.filter((ev) => {
if (ev.isRedacted()) return false;
const content = ev.getContent();
return ev.getType() === EventType.RoomMessage && content.msgtype === msgtype;
const c = ev.getContent();
return ev.getType() === EventType.RoomMessage && c.msgtype === msgtype;
})
.slice()
.reverse();
}, [room, msgtype]);
const [events, setEvents] = useState(() => getFilteredEvents());
const [events, setEvents] = useState<MatrixEvent[]>(() => getFilteredEvents());
useEffect(() => {
setEvents(getFilteredEvents());
setCanLoadMore(true);
setHasLoadedOnce(false);
}, [getFilteredEvents]);
const handleLoadMore = useCallback(async () => {
@@ -174,6 +477,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const hasMore = await mx.paginateEventTimeline(timeline, { backwards: true, limit: 100 });
setEvents(getFilteredEvents());
setCanLoadMore(hasMore);
setHasLoadedOnce(true);
} catch {
// silently swallow
} finally {
@@ -181,187 +485,288 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
}
}, [mx, room, getFilteredEvents]);
// Build lightbox items from current image/video events
const lightboxItems: LightboxItem[] = events
.filter((ev) => {
const c = ev.getContent();
return c.msgtype === MsgType.Image || c.msgtype === MsgType.Video;
})
.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,
encInfo: isEnc ? c.file : undefined,
mimeType: info?.mimetype ?? c.info?.mimetype,
body: c.body ?? '',
sender: getSenderDisplayName(room, ev.getSender() ?? ''),
ts: ev.getTs(),
};
});
return (
<Box
className={ContainerColor({ variant: 'Surface' })}
direction="Column"
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: '320px',
zIndex: 500,
borderLeft: `1px solid ${color.Surface.ContainerLine}`,
overflow: 'hidden',
}}
>
<Header
variant="Surface"
size="600"
<>
<Box
className={ContainerColor({ variant: 'Surface' })}
direction="Column"
style={{
flexShrink: 0,
paddingRight: config.space.S200,
paddingLeft: config.space.S300,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: '320px',
zIndex: 500,
borderLeft: `1px solid ${color.Surface.ContainerLine}`,
overflow: 'hidden',
}}
>
<Box grow="Yes" alignItems="Center" gap="200">
<Icon size="200" src={Icons.Photo} />
<Box grow="Yes">
<Text size="H5" truncate>
Media Gallery
</Text>
{/* Header */}
<Header
variant="Surface"
size="600"
style={{
flexShrink: 0,
paddingRight: config.space.S200,
paddingLeft: config.space.S300,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
}}
>
<Box grow="Yes" alignItems="Center" gap="200">
<Icon size="200" src={Icons.Photo} />
<Box grow="Yes">
<Text size="H5" truncate>
Media Gallery
</Text>
</Box>
{events.length > 0 && (
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
{events.length}
</Text>
)}
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(ref) => (
<IconButton ref={ref} variant="Background" aria-label="Close" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
aria-label="Close media gallery"
onClick={onClose}
>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Header>
{/* Tab bar */}
<Box
shrink="No"
gap="100"
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
>
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
<Button
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>
))}
</Box>
</Header>
<Box shrink="No" gap="100" style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}>
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
<TabButton key={t} label={TAB_LABELS[t]} active={tab === t} onClick={() => setTab(t)} />
))}
</Box>
<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 }}>
{loading && events.length === 0 && (
<Box justifyContent="Center" style={{ padding: config.space.S400 }}>
<Spinner />
</Box>
)}
{!loading && events.length === 0 && (
<Box justifyContent="Center" style={{ padding: config.space.S400 }}>
<Text size="T300" priority="300" align="Center">
{`No ${TAB_LABELS[tab].toLowerCase()} in loaded history. Use Load More to search further back.`}
</Text>
</Box>
)}
{(tab === 'image' || tab === 'video') && events.length > 0 && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: config.space.S100,
}}
>
{events.map((mEvent) => {
const content = mEvent.getContent();
const isEncrypted = !!content.file;
const mxcUrl: string | undefined = content.url ?? content.file?.url;
if (!mxcUrl) return null;
const body: string = content.body ?? '';
const thumbUrl = isEncrypted
? null
: (mxcUrlToHttp(mx, mxcUrl, useAuthentication, 120, 120, 'crop') ?? null);
const fullUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#';
return (
<ImageTile
key={mEvent.getId()}
thumbUrl={thumbUrl}
fullUrl={fullUrl}
body={body}
isEncrypted={isEncrypted}
/>
);
})}
</div>
)}
{tab === 'file' && events.length > 0 && (
<Box direction="Column" gap="100">
{events.map((mEvent) => {
const content = mEvent.getContent();
const mxcUrl: string | undefined = content.url ?? content.file?.url;
const body: string = content.body ?? 'Unnamed file';
const downloadUrl = mxcUrl
? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#')
: '#';
return (
{/* 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 */}
{(tab === 'image' || tab === 'video') && (
<>
{events.length === 0 && !loading && (
<Box
key={mEvent.getId()}
direction="Column"
alignItems="Center"
gap="200"
style={{ padding: config.space.S400 }}
>
<Icon src={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>
)}
{events.length > 0 && (
<div
style={{
padding: `${config.space.S200} ${config.space.S200}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: config.space.S100,
}}
>
<Icon size="200" src={Icons.File} />
<Box grow="Yes" style={{ overflow: 'hidden' }}>
<Text size="T300" truncate title={body}>
{body}
</Text>
</Box>
<IconButton
variant="Background"
size="300"
radii="300"
aria-label={`Download ${body}`}
onClick={() => {
const anchor = document.createElement('a');
anchor.href = downloadUrl;
anchor.download = body;
anchor.target = '_blank';
anchor.rel = 'noreferrer';
anchor.click();
}}
>
<Icon size="200" src={Icons.Download} />
</IconButton>
{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>
)}
</>
)}
{/* 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>
)}
)}
<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 = getSenderDisplayName(room, mEvent.getSender() ?? '');
const downloadUrl = mxcUrl
? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#')
: '#';
return (
<Box
key={mEvent.getId()}
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300,
border: `1px solid ${color.Surface.ContainerLine}`,
background: color.Surface.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="Background"
size="300"
radii="300"
aria-label={`Download ${body}`}
onClick={() => {
const a = document.createElement('a');
a.href = downloadUrl;
a.download = body;
a.target = '_blank';
a.rel = 'noreferrer';
a.click();
}}
>
<Icon size="200" src={Icons.Download} />
</IconButton>
</Box>
);
})}
</Box>
</>
)}
{canLoadMore && !loading && (
<Box justifyContent="Center" style={{ padding: `${config.space.S100} 0` }}>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleLoadMore}
{/* Load more / spinner */}
{loading && (
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
<Spinner />
</Box>
)}
{!loading && canLoadMore && (
<Box justifyContent="Center" style={{ padding: `${config.space.S100} 0` }}>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleLoadMore}
>
<Text size="B300">Load More History</Text>
</Button>
</Box>
)}
{!loading && !canLoadMore && hasLoadedOnce && (
<Text
size="T200"
priority="300"
align="Center"
style={{ padding: `${config.space.S100} 0` }}
>
<Text size="B300">Load More History</Text>
</Button>
</Box>
)}
{loading && events.length > 0 && (
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
<Spinner />
</Box>
)}
</Box>
</Scroll>
Beginning of history
</Text>
)}
</Box>
</Scroll>
</Box>
</Box>
</Box>
{/* Lightbox */}
{lightboxIndex !== null && (
<Lightbox
items={lightboxItems}
initialIndex={lightboxIndex}
useAuthentication={useAuthentication}
onClose={() => setLightboxIndex(null)}
/>
)}
</>
);
}