Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 41 additions & 10 deletions apps/mobile/src/components/ui/ListRow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<RowPressable
accessibilityRole="button"
accessibilityLabel={[props.label, props.description, props.detail].filter(Boolean).join(", ")}
accessibilityState={{ disabled: Boolean(props.disabled) }}
accessibilityState={{
disabled: Boolean(props.disabled),
...(props.selected === undefined ? {} : { checked: props.selected }),
}}
disabled={props.disabled}
onPress={props.onPress}
android_ripple={{ color: rippleStyle.backgroundColor }}
Expand All @@ -21,13 +31,34 @@ export function ListRow(props: ListRowContentProps & { readonly onPress: () => v
<ListRowLayout
{...props}
accessory={
<SymbolView
name="chevron.right"
size={16}
tintColorClassName="accent-chevron"
type="monochrome"
weight="semibold"
/>
<View className="flex-row items-center gap-3">
{props.selected === undefined ? null : (
<View
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
className="size-6 items-center justify-center"
>
{Platform.OS === "android" ? (
<RadioIndicator selected={props.selected} />
) : props.selected ? (
<SymbolView
name="checkmark"
size={18}
tintColorClassName="accent-primary-text"
type="monochrome"
weight="semibold"
/>
) : null}
</View>
)}
<SymbolView
name="chevron.right"
size={16}
tintColorClassName="accent-chevron"
type="monochrome"
weight="semibold"
/>
</View>
}
/>
</RowPressable>
Expand Down
36 changes: 25 additions & 11 deletions apps/mobile/src/features/settings/CloudVoiceSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,31 +40,45 @@ import {
cloudVoiceModelsAtom,
runSpeech,
} from "../voice-input/voiceDestination";
import { SettingsSection } from "./components/SettingsSection";

type CloudDestination = Extract<VoiceTranscriptionDestination, { kind: "cloud" }>;
type Props = { environment: EnvironmentPresentation; active: CloudDestination | null };
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 (
<SettingsSection title={`Cloud via ${environment.label}`}>
<ListActionRow
label="Cloud providers"
<>
<ListChoiceRow
label="Cloud provider"
description={
active
? `Using ${CLOUD_SPEECH_LABELS[active.provider]}`
: "Optional · use your own API key"
? `${CLOUD_SPEECH_LABELS[active.provider]}${active.provider === "openrouter" ? ` · ${active.model}` : ""}`
: usable
? "Use your own API key"
: "Not connected"
}
onPress={() => setOpen(true)}
selected={active !== null}
disabled={!usable}
separated
onPress={() => onOpenChange(true)}
/>
{open && (
<CloudChoices environment={environment} active={active} onClose={() => setOpen(false)} />
<CloudChoices
environment={environment}
active={active}
onClose={() => onOpenChange(false)}
/>
)}
</SettingsSection>
</>
);
}

Expand Down
131 changes: 80 additions & 51 deletions apps/mobile/src/features/settings/SettingsVoiceRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<ListChoiceRow
label={environment.label}
selected={selected}
disabled={!usable}
description={
!usable
? environment.connection.phase !== "connected"
? "Not connected"
: "Transcription unavailable"
: status?.supported === false
? status.reason
: status?.state === "missing-model"
? "No transcription model installed"
: (environment.displayUrl ?? "Send finished recordings to this environment.")
environment.connection.phase !== "connected"
? "Not connected"
: !usable
? "Transcription unavailable"
: (environment.displayUrl ?? "Send finished recordings to this environment.")
}
separated
onPress={() => {
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 (
<SettingsSection title={`Method on ${environment.label}`}>
<LocalModelChoice
environmentId={id}
label={environment.label}
selected={destination?.kind === "environment"}
// The user already agreed to send audio to this environment.
onSelect={() => saveDestination(id)}
/>
{cloudCapable ? (
<CloudVoiceChoice
environment={environment}
active={destination?.kind === "cloud" ? destination : null}
open={cloudOpen}
onOpenChange={onCloudOpenChange}
/>
) : null}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</SettingsSection>
);
}

export function SettingsVoiceRouteScreen() {
const transcriber = useAtomValue(localVoiceTranscriberAtom);
const grant = useAtomValue(voiceGrantAtom);
Expand All @@ -175,6 +212,12 @@ 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<EnvironmentId | null>(null);
const sheetEnvironment = environments.find(
(environment) => environment.environmentId === cloudSheet,
);
const methodEnvironment = sheetEnvironment ?? grantEnvironment;
const insets = useSafeAreaInsets();
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
Expand Down Expand Up @@ -234,7 +277,7 @@ export function SettingsVoiceRouteScreen() {
<ListChoiceRow
label="This device"
description="Audio stays on this device and is transcribed here."
selected={!grant}
selected={!grant && !sheetEnvironment}
onPress={() => saveDestination(null)}
/>
{hasEnvironmentVoiceCapture &&
Expand All @@ -243,9 +286,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 ? (
Expand All @@ -259,30 +304,14 @@ export function SettingsVoiceRouteScreen() {
/>
) : null}
</SettingsSection>
{hasEnvironmentVoiceCapture &&
environments
.filter(
(env) =>
env.serverConfig?.environment.capabilities.cloudVoiceTranscription ||
(grant?.destination.kind === "cloud" &&
grant.destination.environmentId === env.environmentId),
)
.map((environment) => (
<CloudVoiceSection
key={environment.environmentId}
environment={environment}
active={
grant?.destination.kind === "cloud" &&
grant.destination.environmentId === environment.environmentId
? grant.destination
: null
}
/>
))}
{grantEnvironment && grant?.destination.kind === "environment" ? (
<VoiceModelSection
environmentId={grantEnvironment.environmentId}
label={grantEnvironment.label}
{hasEnvironmentVoiceCapture && methodEnvironment ? (
<EnvironmentMethodSection
environment={methodEnvironment}
grant={grant}
cloudOpen={cloudSheet === methodEnvironment.environmentId}
onCloudOpenChange={(open) =>
setCloudSheet(open ? methodEnvironment.environmentId : null)
}
/>
) : null}
{!grant && transcriber === null ? (
Expand Down
Loading
Loading