vojo/src/app/features/room/MediaViewerBody.tsx

1203 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Body for the room media viewer — mounted inside both the mobile
// `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).
// • Mouse-drag pan when zoomed, with bounds clamping so the
// image can't be dragged off the stage.
// • Multi-media navigation: prev / next via on-screen chevrons,
// keyboard arrows, and horizontal swipe (image only — video
// keeps its native seekbar drag).
// • Programmatic `<video>` play on each entry change.
// • Download via `FileSaver`.
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Spinner, Text } from 'folds';
import classNames from 'classnames';
import FileSaver from 'file-saver';
import { MatrixEvent, MatrixEventEvent, MsgType, RoomEvent } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { PageHeader } from '../../components/page';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { MediaViewerEntry } from '../../state/mediaViewer';
import { useOpenMediaViewer } from '../../state/hooks/mediaViewer';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoom } from '../../hooks/useRoom';
import { useZoom } from '../../hooks/useZoom';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
import { FALLBACK_MIMETYPE } from '../../utils/mimeTypes';
import {
IImageContent,
IImageInfo,
IThumbnailContent,
IVideoContent,
IVideoInfo,
} from '../../../types/matrix/common';
import * as css from './MediaViewerBody.css';
// Distance past which a horizontal pointer drag commits to prev/next
// when zoom = 1 (matches the sheet's vertical close threshold).
const SWIPE_COMMIT_PX = 80;
// Horizontal must dominate vertical by at least this factor before
// 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
// the loaded scrollback) is intentionally not surfaced for phase 1;
// `step()` resistance-snaps at the boundary.
function mediaKindFor(ev: MatrixEvent): 'image' | 'video' | null {
if (ev.getType() !== 'm.room.message') return null;
if (ev.isRedacted()) return null;
const content = ev.getContent() as { msgtype?: string; url?: string; file?: { url?: string } };
if (!content) return null;
const hasMedia = !!content.url || !!content.file?.url;
if (!hasMedia) return null;
if (content.msgtype === MsgType.Image) return 'image';
if (content.msgtype === MsgType.Video) return 'video';
return null;
}
function eventToEntry(roomId: string, ev: MatrixEvent): MediaViewerEntry | null {
const kind = mediaKindFor(ev);
if (!kind) return null;
const content = ev.getContent() as (IImageContent | IVideoContent) | undefined;
if (!content) return null;
const eventId = ev.getId();
if (!eventId) return null;
const { file } = content;
const url = file?.url ?? content.url;
if (!url) return null;
const encInfo: EncryptedAttachmentInfo | undefined = file
? (() => {
// Strip the `url` field — `EncryptedAttachmentInfo` doesn't
// include it (the wire shape `IEncryptedFile` does).
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { url: _, ...rest } = file;
return rest as EncryptedAttachmentInfo;
})()
: undefined;
return {
roomId,
eventId,
kind,
url,
body: content.filename ?? content.body ?? eventId,
info: content.info as ((IImageInfo | IVideoInfo) & IThumbnailContent) | undefined,
encInfo,
mimeType: content.info?.mimetype,
};
}
// 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<string>;
offsetPx: number;
transition: string;
}) {
const [src, setSrc] = useState<string | undefined>(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 (
<div className={css.previewLayer} aria-hidden="true">
{src && (
<img
src={src}
alt=""
className={css.image}
draggable={false}
style={{ transform: `translate3d(${offsetPx}px, 0, 0)`, transition }}
/>
)}
</div>
);
}
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, hideHeader }: MediaViewerBodyProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = useRoom();
const openMediaViewer = useOpenMediaViewer();
// `useZoom` gives us `zoom` + `setZoom`; `zoomIn`/`zoomOut` from
// it use a flat max=5, but we want per-image dynamic ceiling.
// Override with custom +/ callbacks that read `maxZoomRef`.
const { zoom, setZoom } = useZoom(0.25);
// Pan is local (not the shared `usePan` hook) because we need to
// clamp against the image bounds so the user can't drag a zoomed
// image past its edges — `usePan` accumulates raw `movementX/Y`
// without bounds, which lets the picture escape off-screen.
// Clamp formula: with `transform: translate3d(X, Y) scale(zoom)`,
// the translate is applied AFTER scaling, so X/Y are in stage
// pixels. Max pan in each axis = (rendered_size * zoom -
// stage_size) / 2. Image fits within stage at zoom=1 (object-fit:
// contain), so clamp is zero there and pan auto-resets via the
// `useEffect` below that watches `zoom`.
const stageRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
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<number | null>(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
// pinch / wheel can read current zoom/pan synchronously.
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const panRef = useRef(pan);
panRef.current = pan;
// Per-image zoom bounds. `minZoom` is fixed at 1.0 — with
// `object-fit: contain` the image already fills the stage to fit
// at zoom=1, and going smaller just leaves dead space. `maxZoom`
// is computed from the natural image size in `onImageLoad` so a
// hi-res screenshot can zoom up to 1:1 pixels (~ natural /
// displayed) and a small avatar gets a friendlier 2× ceiling.
// Falls back to a flat 5× for the brief window before load.
const MIN_ZOOM = 1;
const ZOOM_CEILING_FALLBACK = 5;
const ZOOM_CEILING_HARD_CAP = 8;
const [maxZoom, setMaxZoom] = useState(ZOOM_CEILING_FALLBACK);
// `maxZoom` mirrored into a ref for the long-lived gesture
// listeners installed once with `deps=[]` below (they need the
// latest ceiling, not the value at install time).
const maxZoomRef = useRef(maxZoom);
maxZoomRef.current = maxZoom;
// Custom zoom +/ that respect the dynamic per-image ceiling
// (`useZoom`'s built-in `zoomIn`/`zoomOut` would cap at the
// flat default of 5). Step size 0.25 matches the original hook
// config.
const zoomIn = useCallback(() => {
setZoom((z) => Math.min(maxZoomRef.current, z + 0.25));
}, [setZoom]);
const zoomOut = useCallback(() => {
setZoom((z) => Math.max(MIN_ZOOM, z - 0.25));
}, [setZoom]);
const onImageLoad = useCallback(() => {
const img = imgRef.current;
if (!img) return;
if (img.naturalWidth === 0 || img.offsetWidth === 0) return;
// Ratio to reach 1:1 pixels — how far we'd need to scale the
// displayed (object-fit-contained) image to match its native
// resolution. For images naturally smaller than the display
// (avatars), this is <1; floor to 2 so the user can still
// inspect detail.
const oneToOne = img.naturalWidth / img.offsetWidth;
const computed = Math.max(2, oneToOne);
setMaxZoom(Math.min(ZOOM_CEILING_HARD_CAP, computed));
}, []);
const clampPan = useCallback((raw: { x: number; y: number }, currentZoom: number) => {
const stage = stageRef.current;
const img = imgRef.current;
if (!stage || !img) return raw;
const stageRect = stage.getBoundingClientRect();
const maxX = Math.max(0, (img.offsetWidth * currentZoom - stageRect.width) / 2);
const maxY = Math.max(0, (img.offsetHeight * currentZoom - stageRect.height) / 2);
return {
x: Math.max(-maxX, Math.min(maxX, raw.x)),
y: Math.max(-maxY, Math.min(maxY, raw.y)),
};
}, []);
// Anchor-aware zoom math (image-local point under anchor stays
// under the anchor after zoom change) is inlined in the pinch
// and wheel handlers below — they each need to construct the
// anchor from their own gesture state, so a shared helper would
// just be plumbing.
// Reset zoom, pan, and max-zoom-ceiling whenever the active
// entry changes (swipe / arrow / programmatic). Without
// resetting `maxZoom`, navigating from a low-res image (where
// `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).
useEffect(() => {
setPan((prev) => {
const next = clampPan(prev, zoom);
if (next.x === prev.x && next.y === prev.y) return prev;
return next;
});
setPanCursor(zoom === 1 ? 'initial' : 'grab');
}, [zoom, clampPan]);
// Mouse-drag pan. Touch is handled by the stage-listener block
// below (which routes touch to swipe when zoom=1, ignores when
// zoom > 1 — pinch + touch-pan is a follow-up).
const onMouseDown: MouseEventHandler<HTMLImageElement> = useCallback(
(evt) => {
if (zoomRef.current === 1) return;
evt.preventDefault();
setPanCursor('grabbing');
const onMove = (mEvt: MouseEvent) => {
mEvt.preventDefault();
mEvt.stopPropagation();
const raw = {
x: panRef.current.x + mEvt.movementX,
y: panRef.current.y + mEvt.movementY,
};
const clamped = clampPan(raw, zoomRef.current);
panRef.current = clamped;
setPan(clamped);
};
const onUp = (mEvt: MouseEvent) => {
mEvt.preventDefault();
setPanCursor('grab');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
[clampPan]
);
// 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<Map<string, string>>(new Map());
const blobUrlsRef = useRef<Set<string>>(new Set());
const resolveMediaSrc = useCallback(
async (e: MediaViewerEntry): Promise<string> => {
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');
const enc = e.encInfo;
if (!enc) {
srcCacheRef.current.set(e.eventId, mediaUrl);
return mediaUrl;
}
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 every blob URL we created (and cancel a pending commit
// slide) when the viewer body unmounts — sheet closed, room change.
useEffect(
() => () => {
blobUrlsRef.current.forEach((u) => URL.revokeObjectURL(u));
blobUrlsRef.current.clear();
srcCacheRef.current.clear();
if (commitTimerRef.current !== null) {
window.clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
},
[]
);
// Build the room's image set. Recomputed on each render — cheap
// (timeline events are already in memory). Not subscribed to new
// events for phase 1; the user navigates among the images that
// were visible when they opened the viewer.
// Bump a version counter on every live-timeline mutation in this
// room so `mediaSet` rebuilds when a new image / video event
// arrives mid-viewing — without this the user navigates a frozen
// snapshot. Mirror of Element-Web's FilePanel pattern. Redaction
// is also subscribed so a remotely-deleted image disappears from
// the prev/next chain.
//
// Per-event `MatrixEventEvent.Decrypted` subscription is deliberately
// skipped (cost of attaching N listeners). Late-arriving decrypts
// of pre-existing events are an accepted phase-1 limitation —
// documented in the comment on `mediaKindFor`.
const [timelineVersion, setTimelineVersion] = useState(0);
useEffect(() => {
const bump = () => setTimelineVersion((v) => v + 1);
// Filter Timeline events to media kinds so the mediaSet
// recompute doesn't fire on every reaction / edit / receipt /
// sync rollback (busy rooms produce dozens per second). Each
// recompute does an O(n) walk of `getLiveTimeline().getEvents()`.
const onTimeline = (ev: MatrixEvent) => {
if (mediaKindFor(ev) !== null) bump();
};
// Encrypted rooms (Vojo's primary surface): events arrive as
// `m.room.encrypted` and decrypt asynchronously after the
// Timeline event has fired — `mediaKindFor` returns null at
// that moment, so `onTimeline` above doesn't bump. When the
// event decrypts, `MatrixEventEvent.Decrypted` fires on the
// event itself. We attach via the MatrixClient so we catch
// every decrypt without subscribing to N per-event listeners,
// and filter to this room. Mirror of Element-Web's `FilePanel`
// pattern.
const onDecrypted = (ev: MatrixEvent) => {
if (ev.getRoomId() !== room.roomId) return;
if (mediaKindFor(ev) !== null) bump();
};
// Redactions: bump unconditionally — the redacted event may
// have been a media we're currently navigating, and we can't
// re-derive its kind once redacted.
room.on(RoomEvent.Timeline, onTimeline);
room.on(RoomEvent.Redaction, bump);
mx.on(MatrixEventEvent.Decrypted, onDecrypted);
return () => {
room.removeListener(RoomEvent.Timeline, onTimeline);
room.removeListener(RoomEvent.Redaction, bump);
mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
};
}, [mx, room]);
const mediaSet = useMemo(() => {
const events = room.getLiveTimeline().getEvents();
const entries: MediaViewerEntry[] = [];
events.forEach((ev) => {
const e = eventToEntry(room.roomId, ev);
if (e) entries.push(e);
});
return entries;
// `timelineVersion` is the actual refresh trigger; `entry.eventId`
// is intentionally NOT a dep — stepping doesn't change the set.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, timelineVersion]);
const currentIndex = useMemo(
() => mediaSet.findIndex((e) => e.eventId === entry.eventId),
[mediaSet, entry.eventId]
);
// 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 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, 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 <img> 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<string>([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 via an explicit window keydown listener
// (both the mobile horseshoe and the desktop floating window).
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
) {
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
animatedStep(-1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
animatedStep(1);
} else if (e.key === '+' || e.key === '=') {
e.preventDefault();
zoomIn();
} else if (e.key === '-' || e.key === '_') {
e.preventDefault();
zoomOut();
} else if (e.key === '0') {
e.preventDefault();
setZoom(1);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [animatedStep, zoomIn, zoomOut, setZoom]);
// Multi-touch gesture state on the image stage. Three modes:
// • zoom=1, 1 finger → horizontal-swipe to prev/next image.
// • zoom>1, 1 finger → touch pan (clamped to image bounds; same
// formula as the mouse pan path).
// • 2 fingers → pinch zoom (distance ratio drives zoom; pan
// resets to 0 so the image stays centered while pinching).
// Listeners are installed ONCE and read the latest closure values
// via refs — without that, a zoom-button press would re-bind every
// listener mid-gesture. PointerEvents only (no TouchEvents) — on
// 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
// (`swipeOffset` / `swipeAnimating` are declared up top with the rest
// of the slide state.)
const swipeStateRef = useRef({
active: false,
startX: 0,
startY: 0,
lastDx: 0,
claimed: 'none' as 'none' | 'swipe' | 'reject',
});
// Active touch pointers keyed by `pointerId`. Map (not array)
// because PointerEvents can arrive interleaved across multiple
// pointers and we need to update the per-id entry on each move.
const pointerCacheRef = useRef<Map<number, { x: number; y: number }>>(new Map());
// Pinch state. `null` when not pinching; set on the 2nd pointer
// down. `initialDistance` is the finger spread at pinch start;
// `initialZoom` and `initialPan` snapshot the state we'll scale
// from. `anchorImageX/Y` is the image-local point under the
// pinch midpoint at the start — held constant under the user's
// fingers as zoom changes (anchor-aware pinch, mirrors what
// Apple Photos / Google Photos / Element-Web's wheel-zoom do).
const pinchStateRef = useRef<{
initialDistance: number;
initialZoom: number;
initialPan: { x: number; y: number };
anchorImageX: number;
anchorImageY: number;
} | null>(null);
// Touch pan state. `null` when not panning. Stores the pan offset
// at the start of the gesture so we can compute the delta off the
// current pointer position (matches Element-Web's
// absolute-delta-from-anchor approach, more reliable than
// accumulating `movementX/Y`).
const touchPanStateRef = useRef<{
startX: number;
startY: number;
panAtStartX: number;
panAtStartY: number;
} | null>(null);
// Refs mirror the latest values for the always-installed listeners
// below. Without these, the listeners would close over the values
// from the render that installed them.
const canPrevRef = useRef(canPrev);
canPrevRef.current = canPrev;
const canNextRef = useRef(canNext);
canNextRef.current = canNext;
// 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
// above; reusing them here keeps the source of truth singular.
const clampPanRef = useRef(clampPan);
clampPanRef.current = clampPan;
const setZoomRef = useRef(setZoom);
setZoomRef.current = setZoom;
// `maxZoomRef` + `zoomIn` / `zoomOut` are declared higher up
// (right after the `maxZoom` state) because the keyboard
// useEffect above also depends on them.
// Reset gesture state on entry change. A finger that lifted
// off-stage (no `pointerup` delivered) leaves residue in
// `pointerCacheRef`, so without this the next entry's first
// `pointerdown` sees `count === 2` from nothing and starts a
// fake pinch on a stale midpoint. Similar concern for swipe /
// touch-pan state. `setPointerCapture` (added in the listeners
// below) should normally guarantee a pointerup delivery; this
// reset is the defence-in-depth.
useEffect(() => {
pointerCacheRef.current.clear();
pinchStateRef.current = null;
touchPanStateRef.current = null;
swipeStateRef.current.active = false;
swipeStateRef.current.claimed = 'none';
setSwipeOffset(0);
}, [entry.eventId]);
useEffect(() => {
const stageEl = stageRef.current;
if (!stageEl) return undefined;
const distanceBetween = (a: { x: number; y: number }, b: { x: number; y: number }) =>
Math.hypot(a.x - b.x, a.y - b.y);
const onPointerDown = (e: PointerEvent) => {
// Mouse drag at zoom>1 is handled by the image's `onMouseDown`
// → pan; mouse drag at zoom=1 has no useful gesture. Skip
// 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
// leaves the stage rect (e.g. user pans onto the header).
// Without this, the gesture can be stranded mid-flight with
// a finger off-stage, leaving `pointerCacheRef` populated and
// breaking the next gesture. `try/catch` because some
// browsers reject capture on non-trusted events under
// automation harnesses.
try {
stageEl.setPointerCapture(e.pointerId);
} catch {
// ignore — capture is best-effort
}
pointerCacheRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
const count = pointerCacheRef.current.size;
if (count === 2) {
// 2nd finger arrived — switch to pinch mode. Cancel any
// in-flight single-finger gesture (swipe / touch pan) so
// it doesn't leak state into the pinch.
const pts = Array.from(pointerCacheRef.current.values());
// Pinch midpoint → stage-center-relative anchor → derive
// the image-local point that's under the midpoint right
// now. Holding this point under the moving midpoint is
// what makes the zoom feel anchored.
const midClientX = (pts[0].x + pts[1].x) / 2;
const midClientY = (pts[0].y + pts[1].y) / 2;
const stageRect = stageEl.getBoundingClientRect();
const anchorX = midClientX - (stageRect.left + stageRect.width / 2);
const anchorY = midClientY - (stageRect.top + stageRect.height / 2);
const z0 = zoomRef.current;
const p0 = panRef.current;
pinchStateRef.current = {
initialDistance: distanceBetween(pts[0], pts[1]),
initialZoom: z0,
initialPan: { x: p0.x, y: p0.y },
anchorImageX: (anchorX - p0.x) / z0,
anchorImageY: (anchorY - p0.y) / z0,
};
if (swipeStateRef.current.active) {
swipeStateRef.current.active = false;
setSwipeOffset(0);
}
touchPanStateRef.current = null;
return;
}
if (count === 1) {
if (zoomRef.current === 1) {
// 1 finger + zoom=1 → swipe
const s = swipeStateRef.current;
s.active = true;
s.startX = e.clientX;
s.startY = e.clientY;
s.lastDx = 0;
s.claimed = 'none';
setSwipeOffset(0);
} else {
// 1 finger + zoom>1 → touch pan
touchPanStateRef.current = {
startX: e.clientX,
startY: e.clientY,
panAtStartX: panRef.current.x,
panAtStartY: panRef.current.y,
};
}
}
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType === 'mouse') return;
const cached = pointerCacheRef.current.get(e.pointerId);
if (!cached) return;
pointerCacheRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
// Pinch always wins when 2 fingers are down.
if (pinchStateRef.current && pointerCacheRef.current.size === 2) {
if (e.cancelable) e.preventDefault();
const ps = pinchStateRef.current;
const pts = Array.from(pointerCacheRef.current.values());
const dist = distanceBetween(pts[0], pts[1]);
const ratio = dist / ps.initialDistance;
const nextZoom = Math.max(MIN_ZOOM, Math.min(maxZoomRef.current, ps.initialZoom * ratio));
// Anchor-aware: hold `anchorImage*` (image-local point
// captured at pinch start) under the moving midpoint.
const midClientX = (pts[0].x + pts[1].x) / 2;
const midClientY = (pts[0].y + pts[1].y) / 2;
const stageRect = stageEl.getBoundingClientRect();
const anchorStageX = midClientX - (stageRect.left + stageRect.width / 2);
const anchorStageY = midClientY - (stageRect.top + stageRect.height / 2);
const rawPan = {
x: anchorStageX - ps.anchorImageX * nextZoom,
y: anchorStageY - ps.anchorImageY * nextZoom,
};
const clamped = clampPanRef.current(rawPan, nextZoom);
panRef.current = clamped;
setPan(clamped);
setZoomRef.current(nextZoom);
return;
}
// Single-finger paths run only if pinch isn't active.
if (pinchStateRef.current) return;
// Swipe path (zoom=1).
const s = swipeStateRef.current;
if (s.active) {
const dx = e.clientX - s.startX;
const dy = e.clientY - s.startY;
s.lastDx = dx;
if (s.claimed === 'none') {
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
if (Math.abs(dx) > Math.abs(dy) * SWIPE_DOMINANCE) s.claimed = 'swipe';
else s.claimed = 'reject';
}
}
if (s.claimed !== 'swipe') return;
if (e.cancelable) e.preventDefault();
// 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;
}
// Touch-pan path (zoom>1).
const tp = touchPanStateRef.current;
if (tp) {
if (e.cancelable) e.preventDefault();
const raw = {
x: tp.panAtStartX + (e.clientX - tp.startX),
y: tp.panAtStartY + (e.clientY - tp.startY),
};
const clamped = clampPanRef.current(raw, zoomRef.current);
panRef.current = clamped;
setPan(clamped);
}
};
const onPointerEnd = (e: PointerEvent) => {
if (e.pointerType === 'mouse') return;
// Release the pointer capture taken on `pointerdown`. Browsers
// auto-release on `pointerup` / `pointercancel`, but
// explicitly calling it on a non-captured pointer is a no-op
// and the explicit release pairs the API call for clarity.
try {
if (stageEl.hasPointerCapture(e.pointerId)) {
stageEl.releasePointerCapture(e.pointerId);
}
} catch {
// ignore
}
pointerCacheRef.current.delete(e.pointerId);
const count = pointerCacheRef.current.size;
if (pinchStateRef.current && count < 2) {
// Pinch ended. Reset pinch state but DON'T immediately
// switch back to swipe / pan — the remaining finger (if
// any) needs to lift first and the user starts a fresh
// gesture cleanly.
pinchStateRef.current = null;
return;
}
if (count === 0) {
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) {
animatedStepRef.current(-1);
committed = true;
} else if (s.lastDx < -SWIPE_COMMIT_PX && canNextRef.current) {
animatedStepRef.current(1);
committed = true;
}
}
// 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;
}
};
// Desktop wheel zoom — anchored to the cursor (the same point
// under the cursor stays under the cursor as zoom changes).
// Element-Web's idiom: step factor proportional to `deltaY`,
// sign inverted so wheel-up zooms IN. `passive: false` so we
// can preventDefault the browser's native page scroll.
//
// Browsers send wheel events with very different `deltaMode`
// values (pixel vs line vs page). We normalize to pixels.
const onWheel = (e: WheelEvent) => {
if (entryKindRef.current !== 'image') return;
e.preventDefault();
let dyPx: number;
if (e.deltaMode === 1) {
dyPx = e.deltaY * 16; // line mode → ~16px / line
} else if (e.deltaMode === 2) {
dyPx = e.deltaY * stageEl.clientHeight; // page mode
} else {
dyPx = e.deltaY;
}
const factor = Math.exp(-dyPx * 0.0025);
const oldZoom = zoomRef.current;
const nextZoomRaw = oldZoom * factor;
const nextZoom = Math.max(MIN_ZOOM, Math.min(maxZoomRef.current, nextZoomRaw));
if (nextZoom === oldZoom) return;
// Anchor-aware: hold the image-local point under the cursor
// fixed across the zoom change.
const stageRect = stageEl.getBoundingClientRect();
const anchorX = e.clientX - (stageRect.left + stageRect.width / 2);
const anchorY = e.clientY - (stageRect.top + stageRect.height / 2);
const p0 = panRef.current;
const imageX = (anchorX - p0.x) / oldZoom;
const imageY = (anchorY - p0.y) / oldZoom;
const rawPan = {
x: anchorX - imageX * nextZoom,
y: anchorY - imageY * nextZoom,
};
const clamped = clampPanRef.current(rawPan, nextZoom);
panRef.current = clamped;
setPan(clamped);
setZoomRef.current(nextZoom);
};
stageEl.addEventListener('pointerdown', onPointerDown);
stageEl.addEventListener('pointermove', onPointerMove);
stageEl.addEventListener('pointerup', onPointerEnd);
stageEl.addEventListener('pointercancel', onPointerEnd);
stageEl.addEventListener('wheel', onWheel, { passive: false });
return () => {
stageEl.removeEventListener('pointerdown', onPointerDown);
stageEl.removeEventListener('pointermove', onPointerMove);
stageEl.removeEventListener('pointerup', onPointerEnd);
stageEl.removeEventListener('pointercancel', onPointerEnd);
stageEl.removeEventListener('wheel', onWheel);
};
}, []);
// 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);
const isError = !effectiveSrc && srcState.status === AsyncStatus.Error;
const isReady = !!effectiveSrc;
const handleDownload = useCallback(async () => {
if (!effectiveSrc) return;
FileSaver.saveAs(effectiveSrc, entry.body);
}, [effectiveSrc, entry.body]);
// Video autoplay: the `<video>` element below ships with the
// `muted` + `autoPlay` HTML attributes. Muted autoplay is
// universally allowed (no gesture-activation needed) across
// Chrome / Firefox / WebKit / Android Capacitor / iOS Safari —
// see the MDN Autoplay guide. Unmuted autoplay would require a
// fresh user-gesture per video, which fails on iOS Safari
// after the first step in this viewer. Element-Web takes the
// same `muted` stance for the same reason. The user can unmute
// via the native `<video>` controls; subsequent videos in the
// same viewer session keep their unmuted state inherited
// through the element remount (key={entry.eventId} forces a
// fresh element, so unmute does NOT persist — accepted UX
// trade for "playing always works").
const transform = isReady
? `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})`
: undefined;
// 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 (
<div className={css.root}>
{!hideHeader && (
<PageHeader
// `PageHeader` (= folds `Header size="600"`) so the header's
// bottom rule aligns to the pixel with the chat header to the
// left across the 12px void gap — same trick the profile
// side pane uses. A plain `<div height: 48px>` was visibly
// shorter and broke the cross-pane seam.
className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}
>
<Box grow="Yes" alignItems="Center" gap="200">
<Text className={css.title} size="H4" truncate>
{entry.body}
</Text>
{mediaSet.length > 1 && (
<span className={css.indexPill} aria-hidden="true">
{currentIndex >= 0 ? currentIndex + 1 : '?'} / {mediaSet.length}
</span>
)}
</Box>
<Box shrink="No" alignItems="Center" gap="100">
{entry.kind === 'image' && (
<>
<IconButton
variant="Background"
fill="None"
radii="300"
size="300"
onClick={zoomOut}
aria-label={t('MediaViewer.zoom_out', 'Zoom out')}
>
<Icon size="100" src={Icons.Minus} />
</IconButton>
<Text size="B300" as="span" style={{ minWidth: '3em', textAlign: 'center' }}>
{Math.round(zoom * 100)}%
</Text>
<IconButton
variant="Background"
fill="None"
radii="300"
size="300"
onClick={zoomIn}
aria-label={t('MediaViewer.zoom_in', 'Zoom in')}
>
<Icon size="100" src={Icons.Plus} />
</IconButton>
</>
)}
<IconButton
variant="Background"
fill="None"
radii="300"
size="300"
onClick={handleDownload}
disabled={!isReady}
aria-label={t('MediaViewer.download', 'Download')}
>
<Icon size="100" src={Icons.Download} />
</IconButton>
{entry.kind === 'image' && <div className={css.headerSeparator} aria-hidden="true" />}
<IconButton
variant="Background"
fill="None"
radii="300"
size="300"
onClick={requestClose}
aria-label={t('Common.close', 'Close')}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
</PageHeader>
)}
<div className={css.stage} ref={stageRef}>
{showSwipePreviews && prevEntry?.kind === 'image' && (
<SwipePreview
key={prevEntry.eventId}
entry={prevEntry}
resolveSrc={resolveMediaSrc}
offsetPx={swipeOffset - stageWidth - SWIPE_GAP_PX}
transition={imageTransition}
/>
)}
{showSwipePreviews && nextEntry?.kind === 'image' && (
<SwipePreview
key={nextEntry.eventId}
entry={nextEntry}
resolveSrc={resolveMediaSrc}
offsetPx={swipeOffset + stageWidth + SWIPE_GAP_PX}
transition={imageTransition}
/>
)}
{isReady && effectiveSrc && entry.kind === 'image' && (
// `onMouseDown` drives drag-pan when the image is zoomed in.
// Keyboard pan is wired separately at the stage level. Keyed by
// eventId (like the video) so a commit mounts a fresh element
// centred at offset 0 — no slide-back from the committed edge.
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<img
ref={imgRef}
key={entry.eventId}
src={effectiveSrc}
alt={entry.body}
className={css.image}
draggable={false}
style={{
transform,
cursor: panCursor,
transition: imageTransition,
}}
onMouseDown={onMouseDown}
onLoad={onImageLoad}
/>
)}
{isReady && effectiveSrc && entry.kind === 'video' && (
<video
ref={videoRef}
key={entry.eventId}
src={effectiveSrc}
title={entry.body}
className={css.image}
controls
// Muted + autoPlay: see the rationale comment above
// — muted autoplay is the only policy-compliant way
// to start every video automatically including on iOS
// Safari after a swipe / chevron step. User unmutes
// via native controls.
muted
autoPlay
playsInline
style={{
transform: `translate3d(${swipeOffset}px, 0, 0)`,
transition: imageTransition,
}}
/>
)}
{isLoading && (
<div className={css.stateOverlay}>
<Spinner variant="Secondary" size="600" />
</div>
)}
{isError && (
<div className={css.stateOverlay} style={{ pointerEvents: 'auto' }}>
<IconButton
variant="Critical"
fill="Soft"
radii="300"
size="400"
onClick={() => loadSrc()}
aria-label={t('Common.retry', 'Retry')}
>
<Icon size="200" src={Icons.Warning} />
</IconButton>
</div>
)}
{mediaSet.length > 1 && (
<>
<button
type="button"
className={classNames(css.chevron, css.chevronLeft)}
aria-label={t('MediaViewer.previous', 'Previous')}
// `aria-disabled` (not `disabled`) so the CSS dimmed-
// state selector still matches; the onClick already
// bails internally via `animatedStep`'s bounds check.
aria-disabled={!canPrev}
onClick={() => animatedStep(-1)}
>
<Icon size="200" src={Icons.ChevronLeft} />
</button>
<button
type="button"
className={classNames(css.chevron, css.chevronRight)}
aria-label={t('MediaViewer.next', 'Next')}
aria-disabled={!canNext}
onClick={() => animatedStep(1)}
>
<Icon size="200" src={Icons.ChevronRight} />
</button>
</>
)}
</div>
</div>
);
}