Files
cinny/src/app/utils/mediaGroups.ts
T

107 lines
3.3 KiB
TypeScript
Raw Normal View History

/**
* [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;
}