diff --git a/apps/mobile/src/components/ui/ListRow.tsx b/apps/mobile/src/components/ui/ListRow.tsx index de8bba51beb35..8f2ce11259bc9 100644 --- a/apps/mobile/src/components/ui/ListRow.tsx +++ b/apps/mobile/src/components/ui/ListRow.tsx @@ -1,17 +1,27 @@ -import { Platform } from "react-native"; +import { Platform, View } from "react-native"; import { useResolveClassNames } from "uniwind"; import { SymbolView } from "./AppSymbol"; import { ListRowLayout, type ListRowContentProps } from "./ListRowLayout"; +import { RadioIndicator } from "./RadioIndicator"; import { RowPressable } from "./RowPressable"; -export function ListRow(props: ListRowContentProps & { readonly onPress: () => void }) { +export function ListRow( + props: ListRowContentProps & { + readonly onPress: () => void; + /** Marks the row as the chosen option while it still navigates. */ + readonly selected?: boolean; + }, +) { const rippleStyle = useResolveClassNames("bg-subtle-strong"); return ( v + + {props.selected === undefined ? null : ( + + {Platform.OS === "android" ? ( + + ) : props.selected ? ( + + ) : null} + + )} + + } /> diff --git a/apps/mobile/src/features/settings/CloudVoiceSection.tsx b/apps/mobile/src/features/settings/CloudVoiceSection.tsx index 684f8dbc83769..6ca61ece7b90a 100644 --- a/apps/mobile/src/features/settings/CloudVoiceSection.tsx +++ b/apps/mobile/src/features/settings/CloudVoiceSection.tsx @@ -21,7 +21,7 @@ import { import * as Schema from "effect/Schema"; import Constants from "expo-constants"; import { AsyncResult } from "effect/reactivity"; -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { ScrollView, TextInput, View } from "react-native"; import { Sheet } from "../../components/ui/Sheet"; import * as Dialog from "../../components/ui/Dialog"; @@ -30,6 +30,7 @@ import { AppText as Text } from "../../components/ui/AppText"; import { ListActionRow } from "../../components/ui/ListActionRow"; import { ListChoiceRow } from "../../components/ui/ListChoiceRow"; import { Chip } from "../../components/ui/Chip"; +import { ListSection } from "../../components/ui/ListSection"; import type { EnvironmentPresentation } from "../../state/environments"; import { useEnvironmentScope } from "../../state/session"; import { @@ -40,7 +41,6 @@ import { cloudVoiceModelsAtom, runSpeech, } from "../voice-input/voiceDestination"; -import { SettingsSection } from "./components/SettingsSection"; type CloudDestination = Extract; type Props = { environment: EnvironmentPresentation; active: CloudDestination | null }; @@ -48,23 +48,38 @@ const isApiKey = Schema.is(CloudSpeechApiKey); const isModelId = Schema.is(CloudSpeechModelId); const MODEL_ROWS = 6; -export function CloudVoiceSection({ environment, active }: Props) { - const [open, setOpen] = useState(false); +/** The cloud method row for one environment; opens the provider sheet. */ +export function CloudVoiceChoice({ + environment, + active, + open, + onOpenChange, +}: Props & { open: boolean; onOpenChange: (open: boolean) => void }) { + const usable = useAtomValue(cloudVoiceConnectionAtom(environment.environmentId)) !== null; return ( - - + setOpen(true)} + selected={active !== null} + disabled={!usable} + separated + onPress={() => onOpenChange(true)} /> {open && ( - setOpen(false)} /> + onOpenChange(false)} + /> )} - + ); } @@ -92,54 +107,70 @@ function ModelPicker({ const shown = showAll ? matches : matches.slice(0, MODEL_ROWS); const delisted = current !== undefined && !models.some((model) => model.id === current); return ( - <> - - {delisted ? ( - - OpenRouter no longer lists {current}. Dictation stays off until you choose another model. - - ) : null} + + + + {selected && !shown.some((model) => model.id === selected.id) ? ( {}} /> ) : null} - {shown.map((model, index) => ( + {shown.map((model) => ( 0} + separated disabled={disabled} onPress={() => onChange(model.id)} /> ))} {matches.length === 0 ? ( - No transcription model matches. + + + No transcription model matches. + + ) : matches.length > shown.length ? ( - setShowAll(true)} - /> + + setShowAll(true)} + /> + ) : null} - + ); } +/** A hairline above a row that has no `separated` prop of its own. */ +function Separated({ children }: { children: ReactNode }) { + return {children}; +} + function CloudChoices({ environment, active, onClose }: Props & { onClose: () => void }) { const id = environment.environmentId; const connection = useAtomValue(cloudVoiceConnectionAtom(id)); @@ -223,58 +254,35 @@ function CloudChoices({ environment, active, onClose }: Props & { onClose: () => onClose(); }; const catalog = AsyncResult.isSuccess(models) ? (models.value?.models ?? null) : null; - const content = - !AsyncResult.isSuccess(keys) || !keys.value || !connection ? ( - - ) : ( - CloudSpeechProvider.literals.map((provider) => { - if (editing && provider !== editing.provider) return null; - const label = CLOUD_SPEECH_LABELS[provider]; - const company = CLOUD_SPEECH_PROVIDER_NAMES[provider]; - const routed = provider === "openrouter"; - const disclosure = cloudSpeechDisclosure(provider, environment.label); - const saved = keys.value![provider]; - const isActive = active?.provider === provider; - const isEditing = editing?.provider === provider; - const modelChosen = - isModelId(model) && (catalog?.some((entry) => entry.id === model) ?? false); - const modelChanged = model !== (activeModel ?? ""); - const original = environment.serverConfig?.settings.cloudSpeechCredentials?.[provider]; - const keyChanged = - credential !== undefined && - !( - isSecretReference(credential) && - isSecretReference(original) && - original.provider === credential.provider && - original.reference === credential.reference - ); - const routedReady = - modelChosen && - (draft.trim() ? credential !== undefined : Boolean(saved)) && - (!isActive || modelChanged || keyChanged); - const saveRouted = () => { - if (!routedReady || !isModelId(model)) return; - const grantChanges = !isActive || modelChanged; - if (credential !== undefined && keyChanged) - return void changeKey(provider, credential, model); - // Model-only changes never write to the environment. - if (grantChanges) enable(() => chooseVoiceDestination(id, provider, model)); - onClose(); - }; - return ( - - {!isEditing && ( + const content = (() => { + if (!AsyncResult.isSuccess(keys) || !keys.value || !connection) + return ( + + + + ); + const saved = keys.value; + if (!editing) + return ( + + {CloudSpeechProvider.literals.map((provider, index) => { + const label = CLOUD_SPEECH_LABELS[provider]; + const routed = provider === "openrouter"; + const key = saved[provider]; + const isActive = active?.provider === provider; + return ( /> } selected={isActive} - disabled={saved === undefined || (!saved && !canManage) || saving} + separated={index > 0} + disabled={key === undefined || (!key && !canManage) || saving} description={ - saved === undefined + key === undefined ? "Update the environment to use this provider" : isActive && routed ? `${activeModel} · Tap to change model${canManage ? " or key" : ""}` - : isActive && saved && canManage + : isActive && key && canManage ? "Tap to manage key" - : saved + : key ? routed ? "API key saved · choose a model to use it" - : "API key saved on this environment" + : "API key saved" : canManage ? "Add an API key to use this provider" : "Ask an environment administrator to add an API key" } onPress={() => { if (routed) return edit(provider, !isActive); - if (!saved) return edit(provider, true); + if (!key) return edit(provider, true); if (isActive) { if (canManage) edit(provider, false); return; } Dialog.confirm({ title: `Use ${label} for dictation?`, - message: `${disclosure} Select This device to stop sharing.`, + message: `${cloudSpeechDisclosure(provider, environment.label)} Select This device to stop sharing.`, confirmText: `Use ${label}`, onConfirm: () => enable(pendingCloudVoiceChoice(id, provider)), }); }} /> - )} - {isEditing && routed && ( - <> - {disclosure} - {catalog ? ( - + ); + + const provider = editing.provider; + const label = CLOUD_SPEECH_LABELS[provider]; + const company = CLOUD_SPEECH_PROVIDER_NAMES[provider]; + const routed = provider === "openrouter"; + const disclosure = cloudSpeechDisclosure(provider, environment.label); + const key = saved[provider]; + const isActive = active?.provider === provider; + const modelChosen = isModelId(model) && (catalog?.some((entry) => entry.id === model) ?? false); + const modelChanged = model !== (activeModel ?? ""); + const original = environment.serverConfig?.settings.cloudSpeechCredentials?.[provider]; + const keyChanged = + credential !== undefined && + !( + isSecretReference(credential) && + isSecretReference(original) && + original.provider === credential.provider && + original.reference === credential.reference + ); + const routedReady = + modelChosen && + (draft.trim() ? credential !== undefined : Boolean(key)) && + (!isActive || modelChanged || keyChanged); + const saveRouted = () => { + if (!routedReady || !isModelId(model)) return; + const grantChanges = !isActive || modelChanged; + if (credential !== undefined && keyChanged) + return void changeKey(provider, credential, model); + // Model-only changes never write to the environment. + if (grantChanges) enable(() => chooseVoiceDestination(id, provider, model)); + onClose(); + }; + const submit = () => { + if (routed) saveRouted(); + else if (credential) void changeKey(provider, credential); + }; + const keyFooter = + draft && !credential + ? (source?.invalidReferenceMessage ?? "Enter a valid API key.") + : routed + ? key && !credential + ? "Uses the API key saved on this environment. The model choice applies only to this device." + : `Anyone with dictation access to ${environment.label} can bill this key.` + : editing.enable + ? `Anyone with dictation access to ${environment.label} can bill this key.` + : `Updates the ${company} key on ${environment.label}. Your dictation destination stays the same.`; + return ( + <> + {disclosure} + {routed ? ( + catalog ? ( + + ) : ( + + + + ) + ) : null} + {canManage ? ( + + + {sources.length > 0 && ( + + - ) : ( - { + setDraft(""); + setSourceId(null); + }} /> - )} - - )} - {isEditing && (canManage || routed) && ( - <> - {canManage && sources.length > 0 && ( - + {sources.map((candidate) => ( { setDraft(""); - setSourceId(null); + setSourceId(candidate.id); }} /> - {sources.map((candidate) => ( - { - setDraft(""); - setSourceId(candidate.id); - }} - /> - ))} - - )} - {canManage && ( - { - if (routed) saveRouted(); - else if (credential) void changeKey(provider, credential); - }} - value={draft} - onChangeText={setDraft} - className="mx-4 rounded-lg bg-background px-3 py-2 text-foreground" - /> - )} - {routed && !saved && !canManage ? ( - {}} - /> - ) : ( - { - if (routed) saveRouted(); - else if (credential) void changeKey(provider, credential); - }} - /> - )} - {saved && canManage && ( - - Dialog.confirm({ - title: `Remove the ${label} key from ${environment.label}?`, - message: "Everyone using it for dictation will lose access.", - confirmText: "Remove key", - destructive: true, - onConfirm: () => void changeKey(provider, null), - }) - } - /> - )} - - + ))} + + )} + + + + ) : null} + {canManage || routed ? ( + + {routed && !key && !canManage ? ( + {}} + /> + ) : ( + )} - - ); - }) + {key && canManage ? ( + + + Dialog.confirm({ + title: `Remove the ${label} key from ${environment.label}?`, + message: "Everyone using it for dictation will lose access.", + confirmText: "Remove key", + destructive: true, + onConfirm: () => void changeKey(provider, null), + }) + } + /> + + ) : null} + + ) : null} + + + + ); + })(); return ( {content} diff --git a/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx index 5f7328214cb16..4b0ca00e88666 100644 --- a/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx @@ -24,13 +24,14 @@ import * as Dialog from "../../components/ui/Dialog"; import { useEnvironments, type EnvironmentPresentation } from "../../state/environments"; import { chooseVoiceDestination, + cloudVoiceConnectionAtom, voiceConnectionAtom, voiceGrantAtom, - voiceSpeechStatusAtom, hasEnvironmentVoiceCapture, } from "../voice-input/voiceDestination"; -import { VoiceModelSection } from "./VoiceModelSection"; -import { CloudVoiceSection } from "./CloudVoiceSection"; +import { LocalModelChoice } from "./VoiceModelSection"; +import { CloudVoiceChoice } from "./CloudVoiceSection"; +import type { VoiceGrant } from "../voice-input/voiceGrantStorage"; import type { EnvironmentId } from "@t3tools/contracts"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; @@ -127,45 +128,81 @@ function saveDestination(id: EnvironmentId | null) { function EnvironmentChoice({ environment, selected, + onCloudOnly, }: { environment: EnvironmentPresentation; selected: boolean; + /** Called instead of a local grant when the environment only offers cloud transcription. */ + onCloudOnly: () => void; }) { - const usable = useAtomValue(voiceConnectionAtom(environment.environmentId)) !== null; - const status = AsyncResult.getOrElse( - useAtomValue(voiceSpeechStatusAtom(environment.environmentId)), - () => null, - ); + const local = useAtomValue(voiceConnectionAtom(environment.environmentId)) !== null; + const cloud = useAtomValue(cloudVoiceConnectionAtom(environment.environmentId)) !== null; + const usable = local || cloud; return ( { - if (!selected) - Dialog.confirm({ - title: `Send microphone audio to ${environment.label}?`, - message: `Finished recordings will be sent to ${environment.displayUrl ?? environment.label} for transcription. You can switch back to This device at any time.`, - confirmText: "Use environment", - onConfirm: () => saveDestination(environment.environmentId), - }); + if (selected) return; + if (!local) return onCloudOnly(); + Dialog.confirm({ + title: `Send microphone audio to ${environment.label}?`, + message: `Finished recordings will be sent to ${environment.displayUrl ?? environment.label} for transcription. You can switch back to This device at any time.`, + confirmText: "Use environment", + onConfirm: () => saveDestination(environment.environmentId), + }); }} /> ); } +/** How the selected environment transcribes: its own model or a cloud provider. */ +function EnvironmentMethodSection({ + environment, + grant, + cloudOpen, + onCloudOpenChange, +}: { + environment: EnvironmentPresentation; + grant: VoiceGrant | null; + cloudOpen: boolean; + onCloudOpenChange: (open: boolean) => void; +}) { + const id = environment.environmentId; + const destination = grant?.destination.environmentId === id ? grant.destination : null; + const cloudCapable = + environment.serverConfig?.environment.capabilities.cloudVoiceTranscription === true || + destination?.kind === "cloud"; + return ( + + saveDestination(id)} + /> + {cloudCapable ? ( + + ) : null} + + ); +} + export function SettingsVoiceRouteScreen() { const transcriber = useAtomValue(localVoiceTranscriberAtom); const grant = useAtomValue(voiceGrantAtom); @@ -175,6 +212,15 @@ export function SettingsVoiceRouteScreen() { (environment) => environment.environmentId === grant.destination.environmentId, ) : undefined; + // An environment whose cloud sheet is open; it may not hold the grant yet. + const [cloudSheet, setCloudSheet] = useState(null); + // A sheet whose environment lost cloud transcription is gone, so its pending choice is too. + const sheetEnvironment = environments.find( + (environment) => + environment.environmentId === cloudSheet && + environment.serverConfig?.environment.capabilities.cloudVoiceTranscription === true, + ); + const methodEnvironment = sheetEnvironment ?? grantEnvironment; const insets = useSafeAreaInsets(); const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -234,8 +280,11 @@ export function SettingsVoiceRouteScreen() { saveDestination(null)} + selected={!grant && !sheetEnvironment} + onPress={() => { + setCloudSheet(null); + saveDestination(null); + }} /> {hasEnvironmentVoiceCapture && environments.map((environment) => ( @@ -243,9 +292,11 @@ export function SettingsVoiceRouteScreen() { key={environment.environmentId} environment={environment} selected={ - grant?.destination.kind === "environment" && - grant.destination.environmentId === environment.environmentId + sheetEnvironment + ? sheetEnvironment.environmentId === environment.environmentId + : grant?.destination.environmentId === environment.environmentId } + onCloudOnly={() => setCloudSheet(environment.environmentId)} /> ))} {isReady && grant && !grantEnvironment ? ( @@ -259,30 +310,14 @@ export function SettingsVoiceRouteScreen() { /> ) : null} - {hasEnvironmentVoiceCapture && - environments - .filter( - (env) => - env.serverConfig?.environment.capabilities.cloudVoiceTranscription || - (grant?.destination.kind === "cloud" && - grant.destination.environmentId === env.environmentId), - ) - .map((environment) => ( - - ))} - {grantEnvironment && grant?.destination.kind === "environment" ? ( - + setCloudSheet(open ? methodEnvironment.environmentId : null) + } /> ) : null} {!grant && transcriber === null ? ( diff --git a/apps/mobile/src/features/settings/VoiceModelSection.tsx b/apps/mobile/src/features/settings/VoiceModelSection.tsx index 2403f113b2af8..0bee9748b19cb 100644 --- a/apps/mobile/src/features/settings/VoiceModelSection.tsx +++ b/apps/mobile/src/features/settings/VoiceModelSection.tsx @@ -29,7 +29,6 @@ import { voiceSpeechModelsAtom, voiceSpeechStatusAtom, } from "../voice-input/voiceDestination"; -import { SettingsSection } from "./components/SettingsSection"; const isDownloading = (model: EnvironmentSpeechModel) => model.state === "downloading" || model.state === "verifying"; @@ -272,51 +271,59 @@ export function VoiceModelRow(props: { ); } -export function VoiceModelSection(props: { +/** The environment's local-model method: chosen by its mark, opens the model list. */ +export function LocalModelChoice(props: { readonly environmentId: EnvironmentId; readonly label: string; + readonly selected: boolean; + readonly onSelect: () => void; }) { const navigation = useNavigation(); const voice = useVoiceModels(props.environmentId, props.label); const { status } = voice; - if (!voice.connection || !status) return null; - const title = `Transcription model on ${props.label}`; - if (!status.supported) { + const open = () => + navigation.navigate("SettingsVoiceModels", { environmentId: props.environmentId }); + if (!voice.connection || !status || !status.supported) return ( - - {null} - + {}} + /> ); - } const active = status.state === "missing-model" ? null : status; const pending = voice.models.find(isDownloading); const progress = pending?.downloaded === undefined ? undefined : pending.downloaded / pending.size; return ( - - - navigation.navigate("SettingsVoiceModels", { environmentId: props.environmentId }) - } - /> - + selected={props.selected} + onPress={() => { + if (!props.selected) props.onSelect(); + // Switching to an installed model needs no further step. + if (props.selected || !active) open(); + }} + /> ); }