419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
// 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>;
|
||
}
|