199 lines · 7.8 KB · TypeScript
RawDownload
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<ComposerEditorHandle | null>;  readonly isEditorFocused: boolean;}) {  const [phase, setPhase] = useState<PresentationPhase>("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<ReturnType<typeof setTimeout> | 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;}