From bd8c79e0e631b0da272c5a356e6fa32b0708e0a7 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 20 Sep 2026 00:48:13 -0400 Subject: [PATCH] feat(media): consecutive photos/videos render as one gallery grid (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/room/MediaGallery.tsx | 4 +- src/app/features/room/RoomTimeline.tsx | 247 ++++++++++++++---- .../room/message/MediaGroupGrid.css.ts | 118 +++++++++ .../features/room/message/MediaGroupGrid.tsx | 148 +++++++++++ src/app/utils/mediaGroups.test.ts | 119 +++++++++ src/app/utils/mediaGroups.ts | 106 ++++++++ 6 files changed, 688 insertions(+), 54 deletions(-) create mode 100644 src/app/features/room/message/MediaGroupGrid.css.ts create mode 100644 src/app/features/room/message/MediaGroupGrid.tsx create mode 100644 src/app/utils/mediaGroups.test.ts create mode 100644 src/app/utils/mediaGroups.ts diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx index 43d66041d..4d62f3e8d 100644 --- a/src/app/features/room/MediaGallery.tsx +++ b/src/app/features/room/MediaGallery.tsx @@ -58,7 +58,7 @@ const TAB_MSGTYPES: Record = { 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; diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 0b7f5bb5b..b0df974f2 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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(); + 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(); // [Gitea #219] Timeline images open the shared media lightbox at that event. const [lightboxEventId, setLightboxEventId] = useState(); + // [Gitea #137] Opened from a gallery grid: the viewer walks that group in send order. + const [lightboxGroup, setLightboxGroup] = useState(); 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() ? ( - ) : ( - setEditHistoryEvent(mEvent) : undefined} - getContent={getContent} + ) : mediaGroup ? ( + setLightboxEventId(mEventId)} - mEvent={mEvent} + onOpen={(id) => { + setLightboxGroup(mediaGroup); + setLightboxEventId(id); + }} + onShowSeparately={() => { + separatedGalleries.add(mEventId); + setSeparatedTick((n) => n + 1); + }} /> + ) : ( + <> + 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 && ( + { + separatedGalleries.delete(regroupId); + setSeparatedTick((n) => n + 1); + }} + /> + )} + )} ); @@ -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(); + 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 && ( + ev.getId() === lightboxEventId), + )} + useAuthentication={useAuthentication} + onClose={() => { + setLightboxEventId(undefined); + setLightboxGroup(undefined); + }} + onJump={(id) => { + setLightboxEventId(undefined); + setLightboxGroup(undefined); + navigateRoom(room.roomId, id); + }} + /> + )} + {lightboxEventId && !lightboxGroup && ( { + 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 | 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 ( + + ); +} + +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 ( +
+
setLoad(true)} + > + {events.map((ev) => ( + setLoad(true)} + /> + ))} +
+
+ + {describeGroup(events)} + {!load && ' · tap to load'} + + + · + + + Show separately + +
+
+ ); +} + +/** Shown under the last of a gallery the user split up, to put it back together. */ +export function RegroupChip({ onClick }: { onClick: () => void }) { + return ( +
+ + Show as gallery + +
+ ); +} diff --git a/src/app/utils/mediaGroups.test.ts b/src/app/utils/mediaGroups.test.ts new file mode 100644 index 000000000..c5ebc6618 --- /dev/null +++ b/src/app/utils/mediaGroups.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/src/app/utils/mediaGroups.ts b/src/app/utils/mediaGroups.ts new file mode 100644 index 000000000..87c12c9b0 --- /dev/null +++ b/src/app/utils/mediaGroups.ts @@ -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 { + 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; +}