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:
@@ -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;
|
||||
|
||||
@@ -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,7 +1290,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
>
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
) : mediaGroup ? (
|
||||
<MediaGroupGrid
|
||||
events={mediaGroup}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
onOpen={(id) => {
|
||||
setLightboxGroup(mediaGroup);
|
||||
setLightboxEventId(id);
|
||||
}}
|
||||
onShowSeparately={() => {
|
||||
separatedGalleries.add(mEventId);
|
||||
setSeparatedTick((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<RenderMessageContent
|
||||
displayName={senderDisplayName}
|
||||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
@@ -1281,6 +1322,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
onOpenImageViewer={() => setLightboxEventId(mEventId)}
|
||||
mEvent={mEvent}
|
||||
/>
|
||||
{regroupId && (
|
||||
<RegroupChip
|
||||
onClick={() => {
|
||||
separatedGalleries.delete(regroupId);
|
||||
setSeparatedTick((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Message>
|
||||
);
|
||||
@@ -2185,11 +2235,10 @@ 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;
|
||||
{
|
||||
const resolveItem = (
|
||||
item: number,
|
||||
): { eventTimeline: EventTimeline; baseIndex: number } | undefined => {
|
||||
let lo = 0;
|
||||
let hi = timelineSegments.length - 1;
|
||||
while (lo <= hi) {
|
||||
@@ -2201,14 +2250,85 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
} else if (item >= base + len) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
eventTimeline = timelineSegments[mid][2];
|
||||
baseIndex = base;
|
||||
break;
|
||||
return { eventTimeline: timelineSegments[mid][2], baseIndex: base };
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (!eventTimeline) return null;
|
||||
const timelineSet = eventTimeline?.getTimelineSet();
|
||||
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,7 +2372,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
prevEvent.getType() === mEvent.getType() &&
|
||||
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
|
||||
|
||||
const eventJSX = reactionOrEditEvent(mEvent)
|
||||
const mediaGroup = mediaGroupFor(item);
|
||||
const eventJSX =
|
||||
reactionOrEditEvent(mEvent) || mediaGroup?.hidden
|
||||
? null
|
||||
: renderMatrixEvent(
|
||||
mEvent.getType(),
|
||||
@@ -2262,6 +2384,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
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, 5–6 → 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user