From cf80729a5c3a79802d70048031ded99680d3dd28 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 23 Sep 2026 15:20:22 -0400 Subject: [PATCH] feat(media): swipe between items in the full-screen viewer (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Touch had no way to move between images except the small arrow buttons. A one-finger horizontal swipe now goes next/previous, and is inert while the image is zoomed in (where the same gesture pans). The classifier is pure and unit-tested: ≥ 60 px horizontal, not mostly vertical, under 800 ms, single finger. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/room/MediaGallery.tsx | 13 ++++- src/app/hooks/useSwipeNav.test.ts | 23 +++++++++ src/app/hooks/useSwipeNav.ts | 66 ++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src/app/hooks/useSwipeNav.test.ts create mode 100644 src/app/hooks/useSwipeNav.ts diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx index ab53d5864..6689d7af1 100644 --- a/src/app/features/room/MediaGallery.tsx +++ b/src/app/features/room/MediaGallery.tsx @@ -23,6 +23,7 @@ import classNames from 'classnames'; import { useNearViewport } from '../../hooks/useNearViewport'; import { useZoom } from '../../hooks/useZoom'; import { usePan, Pan } from '../../hooks/usePan'; +import { useSwipeNav } from '../../hooks/useSwipeNav'; import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -255,7 +256,15 @@ export function Lightbox({ 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, onTouchStart } = usePan(isImage && zoom !== 1); + const zoomedIn = isImage && zoom !== 1; + const { pan, cursor, onMouseDown, onTouchStart } = usePan(zoomedIn); + // [Gitea #164] Swipe between items on touch — only while not zoomed in, + // where the same gesture pans the image instead. + const swipe = useSwipeNav( + !zoomedIn, + useCallback(() => setIndex((i) => Math.min(items.length - 1, i + 1)), [items.length]), + useCallback(() => setIndex((i) => Math.max(0, i - 1)), []), + ); const dialogRef = useRef(null); const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]); @@ -446,6 +455,8 @@ export function Lightbox({ alignItems="Center" justifyContent="Center" onWheel={handleWheel} + onTouchStart={swipe.onTouchStart} + onTouchEnd={swipe.onTouchEnd} style={{ overflow: 'hidden', padding: config.space.S400 }} > {index > 0 && ( diff --git a/src/app/hooks/useSwipeNav.test.ts b/src/app/hooks/useSwipeNav.test.ts new file mode 100644 index 000000000..7e136708e --- /dev/null +++ b/src/app/hooks/useSwipeNav.test.ts @@ -0,0 +1,23 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { classifySwipe } from './useSwipeNav'; + +const start = { x: 300, y: 200, t: 1_000 }; +const at = (x: number, y: number) => ({ x, y }); + +describe('classifySwipe', () => { + it('left is next, right is prev', () => { + assert.equal(classifySwipe(start, at(100, 210), 1_200), 'next'); + assert.equal(classifySwipe({ ...start, x: 100 }, at(300, 190), 1_200), 'prev'); + }); + + it('ignores short, too-vertical and too-slow gestures', () => { + assert.equal(classifySwipe(start, at(260, 200), 1_200), undefined); + assert.equal(classifySwipe(start, at(100, 400), 1_200), undefined); + assert.equal(classifySwipe(start, at(100, 200), 2_500), undefined); + }); + + it('allows a little vertical drift on a long horizontal swipe', () => { + assert.equal(classifySwipe(start, at(60, 340), 1_300), 'next'); + }); +}); diff --git a/src/app/hooks/useSwipeNav.ts b/src/app/hooks/useSwipeNav.ts new file mode 100644 index 000000000..cf6452414 --- /dev/null +++ b/src/app/hooks/useSwipeNav.ts @@ -0,0 +1,66 @@ +import { TouchEventHandler, useCallback, useRef } from 'react'; + +/** Horizontal travel that counts as a swipe rather than a tap or a scroll. */ +const THRESHOLD_PX = 60; +/** Beyond this vertical travel it's a scroll/pan, not a horizontal swipe. */ +const VERTICAL_TOLERANCE = 1.2; +const MAX_DURATION_MS = 800; + +export type SwipeStart = { x: number; y: number; t: number }; + +/** + * Classify a finished one-finger gesture. Exported for tests. + */ +export function classifySwipe( + start: SwipeStart, + end: { x: number; y: number }, + now: number, +): 'next' | 'prev' | undefined { + if (now - start.t > MAX_DURATION_MS) return undefined; + const dx = end.x - start.x; + const dy = end.y - start.y; + if (Math.abs(dx) < THRESHOLD_PX) return undefined; + if (Math.abs(dy) > Math.abs(dx) / VERTICAL_TOLERANCE) return undefined; + return dx < 0 ? 'next' : 'prev'; +} + +/** + * [Gitea #164] One-finger horizontal swipe for the media viewer: left goes to + * the next item, right to the previous. Inert while `active` is false (e.g. + * the image is zoomed in, where the same gesture pans instead). + */ +export function useSwipeNav( + active: boolean, + onNext: () => void, + onPrev: () => void, +): { onTouchStart: TouchEventHandler; onTouchEnd: TouchEventHandler } { + const start = useRef(null); + + const onTouchStart = useCallback>( + (evt) => { + if (!active || evt.touches.length !== 1) { + start.current = null; + return; + } + const t = evt.touches[0]; + start.current = { x: t.clientX, y: t.clientY, t: Date.now() }; + }, + [active], + ); + + const onTouchEnd = useCallback>( + (evt) => { + const s = start.current; + start.current = null; + if (!active || !s) return; + const t = evt.changedTouches[0]; + if (!t) return; + const verdict = classifySwipe(s, { x: t.clientX, y: t.clientY }, Date.now()); + if (verdict === 'next') onNext(); + else if (verdict === 'prev') onPrev(); + }, + [active, onNext, onPrev], + ); + + return { onTouchStart, onTouchEnd }; +}