Rebased onto main with Expo SDK 57 typecheck fixes. Voice options expose stable test ids; Shell keeps /app/heute across bootstrap and redirects legacy /app/heute bot URLs. Heute coach E2E asserts URL after reload. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
134 lines
4.6 KiB
TypeScript
134 lines
4.6 KiB
TypeScript
import { Trans } from "@lingui/react/macro";
|
|
import type { VoiceInfo } from "@rakazo/contracts";
|
|
import { Button } from "@rakazo/ui-web";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
defaultOnboardingVoiceId,
|
|
offlineOnboardingVoices,
|
|
onboardingVoicesFromList,
|
|
} from "../../lib/onboarding-voices";
|
|
import { rpc } from "../../lib/rpc";
|
|
import type { UiLocale } from "../../lib/ui-locale";
|
|
|
|
/** Must match packages/adapters KokoroVoiceProvider placeholder. */
|
|
export const KOKORO_LOCAL_TOKEN = "local-kokoro";
|
|
|
|
type OnboardingVoiceStepProps = {
|
|
uiLocale: UiLocale;
|
|
selectedVoiceId: string;
|
|
onSelectedVoiceIdChange: (voiceId: string) => void;
|
|
onBack: () => void;
|
|
onSkip: () => void;
|
|
onContinue: () => void;
|
|
};
|
|
|
|
export function OnboardingVoiceStep({
|
|
uiLocale,
|
|
selectedVoiceId,
|
|
onSelectedVoiceIdChange,
|
|
onBack,
|
|
onSkip,
|
|
onContinue,
|
|
}: OnboardingVoiceStepProps) {
|
|
const [voiceOptions, setVoiceOptions] = useState<VoiceInfo[]>([]);
|
|
const [voiceLoading, setVoiceLoading] = useState(true);
|
|
const previewTask = useRef<Promise<void>>(Promise.resolve());
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setVoiceLoading(true);
|
|
const task = (async () => {
|
|
try {
|
|
await rpc.voice.connect({ provider: "kokoro", apiKey: KOKORO_LOCAL_TOKEN });
|
|
const listed = await rpc.voice.voices({ provider: "kokoro" });
|
|
if (cancelled) return;
|
|
const ordered = onboardingVoicesFromList(listed, uiLocale);
|
|
setVoiceOptions(ordered);
|
|
const nextId = defaultOnboardingVoiceId(uiLocale, ordered);
|
|
if (nextId) onSelectedVoiceIdChange(nextId);
|
|
} catch {
|
|
if (!cancelled) {
|
|
const fallback = offlineOnboardingVoices(uiLocale);
|
|
setVoiceOptions(fallback);
|
|
const nextId = defaultOnboardingVoiceId(uiLocale, fallback);
|
|
if (nextId) onSelectedVoiceIdChange(nextId);
|
|
}
|
|
} finally {
|
|
if (!cancelled) setVoiceLoading(false);
|
|
}
|
|
})();
|
|
previewTask.current = task.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [onSelectedVoiceIdChange, uiLocale]);
|
|
|
|
async function skipVoice() {
|
|
await previewTask.current;
|
|
// Listing voices persists a local credential. Skip must drop it so Voice
|
|
// still opens settings instead of a call the user never chose.
|
|
await rpc.voice.disconnect({ provider: "kokoro" }).catch(() => undefined);
|
|
onSkip();
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-xl">
|
|
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
|
<Trans>Pick a voice</Trans>
|
|
</h2>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
<Trans>How should your Oo sound when reading replies aloud?</Trans>
|
|
</p>
|
|
{voiceLoading ? (
|
|
<p className="mt-6 text-sm text-muted-foreground">
|
|
<Trans>Loading voices…</Trans>
|
|
</p>
|
|
) : voiceOptions.length > 0 ? (
|
|
<div className="mt-6 grid grid-cols-2 gap-2">
|
|
{voiceOptions.map((voice) => (
|
|
<button
|
|
key={voice.id}
|
|
type="button"
|
|
data-testid={`onboarding-voice-${voice.id}`}
|
|
onClick={() => onSelectedVoiceIdChange(voice.id)}
|
|
aria-pressed={selectedVoiceId === voice.id}
|
|
className={`rounded-xl border-2 px-3 py-3 text-left text-sm font-medium transition-all ${
|
|
selectedVoiceId === voice.id
|
|
? "border-primary bg-primary/10 text-foreground shadow-sm"
|
|
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
|
|
}`}
|
|
>
|
|
<span className="block text-foreground">{voice.label}</span>
|
|
{voice.description ? (
|
|
<span className="mt-0.5 block text-xs text-muted-foreground">
|
|
{voice.description}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="mt-6 text-sm text-muted-foreground">
|
|
<Trans>Voice is not available yet. You can turn it on later in settings.</Trans>
|
|
</p>
|
|
)}
|
|
<div className="mt-8 flex items-center justify-between">
|
|
<Button variant="ghost" size="sm" onClick={onBack}>
|
|
<Trans>Back</Trans>
|
|
</Button>
|
|
<div className="flex gap-2">
|
|
<Button variant="ghost" onClick={() => void skipVoice()}>
|
|
<Trans>Skip</Trans>
|
|
</Button>
|
|
<Button disabled={!selectedVoiceId} onClick={onContinue}>
|
|
<Trans>Continue</Trans>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|