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:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -14,19 +14,16 @@ import {
|
|||||||
color,
|
color,
|
||||||
config,
|
config,
|
||||||
} from 'folds';
|
} 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 { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
|
|
||||||
type GalleryTab = 'image' | 'video' | 'file';
|
type GalleryTab = 'image' | 'video' | 'file';
|
||||||
|
|
||||||
type MediaGalleryProps = {
|
|
||||||
room: Room;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TAB_LABELS: Record<GalleryTab, string> = {
|
const TAB_LABELS: Record<GalleryTab, string> = {
|
||||||
image: 'Images',
|
image: 'Images',
|
||||||
video: 'Videos',
|
video: 'Videos',
|
||||||
@@ -39,132 +36,438 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
|||||||
file: MsgType.File,
|
file: MsgType.File,
|
||||||
};
|
};
|
||||||
|
|
||||||
type ThumbState = 'loading' | 'error' | 'ok';
|
// ── decrypt hook ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ImageTile({
|
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
|
||||||
thumbUrl,
|
|
||||||
fullUrl,
|
function useDecryptedMediaUrl(
|
||||||
body,
|
mx: MatrixClient,
|
||||||
isEncrypted,
|
mxcUrl: string | undefined,
|
||||||
}: {
|
encInfo: IEncryptedFile | undefined,
|
||||||
thumbUrl: string | null;
|
useAuthentication: boolean,
|
||||||
fullUrl: string;
|
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;
|
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 (
|
return (
|
||||||
<a
|
<Box
|
||||||
href={isEncrypted ? undefined : fullUrl}
|
direction="Column"
|
||||||
target={isEncrypted ? undefined : '_blank'}
|
alignItems="Center"
|
||||||
rel="noreferrer"
|
justifyContent="Center"
|
||||||
title={body}
|
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={{
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
background: 'rgba(0,0,0,0.92)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
}}
|
||||||
|
>
|
||||||
|
{/* 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',
|
aspectRatio: '1',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
background: color.SurfaceVariant.Container,
|
background: color.SurfaceVariant.Container,
|
||||||
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
|
border: `1px solid ${hovered ? color.Primary.Main : color.SurfaceVariant.ContainerLine}`,
|
||||||
cursor: isEncrypted ? 'default' : 'pointer',
|
cursor: 'pointer',
|
||||||
textDecoration: 'none',
|
padding: 0,
|
||||||
position: 'relative',
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'border-color 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{thumbUrl && thumbState !== 'error' && (
|
{media.status === 'loading' && <Spinner size="200" />}
|
||||||
<img
|
{media.status === 'error' && (
|
||||||
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) && (
|
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
gap="100"
|
gap="100"
|
||||||
style={{
|
style={{ padding: config.space.S100 }}
|
||||||
padding: config.space.S100,
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Icon src={isEncrypted ? Icons.Lock : Icons.Photo} size="400" />
|
<Icon src={Icons.Photo} size="300" />
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
truncate
|
truncate
|
||||||
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.7 }}
|
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.5 }}
|
||||||
>
|
>
|
||||||
{body || (isEncrypted ? 'Encrypted' : 'Image')}
|
{body || 'Image'}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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({
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
label,
|
|
||||||
active,
|
function formatRelativeDate(ts: number): string {
|
||||||
onClick,
|
const now = Date.now();
|
||||||
}: {
|
const diff = now - ts;
|
||||||
label: string;
|
const mins = Math.floor(diff / 60000);
|
||||||
active: boolean;
|
if (mins < 60) return mins <= 1 ? 'Just now' : `${mins}m ago`;
|
||||||
onClick: () => void;
|
const hrs = Math.floor(diff / 3600000);
|
||||||
}) {
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
return (
|
const days = Math.floor(diff / 86400000);
|
||||||
<Button
|
if (days < 7) return `${days}d ago`;
|
||||||
size="300"
|
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
variant={active ? 'Primary' : 'Secondary'}
|
|
||||||
fill={active ? 'Soft' : 'None'}
|
|
||||||
radii="300"
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<Text size="B300">{label}</Text>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
const [tab, setTab] = useState<GalleryTab>('image');
|
const [tab, setTab] = useState<GalleryTab>('image');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||||
const [canLoadMore, setCanLoadMore] = useState(true);
|
const [canLoadMore, setCanLoadMore] = useState(true);
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const msgtype = TAB_MSGTYPES[tab];
|
const msgtype = TAB_MSGTYPES[tab];
|
||||||
|
|
||||||
const getFilteredEvents = useCallback(() => {
|
const getFilteredEvents = useCallback((): MatrixEvent[] => {
|
||||||
const timeline = room.getLiveTimeline();
|
const timeline = room.getLiveTimeline();
|
||||||
return timeline
|
return timeline
|
||||||
.getEvents()
|
.getEvents()
|
||||||
.filter((ev) => {
|
.filter((ev) => {
|
||||||
if (ev.isRedacted()) return false;
|
if (ev.isRedacted()) return false;
|
||||||
const content = ev.getContent();
|
const c = ev.getContent();
|
||||||
return ev.getType() === EventType.RoomMessage && content.msgtype === msgtype;
|
return ev.getType() === EventType.RoomMessage && c.msgtype === msgtype;
|
||||||
})
|
})
|
||||||
.slice()
|
.slice()
|
||||||
.reverse();
|
.reverse();
|
||||||
}, [room, msgtype]);
|
}, [room, msgtype]);
|
||||||
|
|
||||||
const [events, setEvents] = useState(() => getFilteredEvents());
|
const [events, setEvents] = useState<MatrixEvent[]>(() => getFilteredEvents());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEvents(getFilteredEvents());
|
setEvents(getFilteredEvents());
|
||||||
setCanLoadMore(true);
|
setCanLoadMore(true);
|
||||||
|
setHasLoadedOnce(false);
|
||||||
}, [getFilteredEvents]);
|
}, [getFilteredEvents]);
|
||||||
|
|
||||||
const handleLoadMore = useCallback(async () => {
|
const handleLoadMore = useCallback(async () => {
|
||||||
@@ -174,6 +477,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
const hasMore = await mx.paginateEventTimeline(timeline, { backwards: true, limit: 100 });
|
const hasMore = await mx.paginateEventTimeline(timeline, { backwards: true, limit: 100 });
|
||||||
setEvents(getFilteredEvents());
|
setEvents(getFilteredEvents());
|
||||||
setCanLoadMore(hasMore);
|
setCanLoadMore(hasMore);
|
||||||
|
setHasLoadedOnce(true);
|
||||||
} catch {
|
} catch {
|
||||||
// silently swallow
|
// silently swallow
|
||||||
} finally {
|
} finally {
|
||||||
@@ -181,187 +485,288 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
}
|
}
|
||||||
}, [mx, room, getFilteredEvents]);
|
}, [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 (
|
return (
|
||||||
<Box
|
<>
|
||||||
className={ContainerColor({ variant: 'Surface' })}
|
<Box
|
||||||
direction="Column"
|
className={ContainerColor({ variant: 'Surface' })}
|
||||||
style={{
|
direction="Column"
|
||||||
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"
|
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
position: 'fixed',
|
||||||
paddingRight: config.space.S200,
|
top: 0,
|
||||||
paddingLeft: config.space.S300,
|
right: 0,
|
||||||
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
|
bottom: 0,
|
||||||
|
width: '320px',
|
||||||
|
zIndex: 500,
|
||||||
|
borderLeft: `1px solid ${color.Surface.ContainerLine}`,
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
{/* Header */}
|
||||||
<Icon size="200" src={Icons.Photo} />
|
<Header
|
||||||
<Box grow="Yes">
|
variant="Surface"
|
||||||
<Text size="H5" truncate>
|
size="600"
|
||||||
Media Gallery
|
style={{
|
||||||
</Text>
|
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>
|
</Box>
|
||||||
<TooltipProvider
|
</Header>
|
||||||
position="Bottom"
|
|
||||||
align="End"
|
{/* Tab bar */}
|
||||||
offset={4}
|
<Box
|
||||||
tooltip={
|
shrink="No"
|
||||||
<Tooltip>
|
gap="100"
|
||||||
<Text>Close</Text>
|
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
|
||||||
</Tooltip>
|
>
|
||||||
}
|
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
|
||||||
>
|
<Button
|
||||||
{(triggerRef) => (
|
key={t}
|
||||||
<IconButton
|
size="300"
|
||||||
ref={triggerRef}
|
variant={tab === t ? 'Primary' : 'Secondary'}
|
||||||
variant="Background"
|
fill={tab === t ? 'Soft' : 'None'}
|
||||||
aria-label="Close media gallery"
|
radii="300"
|
||||||
onClick={onClose}
|
onClick={() => setTab(t)}
|
||||||
>
|
>
|
||||||
<Icon src={Icons.Cross} />
|
<Text size="B300">{TAB_LABELS[t]}</Text>
|
||||||
</IconButton>
|
</Button>
|
||||||
)}
|
))}
|
||||||
</TooltipProvider>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Header>
|
|
||||||
|
|
||||||
<Box shrink="No" gap="100" style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}>
|
{/* Content */}
|
||||||
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
|
<Box grow="Yes" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||||
<TabButton key={t} label={TAB_LABELS[t]} active={tab === t} onClick={() => setTab(t)} />
|
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
|
||||||
))}
|
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
|
||||||
</Box>
|
{/* Image / video grid */}
|
||||||
|
{(tab === 'image' || tab === 'video') && (
|
||||||
<Box grow="Yes" style={{ position: 'relative', overflow: 'hidden' }}>
|
<>
|
||||||
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
|
{events.length === 0 && !loading && (
|
||||||
<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 (
|
|
||||||
<Box
|
<Box
|
||||||
key={mEvent.getId()}
|
direction="Column"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
gap="200"
|
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={{
|
style={{
|
||||||
padding: `${config.space.S200} ${config.space.S200}`,
|
display: 'grid',
|
||||||
borderRadius: config.radii.R300,
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
gap: config.space.S100,
|
||||||
background: color.Surface.Container,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon size="200" src={Icons.File} />
|
{events.map((mEvent, idx) => {
|
||||||
<Box grow="Yes" style={{ overflow: 'hidden' }}>
|
const c = mEvent.getContent();
|
||||||
<Text size="T300" truncate title={body}>
|
const isEnc = !!c.file;
|
||||||
{body}
|
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
||||||
</Text>
|
// For thumbnails: prefer thumbnail_file > file; for non-enc: use thumbnail URL
|
||||||
</Box>
|
const thumbMxc: string | undefined = isEnc
|
||||||
<IconButton
|
? (info?.thumbnail_file?.url ?? c.file?.url)
|
||||||
variant="Background"
|
: (info?.thumbnail_url ?? c.url);
|
||||||
size="300"
|
const thumbEncInfo: IEncryptedFile | undefined = isEnc
|
||||||
radii="300"
|
? (info?.thumbnail_file ?? c.file)
|
||||||
aria-label={`Download ${body}`}
|
: undefined;
|
||||||
onClick={() => {
|
const mimeType: string | undefined =
|
||||||
const anchor = document.createElement('a');
|
info?.thumbnail_file != null
|
||||||
anchor.href = downloadUrl;
|
? (info.thumbnail_info?.mimetype ?? 'image/jpeg')
|
||||||
anchor.download = body;
|
: (info?.mimetype ?? c.info?.mimetype);
|
||||||
anchor.target = '_blank';
|
if (!thumbMxc) return null;
|
||||||
anchor.rel = 'noreferrer';
|
return (
|
||||||
anchor.click();
|
<GalleryTile
|
||||||
}}
|
key={mEvent.getId()}
|
||||||
>
|
mxcUrl={thumbMxc}
|
||||||
<Icon size="200" src={Icons.Download} />
|
encInfo={thumbEncInfo}
|
||||||
</IconButton>
|
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">
|
||||||
</Box>
|
{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 && (
|
{/* Load more / spinner */}
|
||||||
<Box justifyContent="Center" style={{ padding: `${config.space.S100} 0` }}>
|
{loading && (
|
||||||
<Button
|
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
|
||||||
size="300"
|
<Spinner />
|
||||||
variant="Secondary"
|
</Box>
|
||||||
fill="Soft"
|
)}
|
||||||
radii="300"
|
{!loading && canLoadMore && (
|
||||||
onClick={handleLoadMore}
|
<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>
|
Beginning of history
|
||||||
</Button>
|
</Text>
|
||||||
</Box>
|
)}
|
||||||
)}
|
</Box>
|
||||||
|
</Scroll>
|
||||||
{loading && events.length > 0 && (
|
</Box>
|
||||||
<Box justifyContent="Center" style={{ padding: config.space.S200 }}>
|
|
||||||
<Spinner />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Scroll>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
|
||||||
|
{/* Lightbox */}
|
||||||
|
{lightboxIndex !== null && (
|
||||||
|
<Lightbox
|
||||||
|
items={lightboxItems}
|
||||||
|
initialIndex={lightboxIndex}
|
||||||
|
useAuthentication={useAuthentication}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user