diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 8012dc4f6..434daca85 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -1107,7 +1107,7 @@ A toggle in **Settings → Privacy** switches between sending `m.read` (public r `MediaGallery.tsx` — a right-side drawer for browsing room media. - Four tabs: **Images**, **Videos**, **Audio**, **Files** (each with a live count) -- **Images/Videos** — a month-grouped grid; tiles decrypt on demand (lazy, near-viewport), open a keyboard-navigable **lightbox** (←/→/Esc, prev/next). Each grid tile has a hover/focus **download** button, and the lightbox header has a **Download** action — both reuse the shared `FileDownloadButton` (full-resolution source, decrypts E2EE media client-side, spinner/✓/retry states), so images and videos can be saved without jumping to the message. On touch (no-hover) devices the tile download button stays visible. +- **Images/Videos** — a month-grouped grid; tiles decrypt on demand (lazy, near-viewport), open a keyboard-navigable **lightbox** (←/→/Esc, prev/next). Each grid tile has a hover/focus **download** button, and the lightbox header has a **Download** action — both reuse the shared `FileDownloadButton` (full-resolution source, decrypts E2EE media client-side, spinner/✓/retry states), so images and videos can be saved without jumping to the message. On touch (no-hover) devices the tile download button stays visible. In the lightbox, **images support zoom & pan** (scroll wheel or −/+ header buttons, `+`/`-`/`0` keys, double-click or the % chip to toggle 1×↔2×; drag to pan when zoomed) via the shared `useZoom`/`usePan` hooks; zoom resets when navigating to another item. - **Audio** — voice messages + audio files (`m.audio`) with an inline player (reuses `AudioContent`: **waveform scrubbing** for voice messages, play/seek/**speed control**; decrypts on play) - **Files** — name/size/sender rows with download - **Jump to message** — a "Go to message" action on file rows, audio rows, and in the lightbox navigates the timeline to the source event (`useRoomNavigate`) and closes the drawer diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx index f157583e6..f4e12fa6a 100644 --- a/src/app/features/room/MediaGallery.tsx +++ b/src/app/features/room/MediaGallery.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Box, Button, + Chip, Header, Icon, IconButton, @@ -20,6 +21,8 @@ import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-s import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; import { useNearViewport } from '../../hooks/useNearViewport'; +import { useZoom } from '../../hooks/useZoom'; +import { usePan, Pan } from '../../hooks/usePan'; import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -176,9 +179,19 @@ type LightboxItem = { function LightboxMedia({ item, useAuthentication, + zoom, + pan, + cursor, + onMouseDown, + onImageDoubleClick, }: { item: LightboxItem; useAuthentication: boolean; + zoom: number; + pan: Pan; + cursor: string; + onMouseDown: React.MouseEventHandler; + onImageDoubleClick: () => void; }) { const mx = useMatrixClient(); const media = useDecryptedMediaUrl( @@ -231,12 +244,19 @@ function LightboxMedia({ {item.body} ))} @@ -259,6 +279,20 @@ function Lightbox({ }) { const [index, setIndex] = useState(initialIndex); + const item = items[index]; + const isImage = item?.msgtype === MsgType.Image; + + const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); + // Pan is only active for a zoomed-in image; usePan resets its offset when this + // flips false (i.e. back to 1x, on navigation, or on a video). + const { pan, cursor, onMouseDown } = usePan(isImage && zoom !== 1); + const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]); + + // Reset zoom when navigating to another item (and thus pan, via usePan). + useEffect(() => { + setZoom(1); + }, [index, setZoom]); + const prev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []); const next = useCallback( () => setIndex((i) => Math.min(items.length - 1, i + 1)), @@ -269,11 +303,21 @@ function Lightbox({ if (e.key === 'ArrowLeft') prev(); else if (e.key === 'ArrowRight') next(); else if (e.key === 'Escape') onClose(); + else if (isImage && (e.key === '+' || e.key === '=')) zoomIn(); + else if (isImage && e.key === '-') zoomOut(); + else if (isImage && e.key === '0') setZoom(1); }, - [prev, next, onClose], + [prev, next, onClose, isImage, zoomIn, zoomOut, setZoom], + ); + const handleWheel = useCallback( + (e: React.WheelEvent) => { + if (!isImage) return; + if (e.deltaY < 0) zoomIn(); + else zoomOut(); + }, + [isImage, zoomIn, zoomOut], ); - const item = items[index]; if (!item) return null; const dateStr = new Date(item.ts).toLocaleDateString(undefined, { @@ -327,6 +371,33 @@ function Lightbox({ {index + 1} / {items.length} + {isImage && ( + + + + + + {Math.round(zoom * 100)}% + + = 5} + > + + + + )} {item.mxcUrl && ( {index > 0 && ( @@ -418,6 +490,11 @@ function Lightbox({ key={`${item.mxcUrl}-${item.ts}`} item={item} useAuthentication={useAuthentication} + zoom={zoom} + pan={pan} + cursor={cursor} + onMouseDown={onMouseDown} + onImageDoubleClick={toggleZoom} /> {index < items.length - 1 && (