1import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
2import {
3 ActivityIndicator,
4 Linking,
5 Platform,
6 Pressable,
7 useWindowDimensions,
8 View,
9} from "react-native";
10import Animated, {
11 Easing,
12 Extrapolation,
13 FadeIn,
14 interpolate,
15 ReduceMotion,
16 useAnimatedStyle,
17 useSharedValue,
18 withTiming,
19} from "react-native-reanimated";
20import { useSafeAreaInsets } from "react-native-safe-area-context";
21import { FullWindowOverlay } from "react-native-screens";
22
23import { AppText as Text } from "../../components/AppText";
24import { SymbolView } from "../../components/AppSymbol";
25import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
26import { cn } from "../../lib/cn";
27import { CompactVoiceWaveform, DictationElapsedTime } from "./ComposerDictationControl";
28import { useGlobalVoiceInput } from "./VoiceInputProvider";
29import { resolveVoiceComposerPresentation } from "./voiceInputPresentation";
30
31const PILL_HEIGHT = 36;
32const COLLAPSED_WIDTH = 64;
33const EXPANDED_MAX_WIDTH = 280;
34// Clears Home's floating bottom toolbar (56) and a collapsed thread composer (60).
35const IOS_BOTTOM_CHROME_CLEARANCE = 64;
36const MORPH_TIMING = {
37 duration: 260,
38 easing: Easing.out(Easing.cubic),
39 reduceMotion: ReduceMotion.System,
40} as const;
41const ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System);
42
43/**
44 * Keeps an off-screen dictation reachable as a pill on the screen's trailing
45 * edge. Wraps the app content so any touch that starts outside the pill,
46 * including a scroll, collapses it without claiming the touch.
47 */
48export function GlobalVoiceInputControl(props: { readonly children: ReactNode }) {
49 const voice = useGlobalVoiceInput();
50 const collapseRef = useRef<(() => void) | null>(null);
51 const presentation = resolveVoiceComposerPresentation(voice.state, voice.elapsedSeconds);
52 const visible =
53 presentation.statusLabel !== null &&
54 !(voice.ownerKey && voice.focusedOwners.has(voice.ownerKey));
55 // Mounted only while visible, so each dictation starts collapsed.
56 const pill = visible ? <EdgeDictationPill collapseRef={collapseRef} /> : null;
57 return (
58 <>
59 <View
60 className="flex-1"
61 onStartShouldSetResponderCapture={() => {
62 collapseRef.current?.();
63 return false;
64 }}
65 >
66 {props.children}
67 </View>
68 {pill && Platform.OS === "ios" ? <FullWindowOverlay>{pill}</FullWindowOverlay> : pill}
69 </>
70 );
71}
72
73function EdgeDictationPill(props: { readonly collapseRef: RefObject<(() => void) | null> }) {
74 const voice = useGlobalVoiceInput();
75 const insets = useSafeAreaInsets();
76 const { width } = useWindowDimensions();
77 const { fabSize } = useAndroidControlSizing();
78 const [expanded, setExpanded] = useState(false);
79 const progress = useSharedValue(0);
80 useEffect(() => {
81 progress.value = withTiming(expanded ? 1 : 0, MORPH_TIMING);
82 }, [expanded, progress]);
83 const { collapseRef } = props;
84 useEffect(() => {
85 if (!expanded) return;
86 collapseRef.current = () => setExpanded(false);
87 return () => {
88 collapseRef.current = null;
89 };
90 }, [collapseRef, expanded]);
91
92 const expandedWidth = Math.min(width - insets.left - insets.right - 32, EXPANDED_MAX_WIDTH);
93 // Each face keeps its final width and stays pinned to the edge, so the
94 // morphing pill reveals the expanded row instead of reflowing it.
95 const pillStyle = useAnimatedStyle(() => ({
96 width: interpolate(progress.value, [0, 1], [COLLAPSED_WIDTH, expandedWidth]),
97 }));
98 const collapsedStyle = useAnimatedStyle(() => ({
99 opacity: interpolate(progress.value, [0, 0.4], [1, 0], Extrapolation.CLAMP),
100 }));
101 const expandedStyle = useAnimatedStyle(() => ({
102 opacity: interpolate(progress.value, [0.3, 1], [0, 1], Extrapolation.CLAMP),
103 }));
104
105 // Sit just above the bottom chrome: the toolbar or composer on iOS, the
106 // new-thread FAB on Android.
107 const bottom =
108 Platform.OS === "android"
109 ? Math.max(insets.bottom, 16) + 16 + fabSize + 12
110 : Math.max(insets.bottom, 12) + IOS_BOTTOM_CHROME_CLEARANCE;
111
112 const presentation = resolveVoiceComposerPresentation(voice.state, voice.elapsedSeconds);
113 const phase = voice.state.phase;
114 const isError = phase === "error";
115 const openSettings = isError && voice.state.errorAction === "settings";
116 const label = voice.label ?? "Draft";
117
118 return (
119 <View pointerEvents="box-none" className="absolute inset-0">
120 <Animated.View
121 entering={ENTERING}
122 className="absolute rounded-l-full border border-r-0 border-border bg-card shadow-md shadow-black/10"
123 style={[{ bottom, right: insets.right, height: PILL_HEIGHT }, pillStyle]}
124 >
125 <View className="flex-1 overflow-hidden rounded-l-full">
126 <Animated.View
127 accessibilityElementsHidden={expanded}
128 importantForAccessibility={expanded ? "no-hide-descendants" : "auto"}
129 pointerEvents={expanded ? "none" : "auto"}
130 className="absolute bottom-0 right-0 top-0"
131 style={[{ width: COLLAPSED_WIDTH }, collapsedStyle]}
132 >
133 <Pressable
134 accessibilityLabel={`${presentation.statusLabel}, dictating into ${label}`}
135 accessibilityHint="Shows dictation controls"
136 accessibilityRole="button"
137 className="flex-1 flex-row items-center justify-center gap-1.5 active:opacity-70"
138 hitSlop={{ top: 6, bottom: 6, left: 6 }}
139 onPress={() => setExpanded(true)}
140 >
141 {isError ? (
142 <SymbolView
143 name="exclamationmark.circle"
144 size={16}
145 tintColorClassName="accent-danger-foreground"
146 type="monochrome"
147 />
148 ) : phase === "recording" ? (
149 <>
150 <View className="size-2 rounded-full bg-danger-foreground" />
151 <DictationElapsedTime
152 className="text-foreground"
153 seconds={voice.elapsedSeconds}
154 />
155 </>
156 ) : (
157 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />
158 )}
159 </Pressable>
160 </Animated.View>
161
162 <Animated.View
163 accessibilityElementsHidden={!expanded}
164 importantForAccessibility={expanded ? "auto" : "no-hide-descendants"}
165 pointerEvents={expanded ? "auto" : "none"}
166 className="absolute bottom-0 right-0 top-0 flex-row items-center gap-1.5 pl-1 pr-1.5"
167 style={[{ width: expandedWidth }, expandedStyle]}
168 >
169 <Pressable
170 accessibilityLabel={isError ? "Dismiss voice input error" : "Cancel dictation"}
171 accessibilityRole="button"
172 className="size-[30px] items-center justify-center active:opacity-70"
173 onPress={voice.cancel}
174 >
175 <SymbolView
176 name="xmark"
177 size={13}
178 tintColorClassName="accent-icon-muted"
179 type="monochrome"
180 />
181 </Pressable>
182 <Pressable
183 accessibilityLabel={isError ? (presentation.statusLabel ?? label) : label}
184 accessibilityHint="Hides dictation controls"
185 accessibilityRole="button"
186 className="min-w-0 flex-1 justify-center self-stretch active:opacity-70"
187 onPress={() => setExpanded(false)}
188 >
189 <Text
190 className={cn(
191 "text-xs",
192 isError ? "text-danger-foreground" : "font-t3-medium text-foreground",
193 )}
194 numberOfLines={1}
195 >
196 {isError ? presentation.statusLabel : label}
197 </Text>
198 </Pressable>
199 {phase === "recording" ? (
200 <>
201 <CompactVoiceWaveform audioLevels={voice.audioLevels} />
202 <DictationElapsedTime
203 className="text-foreground-muted"
204 seconds={voice.elapsedSeconds}
205 />
206 <Pressable
207 accessibilityLabel="Finish dictation"
208 accessibilityRole="button"
209 className="size-[30px] items-center justify-center active:opacity-70"
210 onPress={voice.stop}
211 >
212 <View className="size-[24px] items-center justify-center rounded-full bg-primary">
213 <SymbolView
214 name="checkmark"
215 size={12}
216 weight="semibold"
217 tintColorClassName="accent-primary-foreground"
218 type="monochrome"
219 />
220 </View>
221 </Pressable>
222 </>
223 ) : isError ? (
224 voice.isAvailable ? (
225 <Pressable
226 accessibilityLabel={openSettings ? "Open microphone settings" : "Retry dictation"}
227 accessibilityRole="button"
228 className="size-[30px] items-center justify-center active:opacity-70"
229 onPress={() => {
230 if (!openSettings) {
231 void voice.session.retry();
232 return;
233 }
234 voice.cancel();
235 void Linking.openSettings();
236 }}
237 >
238 <SymbolView name="mic" size={16} tintColorClassName="accent-icon" />
239 </Pressable>
240 ) : null
241 ) : (
242 <View
243 accessibilityLabel={presentation.statusLabel ?? undefined}
244 className="size-[30px] items-center justify-center"
245 >
246 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />
247 </View>
248 )}
249 </Animated.View>
250 </View>
251 </Animated.View>
252 </View>
253 );
254}