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