feat(media): consecutive photos/videos render as one gallery grid (#137)
CI / Build & Quality Checks (push) Successful in 1m33s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 5m9s

Client-side only: every file is still its own standard m.image/m.video
event, so Element and friends keep seeing N plain images. In Lotus a run
of media from one sender — contiguous, ≤ 60 s apart, no reply/thread/edit
relation, up to 10 — renders once, at its last event, as a 2–4 column
grid of square thumbnails (blurhash placeholder, video play badge,
tap-to-load when media auto-load is off). A member with reactions or a
thread closes its group so those stay visible under the rendered event.

Tapping a tile opens the lightbox on just that group in send order
(←/→, zoom, download, jump). "Show separately" splits a group back into
individual messages for the session; "Show as gallery" undoes it.

Planning is lazy per render pass (utils/mediaGroups.ts, unit-tested):
the first media event met plans its whole run in both directions, so a
virtual window that starts mid-run agrees with one that starts before it.

Verified: 5 files dropped at once in an encrypted room — both sender and
recipient see one 5-tile grid with decrypted thumbnails; desktop + phone;
a reaction on photo 3 yields [1–3]+👍 and [4–5].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-20 00:48:13 -04:00
co-authored by Claude Opus 5
parent 4d4a76214a
commit bd8c79e0e6
6 changed files with 688 additions and 54 deletions
@@ -0,0 +1,118 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const Wrap = style([
DefaultReset,
{
display: 'block',
width: toRem(480),
maxWidth: '100%',
},
]);
export const Grid = style([
DefaultReset,
{
display: 'grid',
gap: toRem(3),
width: '100%',
borderRadius: config.radii.R400,
overflow: 'hidden',
},
]);
export const Cell = style([
DefaultReset,
{
position: 'relative',
aspectRatio: '1 / 1',
minWidth: 0,
padding: 0,
border: 'none',
cursor: 'pointer',
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
selectors: {
'&:focus-visible': {
outline: `${config.borderWidth.B600} solid ${color.Primary.Main}`,
outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
},
},
},
]);
export const CellImg = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
transition: 'transform 150ms',
selectors: {
[`${Cell}:hover &`]: {
transform: 'scale(1.03)',
},
},
},
]);
export const CellBlur = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
},
]);
export const PlayBadge = style([
DefaultReset,
{
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(36),
height: toRem(36),
borderRadius: config.radii.Pill,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
color: 'white',
pointerEvents: 'none',
},
]);
export const Footer = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
marginTop: config.space.S100,
},
]);
export const FooterButton = style([
DefaultReset,
{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'inherit',
textDecoration: 'underline',
textDecorationColor: 'transparent',
selectors: {
'&:hover, &:focus-visible': {
textDecorationColor: 'currentColor',
},
},
},
]);