Files
cinny/src/app/utils/mediaGroups.test.ts
T
jaredandClaude Opus 5 bd8c79e0e6
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
feat(media): consecutive photos/videos render as one gallery grid (#137)
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
2026-09-20 00:48:13 -04:00

120 lines
3.6 KiB
TypeScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { GroupCandidate, findMediaRun, planMediaGroups, splitRun } from './mediaGroups';
const media = (
sender: string,
ts: number,
extra: Partial<GroupCandidate> = {},
): GroupCandidate => ({
sender,
ts,
kind: 'media',
hasRelation: false,
redacted: false,
mustEnd: false,
...extra,
});
const text = (sender: string, ts: number): GroupCandidate => ({
...media(sender, ts),
kind: 'other',
});
const skip = (ts: number): GroupCandidate => ({ ...media('x', ts), kind: 'skip' });
const seq = (items: GroupCandidate[]) => (i: number) => items[i];
describe('findMediaRun', () => {
it('collects contiguous same-sender media within the gap, from any member', () => {
const at = seq([
text('a', 0),
media('a', 1000),
media('a', 2000),
media('a', 3000),
text('a', 4000),
]);
assert.deepEqual(findMediaRun(at, 1), [1, 2, 3]);
assert.deepEqual(findMediaRun(at, 2), [1, 2, 3]);
assert.deepEqual(findMediaRun(at, 3), [1, 2, 3]);
});
it('breaks on a different sender, text in between, or a long gap', () => {
const at = seq([
media('a', 0),
media('b', 1000),
media('a', 2000),
text('a', 2500),
media('a', 3000),
media('a', 70_000),
]);
assert.deepEqual(findMediaRun(at, 0), [0]);
assert.deepEqual(findMediaRun(at, 2), [2]);
assert.deepEqual(findMediaRun(at, 4), [4]);
assert.deepEqual(findMediaRun(at, 5), [5]);
});
it('skips invisible filler such as reactions and edits', () => {
const at = seq([media('a', 0), skip(100), skip(200), media('a', 1000)]);
assert.deepEqual(findMediaRun(at, 0), [0, 3]);
});
it('never groups replies, thread messages, edits or redacted events', () => {
const at = seq([
media('a', 0),
media('a', 500, { hasRelation: true }),
media('a', 1000),
media('a', 1500, { redacted: true }),
]);
assert.deepEqual(findMediaRun(at, 0), [0]);
assert.deepEqual(findMediaRun(at, 1), []);
assert.deepEqual(findMediaRun(at, 2), [2]);
});
it('measures the gap between consecutive members, not from the first', () => {
const at = seq([media('a', 0), media('a', 50_000), media('a', 100_000)]);
assert.deepEqual(findMediaRun(at, 0), [0, 1, 2]);
});
});
describe('splitRun', () => {
it('caps group size and drops singles', () => {
const items = Array.from({ length: 12 }, (_, i) => media('a', i * 1000));
const groups = splitRun(
Array.from({ length: 12 }, (_, i) => i),
seq(items),
10,
);
assert.deepEqual(groups, [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[10, 11],
]);
assert.deepEqual(splitRun([0, 1, 2, 3], seq(items), 3), [[0, 1, 2]]);
});
it('closes a group at a member with reactions so they stay visible', () => {
const items = [
media('a', 0),
media('a', 1000, { mustEnd: true }),
media('a', 2000),
media('a', 3000),
];
assert.deepEqual(splitRun([0, 1, 2, 3], seq(items)), [
[0, 1],
[2, 3],
]);
});
});
describe('planMediaGroups', () => {
it('marks only the last member as the renderer', () => {
const at = seq([media('a', 0), media('a', 1000), media('a', 2000)]);
const plan = planMediaGroups(at, 1);
assert.equal(plan.get(0)?.renders, false);
assert.equal(plan.get(1)?.renders, false);
assert.equal(plan.get(2)?.renders, true);
assert.deepEqual(plan.get(2)?.members, [0, 1, 2]);
});
it('returns an empty plan for a lone image', () => {
assert.equal(planMediaGroups(seq([text('a', 0), media('a', 1000)]), 1).size, 0);
});
});