feat(media): timeline images open the gallery lightbox at that event (#219)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s

Clicking an image in the room or thread timeline now opens the same viewer
the media gallery uses — dark backdrop, sender + date, 1/N counter, ←/→ across
the room's media, +/-/0 and wheel/double-click zoom, download, jump to
message — positioned at the clicked event. RoomMediaLightbox feeds it from the
detached media timeline (#163); when the event isn't in the loaded window it
pages back (bounded, 6 pages) and shows the clicked image alone meanwhile, so
the viewer opens instantly. ImageContent gains onOpenViewer (RenderMessageContent
passes onOpenImageViewer); its built-in viewer remains for stickers, search
results, pins, notifications and avatars.

Verified headless: click → 'Media viewer' dialog focused, counter 1/2, '+' →
120 %, Jump to message present, Esc closes; an older image 10 pages up →
found at 10/45 after paging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-19 13:13:28 -04:00
co-authored by Claude Opus 5
parent 8b1c9fa610
commit f111b3c9af
6 changed files with 137 additions and 25 deletions
@@ -70,6 +70,8 @@ type RenderMessageContentProps = {
linkifyOpts: Opts;
outlineAttachment?: boolean;
eventId?: string;
/** [Gitea #219] Open the room's shared media lightbox at this event. */
onOpenImageViewer?: () => void;
};
export function RenderMessageContent({
displayName,
@@ -85,6 +87,7 @@ export function RenderMessageContent({
linkifyOpts,
outlineAttachment,
eventId,
onOpenImageViewer,
}: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => {
// Cap previews per message so a link-dump doesn't spawn dozens of preview
@@ -241,6 +244,7 @@ export function RenderMessageContent({
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
onOpenViewer={onOpenImageViewer}
/>
)}
outlined={outlineAttachment}
@@ -59,6 +59,9 @@ export type ImageContentProps = {
spoilerReason?: string;
renderViewer: (props: RenderViewerProps) => ReactNode;
renderImage: (props: RenderImageProps) => ReactNode;
// [Gitea #219] When given, a click opens THIS instead of the built-in
// viewer — the room timeline hands it to the shared media lightbox.
onOpenViewer?: () => void;
};
export const ImageContent = as<'div', ImageContentProps>(
(
@@ -74,6 +77,7 @@ export const ImageContent = as<'div', ImageContentProps>(
spoilerReason,
renderViewer,
renderImage,
onOpenViewer,
...props
},
ref,
@@ -182,7 +186,7 @@ export const ImageContent = as<'div', ImageContentProps>(
src: srcState.data,
onLoad: handleLoad,
onError: handleError,
onClick: () => setViewer(true),
onClick: () => (onOpenViewer ? onOpenViewer() : setViewer(true)),
tabIndex: 0,
})}
</Box>
+32 -24
View File
@@ -173,7 +173,7 @@ function getThumbMxc(mEvent: MatrixEvent): string | undefined {
// ── Lightbox ──────────────────────────────────────────────────────────────────
type LightboxItem = {
export type LightboxItem = {
mxcUrl: string;
encInfo?: IEncryptedFile;
mimeType?: string;
@@ -184,6 +184,35 @@ type LightboxItem = {
eventId: string;
};
/**
* Images + videos of a media timeline as lightbox slots. Shared with the
* timeline's viewer (#219) so both open the same items in the same order.
*/
export function toLightboxItems(room: Room, events: MatrixEvent[]): LightboxItem[] {
return events
.filter((ev) => {
const c = ev.getContent();
if (c.msgtype !== MsgType.Image && c.msgtype !== MsgType.Video) return false;
// Match the grid's guard exactly: tiles without a thumb are not rendered,
// so they must not occupy a lightbox slot either.
return !!getThumbMxc(ev);
})
.map((ev) => {
const c = ev.getContent();
const isEnc = !!c.file;
return {
mxcUrl: c.file?.url ?? c.url ?? '',
encInfo: isEnc ? c.file : undefined,
mimeType: c.info?.mimetype,
msgtype: c.msgtype as MsgType.Image | MsgType.Video,
body: c.body ?? '',
sender: getSenderName(room, ev.getSender() ?? ''),
ts: ev.getTs(),
eventId: ev.getId() ?? '',
};
});
}
function LightboxMedia({
item,
useAuthentication,
@@ -280,7 +309,7 @@ function LightboxMedia({
);
}
function Lightbox({
export function Lightbox({
items,
initialIndex,
useAuthentication,
@@ -765,28 +794,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
}, [canLoadMore, loading, handleLoadMore]);
// Lightbox items (images + videos, flat)
const lightboxItems: LightboxItem[] = events
.filter((ev) => {
const c = ev.getContent();
if (c.msgtype !== MsgType.Image && c.msgtype !== MsgType.Video) return false;
// Match the grid's guard exactly: tiles without a thumb are not rendered,
// so they must not occupy a lightbox slot either.
return !!getThumbMxc(ev);
})
.map((ev) => {
const c = ev.getContent();
const isEnc = !!c.file;
return {
mxcUrl: c.file?.url ?? c.url ?? '',
encInfo: isEnc ? c.file : undefined,
mimeType: c.info?.mimetype,
msgtype: c.msgtype as MsgType.Image | MsgType.Video,
body: c.body ?? '',
sender: getSenderName(room, ev.getSender() ?? ''),
ts: ev.getTs(),
eventId: ev.getId() ?? '',
};
});
const lightboxItems: LightboxItem[] = toLightboxItems(room, events);
// Per-tab counts for the tab labels (single pass over the loaded media)
const tabCounts = useMemo(() => {
@@ -0,0 +1,73 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomMediaTimeline } from '../../hooks/useRoomMediaTimeline';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { Lightbox, toLightboxItems } from './MediaGallery';
// How many media pages to walk back looking for the clicked event before
// settling for a single-item viewer.
const MAX_SEARCH_PAGES = 6;
type RoomMediaLightboxProps = {
room: Room;
eventId: string;
onClose: () => void;
};
/**
* [Gitea #219] The room timeline's image viewer is the gallery lightbox,
* opened at the clicked event: same dark backdrop, sender + date, 1/N counter,
* ←/→ across the room's media, zoom keys, download and "jump to message".
*
* Items come from the same detached media timeline the gallery uses (#163).
* It starts at the live end, so an older image may not be loaded yet: we page
* back a bounded number of times looking for it and meanwhile show the clicked
* image on its own, so the viewer never waits on the network to open.
*/
export function RoomMediaLightbox({ room, eventId, onClose }: RoomMediaLightboxProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate();
const { events, loadMore, loading, canLoadMore } = useRoomMediaTimeline(mx, room);
const items = useMemo(() => toLightboxItems(room, events), [room, events]);
const index = items.findIndex((it) => it.eventId === eventId);
const pagesRef = useRef(0);
useEffect(() => {
if (index !== -1 || loading || !canLoadMore || pagesRef.current >= MAX_SEARCH_PAGES) return;
pagesRef.current += 1;
loadMore();
}, [index, loading, canLoadMore, loadMore]);
// Until the event is in the loaded window, show just the clicked media.
const fallbackItems = useMemo(() => {
const ev: MatrixEvent | undefined = room.findEventById(eventId) ?? undefined;
return ev ? toLightboxItems(room, [ev]) : [];
}, [room, eventId]);
const handleJump = useCallback(
(id: string) => {
onClose();
navigateRoom(room.roomId, id);
},
[onClose, navigateRoom, room.roomId],
);
const found = index !== -1;
const shownItems = found ? items : fallbackItems;
if (shownItems.length === 0) return null;
return (
<Lightbox
// Remount when the full list arrives so the index and zoom reset cleanly.
key={found ? 'room' : 'single'}
items={shownItems}
initialIndex={found ? index : 0}
useAuthentication={useAuthentication}
onClose={onClose}
onJump={handleJump}
/>
);
}
+12
View File
@@ -115,6 +115,7 @@ import { RetentionContent, isExpired } from '../../utils/retention';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { RoomMediaLightbox } from './RoomMediaLightbox';
import { Image } from '../../components/media';
import { ImageViewer } from '../../components/image-viewer';
import { roomToParentsAtom } from '../../state/room/roomToParents';
@@ -523,6 +524,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
const [editId, setEditId] = useState<string>();
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
// [Gitea #219] Timeline images open the shared media lightbox at that event.
const [lightboxEventId, setLightboxEventId] = useState<string | undefined>();
const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
@@ -1231,6 +1234,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
/>
)}
</Message>
@@ -1360,6 +1364,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
/>
);
}
@@ -2317,6 +2322,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
onClose={() => setEditHistoryEvent(undefined)}
/>
)}
{lightboxEventId && (
<RoomMediaLightbox
room={room}
eventId={lightboxEventId}
onClose={() => setLightboxEventId(undefined)}
/>
)}
</>
);
}
@@ -72,6 +72,7 @@ import { useSetting } from '../../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../../state/settings';
import { Message, Reactions, EncryptedContent } from '../message';
import { RenderMessageContent } from '../../../components/RenderMessageContent';
import { RoomMediaLightbox } from '../RoomMediaLightbox';
import { Image } from '../../../components/media';
import { ImageViewer } from '../../../components/image-viewer';
import * as css from './ThreadTimeline.css';
@@ -309,6 +310,8 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const [editId, setEditId] = useState<string>();
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
// [Gitea #219] Thread images open the room's shared media lightbox too.
const [lightboxEventId, setLightboxEventId] = useState<string | undefined>();
const linkifyOpts = useMemo<LinkifyOpts>(
() => ({
@@ -711,6 +714,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
/>
);
};
@@ -1051,6 +1055,13 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
onClose={() => setEditHistoryEvent(undefined)}
/>
)}
{lightboxEventId && (
<RoomMediaLightbox
room={room}
eventId={lightboxEventId}
onClose={() => setLightboxEventId(undefined)}
/>
)}
</Box>
);
}