| 1 | import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; |
| 2 | import { |
| 3 | ActivityIndicator, |
| 4 | Linking, |
| 5 | Platform, |
| 6 | Pressable, |
| 7 | useWindowDimensions, |
| 8 | View, |
| 9 | } from "react-native"; |
| 10 | import Animated, { |
| 11 | Easing, |
| 12 | Extrapolation, |
| 13 | FadeIn, |
| 14 | interpolate, |
| 15 | ReduceMotion, |
| 16 | useAnimatedStyle, |
| 17 | useSharedValue, |
| 18 | withTiming, |
| 19 | } from "react-native-reanimated"; |
| 20 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 21 | import { FullWindowOverlay } from "react-native-screens"; |
| 22 | |
| 23 | import { AppText as Text } from "../../components/AppText"; |
| 24 | import { SymbolView } from "../../components/AppSymbol"; |
| 25 | import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; |
| 26 | import { cn } from "../../lib/cn"; |
| 27 | import { CompactVoiceWaveform, DictationElapsedTime } from "./ComposerDictationControl"; |
| 28 | import { useGlobalVoiceInput } from "./VoiceInputProvider"; |
| 29 | import { resolveVoiceComposerPresentation } from "./voiceInputPresentation"; |
| 30 | |
| 31 | const PILL_HEIGHT = 36; |
| 32 | const COLLAPSED_WIDTH = 64; |
| 33 | const EXPANDED_MAX_WIDTH = 280; |
| 34 | // Clears Home's floating bottom toolbar (56) and a collapsed thread composer (60). |
| 35 | const IOS_BOTTOM_CHROME_CLEARANCE = 64; |
| 36 | const MORPH_TIMING = { |
| 37 | duration: 260, |
| 38 | easing: Easing.out(Easing.cubic), |
| 39 | reduceMotion: ReduceMotion.System, |
| 40 | } as const; |
| 41 | const 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 | */ |
| 48 | export 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 | |
| 73 | function 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 | } |