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