Snapshot of in-progress work parked here so group-DM-polish can land cleanly on main. Touches MainContent + mobile-ui.md which overlap with group-DM-polish; rebase onto post-merge main and resolve conflicts on those two files manually. Files: MainContent, MessageInput, MobileVoiceFullScreen (+test), StreamTile, VoiceUser, useLiveKit, useVisualViewportInset, AudioManager, voiceStore, voice utils, mobile-ui.md, voice.md, mobile-parity handoff doc.
248 lines
11 KiB
TypeScript
248 lines
11 KiB
TypeScript
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 `<n>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<VisualViewportInset>(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<typeof setInterval> | 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
|
|
// <button> never opens the soft keyboard, so polling for it would
|
|
// waste cycles.
|
|
const t = e.target as Element | null;
|
|
if (!t) return;
|
|
const tag = t.tagName;
|
|
const editable =
|
|
tag === 'INPUT' ||
|
|
tag === 'TEXTAREA' ||
|
|
(t as HTMLElement).isContentEditable === true;
|
|
if (!editable) return;
|
|
// Track focus state — used as a fallback signal in the iOS PWA case
|
|
// where `vv.height` doesn't shrink for the keyboard (because iOS
|
|
// native-shifts the layout viewport instead). `focusin` → focused;
|
|
// `focusout` → unfocused. Capture phase listener so we see all events.
|
|
textInputFocusedRef.current = e.type === 'focusin';
|
|
// Immediate measure + a polling window for laggy iOS PWA event flows.
|
|
update();
|
|
startPolling(600);
|
|
};
|
|
|
|
measure();
|
|
vv.addEventListener('resize', update);
|
|
vv.addEventListener('scroll', update);
|
|
window.addEventListener('focusin', onFocusChange, true);
|
|
window.addEventListener('focusout', onFocusChange, true);
|
|
return () => {
|
|
if (raf) cancelAnimationFrame(raf);
|
|
if (pollTimer) clearInterval(pollTimer);
|
|
vv.removeEventListener('resize', update);
|
|
vv.removeEventListener('scroll', update);
|
|
window.removeEventListener('focusin', onFocusChange, true);
|
|
window.removeEventListener('focusout', onFocusChange, true);
|
|
};
|
|
}, []);
|
|
|
|
return inset;
|
|
}
|