refactor(media-gallery): redesign chrome to match native folds conventions
The Media Gallery panel worked but didn't look like a first-party Cinny
drawer. Redesign the chrome to match MembersDrawer / Saved Messages and the
PolicyListViewer tab precedent:
- panel + header: Surface -> Background variant; header uses Text size="H4"
and a plain close IconButton (dropped the bespoke tooltip-wrapped button)
- tabs: moved into a bordered toolbar strip; adopt the repo's
variant={active?'Primary':'Secondary'} fill={active?'Solid':'Soft'} pattern
and show per-tab counts (Images (N) / Videos (N) / Files (N))
- month grouping: replaced the centered "lines + label" divider with a
left-aligned group label (the Cinny group-label pattern)
- thumbnail tiles: hover/focus border + caption overlay are now CSS-driven
(:hover / :focus-visible) instead of React state, and live in
MediaGallery.css.ts; grid + file rows tokenized
- caption overlay also reveals on keyboard focus (a11y)
All styling consolidated into MediaGallery.css.ts; no inline grid/tile styles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ export const MediaGalleryDrawer = style({
|
||||
overflow: 'hidden',
|
||||
borderLeftWidth: config.borderWidth.B300,
|
||||
borderLeftStyle: 'solid',
|
||||
borderLeftColor: color.Surface.ContainerLine,
|
||||
borderLeftColor: color.Background.ContainerLine,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
width: '100%',
|
||||
@@ -25,7 +25,102 @@ export const MediaGalleryDrawer = style({
|
||||
|
||||
export const MediaGalleryHeader = style({
|
||||
flexShrink: 0,
|
||||
paddingRight: config.space.S200,
|
||||
paddingLeft: config.space.S300,
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
});
|
||||
|
||||
export const MediaGalleryTabs = style({
|
||||
flexShrink: 0,
|
||||
padding: config.space.S200,
|
||||
gap: config.space.S100,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
borderBottomStyle: 'solid',
|
||||
borderBottomColor: color.Background.ContainerLine,
|
||||
});
|
||||
|
||||
export const MediaGalleryContent = style({
|
||||
padding: config.space.S200,
|
||||
});
|
||||
|
||||
export const MediaGalleryGroup = style({
|
||||
marginBottom: config.space.S300,
|
||||
});
|
||||
|
||||
export const MediaGalleryGroupLabel = style({
|
||||
padding: `${config.space.S200} ${config.space.S100}`,
|
||||
});
|
||||
|
||||
export const MediaGalleryGrid = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: config.space.S100,
|
||||
});
|
||||
|
||||
export const GalleryTile = style({
|
||||
position: 'relative',
|
||||
aspectRatio: '1',
|
||||
overflow: 'hidden',
|
||||
borderRadius: config.radii.R300,
|
||||
background: color.SurfaceVariant.Container,
|
||||
borderWidth: config.borderWidth.B300,
|
||||
borderStyle: 'solid',
|
||||
borderColor: color.SurfaceVariant.ContainerLine,
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'border-color 100ms ease-in-out',
|
||||
selectors: {
|
||||
'&:hover, &:focus-visible': {
|
||||
borderColor: color.Primary.Main,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const GalleryTileImg = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center top',
|
||||
display: 'block',
|
||||
});
|
||||
|
||||
// Dark scrim is intentional: it overlays arbitrary media, so a theme surface
|
||||
// token would not guarantee legibility of the sender/date caption.
|
||||
export const GalleryTileOverlay = style({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.72) 0%, transparent 55%)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 100ms ease-in-out',
|
||||
pointerEvents: 'none',
|
||||
selectors: {
|
||||
[`${GalleryTile}:hover &, ${GalleryTile}:focus-visible &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const GalleryTileCaption = style({
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
});
|
||||
|
||||
export const GalleryVideoBadge = style({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: toRem(28),
|
||||
height: toRem(28),
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -383,7 +383,6 @@ function GalleryTile({
|
||||
mimeType,
|
||||
nearViewport,
|
||||
);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const relDate = formatRelativeDate(ts);
|
||||
|
||||
return (
|
||||
@@ -392,22 +391,7 @@ function GalleryTile({
|
||||
type="button"
|
||||
aria-label={body || (isVideo ? 'Video' : '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 ${hovered ? color.Primary.Main : color.SurfaceVariant.ContainerLine}`,
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
className={css.GalleryTile}
|
||||
>
|
||||
{media.status === 'loading' && <Spinner size="200" />}
|
||||
{media.status === 'error' && (
|
||||
@@ -421,71 +405,30 @@ function GalleryTile({
|
||||
<Text
|
||||
size="T200"
|
||||
truncate
|
||||
style={{ maxWidth: '100%', textAlign: 'center', opacity: 0.5 }}
|
||||
priority="300"
|
||||
style={{ maxWidth: '100%', textAlign: 'center' }}
|
||||
>
|
||||
{body}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{media.status === 'ok' && (
|
||||
<img
|
||||
src={media.url}
|
||||
alt={body}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center top',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{media.status === 'ok' && <img src={media.url} alt={body} className={css.GalleryTileImg} />}
|
||||
|
||||
{/* 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',
|
||||
}}
|
||||
>
|
||||
<div className={css.GalleryVideoBadge}>
|
||||
<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.72) 0%, transparent 55%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
}}
|
||||
>
|
||||
{/* Hover/focus caption overlay (CSS-driven) */}
|
||||
{media.status === 'ok' && (
|
||||
<div className={css.GalleryTileOverlay}>
|
||||
<div className={css.GalleryTileCaption}>
|
||||
<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 }}>
|
||||
<Text size="T200" style={{ color: 'rgba(255,255,255,0.65)' }}>
|
||||
{relDate}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -497,30 +440,16 @@ function GalleryTile({
|
||||
|
||||
// ── Month separator ───────────────────────────────────────────────────────────
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab button ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
@@ -528,11 +457,14 @@ function TabButton({
|
||||
<Button
|
||||
size="300"
|
||||
variant={active ? 'Primary' : 'Secondary'}
|
||||
fill={active ? 'Soft' : 'None'}
|
||||
fill={active ? 'Solid' : 'Soft'}
|
||||
radii="300"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Text size="B300">{label}</Text>
|
||||
<Text size="B300">
|
||||
{label}
|
||||
{count > 0 ? ` (${count})` : ''}
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -657,6 +589,25 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
};
|
||||
});
|
||||
|
||||
// Per-tab counts for the tab labels (single pass over loaded timeline)
|
||||
const tabCounts = useMemo(() => {
|
||||
const counts: Record<GalleryTab, number> = { image: 0, video: 0, file: 0 };
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
.forEach((ev) => {
|
||||
if (ev.isRedacted() || ev.getType() !== EventType.RoomMessage) return;
|
||||
const mt = ev.getContent().msgtype;
|
||||
if (mt === MsgType.Image) counts.image += 1;
|
||||
else if (mt === MsgType.Video) counts.video += 1;
|
||||
else if (mt === MsgType.File) counts.file += 1;
|
||||
});
|
||||
return counts;
|
||||
// `events` is intentional: it changes when more history is paginated in, so
|
||||
// the counts stay in sync with the loaded window (it isn't read directly).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room, events]);
|
||||
|
||||
// Group image/video events by month for the grid
|
||||
type MonthGroup = { label: string; events: MatrixEvent[] };
|
||||
const monthGroups: MonthGroup[] = [];
|
||||
@@ -673,52 +624,31 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Surface' }))}
|
||||
className={classNames(css.MediaGalleryDrawer, ContainerColor({ variant: 'Background' }))}
|
||||
direction="Column"
|
||||
>
|
||||
{/* Header */}
|
||||
<Header variant="Surface" size="600" className={css.MediaGalleryHeader}>
|
||||
<Header variant="Background" size="600" className={css.MediaGalleryHeader}>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon size="200" src={Icons.Photo} />
|
||||
<Box grow="Yes">
|
||||
<Text size="H5" truncate>
|
||||
<Text size="H4" 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>
|
||||
<IconButton size="300" radii="300" aria-label="Close media gallery" onClick={onClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
{/* Tabs */}
|
||||
<Box
|
||||
shrink="No"
|
||||
gap="100"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
|
||||
>
|
||||
<Box className={css.MediaGalleryTabs} shrink="No">
|
||||
{(Object.keys(TAB_LABELS) as GalleryTab[]).map((t) => (
|
||||
<TabButton
|
||||
key={t}
|
||||
label={TAB_LABELS[t]}
|
||||
count={tabCounts[t]}
|
||||
active={tab === t}
|
||||
onClick={() => handleTabChange(t)}
|
||||
/>
|
||||
@@ -728,7 +658,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
{/* Content */}
|
||||
<Box grow="Yes" style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
<Scroll variant="Background" size="300" visibility="Hover" hideTrack>
|
||||
<Box direction="Column" style={{ padding: config.space.S200, gap: 0 }}>
|
||||
<Box className={css.MediaGalleryContent} direction="Column">
|
||||
{/* ── Image / video grid ── */}
|
||||
{(tab === 'image' || tab === 'video') && (
|
||||
<>
|
||||
@@ -752,20 +682,14 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
{(() => {
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<Box key={group.label} direction="Column" className={css.MediaGalleryGroup}>
|
||||
{/* Month label — only shown when there are multiple groups */}
|
||||
{monthGroups.length > 1 && (
|
||||
<Text className={css.MediaGalleryGroupLabel} size="L400" priority="300">
|
||||
{group.label}
|
||||
</Text>
|
||||
)}
|
||||
<div className={css.MediaGalleryGrid}>
|
||||
{group.events.map((mEvent) => {
|
||||
const c = mEvent.getContent();
|
||||
const isEnc = !!c.file;
|
||||
@@ -843,8 +767,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
style={{
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderRadius: config.radii.R300,
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
background: color.Surface.Container,
|
||||
background: color.SurfaceVariant.Container,
|
||||
}}
|
||||
>
|
||||
<Icon size="300" src={Icons.File} />
|
||||
@@ -862,7 +785,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
variant="Background"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label={`Download ${body}`}
|
||||
|
||||
Reference in New Issue
Block a user