// 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(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(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(null); const dragRef = useRef(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 (
{open && portalTarget ? createPortal(