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; linkifyOpts: Opts;
outlineAttachment?: boolean; outlineAttachment?: boolean;
eventId?: string; eventId?: string;
/** [Gitea #219] Open the room's shared media lightbox at this event. */
onOpenImageViewer?: () => void;
}; };
export function RenderMessageContent({ export function RenderMessageContent({
displayName, displayName,
@@ -85,6 +87,7 @@ export function RenderMessageContent({
linkifyOpts, linkifyOpts,
outlineAttachment, outlineAttachment,
eventId, eventId,
onOpenImageViewer,
}: RenderMessageContentProps) { }: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => { const renderUrlsPreview = (urls: string[]) => {
// Cap previews per message so a link-dump doesn't spawn dozens of preview // Cap previews per message so a link-dump doesn't spawn dozens of preview
@@ -241,6 +244,7 @@ export function RenderMessageContent({
autoPlay={mediaAutoLoad} autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />} renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />} renderViewer={(p) => <ImageViewer {...p} />}
onOpenViewer={onOpenImageViewer}
/> />
)} )}
outlined={outlineAttachment} outlined={outlineAttachment}
@@ -59,6 +59,9 @@ export type ImageContentProps = {
spoilerReason?: string; spoilerReason?: string;
renderViewer: (props: RenderViewerProps) => ReactNode; renderViewer: (props: RenderViewerProps) => ReactNode;
renderImage: (props: RenderImageProps) => 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>( export const ImageContent = as<'div', ImageContentProps>(
( (
@@ -74,6 +77,7 @@ export const ImageContent = as<'div', ImageContentProps>(
spoilerReason, spoilerReason,
renderViewer, renderViewer,
renderImage, renderImage,
onOpenViewer,
...props ...props
}, },
ref, ref,
@@ -182,7 +186,7 @@ export const ImageContent = as<'div', ImageContentProps>(
src: srcState.data, src: srcState.data,
onLoad: handleLoad, onLoad: handleLoad,
onError: handleError, onError: handleError,
onClick: () => setViewer(true), onClick: () => (onOpenViewer ? onOpenViewer() : setViewer(true)),
tabIndex: 0, tabIndex: 0,
})} })}
</Box> </Box>
+32 -24
View File
@@ -173,7 +173,7 @@ function getThumbMxc(mEvent: MatrixEvent): string | undefined {
// ── Lightbox ────────────────────────────────────────────────────────────────── // ── Lightbox ──────────────────────────────────────────────────────────────────
type LightboxItem = { export type LightboxItem = {
mxcUrl: string; mxcUrl: string;
encInfo?: IEncryptedFile; encInfo?: IEncryptedFile;
mimeType?: string; mimeType?: string;
@@ -184,6 +184,35 @@ type LightboxItem = {
eventId: string; 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({ function LightboxMedia({
item, item,
useAuthentication, useAuthentication,
@@ -280,7 +309,7 @@ function LightboxMedia({
); );
} }
function Lightbox({ export function Lightbox({
items, items,
initialIndex, initialIndex,
useAuthentication, useAuthentication,
@@ -765,28 +794,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
}, [canLoadMore, loading, handleLoadMore]); }, [canLoadMore, loading, handleLoadMore]);
// Lightbox items (images + videos, flat) // Lightbox items (images + videos, flat)
const lightboxItems: LightboxItem[] = events const lightboxItems: LightboxItem[] = toLightboxItems(room, 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() ?? '',
};
});
// Per-tab counts for the tab labels (single pass over the loaded media) // Per-tab counts for the tab labels (single pass over the loaded media)
const tabCounts = useMemo(() => { 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 { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent'; import { RenderMessageContent } from '../../components/RenderMessageContent';
import { RoomMediaLightbox } from './RoomMediaLightbox';
import { Image } from '../../components/media'; import { Image } from '../../components/media';
import { ImageViewer } from '../../components/image-viewer'; import { ImageViewer } from '../../components/image-viewer';
import { roomToParentsAtom } from '../../state/room/roomToParents'; 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 canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
const [editId, setEditId] = useState<string>(); const [editId, setEditId] = useState<string>();
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>(); 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 roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
@@ -1231,6 +1234,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble} outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId} eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
/> />
)} )}
</Message> </Message>
@@ -1360,6 +1364,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble} outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId} eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
/> />
); );
} }
@@ -2317,6 +2322,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
onClose={() => setEditHistoryEvent(undefined)} 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 { MessageLayout, settingsAtom } from '../../../state/settings';
import { Message, Reactions, EncryptedContent } from '../message'; import { Message, Reactions, EncryptedContent } from '../message';
import { RenderMessageContent } from '../../../components/RenderMessageContent'; import { RenderMessageContent } from '../../../components/RenderMessageContent';
import { RoomMediaLightbox } from '../RoomMediaLightbox';
import { Image } from '../../../components/media'; import { Image } from '../../../components/media';
import { ImageViewer } from '../../../components/image-viewer'; import { ImageViewer } from '../../../components/image-viewer';
import * as css from './ThreadTimeline.css'; import * as css from './ThreadTimeline.css';
@@ -309,6 +310,8 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const [editId, setEditId] = useState<string>(); const [editId, setEditId] = useState<string>();
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>(); 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>( const linkifyOpts = useMemo<LinkifyOpts>(
() => ({ () => ({
@@ -711,6 +714,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
linkifyOpts={linkifyOpts} linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble} outlineAttachment={messageLayout === MessageLayout.Bubble}
eventId={mEventId} eventId={mEventId}
onOpenImageViewer={() => setLightboxEventId(mEventId)}
/> />
); );
}; };
@@ -1051,6 +1055,13 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
onClose={() => setEditHistoryEvent(undefined)} onClose={() => setEditHistoryEvent(undefined)}
/> />
)} )}
{lightboxEventId && (
<RoomMediaLightbox
room={room}
eventId={lightboxEventId}
onClose={() => setLightboxEventId(undefined)}
/>
)}
</Box> </Box>
); );
} }