Open History to see the last commit
1import { useFocusEffect } from "@react-navigation/native";
2import { useCallback, useEffect, useRef } from "react";
3import {
4 voiceInputBlocksSubmission,
5 type VoiceInputState,
6} from "@t3tools/client-runtime/voice-input";
7
8import type { ComposerEditorSelection } from "../../components/ComposerEditor";
9import { useGlobalVoiceInput } from "./VoiceInputProvider";
10import { createVoiceInputTarget } from "./voiceInputSession";
11
12const IDLE_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null };
13
14export function useVoiceInputController(input: {
15 readonly ownerKey: string | null;
16 readonly label: string;
17 readonly readDraftMessage: () => string | null;
18 readonly selection: ComposerEditorSelection;
19 readonly disabled?: boolean;
20 readonly onChangeDraftMessage: (value: string) => void;
21 readonly onChangeSelection: (selection: ComposerEditorSelection) => void;
22}) {
23 const global = useGlobalVoiceInput();
24 const { session } = global;
25 const latestInput = useRef(input);
26 // oxlint-disable-next-line react/refs -- Transcription finishes asynchronously and must reach the composer's current selection setter, not the one captured at start.
27 latestInput.current = input;
28 const mounted = useRef(true);
29 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tracks mount so async transcription results skip a composer that has unmounted
30 useEffect(() => {
31 mounted.current = true;
32 return () => {
33 mounted.current = false;
34 };
35 }, []);
36
37 useFocusEffect(
38 useCallback(() => {
39 const ownerKey = input.ownerKey;
40 if (!ownerKey) return;
41 session.setOwnerFocused(ownerKey, true);
42 return () => session.setOwnerFocused(ownerKey, false);
43 }, [input.ownerKey, session]),
44 );
45
46 const start = useCallback(() => {
47 const captured = latestInput.current;
48 if (!captured.ownerKey || captured.disabled) return;
49 void session.start({
50 ...createVoiceInputTarget(
51 captured.ownerKey,
52 captured.readDraftMessage,
53 (text, selection) => {
54 captured.onChangeDraftMessage(text);
55 if (mounted.current && latestInput.current.ownerKey === captured.ownerKey) {
56 latestInput.current.onChangeSelection(selection);
57 }
58 },
59 captured.selection,
60 ),
61 label: captured.label,
62 });
63 }, [session]);
64 const state = global.ownerKey === input.ownerKey ? global.state : IDLE_STATE;
65 const isBusy = voiceInputBlocksSubmission(state);
66 return {
67 isAvailable: global.isAvailable && (!global.isBusy || global.ownerKey === input.ownerKey),
68 state,
69 audioLevels: global.audioLevels,
70 elapsedSeconds: global.elapsedSeconds,
71 isBusy,
72 freezesEditor: isBusy,
73 blocksSubmission: isBusy,
74 start,
75 stop: global.stop,
76 cancel: global.cancel,
77 };
78}