1import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
2import { KeyboardController } from "react-native-keyboard-controller";
3
4import type { ComposerEditorHandle } from "../../components/ComposerEditor";
5
6type PresentationPhase = "closed" | "opening" | "visible" | "restoring";
7
8/**
9 * The navigator-level UIKit completion event added by the repo's
10 * `@react-navigation/native-stack` patch; absent from upstream event maps.
11 */
12export type NavigationWithFinishTransitioning = {
13 readonly addListener: (type: "finishTransitioning", callback: () => void) => () => void;
14};
15
16/**
17 * How long after the dismissal's state change the keyboard starts rising, so
18 * its ~250ms show overlaps the tail of the sheet's ~500ms travel the way
19 * UIKit apps choreograph it. This is aesthetics, not correctness: without
20 * keepFocus-style inputView overrides a show started mid-dismissal completes
21 * cleanly, so a slower device merely gets more overlap — no failure mode.
22 * The navigator's `finishTransitioning` event (UIKit's real completion
23 * callback, surfaced by the repo's native-stack patch) additionally bounds
24 * the restore at the true landing moment should this timer ever lag it.
25 */
26const SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS = 300;
27
28/**
29 * A JS-initiated dismissal pops state before its animation runs; a
30 * gesture-driven one animates natively first and pops afterwards, with the
31 * navigator's completion event landing a few dozen milliseconds before the
32 * pop. A completion this fresh at pop time therefore means the sheet is
33 * already gone and the keyboard should return immediately. The two orderings
34 * are separated by the sheet's full ~500ms travel, so this window is a
35 * classification with wide margin, not an animation race.
36 */
37const NATIVE_DISMISSAL_ECHO_WINDOW_MS = 150;
38
39/**
40 * Keeps the custom native composer and the settings sheet from owning focus at
41 * the same time. Opening resigns the editor cleanly; a dismissal re-focuses it
42 * once the sheet has fully landed. A plain blur/focus pair costs one keyboard
43 * animation each way — keepFocus-style inputView overrides are avoided because
44 * removing them forces UIKit to reload input views, replaying the keyboard's
45 * show as a visible collapse/re-open.
46 */
47export function useThreadSettingsSheetPresentation(input: {
48 readonly editorRef: RefObject<ComposerEditorHandle | null>;
49 readonly isEditorFocused: boolean;
50}) {
51 const [phase, setPhase] = useState<PresentationPhase>("closed");
52 const isActiveRef = useRef(false);
53 const isMountedRef = useRef(true);
54 const isEditorFocusedRef = useRef(input.isEditorFocused);
55 const openingIdRef = useRef(0);
56 const focusRestoreIdRef = useRef(0);
57 const restoreFocusAfterDismissRef = useRef(false);
58 const restorePendingRef = useRef(false);
59 const lastStackTransitionFinishedAtRef = useRef(0);
60 const dismissRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
61 const clearDismissRestoreTimer = useCallback(() => {
62 if (dismissRestoreTimerRef.current !== null) {
63 clearTimeout(dismissRestoreTimerRef.current);
64 dismissRestoreTimerRef.current = null;
65 }
66 }, []);
67
68 useEffect(() => {
69 isEditorFocusedRef.current = input.isEditorFocused;
70 }, [input.isEditorFocused]);
71
72 useEffect(() => {
73 // React Strict Mode and Fast Refresh both run an effect cleanup/setup
74 // cycle without recreating refs. Re-arm the mounted guard on every setup.
75 isMountedRef.current = true;
76 return () => {
77 isMountedRef.current = false;
78 isActiveRef.current = false;
79 openingIdRef.current += 1;
80 focusRestoreIdRef.current += 1;
81 clearDismissRestoreTimer();
82 };
83 }, [clearDismissRestoreTimer]);
84
85 const open = useCallback(() => {
86 if (isActiveRef.current) {
87 return;
88 }
89
90 isActiveRef.current = true;
91 focusRestoreIdRef.current += 1;
92 clearDismissRestoreTimer();
93 restorePendingRef.current = false;
94 restoreFocusAfterDismissRef.current =
95 phase === "restoring" || input.isEditorFocused || KeyboardController.isVisible();
96 setPhase("opening");
97
98 const openingId = openingIdRef.current + 1;
99 openingIdRef.current = openingId;
100
101 // Start the keyboard transition before the custom native editor resigns
102 // first responder, then present the sheet on the next frame. The sheet and
103 // keyboard animate together instead of serializing two native transitions.
104 void KeyboardController.dismiss({ animated: true });
105 input.editorRef.current?.blur();
106
107 requestAnimationFrame(() => {
108 if (!isMountedRef.current || !isActiveRef.current || openingIdRef.current !== openingId) {
109 return;
110 }
111 setPhase("visible");
112 });
113 }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused, phase]);
114
115 const restoreEditorFocus = useCallback(() => {
116 const focusRestoreId = focusRestoreIdRef.current + 1;
117 focusRestoreIdRef.current = focusRestoreId;
118 let attemptsRemaining = 20;
119
120 // Restoration runs after the dismissal transition, so the first attempt
121 // normally succeeds; the retries are insurance against UIKit briefly
122 // refusing first-responder status right at the transition boundary.
123 const restoreFocus = () => {
124 if (!isMountedRef.current || focusRestoreIdRef.current !== focusRestoreId) {
125 return;
126 }
127 if (isEditorFocusedRef.current || attemptsRemaining <= 0) {
128 setPhase("closed");
129 return;
130 }
131
132 attemptsRemaining -= 1;
133 input.editorRef.current?.focus();
134 setTimeout(restoreFocus, 50);
135 };
136 requestAnimationFrame(restoreFocus);
137 }, [input.editorRef]);
138
139 /** Runs the queued restore once — whichever completion signal arrives first. */
140 const runPendingDismissalRestore = useCallback(() => {
141 if (!restorePendingRef.current) {
142 return;
143 }
144 restorePendingRef.current = false;
145 clearDismissRestoreTimer();
146 // A reopened sheet owns focus again; drop the stale restore request.
147 if (!isMountedRef.current || isActiveRef.current) {
148 return;
149 }
150 restoreEditorFocus();
151 }, [clearDismissRestoreTimer, restoreEditorFocus]);
152
153 /**
154 * Marks the sheet closed and queues the keyboard's return for the moment
155 * the dismissal transition actually completes: the sheet slides away over a
156 * resting composer, then the keyboard lifts it in one continuous motion.
157 */
158 const onDismissed = useCallback(() => {
159 isActiveRef.current = false;
160
161 if (!restoreFocusAfterDismissRef.current) {
162 setPhase("closed");
163 return;
164 }
165 // Keep the card expanded across the handoff back to its editor. With a
166 // hardware keyboard there is no software-keyboard travel to hide a collapse
167 // while the sheet dismissal and focus restoration finish.
168 setPhase("restoring");
169 restoreFocusAfterDismissRef.current = false;
170 restorePendingRef.current = true;
171 clearDismissRestoreTimer();
172 if (Date.now() - lastStackTransitionFinishedAtRef.current <= NATIVE_DISMISSAL_ECHO_WINDOW_MS) {
173 // A stack transition finished just before this pop reached JS: the pop
174 // is the state echo of a gesture-driven dismissal whose animation has
175 // already completed. The sheet is gone — bring the keyboard back now.
176 runPendingDismissalRestore();
177 return;
178 }
179 dismissRestoreTimerRef.current = setTimeout(() => {
180 dismissRestoreTimerRef.current = null;
181 runPendingDismissalRestore();
182 }, SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS);
183 }, [clearDismissRestoreTimer, runPendingDismissalRestore]);
184
185 /** Wire to the navigator's `finishTransitioning` event. */
186 const onStackTransitionsFinished = useCallback(() => {
187 lastStackTransitionFinishedAtRef.current = Date.now();
188 runPendingDismissalRestore();
189 }, [runPendingDismissalRestore]);
190
191 return {
192 isActive: phase === "opening" || phase === "visible",
193 keepsComposerExpanded: phase !== "closed",
194 isVisible: phase === "visible",
195 open,
196 onDismissed,
197 onStackTransitionsFinished,
198 } as const;
199}