/** * The mobile composer's pill ↔ full-composer state machine. * * With the keyboard closed the composer collapses into a narrow pill; any * interaction expands it back. The swap is deliberately instant and * synchronized with the keyboard choreography, so the chat compensates * keyboard and composer height in a single motion rather than a staircase. * * Most of the code here is not the state machine itself but the corrections * that keep it from fighting the platform: mobile browsers dismiss the * keyboard on a tap before the click lands, iOS refuses programmatic focus * outside a gesture, WebKit leaves the layout viewport panned after the * keyboard hides, and overlay chains hand off through a frame where nothing * is open. Every timeout and flushSync below marks one of those, and none of * them is verifiable outside a real device. */ import React from 'react'; import { flushSync } from 'react-dom'; import { isCapacitorApp } from '@/lib/platform'; import type { ComposerEditorHandle } from '../editor/ComposerEditor'; /** * Everything that must keep the composer expanded even with the keyboard * down. Collapsing under an open sheet would unmount the focused editor and * kill the keyboard the sheet is about to hand back. */ export interface MobileComposerHolders { controlsPanelOpen: boolean; attachMenuOpen: boolean; draftPickerOpen: boolean; issuePickerOpen: boolean; prPickerOpen: boolean; isDragging: boolean; } export interface MobileComposerShellOptions { isMobile: boolean; editorRef: React.RefObject; formRef: React.RefObject; setExpandedInput: (expanded: boolean) => void; holders: MobileComposerHolders; } export interface MobileComposerShell { /** The full composer is showing rather than the collapsed pill. */ expanded: boolean; /** The editor has focus; the best keyboard proxy a browser offers. */ focused: boolean; /** A MobileOverlayPanel is mounted in the shared portal root. */ overlayHostBusy: boolean; dictationActive: boolean; /** Expand and focus, synchronously, from inside a user gesture. */ expand: () => void; onDictationActiveChange: (active: boolean) => void; onEditorFocus: () => void; onEditorBlur: () => void; /** Suppress the keyboard restore when another overlay opens next. */ skipNextOverlayCloseRestore: () => void; /** Cancel a pending keyboard restore entirely (a native picker takes over). */ cancelOverlayCloseRestore: () => void; } export function useMobileComposerShell( options: MobileComposerShellOptions, ): MobileComposerShell { const { isMobile, editorRef, formRef, setExpandedInput, holders } = options; const [expanded, setExpanded] = React.useState(false); const [focused, setFocused] = React.useState(false); const [overlayHostBusy, setOverlayHostBusy] = React.useState(false); const [dictationActive, setDictationActive] = React.useState(false); // Set while an expansion is settling (focus or dictation not yet active) so // the collapse watcher does not immediately fold it back into the pill. const expandIntentRef = React.useRef<'focus' | null>(null); const lastBlurAtRef = React.useRef(0); const restoreKeyboardRef = React.useRef(false); const blurTimerRef = React.useRef(null); React.useEffect(() => () => { if (blurTimerRef.current !== null) window.clearTimeout(blurTimerRef.current); }, []); const expandedRef = React.useRef(expanded); React.useEffect(() => { expandedRef.current = expanded; }); // The draft screen restructures itself around the composer: its starter // chips leave once the full composer is up, and its centered title // re-centers over whatever room remains. Announced as a root class from a // layout effect so the restructure lands in the SAME frame as the pill // swap — keyed on the keyboard instead (oc-keyboard-open arrives with the // keyboardWillShow bridge event, ~100ms later), the chips vanished // mid-rise as a second visible jump. React.useLayoutEffect(() => { if (!isMobile || typeof document === 'undefined') return; const root = document.documentElement; root.classList.toggle('oc-composer-expanded', expanded); return () => root.classList.remove('oc-composer-expanded'); }, [expanded, isMobile]); const expand = React.useCallback(() => { expandIntentRef.current = 'focus'; // flushSync so the editor exists NOW and focus() still runs inside the // gesture's call stack: mobile browsers only open the soft keyboard for // focus calls made synchronously from the tap (an rAF here worked in // the Capacitor WebView but not in Safari or Chrome). flushSync(() => setExpanded(true)); if (isCapacitorApp()) { // Timing tuned on device, against WKWebView pausing frame // presentation while the keyboard transition runs: // - focus in the same task as the swap → the pause starts before // the swap's first frame, so the pill stays on glass until the // keyboard is nearly up; // - focus two frames later → the swap is presented first and the // keyboard only then begins, a visibly sequential two-step. // Focusing INSIDE the first frame after the commit threads the // needle: the swap's frame is already in the rendering pipeline // when the keyboard transaction starts, so the keyboard rises from // the tap and the composer appears during the rise. The Capacitor // WebView raises the keyboard for a focus() outside the gesture // task (browsers do not, hence the split); the choreography // positions everything, so preventScroll stays on. requestAnimationFrame(() => { editorRef.current?.focus({ preventScroll: true }); }); return; } // Mobile browsers only open the soft keyboard for focus calls made // synchronously from the tap; their native reveal is also the only // thing that positions the composer, so no preventScroll. editorRef.current?.focus({ preventScroll: false }); }, [editorRef]); const onDictationActiveChange = React.useCallback((active: boolean) => { setDictationActive(active); if (active) { expandIntentRef.current = null; // Dictation went live, possibly from the pill: switch straight into // the voice variant of the full composer. if (!expandedRef.current) setExpanded(true); return; } // Dictation ended. The insert flow hands focus back a tick later — if // that happened, stay expanded; otherwise (cancel, discard, // insert-and-send) collapse straight back to the pill rather than // parking on the normal composer for the usual grace period. window.setTimeout(() => { if (!expandedRef.current) return; if (editorRef.current?.isFocused()) return; setExpanded(false); setExpandedInput(false); }, 30); }, [editorRef, setExpandedInput]); // Watch the shared overlay portal root: any mounted MobileOverlayPanel // counts as busy. Observing the host catches overlays whose open state // lives in other components without threading it through here. React.useEffect(() => { if (!isMobile || typeof document === 'undefined') return; let host = document.getElementById('mobile-overlay-root'); if (!host) { // Same lazy-create contract as MobileOverlayPanel's ensureOverlayRoot. host = document.createElement('div'); host.id = 'mobile-overlay-root'; document.body.appendChild(host); } const hostEl = host; const update = () => setOverlayHostBusy(hostEl.childElementCount > 0); update(); const observer = new MutationObserver(update); observer.observe(hostEl, { childList: true }); return () => observer.disconnect(); }, [isMobile]); const overlayOpen = overlayHostBusy || holders.controlsPanelOpen || holders.attachMenuOpen || holders.issuePickerOpen || holders.prPickerOpen; // Installed PWA (standalone): a focus() from a bare timeout is outside the // user gesture and iOS refuses to raise the keyboard for it (Safari // in-browser is lenient). MobileOverlayPanel dispatches // 'oc:mobile-overlay-closed' synchronously from the same React flush as the // click that closed it — refocus right there, while the gesture is live. const pickerDialogsOpenRef = React.useRef(false); pickerDialogsOpenRef.current = holders.issuePickerOpen || holders.prPickerOpen; const skipNextCloseRestoreRef = React.useRef(false); const openSheetCountRef = React.useRef(0); const holdFocusUntilRef = React.useRef(0); React.useEffect(() => { if (!isMobile || isCapacitorApp() || typeof window === 'undefined') return; if (!window.matchMedia?.('(display-mode: standalone)')?.matches) return; const handleOverlayOpened = () => { openSheetCountRef.current += 1; }; const handleOverlayClosed = () => { // Counter instead of a DOM check: the close event fires from a // layout-effect cleanup, when the closing sheet's portal nodes may // still be attached — the DOM cannot tell "this sheet going away" // from "another sheet still up". openSheetCountRef.current = Math.max(0, openSheetCountRef.current - 1); if (skipNextCloseRestoreRef.current) { skipNextCloseRestoreRef.current = false; return; } if (!restoreKeyboardRef.current) return; if (pickerDialogsOpenRef.current) return; if (openSheetCountRef.current > 0) return; restoreKeyboardRef.current = false; // iOS can still dismiss the freshly-raised keyboard when the tap // that closed the overlay finishes over non-input content — hold // focus through that window (see onEditorBlur). holdFocusUntilRef.current = Date.now() + 600; editorRef.current?.focus(); // The native focus lands mid-commit; React's delegated onFocus may // not make it into this flush, leaving the composer un-busy for a // beat — enough for the collapse timer to unmount the focused // editor and kill the rising keyboard. Set the state explicitly. if (editorRef.current?.isFocused()) setFocused(true); // iOS reveals a field above the keyboard only for user-initiated // focus; a programmatic one leaves the composer parked behind it. // Reveal once the keyboard has mostly risen, and again after it // settles. const reveal = () => { const editor = editorRef.current; if (!editor?.isFocused()) return; // Align the BOTTOM of the whole form with the visible bottom: // revealing the editor alone leaves the footer icon row parked // behind the keyboard accessory bar. (formRef.current ?? editor.getScrollDOM())?.scrollIntoView({ block: 'end' }); }; window.setTimeout(reveal, 300); window.setTimeout(reveal, 650); }; window.addEventListener('oc:mobile-overlay-opened', handleOverlayOpened); window.addEventListener('oc:mobile-overlay-closed', handleOverlayClosed); return () => { window.removeEventListener('oc:mobile-overlay-opened', handleOverlayOpened); window.removeEventListener('oc:mobile-overlay-closed', handleOverlayClosed); }; }, [editorRef, formRef, isMobile]); // If the keyboard was open (or closed moments ago by the overlay's own // blur) when an overlay appeared, bring it back once every overlay is gone. React.useEffect(() => { if (!isMobile) return; if (overlayOpen) { if (focused || Date.now() - lastBlurAtRef.current < 800) { restoreKeyboardRef.current = true; } return; } if (!restoreKeyboardRef.current) return; // Debounced: overlay chains hand off with a frame of "nothing open" // between steps (attach sheet closes, then the picker opens). Restoring // instantly in that gap would pop the keyboard open inside the next // overlay — wait out the gap and cancel if another overlay appears. const timer = window.setTimeout(() => { restoreKeyboardRef.current = false; // Browsers need their native scroll-into-view (see expand). editorRef.current?.focus({ preventScroll: isCapacitorApp() }); }, 180); return () => window.clearTimeout(timer); }, [editorRef, focused, isMobile, overlayOpen]); // Fold back into the pill once nothing keeps the composer open. The short // delay bridges focus moving between composer controls. const busy = focused || overlayHostBusy || dictationActive || holders.controlsPanelOpen || holders.attachMenuOpen || holders.draftPickerOpen || holders.issuePickerOpen || holders.prPickerOpen || holders.isDragging; React.useEffect(() => { if (!isMobile || !expanded || busy) return; const timer = window.setTimeout(() => { // Authoritative DOM check: the React focus state can lag a // programmatic refocus (the overlay-close restore above). // Collapsing would unmount the focused editor and kill the keyboard. if (editorRef.current?.isFocused()) return; expandIntentRef.current = null; setExpanded(false); setExpandedInput(false); }, 250); return () => window.clearTimeout(timer); }, [busy, editorRef, expanded, isMobile, setExpandedInput]); const busyRef = React.useRef(false); busyRef.current = busy; // Browser counterpart of Capacitor's oc-keyboard-open root class (which is // driven by native keyboard events): the focused composer is the best // keyboard proxy a browser has. CSS keyed on it hides the draft starters // while typing, mirroring the native app. React.useEffect(() => { if (!isMobile || isCapacitorApp() || typeof document === 'undefined') return; const root = document.documentElement; if (focused) { root.classList.add('oc-browser-keyboard-open'); } else { root.classList.remove('oc-browser-keyboard-open'); // Installed PWA: after the keyboard dismisses, WebKit can leave the // layout viewport stuck smaller or panned (content shifted up with // a dead strip at the bottom) until something forces a recompute. A // zero scroll after the exit animation settles snaps it back, and // is harmless when nothing is stuck. if (window.matchMedia?.('(display-mode: standalone)')?.matches) { window.setTimeout(() => { if (root.classList.contains('oc-browser-keyboard-open')) return; window.scrollTo(0, 0); document.body.scrollTop = 0; root.scrollTop = 0; }, 350); } } return () => root.classList.remove('oc-browser-keyboard-open'); }, [focused, isMobile]); // Capacitor: collapse in the SAME frame the keyboard starts hiding. The // hide choreography dispatches oc:keyboard-intent BEFORE restoring the // shell layout and measuring the chat compensation; flushSync commits the // pill swap first, so keyboard land and composer shrink are measured — and // compensated — as one motion instead of a two-step staircase. The delayed // effect above remains the fallback for non-Capacitor and for overlays // closing without a keyboard transition. React.useEffect(() => { if (!isMobile || typeof window === 'undefined') return; const handleIntent = (event: Event) => { const detail = (event as CustomEvent<{ open?: boolean }>).detail; if (!detail || detail.open !== false) return; if (!expandedRef.current) return; // Something still holds the composer open (dictation, an overlay // that closed the keyboard, a drag) — the fallback path handles it. if (busyRef.current) return; expandIntentRef.current = null; flushSync(() => { setExpanded(false); setExpandedInput(false); }); }; window.addEventListener('oc:keyboard-intent', handleIntent); return () => window.removeEventListener('oc:keyboard-intent', handleIntent); }, [isMobile, setExpandedInput]); const onEditorFocus = React.useCallback(() => { if (!isMobile) return; if (blurTimerRef.current !== null) { window.clearTimeout(blurTimerRef.current); blurTimerRef.current = null; } expandIntentRef.current = null; setFocused(true); }, [isMobile]); const onEditorBlur = React.useCallback(() => { if (!isMobile) return; // Focus hold after an overlay-close restore: iOS may retract the rising // keyboard as the closing tap settles — take the focus right back // instead of accepting the blur. if (Date.now() < holdFocusUntilRef.current) { const editor = editorRef.current; if (editor) { editor.focus(); window.setTimeout(() => { if (Date.now() < holdFocusUntilRef.current && !editor.isFocused()) { editor.focus(); } }, 50); return; } } lastBlurAtRef.current = Date.now(); // Mobile browsers and installed PWAs share a blur race: the // keyboard-dismiss reflow moves composer buttons before the tap's // synthesized click lands, so the click misses its target. Capacitor's // WebView does not need the hold — but it DOES need the state committed // synchronously: the oc:keyboard-intent collapse arrives a few // milliseconds after this blur on a setTimeout(0), and React's own // scheduling can lose that race, leaving busyRef stale — the intent // handler then skips the instant collapse and the pill appears only // via the 250ms fallback, well after the keyboard has gone. if (isCapacitorApp()) { flushSync(() => setFocused(false)); return; } if (blurTimerRef.current !== null) window.clearTimeout(blurTimerRef.current); // 120ms outlives the tap's synthesized click (which lands within a few // ms of the blur) while keeping the composer's return visually tied to // the keyboard dismissal. blurTimerRef.current = window.setTimeout(() => { blurTimerRef.current = null; setFocused(false); }, 120); }, [editorRef, isMobile]); const skipNextOverlayCloseRestore = React.useCallback(() => { skipNextCloseRestoreRef.current = true; }, []); const cancelOverlayCloseRestore = React.useCallback(() => { restoreKeyboardRef.current = false; }, []); return { expanded, focused, overlayHostBusy, dictationActive, expand, onDictationActiveChange, onEditorFocus, onEditorBlur, skipNextOverlayCloseRestore, cancelOverlayCloseRestore, }; }