Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
20 changes: 20 additions & 0 deletions src/components/OnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import UseCaseStep from "./onboarding/UseCaseStep";
import { hasUseCaseIntent } from "./onboarding/useCases";
import OnboardingShell, { OnboardingStepHeader } from "./onboarding/OnboardingShell";
import CompactPermissionsStep from "./onboarding/CompactPermissionsStep";
import InterfaceLanguageStep from "./onboarding/InterfaceLanguageStep";
import LanguageSelectionStep from "./onboarding/LanguageSelectionStep";
import ShortcutSetupStep from "./onboarding/ShortcutSetupStep";
import AssistantHotkeyPreview from "./onboarding/AssistantHotkeyPreview";
Expand Down Expand Up @@ -590,6 +591,25 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
/>
);

case "interface-language":
return (
<div className="flex h-full min-h-0 w-full flex-col pt-1">
<OnboardingStepHeader
title={t("onboarding.rehaul.interfaceLanguage.title")}
titleLines={[
t("onboarding.rehaul.interfaceLanguage.titleLineOne"),
t("onboarding.rehaul.interfaceLanguage.titleLineTwo"),
]}
description={t("onboarding.rehaul.interfaceLanguage.description")}
/>
<InterfaceLanguageStep
value={settings.uiLanguage}
onChange={settings.setUiLanguage}
label={t("onboarding.rehaul.interfaceLanguage.title")}
/>
</div>
);

case "languages":
return (
<div className="flex h-full min-h-0 w-full flex-col pt-1">
Expand Down
14 changes: 1 addition & 13 deletions src/components/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ import ProfileSection from "./settings/ProfileSection";
import { formatAmount } from "../utils/formatAmount";
import { getTranscriptionProvider } from "../models/ModelRegistry";
import { supportsLiveTranscriptionPreview } from "../utils/transcriptionPreview";
import { UI_LANGUAGE_OPTIONS } from "../config/uiLanguages";

export type SettingsSectionType =
| "account"
Expand All @@ -157,19 +158,6 @@ interface SettingsPageProps {
initialSubTab?: string;
}

const UI_LANGUAGE_OPTIONS: import("./ui/LanguageSelector").LanguageOption[] = [
{ value: "en", label: "English", flag: "🇺🇸" },
{ value: "es", label: "Español", flag: "🇪🇸" },
{ value: "fr", label: "Français", flag: "🇫🇷" },
{ value: "de", label: "Deutsch", flag: "🇩🇪" },
{ value: "pt", label: "Português", flag: "🇵🇹" },
{ value: "it", label: "Italiano", flag: "🇮🇹" },
{ value: "ru", label: "Русский", flag: "🇷🇺" },
{ value: "ja", label: "日本語", flag: "🇯🇵" },
{ value: "zh-CN", label: "简体中文", flag: "🇨🇳" },
{ value: "zh-TW", label: "繁體中文", flag: "🇹🇼" },
];

const RETENTION_DAY_OPTIONS = [1, 7, 14, 30, 60, 90];

const RETENTION_SELECT_CLASS =
Expand Down
59 changes: 59 additions & 0 deletions src/components/onboarding/InterfaceLanguageStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useId } from "react";
import { Check } from "lucide-react";
import { UI_LANGUAGE_OPTIONS, type UiLanguage } from "../../config/uiLanguages";
import OnboardingList from "./OnboardingList";

interface InterfaceLanguageStepProps {
value: string;
onChange: (language: UiLanguage) => void;
label: string;
}

