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

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:
2026-09-20 00:48:13 -04:00
co-authored by Claude Opus 5
parent 4d4a76214a
commit bd8c79e0e6
6 changed files with 688 additions and 54 deletions
+2 -2
View File
@@ -58,7 +58,7 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
function useDecryptedMediaUrl(
export function useDecryptedMediaUrl(
mx: MatrixClient,
mxcUrl: string | undefined,
encInfo: IEncryptedFile | undefined,
@@ -154,7 +154,7 @@ function getSenderName(room: Room, userId: string): string {
// the grid and the lightbox must use this so their positional indices stay in
// lockstep — otherwise a tile skipped for lack of a thumb would shift the
// lightbox and open the wrong media.
function getThumbMxc(mEvent: MatrixEvent): string | undefined {
export function getThumbMxc(mEvent: MatrixEvent): string | undefined {
const c = mEvent.getContent();
const isEnc = !!c.file;
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
+195 -52
View File
@@ -19,6 +19,7 @@ import {
IContent,
MatrixClient,
MatrixEvent,
MsgType,
RelationType,
Room,
RoomEvent,
@@ -90,6 +91,9 @@ import {
reactionOrEditEvent,
} from '../../utils/room';
import { getLastEditDiff } from '../../utils/editDiff';
import { GroupCandidate, GroupPlan, planMediaGroups } from '../../utils/mediaGroups';
import { MediaGroupGrid, RegroupChip } from './message/MediaGroupGrid';
import { Lightbox, getThumbMxc, toLightboxItems } from './MediaGallery';
import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
@@ -181,6 +185,9 @@ export const getFirstLinkedTimeline = (
return getFirstLinkedTimeline(linkedTm, direction);
};
/** [Gitea #137] Galleries the user asked to see as separate messages (keyed by the group's last event id). */
const separatedGalleries = new Set<string>();
export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
const timelines: EventTimeline[] = [];
@@ -470,6 +477,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// Read positions are computed once in Room.tsx and provided via ReadPositionsContext
// so both RoomTimeline and ThreadTimeline consume the same value (Gitea #38).
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
// [Gitea #137] Re-render after "Show separately" (the Set itself is module-level).
const [, setSeparatedTick] = useState(0);
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
@@ -529,6 +538,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
// [Gitea #219] Timeline images open the shared media lightbox at that event.
const [lightboxEventId, setLightboxEventId] = useState<string | undefined>();
// [Gitea #137] Opened from a gallery grid: the viewer walks that group in send order.
const [lightboxGroup, setLightboxGroup] = useState<MatrixEvent[] | undefined>();
const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
@@ -1167,10 +1178,26 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const { t } = useTranslation();
const renderMatrixEvent = useMatrixEventRenderer<
[string, MatrixEvent, number, EventTimelineSet, boolean]
[
string,
MatrixEvent,
number,
EventTimelineSet,
boolean,
MatrixEvent[] | undefined,
string | undefined,
]
>(
{
[MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
[MessageEvent.RoomMessage]: (
mEventId,
mEvent,
item,
timelineSet,
collapse,
mediaGroup,
regroupId,
) => {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0;
@@ -1263,24 +1290,47 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
) : (
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
getContent={getContent}
) : mediaGroup ? (
<MediaGroupGrid
events={mediaGroup}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
mEvent={mEvent}
onOpen={(id) => {
setLightboxGroup(mediaGroup);
setLightboxEventId(id);
}}
onShowSeparately={() => {
separatedGalleries.add(mEventId);
setSeparatedTick((n) => n + 1);
}}
/>
) : (
<>
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
mEvent={mEvent}
/>
{regroupId && (
<RegroupChip
onClick={() => {
separatedGalleries.delete(regroupId);
setSeparatedTick((n) => n + 1);
}}
/>
)}
</>
)}
</Message>
);
@@ -2185,30 +2235,100 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
let isPrevRendered = false;
let newDivider = false;
let dayDivider = false;
const eventRenderer = (item: number) => {
// Perf-5: O(T) → O(log T) via precomputed segments
let eventTimeline: EventTimeline | undefined;
let baseIndex = 0;
{
let lo = 0;
let hi = timelineSegments.length - 1;
while (lo <= hi) {
// eslint-disable-next-line no-bitwise
const mid = (lo + hi) >>> 1;
const [base, len] = timelineSegments[mid];
if (item < base) {
hi = mid - 1;
} else if (item >= base + len) {
lo = mid + 1;
} else {
eventTimeline = timelineSegments[mid][2];
baseIndex = base;
break;
}
// Perf-5: O(T) → O(log T) via precomputed segments
const resolveItem = (
item: number,
): { eventTimeline: EventTimeline; baseIndex: number } | undefined => {
let lo = 0;
let hi = timelineSegments.length - 1;
while (lo <= hi) {
// eslint-disable-next-line no-bitwise
const mid = (lo + hi) >>> 1;
const [base, len] = timelineSegments[mid];
if (item < base) {
hi = mid - 1;
} else if (item >= base + len) {
lo = mid + 1;
} else {
return { eventTimeline: timelineSegments[mid][2], baseIndex: base };
}
}
if (!eventTimeline) return null;
const timelineSet = eventTimeline?.getTimelineSet();
return undefined;
};
const eventAt = (
item: number,
): { mEvent: MatrixEvent; timelineSet: EventTimelineSet } | undefined => {
const seg = resolveItem(item);
if (!seg) return undefined;
const mEvent = getTimelineEvent(
seg.eventTimeline,
getTimelineRelativeIndex(item, seg.baseIndex),
);
return mEvent ? { mEvent, timelineSet: seg.eventTimeline.getTimelineSet() } : undefined;
};
// [Gitea #137] Gallery grouping is planned lazily per render pass: the first
// media event we meet plans its whole run (looking both ways, so a virtual
// window that starts mid-run still agrees), and the plan is reused for the
// run's other members.
const groupPlans = new Map<number, GroupPlan | null>();
const candidateAt = (index: number): GroupCandidate | undefined => {
const found = eventAt(index);
if (!found) return undefined;
const { mEvent: ev, timelineSet } = found;
const sender = ev.getSender() ?? '';
const base = { sender, ts: ev.getTs(), hasRelation: false, redacted: false, mustEnd: false };
if (
reactionOrEditEvent(ev) ||
ev.getType() === 'm.room.redaction' ||
ignoredUsersSet.has(sender)
)
return { ...base, kind: 'skip' };
if (ev.getType() === StateEvent.RoomMember && hideMembershipEvents)
return { ...base, kind: 'skip' };
const msgtype = ev.getContent().msgtype;
const isMedia =
ev.getType() === MessageEvent.RoomMessage &&
(msgtype === MsgType.Image || msgtype === MsgType.Video) &&
!!getThumbMxc(ev);
if (!isMedia) return { ...base, kind: 'other' };
const id = ev.getId() ?? '';
const reactions = getEventReactions(timelineSet, id)?.getSortedAnnotationsByKey();
const hasThread =
ev.getThread() !== undefined ||
ev.getServerAggregatedRelation(RelationType.Thread) !== undefined;
return {
...base,
kind: 'media',
hasRelation: !!ev.getContent()['m.relates_to'],
redacted: ev.isRedacted(),
mustEnd: (reactions?.length ?? 0) > 0 || hasThread,
};
};
const mediaGroupFor = (
item: number,
): { hidden: boolean; events?: MatrixEvent[]; regroup?: string } | undefined => {
if (!groupPlans.has(item)) {
if (candidateAt(item)?.kind !== 'media') return undefined;
const plans = planMediaGroups(candidateAt, item);
plans.forEach((plan, index) => groupPlans.set(index, plan));
if (!plans.has(item)) groupPlans.set(item, null);
}
const plan = groupPlans.get(item);
if (!plan) return undefined;
const lastId = eventAt(plan.members[plan.members.length - 1])?.mEvent.getId() ?? '';
if (separatedGalleries.has(lastId))
return plan.renders ? { hidden: false, regroup: lastId } : undefined;
if (!plan.renders) return { hidden: true };
const events = plan.members
.map((index) => eventAt(index)?.mEvent)
.filter((ev): ev is MatrixEvent => !!ev);
return { hidden: false, events };
};
const eventRenderer = (item: number) => {
const resolved = resolveItem(item);
if (!resolved) return null;
const { eventTimeline, baseIndex } = resolved;
const timelineSet = eventTimeline.getTimelineSet();
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
const mEventId = mEvent?.getId();
@@ -2252,17 +2372,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
const eventJSX = reactionOrEditEvent(mEvent)
? null
: renderMatrixEvent(
mEvent.getType(),
typeof mEvent.getStateKey() === 'string',
mEventId,
mEvent,
item,
timelineSet,
collapsed,
);
const mediaGroup = mediaGroupFor(item);
const eventJSX =
reactionOrEditEvent(mEvent) || mediaGroup?.hidden
? null
: renderMatrixEvent(
mEvent.getType(),
typeof mEvent.getStateKey() === 'string',
mEventId,
mEvent,
item,
timelineSet,
collapsed,
mediaGroup?.events,
mediaGroup?.regroup,
);
prevEvent = mEvent;
isPrevRendered = !!eventJSX;
@@ -2458,7 +2582,26 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
onClose={() => setEditHistoryEvent(undefined)}
/>
)}
{lightboxEventId && (
{lightboxEventId && lightboxGroup && (
<Lightbox
items={toLightboxItems(room, lightboxGroup)}
initialIndex={Math.max(
0,
lightboxGroup.findIndex((ev) => ev.getId() === lightboxEventId),
)}
useAuthentication={useAuthentication}
onClose={() => {
setLightboxEventId(undefined);
setLightboxGroup(undefined);
}}
onJump={(id) => {
setLightboxEventId(undefined);
setLightboxGroup(undefined);
navigateRoom(room.roomId, id);
}}
/>
)}
{lightboxEventId && !lightboxGroup && (
<RoomMediaLightbox
room={room}
eventId={lightboxEventId}
@@ -0,0 +1,118 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const Wrap = style([
DefaultReset,
{
display: 'block',
width: toRem(480),
maxWidth: '100%',
},
]);
export const Grid = style([
DefaultReset,
{
display: 'grid',
gap: toRem(3),
width: '100%',
borderRadius: config.radii.R400,
overflow: 'hidden',
},
]);
export const Cell = style([
DefaultReset,
{
position: 'relative',
aspectRatio: '1 / 1',
minWidth: 0,
padding: 0,
border: 'none',
cursor: 'pointer',
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
selectors: {
'&:focus-visible': {
outline: `${config.borderWidth.B600} solid ${color.Primary.Main}`,
outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
},
},
},
]);
export const CellImg = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
transition: 'transform 150ms',
selectors: {
[`${Cell}:hover &`]: {
transform: 'scale(1.03)',
},
},
},
]);
export const CellBlur = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
},
]);
export const PlayBadge = style([
DefaultReset,
{
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: toRem(36),
height: toRem(36),
borderRadius: config.radii.Pill,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
color: 'white',
pointerEvents: 'none',
},
]);
export const Footer = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
marginTop: config.space.S100,
},
]);
export const FooterButton = style([
DefaultReset,
{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'inherit',
textDecoration: 'underline',
textDecorationColor: 'transparent',
selectors: {
'&:hover, &:focus-visible': {
textDecorationColor: 'currentColor',
},
},
},
]);
@@ -0,0 +1,148 @@
import React, { useState } from 'react';
import { Icon, Icons, Spinner, Text } from 'folds';
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
import { BlurhashCanvas } from 'react-blurhash';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { getThumbMxc, useDecryptedMediaUrl } from '../MediaGallery';
import { validBlurHash } from '../../../utils/blurHash';
import { MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
import * as css from './MediaGroupGrid.css';
/** Column count for `n` tiles: 2 → 2, 3 → 3, 4 → 2×2, 56 → 3, 7+ → 4. */
export const gridColumns = (n: number): number => {
if (n <= 2) return 2;
if (n === 3) return 3;
if (n === 4) return 2;
if (n <= 6) return 3;
return 4;
};
/** "5 photos" / "2 videos" / "6 items". */
export const describeGroup = (events: MatrixEvent[]): string => {
const videos = events.filter((e) => e.getContent().msgtype === MsgType.Video).length;
const n = events.length;
if (videos === 0) return `${n} photos`;
if (videos === n) return `${n} videos`;
return `${n} items`;
};
function Cell({
mEvent,
load,
onOpen,
}: {
mEvent: MatrixEvent;
load: boolean;
onOpen: (eventId: string) => void;
}) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const content = mEvent.getContent();
const isVideo = content.msgtype === MsgType.Video;
const thumbMxc = getThumbMxc(mEvent);
const info = content.info as Record<string, unknown> | undefined;
const encInfo = content.file
? ((info?.thumbnail_file as typeof content.file | undefined) ?? content.file)
: undefined;
const mimeType =
(info?.thumbnail_info as { mimetype?: string } | undefined)?.mimetype ??
(info?.mimetype as string | undefined);
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME] as string | undefined);
const media = useDecryptedMediaUrl(mx, thumbMxc, encInfo, useAuthentication, mimeType, load);
const body = typeof content.body === 'string' ? content.body : '';
return (
<button
type="button"
className={css.Cell}
aria-label={body || (isVideo ? 'Video' : 'Image')}
onClick={() => onOpen(mEvent.getId() ?? '')}
>
{blurHash && media.status !== 'ok' && (
<BlurhashCanvas className={css.CellBlur} hash={blurHash} width={32} height={32} punch={1} />
)}
{load && media.status === 'loading' && <Spinner size="200" />}
{media.status === 'error' && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
{!load && !blurHash && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
{media.status === 'ok' && <img src={media.url} alt="" className={css.CellImg} />}
{isVideo && (
<span className={css.PlayBadge}>
<Icon src={Icons.Play} size="200" filled />
</span>
)}
</button>
);
}
type MediaGroupGridProps = {
events: MatrixEvent[];
mediaAutoLoad: boolean;
onOpen: (eventId: string) => void;
onShowSeparately: () => void;
};
/**
* [Gitea #137] Several consecutive image/video events from one sender shown
* as one grid. Each tile opens the room's shared lightbox at that event, so
* / walk through the group (and beyond). Purely a render-time grouping.
*/
export function MediaGroupGrid({
events,
mediaAutoLoad,
onOpen,
onShowSeparately,
}: MediaGroupGridProps) {
const [load, setLoad] = useState(mediaAutoLoad);
const columns = gridColumns(events.length);
return (
<div className={css.Wrap}>
<div
className={css.Grid}
role="group"
aria-label={describeGroup(events)}
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
onClick={load ? undefined : () => setLoad(true)}
>
{events.map((ev) => (
<Cell
key={ev.getId()}
mEvent={ev}
load={load}
onOpen={load ? onOpen : () => setLoad(true)}
/>
))}
</div>
<div className={css.Footer}>
<Text size="T200" priority="300">
{describeGroup(events)}
{!load && ' · tap to load'}
</Text>
<Text size="T200" priority="300">
·
</Text>
<Text
as="button"
size="T200"
priority="300"
className={css.FooterButton}
onClick={onShowSeparately}
>
Show separately
</Text>
</div>
</div>
);
}
/** Shown under the last of a gallery the user split up, to put it back together. */
export function RegroupChip({ onClick }: { onClick: () => void }) {
return (
<div className={css.Footer}>
<Text as="button" size="T200" priority="300" className={css.FooterButton} onClick={onClick}>
Show as gallery
</Text>
</div>
);
}
+119
View File
@@ -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);
});
});
+106
View File
@@ -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;
}