From 95f188a509684c5233f906a6b5ceae7b5ecb227d Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Wed, 26 Aug 2026 19:12:42 -0400 Subject: [PATCH] feat(media): navigate attachment bundles and galleries in the viewer --- .changeset/add-media-viewer-navigation.md | 5 + src/app/components/RenderMessageContent.tsx | 79 +++++++- .../image-viewer/ImageViewer.css.ts | 42 ++++- .../components/image-viewer/ImageViewer.tsx | 42 ++++- .../image-viewer/RoomMediaViewer.test.tsx | 177 +++++++++++++++++- .../image-viewer/RoomMediaViewer.tsx | 98 +++++++++- .../upload-card/UploadCardRenderer.tsx | 14 +- src/app/features/room/RoomInput.test.tsx | 4 + src/app/features/room/RoomInput.tsx | 23 +++ src/app/features/room/RoomTimeline.tsx | 28 ++- .../room/input/StagedUploadViewer.test.tsx | 100 ++++++++++ .../room/input/StagedUploadViewer.tsx | 76 ++++++++ src/app/features/room/mediaBundle.test.ts | 156 +++++++++++++++ src/app/features/room/mediaBundle.ts | 67 +++++++ src/app/utils/common.test.ts | 14 ++ src/app/utils/common.ts | 3 + 16 files changed, 896 insertions(+), 32 deletions(-) create mode 100644 .changeset/add-media-viewer-navigation.md create mode 100644 src/app/features/room/input/StagedUploadViewer.test.tsx create mode 100644 src/app/features/room/input/StagedUploadViewer.tsx create mode 100644 src/app/features/room/mediaBundle.test.ts create mode 100644 src/app/features/room/mediaBundle.ts diff --git a/.changeset/add-media-viewer-navigation.md b/.changeset/add-media-viewer-navigation.md new file mode 100644 index 0000000000..1c33385f31 --- /dev/null +++ b/.changeset/add-media-viewer-navigation.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add arrow-key and chevron navigation to the media viewer: zooming into an attachment bundle or a multi-image gallery navigates between its images with wrap-around, dimmed edge buttons, neighbour preloading, and hover-revealed controls. Staged composer attachments can be zoomed in before sending and navigate the same way. diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index d8da91dd7e..ea7db67581 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -1,5 +1,5 @@ -import type { CSSProperties, JSX } from 'react'; -import { memo, useMemo, useCallback } from 'react'; +import type { CSSProperties, JSX, ReactNode } from 'react'; +import { memo, useMemo, useCallback, useState } from 'react'; import type { IPreviewUrlResponse, MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; import { MsgType } from '$types/matrix-sdk'; import { parseSettingsLink } from '$features/settings/settingsLink'; @@ -46,6 +46,7 @@ import { isHttpsFullSableCssUrl } from '../theme/previewUrls'; import { isSableCssAttachmentFileName } from '../theme/processThemeImport'; import { Image, MediaControl, PersistedVolumeVideo } from './media'; import { ImageViewer } from './image-viewer'; +import { RoomMediaViewer } from './image-viewer/RoomMediaViewer'; import { PdfViewer } from './Pdf-viewer'; import { TextViewer } from './text-viewer'; import { ClientSideHoverFreeze } from './ClientSideHoverFreeze'; @@ -54,6 +55,7 @@ import { PollEvent } from './message/PollEvent'; import { M_POLL_START, M_TEXT } from 'matrix-js-sdk'; import type { IImageInfo, IGalleryContent } from '$types/matrix/common'; import { GALLERY_MSGTYPE } from '$types/matrix/common'; +import { getGalleryMediaItems } from '$features/room/mediaBundle'; import { parseExternalGif } from '$utils/externalGif'; import { parseLegacyKlipyGif } from '$utils/klipy'; import { @@ -88,6 +90,7 @@ type RenderMessageContentProps = { mx?: MatrixClient; room?: Room; onOpenMedia?: (mEvent: MatrixEvent) => boolean; + onOpenViewerOverride?: () => boolean; }; const getMediaType = (url: string) => { @@ -106,6 +109,64 @@ const CAPTION_STYLE: CSSProperties = { marginTop: config.space.S200, maxWidth: ' const TEXT_STYLE: CSSProperties = { maxWidth: '100%' }; const EXTERNAL_GIF_MAX_SIZE = 400; +// Clicking an image opens the gallery in a RoomMediaViewer so arrows and +// chevrons navigate its items; single-image galleries keep their own viewers. +function GalleryContent({ + content, + mEvent, + displayName, + renderItem, +}: { + content: IGalleryContent; + mEvent?: MatrixEvent; + displayName: string; + renderItem: (itemContent: unknown, claimOpen: (() => boolean) | undefined) => ReactNode; +}) { + const [viewId, setViewId] = useState(); + const mediaItems = useMemo( + () => getGalleryMediaItems(mEvent, content, displayName), + [mEvent, content, displayName] + ); + // MGallery partitions items into grids, so its render index is not the itemtypes + // index — match the clicked item back to its viewer entry by media url. + const eventIdByUrl = useMemo( + () => new Map(mediaItems.map((item) => [item.url, item.eventId])), + [mediaItems] + ); + + return ( + <> + { + const url = + (itemContent as { file?: { url?: string }; url?: string }).file?.url ?? + (itemContent as { url?: string }).url; + const eventId = + mediaItems.length > 1 && typeof url === 'string' ? eventIdByUrl.get(url) : undefined; + return renderItem( + itemContent, + eventId + ? () => { + setViewId(eventId); + return true; + } + : undefined + ); + }} + /> + {mediaItems.length > 1 && viewId && ( + setViewId(undefined)} + /> + )} + + ); +} + function RenderMessageContentInternal({ displayName, msgType, @@ -127,6 +188,7 @@ function RenderMessageContentInternal({ mx, room, onOpenMedia, + onOpenViewerOverride, }: RenderMessageContentProps) { const content = useMemo(() => getContent() as Record, [getContent]); @@ -510,7 +572,9 @@ function RenderMessageContentInternal({ renderImageContent={(imageProps) => ( onOpenMedia?.(mEvent) ?? false : undefined} + onOpenViewer={ + onOpenViewerOverride ?? (mEvent ? () => onOpenMedia?.(mEvent) ?? false : undefined) + } autoPlay={mediaAutoLoad} renderImage={(p) => { if (isGif && !autoplayGifs && p.src) { @@ -582,12 +646,14 @@ function RenderMessageContentInternal({ if (msgType === GALLERY_MSGTYPE) { return renderCaptionedAttachment( - ( + mEvent={mEvent} + displayName={displayName} + renderItem={(itemContent, claimOpen) => ( itemContent} mediaAutoLoad={mediaAutoLoad} @@ -597,6 +663,7 @@ function RenderMessageContentInternal({ linkifyOpts={linkifyOpts} outlineAttachment={outlineAttachment} isGallery={true} + onOpenViewerOverride={claimOpen} /> )} /> diff --git a/src/app/components/image-viewer/ImageViewer.css.ts b/src/app/components/image-viewer/ImageViewer.css.ts index 5606dc45e0..0439fc35e6 100644 --- a/src/app/components/image-viewer/ImageViewer.css.ts +++ b/src/app/components/image-viewer/ImageViewer.css.ts @@ -148,16 +148,56 @@ const mobileGalleryControl = { top: '50%', zIndex: 1, transform: 'translateY(-50%)', - backgroundColor: '#0009', + backgroundColor: 'rgba(0, 0, 0, 0.5)', color: '#fff', + transition: 'background-color 150ms ease, opacity 150ms ease', +}; + +// Desktop hides the chevrons until the cursor is over the viewer; keyboard +// users can still tab to them, which reveals them again. +export const ImageViewerControlsHidden = style({ + opacity: 0, + pointerEvents: 'none', + selectors: { + '&:focus-visible': { + opacity: 1, + pointerEvents: 'auto', + }, + }, +}); + +// The global `button:hover` lift would replace the -50% centering transform and +// bounce the hitbox under the cursor, so re-assert it here and only fade color. +const galleryControlStates = { + selectors: { + '&:hover': { + transform: 'translateY(-50%)', + backgroundColor: 'rgba(0, 0, 0, 0.7)', + }, + '&:focus-visible': { + transform: 'translateY(-50%)', + backgroundColor: 'rgba(0, 0, 0, 0.7)', + }, + '&:active': { + transform: 'translateY(-50%)', + backgroundColor: 'rgba(0, 0, 0, 0.8)', + }, + }, }; export const ImageViewerPrevious = style({ ...mobileGalleryControl, + ...galleryControlStates, left: `calc(${config.space.S100} + ${safeAreaLeft})`, }); export const ImageViewerNext = style({ ...mobileGalleryControl, + ...galleryControlStates, right: `calc(${config.space.S100} + ${safeAreaRight})`, }); + +// Dimmed rest state for the button whose direction wraps around the bundle end. +export const ImageViewerEdge = style({ + backgroundColor: 'rgba(0, 0, 0, 0.2)', +}); diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index b33ac6bed5..882d5111e1 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -64,6 +64,8 @@ type ImageViewerProps = { sentAt?: string; onPrevious?: () => void; onNext?: () => void; + atStart?: boolean; + atEnd?: boolean; getDownloadBlob?: () => Promise; }; @@ -80,12 +82,15 @@ export const ImageViewer = as<'div', ImageViewerProps>( sentAt, onPrevious, onNext, + atStart, + atEnd, getDownloadBlob, ...props }, ref ) => { const zoomInputRef = useRef(null); + const [controlsVisible, setControlsVisible] = useState(false); const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering'); const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile; @@ -100,6 +105,25 @@ export const ImageViewer = as<'div', ImageViewerProps>( // Android back closes the viewer instead of navigating away. useDismissOnBack(requestClose); + // Arrow keys navigate a media gallery; the zoom input and menu buttons keep + // their own key handling. + useEffect(() => { + if (!onPrevious && !onNext) return undefined; + const handleKeyDown = (evt: KeyboardEvent) => { + const target = evt.target; + if ( + target instanceof HTMLElement && + target.closest('input, textarea, button, [role="menuitem"]') + ) { + return; + } + if (evt.key === 'ArrowLeft') onPrevious?.(); + else if (evt.key === 'ArrowRight') onNext?.(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onPrevious, onNext]); + const [isImageReady, setIsImageReady] = useState(false); const [isEditingZoom, setIsEditingZoom] = useState(false); const [zoomInput, setZoomInput] = useState('100'); @@ -616,14 +640,20 @@ export const ImageViewer = as<'div', ImageViewerProps>( style={{ overflow: 'hidden', touchAction: 'none', cursor }} onPointerDown={onPointerDown} onContextMenu={handleContextMenu} + onMouseEnter={() => !isMobile && setControlsVisible(true)} + onMouseLeave={() => setControlsVisible(false)} onTouchStart={menu.triggerProps.onTouchStart} onTouchEnd={menu.triggerProps.onTouchEnd} onTouchMove={menu.triggerProps.onTouchMove} onTouchCancel={menu.triggerProps.onTouchCancel} > - {isMobile && onPrevious && ( + {onPrevious && ( ( {sizedIcon(CaretLeft, '200')} )} - {isMobile && onNext && ( + {onNext && ( ({ @@ -60,12 +61,48 @@ describe('RoomMediaViewer', () => { expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); }); - it('offers next but not previous on the first item', async () => { + it('offers both directions on the first item', async () => { renderViewer('$one'); await screen.findByAltText('first.png'); expect(screen.getByRole('button', { name: 'Next image' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Previous image' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Previous image' })).toBeInTheDocument(); + }); + + it('dims the wrap-around buttons on the bundle edges', async () => { + const { rerender } = render( + void>()} + selectEvent={vi.fn<(id: string) => void>()} + /> + ); + await screen.findByAltText('first.png'); + + expect(screen.getByRole('button', { name: 'Previous image' }).className).toContain( + css.ImageViewerEdge + ); + expect(screen.getByRole('button', { name: 'Next image' }).className).not.toContain( + css.ImageViewerEdge + ); + + rerender( + void>()} + selectEvent={vi.fn<(id: string) => void>()} + /> + ); + await screen.findByAltText('second.png'); + + expect(screen.getByRole('button', { name: 'Next image' }).className).toContain( + css.ImageViewerEdge + ); + expect(screen.getByRole('button', { name: 'Previous image' }).className).not.toContain( + css.ImageViewerEdge + ); }); it('selects the following event when Next is tapped', async () => { @@ -77,6 +114,52 @@ describe('RoomMediaViewer', () => { await waitFor(() => expect(selectEvent).toHaveBeenCalledWith('$two')); }); + it('navigates between items with the arrow keys', async () => { + const selectEvent = renderViewer('$one'); + + await screen.findByAltText('first.png'); + fireEvent.keyDown(window, { key: 'ArrowRight' }); + + await waitFor(() => expect(selectEvent).toHaveBeenCalledWith('$two')); + }); + + it('moves to the previous item with ArrowLeft', async () => { + const selectEvent = renderViewer('$two'); + + await screen.findByAltText('second.png'); + fireEvent.keyDown(window, { key: 'ArrowLeft' }); + + await waitFor(() => expect(selectEvent).toHaveBeenCalledWith('$one')); + }); + + it('wraps to the first item with ArrowRight on the last', async () => { + const selectEvent = renderViewer('$two'); + + await screen.findByAltText('second.png'); + fireEvent.keyDown(window, { key: 'ArrowRight' }); + + await waitFor(() => expect(selectEvent).toHaveBeenCalledWith('$one')); + }); + + it('wraps to the last item when pressing Previous on the first', async () => { + const selectEvent = renderViewer('$one'); + + await screen.findByAltText('first.png'); + fireEvent.click(screen.getByRole('button', { name: 'Previous image' })); + + await waitFor(() => expect(selectEvent).toHaveBeenCalledWith('$two')); + }); + + it('does not navigate while a button is focused', async () => { + const selectEvent = renderViewer('$one'); + + await screen.findByAltText('first.png'); + const nextButton = screen.getByRole('button', { name: 'Next image' }); + fireEvent.keyDown(nextButton, { key: 'ArrowRight' }); + + expect(selectEvent).not.toHaveBeenCalled(); + }); + it('closes when the selected event is no longer in the gallery', async () => { const requestClose = vi.fn<() => void>(); render( @@ -116,4 +199,94 @@ describe('RoomMediaViewer', () => { expect(await screen.findByText('Failed to load media')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); }); + + it('keeps the previous image mounted while the next resolves', async () => { + const encInfo = { key: {}, iv: 'iv', hashes: {} } as EncryptedAttachmentInfo; + const encryptedItems: RoomMediaItem[] = [ + { + eventId: '$one', + body: 'first.png', + url: 'mxc://example.org/one', + encInfo, + mimeType: 'image/png', + }, + { + eventId: '$two', + body: 'second.png', + url: 'mxc://example.org/two', + encInfo, + mimeType: 'image/png', + }, + ]; + let releaseSecond: ((buffer: ArrayBuffer) => void) | undefined; + downloadEncryptedMedia.mockResolvedValueOnce(new ArrayBuffer(1)).mockImplementationOnce( + () => + new Promise((resolve) => { + releaseSecond = resolve; + }) + ); + + const viewer = render( + void>()} + selectEvent={vi.fn<(id: string) => void>()} + /> + ); + await screen.findByAltText('first.png'); + + viewer.rerender( + void>()} + selectEvent={vi.fn<(id: string) => void>()} + /> + ); + expect(screen.getByAltText('first.png')).toBeInTheDocument(); + expect(screen.queryByAltText('second.png')).not.toBeInTheDocument(); + + releaseSecond?.(new ArrayBuffer(1)); + expect(await screen.findByAltText('second.png')).toBeInTheDocument(); + }); + + it('does not double-download encrypted media when preloading on web', async () => { + downloadEncryptedMedia.mockClear(); + const encInfo = { key: {}, iv: 'iv', hashes: {} } as EncryptedAttachmentInfo; + const encryptedItems: RoomMediaItem[] = [ + { + eventId: '$one', + body: 'a.png', + url: 'mxc://example.org/a', + encInfo, + mimeType: 'image/png', + }, + { + eventId: '$two', + body: 'b.png', + url: 'mxc://example.org/b', + encInfo, + mimeType: 'image/png', + }, + { + eventId: '$three', + body: 'c.png', + url: 'mxc://example.org/c', + encInfo, + mimeType: 'image/png', + }, + ]; + + render( + void>()} + selectEvent={vi.fn<(id: string) => void>()} + /> + ); + await screen.findByAltText('b.png'); + await waitFor(() => expect(downloadEncryptedMedia).toHaveBeenCalledTimes(1)); + }); }); diff --git a/src/app/components/image-viewer/RoomMediaViewer.tsx b/src/app/components/image-viewer/RoomMediaViewer.tsx index e45446eb45..c9d435a317 100644 --- a/src/app/components/image-viewer/RoomMediaViewer.tsx +++ b/src/app/components/image-viewer/RoomMediaViewer.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Box, Chip, Spinner, Text } from 'folds'; import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; +import type { MatrixClient } from '$types/matrix-sdk'; import { ModalOverlay } from '$components/modal-overlay/ModalOverlay'; import { useCreateObjectURL } from '$hooks/useObjectURL'; import { useMatrixClient } from '$hooks/useMatrixClient'; @@ -20,6 +21,7 @@ import { isTauri } from '@tauri-apps/api/core'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { timeDayMonthYear, timeHourMinute, today, yesterday } from '$utils/time'; +import { wrapIndex } from '$utils/common'; import { ImageViewer } from './ImageViewer'; export type RoomMediaItem = { @@ -43,6 +45,49 @@ type RoomMediaViewerProps = { type ResolvedMedia = { item: RoomMediaItem; src: string }; +// Bounded so large bundles stay flat in memory; unreferenced bitmaps and HTTP +// entries are evicted by the browser on its own. +const PRELOAD_AHEAD = 2; +const PRELOAD_BEHIND = 2; + +type MediaResolution = { + mx: MatrixClient; + useAuthentication: boolean; + createObjectURL: (data: Blob) => string; + tauri: boolean; +}; + +// Shared by live resolution and preloading so both register the same caches +// (encryption keys, loopback, HTTP). Web encrypted media is skipped: its blob +// lives in the hook's own cache, so a second download would double bandwidth. +const preloadRoomMediaItem = async ( + item: RoomMediaItem, + { mx, useAuthentication, tauri }: Omit +): Promise => { + if (item.encInfo && !tauri) return; + const rawMediaUrl = item.url.startsWith('http') + ? item.url + : mxcUrlToHttp(mx, item.url, useAuthentication); + let src = rawMediaUrl; + if (item.encInfo) { + if (!src) return; + await setMediaEncryption(src, item.encInfo, item.mimeType ?? FALLBACK_MIMETYPE); + src = rewriteAuthenticatedMediaUrl(src)!; + } else if (tauri) { + if (!src) return; + src = await prepareLoopbackImageSource(src); + } + if (src) await loadImage(src); +}; + +const loadImage = (src: string): Promise => + new Promise((resolve) => { + const image = new Image(); + image.addEventListener('load', () => resolve(), { once: true }); + image.addEventListener('error', () => resolve(), { once: true }); + image.src = src; + }); + const formatSentAt = (ts: number | undefined, hour24Clock: boolean): string | undefined => { if (ts === undefined) return undefined; const time = timeHourMinute(ts, hour24Clock); @@ -56,11 +101,15 @@ function ResolvedRoomMedia({ requestClose, onPrevious, onNext, + atStart, + atEnd, }: { item: RoomMediaItem; requestClose: () => void; onPrevious?: () => void; onNext?: () => void; + atStart?: boolean; + atEnd?: boolean; }) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -115,11 +164,10 @@ function ResolvedRoomMedia({ }, [item, rawMediaUrl, resolvedMediaUrl, tauri, createObjectURL, retryToken]); const loading = !error && resolved?.item.eventId !== item.eventId; - const showingResolved = resolved?.item.eventId === item.eventId; return ( <> - {resolved && showingResolved && !error && ( + {resolved && !error && ( @@ -189,6 +239,35 @@ export function RoomMediaViewer({ }: RoomMediaViewerProps) { const selectedIndex = items.findIndex((mediaItem) => mediaItem.eventId === selectedEventId); const item = items[selectedIndex]; + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const tauri = isTauri(); + + // Preload a small sliding window around the current image — forward first, + // then backward — so navigation swaps without re-fetching. + useEffect(() => { + if (items.length < 2 || selectedIndex < 0) return undefined; + let cancelled = false; + const forward = items.slice(selectedIndex + 1, selectedIndex + 1 + PRELOAD_AHEAD); + const backward = items + .slice(Math.max(selectedIndex - PRELOAD_BEHIND, 0), selectedIndex) + .toReversed(); + const run = async () => { + for (const next of [...forward, ...backward]) { + if (cancelled) return; + try { + // oxlint-disable-next-line no-await-in-loop -- forward neighbours download before backward ones + await preloadRoomMediaItem(next, { mx, useAuthentication, tauri }); + } catch { + // Preloading is best-effort; the real navigation reports its own errors. + } + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [items, selectedIndex, mx, useAuthentication, tauri]); useEffect(() => { if (!item) requestClose(); @@ -196,6 +275,9 @@ export function RoomMediaViewer({ if (!item) return null; + const selectRelative = (offset: number) => + selectEvent(items[wrapIndex(selectedIndex, offset, items.length)]!.eventId); + return ( 0 ? () => selectEvent(items[selectedIndex - 1]!.eventId) : undefined - } - onNext={ - selectedIndex < items.length - 1 - ? () => selectEvent(items[selectedIndex + 1]!.eventId) - : undefined - } + atStart={selectedIndex === 0} + atEnd={selectedIndex === items.length - 1} + onPrevious={() => selectRelative(-1)} + onNext={() => selectRelative(1)} /> ); diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index ee0ecc0356..84046f03f4 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -321,9 +321,10 @@ function PreviewAudio({ fileItem }: PreviewAudioProps) { type MediaPreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void; + onOpen?: () => void; children: ReactNode; }; -function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) { +function MediaPreview({ fileItem, onSpoiler, onOpen, children }: MediaPreviewProps) { const { originalFile, metadata } = fileItem; const fileUrl = useObjectURL(originalFile); @@ -334,7 +335,9 @@ function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) { overflow: 'hidden', backgroundColor: 'black', position: 'relative', + cursor: onOpen ? 'zoom-in' : undefined, }} + onClick={onOpen} > {children} onSpoiler(!metadata.markedAsSpoiler)} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + onSpoiler(!metadata.markedAsSpoiler); + }} > Spoiler @@ -370,6 +376,7 @@ type UploadCardRendererProps = { onComplete?: (upload: UploadSuccess) => void; roomId: string; hideCaption?: boolean; + onOpenPreview?: () => void; }; export function UploadCardRenderer({ isEncrypted, @@ -380,6 +387,7 @@ export function UploadCardRenderer({ onComplete, roomId, hideCaption, + onOpenPreview, }: Readonly) { const mx = useMatrixClient(); const mediaConfig = useMediaConfig(); @@ -507,7 +515,7 @@ export function UploadCardRenderer({ bottom={ <> {isImageMimeType(fileItem.originalFile.type) && ( - + )} diff --git a/src/app/features/room/RoomInput.test.tsx b/src/app/features/room/RoomInput.test.tsx index 3d9cdd29aa..e3064176ec 100644 --- a/src/app/features/room/RoomInput.test.tsx +++ b/src/app/features/room/RoomInput.test.tsx @@ -235,6 +235,10 @@ vi.mock('$components/upload-board', async () => { }; }); +vi.mock('./input/StagedUploadViewer', () => ({ + StagedUploadViewer: () => null, +})); + vi.mock('$components/upload-card', () => ({ UploadCardRenderer: ({ fileItem, setMetadata, setDesc }: any) => ( <> diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 594df1da2d..d9352aa385 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -96,6 +96,7 @@ import { import { UploadCardRenderer } from '$components/upload-card'; import type { UploadBoardImperativeHandlers } from '$components/upload-board'; import { UploadBoard, UploadBoardContent, UploadBoardHeader } from '$components/upload-board'; +import { StagedUploadViewer } from './input/StagedUploadViewer'; import type { Upload, UploadSuccess } from '$state/upload'; import { UploadStatus, createUploadFamilyObserverAtom } from '$state/upload'; import { loadImageElementFromMediaUrl } from '$utils/dom'; @@ -379,6 +380,7 @@ export const RoomInput = forwardRef( replyDraftRef.current = replyDraft; const [uploadBoard, setUploadBoard] = useState(true); + const [stagedViewIndex, setStagedViewIndex] = useState(); const [uploadSending, setUploadSending] = useState(false); const [uploadBusy, setUploadBusy] = useState(false); const [ingestingFiles, setIngestingFiles] = useState(false); @@ -391,6 +393,14 @@ export const RoomInput = forwardRef( const draftEpochRef = useRef(0); const mountedRef = useRef(false); const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey)); + // Tile order is newest-first, and the zoom bundle follows it. + const stagedImageItems = useMemo( + () => + Array.from(selectedFiles) + .toReversed() + .filter((f) => isImageMimeType(f.originalFile.type)), + [selectedFiles] + ); const selectedFilesRef = useRef(selectedFiles); selectedFilesRef.current = selectedFiles; const uploadItemOverridesRef = useRef(new Map>()); @@ -2057,6 +2067,11 @@ export const RoomInput = forwardRef( hideCaption={ selectedFiles.length == 1 && sendIndividualAttachmentAsCaption } + onOpenPreview={ + isImageMimeType(fileItem.originalFile.type) + ? () => setStagedViewIndex(stagedImageItems.indexOf(fileItem)) + : undefined + } /> ))} @@ -2064,6 +2079,14 @@ export const RoomInput = forwardRef( )} )} + {stagedViewIndex !== undefined && ( + setStagedViewIndex(undefined)} + selectIndex={setStagedViewIndex} + /> + )} {scheduledTime && (
{ const mediaEventId = mEvent.getId(); - if (!isMobile || !mediaEventId || !getRoomMediaItem(mEvent, room, nicknames)) return false; - setRoomMedia( - timelineSyncRef.current.timeline.linkedTimelines.flatMap((timeline) => - timeline.getEvents().flatMap((timelineEvent) => { - const item = getRoomMediaItem(timelineEvent, room, nicknames); + if (!mediaEventId || !getRoomMediaItem(mEvent, room, nicknames)) return false; + const processedMediaEvents = processedEventsRef.current.flatMap((row) => + getRoomMediaItem(row.mEvent, room, nicknames) ? [row.mEvent] : [] + ); + if (isMobile) { + setRoomMedia( + processedMediaEvents.flatMap((m) => { + const item = getRoomMediaItem(m, room, nicknames); return item ? [item] : []; }) - ) + ); + setSelectedMediaEventId(mediaEventId); + return true; + } + // Desktop zooms are scoped to the attachment bundle around the clicked event, + // so arrow keys navigate its siblings and lone images keep the plain viewer. + const bundle = getMediaBundleEvents(processedEventsRef.current, mediaEventId); + if (bundle.length < 2) return false; + setRoomMedia( + bundle.flatMap((timelineEvent) => { + const item = getRoomMediaItem(timelineEvent, room, nicknames); + return item ? [item] : []; + }) ); setSelectedMediaEventId(mediaEventId); return true; diff --git a/src/app/features/room/input/StagedUploadViewer.test.tsx b/src/app/features/room/input/StagedUploadViewer.test.tsx new file mode 100644 index 0000000000..7072f00f77 --- /dev/null +++ b/src/app/features/room/input/StagedUploadViewer.test.tsx @@ -0,0 +1,100 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; +import type { TUploadItem } from '$state/room/roomInputDrafts'; +import { StagedUploadViewer } from './StagedUploadViewer'; + +vi.mock('$hooks/useObjectURL', () => ({ + useObjectURL: (file: Blob | undefined) => (file ? `blob:${(file as File).name}` : undefined), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + isTauri: () => false, + invoke: vi.fn<() => Promise>(), +})); + +vi.mock('$hooks/useScreenSize', () => ({ + ScreenSize: { Desktop: 'Desktop', Tablet: 'Tablet', Mobile: 'Mobile' }, + useScreenSizeContext: () => 'Desktop', + useScreenSizeOptionally: () => 'Desktop', + useCompactLayout: () => false, +})); + +const makeItem = (name: string): TUploadItem => + ({ + file: new File(['x'], name, { type: 'image/png' }), + originalFile: new File(['x'], name, { type: 'image/png' }), + metadata: { markedAsSpoiler: false }, + encInfo: undefined as EncryptedAttachmentInfo | undefined, + }) as TUploadItem; + +const items = [makeItem('one.png'), makeItem('two.png'), makeItem('three.png')]; + +const renderViewer = ( + index: number, + selectIndex = vi.fn<(i: number) => void>(), + requestClose = vi.fn<() => void>() +) => { + render( + + ); + return { selectIndex, requestClose }; +}; + +describe('StagedUploadViewer', () => { + it('renders the selected staged image', async () => { + renderViewer(1); + + expect(await screen.findByAltText('two.png')).toBeInTheDocument(); + }); + + it('offers both chevrons and dims the wrap edges', async () => { + renderViewer(0); + + await screen.findByAltText('one.png'); + const previous = screen.getByRole('button', { name: 'Previous image' }); + const next = screen.getByRole('button', { name: 'Next image' }); + expect(previous).toBeInTheDocument(); + expect(next).toBeInTheDocument(); + }); + + it('wraps to the first staged image after the last', async () => { + const selectIndex = vi.fn<(i: number) => void>(); + renderViewer(2, selectIndex); + + await screen.findByAltText('three.png'); + fireEvent.click(screen.getByRole('button', { name: 'Next image' })); + + await waitFor(() => expect(selectIndex).toHaveBeenCalledWith(0)); + }); + + it('wraps to the last staged image before the first', async () => { + const selectIndex = vi.fn<(i: number) => void>(); + renderViewer(0, selectIndex); + + await screen.findByAltText('one.png'); + fireEvent.click(screen.getByRole('button', { name: 'Previous image' })); + + await waitFor(() => expect(selectIndex).toHaveBeenCalledWith(2)); + }); + + it('closes when the opened attachment disappears', async () => { + const requestClose = vi.fn<() => void>(); + + render( + void>()} + /> + ); + + await waitFor(() => expect(requestClose).toHaveBeenCalled()); + }); +}); diff --git a/src/app/features/room/input/StagedUploadViewer.tsx b/src/app/features/room/input/StagedUploadViewer.tsx new file mode 100644 index 0000000000..2aaefdea1f --- /dev/null +++ b/src/app/features/room/input/StagedUploadViewer.tsx @@ -0,0 +1,76 @@ +import { useEffect } from 'react'; +import { ModalOverlay } from '$components/modal-overlay/ModalOverlay'; +import { ImageViewer } from '$components/image-viewer'; +import { useObjectURL } from '$hooks/useObjectURL'; +import { wrapIndex } from '$utils/common'; +import type { TUploadItem } from '$state/room/roomInputDrafts'; + +type StagedUploadViewerProps = { + items: TUploadItem[]; + index: number; + requestClose: () => void; + selectIndex: (index: number) => void; +}; + +function StagedImageSlide({ + item, + requestClose, + onPrevious, + onNext, + atStart, + atEnd, +}: { + item: TUploadItem; + requestClose: () => void; + onPrevious?: () => void; + onNext?: () => void; + atStart?: boolean; + atEnd?: boolean; +}) { + const fileUrl = useObjectURL(item.originalFile); + if (!fileUrl) return null; + return ( + Promise.resolve(item.originalFile)} + requestClose={requestClose} + onPrevious={onPrevious} + onNext={onNext} + atStart={atStart} + atEnd={atEnd} + /> + ); +} + +/** The staged composer attachments form a local bundle: the opened image can be + * navigated with arrows or chevrons before it is sent. */ +export function StagedUploadViewer({ + items, + index, + requestClose, + selectIndex, +}: StagedUploadViewerProps) { + useEffect(() => { + if (items.length === 0 || index < 0 || index >= items.length) requestClose(); + }, [items.length, index, requestClose]); + + if (items.length === 0 || index < 0 || index >= items.length) return null; + const item = items[index]!; + + const selectRelative = (offset: number) => selectIndex(wrapIndex(index, offset, items.length)); + + return ( + + 1 ? () => selectRelative(-1) : undefined} + onNext={items.length > 1 ? () => selectRelative(1) : undefined} + atStart={index === 0} + atEnd={index === items.length - 1} + /> + + ); +} diff --git a/src/app/features/room/mediaBundle.test.ts b/src/app/features/room/mediaBundle.test.ts new file mode 100644 index 0000000000..e7f8ee84d3 --- /dev/null +++ b/src/app/features/room/mediaBundle.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; +import type { MatrixEvent } from '$types/matrix-sdk'; +import { EventType } from '$types/matrix-sdk'; +import type { ProcessedEvent } from '$hooks/timeline/useProcessedTimeline'; +import type { IGalleryContent } from '$types/matrix/common'; +import { getGalleryMediaItems, getMediaBundleEvents, isMediaEvent } from './mediaBundle'; + +const BASE = 1_000_000_000; + +const makeEvent = ({ + id, + sender = 'user', + type = 'm.room.message', + msgtype = 'm.image', +}: { + id: string; + sender?: string; + type?: string; + msgtype?: string; +}) => { + const content: Record = msgtype === undefined ? {} : { msgtype }; + return { + getId: () => id, + getSender: () => sender, + getType: () => type, + getTs: () => BASE, + getContent: () => content, + isRedacted: () => false, + } as unknown as MatrixEvent; +}; + +const makeRow = ( + mEvent: MatrixEvent, + collapsed: boolean, + id = mEvent.getId() as string +): ProcessedEvent => + ({ + id, + itemIndex: 0, + mEvent, + isRedacted: false, + timelineSet: undefined, + eventSender: mEvent.getSender(), + collapsed, + willRenderNewDivider: false, + willRenderDayDivider: false, + editId: undefined, + reactionsKey: '', + content: mEvent.getContent(), + sendStatus: null, + }) as unknown as ProcessedEvent; + +describe('isMediaEvent', () => { + it('accepts image and sticker events', () => { + expect(isMediaEvent(makeEvent({ id: '$a', msgtype: 'm.image' }))).toBe(true); + expect(isMediaEvent(makeEvent({ id: '$a', type: EventType.Sticker as string }))).toBe(true); + }); + + it('rejects non-media events', () => { + expect(isMediaEvent(makeEvent({ id: '$a', msgtype: 'm.text' }))).toBe(false); + expect( + isMediaEvent(makeEvent({ id: '$a', type: EventType.RoomMember as string, msgtype: 'm.text' })) + ).toBe(false); + }); +}); + +describe('getMediaBundleEvents', () => { + it('returns the whole collapse group around a bundled event', () => { + const rows = [ + makeRow(makeEvent({ id: '$a' }), false), + makeRow(makeEvent({ id: '$b' }), true), + makeRow(makeEvent({ id: '$c' }), true), + makeRow(makeEvent({ id: '$d' }), false), // own group, >2min after $c + ]; + expect(getMediaBundleEvents(rows, '$b').map((ev) => ev.getId())).toEqual(['$a', '$b', '$c']); + expect(getMediaBundleEvents(rows, '$c').map((ev) => ev.getId())).toEqual(['$a', '$b', '$c']); + expect(getMediaBundleEvents(rows, '$a').map((ev) => ev.getId())).toEqual(['$a', '$b', '$c']); + }); + + it('returns a lone event alone when it anchors its own group', () => { + const rows = [makeRow(makeEvent({ id: '$a' }), false), makeRow(makeEvent({ id: '$b' }), false)]; + expect(getMediaBundleEvents(rows, '$b').map((ev) => ev.getId())).toEqual(['$b']); + }); + + it('skips non-media rows within a collapse group', () => { + const rows = [ + makeRow(makeEvent({ id: '$a' }), false), + makeRow(makeEvent({ id: '$text', msgtype: 'm.text' }), true), + makeRow(makeEvent({ id: '$c' }), true), + ]; + expect(getMediaBundleEvents(rows, '$a').map((ev) => ev.getId())).toEqual(['$a', '$c']); + }); + + it('does not cross a group boundary', () => { + const rows = [ + makeRow(makeEvent({ id: '$a' }), false), + makeRow(makeEvent({ id: '$mid', msgtype: 'm.text' }), false), // separates the groups + makeRow(makeEvent({ id: '$b' }), false), + makeRow(makeEvent({ id: '$c' }), true), + ]; + expect(getMediaBundleEvents(rows, '$b').map((ev) => ev.getId())).toEqual(['$b', '$c']); + expect(getMediaBundleEvents(rows, '$a').map((ev) => ev.getId())).toEqual(['$a']); + }); + + it('returns an empty run for an unknown or non-media event', () => { + const rows = [ + makeRow(makeEvent({ id: '$a' }), false), + makeRow(makeEvent({ id: '$b' }), true), + makeRow(makeEvent({ id: '$text', msgtype: 'm.text' }), true), + ]; + expect(getMediaBundleEvents(rows, '$nope')).toEqual([]); + expect(getMediaBundleEvents(rows, '$text')).toEqual([]); + }); +}); + +describe('getGalleryMediaItems', () => { + const gallery = (itemtypes: unknown[]): IGalleryContent => + ({ msgtype: 'sable.gallery', body: '', itemtypes }) as unknown as IGalleryContent; + + it('builds one synthetic-id item per gallery image', () => { + const items = getGalleryMediaItems( + makeEvent({ id: '$g' }), + gallery([ + { itemtype: 'm.image', body: 'a.png', url: 'mxc://x/a' }, + { itemtype: 'm.image', body: 'b.png', url: 'mxc://x/b' }, + ]), + 'Alice' + ); + expect(items.map((item) => item.eventId)).toEqual(['$g:0', '$g:1']); + expect(items.map((item) => item.url)).toEqual(['mxc://x/a', 'mxc://x/b']); + expect(items[0]).toMatchObject({ body: 'a.png', sender: 'Alice', timestamp: BASE }); + }); + + it('keeps only image items and uses encrypted file urls', () => { + const items = getGalleryMediaItems( + makeEvent({ id: '$g' }), + gallery([ + { itemtype: 'm.image', body: 'enc.png', file: { url: 'mxc://x/enc' } }, + { itemtype: 'm.video', body: 'v.mp4', url: 'mxc://x/v' }, + { itemtype: 'm.file', body: 'f.txt', url: 'mxc://x/f' }, + ]) + ); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ url: 'mxc://x/enc', encInfo: { url: 'mxc://x/enc' } }); + }); + + it('returns nothing without an event or for redacted events', () => { + const content = gallery([{ itemtype: 'm.image', body: 'a.png', url: 'mxc://x/a' }]); + expect(getGalleryMediaItems(undefined, content)).toEqual([]); + const redacted = { + ...makeEvent({ id: '$g' }), + isRedacted: () => true, + } as unknown as MatrixEvent; + expect(getGalleryMediaItems(redacted, content)).toEqual([]); + }); +}); diff --git a/src/app/features/room/mediaBundle.ts b/src/app/features/room/mediaBundle.ts new file mode 100644 index 0000000000..718a23947f --- /dev/null +++ b/src/app/features/room/mediaBundle.ts @@ -0,0 +1,67 @@ +import type { MatrixEvent } from '$types/matrix-sdk'; +import { EventType, MsgType } from '$types/matrix-sdk'; +import type { ProcessedEvent } from '$hooks/timeline/useProcessedTimeline'; +import type { IGalleryContent } from '$types/matrix/common'; +import type { RoomMediaItem } from '$components/image-viewer/RoomMediaViewer'; + +export const isMediaEvent = (mEvent: MatrixEvent): boolean => { + if (mEvent.isRedacted()) return false; + const content = mEvent.getContent() as { msgtype?: string }; + return content.msgtype === MsgType.Image || mEvent.getType() === (EventType.Sticker as string); +}; + +/** The contiguous run of media events forming the attachment bundle around `eventId`, + * derived from the rendered timeline's own collapse groups rather than raw event + * adjacency (raw arrays interleave non-media rows that break a naive contiguous walk). */ +export function getMediaBundleEvents( + processedEvents: ProcessedEvent[], + eventId: string +): MatrixEvent[] { + const clickedIndex = processedEvents.findIndex((row) => row.mEvent.getId() === eventId); + if (clickedIndex < 0 || !isMediaEvent(processedEvents[clickedIndex]!.mEvent)) return []; + + // A collapse group is an anchor row (collapsed=false) followed by rows whose + // `collapsed` flag chains them onto it. Walk back to the anchor, then forward + // through the chain, keeping only the media rows. + let start = clickedIndex; + while (start > 0 && processedEvents[start]!.collapsed) start -= 1; + + const bundle: MatrixEvent[] = []; + for (let i = start; i < processedEvents.length; i += 1) { + const row = processedEvents[i]!; + if (i > start && !row.collapsed) break; + if (isMediaEvent(row.mEvent)) bundle.push(row.mEvent); + } + + return bundle; +} + +/** The navigable media items of a gallery message — one bundle per event, so each + * image gets a synthetic id (`:`) instead of a real event id. */ +export function getGalleryMediaItems( + mEvent: MatrixEvent | undefined, + content: IGalleryContent, + senderName?: string +): RoomMediaItem[] { + const eventId = mEvent?.getId(); + if (!eventId || !mEvent || mEvent.isRedacted()) return []; + + return (content.itemtypes ?? []).flatMap((item, index) => { + if (item.itemtype !== MsgType.Image) return []; + const url = item.file?.url ?? item.url; + if (typeof url !== 'string') return []; + return [ + { + eventId: `${eventId}:${index}`, + body: item.body ?? item.filename ?? 'Image', + filename: item.filename, + url, + info: item.info, + mimeType: item.info?.mimetype, + encInfo: item.file, + sender: senderName, + timestamp: mEvent.getTs(), + }, + ]; + }); +} diff --git a/src/app/utils/common.test.ts b/src/app/utils/common.test.ts index 7b897ae5ff..c0ec7f01e5 100644 --- a/src/app/utils/common.test.ts +++ b/src/app/utils/common.test.ts @@ -13,6 +13,7 @@ import { trimLeadingSlash, trimSlash, trimTrailingSlash, + wrapIndex, } from './common'; describe('bytesToSize', () => { @@ -172,3 +173,16 @@ describe('trimLeadingSlash / trimTrailingSlash / trimSlash', () => { expect(trimSlash(input)).toBe(expected); }); }); + +describe('wrapIndex', () => { + it.each([ + [0, 1, 3, 1], + [2, 1, 3, 0], + [0, -1, 3, 2], + [4, -1, 5, 3], + [1, 0, 3, 1], + [7, 2, 5, 4], + ])('wraps %i + %i in %i to %i', (index, offset, length, expected) => { + expect(wrapIndex(index, offset, length)).toBe(expected); + }); +}); diff --git a/src/app/utils/common.ts b/src/app/utils/common.ts index e8eb408ddc..a6b64ec4a8 100644 --- a/src/app/utils/common.ts +++ b/src/app/utils/common.ts @@ -72,6 +72,9 @@ export const binarySearch = (items: T[], match: (item: T) => -1 | 0 | 1): T | return search(0, items.length - 1); }; +export const wrapIndex = (index: number, offset: number, length: number): number => + (((index + offset) % length) + length) % length; + export const randomNumberBetween = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;