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
119 lines
2.3 KiB
TypeScript
119 lines
2.3 KiB
TypeScript
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',
|
|
},
|
|
},
|
|
},
|
|
]);
|