From b6ffa038911fa07a6d936ae0b2c71c3878b07246 Mon Sep 17 00:00:00 2001 From: heaven Date: Mon, 8 Jun 2026 16:17:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(media):=20rework=20the=20viewer=20?= =?UTF-8?q?=E2=80=94=20smaller=20rounded=20chat=20previews,=20a=20draggabl?= =?UTF-8?q?e=20desktop=20window,=20a=20fullscreen=20mobile=20sheet,=20and?= =?UTF-8?q?=20swipe=20animation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../message/attachment/StreamMedia.css.ts | 72 ++- .../message/attachment/StreamMediaShell.tsx | 18 +- .../message/content/ImageContent.tsx | 4 +- src/app/features/room/MediaViewerBody.css.ts | 14 + src/app/features/room/MediaViewerBody.tsx | 516 +++++++++++++----- .../room/MobileMediaViewerHorseshoe.css.ts | 17 +- .../room/MobileMediaViewerHorseshoe.tsx | 146 +++-- src/app/features/room/Room.tsx | 40 +- .../room/RoomViewMediaSidePanel.css.ts | 107 ---- .../features/room/RoomViewMediaSidePanel.tsx | 222 -------- .../features/room/RoomViewMediaWindow.css.ts | 94 ++++ src/app/features/room/RoomViewMediaWindow.tsx | 243 +++++++++ .../features/room/mediaViewerHostContext.ts | 6 +- src/app/state/hooks/userRoomProfile.ts | 10 +- src/app/state/mediaSidePanelWidth.ts | 68 --- src/app/state/mediaViewer.ts | 3 +- src/app/state/mediaWindowRect.ts | 68 +++ 17 files changed, 952 insertions(+), 696 deletions(-) delete mode 100644 src/app/features/room/RoomViewMediaSidePanel.css.ts delete mode 100644 src/app/features/room/RoomViewMediaSidePanel.tsx create mode 100644 src/app/features/room/RoomViewMediaWindow.css.ts create mode 100644 src/app/features/room/RoomViewMediaWindow.tsx delete mode 100644 src/app/state/mediaSidePanelWidth.ts create mode 100644 src/app/state/mediaWindowRect.ts diff --git a/src/app/components/message/attachment/StreamMedia.css.ts b/src/app/components/message/attachment/StreamMedia.css.ts index 831dba6f..70e671c2 100644 --- a/src/app/components/message/attachment/StreamMedia.css.ts +++ b/src/app/components/message/attachment/StreamMedia.css.ts @@ -1,53 +1,43 @@ import { style } from '@vanilla-extract/css'; -import { recipe } from '@vanilla-extract/recipes'; import { color, config, toRem } from 'folds'; -const StreamMediaCornerNotch = toRem(4); +// Small uniform rounding on ALL four corners (R400 = 8px) — the old +// asymmetric "notch toward sender" corner is gone; previews now read as +// plain rounded thumbnails on both own and peer sides. +const StreamMediaCornerSmall = config.radii.R400; const StreamMediaCornerRound = config.radii.R500; -export const StreamMediaBubble = recipe({ - base: { - position: 'relative', - overflow: 'hidden', - backgroundColor: color.SurfaceVariant.Container, - maxWidth: '100%', - boxSizing: 'border-box', - flexShrink: 0, - // 1px frame drawn ABOVE the image via a pseudo-element. `inset - // box-shadow` would sit on the bg layer and get hidden by the - // `` filling 100% × 100%; a real `border` would push the - // image's coordinate space and re-introduce the 1px chip-alignment - // offset we just removed. The pseudo sits at z-index 1 (above - // image's auto-stacked 0, below the username overlay at z 2), - // inherits the bubble's asymmetric border-radius, and is - // pointer-events:none so clicks pass through to the image. - selectors: { - '&::after': { - content: '""', - position: 'absolute', - inset: 0, - border: `1.2px solid ${color.Surface.Container}`, - borderRadius: 'inherit', - pointerEvents: 'none', - zIndex: 1, - }, - }, - }, - variants: { - own: { - true: { - borderRadius: `${StreamMediaCornerNotch} ${StreamMediaCornerRound} ${StreamMediaCornerRound} ${StreamMediaCornerRound}`, - }, - false: { - borderRadius: `${StreamMediaCornerRound} ${StreamMediaCornerRound} ${StreamMediaCornerRound} ${StreamMediaCornerNotch}`, - }, +export const StreamMediaBubble = style({ + position: 'relative', + overflow: 'hidden', + backgroundColor: color.SurfaceVariant.Container, + maxWidth: '100%', + boxSizing: 'border-box', + flexShrink: 0, + borderRadius: StreamMediaCornerSmall, + // 1px frame drawn ABOVE the image via a pseudo-element. `inset + // box-shadow` would sit on the bg layer and get hidden by the + // `` filling 100% × 100%; a real `border` would push the + // image's coordinate space and re-introduce the 1px chip-alignment + // offset we just removed. The pseudo sits at z-index 1 (above + // image's auto-stacked 0, below the username overlay at z 2), + // inherits the bubble's border-radius, and is pointer-events:none so + // clicks pass through to the image. + selectors: { + '&::after': { + content: '""', + position: 'absolute', + inset: 0, + border: `1.2px solid ${color.Surface.Container}`, + borderRadius: 'inherit', + pointerEvents: 'none', + zIndex: 1, }, }, }); -// Caption mini-bubble under the image. Uniform R500 corners — the asymmetric -// notch lives on the image-bubble itself; stacking two notch'd rectangles -// reads worse than one notched + one rounded. +// Caption mini-bubble under the image. Keeps a slightly larger R500 radius, +// intentionally distinct from the preview bubble's uniform R400. export const StreamMediaCaption = style({ marginTop: toRem(4), backgroundColor: color.SurfaceVariant.Container, diff --git a/src/app/components/message/attachment/StreamMediaShell.tsx b/src/app/components/message/attachment/StreamMediaShell.tsx index b3e63a56..2d231a62 100644 --- a/src/app/components/message/attachment/StreamMediaShell.tsx +++ b/src/app/components/message/attachment/StreamMediaShell.tsx @@ -3,7 +3,10 @@ import { toRem } from 'folds'; import { StreamMediaBubble } from './StreamMedia.css'; import { logMedia, useMediaMeasureDebug } from './streamMediaDebug'; -const STREAM_MEDIA_MAX_DIM = 320; +// Timeline media preview box — shrunk ~1.9× from the original 320 per +// design so photos/videos read as compact inline previews; the full +// image opens in the media viewer on tap. +const STREAM_MEDIA_MAX_DIM = 168; const STREAM_MEDIA_MIN_ASPECT = 3 / 4; const STREAM_MEDIA_MAX_ASPECT = 4 / 3; @@ -40,10 +43,13 @@ function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSPropert return { width: toRem(h * naturalAspect), height: toRem(h) }; } -// Shared chrome for image / video timeline bubbles: square-ish bubble with -// asymmetric notch + 1px pseudo-frame. Caller plugs in the actual media -// renderer as `children`. The sender nick is rendered ABOVE the media by the -// Stream layout's name header (like text messages), not overlaid on the image. +// Shared chrome for image / video timeline bubbles: an aspect-preserving +// (up to 168px) preview box with uniformly-rounded R400 corners and a 1px +// pseudo-frame. Caller plugs in the actual media renderer as `children`. +// The sender nick is rendered ABOVE the media by the Stream layout's name +// header (like text messages), not overlaid on the image. `own` no longer +// affects rendering after the uniform-corner change — it survives only as +// a debug-log field (sender vs peer) for the media-measure diagnostics. export function StreamMediaShell({ naturalW, naturalH, own, children }: StreamMediaShellProps) { const bubbleRef = useRef(null); const computedStyle = computeBoxStyle(naturalW, naturalH); @@ -58,7 +64,7 @@ export function StreamMediaShell({ naturalW, naturalH, own, children }: StreamMe useMediaMeasureDebug(bubbleRef, [computedStyle.width, computedStyle.height]); return ( -
+
{children}
); diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index bf10c0ad..c4812e03 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -68,8 +68,8 @@ export type ImageContentProps = { markedAsSpoiler?: boolean; spoilerReason?: string; // When provided AND the `MediaViewerHostContext` is non-null, - // clicking the thumbnail opens the atom-driven horseshoe viewer - // (mobile bottom-up sheet / desktop right pane) instead of the + // clicking the thumbnail opens the atom-driven viewer (mobile + // bottom-up sheet / desktop+tablet floating window) instead of the // legacy full-screen `` viewer. Non-Room surfaces // (pin-menu, message search) leave the host context as `null` and // therefore keep the legacy modal even if they pass `eventId`. diff --git a/src/app/features/room/MediaViewerBody.css.ts b/src/app/features/room/MediaViewerBody.css.ts index b5f3a2e6..014ea695 100644 --- a/src/app/features/room/MediaViewerBody.css.ts +++ b/src/app/features/room/MediaViewerBody.css.ts @@ -71,6 +71,20 @@ export const image = style({ imageRendering: 'auto', }); +// Swipe-filmstrip neighbour layer — fills the stage, centres its image +// like the current one, and ignores pointers (the current image owns the +// gesture surface). Translated by the parent so prev/next slide with the +// drag. +export const previewLayer = style({ + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + pointerEvents: 'none', +}); + // Side chevrons — visibility logic: // • Coarse pointer (touch devices, mobile): always visible at // 0.7 opacity so users have a tappable nav affordance without diff --git a/src/app/features/room/MediaViewerBody.tsx b/src/app/features/room/MediaViewerBody.tsx index 9b655be9..7e68e02e 100644 --- a/src/app/features/room/MediaViewerBody.tsx +++ b/src/app/features/room/MediaViewerBody.tsx @@ -1,6 +1,6 @@ // Body for the room media viewer — mounted inside both the mobile -// `MobileMediaViewerHorseshoe` panel-body and the desktop -// `RoomViewMediaSidePanel`. Owns: +// `MobileMediaViewerHorseshoe` panel-body and the desktop floating +// `RoomViewMediaWindow`. Owns: // • Resolving + decrypting the media URL for the current entry, // including revoke-on-unmount for blob URLs we create. // • Zoom for images (button row + `+`/`-`/`0` keys). @@ -46,6 +46,13 @@ const SWIPE_COMMIT_PX = 80; // the gesture is interpreted as a swipe — keeps accidental diagonal // drags from committing. const SWIPE_DOMINANCE = 1.5; +// Duration of the slide animation when a prev/next navigation commits +// (swipe release past threshold, chevron tap, arrow key). +const SWIPE_ANIM_MS = 280; +// Gutter between adjacent images in the swipe filmstrip, so consecutive +// pictures don't read as glued edge-to-edge while sliding. The panel +// «pitch» (centre-to-centre travel) is therefore stageWidth + this. +const SWIPE_GAP_PX = 24; // Walk the room's loaded timeline events and build the ordered set // of viewable image + video events. Out-of-window media (older than @@ -94,12 +101,63 @@ function eventToEntry(roomId: string, ev: MatrixEvent): MediaViewerEntry | null }; } +// A non-interactive neighbour image for the swipe «filmstrip». It resolves +// (and shares, via the parent's `resolveSrc` cache) its own decrypted src so +// the prev / next image is already on-screen as the user drags, then slides +// to centre on commit. Image-only — videos keep instant navigation. Sits +// BEHIND the interactive current image (`pointer-events: none`). +function SwipePreview({ + entry, + resolveSrc, + offsetPx, + transition, +}: { + entry: MediaViewerEntry; + resolveSrc: (e: MediaViewerEntry) => Promise; + offsetPx: number; + transition: string; +}) { + const [src, setSrc] = useState(undefined); + useEffect(() => { + let alive = true; + resolveSrc(entry) + .then((s) => { + if (alive) setSrc(s); + }) + .catch(() => { + /* neighbour preview is best-effort */ + }); + return () => { + alive = false; + }; + }, [entry, resolveSrc]); + return ( + + ); +} + type MediaViewerBodyProps = { entry: MediaViewerEntry; requestClose: () => void; + // Drop the title/zoom/download/close toolbar entirely. The desktop + // floating window owns its own minimal chrome (drag bar + close), so + // it passes this; zoom is wheel-only and nav is keyboard/chevron + // there. The mobile horseshoe + (legacy) side pane leave it false to + // keep the full toolbar. + hideHeader?: boolean; }; -export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { +export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewerBodyProps) { const { t } = useTranslation(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -127,6 +185,24 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); const [panCursor, setPanCursor] = useState<'grab' | 'grabbing' | 'initial'>('initial'); + // Horizontal swipe / slide state (declared here, before the + // navigation + keyboard handlers that read it). `swipeOffset` is the + // live drag translate; `swipeAnimating` is true while a commit slide + // is settling (so the transition is on for the ±stageWidth glide); + // `stageWidth` feeds the neighbour-panel positions and the commit + // target; `commitTimerRef` defers the entry swap to the end of the + // slide. Gesture detail is in the big stage-listener effect below. + const [swipeOffset, setSwipeOffset] = useState(0); + const [swipeAnimating, setSwipeAnimating] = useState(false); + const [stageWidth, setStageWidth] = useState(0); + const commitTimerRef = useRef(null); + const prefersReducedMotion = useMemo( + () => + typeof window !== 'undefined' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches, + [] + ); + // Mirror `zoom` and `pan` into refs so callbacks installed in // long-lived listeners can read the latest values without // re-binding, and so the inlined anchor-aware zoom math in @@ -203,12 +279,31 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { // `onImageLoad` set a small ceiling) to a high-res one would // briefly use the stale ceiling until the new image's `onLoad` // fires. Gesture state is reset in a separate effect below. + // Also cancel any in-flight commit slide (e.g. an external open + // mid-animation) so its deferred entry swap can't fire stale. useEffect(() => { setZoom(1); setPan({ x: 0, y: 0 }); setMaxZoom(ZOOM_CEILING_FALLBACK); + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + } + setSwipeAnimating(false); }, [entry.eventId, setZoom]); + // Track the stage width so the swipe filmstrip can position the + // prev/next panels exactly one stage-width to either side and the + // commit slide knows how far to glide. + useEffect(() => { + const el = stageRef.current; + if (!el) return undefined; + setStageWidth(el.clientWidth); + const ro = new ResizeObserver(() => setStageWidth(el.clientWidth)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + // Re-clamp the existing pan against the new bounds when zoom // changes (e.g. zooming out from 3× to 1× should snap a panned // image back to centre since there's nothing to pan into). @@ -254,55 +349,62 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { [clampPan] ); - // Resolve + decrypt the media URL. Mirrors `ImageContent`'s - // pattern. Blob-URL lifecycle is managed via `lastBlobUrlRef`: - // every encrypted entry allocates a fresh blob INSIDE the async - // callback, and we revoke the previous one before storing the - // new one. This pattern handles two leak paths that a naive - // `useEffect(srcState)` cleanup misses: - // 1. `useAsyncCallback`'s "Request replaced!" reject path — - // a rapidly-replaced loadSrc may have created a blob via - // `createObjectURL` before the throw, which never reaches - // `srcState` and so is never cleaned up. Here we revoke - // whatever was last stamped into the ref before stamping - // the new one. - // 2. Unmount mid-load — `useEffect` cleanup with a stale - // `srcState.data` could revoke a blob the new mount is - // still using. With a ref-tracked invariant of "we own - // `lastBlobUrlRef.current`", the unmount cleanup is - // unambiguous. - const lastBlobUrlRef = useRef(null); - const { url: entryUrl, encInfo: entryEncInfo, mimeType: entryMimeType } = entry; - const [srcState, loadSrc] = useAsyncCallback( - useCallback(async () => { - const mediaUrl = mxcUrlToHttp(mx, entryUrl, useAuthentication); + // Resolve + decrypt the media URL into a displayable src, CACHED by + // eventId so the current image and its swipe neighbours share one + // decrypted blob — that's what lets a swipe slide between two + // already-decrypted images with no reload gap. Blob URLs (encrypted + // media) are tracked in `blobUrlsRef` so we revoke exactly those on + // eviction / unmount; plain http(s) srcs need no revoke. The + // concurrent-resolve re-check below means a rapidly-replaced load (or + // a preview + main racing on the same event) never leaks a duplicate + // blob. + const srcCacheRef = useRef>(new Map()); + const blobUrlsRef = useRef>(new Set()); + + const resolveMediaSrc = useCallback( + async (e: MediaViewerEntry): Promise => { + const cached = srcCacheRef.current.get(e.eventId); + if (cached) return cached; + const mediaUrl = mxcUrlToHttp(mx, e.url, useAuthentication); if (!mediaUrl) throw new Error('Invalid media URL'); - if (entryEncInfo) { - const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => - decryptFile(encBuf, entryMimeType ?? FALLBACK_MIMETYPE, entryEncInfo) - ); - const blob = URL.createObjectURL(fileContent); - if (lastBlobUrlRef.current && lastBlobUrlRef.current !== blob) { - URL.revokeObjectURL(lastBlobUrlRef.current); - } - lastBlobUrlRef.current = blob; - return blob; + const enc = e.encInfo; + if (!enc) { + srcCacheRef.current.set(e.eventId, mediaUrl); + return mediaUrl; } - return mediaUrl; - }, [mx, entryUrl, entryEncInfo, entryMimeType, useAuthentication]) + const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => + decryptFile(encBuf, e.mimeType ?? FALLBACK_MIMETYPE, enc) + ); + // A concurrent resolve for the same event may have won the cache + // while we decrypted — drop our duplicate blob and use the winner. + const winner = srcCacheRef.current.get(e.eventId); + if (winner) return winner; + const blob = URL.createObjectURL(fileContent); + blobUrlsRef.current.add(blob); + srcCacheRef.current.set(e.eventId, blob); + return blob; + }, + [mx, useAuthentication] + ); + + const [srcState, loadSrc] = useAsyncCallback( + useCallback(() => resolveMediaSrc(entry), [resolveMediaSrc, entry]) ); useEffect(() => { loadSrc(); }, [loadSrc]); - // Revoke any blob URL we still own when the viewer body unmounts - // (sheet closed entirely, room change, etc). + // Revoke every blob URL we created (and cancel a pending commit + // slide) when the viewer body unmounts — sheet closed, room change. useEffect( () => () => { - if (lastBlobUrlRef.current) { - URL.revokeObjectURL(lastBlobUrlRef.current); - lastBlobUrlRef.current = null; + blobUrlsRef.current.forEach((u) => URL.revokeObjectURL(u)); + blobUrlsRef.current.clear(); + srcCacheRef.current.clear(); + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; } }, [] @@ -377,24 +479,84 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { [mediaSet, entry.eventId] ); - const step = useCallback( + // Navigate prev/next with a slide. For image→image (and motion + // allowed, stage measured) we glide the current image out and the + // neighbour panel in over `SWIPE_ANIM_MS`, THEN swap the atom entry — + // because the neighbour was prefetched into the shared src cache the + // new current paints instantly at centre, so the swap is seamless. + // Anything else (video involved, reduced motion, stage not measured) + // falls back to an instant switch. Guarded against re-entry while a + // commit slide is already in flight. + const animatedStep = useCallback( (delta: -1 | 1) => { + if (commitTimerRef.current !== null) return; if (currentIndex < 0) return; - const target = currentIndex + delta; - if (target < 0 || target >= mediaSet.length) return; - const next = mediaSet[target]; - if (!next) return; - openMediaViewer(next); + const targetIndex = currentIndex + delta; + if (targetIndex < 0 || targetIndex >= mediaSet.length) return; + const target = mediaSet[targetIndex]; + if (!target) return; + const stageW = stageRef.current?.clientWidth ?? 0; + // Slide only at rest zoom (a zoomed image has no neighbour panels + // and shouldn't glide while scaled — chevron/arrow nav while zoomed + // switches instantly). `zoomRef` keeps this out of the deps. + const canSlide = + !prefersReducedMotion && + stageW > 0 && + zoomRef.current === 1 && + entry.kind === 'image' && + target.kind === 'image'; + if (!canSlide) { + setSwipeOffset(0); + openMediaViewer(target); + return; + } + setSwipeAnimating(true); + // delta +1 (next) slides content LEFT by one panel pitch (stage + + // gutter) → current glides off-screen while the prefetched next + // panel (sitting one pitch to the right) glides to centre. + const pitch = stageW + SWIPE_GAP_PX; + setSwipeOffset(delta === 1 ? -pitch : pitch); + commitTimerRef.current = window.setTimeout(() => { + commitTimerRef.current = null; + setSwipeAnimating(false); + setSwipeOffset(0); + openMediaViewer(target); + }, SWIPE_ANIM_MS); }, - [currentIndex, mediaSet, openMediaViewer] + [currentIndex, mediaSet, entry.kind, openMediaViewer, prefersReducedMotion] ); const canPrev = currentIndex > 0; const canNext = currentIndex >= 0 && currentIndex < mediaSet.length - 1; + // Prefetch the immediate image neighbours so a swipe slides into an + // already-decrypted picture, and prune cached srcs outside a small + // window around the current index (revoking their blobs) to bound + // memory. The current entry and its ±1 neighbours are always kept, so + // we never revoke a src a live is using. + useEffect(() => { + if (currentIndex < 0) return; + [mediaSet[currentIndex - 1], mediaSet[currentIndex + 1]].forEach((e) => { + if (e && e.kind === 'image') resolveMediaSrc(e).catch(() => undefined); + }); + const keep = new Set([entry.eventId]); + for (let d = -2; d <= 2; d += 1) { + const e = mediaSet[currentIndex + d]; + if (e) keep.add(e.eventId); + } + srcCacheRef.current.forEach((src, eid) => { + if (keep.has(eid)) return; + if (blobUrlsRef.current.has(src)) { + URL.revokeObjectURL(src); + blobUrlsRef.current.delete(src); + } + srcCacheRef.current.delete(eid); + }); + }, [currentIndex, mediaSet, entry.eventId, resolveMediaSrc]); + // Keyboard — ArrowLeft / Right step, +/- / 0 manage zoom. Esc is - // handled by the parent shell (focus-trap on desktop, explicit - // keydown listener on mobile). + // handled by the parent shell via an explicit window keydown listener + // (both the mobile horseshoe and the desktop floating window). useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented) return; @@ -407,10 +569,10 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { } if (e.key === 'ArrowLeft') { e.preventDefault(); - step(-1); + animatedStep(-1); } else if (e.key === 'ArrowRight') { e.preventDefault(); - step(1); + animatedStep(1); } else if (e.key === '+' || e.key === '=') { e.preventDefault(); zoomIn(); @@ -424,7 +586,7 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [step, zoomIn, zoomOut, setZoom]); + }, [animatedStep, zoomIn, zoomOut, setZoom]); // Multi-touch gesture state on the image stage. Three modes: // • zoom=1, 1 finger → horizontal-swipe to prev/next image. @@ -438,7 +600,8 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { // Chromium both fire for touch, which double-fires every handler. // The MDN reference pattern that drove this implementation: // https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events/Pinch_zoom_gestures - const [swipeOffset, setSwipeOffset] = useState(0); + // (`swipeOffset` / `swipeAnimating` are declared up top with the rest + // of the slide state.) const swipeStateRef = useRef({ active: false, @@ -487,8 +650,19 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { canPrevRef.current = canPrev; const canNextRef = useRef(canNext); canNextRef.current = canNext; - const stepRef = useRef(step); - stepRef.current = step; + // Whether the prev/next neighbour is an IMAGE (i.e. slides). A video + // neighbour (or a true edge) instead rubber-bands the drag — there's no + // slide panel to follow the finger into, it switches instantly on + // release. Mirrored for the once-installed gesture listeners. + const prevIsImageRef = useRef(false); + prevIsImageRef.current = currentIndex > 0 && mediaSet[currentIndex - 1]?.kind === 'image'; + const nextIsImageRef = useRef(false); + nextIsImageRef.current = + currentIndex >= 0 && + currentIndex < mediaSet.length - 1 && + mediaSet[currentIndex + 1]?.kind === 'image'; + const animatedStepRef = useRef(animatedStep); + animatedStepRef.current = animatedStep; const entryKindRef = useRef(entry.kind); entryKindRef.current = entry.kind; // `zoomRef` + `panRef` are already mirrored in the pan section @@ -531,6 +705,12 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { // mouse here for everything. if (e.pointerType === 'mouse') return; if (entryKindRef.current !== 'image') return; + // Ignore new touches while a commit slide is gliding (the entry is + // about to swap). Without this, a fresh pointerdown mid-glide would + // run `setSwipeOffset(0)` while `swipeAnimating` is still on and + // animate the outgoing image BACK toward centre — a reverse/stutter + // on fast double-swipes. Mirrors the same guard in `animatedStep`. + if (commitTimerRef.current !== null) return; // Capture the pointer so subsequent `pointermove`/`pointerup` // events are dispatched to the stage even when the finger @@ -651,8 +831,12 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { } if (s.claimed !== 'swipe') return; if (e.cancelable) e.preventDefault(); - const blocked = (dx > 0 && !canPrevRef.current) || (dx < 0 && !canNextRef.current); - setSwipeOffset(blocked ? dx / 3 : dx); + // Rubber-band (1/3 travel) when the swipe direction has nothing to + // slide into — a true list edge OR a video neighbour (videos switch + // instantly on release rather than gliding). A real image neighbour + // follows the finger 1:1. + const resisted = (dx > 0 && !prevIsImageRef.current) || (dx < 0 && !nextIsImageRef.current); + setSwipeOffset(resisted ? dx / 3 : dx); return; } @@ -699,11 +883,19 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { const s = swipeStateRef.current; if (s.active) { s.active = false; + let committed = false; if (s.claimed === 'swipe') { - if (s.lastDx > SWIPE_COMMIT_PX && canPrevRef.current) stepRef.current(-1); - else if (s.lastDx < -SWIPE_COMMIT_PX && canNextRef.current) stepRef.current(1); + if (s.lastDx > SWIPE_COMMIT_PX && canPrevRef.current) { + animatedStepRef.current(-1); + committed = true; + } else if (s.lastDx < -SWIPE_COMMIT_PX && canNextRef.current) { + animatedStepRef.current(1); + committed = true; + } } - setSwipeOffset(0); + // A commit lets `animatedStep` own the offset (glide to + // ±stageWidth); a non-committing release snaps back to centre. + if (!committed) setSwipeOffset(0); } touchPanStateRef.current = null; } @@ -766,7 +958,12 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { }; }, []); - const effectiveSrc = srcState.status === AsyncStatus.Success ? srcState.data : undefined; + // Prefer the shared cache so a just-committed (prefetched) entry paints + // instantly with no `useAsyncCallback` Loading flash; fall back to the + // loader's result for the first, uncached open. + const cachedSrc = srcCacheRef.current.get(entry.eventId); + const effectiveSrc = + cachedSrc ?? (srcState.status === AsyncStatus.Success ? srcState.data : undefined); const isLoading = !effectiveSrc && (srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Idle); @@ -795,89 +992,130 @@ export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) { const transform = isReady ? `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})` : undefined; - const imageTransition = - swipeOffset === 0 ? 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)' : 'none'; + // Transition ON while a commit slide settles (swipeAnimating) and at + // rest (offset 0 — smooth zoom + snap-back); OFF during an active drag + // so the image tracks the finger 1:1. + let imageTransition = 'none'; + if (swipeAnimating) { + imageTransition = `transform ${SWIPE_ANIM_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`; + } else if (swipeOffset === 0) { + imageTransition = 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)'; + } + + // Swipe filmstrip neighbours — only at rest zoom and for images (swipe + // is image-only). They slide WITH the current image (same offset, one + // stage-width to either side) so the prev/next picture is visible as + // the user drags and glides to centre on commit. + const prevEntry = currentIndex > 0 ? mediaSet[currentIndex - 1] : undefined; + const nextEntry = + currentIndex >= 0 && currentIndex < mediaSet.length - 1 + ? mediaSet[currentIndex + 1] + : undefined; + const showSwipePreviews = entry.kind === 'image' && zoom === 1 && stageWidth > 0; return (
- ` was visibly - // shorter and broke the cross-pane seam. - className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`} - > - - - {entry.body} - - {mediaSet.length > 1 && ( - - )} - - - {entry.kind === 'image' && ( - <> - - - - - {Math.round(zoom * 100)}% - - - - - - )} - - - - {entry.kind === 'image' &&