feat(media): rework viewer paging into a persistent no-remount slide filmstrip with drag-anywhere-to-close and rounded sheet corners

This commit is contained in:
heaven 2026-06-09 14:39:52 +03:00
parent b210f2b3a2
commit 4dcfe7fd1d
4 changed files with 475 additions and 214 deletions

View file

@ -71,10 +71,11 @@ export const image = style({
imageRendering: 'auto',
});
// Swipe-filmstrip neighbour layer — fills the stage, centres its image
// like the current one, and ignores pointers (the current image owns the
// gesture surface). Translated by the parent so prev/next slide with the
// drag.
// Shared filmstrip slide layer — one per ±1 window slot, fills the stage and
// centres its image; the content (img/video) is translated by the parent per
// slot so the strip slides on a swipe. Base `pointer-events: none` is the
// NEIGHBOUR default; the parent overrides it to `auto` on the ACTIVE slide
// (which owns the gesture surface — mouse-pan / native video controls).
export const previewLayer = style({
position: 'absolute',
inset: 0,

View file

@ -6,9 +6,10 @@
// • 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).
// • Multi-media navigation: prev / next via on-screen chevrons
// (web only), keyboard arrows, and horizontal swipe. Images glide
// on swipe; videos swipe too but only from above the native control
// band (so the seekbar drag still scrubs) and switch instantly.
// • Programmatic `<video>` play on each entry change.
// • Download via `FileSaver`.
@ -29,6 +30,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoom } from '../../hooks/useRoom';
import { useZoom } from '../../hooks/useZoom';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
import { isNativePlatform } from '../../utils/capacitor';
import { FALLBACK_MIMETYPE } from '../../utils/mimeTypes';
import {
IImageContent,
@ -53,6 +55,17 @@ const SWIPE_ANIM_MS = 280;
// 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;
// Height of the bottom strip on a `<video controls>` reserved for the
// native control cluster. A horizontal page-swipe / drag-to-close must NOT
// start inside it or it would steal the seekbar's own horizontal drag. On
// Android Chrome the stacked cluster (timeline + button row) is ~96px and
// the seekbar itself is the bottom ~48px; 88px comfortably covers the
// seekbar plus most of the button row (a DRAG starting on a button would
// otherwise read as a swipe — a TAP still passes through, since nothing is
// claimed until the drag threshold). Clamped to 40% of the video's rendered
// height so a short clip stays swipeable above its controls. NB: the exact
// native band is browser/version-variable — worth an on-device sanity check.
const VIDEO_CONTROL_BAND_PX = 88;
// Walk the room's loaded timeline events and build the ordered set
// of viewable image + video events. Out-of-window media (older than
@ -101,45 +114,109 @@ function eventToEntry(roomId: string, ev: MatrixEvent): MediaViewerEntry | null
};
}
// A non-interactive neighbour image for the swipe «filmstrip». It resolves
// (and shares, via the parent's `resolveSrc` cache) its own decrypted src so
// the prev / next image is already on-screen as the user drags, then slides
// to centre on commit. Image-only — videos keep instant navigation. Sits
// BEHIND the interactive current image (`pointer-events: none`).
function SwipePreview({
// One slide of the viewer's persistent filmstrip. The ±1 window around the
// current entry is always mounted and keyed by eventId, so paging is a track
// re-translate + index shift: the element that was the neighbour BECOMES the
// active slide without a remount — no decode-blank flash. Only the ACTIVE
// slide is interactive (zoom/pan via the parent's `activeTransform` + refs +
// handlers); neighbours are static, decoded-ahead images one pitch to either
// side that slide in on a swipe. A video renders only while active (video
// steps are instant — a video never glides into view as a neighbour).
function Slide({
entry,
resolveSrc,
offsetPx,
isActive,
activeSrc,
slideTransform,
activeTransform,
transition,
panCursor,
imgRefCb,
videoRefCb,
onMouseDown,
onImageLoad,
}: {
entry: MediaViewerEntry;
resolveSrc: (e: MediaViewerEntry) => Promise<string>;
offsetPx: number;
isActive: boolean;
activeSrc: string | undefined;
slideTransform: string;
activeTransform: string;
transition: string;
panCursor: 'grab' | 'grabbing' | 'initial';
imgRefCb: (el: HTMLImageElement | null) => void;
videoRefCb: (el: HTMLVideoElement | null) => void;
onMouseDown: MouseEventHandler<HTMLImageElement>;
onImageLoad: () => void;
}) {
const [src, setSrc] = useState<string | undefined>(undefined);
// Neighbours resolve their own decrypted src (shared cache) so they're
// decoded before they slide in. The active slide instead uses the parent's
// resolved `activeSrc` — the SAME cached blob, but routed through the parent
// so its loading / error / retry + download chrome stays in sync.
const [ownSrc, setOwnSrc] = useState<string | undefined>(undefined);
useEffect(() => {
let alive = true;
resolveSrc(entry)
.then((s) => {
if (alive) setSrc(s);
if (alive) setOwnSrc(s);
})
.catch(() => {
/* neighbour preview is best-effort */
/* best-effort — the parent's loader surfaces errors for the active slide */
});
return () => {
alive = false;
};
}, [entry, resolveSrc]);
const src = isActive ? activeSrc : ownSrc;
if (entry.kind === 'video') {
// Neighbour videos never glide into view, so they render nothing; only the
// active video mounts (controls + muted autoplay). Switching to a video is
// instant (no slide), so this mount-on-activation reads fine.
if (!isActive || !src) return null;
return (
<div className={css.previewLayer} aria-hidden="true">
{src && (
<img
<div className={css.previewLayer} style={{ pointerEvents: 'auto' }}>
<video
ref={videoRefCb}
src={src}
alt=""
title={entry.body}
className={css.image}
controls
muted
autoPlay
playsInline
style={{ transform: slideTransform, transition }}
/>
</div>
);
}
return (
<div
className={css.previewLayer}
aria-hidden={!isActive}
style={{ pointerEvents: isActive ? 'auto' : 'none' }}
>
{src && (
// `onMouseDown` drives drag-pan when the active image is zoomed in.
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<img
ref={isActive ? imgRefCb : undefined}
src={src}
alt={isActive ? entry.body : ''}
className={css.image}
draggable={false}
style={{ transform: `translate3d(${offsetPx}px, 0, 0)`, transition }}
// Lets the parent's reset effect confirm `imgRef` points at the
// now-active image before deriving its zoom ceiling (see there).
data-event-id={entry.eventId}
style={{
transform: isActive ? activeTransform : slideTransform,
transition,
cursor: isActive ? panCursor : undefined,
}}
onMouseDown={isActive ? onMouseDown : undefined}
onLoad={isActive ? onImageLoad : undefined}
/>
)}
</div>
@ -155,9 +232,21 @@ type MediaViewerBodyProps = {
// there. The mobile horseshoe + (legacy) side pane leave it false to
// keep the full toolbar.
hideHeader?: boolean;
// Vertical drag-to-close sink. The mobile sheet passes this so a
// downward drag from ANYWHERE on the stage (zoom=1 only — a zoomed
// image pans instead) drives the sheet's close. Called live with the
// raw downward finger distance (`dragging: true`) and once on release
// (`dragging: false`); the sheet owns the rubber-band + commit
// threshold. The desktop window leaves it undefined → no close drag.
onCloseDrag?: (rawDeltaY: number, dragging: boolean) => void;
};
export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewerBodyProps) {
export function MediaViewerBody({
entry,
requestClose,
hideHeader,
onCloseDrag,
}: MediaViewerBodyProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
@ -180,8 +269,11 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
// 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);
// `| null` (→ MutableRefObject) because the active slide assigns these via a
// ref callback (`setActiveImgRef` / `setActiveVideoRef`) rather than React's
// static `ref=` binding, so we write `.current` ourselves.
const imgRef = useRef<HTMLImageElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [panCursor, setPanCursor] = useState<'grab' | 'grabbing' | 'initial'>('initial');
@ -194,6 +286,12 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
// slide. Gesture detail is in the big stage-listener effect below.
const [swipeOffset, setSwipeOffset] = useState(0);
const [swipeAnimating, setSwipeAnimating] = useState(false);
// True for the single render after an INSTANT (non-glide) step so the
// persisted incoming slide swaps WITHOUT a transition — otherwise it would
// animate in from the neighbour slot (and, while zoomed, inherit the previous
// slide's zoom/pan and wobble back to fit). Cleared in the entry-change reset
// effect once the incoming slide is at centre.
const [instantStep, setInstantStep] = useState(false);
const [stageWidth, setStageWidth] = useState(0);
const commitTimerRef = useRef<number | null>(null);
const prefersReducedMotion = useMemo(
@ -254,6 +352,18 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
setMaxZoom(Math.min(ZOOM_CEILING_HARD_CAP, computed));
}, []);
// Point the parent's `imgRef` / `videoRef` at whichever slide is currently
// active. A ref callback (not a static `ref`) lets the persistent filmstrip
// hand the ref to the new element as the active slide changes, and ignoring
// the `null` detach keeps the ref pointing at the live active element across
// the swap (the attach of the new active element wins regardless of order).
const setActiveImgRef = useCallback((el: HTMLImageElement | null) => {
if (el) imgRef.current = el;
}, []);
const setActiveVideoRef = useCallback((el: HTMLVideoElement | null) => {
if (el) videoRef.current = el;
}, []);
const clampPan = useCallback((raw: { x: number; y: number }, currentZoom: number) => {
const stage = stageRef.current;
const img = imgRef.current;
@ -284,12 +394,34 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
useEffect(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
// Re-derive the per-image zoom ceiling for the now-active image. In the
// persistent filmstrip it was usually already loaded as a neighbour (so its
// onLoad won't refire on activation) — read its natural size directly. The
// `dataset.eventId` check ensures `imgRef` actually points at the NOW-active
// image: the ref callback ignores the null detach, so when the new active
// slide's src isn't resolved yet it can still point at the prior image —
// then we fall back to the flat ceiling and its onLoad sets the real one.
const activeImg = imgRef.current;
if (
activeImg &&
activeImg.dataset.eventId === entry.eventId &&
activeImg.naturalWidth > 0 &&
activeImg.offsetWidth > 0
) {
setMaxZoom(
Math.min(ZOOM_CEILING_HARD_CAP, Math.max(2, activeImg.naturalWidth / activeImg.offsetWidth))
);
} else {
setMaxZoom(ZOOM_CEILING_FALLBACK);
}
if (commitTimerRef.current !== null) {
window.clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
setSwipeAnimating(false);
// Re-enable transitions after an instant step (the incoming slide is now at
// centre, so flipping the transition back on doesn't retroactively animate).
setInstantStep(false);
}, [entry.eventId, setZoom]);
// Track the stage width so the swipe filmstrip can position the
@ -506,14 +638,25 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
entry.kind === 'image' &&
target.kind === 'image';
if (!canSlide) {
// Instant switch (zoomed, reduced-motion, video, or unmeasured stage).
// The incoming slide is a PERSISTED neighbour, so reset its transform
// state synchronously and flag this commit as instant — without that it
// would animate in from the neighbour slot, and while zoomed it would
// inherit the previous slide's zoom/pan and wobble back to fit.
setInstantStep(true);
setZoom(1);
setPan({ x: 0, y: 0 });
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.
// delta +1 (next) slides the whole filmstrip LEFT by one panel pitch
// (stage + gutter): the current slide glides off-screen while the
// already-mounted next slide glides to centre. At the commit below the
// window shifts (currentIndex += delta) and swipeOffset resets to 0 —
// each surviving slide's `slot*pitch + swipeOffset` is unchanged, so the
// next slide simply BECOMES the active one with no remount and no jump.
const pitch = stageW + SWIPE_GAP_PX;
setSwipeOffset(delta === 1 ? -pitch : pitch);
commitTimerRef.current = window.setTimeout(() => {
@ -523,7 +666,7 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
openMediaViewer(target);
}, SWIPE_ANIM_MS);
},
[currentIndex, mediaSet, entry.kind, openMediaViewer, prefersReducedMotion]
[currentIndex, mediaSet, entry.kind, openMediaViewer, prefersReducedMotion, setZoom]
);
const canPrev = currentIndex > 0;
@ -608,7 +751,8 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
startX: 0,
startY: 0,
lastDx: 0,
claimed: 'none' as 'none' | 'swipe' | 'reject',
lastDy: 0,
claimed: 'none' as 'none' | 'swipe' | 'reject' | 'close',
});
// Active touch pointers keyed by `pointerId`. Map (not array)
@ -663,6 +807,11 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
mediaSet[currentIndex + 1]?.kind === 'image';
const animatedStepRef = useRef(animatedStep);
animatedStepRef.current = animatedStep;
// Vertical-down drag-to-close sink (mobile sheet only; desktop window
// leaves it undefined). Mirrored for the once-installed gesture
// listeners; also gates whether the 'close' claim is even reachable.
const onCloseDragRef = useRef(onCloseDrag);
onCloseDragRef.current = onCloseDrag;
const entryKindRef = useRef(entry.kind);
entryKindRef.current = entry.kind;
// `zoomRef` + `panRef` are already mirrored in the pan section
@ -687,6 +836,9 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
pointerCacheRef.current.clear();
pinchStateRef.current = null;
touchPanStateRef.current = null;
// Release the sheet if an entry swap lands mid close-drag — otherwise it
// would stay frozen at the dragged height with no release event coming.
if (swipeStateRef.current.claimed === 'close') onCloseDragRef.current?.(0, false);
swipeStateRef.current.active = false;
swipeStateRef.current.claimed = 'none';
setSwipeOffset(0);
@ -704,7 +856,38 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
// → 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;
// Video entries keep their native `<video controls>`. Only a single-
// finger horizontal swipe pages between entries (no pinch / zoom / pan
// — videos don't zoom), and only when the touch starts clear of the
// bottom control band so dragging the seekbar still scrubs. Pointer
// capture is deferred to swipe-claim (in `pointermove`) so a plain tap
// still reaches the native controls (play/pause, show/hide overlay).
if (entryKindRef.current !== 'image') {
if (commitTimerRef.current !== null) return;
if (pointerCacheRef.current.size > 0) return; // already tracking a finger
const v = videoRef.current;
// `v.isConnected` guards a stale ref: the ref callback ignores the null
// detach, so while a freshly-active video is still loading (no <video>
// mounted yet) `videoRef` can point at the previous, now-detached
// element — whose getBoundingClientRect() is all-zero and would read as
// «touch is in the control band», wrongly blocking the swipe/close.
if (v && v.isConnected) {
const r = v.getBoundingClientRect();
const band = Math.min(VIDEO_CONTROL_BAND_PX, r.height * 0.4);
if (e.clientY >= r.bottom - band) return; // leave the seekbar to scrub
}
pointerCacheRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
const s = swipeStateRef.current;
s.active = true;
s.startX = e.clientX;
s.startY = e.clientY;
s.lastDx = 0;
s.lastDy = 0;
s.claimed = 'none';
setSwipeOffset(0);
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
@ -753,7 +936,11 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
anchorImageY: (anchorY - p0.y) / z0,
};
if (swipeStateRef.current.active) {
// A second finger cancels an in-flight swipe / close: spring the
// sheet back if a close drag was running, then drop into pinch.
if (swipeStateRef.current.claimed === 'close') onCloseDragRef.current?.(0, false);
swipeStateRef.current.active = false;
swipeStateRef.current.claimed = 'none';
setSwipeOffset(0);
}
touchPanStateRef.current = null;
@ -768,6 +955,7 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
s.startX = e.clientX;
s.startY = e.clientY;
s.lastDx = 0;
s.lastDy = 0;
s.claimed = 'none';
setSwipeOffset(0);
} else {
@ -823,11 +1011,37 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
const dx = e.clientX - s.startX;
const dy = e.clientY - s.startY;
s.lastDx = dx;
s.lastDy = dy;
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 (Math.abs(dx) > Math.abs(dy) * SWIPE_DOMINANCE) {
s.claimed = 'swipe';
} else if (onCloseDragRef.current && dy > 0 && Math.abs(dy) > Math.abs(dx)) {
// Vertical-DOWN drag → drive the sheet's drag-to-close. Only
// when a close sink is wired (mobile sheet) and the motion
// clearly dominates vertical; an ambiguous diagonal stays
// 'reject' so it neither pages nor closes.
s.claimed = 'close';
} else {
s.claimed = 'reject';
}
// Once the gesture is a real swipe/close, capture the pointer so
// it tracks even if the finger leaves the stage. Images already
// captured on `pointerdown` (re-capture is a no-op); video / a
// tap that never resolves stays with the native controls.
if (s.claimed === 'swipe' || s.claimed === 'close') {
try {
stageEl.setPointerCapture(e.pointerId);
} catch {
// ignore — capture is best-effort
}
}
}
}
if (s.claimed === 'close') {
if (e.cancelable) e.preventDefault();
onCloseDragRef.current?.(Math.max(0, dy), true);
return;
}
if (s.claimed !== 'swipe') return;
if (e.cancelable) e.preventDefault();
@ -883,6 +1097,11 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
const s = swipeStateRef.current;
if (s.active) {
s.active = false;
if (s.claimed === 'close') {
// Hand the release to the sheet — it applies the rubber-band,
// checks the commit threshold, and either closes or springs back.
onCloseDragRef.current?.(Math.max(0, s.lastDy), false);
} else {
let committed = false;
if (s.claimed === 'swipe') {
if (s.lastDx > SWIPE_COMMIT_PX && canPrevRef.current) {
@ -897,6 +1116,7 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
// ±stageWidth); a non-committing release snaps back to centre.
if (!committed) setSwipeOffset(0);
}
}
touchPanStateRef.current = null;
}
};
@ -975,53 +1195,73 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
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").
// Video autoplay: the active `<video>` ships `muted` + `autoPlay`. Muted
// autoplay is universally allowed without a gesture (Chrome / Firefox /
// WebKit / Android Capacitor / iOS Safari); unmuted would need a fresh
// gesture per video and fails on iOS Safari after the first step. The user
// unmutes via native controls; that doesn't persist across steps because a
// neighbour video isn't mounted until it becomes the active slide.
// Element-Web takes the same muted stance.
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.
// The active image's transform composes the paging offset with zoom + pan;
// neighbour slides use the plain slot offset (computed per-slide below).
const activeTransform = `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})`;
// Transition ON while a commit slide settles (swipeAnimating) and at rest
// (smooth zoom / snap-back); OFF during an active finger drag (1:1 tracking).
let imageTransition = 'none';
if (swipeAnimating) {
if (instantStep) {
// Instant step: no transition this commit so the persisted incoming slide
// appears at centre at once — no slide-in from the neighbour slot, and no
// wobble from a stale zoom/pan (also honours prefers-reduced-motion steps).
imageTransition = 'none';
} else 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;
// Persistent filmstrip — the ±1 window around the current entry, always
// mounted and keyed by eventId. Paging shifts `currentIndex` (via the atom)
// and resets `swipeOffset`; each surviving slide's `slot*pitch + swipeOffset`
// is unchanged across that shift, so nothing remounts or jumps — the old
// neighbour just becomes the active slide. Neighbours sit one pitch to either
// side (off-screen) and slide in on a swipe.
const pitch = stageWidth + SWIPE_GAP_PX;
const slides: Array<{ entry: MediaViewerEntry; slot: number }> = [];
if (currentIndex >= 0) {
// Neighbours need the measured `pitch` to sit off-screen; until the stage
// is measured (first frame) render only the active slide so they don't
// briefly flash near centre with a stage-less `pitch`.
const neighboursReady = stageWidth > 0;
for (let slot = -1; slot <= 1; slot += 1) {
if (slot === 0 || neighboursReady) {
const slideEntry = mediaSet[currentIndex + slot];
if (slideEntry) slides.push({ entry: slideEntry, slot });
}
}
} else {
// Entry isn't in the loaded timeline window (out-of-scrollback media): show
// it alone as the active slide — there are no neighbours to page to, and
// `canPrev`/`canNext` already gate stepping off `currentIndex >= 0`.
slides.push({ entry, slot: 0 });
}
// On native the prev/next chevrons are redundant — a horizontal swipe pages
// between entries (photos glide; videos switch instantly above their control
// band). Web keeps the chevrons on every entry: swipe is touch-only, so a
// desktop mouse still needs the buttons (web-mobile touch gets both).
const showChevrons = mediaSet.length > 1 && !isNativePlatform();
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.
// `PageHeader` (= folds `Header size="600"`) for a consistent
// strip height. `outlined={false}` drops the default bottom rule:
// the only consumer that renders this header is the mobile sheet
// (the desktop window passes `hideHeader`), and there it's a
// free-floating viewer over the image — a divider under the title
// just reads as a stray line, not a cross-pane seam.
outlined={false}
className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}
>
<Box grow="Yes" alignItems="Center" gap="200">
@ -1089,68 +1329,23 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
)}
<div className={css.stage} ref={stageRef}>
{showSwipePreviews && prevEntry?.kind === 'image' && (
<SwipePreview
key={prevEntry.eventId}
entry={prevEntry}
{slides.map(({ entry: slideEntry, slot }) => (
<Slide
key={slideEntry.eventId}
entry={slideEntry}
resolveSrc={resolveMediaSrc}
offsetPx={swipeOffset - stageWidth - SWIPE_GAP_PX}
isActive={slot === 0}
activeSrc={effectiveSrc}
slideTransform={`translate3d(${slot * pitch + swipeOffset}px, 0, 0)`}
activeTransform={activeTransform}
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,
}}
panCursor={panCursor}
imgRefCb={setActiveImgRef}
videoRefCb={setActiveVideoRef}
onMouseDown={onMouseDown}
onLoad={onImageLoad}
onImageLoad={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}>
@ -1172,7 +1367,7 @@ export function MediaViewerBody({ entry, requestClose, hideHeader }: MediaViewer
</div>
)}
{mediaSet.length > 1 && (
{showChevrons && (
<>
<button
type="button"

View file

@ -3,8 +3,18 @@ import { color, toRem } from 'folds';
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// Outer container — anchor for the two absolutely-positioned panes
// (`appBody` and `silhouette`). Same shape as the settings-sheet
// container, see that file for the full rationale.
// (`appBody` and `silhouette`). `overflow: hidden` clips the full-bleed
// children at the SCREEN edges; the rounded top corners are carved by the
// silhouette's own `overflow: hidden` + border-radius (its top edge sits
// flush under the status bar, nowhere near the container's edges).
//
// Deliberately NO background color. The silhouette's carved corners must
// stay TRANSPARENT so they reveal the live chat behind (`appBody`), exactly
// like the chat-list curtain / profile horseshoe render «only the rounded
// shape» over real content. Painting the container would put a flat square
// behind the curve — a rounded silhouette sitting on a coloured block,
// which is the «rounding without rounding» look we're avoiding (whether the
// block is black or light-blue).
export const container = style({
position: 'relative',
display: 'flex',
@ -15,11 +25,21 @@ export const container = style({
overflow: 'hidden',
});
// Holds the wrapped chat column. Stays put — clip-path carves the
// bottom edge so virtualized timeline rows aren't re-measured
// mid-gesture. `backgroundColor` opaque so the void colour painted
// on the container doesn't bleed through any transparent slivers
// in the wrapped tree (e.g. between bubble rows).
// Holds the wrapped chat column, full-bleed behind the silhouette. NOT
// clipped: the live chat must paint behind the silhouette's rounded top
// corners so the carved corners reveal real content (room header /
// timeline) — that's what makes the rounding read, with no synthetic
// backing square. The opaque silhouette covers everything except the two
// corner triangles + the status-bar strip above its top edge.
//
// `isolation: isolate` is load-bearing now that the chat is full-bleed
// (it used to be clip-path-masked away below the sheet). It makes appBody
// its OWN stacking context so the chat's positively z-indexed overlays —
// the composer / input form, scroll-to-bottom pill, typing strip — stay
// confined below the silhouette sibling. Without it they out-stack the
// (z-index: auto) sheet and render on top of the open viewer.
// `backgroundColor` opaque so nothing behind the wrapper bleeds through any
// transparent slivers in the wrapped tree (e.g. between bubble rows).
export const appBody = style({
position: 'absolute',
top: 0,
@ -31,16 +51,18 @@ export const appBody = style({
minWidth: 0,
minHeight: 0,
backgroundColor: color.Background.Container,
willChange: 'clip-path',
isolation: 'isolate',
});
// The viewer sheet's surface. `Background.Container` (deepest Dawn
// tone) so the image floats on a near-black backdrop — closest to
// the legacy `<Overlay>` viewer's full-screen dark scrim. Rounded
// top corners (same radius as the tab curtains) with `overflow:
// hidden` clip the header / image to the curve; the carved corners
// expose the app's dark background behind, so the rounding reads
// without re-introducing a black void gap.
// The viewer sheet's surface. `Background.Container` (deepest Dawn tone)
// so the image floats on a near-black backdrop — closest to the legacy
// `<Overlay>` viewer's full-screen dark scrim. Rounded top corners (same
// radius as the tab curtains) with `overflow: hidden` clip the header /
// image to the curve. `overflow: hidden` also means the two corner
// triangles paint NOTHING — they stay transparent and reveal the live chat
// behind (`appBody` is full-bleed, not clipped), so the rounding reads
// against real content the way the chat-list curtain / profile horseshoe
// do, with no backing square behind the curve.
export const silhouette = style({
position: 'absolute',
bottom: 0,
@ -71,10 +93,11 @@ export const panelContent = style({
flexDirection: 'column',
});
// 20px drag-to-close band. The ONLY drag-to-close origin — touches
// on the viewer body below this strip drive zoom / pan / swipe and
// MUST NOT initiate a close gesture. `touchAction: none` blocks
// browser-native scroll on this strip.
// 20px drag-to-close band — ONE of two close origins. This dedicated strip
// drives close via the handle gesture below; the viewer body is the other,
// driving close via MediaViewerBody's `onCloseDrag` (a vertical-down drag at
// zoom=1 anywhere on the stage). Touches on the body otherwise drive zoom /
// pan / swipe. `touchAction: none` blocks browser-native scroll on this strip.
export const panelHandle = style({
flexShrink: 0,
height: toRem(20),

View file

@ -1,41 +1,62 @@
// Bottom-up sheet that wraps the mobile chat column for the media
// viewer. Built on the same scaffolding as `MobileSettingsHorseshoe`
// (clip-path bottom-carve, VAUL easing, Strict-Mode-safe entry
// animation, `keepMounted` delayed unmount) but — being a FULLSCREEN
// viewer — it drops the «horseshoe» look: no rounded top corners and
// no black void gap above the sheet (see the geometry block below).
// (Strict-Mode-safe entry animation, `keepMounted` delayed unmount), but
// this sheet is full-bleed — no clip-path bottom-carve — and rides the
// pager / swipe-back easing (`PAGER_EASING`), not vaul's. Being a
// near-fullscreen viewer it keeps the «horseshoe» rounded top corners
// (same radius as the tab curtains) but stays tall — the rounded top
// lands flush under the system status bar (see the geometry block below).
//
// Differences from the settings sheet:
// • Tap-to-open only. No drag-up origin — opening is driven from
// `useOpenMediaViewer` called by `ImageContent.onClick`. The
// 20px handle band is the ONLY drag-sensitive area; touches on
// the viewer body below run zoom / pan / horizontal swipe
// instead.
// • Fullscreen, flush top: the sheet covers the chat (incl. its
// header) and its flat top lands flush under the system status
// bar — no rounded carve, no void seam — vs settings's rounded 2/3.
// • Silhouette bg = `Background.Container` (deepest Dawn tone) for
// a dark image backdrop, vs settings's `SurfaceVariant.Container`.
// `useOpenMediaViewer` called by `ImageContent.onClick`. Drag-to-
// CLOSE works from BOTH the 20px handle band AND anywhere on the
// viewer body (a vertical-down drag at zoom=1, fed by
// MediaViewerBody's `onCloseDrag`); horizontal swipe pages, pinch
// zooms, and a one-finger drag at zoom>1 pans.
// • Near-fullscreen, flush top: the sheet covers the chat (incl. its
// header) and its rounded top lands flush under the system status
// bar — vs settings's shorter rounded 2/3.
// • Silhouette bg = `Background.Container` (deepest Dawn tone) for a
// dark image backdrop. The rounded top corners are transparent and
// reveal the live chat behind (appBody is full-bleed, not clipped) —
// no backing square — unlike settings's `SurfaceVariant.Container`
// silhouette over a void seam. See the css file.
// • Wrapped content is the chat column, not the DM list.
// • No `--vojo-safe-top` reset — there's no PageNav inside the
// viewer body that would inherit it; the viewer body has its own
// padding policy.
// • The silhouette resets `--vojo-safe-top` to 0 (mirror of the
// settings sheet) so a status-bar-padded descendant added here later
// isn't pushed down inside a sheet that already sits below the bar.
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
import React, { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { mediaViewerAtom } from '../../state/mediaViewer';
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { PAGER_EASING, PAGER_TRANSITION_MS } from '../../components/mobile-tabs-pager/geometry';
import { MediaViewerBody } from './MediaViewerBody';
import * as css from './MobileMediaViewerHorseshoe.css';
const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
const ANIMATION_MS = 250;
// Open / close timing borrowed from the tab pager — the SAME constants the
// swipe-back-from-chat gesture re-uses (`swipe-back/geometry.ts`). Reusing
// them (not re-literalling 250ms / a vaul curve) keeps the sheet's slide
// reading at the same speed + decelerating curve as returning from a chat,
// and means the two can never drift in feel. `cubic-bezier(0.22, 1, 0.36, 1)`
// is the long-tail easeOut that makes the motion settle rather than snap.
const SHEET_EASING = PAGER_EASING;
const ANIMATION_MS = PAGER_TRANSITION_MS;
// Drag distance past which release commits the close. Matches the
// settings sheet's 80px (the only commit gesture there too).
// settings sheet's 80px (the only commit gesture there too). Measured
// against the RUBBER-BANDED sheet displacement below, not the raw finger
// pull — the same «displacement, not raw» rule the chat-list curtain uses.
const COMMIT_THRESHOLD_PX = 80;
// Finger→sheet damping for the drag-to-close, mirroring the chat-list
// curtain's body gesture (`stream-header/geometry.ts::RUBBER_BAND`): the
// sheet follows the finger at 0.65× so the pull reads as physically
// «heavier». With the threshold above on the damped displacement, a commit
// needs ≈80 / 0.65 ≈ 123px of actual finger travel.
const DRAG_RUBBER_BAND = 0.65;
// Sheet height as a fraction of the viewport — near-fullscreen by
// design: the viewer should cover the chat (incl. its header) and stop
// just under the system status bar / tray. Capped at runtime by the
@ -51,7 +72,9 @@ const RAIL_VIEWPORT_FRACTION = 1;
const RAIL_MIN_PX = 200;
type DragState = {
inputType: 'touch' | 'pointer';
// 'touch' / 'pointer' originate on the top handle band; 'body' is fed by
// MediaViewerBody's drag-to-close (a downward drag anywhere on the stage).
inputType: 'touch' | 'pointer' | 'body';
startY: number;
deltaY: number;
};
@ -120,7 +143,7 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
// Final rail = viewport × fraction (full screen), then floor-then-
// ceiling clamped. Container minus the status-bar inset is the HARD
// ceiling: the sheet's flat top lands flush at the bottom of the
// ceiling: the sheet's rounded top lands flush at the bottom of the
// status bar (no void gap / breathing), covering the whole chat column
// down to just under the tray; when the wrapper shrinks (call rail /
// split-screen) it shrinks below `RAIL_MIN_PX` rather than spilling
@ -145,9 +168,9 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
return () => cancelAnimationFrame(id);
}, []);
// Keep the viewer body mounted through the 250ms exit slide so
// the user sees the image slide down with the sheet instead of
// an empty panel collapsing.
// Keep the viewer body mounted through the exit slide (`ANIMATION_MS`)
// so the user sees the image slide down with the sheet instead of an
// empty panel collapsing.
const [keepMounted, setKeepMounted] = useState(open);
useEffect(() => {
if (open) {
@ -164,12 +187,13 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
if (entry) lastEntryRef.current = entry;
const baseExpanded = open && hasEntered ? railHeightPx : 0;
// Drag is close-only (positive deltaY); clamp negative deltas to
// 0 so an upward reversal cancels rather than expanding past the
// open height.
const expandedPx = drag
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
: baseExpanded;
// Drag is close-only (positive deltaY) and rubber-banded: the sheet
// shrinks by `deltaY × DRAG_RUBBER_BAND`, so it lags the finger and the
// pull feels «heavier» (same as the chat-list curtain body gesture).
// `Math.max(0, deltaY)` ignores any upward reversal past the start so it
// can't expand the sheet past its open height.
const dragDisplacementPx = drag ? Math.max(0, drag.deltaY) * DRAG_RUBBER_BAND : 0;
const expandedPx = Math.max(0, baseExpanded - dragDisplacementPx);
const isDragging = drag !== null;
const handleRef = useRef<HTMLDivElement>(null);
@ -181,6 +205,22 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
const closeSheetRef = useRef(closeSheet);
closeSheetRef.current = closeSheet;
// Drag-to-close fed from MediaViewerBody — a downward drag anywhere on
// the stage (zoom=1). Mirrors the handle's rubber-band + commit threshold
// so the two surfaces feel identical. `dragging:false` is the release:
// commit if the damped displacement cleared the threshold, else spring
// back. Stable identity — MediaViewerBody mirrors it into a ref anyway.
const onBodyCloseDrag = useCallback((rawDeltaY: number, dragging: boolean) => {
if (dragging) {
setDrag({ inputType: 'body', startY: 0, deltaY: Math.max(0, rawDeltaY) });
return;
}
if (Math.max(0, rawDeltaY) * DRAG_RUBBER_BAND > COMMIT_THRESHOLD_PX) {
closeSheetRef.current();
}
setDrag(null);
}, []);
// Clear the atom if the wrapper unmounts while the sheet is open
// (route change). Strict-mode rehearsal guard via `hasEnteredRef`
// — without it, the cleanup fires before the entry rAF and a
@ -237,7 +277,9 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
const applyEnd = () => {
const d = dragRef.current;
if (!d) return;
if (d.deltaY > COMMIT_THRESHOLD_PX) {
// Threshold is on the rubber-banded sheet displacement, not the raw
// finger pull — matches the live `dragDisplacementPx` the user sees.
if (Math.max(0, d.deltaY) * DRAG_RUBBER_BAND > COMMIT_THRESHOLD_PX) {
closeSheetRef.current();
}
setDrag(null);
@ -301,18 +343,13 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
};
}, []);
// Geometry — the media viewer is fullscreen, so (unlike the settings /
// profile horseshoes) it carves NO rounded top corners and NO void gap
// above the sheet: the sheet's flat top butts directly against the chat
// it clips (which now fills only the status-bar strip above it). Chat bg
// and sheet bg are the same Dawn tone, so there's no black void seam at
// the top — the viewer reads as a flush fullscreen panel under the tray.
const appBodyMaskBottomPx = expandedPx;
const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px)`;
const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;
// Geometry — the media viewer is near-fullscreen: its rounded top lands
// flush under the status-bar strip. The opaque silhouette grows from the
// bottom over the live chat (`appBody`, full-bleed behind it); its rounded
// TL/TR corners stay transparent and reveal that chat, so the rounding
// reads against real content — no clip-path carve, no backing square (see
// the css file). Only the silhouette's height animates.
const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${SHEET_EASING}`;
const renderEntry = entry ?? lastEntryRef.current;
const renderBody = !!renderEntry && (keepMounted || isDragging);
@ -353,14 +390,7 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
portalTarget
)
: null}
<div
className={css.appBody}
style={{
clipPath: appBodyClipPath,
transition: appBodyTransition,
overscrollBehaviorY: 'contain',
}}
>
<div className={css.appBody} style={{ overscrollBehaviorY: 'contain' }}>
{children}
</div>
@ -369,7 +399,15 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
style={{
height: `${expandedPx}px`,
transition: silhouetteTransition,
visibility: expandedPx > 0 ? 'visible' : 'hidden',
// Gate on `renderBody` (open / dragging / exiting), NOT on
// `expandedPx > 0`. On close `expandedPx` snaps to 0 immediately
// while the height transition animates X→0 over `ANIMATION_MS`;
// gating on `expandedPx` would flip to `hidden` on that first
// frame and the slide-down would never be seen (the sheet would
// just pop out). `renderBody` stays true through the exit window
// (`keepMounted`), so the retract animation is visible, then it
// goes false and hides the spent 0-height sheet.
visibility: renderBody ? 'visible' : 'hidden',
// Reset `--vojo-safe-top` so any descendant that paints a
// `padding-top: var(--vojo-safe-top)` for status-bar safe-
// area (e.g. PageNav-style headers, if anyone adds one
@ -391,7 +429,11 @@ function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoe
</div>
<div className={css.panelBody}>
{renderBody && renderEntry && (
<MediaViewerBody entry={renderEntry} requestClose={() => closeSheetRef.current()} />
<MediaViewerBody
entry={renderEntry}
requestClose={() => closeSheetRef.current()}
onCloseDrag={onBodyCloseDrag}
/>
)}
</div>
</div>