import { useEffect, useState } from 'react'; /** * Live geometry of `window.visualViewport`, plus a derived `inset` string * that floating overlays (e.g. the chat composer) can paste into a `bottom` * style to sit just above the iOS / Android soft keyboard when one is open, * or above the system home indicator when one is not. * * Why this hook exists * -------------------- * On iOS Safari (and PWA), `env(safe-area-inset-bottom)` is defined relative * to the **layout** viewport, not the **visual** viewport. When the soft * keyboard slides up, the layout viewport stays the same height and the home- * indicator inset still reports ~34 px — so a composer pinned to * `bottom: env(safe-area-inset-bottom) + 6px` ends up `~40px` above the * layout-bottom, which on iPhone 14 Pro is `300+ px` above the keyboard top. * * `window.visualViewport` reports the live size of the visible region. When * the keyboard is open, `visualViewport.height` shrinks and * `visualViewport.offsetTop` may become non-zero. The bottom of the visual * viewport (in layout-viewport coordinates) is therefore * `visualViewport.offsetTop + visualViewport.height`. The distance between * that line and the layout-viewport bottom is the keyboard occlusion: * keyboardOcclusion = window.innerHeight - (offsetTop + height) * * When the keyboard is closed, that value is ~0 and we fall back to the * standard `safe-area-inset-bottom` so the overlay sits above the home * indicator. When the keyboard is open, we use the keyboard occlusion * directly — `safe-area-inset-bottom` no longer applies because the home * indicator is occluded by the keyboard. * * iOS PWA standalone caveats * -------------------------- * In iOS Safari standalone PWA mode, `visualViewport.resize` events are * known to fire late, fire only once after the keyboard finishes animating, * or in some iOS versions not fire at all for the keyboard transition. To * cover those cases we additionally: * 1. Listen to `focusin` / `focusout` on `window` and re-measure (a focus * change on a text input is a strong signal that the keyboard is about * to open / close). * 2. Poll `visualViewport` for ~600ms after a focus change so we catch the * shrunk height even when no `resize` event ever lands. * 3. Listen to `vv.scroll` events too — on some iOS builds the keyboard * transition fires `scroll` (offsetTop change) without `resize`. * * Consumers * --------- * - `MobileShell.tsx` reads `{ height, keyboardOpen }` and uses `height` as * the container's CSS height when the keyboard is open. This is the * primary mechanism for the composer to sit flush above the keyboard: * the container shrinks to the visible region, so a `position: absolute; * bottom: 0` child naturally lands on the keyboard's top edge regardless * of how reliably the `inset` value tracks the keyboard. * - `MessageInput.tsx` reads `{ value, keyboardOpen }` and uses them only * on desktop fallback paths and for the breathing-room toggle (above the * home indicator vs flush with the keyboard). */ export interface VisualViewportInset { /** CSS string for `bottom`: either `env(safe-area-inset-bottom)` or `px`. */ value: string; /** True if the soft keyboard is occluding the bottom of the layout viewport. */ keyboardOpen: boolean; /** * True if a text-entry element currently has focus. Used as a focus-based * fallback signal for the iOS PWA case where `interactive-widget=resizes-content` * (or iOS's native standalone behavior) shrinks the layout viewport itself * for the keyboard — `vv.height` ends up matching `innerHeight`, so * `keyboardOpen` (which infers from height delta) stays false even though * the keyboard IS up. Consumers that need a "is the keyboard most likely * up" signal should OR `keyboardOpen || textInputFocused`. */ textInputFocused: boolean; /** * Live `visualViewport.height` in pixels, or `null` if `visualViewport` is * unavailable. Consumers that want to size a container to the visible * region (e.g. MobileShell when the keyboard is open) read this directly. */ height: number | null; /** * Live `visualViewport.offsetTop` in pixels (0 when no scroll occlusion at * the top of the visible region), or `null` if `visualViewport` is * unavailable. */ offsetTop: number | null; } const FALLBACK: VisualViewportInset = { value: 'env(safe-area-inset-bottom)', keyboardOpen: false, textInputFocused: false, height: null, offsetTop: null, }; export function useVisualViewportInset(): VisualViewportInset { const [inset, setInset] = useState(FALLBACK); // Mutable ref so `measure()` reads the latest focus state without React // re-renders racing the visualViewport update path. const textInputFocusedRef = { current: false } as { current: boolean }; useEffect(() => { const vv = window.visualViewport; if (!vv) return; let raf = 0; let pollTimer: ReturnType | null = null; let pollDeadline = 0; const measure = () => { // Distance from the bottom of the layout viewport (window.innerHeight) // to the bottom of the visual viewport (offsetTop + height). On iOS // when the keyboard is up, this equals the keyboard's height. const occlusion = window.innerHeight - (vv.offsetTop + vv.height); // Sub-pixel noise on iOS — anything under 1 px we treat as "no // keyboard" so we don't flap between safe-area and a 0.4 px offset. const next: VisualViewportInset = occlusion > 1 ? { value: `${Math.round(occlusion)}px`, keyboardOpen: true, textInputFocused: textInputFocusedRef.current, height: vv.height, offsetTop: vv.offsetTop, } : { value: 'env(safe-area-inset-bottom)', keyboardOpen: false, textInputFocused: textInputFocusedRef.current, height: vv.height, offsetTop: vv.offsetTop, }; // Functional update + shallow compare so identical re-measurements // don't churn React state every animation frame during keyboard // transitions. // // ALL returned fields must be in this comparison, otherwise an // observable change to one of them is silently dropped — the historical // bug was `textInputFocused` being absent here. On iOS PWA standalone // the layout viewport is shrunk natively for the keyboard, so // `vv.height` matches `innerHeight` and `keyboardOpen`/`value` stay // unchanged across the transition. The only signal that flips is // `textInputFocused`. Without it in the compare, consumers // (`MessageInput`'s `--composer-clearance` effect, the composer's // inline `bottom` style) never see the focus change propagate, the // composer stayed pinned above the home indicator while the keyboard // was actually up, and on close the clearance variable was computed // off a stale `bottom` value — surfacing as a -4 px overlap between // the last message and the composer's top edge. setInset((prev) => prev.value === next.value && prev.keyboardOpen === next.keyboardOpen && prev.textInputFocused === next.textInputFocused && prev.height === next.height && prev.offsetTop === next.offsetTop ? prev : next, ); }; const update = () => { // Schedule a single rAF — `resize`/`scroll` on visualViewport can fire // many times per frame on iOS during keyboard transitions; coalescing // avoids redundant React state updates. if (raf) cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { raf = 0; measure(); }); }; /** * iOS PWA fallback: poll for ~600 ms after a focus change. iOS Safari * (especially in standalone PWA mode) often fails to dispatch a * `visualViewport.resize` event when the soft keyboard opens — but the * `vv.height` value itself does update once the keyboard finishes * animating. Polling at ~16 ms intervals from `focusin` until the * deadline ensures we observe the shrunk height even when no event * fires. The interval clears as soon as we observe a steady state for * two consecutive frames. */ let lastPolledHeight = vv.height; let stableFrames = 0; const startPolling = (durationMs: number) => { pollDeadline = performance.now() + durationMs; lastPolledHeight = vv.height; stableFrames = 0; if (pollTimer) clearInterval(pollTimer); pollTimer = setInterval(() => { measure(); if (vv.height === lastPolledHeight) { stableFrames += 1; } else { lastPolledHeight = vv.height; stableFrames = 0; } if (stableFrames >= 3 || performance.now() > pollDeadline) { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } // One last measurement after we stop, in case the value just // settled this tick. measure(); } }, 32); }; const onFocusChange = (e: FocusEvent) => { // Only react to focus changes on text-entry elements — focusing a //