Files
cinny/src/app/features/room/MediaGallery.tsx
T
jared 69249d1746 fix: media gallery thumbnails — skip auth URL, handle encrypted media
- Use useAuthentication=false for thumbnail requests: the v1 authenticated
  URL adds allow_redirect=true which Synapse rejects with 400
- Encrypted events (content.file set) show a lock+filename placeholder
  since server can't thumbnail encrypted blobs
- Unencrypted thumbnails add onError handler to hide broken images gracefully

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:13:18 -04:00

356 lines
11 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import {
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Scroll,
Spinner,
Text,
Tooltip,
TooltipProvider,
config,
} from 'folds';
import { EventType, MsgType, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { 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',
file: 'Files',
};
const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
image: MsgType.Image,
video: MsgType.Video,
file: MsgType.File,
};
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>
);
}
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [tab, setTab] = useState<GalleryTab>('image');
const [loading, setLoading] = useState(false);
const [canLoadMore, setCanLoadMore] = useState(true);
const msgtype = TAB_MSGTYPES[tab];
// Read already-decrypted events from the live timeline (works for E2EE rooms)
const getFilteredEvents = useCallback(() => {
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;
})
.slice()
.reverse(); // newest first
}, [room, msgtype]);
const [events, setEvents] = useState(() => getFilteredEvents());
useEffect(() => {
setEvents(getFilteredEvents());
setCanLoadMore(true);
}, [getFilteredEvents]);
const handleLoadMore = useCallback(async () => {
setLoading(true);
try {
const timeline = room.getLiveTimeline();
const hasMore = await mx.paginateEventTimeline(timeline, { backwards: true, limit: 100 });
setEvents(getFilteredEvents());
setCanLoadMore(hasMore);
} catch {
// silently swallow
} finally {
setLoading(false);
}
}, [mx, room, getFilteredEvents]);
return (
<Box
className={ContainerColor({ variant: 'Surface' })}
direction="Column"
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: '320px',
zIndex: 500,
background: 'var(--bg-surface)',
overflow: 'hidden',
}}
>
{/* Header */}
<Header
variant="Background"
size="600"
style={{
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
}}
>
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Icon size="200" src={Icons.Photo} />
<Text size="H5" truncate>
Media
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<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>
</Box>
</Box>
</Header>
{/* Tab bar */}
<Box
shrink="No"
gap="100"
style={{
padding: config.space.S200,
}}
>
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
<TabButton key={t} label={TAB_LABELS[t]} active={tab === t} onClick={() => setTab(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 }}>
{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()} found in this room.`}
</Text>
</Box>
)}
{/* Image/Video grid */}
{(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 ?? '';
// Use unauthenticated thumbnail URL — the v1 authenticated endpoint adds
// allow_redirect=true which Synapse rejects with 400.
const thumbUrl = isEncrypted
? null
: (mxcUrlToHttp(mx, mxcUrl, false, 120, 120, 'crop') ?? null);
const fullUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#';
return (
<a
key={mEvent.getId()}
href={isEncrypted ? '#' : fullUrl}
target={isEncrypted ? undefined : '_blank'}
rel="noreferrer"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
aspectRatio: '1',
overflow: 'hidden',
borderRadius: config.radii.R300,
background: 'var(--bg-surface-low)',
cursor: isEncrypted ? 'default' : 'pointer',
}}
title={body}
>
{thumbUrl ? (
<img
src={thumbUrl}
alt={body}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
) : (
<Box
direction="Column"
alignItems="Center"
gap="100"
style={{ padding: config.space.S100 }}
>
<Icon src={isEncrypted ? Icons.Lock : Icons.Photo} size="400" />
<Text
size="T200"
truncate
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.7 }}
>
{body || (isEncrypted ? 'Encrypted' : 'Image')}
</Text>
</Box>
)}
</a>
);
})}
</div>
)}
{/* File list */}
{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 (
<Box
key={mEvent.getId()}
alignItems="Center"
gap="200"
style={{
padding: `${config.space.S100} ${config.space.S200}`,
borderRadius: config.radii.R300,
background: 'var(--bg-surface)',
overflow: 'hidden',
}}
>
<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>
</Box>
);
})}
</Box>
)}
{/* Load more */}
{canLoadMore && !loading && events.length > 0 && (
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleLoadMore}
>
<Text size="B300">Load more</Text>
</Button>
</Box>
)}
{/* Loading more spinner */}
{loading && events.length > 0 && (
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
<Spinner />
</Box>
)}
</Box>
</Scroll>
</Box>
</Box>
);
}