436 lines
20 KiB
TypeScript
436 lines
20 KiB
TypeScript
/**
|
|||
|
|
* 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<ComposerEditorHandle | null>;
|
||
|
|
formRef: React.RefObject<HTMLFormElement | null>;
|
||
|
|
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<number | null>(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,
|
||
|
|
};
|
||
|
|
}
|