1203 lines
48 KiB
TypeScript
1203 lines
48 KiB
TypeScript
// 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>
|
||
);
|
||
}
|