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
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:
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Icon, Icons, Spinner, Text } from 'folds';
|
||||
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
|
||||
import { BlurhashCanvas } from 'react-blurhash';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { getThumbMxc, useDecryptedMediaUrl } from '../MediaGallery';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
|
||||
import * as css from './MediaGroupGrid.css';
|
||||
|
||||
/** Column count for `n` tiles: 2 → 2, 3 → 3, 4 → 2×2, 5–6 → 3, 7+ → 4. */
|
||||
export const gridColumns = (n: number): number => {
|
||||
if (n <= 2) return 2;
|
||||
if (n === 3) return 3;
|
||||
if (n === 4) return 2;
|
||||
if (n <= 6) return 3;
|
||||
return 4;
|
||||
};
|
||||
|
||||
/** "5 photos" / "2 videos" / "6 items". */
|
||||
export const describeGroup = (events: MatrixEvent[]): string => {
|
||||
const videos = events.filter((e) => e.getContent().msgtype === MsgType.Video).length;
|
||||
const n = events.length;
|
||||
if (videos === 0) return `${n} photos`;
|
||||
if (videos === n) return `${n} videos`;
|
||||
return `${n} items`;
|
||||
};
|
||||
|
||||
function Cell({
|
||||
mEvent,
|
||||
load,
|
||||
onOpen,
|
||||
}: {
|
||||
mEvent: MatrixEvent;
|
||||
load: boolean;
|
||||
onOpen: (eventId: string) => void;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const content = mEvent.getContent();
|
||||
const isVideo = content.msgtype === MsgType.Video;
|
||||
const thumbMxc = getThumbMxc(mEvent);
|
||||
const info = content.info as Record<string, unknown> | undefined;
|
||||
const encInfo = content.file
|
||||
? ((info?.thumbnail_file as typeof content.file | undefined) ?? content.file)
|
||||
: undefined;
|
||||
const mimeType =
|
||||
(info?.thumbnail_info as { mimetype?: string } | undefined)?.mimetype ??
|
||||
(info?.mimetype as string | undefined);
|
||||
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME] as string | undefined);
|
||||
const media = useDecryptedMediaUrl(mx, thumbMxc, encInfo, useAuthentication, mimeType, load);
|
||||
const body = typeof content.body === 'string' ? content.body : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.Cell}
|
||||
aria-label={body || (isVideo ? 'Video' : 'Image')}
|
||||
onClick={() => onOpen(mEvent.getId() ?? '')}
|
||||
>
|
||||
{blurHash && media.status !== 'ok' && (
|
||||
<BlurhashCanvas className={css.CellBlur} hash={blurHash} width={32} height={32} punch={1} />
|
||||
)}
|
||||
{load && media.status === 'loading' && <Spinner size="200" />}
|
||||
{media.status === 'error' && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
|
||||
{!load && !blurHash && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
|
||||
{media.status === 'ok' && <img src={media.url} alt="" className={css.CellImg} />}
|
||||
{isVideo && (
|
||||
<span className={css.PlayBadge}>
|
||||
<Icon src={Icons.Play} size="200" filled />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type MediaGroupGridProps = {
|
||||
events: MatrixEvent[];
|
||||
mediaAutoLoad: boolean;
|
||||
onOpen: (eventId: string) => void;
|
||||
onShowSeparately: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #137] Several consecutive image/video events from one sender shown
|
||||
* as one grid. Each tile opens the room's shared lightbox at that event, so
|
||||
* ←/→ walk through the group (and beyond). Purely a render-time grouping.
|
||||
*/
|
||||
export function MediaGroupGrid({
|
||||
events,
|
||||
mediaAutoLoad,
|
||||
onOpen,
|
||||
onShowSeparately,
|
||||
}: MediaGroupGridProps) {
|
||||
const [load, setLoad] = useState(mediaAutoLoad);
|
||||
const columns = gridColumns(events.length);
|
||||
|
||||
return (
|
||||
<div className={css.Wrap}>
|
||||
<div
|
||||
className={css.Grid}
|
||||
role="group"
|
||||
aria-label={describeGroup(events)}
|
||||
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
|
||||
onClick={load ? undefined : () => setLoad(true)}
|
||||
>
|
||||
{events.map((ev) => (
|
||||
<Cell
|
||||
key={ev.getId()}
|
||||
mEvent={ev}
|
||||
load={load}
|
||||
onOpen={load ? onOpen : () => setLoad(true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={css.Footer}>
|
||||
<Text size="T200" priority="300">
|
||||
{describeGroup(events)}
|
||||
{!load && ' · tap to load'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
·
|
||||
</Text>
|
||||
<Text
|
||||
as="button"
|
||||
size="T200"
|
||||
priority="300"
|
||||
className={css.FooterButton}
|
||||
onClick={onShowSeparately}
|
||||
>
|
||||
Show separately
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shown under the last of a gallery the user split up, to put it back together. */
|
||||
export function RegroupChip({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<div className={css.Footer}>
|
||||
<Text as="button" size="T200" priority="300" className={css.FooterButton} onClick={onClick}>
|
||||
Show as gallery
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user