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
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user