/** * [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 { const plans = new Map(); 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; }