69249d1746
- 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>
356 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|