1import { 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/** UIKit completion event from the native-stack patch; absent from upstream event maps. */
9export type NavigationWithFinishTransitioning = {
10 readonly addListener: (type: "finishTransitioning", callback: () => void) => () => void;
11};
12
13const SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS = 300;
14
15/** Gesture dismissal completes before router pop; recent completion means keyboard restoration need not wait again. */
16const NATIVE_DISMISSAL_ECHO_WINDOW_MS = 150;
17
18/** Blur/focus costs two keyboard animations; inputView overrides also replay show when removed. */
19export function useThreadSettingsSheetPresentation(input: {
20 readonly editorRef: RefObject<ComposerEditorHandle | null>;
21 /** Runs on the frame the sheet becomes visible, after the keyboard started leaving. */
22 readonly onPresent: () => void;
23}) {
24 // Destructured so the compiler keys memoized handlers on the stable editor ref, not on
25 // the fresh input object: the finishTransitioning listener re-subscribes on every change.
26 const { editorRef, onPresent } = input;
27 const [phase, setPhase] = useState<PresentationPhase>("closed");
28 const isActiveRef = useRef(false);
29 // Written by the editor's focus handlers; read when restoring focus after dismissal.
30 const isEditorFocusedRef = useRef(false);
31 const openingIdRef = useRef(0);
32 const focusRestoreIdRef = useRef(0);
33 const restoreFocusAfterDismissRef = useRef(false);
34 const restorePendingRef = useRef(false);
35 const lastStackTransitionFinishedAtRef = useRef(0);
36 const dismissRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
37 const clearDismissRestoreTimer = () => {
38 if (dismissRestoreTimerRef.current !== null) {
39 clearTimeout(dismissRestoreTimerRef.current);
40 dismissRestoreTimerRef.current = null;
41 }
42 };
43
44 const open = () => {
45 if (isActiveRef.current) {
46 return;
47 }
48
49 isActiveRef.current = true;
50 focusRestoreIdRef.current += 1;
51 clearDismissRestoreTimer();
52 restorePendingRef.current = false;
53 restoreFocusAfterDismissRef.current =
54 phase === "restoring" || isEditorFocusedRef.current || KeyboardController.isVisible();
55 setPhase("opening");
56
57 const openingId = openingIdRef.current + 1;
58 openingIdRef.current = openingId;
59
60 // Dismiss the keyboard before blur, then present next frame so native transitions overlap.
61 void KeyboardController.dismiss({ animated: true });
62 editorRef.current?.blur();
63
64 requestAnimationFrame(() => {
65 // A composer unmounted during the frame takes its editor with it; present nothing.
66 if (
67 !isActiveRef.current ||
68 openingIdRef.current !== openingId ||
69 editorRef.current === null
70 ) {
71 return;
72 }
73 setPhase("visible");
74 onPresent();
75 });
76 };
77
78 const restoreEditorFocus = () => {
79 const focusRestoreId = focusRestoreIdRef.current + 1;
80 focusRestoreIdRef.current = focusRestoreId;
81 let attemptsRemaining = 20;
82
83 // Retry focus restoration if UIKit briefly refuses first responder at the dismissal boundary.
84 const restoreFocus = () => {
85 if (focusRestoreIdRef.current !== focusRestoreId) {
86 return;
87 }
88 if (isEditorFocusedRef.current || attemptsRemaining <= 0) {
89 setPhase("closed");
90 return;
91 }
92
93 attemptsRemaining -= 1;
94 editorRef.current?.focus();
95 setTimeout(restoreFocus, 50);
96 };
97 requestAnimationFrame(restoreFocus);
98 };
99
100 const runPendingDismissalRestore = () => {
101 if (!restorePendingRef.current) {
102 return;
103 }
104 restorePendingRef.current = false;
105 clearDismissRestoreTimer();
106 if (isActiveRef.current) {
107 return;
108 }
109 restoreEditorFocus();
110 };
111
112 const onDismissed = () => {
113 isActiveRef.current = false;
114
115 if (!restoreFocusAfterDismissRef.current) {
116 setPhase("closed");
117 return;
118 }
119 // Keep the card expanded during focus handoff; hardware keyboards cannot hide a collapse with keyboard travel.
120 setPhase("restoring");
121 restoreFocusAfterDismissRef.current = false;
122 restorePendingRef.current = true;
123 clearDismissRestoreTimer();
124 if (Date.now() - lastStackTransitionFinishedAtRef.current <= NATIVE_DISMISSAL_ECHO_WINDOW_MS) {
125 runPendingDismissalRestore();
126 return;
127 }
128 dismissRestoreTimerRef.current = setTimeout(() => {
129 dismissRestoreTimerRef.current = null;
130 runPendingDismissalRestore();
131 }, SHEET_DISMISSAL_KEYBOARD_OVERLAP_MS);
132 };
133
134 const onStackTransitionsFinished = () => {
135 lastStackTransitionFinishedAtRef.current = Date.now();
136 runPendingDismissalRestore();
137 };
138
139 const onEditorFocusChange = (focused: boolean) => {
140 isEditorFocusedRef.current = focused;
141 };
142
143 // Called when the composer unmounts: cancels a queued presentation frame, the
144 // dismissal restore timer, and any focus-restore retries still in flight.
145 const dispose = () => {
146 isActiveRef.current = false;
147 openingIdRef.current += 1;
148 focusRestoreIdRef.current += 1;
149 restorePendingRef.current = false;
150 clearDismissRestoreTimer();
151 };
152
153 return {
154 isActive: phase === "opening" || phase === "visible",
155 keepsComposerExpanded: phase !== "closed",
156 isVisible: phase === "visible",
157 open,
158 onEditorFocusChange,
159 onDismissed,
160 onStackTransitionsFinished,
161 dispose,
162 } as const;
163}