Skip to content
Source
Sign inSign up

GlobalVoiceInputControl.tsx

233 lines9.1 KB

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