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
107 lines
3.3 KiB
TypeScript
107 lines
3.3 KiB
TypeScript
/**
|
|
* [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;
|
|
}
|