export default function InterfaceLanguageStep({
value,
onChange,
label,
}: InterfaceLanguageStepProps) {
const groupName = useId();

return (
<OnboardingList className="mx-auto mt-5 min-h-0 w-full max-w-md flex-1">
<div role="radiogroup" aria-label={label}>
{UI_LANGUAGE_OPTIONS.map((language) => {
const selected = language.value === value;
return (
<label key={language.value} className="onboarding-list-row w-full text-left">
<input
type="radio"
name={groupName}
value={language.value}
checked={selected}
onChange={() => onChange(language.value)}
className="peer sr-only"
/>
<span className="text-xl leading-none" aria-hidden="true">
{language.flag}
</span>
<span
className="min-w-0 flex-1 truncate text-sm font-medium leading-[1.4] text-[var(--onboarding-text-primary)]"
dir="auto"
>
{language.label}
</span>
<span
className={`flex size-5 shrink-0 items-center justify-center rounded-full border peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--onboarding-accent)] peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-[var(--onboarding-surface)] ${
selected
? "border-[var(--onboarding-accent)] bg-[var(--onboarding-accent)] text-[var(--onboarding-accent-foreground)]"
: "border-[var(--onboarding-control-border)] bg-[var(--onboarding-surface)]"
}`}
aria-hidden="true"
>
{selected && <Check className="size-3.5" strokeWidth={1.7} />}
</span>
</label>
);
})}
</div>
</OnboardingList>
);
}
8 changes: 6 additions & 2 deletions src/components/onboarding/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type OnboardingStorage = Pick<Storage, "setItem" | "removeItem">;
export type OnboardingStepId =
| "auth"
| "permissions"
| "interface-language"
| "languages"
| "use-cases"
| "dictation-hotkey"
Expand Down Expand Up @@ -44,6 +45,7 @@ export interface OnboardingRouteContext {
const ACCOUNT_ROUTE: OnboardingStepId[] = [
"auth",
"permissions",
"interface-language",
"languages",
"use-cases",
"dictation-hotkey",
Expand All @@ -61,6 +63,7 @@ const SETUP_ROUTES: Record<Exclude<OnboardingSetupMode, null | "cloud">, Onboard
const STEP_ORDER: OnboardingStepId[] = [
"auth",
"permissions",
"interface-language",
"languages",
"use-cases",
"dictation-hotkey",
Expand All @@ -81,8 +84,8 @@ const KNOWN_STEPS = new Set<OnboardingStepId>(STEP_ORDER);
/**
* Steps that render in the compact frame. That frame has no footer, so these
* steps show no progress row and are left out of the count entirely — landing on
* `languages` reads as "1 of N", not "3 of N" for two steps the user never saw a
* counter on.
* `interface-language` reads as "1 of N", not "3 of N" for two steps the user
* never saw a counter on.
*/
export const COMPACT_STEPS: ReadonlySet<OnboardingStepId> = new Set<OnboardingStepId>([
"auth",
Expand Down Expand Up @@ -138,6 +141,7 @@ export function getOnboardingRoute(context: OnboardingRouteContext): OnboardingS
([
"auth",
"permissions",
"interface-language",
"dictation-hotkey",
"activation-mode",
"setup-choice",
Expand Down
40 changes: 40 additions & 0 deletions src/config/uiLanguages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export const SUPPORTED_UI_LANGUAGES = [
"en",
"es",
"fr",
"de",
"pt",
"it",
"ru",
"ja",
"zh-CN",
"zh-TW",
] as const;

export type UiLanguage = (typeof SUPPORTED_UI_LANGUAGES)[number];

export interface UiLanguageOption {
value: UiLanguage;
label: string;
flag: string;
}

type UiLanguageMetadata = Omit<UiLanguageOption, "value">;

const UI_LANGUAGE_METADATA = {
en: { label: "English", flag: "🇺🇸" },
es: { label: "Español", flag: "🇪🇸" },
fr: { label: "Français", flag: "🇫🇷" },
de: { label: "Deutsch", flag: "🇩🇪" },
pt: { label: "Português", flag: "🇵🇹" },
it: { label: "Italiano", flag: "🇮🇹" },
ru: { label: "Русский", flag: "🇷🇺" },
ja: { label: "日本語", flag: "🇯🇵" },
"zh-CN": { label: "简体中文", flag: "🇨🇳" },
"zh-TW": { label: "繁體中文", flag: "🇹🇼" },
} satisfies Record<UiLanguage, UiLanguageMetadata>;

export const UI_LANGUAGE_OPTIONS: UiLanguageOption[] = SUPPORTED_UI_LANGUAGES.map((value) => ({
value,
...UI_LANGUAGE_METADATA[value],
}));
16 changes: 3 additions & 13 deletions src/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,11 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { SUPPORTED_UI_LANGUAGES, type UiLanguage } from "./config/uiLanguages";
import { PROMPTS_BY_LOCALE } from "./locales/prompts";
import { TRANSLATIONS_BY_LOCALE } from "./locales/translations";

export const SUPPORTED_UI_LANGUAGES = [
"en",
"es",
"fr",
"de",
"pt",
"it",
"ru",
"ja",
"zh-CN",
"zh-TW",
] as const;
export type UiLanguage = (typeof SUPPORTED_UI_LANGUAGES)[number];
export { SUPPORTED_UI_LANGUAGES };
export type { UiLanguage };

export function normalizeUiLanguage(language: string | null | undefined): UiLanguage {
const candidate = (language || "").trim();
Expand Down
6 changes: 6 additions & 0 deletions src/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@
"enable": "Aktivieren",
"enabled": "Aktiviert"
},
"interfaceLanguage": {
"title": "Wähle deine Oberflächensprache",
"titleLineOne": "Wähle deine",
"titleLineTwo": "Oberflächensprache",
"description": "Dies ändert die Menüs und die Einrichtung von OpenWhispr, nicht die Transkription. Du kannst die Sprache später in den Einstellungen ändern."
},
"languages": {
"title": "Welche Sprachen sprechen Sie?",
"titleLineOne": "Welche Sprachen",
Expand Down
6 changes: 6 additions & 0 deletions src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@
"enable": "Enable",
"enabled": "Enabled"
},
"interfaceLanguage": {
"title": "Choose your interface language",
"titleLineOne": "Choose your",
"titleLineTwo": "interface language",
"description": "This controls OpenWhispr's menus and onboarding, not transcription. You can change it later in Settings."
},
"languages": {
"title": "Which languages do you speak?",
"titleLineOne": "Which languages",
Expand Down
6 changes: 6 additions & 0 deletions src/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@
"enable": "Activar",
"enabled": "Activado"
},
"interfaceLanguage": {
"title": "Elige el idioma de la interfaz",
"titleLineOne": "Elige el idioma",
"titleLineTwo": "de la interfaz",
"description": "Esto cambia los menús y la configuración inicial de OpenWhispr, no la transcripción. Puedes cambiarlo más tarde en Ajustes."
},
"languages": {
"title": "¿Qué idiomas hablas?",
"titleLineOne": "¿Qué idiomas",
Expand Down
6 changes: 6 additions & 0 deletions src/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@
"enable": "Activer",
"enabled": "Activé"
},
"interfaceLanguage": {
"title": "Choisissez la langue de l'interface",
"titleLineOne": "Choisissez la langue",
"titleLineTwo": "de l'interface",
"description": "Cela change la langue des menus et de la configuration initiale d'OpenWhispr, pas celle de la transcription. Vous pourrez la modifier plus tard dans les paramètres."
},
"languages": {
"title": "Quelles langues parlez-vous ?",
"titleLineOne": "Quelles langues",
Expand Down
6 changes: 6 additions & 0 deletions src/locales/it/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@
"enable": "Attiva",
"enabled": "Attivato"
},
"interfaceLanguage": {
"title": "Scegli la lingua dell'interfaccia",
"titleLineOne": "Scegli la lingua",
"titleLineTwo": "dell'interfaccia",
"description": "Questo cambia la lingua dei menu e della configurazione iniziale di OpenWhispr, non quella della trascrizione. Puoi modificarla in seguito nelle Impostazioni."
},
"languages": {
"title": "Quali lingue parli?",
"titleLineOne": "Quali lingue",
Expand Down
6 changes: 6 additions & 0 deletions src/locales/ja/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@
"enable": "有効にする",
"enabled": "有効"
},
"interfaceLanguage": {
"title": "インターフェースの言語を選択",
"titleLineOne": "インターフェースの",
"titleLineTwo": "言語を選択",
"description": "OpenWhisprのメニューと初期設定に使用する言語です。文字起こし言語には影響しません。後から設定で変更できます。"
},
"languages": {
"title": "どの言語を話しますか?",
"titleLineOne": "どの言語を",
Expand Down
6 changes: 6 additions & 0 deletions src/locales/pt/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@
"enable": "Ativar",
"enabled": "Ativado"
},
"interfaceLanguage": {
"title": "Escolha o idioma da interface",
"titleLineOne": "Escolha o idioma",
"titleLineTwo": "da interface",
"description": "Isso altera os menus e a configuração inicial do OpenWhispr, não a transcrição. Você pode mudar o idioma depois nas Configurações."
},
"languages": {
"title": "Quais idiomas você fala?",
"titleLineOne": "Quais idiomas",
Expand Down
6 changes: 6 additions & 0 deletions src/locales/ru/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@
"enable": "Включить",
"enabled": "Включено"
},
"interfaceLanguage": {
"title": "Выберите язык интерфейса",
"titleLineOne": "Выберите язык",
"titleLineTwo": "интерфейса",
"description": "Он используется в меню и при первоначальной настройке OpenWhispr, но не влияет на язык транскрипции. Позже его можно изменить в настройках."
},
"languages": {
"title": "На каких языках вы говорите?",
"titleLineOne": "На каких языках",
Expand Down
6 changes: 6 additions & 0 deletions src/locales/zh-CN/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@
"enable": "启用",
"enabled": "已启用"
},
"interfaceLanguage": {
"title": "选择界面语言",
"titleLineOne": "选择",
"titleLineTwo": "界面语言",
"description": "此选项控制 OpenWhispr 的菜单和引导界面,不影响转写语言。你可以稍后在设置中修改。"
},
"languages": {
"title": "你会说哪些语言?",
"titleLineOne": "你会说",
Expand Down
6 changes: 6 additions & 0 deletions src/locales/zh-TW/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@
"enable": "啟用",
"enabled": "已啟用"
},
"interfaceLanguage": {
"title": "選擇介面語言",
"titleLineOne": "選擇",
"titleLineTwo": "介面語言",
"description": "此選項控制 OpenWhispr 的選單和引導介面,不影響轉錄語言。你可以稍後在設定中修改。"
},
"languages": {
"title": "你會說哪些語言?",
"titleLineOne": "你會說",
Expand Down
24 changes: 24 additions & 0 deletions test/components/onboardingCompatibility.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,30 @@ test("expanded Linux onboarding keeps maximize alongside minimize and close", as
assert.match(markup, /title="windowControls.close"/);
});

test("interface language step exposes one selected locale without transcription options", async (t) => {
const vite = await createOnboardingRenderer(t);
const { default: InterfaceLanguageStep } = await vite.ssrLoadModule(
"/components/onboarding/InterfaceLanguageStep.tsx"
);

const markup = renderToStaticMarkup(
React.createElement(InterfaceLanguageStep, {
value: "zh-CN",
onChange: noop,
label: "Interface language",
})
);

assert.match(markup, /role="radiogroup" aria-label="Interface language"/);
const radioInputs = markup.match(/<input[^>]*type="radio"[^>]*>/g) ?? [];
const names = radioInputs.map((input) => input.match(/name="([^"]+)"/)?.[1]);
assert.equal(radioInputs.length, 10);
assert.equal(new Set(names).size, 1);
assert.equal(radioInputs.filter((input) => input.includes('checked=""')).length, 1);
assert.match(markup, /简体中文/);
assert.doesNotMatch(markup, />Auto</);
});

test("a denied microphone exposes the existing Linux settings recovery", async (t) => {
const vite = await createOnboardingRenderer(t);
const { default: CompactPermissionsStep } = await vite.ssrLoadModule(
Expand Down
Loading