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,119 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* [Gitea #137] Client-side "gallery" grouping. Consecutive image/video events
|
||||
* from one sender, close together in time and with nothing else in between,
|
||||
* render as one grid. Nothing changes on the wire: every file is still its
|
||||
* own standard event, so other clients see N ordinary images.
|
||||
*/
|
||||
|
||||
export type GroupCandidate = {
|
||||
sender: string;
|
||||
ts: number;
|
||||
/** `media` can join; `skip` is invisible filler (reactions, edits…); anything else breaks. */
|
||||
kind: 'media' | 'skip' | 'other';
|
||||
/** Reply / thread / edit relation on the event itself — never grouped. */
|
||||
hasRelation: boolean;
|
||||
redacted: boolean;
|
||||
/** Reactions or a thread hang off this event: it may only be a group's last member. */
|
||||
mustEnd: boolean;
|
||||
};
|
||||
|
||||
export const MEDIA_GROUP_MAX_GAP_MS = 60_000;
|
||||
export const MEDIA_GROUP_CAP = 10;
|
||||
|
||||
const joinable = (c: GroupCandidate | undefined): c is GroupCandidate =>
|
||||
!!c && c.kind === 'media' && !c.hasRelation && !c.redacted;
|
||||
|
||||
/**
|
||||
* The maximal run of groupable media around index `i` (inclusive), as ordered
|
||||
* indices. `at` returns the candidate at an absolute index or undefined past
|
||||
* either end. Returns just `[i]` (or `[]` if `i` itself can't group) when
|
||||
* there is nothing to group with.
|
||||
*/
|
||||
export function findMediaRun(
|
||||
at: (index: number) => GroupCandidate | undefined,
|
||||
i: number,
|
||||
maxGapMs: number = MEDIA_GROUP_MAX_GAP_MS,
|
||||
): number[] {
|
||||
const me = at(i);
|
||||
if (!joinable(me)) return [];
|
||||
|
||||
const extend = (dir: 1 | -1): number[] => {
|
||||
const out: number[] = [];
|
||||
let last = me;
|
||||
let j = i + dir;
|
||||
for (;;) {
|
||||
const c = at(j);
|
||||
if (!c) break;
|
||||
if (c.kind === 'skip') {
|
||||
j += dir;
|
||||
continue;
|
||||
}
|
||||
if (!joinable(c) || c.sender !== me.sender) break;
|
||||
if (Math.abs(c.ts - last.ts) > maxGapMs) break;
|
||||
out.push(j);
|
||||
last = c;
|
||||
j += dir;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return [...extend(-1).reverse(), i, ...extend(1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut a run into groups: at most `cap` members each, and a member that has
|
||||
* reactions/threads (`mustEnd`) closes its group so those stay visible under
|
||||
* the rendered (last) event. Runs of one are dropped — they render normally.
|
||||
*/
|
||||
export function splitRun(
|
||||
run: number[],
|
||||
at: (index: number) => GroupCandidate | undefined,
|
||||
cap: number = MEDIA_GROUP_CAP,
|
||||
): number[][] {
|
||||
const groups: number[][] = [];
|
||||
let current: number[] = [];
|
||||
run.forEach((index) => {
|
||||
current.push(index);
|
||||
if (at(index)?.mustEnd || current.length >= cap) {
|
||||
groups.push(current);
|
||||
current = [];
|
||||
}
|
||||
});
|
||||
if (current.length) groups.push(current);
|
||||
return groups.filter((g) => g.length >= 2);
|
||||
}
|
||||
|
||||
export type GroupPlan = {
|
||||
/** Ordered member indices; the last one renders the grid. */
|
||||
members: number[];
|
||||
/** Whether `index` is the member that renders. */
|
||||
renders: boolean;
|
||||
};
|
||||
|
||||
/** Plan every group in the run containing `i`, keyed by member index. */
|
||||
export function planMediaGroups(
|
||||
at: (index: number) => GroupCandidate | undefined,
|
||||
i: number,
|
||||
opts: { maxGapMs?: number; cap?: number } = {},
|
||||
): Map<number, GroupPlan> {
|
||||
const plans = new Map<number, GroupPlan>();
|
||||
const run = findMediaRun(at, i, opts.maxGapMs);
|
||||
splitRun(run, at, opts.cap).forEach((members) => {
|
||||
const last = members[members.length - 1];
|
||||
members.forEach((index) => plans.set(index, { members, renders: index === last }));
|
||||
});
|
||||
return plans;
|
||||
}
|
||||
Reference in New Issue
Block a user