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)} + /> + )} ); }