import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import { KeyboardController } from "react-native-keyboard-controller"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; type PresentationPhase = "closed" | "opening" | "visible" | "restoring"; /** * The navigator-level UIKit completion event added by the repo's * `@react-navigation/native-stack` patch; absent from upstream event maps. */ export type NavigationWithFinishTransitioning = { readonly addListener: (type: "finishTransitioning", callback: () => void) => () => void; }; /** * How long after the dismissal's state change the keyboard starts rising, so * its ~250ms show overlaps the tail of the sheet's ~500ms travel the way * UIKit apps choreograph it. This is aesthetics, not correctness: without * keepFocus-style inputView overrides a show started mid-dismissal completes * cleanly, so a slower device merely gets more overlap — no failure mode. * The navigator's `finishTransitioning` event (UIKit's real completion * callback, surfaced by the repo's native-stack patch) additionally bounds * the restore at the true landing moment should this timer ever lag it. */ const SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS = 300; /** * A JS-initiated dismissal pops state before its animation runs; a * gesture-driven one animates natively first and pops afterwards, with the * navigator's completion event landing a few dozen milliseconds before the * pop. A completion this fresh at pop time therefore means the sheet is * already gone and the keyboard should return immediately. The two orderings * are separated by the sheet's full ~500ms travel, so this window is a * classification with wide margin, not an animation race. */ const NATIVE_DISMISSAL_ECHO_WINDOW_MS = 150; /** * Keeps the custom native composer and the settings sheet from owning focus at * the same time. Opening resigns the editor cleanly; a dismissal re-focuses it * once the sheet has fully landed. A plain blur/focus pair costs one keyboard * animation each way — keepFocus-style inputView overrides are avoided because * removing them forces UIKit to reload input views, replaying the keyboard's * show as a visible collapse/re-open. */ export function useThreadSettingsSheetPresentation(input: { readonly editorRef: RefObject; readonly isEditorFocused: boolean; }) { const [phase, setPhase] = useState("closed"); const isActiveRef = useRef(false); const isMountedRef = useRef(true); const isEditorFocusedRef = useRef(input.isEditorFocused); const openingIdRef = useRef(0); const focusRestoreIdRef = useRef(0); const restoreFocusAfterDismissRef = useRef(false); const restorePendingRef = useRef(false); const lastStackTransitionFinishedAtRef = useRef(0); const dismissRestoreTimerRef = useRef | null>(null); const clearDismissRestoreTimer = useCallback(() => { if (dismissRestoreTimerRef.current !== null) { clearTimeout(dismissRestoreTimerRef.current); dismissRestoreTimerRef.current = null; } }, []); useEffect(() => { isEditorFocusedRef.current = input.isEditorFocused; }, [input.isEditorFocused]); useEffect(() => { // React Strict Mode and Fast Refresh both run an effect cleanup/setup // cycle without recreating refs. Re-arm the mounted guard on every setup. isMountedRef.current = true; return () => { isMountedRef.current = false; isActiveRef.current = false; openingIdRef.current += 1; focusRestoreIdRef.current += 1; clearDismissRestoreTimer(); }; }, [clearDismissRestoreTimer]); const open = useCallback(() => { if (isActiveRef.current) { return; } isActiveRef.current = true; focusRestoreIdRef.current += 1; clearDismissRestoreTimer(); restorePendingRef.current = false; restoreFocusAfterDismissRef.current = phase === "restoring" || input.isEditorFocused || KeyboardController.isVisible(); setPhase("opening"); const openingId = openingIdRef.current + 1; openingIdRef.current = openingId; // Start the keyboard transition before the custom native editor resigns // first responder, then present the sheet on the next frame. The sheet and // keyboard animate together instead of serializing two native transitions. void KeyboardController.dismiss({ animated: true }); input.editorRef.current?.blur(); requestAnimationFrame(() => { if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) { return; } setPhase("visible"); }); }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused, phase]); const restoreEditorFocus = useCallback(() => { const focusRestoreId = focusRestoreIdRef.current + 1; focusRestoreIdRef.current = focusRestoreId; let attemptsRemaining = 20; // Restoration runs after the dismissal transition, so the first attempt // normally succeeds; the retries are insurance against UIKit briefly // refusing first-responder status right at the transition boundary. const restoreFocus = () => { if (!isMountedRef.current || focusRestoreIdRef.current !== focusRestoreId) { return; } if (isEditorFocusedRef.current || attemptsRemaining <= 0) { setPhase("closed"); return; } attemptsRemaining -= 1; input.editorRef.current?.focus(); setTimeout(restoreFocus, 50); }; requestAnimationFrame(restoreFocus); }, [input.editorRef]); /** Runs the queued restore once — whichever completion signal arrives first. */ const runPendingDismissalRestore = useCallback(() => { if (!restorePendingRef.current) { return; } restorePendingRef.current = false; clearDismissRestoreTimer(); // A reopened sheet owns focus again; drop the stale restore request. if (!isMountedRef.current || isActiveRef.current) { return; } restoreEditorFocus(); }, [clearDismissRestoreTimer, restoreEditorFocus]); /** * Marks the sheet closed and queues the keyboard's return for the moment * the dismissal transition actually completes: the sheet slides away over a * resting composer, then the keyboard lifts it in one continuous motion. */ const onDismissed = useCallback(() => { isActiveRef.current = false; if (!restoreFocusAfterDismissRef.current) { setPhase("closed"); return; } // Keep the card expanded across the handoff back to its editor. With a // hardware keyboard there is no software-keyboard travel to hide a collapse // while the sheet dismissal and focus restoration finish. setPhase("restoring"); restoreFocusAfterDismissRef.current = false; restorePendingRef.current = true; clearDismissRestoreTimer(); if (Date.now() - lastStackTransitionFinishedAtRef.current <= NATIVE_DISMISSAL_ECHO_WINDOW_MS) { // A stack transition finished just before this pop reached JS: the pop // is the state echo of a gesture-driven dismissal whose animation has // already completed. The sheet is gone — bring the keyboard back now. runPendingDismissalRestore(); return; } dismissRestoreTimerRef.current = setTimeout(() => { dismissRestoreTimerRef.current = null; runPendingDismissalRestore(); }, SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS); }, [clearDismissRestoreTimer, runPendingDismissalRestore]); /** Wire to the navigator's `finishTransitioning` event. */ const onStackTransitionsFinished = useCallback(() => { lastStackTransitionFinishedAtRef.current = Date.now(); runPendingDismissalRestore(); }, [runPendingDismissalRestore]); return { isActive: phase === "opening" || phase === "visible", keepsComposerExpanded: phase !== "closed", isVisible: phase === "visible", open, onDismissed, onStackTransitionsFinished, } as const; }