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

419 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// Bottom-up «horseshoe» sheet that wraps the mobile chat column for
// the media viewer. Sister of `MobileSettingsHorseshoe` (which wraps
// the DM list with the same idiom for the Settings tree) — same
// clip-path carve, same VAUL easing, same Strict-Mode-safe entry
// animation, same `keepMounted` delayed unmount.
//
// 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.
// • `RAIL_FRACTION = 3 / 4` (75% of viewport) vs settings's 2/3.
// • Silhouette bg = `Background.Container` (deepest Dawn tone) for
// a dark image backdrop, vs settings's `SurfaceVariant.Container`.
// • 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.
import React, { ReactNode, 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 { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
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;
// Drag distance past which release commits the close. Matches the
// settings sheet's 80px (the only commit gesture there too).
const COMMIT_THRESHOLD_PX = 80;
// Sheet height as a fraction of the viewport — product spec.
// Capped at runtime by the wrapper container's actual height
// (see `railHeightPx` calc below) so it can't spill past the
// chat header when chrome outside the chat column (e.g.
// `HorseshoeContainer.bottomRail` during a call) shrinks the
// wrapper.
const RAIL_VIEWPORT_FRACTION = 0.8;
// Absolute floor for tiny / split-screen viewports — the sheet
// becomes useless below this. NB: floor cannot exceed the
// container's ceiling (`Math.min` is applied last), so in extreme
// squeeze cases (split-screen, oversized call rail) the sheet
// silently shrinks below the floor rather than clipping the chat
// header.
const RAIL_MIN_PX = 200;
// Headroom reserved at the TOP of the container for the chat
// header (`PageHeader` = folds `Header size="600"`, ~64px) plus
// the 12px horseshoe void gap above the silhouette's rounded
// TL/TR carves. With this in place the sheet's top edge stops
// just below the chat header even when the wrapper container
// shrinks (e.g. `HorseshoeContainer.bottomRail` is on during a
// call). Previously the headroom was 8px which let the sheet
// climb over the header in tight viewports.
const CHAT_HEADER_RESERVED_PX = 76;
// Same emerge window as the settings sheet so the silhouette's
// rounding + void gap finish exactly when the gesture qualifies to
// commit.
const HORSESHOE_EMERGE_PX = 80;
// Symmetric cubic in-out — slow start, fast middle, slow finish.
// Same curve the settings / profile horseshoes use for their emerge
// (rationale at the top of `MobileSettingsHorseshoe.tsx`).
const easeInOutCubic = (t: number): number =>
t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2;
type DragState = {
inputType: 'touch' | 'pointer';
startY: number;
deltaY: number;
};
type MobileMediaViewerHorseshoeProps = {
children: ReactNode;
};
function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoeProps) {
const { t } = useTranslation();
const entry = useAtomValue(mediaViewerAtom);
const closeSheet = useCloseMediaViewer();
const [drag, setDrag] = useState<DragState | null>(null);
const [viewportHeight, setViewportHeight] = useState(() =>
typeof window === 'undefined' ? 800 : window.innerHeight
);
useEffect(() => {
const onResize = () => setViewportHeight(window.innerHeight);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// Measure the wrapper container directly via `ResizeObserver` so
// the rail height tracks whatever vertical space is actually
// available to the chat column. The viewport-based fraction is
// the *intent* (90% of the screen — product spec), but
// `HorseshoeContainer.bottomRail` (the call rail) sits OUTSIDE
// the chat column inside the same viewport and shrinks our
// wrapper when active. Without the container clamp, 90% of the
// viewport could exceed the wrapper's height, and the sheet
// would render up to / past the chat header which sits inside
// the wrapper's appBody.
const containerRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState(() =>
typeof window === 'undefined' ? 800 : window.innerHeight
);
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return undefined;
setContainerHeight(el.clientHeight);
const ro = new ResizeObserver((entries) => {
const cr = entries[0]?.contentRect;
if (cr) setContainerHeight(cr.height);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// Final rail = viewport × fraction, then floor-then-ceiling
// clamped. Container (minus chat-header reserve) is the HARD
// ceiling: when the wrapper shrinks (call rail / split-screen)
// the sheet shrinks below `RAIL_MIN_PX` rather than spilling
// over the chat header. Order matters — `Math.min` last makes
// container win against floor.
const idealRailPx = Math.round(viewportHeight * RAIL_VIEWPORT_FRACTION);
const railHeightPx = Math.min(
Math.max(0, containerHeight - CHAT_HEADER_RESERVED_PX),
Math.max(RAIL_MIN_PX, idealRailPx)
);
const open = !!entry;
// Entry-animation gate — see `MobileSettingsHorseshoe` for the
// full rationale. `hasEnteredRef` mirror guards the atom-clearing
// unmount cleanup against React 18 strict-mode dev rehearsal.
const [hasEntered, setHasEntered] = useState(false);
const hasEnteredRef = useRef(false);
useLayoutEffect(() => {
const id = requestAnimationFrame(() => {
hasEnteredRef.current = true;
setHasEntered(true);
});
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.
const [keepMounted, setKeepMounted] = useState(open);
useEffect(() => {
if (open) {
setKeepMounted(true);
return undefined;
}
const id = window.setTimeout(() => setKeepMounted(false), ANIMATION_MS);
return () => window.clearTimeout(id);
}, [open]);
// Persist the last opened entry through the exit animation so the
// body keeps rendering the image as it slides down.
const lastEntryRef = useRef(entry);
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;
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
const isDragging = drag !== null;
const horseshoeActive = expandedPx > 0;
const handleRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
dragRef.current = drag;
const entryRef = useRef(entry);
entryRef.current = entry;
const closeSheetRef = useRef(closeSheet);
closeSheetRef.current = closeSheet;
// 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
// deep-link cold-start would clear the atom mid-rehearsal.
useEffect(
() => () => {
if (!hasEnteredRef.current) return;
if (entryRef.current) closeSheetRef.current();
},
[]
);
// Hardware Escape (also dispatched by the global Android-back
// handler via the portal-marker pattern below). Skip inputs /
// contenteditable so typing inside a future caption field doesn't
// dismiss the sheet.
useEffect(() => {
if (!open) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable)
) {
return;
}
closeSheetRef.current();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open]);
// Drag-to-close only — handle band at the top of the silhouette.
// No document-level open-drag listener; opening is tap-only via
// `useOpenMediaViewer`.
//
// CLAMP, not early-return — see `MobileSettingsHorseshoe` for
// the bug-fix rationale. Reversing the gesture must drag deltaY
// back toward 0, not leave the stale value.
useEffect(() => {
const handleEl = handleRef.current;
if (!handleEl) return undefined;
const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const rawDelta = clientY - d.startY;
const nextDelta = Math.max(0, rawDelta);
if (e.cancelable) e.preventDefault();
setDrag({ ...d, deltaY: nextDelta });
};
const applyEnd = () => {
const d = dragRef.current;
if (!d) return;
if (d.deltaY > COMMIT_THRESHOLD_PX) {
closeSheetRef.current();
}
setDrag(null);
};
const onHandleTouchStart = (e: TouchEvent) => {
if (dragRef.current) return;
if (!entryRef.current) return;
const touch = e.touches[0];
setDrag({ inputType: 'touch', startY: touch.clientY, deltaY: 0 });
};
const onTouchMove = (e: TouchEvent) => {
const d = dragRef.current;
if (!d || d.inputType !== 'touch') return;
applyMove(e.touches[0].clientY, e);
};
const onTouchEnd = () => {
const d = dragRef.current;
if (!d || d.inputType !== 'touch') return;
applyEnd();
};
const onHandlePointerDown = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
if (dragRef.current) return;
if (!entryRef.current) return;
if (e.button !== 0) return;
setDrag({ inputType: 'pointer', startY: e.clientY, deltaY: 0 });
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
const d = dragRef.current;
if (!d || d.inputType !== 'pointer') return;
applyMove(e.clientY, e);
};
const onPointerEnd = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
const d = dragRef.current;
if (!d || d.inputType !== 'pointer') return;
applyEnd();
};
handleEl.addEventListener('touchstart', onHandleTouchStart, { passive: true });
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd, { passive: true });
document.addEventListener('touchcancel', onTouchEnd, { passive: true });
handleEl.addEventListener('pointerdown', onHandlePointerDown);
document.addEventListener('pointermove', onPointerMove, { passive: false });
document.addEventListener('pointerup', onPointerEnd, { passive: true });
document.addEventListener('pointercancel', onPointerEnd, { passive: true });
return () => {
handleEl.removeEventListener('touchstart', onHandleTouchStart);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
document.removeEventListener('touchcancel', onTouchEnd);
handleEl.removeEventListener('pointerdown', onHandlePointerDown);
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerEnd);
document.removeEventListener('pointercancel', onPointerEnd);
};
}, []);
// Geometry — same emerge ramp as the settings / profile horseshoes
// (rationale at the top of `MobileSettingsHorseshoe`).
let horseshoeRamp: number;
if (isDragging) {
horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
} else {
horseshoeRamp = expandedFraction > 0 ? 1 : 0;
}
const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
const appBodyMaskBottomPx = expandedPx + appBodyGapPx;
const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`;
const silhouetteTransition = isDragging
? 'none'
: `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;
const containerStyle: React.CSSProperties = {
backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
};
const renderEntry = entry ?? lastEntryRef.current;
const renderBody = !!renderEntry && (keepMounted || isDragging);
// Marker portal for the global Android-back handler — when
// `portalContainer.firstChild` matches our `data-vojo-…-active`
// attribute, the back-button dispatches an Escape keydown that
// the `keydown` effect above catches.
const portalTarget =
typeof document !== 'undefined'
? document.getElementById('portalContainer') ?? document.body
: null;
return (
<div ref={containerRef} className={css.container} style={containerStyle}>
{open && portalTarget
? createPortal(
<div
data-vojo-media-viewer-sheet-active="true"
aria-hidden="true"
style={{ display: 'none' }}
/>,
portalTarget
)
: null}
<div
className={css.appBody}
style={{
clipPath: appBodyClipPath,
transition: appBodyTransition,
overscrollBehaviorY: 'contain',
}}
>
{children}
</div>
<div
className={css.silhouette}
style={{
height: `${expandedPx}px`,
borderTopLeftRadius: `${silhouetteRadiusPx}px`,
borderTopRightRadius: `${silhouetteRadiusPx}px`,
transition: silhouetteTransition,
visibility: expandedPx > 0 ? '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
// here later) doesn't push the viewer header down inside
// the sheet — the sheet itself sits below the status bar.
// Mirror of the settings horseshoe.
['--vojo-safe-top' as string]: '0px',
}}
role="dialog"
aria-label={renderEntry?.body ?? t('MediaViewer.title', 'Media viewer')}
>
<div className={css.panelContent} style={{ height: `${railHeightPx}px` }}>
<div
ref={handleRef}
className={css.panelHandle}
aria-label={t('MediaViewer.drag_to_close', 'Drag down to close')}
>
<div className={css.panelHandleBar} />
</div>
<div className={css.panelBody}>
{renderBody && renderEntry && (
<MediaViewerBody entry={renderEntry} requestClose={() => closeSheetRef.current()} />
)}
</div>
</div>
</div>
</div>
);
}
// Top-level branch — pass through on non-mobile (the desktop right
// pane handles the same atom over there). The mobile branch owns
// the drag / atom hooks in a sub-component so we don't run them on
// desktop renders.
export function MobileMediaViewerHorseshoe({
children,
}: MobileMediaViewerHorseshoeProps): React.ReactElement {
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
if (!isMobile) {
return children as React.ReactElement;
}
return <MobileMediaViewerHorseshoeImpl>{children}</MobileMediaViewerHorseshoeImpl>;
}