diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx
index 38ece5a44..e57053278 100644
--- a/src/app/components/RenderMessageContent.tsx
+++ b/src/app/components/RenderMessageContent.tsx
@@ -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) => }
renderViewer={(p) => }
+ onOpenViewer={onOpenImageViewer}
/>
)}
outlined={outlineAttachment}
diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx
index 65ab4655c..98aa00a67 100644
--- a/src/app/components/message/content/ImageContent.tsx
+++ b/src/app/components/message/content/ImageContent.tsx
@@ -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,
})}
diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx
index e1ee9e3c8..06fab5a0b 100644
--- a/src/app/features/room/MediaGallery.tsx
+++ b/src/app/features/room/MediaGallery.tsx
@@ -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(() => {
diff --git a/src/app/features/room/RoomMediaLightbox.tsx b/src/app/features/room/RoomMediaLightbox.tsx
new file mode 100644
index 000000000..6f6511fd9
--- /dev/null
+++ b/src/app/features/room/RoomMediaLightbox.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 1336b45f4..1da3c02b8 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -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();
const [editHistoryEvent, setEditHistoryEvent] = useState();
+ // [Gitea #219] Timeline images open the shared media lightbox at that event.
+ const [lightboxEventId, setLightboxEventId] = useState();
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)}
/>
)}
@@ -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 && (
+ setLightboxEventId(undefined)}
+ />
+ )}
>
);
}
diff --git a/src/app/features/room/thread/ThreadTimeline.tsx b/src/app/features/room/thread/ThreadTimeline.tsx
index bb67f3737..edc02499e 100644
--- a/src/app/features/room/thread/ThreadTimeline.tsx
+++ b/src/app/features/room/thread/ThreadTimeline.tsx
@@ -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();
const [editHistoryEvent, setEditHistoryEvent] = useState();
+ // [Gitea #219] Thread images open the room's shared media lightbox too.
+ const [lightboxEventId, setLightboxEventId] = useState();
const linkifyOpts = useMemo(
() => ({
@@ -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 && (
+ setLightboxEventId(undefined)}
+ />
+ )}
);
}