diff --git a/CHANGELOG.md b/CHANGELOG.md index 741b96e..bda4ba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ The format is based on Keep a Changelog and the project follows Semantic Version ### Changed +- The interface language can be chosen in Settings. Automatic still follows the + browser language. Popup, sidebar, and Settings update without a reload. - Experimental: every subframe in a tab now uses the top-frame runtime snapshot (locale, timezone, fingerprint, geolocation) so iframe and worker realms do not diverge from the page. Per-tab header rules apply to all requests in that diff --git a/src/background/runtime-refresh.target.test.ts b/src/background/runtime-refresh.target.test.ts index 5b3668a..405950a 100644 --- a/src/background/runtime-refresh.target.test.ts +++ b/src/background/runtime-refresh.target.test.ts @@ -80,8 +80,11 @@ describe("shouldReloadRuntimeTabs", () => { shouldReloadRuntimeTabs({ themeMode: "dark", themeAccentPreset: "purple", + uiLocale: "es", }), ).toBe(false); + expect(shouldSyncPreload({ uiLocale: "ru" })).toBe(false); + expect(shouldSyncHeaderRules({ uiLocale: "uk" })).toBe(false); }); }); diff --git a/src/background/settings-commands.ts b/src/background/settings-commands.ts index 061feaa..f209d77 100644 --- a/src/background/settings-commands.ts +++ b/src/background/settings-commands.ts @@ -184,6 +184,7 @@ const persistImport = async (settings: ImportedSettings): Promise => { saveContainerAssignments(containerAssignments), clearSiteSuggestions(), savePreferences({ + uiLocale: settings.uiLocale, themeMode: settings.themeMode, themeAccentPreset: settings.themeAccentPreset, reduceMotion: settings.reduceMotion, @@ -243,6 +244,7 @@ const buildImportResponse = (settings: ImportedSettings): ImportSettingsResponse locations: settings.locations, rules: settings.rules, trustedSites: settings.trustedSites, + uiLocale: settings.uiLocale, themeMode: settings.themeMode, themeAccentPreset: settings.themeAccentPreset, reduceMotion: settings.reduceMotion, diff --git a/src/background/settings-save-command.ts b/src/background/settings-save-command.ts index f769f8c..acf0c55 100644 --- a/src/background/settings-save-command.ts +++ b/src/background/settings-save-command.ts @@ -42,6 +42,7 @@ import type { } from "@/shared/types"; type ResolvedSimpleSettings = { + nextUiLocale: Preferences["uiLocale"]; nextThemeMode: ThemeMode; nextTrustedSites: TrustedSite[]; nextThemeAccentPreset: ThemeAccentPreset; @@ -177,6 +178,7 @@ const resolveSettings = async ( loadCurrent: getGlobalFallbackRule, }); return { + nextUiLocale: nextCommand.uiLocale ?? currentPreferences.uiLocale, nextThemeMode, nextTrustedSites, nextThemeAccentPreset, @@ -230,6 +232,7 @@ const buildPreferencesPatch = ( ): Partial => { const patch: Partial = {}; for (const key of [ + "uiLocale", "themeMode", "themeAccentPreset", "reduceMotion", @@ -342,6 +345,7 @@ const cacheResolvedSettings = ( const buildSaveResponse = (settings: ResolvedSimpleSettings): SaveSettingsResponse => ({ ok: true, + uiLocale: settings.nextUiLocale, themeMode: settings.nextThemeMode, themeAccentPreset: settings.nextThemeAccentPreset, reduceMotion: settings.nextReduceMotion, diff --git a/src/background/settings.ts b/src/background/settings.ts index 14269d0..aa81c3b 100644 --- a/src/background/settings.ts +++ b/src/background/settings.ts @@ -16,8 +16,11 @@ import { locationProfilesSchema, } from "@/shared/profile-schema"; import { withAuthKey, withContainerSeed, withRuleSeedKey } from "@/shared/rule-seed"; -import { normalizePreferences } from "@/shared/settings-defaults"; -import { MAX_RANDOM_RADIUS_KM, MIN_RANDOM_RADIUS_KM } from "@/shared/settings-defaults"; +import { + MAX_RANDOM_RADIUS_KM, + MIN_RANDOM_RADIUS_KM, + normalizePreferences, +} from "@/shared/settings-defaults"; import { DEFAULT_THEME_MODE } from "@/shared/theme-types"; import { DEFAULT_ACCENT_PRESET, THEME_ACCENT_PRESETS } from "@/shared/types"; import type { @@ -33,6 +36,7 @@ import type { ThemeAccentPreset, ThemeMode, } from "@/shared/types"; +import { UI_LOCALE_PREFERENCES, type UiLocalePreference } from "@/shared/ui-locale"; const THEME_MODE_VALUES = ["light", "dark", "system"] as const; const OSM_CONSENT_VALUES = ["unknown", "granted", "denied"] as const; @@ -57,6 +61,7 @@ const normalizeLegacySpoofing = < const settingsCommandSchema = z .object({ + uiLocale: z.enum(UI_LOCALE_PREFERENCES).optional(), themeMode: z.enum(THEME_MODE_VALUES).optional(), themeAccentPreset: z.enum(THEME_ACCENT_PRESETS).optional(), reduceMotion: z.boolean().optional(), @@ -258,6 +263,7 @@ export const validateImportedSettings = ( rules: DomainRule[]; trustedSites: TrustedSite[]; globalFallbackRule?: GlobalFallbackRule | undefined; + uiLocale: UiLocalePreference; themeMode: ThemeMode; themeAccentPreset: ThemeAccentPreset; reduceMotion: boolean; @@ -323,6 +329,7 @@ export const validateImportedSettings = ( return { ...validated, trustedSites, + uiLocale: preferences.uiLocale, themeMode: sanitizeThemeMode(settings.themeMode), themeAccentPreset: sanitizeAccent(settings.themeAccentPreset), reduceMotion: preferences.reduceMotion, diff --git a/src/shared/extension-contract-types.ts b/src/shared/extension-contract-types.ts index 7df2718..de8e3a4 100644 --- a/src/shared/extension-contract-types.ts +++ b/src/shared/extension-contract-types.ts @@ -31,6 +31,7 @@ import type { XRaySurfaceCategory, } from "./spoofing-surfaces.js"; import type { ThemeAccentPreset, ThemeMode } from "./theme-types.js"; +import type { UiLocalePreference } from "./ui-locale.js"; export type ResolveSnapshotRequest = { type: typeof EXTENSION_COMMAND_TYPES.resolveRuntimeSnapshot; @@ -57,6 +58,7 @@ export type ExtensionCommand = } | { type: typeof EXTENSION_COMMAND_TYPES.saveSimpleSettings; + uiLocale?: UiLocalePreference; themeMode?: ThemeMode; themeAccentPreset?: ThemeAccentPreset; debugMode?: boolean; @@ -400,6 +402,7 @@ export type GetSettingsResponse = { rules: DomainRule[]; trustedSites: TrustedSite[]; globalFallbackRule?: GlobalFallbackRule | undefined; + uiLocale: UiLocalePreference; themeMode: ThemeMode; themeAccentPreset: ThemeAccentPreset; reduceMotion: boolean; @@ -425,6 +428,7 @@ export type GetSettingsResponse = { export type SaveSettingsResponse = | { ok: true; + uiLocale: UiLocalePreference; themeMode: ThemeMode; themeAccentPreset: ThemeAccentPreset; reduceMotion: boolean; @@ -468,6 +472,7 @@ export type ResetSettingsResponse = { rules: DomainRule[]; trustedSites: TrustedSite[]; globalFallbackRule?: GlobalFallbackRule | undefined; + uiLocale: UiLocalePreference; themeMode: ThemeMode; themeAccentPreset: ThemeAccentPreset; reduceMotion: boolean; @@ -499,6 +504,7 @@ export type ImportSettingsResponse = locations: Location[]; rules: DomainRule[]; trustedSites: TrustedSite[]; + uiLocale: UiLocalePreference; themeMode: ThemeMode; themeAccentPreset: ThemeAccentPreset; reduceMotion: boolean; @@ -594,34 +600,13 @@ export type UpdateRuleResponse = }; export type ToggleRuleResponse = - | { - ok: true; - state: PopupState; - } - | { - ok: false; - error: string; - }; + { ok: true; state: PopupState } | { ok: false; error: string }; export type DeleteRuleResponse = - | { - ok: true; - state: PopupState; - } - | { - ok: false; - error: string; - }; + { ok: true; state: PopupState } | { ok: false; error: string }; export type ApplySuggestionResponse = - | { - ok: true; - state: PopupState; - } - | { - ok: false; - error: string; - }; + { ok: true; state: PopupState } | { ok: false; error: string }; export type LocationDraftResponse = | { diff --git a/src/shared/settings-defaults.test.ts b/src/shared/settings-defaults.test.ts index 6926f21..d3d2ff9 100644 --- a/src/shared/settings-defaults.test.ts +++ b/src/shared/settings-defaults.test.ts @@ -147,6 +147,12 @@ describe("normalizePreferences", () => { expect(result).not.toHaveProperty("foo"); }); + it("falls back to automatic when the interface language is invalid", () => { + expect(normalizePreferences({ uiLocale: "sepia" }).uiLocale).toBe("auto"); + expect(normalizePreferences({}).uiLocale).toBe("auto"); + expect(normalizePreferences({ uiLocale: "uk" }).uiLocale).toBe("uk"); + }); + it("rejects invalid theme and default radius values", () => { const result = normalizePreferences({ themeMode: "sepia", diff --git a/src/shared/settings-defaults.ts b/src/shared/settings-defaults.ts index c6b2104..c2ad011 100644 --- a/src/shared/settings-defaults.ts +++ b/src/shared/settings-defaults.ts @@ -14,6 +14,7 @@ import { isThemeMode, } from "@/shared/theme-types"; import type { OsmConsentState, ThemeAccentPreset, ThemeMode } from "@/shared/types"; +import { isUiLocalePreference, type UiLocalePreference } from "@/shared/ui-locale"; /** * Canonical scalar preferences for the extension. @@ -37,6 +38,7 @@ export type Preferences = { /** @deprecated Import/migration compatibility. Use sharedWorkerHandlingMode. */ sharedWorkerCompatibilityMode: boolean; onboardingCompleted: boolean; + uiLocale: UiLocalePreference; themeMode: ThemeMode; themeAccentPreset: ThemeAccentPreset; reduceMotion: boolean; @@ -69,6 +71,7 @@ export const DEFAULT_PREFERENCES: Preferences = { sharedWorkerHandlingMode: "strict", sharedWorkerCompatibilityMode: false, onboardingCompleted: false, + uiLocale: "auto", themeMode: DEFAULT_THEME_MODE, themeAccentPreset: DEFAULT_ACCENT_PRESET, reduceMotion: false, @@ -96,6 +99,11 @@ const isWatchPositionDelay = (value: unknown): value is [number, number] => const asRecord = (value: unknown): Record => typeof value === "object" && value !== null ? (value as Record) : {}; +const readUiLocale = (source: Record): UiLocalePreference => + isUiLocalePreference(source.uiLocale) + ? source.uiLocale + : DEFAULT_PREFERENCES.uiLocale; + /** * Fills a partial/unknown preferences value with canonical defaults, validating * each field. The result is always a complete, well-typed {@link Preferences}. @@ -133,6 +141,7 @@ export const normalizePreferences = (raw: unknown): Preferences => { typeof source.onboardingCompleted === "boolean" ? source.onboardingCompleted : DEFAULT_PREFERENCES.onboardingCompleted, + uiLocale: readUiLocale(source), themeMode: isThemeMode(source.themeMode) ? source.themeMode : DEFAULT_PREFERENCES.themeMode, diff --git a/src/shared/shared-model-types.ts b/src/shared/shared-model-types.ts index 2e5ed59..b662125 100644 --- a/src/shared/shared-model-types.ts +++ b/src/shared/shared-model-types.ts @@ -55,6 +55,7 @@ import type { import { XRAY_SURFACE_CATEGORIES } from "./spoofing-surfaces.js"; import { DEFAULT_ACCENT_PRESET, THEME_ACCENT_PRESETS } from "./theme-types.js"; import type { ThemeAccentPreset, ThemeMode } from "./theme-types.js"; +import type { UiLocalePreference } from "./ui-locale.js"; export type { BrowserClientHintBrand, @@ -327,6 +328,7 @@ export type ExportedSettings = { rules: DomainRule[]; trustedSites?: TrustedSite[]; globalFallbackRule?: GlobalFallbackRule | undefined; + uiLocale?: UiLocalePreference; themeMode?: ThemeMode; themeAccentPreset?: ThemeAccentPreset; reduceMotion?: boolean; diff --git a/src/shared/ui-locale.ts b/src/shared/ui-locale.ts new file mode 100644 index 0000000..7884b6e --- /dev/null +++ b/src/shared/ui-locale.ts @@ -0,0 +1,14 @@ +export const UI_LOCALE_PREFERENCES = ["auto", "en", "es", "pt", "ru", "uk"] as const; + +export type UiLocalePreference = (typeof UI_LOCALE_PREFERENCES)[number]; + +export const UI_LOCALES = ["en", "es", "pt", "ru", "uk"] as const; + +export type UiLocale = (typeof UI_LOCALES)[number]; + +export const isUiLocalePreference = (value: unknown): value is UiLocalePreference => + typeof value === "string" && + (UI_LOCALE_PREFERENCES as readonly string[]).includes(value); + +export const isUiLocale = (value: string): value is UiLocale => + (UI_LOCALES as readonly string[]).includes(value); diff --git a/src/ui/i18n/LocaleRefresh.tsx b/src/ui/i18n/LocaleRefresh.tsx new file mode 100644 index 0000000..52dbc2b --- /dev/null +++ b/src/ui/i18n/LocaleRefresh.tsx @@ -0,0 +1,16 @@ +import { useSyncExternalStore, type ReactNode } from "react"; + +import { getActiveUiLocale, subscribeActiveUiLocale } from "@/ui/i18n"; +import type { UiLocale } from "@/ui/i18n"; + +export const useUiLocale = (): UiLocale => + useSyncExternalStore(subscribeActiveUiLocale, getActiveUiLocale, getActiveUiLocale); + +export const LocaleRefresh = ({ children }: { children: ReactNode }) => { + const locale = useUiLocale(); + return ( +
+ {children} +
+ ); +}; diff --git a/src/ui/i18n/en-sections/advanced.ts b/src/ui/i18n/en-sections/advanced.ts index 8ff6c3f..647f8a2 100644 --- a/src/ui/i18n/en-sections/advanced.ts +++ b/src/ui/i18n/en-sections/advanced.ts @@ -103,13 +103,14 @@ export const advanced = { language: { title: "Language", - description: `${BRAND_DISPLAY_NAME} automatically follows your browser interface language.`, + description: `${BRAND_DISPLAY_NAME} follows your browser language on Automatic. Choose a language to use that language instead.`, + optionAutomatic: "Automatic", optionEnglish: "English", optionSpanish: "Spanish", optionPortuguese: "Portuguese", optionRussian: "Russian", optionUkrainian: "Ukrainian", - soon: "AUTO", + reportLabel: "Found a translation error? Report it", copyLinkLabel: "language", }, diff --git a/src/ui/i18n/es-sections/advanced.ts b/src/ui/i18n/es-sections/advanced.ts index 87c9411..b77121c 100644 --- a/src/ui/i18n/es-sections/advanced.ts +++ b/src/ui/i18n/es-sections/advanced.ts @@ -104,13 +104,14 @@ export const advanced = { language: { title: "Idioma", - description: `${BRAND_DISPLAY_NAME} usa automáticamente el idioma de la interfaz del navegador.`, + description: `${BRAND_DISPLAY_NAME} sigue el idioma del navegador en Automático. Elige un idioma para usarlo en su lugar.`, + optionAutomatic: "Automático", optionEnglish: "Inglés", optionSpanish: "Español", optionPortuguese: "Portugués", optionRussian: "Ruso", optionUkrainian: "Ucraniano", - soon: "AUTOMÁTICO", + reportLabel: "¿Encontraste un error de traducción? Infórmalo", copyLinkLabel: "idioma", }, diff --git a/src/ui/i18n/index.test.ts b/src/ui/i18n/index.test.ts index fac6c82..59864b0 100644 --- a/src/ui/i18n/index.test.ts +++ b/src/ui/i18n/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveUiLocale } from "./index"; +import { applyUiLocalePreference, resolveUiLocale, t } from "./index"; describe("resolveUiLocale", () => { it.each(["es", "es-ES", "es-MX", "ES-ar"])("selects Spanish for %s", (language) => { @@ -28,4 +28,12 @@ describe("resolveUiLocale", () => { expect(resolveUiLocale(language)).toBe("en"); }, ); + + it("switches the active catalog without recapturing nested messages", () => { + const language = t.advanced.display.language; + applyUiLocalePreference("es"); + expect(language.title).toBe("Idioma"); + applyUiLocalePreference("en"); + expect(language.title).toBe("Language"); + }); }); diff --git a/src/ui/i18n/index.ts b/src/ui/i18n/index.ts index 2c96b81..9b7b2eb 100644 --- a/src/ui/i18n/index.ts +++ b/src/ui/i18n/index.ts @@ -4,23 +4,79 @@ import { pt } from "./pt"; import { ru } from "./ru"; import { uk } from "./uk"; -export type UiLocale = "en" | "es" | "pt" | "ru" | "uk"; +import { isUiLocale, type UiLocale, type UiLocalePreference } from "@/shared/ui-locale"; + +export type { UiLocale, UiLocalePreference }; + +const catalogs = { en, es, pt, ru, uk } as const; export const resolveUiLocale = (language: string | undefined): UiLocale => { const baseLanguage = language?.toLowerCase().split("-")[0]; - return baseLanguage === "es" || - baseLanguage === "pt" || - baseLanguage === "ru" || - baseLanguage === "uk" - ? baseLanguage - : "en"; + return baseLanguage !== undefined && isUiLocale(baseLanguage) ? baseLanguage : "en"; }; -export const UI_LOCALE = resolveUiLocale(globalThis.navigator?.language); -export const t = { en, es, pt, ru, uk }[UI_LOCALE]; +const resolveActiveUiLocale = (preference: UiLocalePreference): UiLocale => + preference === "auto" ? resolveUiLocale(globalThis.navigator?.language) : preference; + +let activeLocale = resolveUiLocale(globalThis.navigator?.language); +const localeListeners = new Set<() => void>(); + +export const getActiveUiLocale = (): UiLocale => activeLocale; + +export const subscribeActiveUiLocale = (listener: () => void): (() => void) => { + localeListeners.add(listener); + return () => { + localeListeners.delete(listener); + }; +}; + +export const applyUiLocalePreference = (preference: UiLocalePreference): void => { + const nextLocale = resolveActiveUiLocale(preference); + if (nextLocale === activeLocale) return; + activeLocale = nextLocale; + if (typeof document !== "undefined") { + document.documentElement.lang = activeLocale; + } + for (const listener of localeListeners) listener(); +}; + +const isMessageRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const createMessagesProxy = (read: () => object): object => + new Proxy( + {}, + { + get(_target, property) { + const value = Reflect.get(read(), property); + if (!isMessageRecord(value)) return value; + return createMessagesProxy(() => { + const next = Reflect.get(read(), property); + return isMessageRecord(next) ? next : {}; + }); + }, + has(_target, property) { + return Reflect.has(read(), property); + }, + ownKeys() { + return Reflect.ownKeys(read()); + }, + getOwnPropertyDescriptor(_target, property) { + if (!Reflect.has(read(), property)) return undefined; + return { + configurable: true, + enumerable: true, + value: Reflect.get(read(), property), + writable: true, + }; + }, + }, + ); + +export const t = createMessagesProxy(() => catalogs[activeLocale]) as typeof en; if (typeof document !== "undefined") { - document.documentElement.lang = UI_LOCALE; + document.documentElement.lang = activeLocale; } export type { Translations } from "./types"; diff --git a/src/ui/i18n/pt-sections/advanced.ts b/src/ui/i18n/pt-sections/advanced.ts index 3feaad3..a5f3225 100644 --- a/src/ui/i18n/pt-sections/advanced.ts +++ b/src/ui/i18n/pt-sections/advanced.ts @@ -104,13 +104,14 @@ export const advanced = { language: { title: "Idioma", - description: `${BRAND_DISPLAY_NAME} segue automaticamente o idioma da interface do seu navegador.`, + description: `${BRAND_DISPLAY_NAME} segue o idioma do navegador em Automático. Escolha um idioma para usá-lo no lugar.`, + optionAutomatic: "Automático", optionEnglish: "Inglês", optionSpanish: "Espanhol", optionPortuguese: "Português", optionRussian: "Russo", optionUkrainian: "Ucraniano", - soon: "AUTO", + reportLabel: "Encontrou um erro de tradução? Informe", copyLinkLabel: "idioma", }, diff --git a/src/ui/i18n/ru-sections/advanced.ts b/src/ui/i18n/ru-sections/advanced.ts index 32a5d71..04f944b 100644 --- a/src/ui/i18n/ru-sections/advanced.ts +++ b/src/ui/i18n/ru-sections/advanced.ts @@ -103,13 +103,14 @@ export const advanced = { language: { title: "Язык", - description: `${BRAND_DISPLAY_NAME} автоматически следует языку интерфейса вашего браузера.`, + description: `${BRAND_DISPLAY_NAME} в режиме «Автоматически» следует языку браузера. Выберите язык, чтобы использовать его вместо этого.`, + optionAutomatic: "Автоматически", optionEnglish: "Английский", optionSpanish: "Испанский", optionPortuguese: "Португальский", optionRussian: "Русский", optionUkrainian: "Украинский", - soon: "АВТО", + reportLabel: "Нашли ошибку в переводе? Сообщите о ней", copyLinkLabel: "язык", }, diff --git a/src/ui/i18n/uk-sections/advanced.ts b/src/ui/i18n/uk-sections/advanced.ts index 13dae2b..a413faf 100644 --- a/src/ui/i18n/uk-sections/advanced.ts +++ b/src/ui/i18n/uk-sections/advanced.ts @@ -104,13 +104,14 @@ export const advanced = { language: { title: "Мова", - description: `${BRAND_DISPLAY_NAME} автоматично відповідає мові інтерфейсу вашого браузера.`, + description: `${BRAND_DISPLAY_NAME} у режимі «Автоматично» відповідає мові браузера. Виберіть мову, щоб використовувати її замість цього.`, + optionAutomatic: "Автоматично", optionEnglish: "Англійська", optionSpanish: "Іспанська", optionPortuguese: "Португальська", optionRussian: "Російська", optionUkrainian: "Українська", - soon: "АВТО", + reportLabel: "Знайшли помилку в перекладі? Повідомте про неї", copyLinkLabel: "мова", }, diff --git a/src/ui/internal-api/settings-api.test.ts b/src/ui/internal-api/settings-api.test.ts index 071570b..57649be 100644 --- a/src/ui/internal-api/settings-api.test.ts +++ b/src/ui/internal-api/settings-api.test.ts @@ -79,6 +79,7 @@ const baseSettings = ( includeDateCallsInBadgeCount: true, onboardingCompleted: true, notice: null, + uiLocale: "auto", ...overrides, }); @@ -86,6 +87,7 @@ const saveSimpleOk = ( overrides: Partial> = {}, ): SaveSettingsResponse => ({ ok: true, + uiLocale: "auto", themeMode: "system", themeAccentPreset: "teal", reduceMotion: false, diff --git a/src/ui/options/components/tabs/OptionsTab.render.target.test.tsx b/src/ui/options/components/tabs/OptionsTab.render.target.test.tsx index 27a0031..51068e0 100644 --- a/src/ui/options/components/tabs/OptionsTab.render.target.test.tsx +++ b/src/ui/options/components/tabs/OptionsTab.render.target.test.tsx @@ -4,6 +4,7 @@ import { act, createElement, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { commitLanguageSelection } from "@/ui/options/components/tabs/options-preference-sections"; import { OptionsTab } from "@/ui/options/components/tabs/OptionsTab"; const { @@ -49,6 +50,8 @@ const { motionOverride: false, setReduceMotion: vi.fn(), setHighContrast: vi.fn(), + uiLocale: "auto" as const, + setUiLocale: vi.fn(), }, })); @@ -163,7 +166,9 @@ describe("OptionsTab Shared Worker handling", () => { useSettingsValue.sharedWorkerHandlingMode = "native"; themeValue.reduceMotion = false; themeValue.motionOverride = false; + themeValue.uiLocale = "auto"; themeValue.setReduceMotion.mockReset(); + themeValue.setUiLocale.mockReset(); }); afterEach(async () => { @@ -212,17 +217,30 @@ describe("OptionsTab Shared Worker handling", () => { ); }); - it("shows the automatically selected UI language without saving settings", async () => { + it("shows the language selector without saving settings", async () => { root = await renderWithRoot(); const languageTrigger = document.getElementById("language-trigger"); + const reportLink = document.querySelector( + 'a[href="https://github.com/Privacy-Thing/browser-extension/issues/new?template=bug_report.yml"]', + ); expect(languageTrigger).toBeInstanceOf(HTMLButtonElement); - expect((languageTrigger as HTMLButtonElement).disabled).toBe(true); - expect(languageTrigger?.textContent).toContain("English"); + expect((languageTrigger as HTMLButtonElement).disabled).toBe(false); + expect(languageTrigger?.textContent).toContain("Automatic"); expect(document.body.textContent).toContain("Language"); - expect(document.body.textContent).toContain("AUTO"); + expect(document.body.textContent).not.toContain("AUTO"); + expect(reportLink?.textContent).toContain("Found a translation error? Report it"); expect(scheduleAutosaveMock).not.toHaveBeenCalled(); + expect(themeValue.setUiLocale).not.toHaveBeenCalled(); + }); + + it("saves a chosen interface language and ignores unknown values", () => { + const setUiLocale = vi.fn(async () => undefined); + commitLanguageSelection("es", setUiLocale); + commitLanguageSelection("nope", setUiLocale); + expect(setUiLocale).toHaveBeenCalledTimes(1); + expect(setUiLocale).toHaveBeenCalledWith("es"); }); it("states which external requests map consent allows", async () => { diff --git a/src/ui/options/components/tabs/options-preference-sections.tsx b/src/ui/options/components/tabs/options-preference-sections.tsx index f5b09d9..8883c0c 100644 --- a/src/ui/options/components/tabs/options-preference-sections.tsx +++ b/src/ui/options/components/tabs/options-preference-sections.tsx @@ -1,5 +1,6 @@ import { fireAndForget } from "@/shared/async"; import type { ThemeAccentPreset } from "@/shared/types"; +import { isUiLocalePreference, type UiLocalePreference } from "@/shared/ui-locale"; import { cn } from "@/ui/components/lib/utils"; import { getSettingDescriptionId, @@ -10,7 +11,6 @@ import { SettingsControlCard } from "@/ui/components/SettingsControlCard"; import { SettingsHelpCard } from "@/ui/components/SettingsHelpCard"; import { SettingsSectionCard } from "@/ui/components/SettingsSectionCard"; import { SettingsSubcard } from "@/ui/components/SettingsSubcard"; -import { Badge } from "@/ui/components/ui/badge"; import { Button } from "@/ui/components/ui/button"; import { Select, @@ -20,7 +20,7 @@ import { SelectValue, } from "@/ui/components/ui/select"; import { Switch } from "@/ui/components/ui/switch"; -import { t, UI_LOCALE } from "@/ui/i18n"; +import { t } from "@/ui/i18n"; import type { OptionsModel } from "@/ui/options/components/tabs/options-model"; import { renderOsmConsentState } from "@/ui/options/components/tabs/options-surface-data"; import { @@ -31,6 +31,7 @@ import { import { icon } from "@/ui/options/utils"; import { getThemeAccentTokens } from "@/ui/shared/theme"; import { THEME_ACCENT_OPTIONS } from "@/ui/shared/theme-accent-options"; +import { useTheme } from "@/ui/shared/ThemeProvider"; export const PrivacySection = ({ model }: { model: OptionsModel }) => ( ); -const LanguageCard = () => ( - - {t.advanced.display.language.title} - {t.advanced.display.language.soon} - - } - description={t.advanced.display.language.description} - actionClassName="w-full sm:w-44" - action={ - { + commitLanguageSelection(value, setUiLocale); + }} > - - - - - {t.advanced.display.language.optionEnglish} - - - {t.advanced.display.language.optionSpanish} - - - {t.advanced.display.language.optionPortuguese} - - - {t.advanced.display.language.optionRussian} - - - {t.advanced.display.language.optionUkrainian} - - - - } - /> -); + + + + + + {t.advanced.display.language.optionAutomatic} + + + {t.advanced.display.language.optionEnglish} + + + {t.advanced.display.language.optionSpanish} + + + {t.advanced.display.language.optionPortuguese} + + + {t.advanced.display.language.optionRussian} + + + {t.advanced.display.language.optionUkrainian} + + + + } + /> + ); +}; const ThemeCard = ({ model }: { model: OptionsModel }) => ( ( } contentClassName="flex flex-col gap-6 pt-6" > - + diff --git a/src/ui/options/options-anchor-open.target.test.ts b/src/ui/options/options-anchor-open.target.test.ts index da1f60a..bc24e33 100644 --- a/src/ui/options/options-anchor-open.target.test.ts +++ b/src/ui/options/options-anchor-open.target.test.ts @@ -122,6 +122,7 @@ const baseSettingsResponse: GetSettingsResponse = { ruleSeedKey: "seed123", fingerprintSurfaceOverrides: { geolocation: false }, }, + uiLocale: "auto", themeMode: "system", themeAccentPreset: DEFAULT_ACCENT_PRESET, reduceMotion: false, diff --git a/src/ui/options/options-page.tsx b/src/ui/options/options-page.tsx index 7e37fa3..441ee85 100644 --- a/src/ui/options/options-page.tsx +++ b/src/ui/options/options-page.tsx @@ -5,11 +5,13 @@ import { } from "@privacy-thing/brand"; import React from "react"; +import { BRAND_DISPLAY_NAME } from "@/shared/brand"; import { BUILD_BROWSER_TARGET } from "@/shared/build-flags"; import { Button } from "@/ui/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui/components/ui/tabs"; import { AppToaster } from "@/ui/components/ui/toast"; import { t } from "@/ui/i18n"; +import { LocaleRefresh, useUiLocale } from "@/ui/i18n/LocaleRefresh"; import { OPTIONS_HOVER_REACTION, OPTIONS_THING_TIMING } from "@/ui/options/brand-thing"; import { ConfirmDialog } from "@/ui/options/components/modals/ConfirmDialog"; import { GlobalFallbackRuleDialog } from "@/ui/options/components/modals/GlobalFallbackRuleDialog"; @@ -323,11 +325,19 @@ export const OptionsUi = () => { ); }; -export const App = () => ( - - - - - - -); +export const App = () => { + const locale = useUiLocale(); + React.useEffect(() => { + document.title = `${BRAND_DISPLAY_NAME} ${t.options.title}`; + }, [locale]); + return ( + + + + + + + + + ); +}; diff --git a/src/ui/options/state/SettingsContext.target.test.ts b/src/ui/options/state/SettingsContext.target.test.ts index 5a65a20..b935778 100644 --- a/src/ui/options/state/SettingsContext.target.test.ts +++ b/src/ui/options/state/SettingsContext.target.test.ts @@ -38,6 +38,7 @@ const baseSettingsResponse: GetSettingsResponse = { locations: [], rules: [], trustedSites: [], + uiLocale: "auto", themeMode: "system", themeAccentPreset: "teal", reduceMotion: false, @@ -62,6 +63,7 @@ const baseSettingsResponse: GetSettingsResponse = { const baseSaveResponse = { ok: true, + uiLocale: "auto", themeMode: "system", themeAccentPreset: "teal", reduceMotion: false, diff --git a/src/ui/options/state/settings-state-sync.target.test.ts b/src/ui/options/state/settings-state-sync.target.test.ts index 8f40e9e..af2f04d 100644 --- a/src/ui/options/state/settings-state-sync.target.test.ts +++ b/src/ui/options/state/settings-state-sync.target.test.ts @@ -36,6 +36,7 @@ describe("applySettingsPayload", () => { }, ], trustedSites: [{ pattern: "bank.example.com", enabled: true }], + uiLocale: "auto", themeMode: "dark", themeAccentPreset: "blue", reduceMotion: true, diff --git a/src/ui/popup/components/PopupNotifications.tsx b/src/ui/popup/components/PopupNotifications.tsx index 69e15a6..89d2a84 100644 --- a/src/ui/popup/components/PopupNotifications.tsx +++ b/src/ui/popup/components/PopupNotifications.tsx @@ -17,7 +17,7 @@ import type { PopupSiteSuggestion, WorkerInjectionMode, } from "@/shared/types"; -import { t, UI_LOCALE } from "@/ui/i18n"; +import { getActiveUiLocale, t } from "@/ui/i18n"; export type NotificationDisplayState = "unread" | "acknowledged" | "resolved"; @@ -33,12 +33,12 @@ const getReleaseNotification = ( ): ReleaseNotice | null => { if (notification.kind !== "significant-update") return null; return ( - getReleaseNotice(notification.id, UI_LOCALE) ?? + getReleaseNotice(notification.id, getActiveUiLocale()) ?? (notification.channel && notification.introducedInVersion ? (getVersionNotices( notification.channel, notification.introducedInVersion, - UI_LOCALE, + getActiveUiLocale(), )[0] ?? null) : null) ); diff --git a/src/ui/popup/popup.tsx b/src/ui/popup/popup.tsx index 26d6ccf..aaa1a6e 100644 --- a/src/ui/popup/popup.tsx +++ b/src/ui/popup/popup.tsx @@ -8,6 +8,7 @@ import { createRoot } from "react-dom/client"; import { PopupApp } from "./PopupApp"; import { BRAND_DISPLAY_NAME } from "@/shared/brand"; +import { LocaleRefresh } from "@/ui/i18n/LocaleRefresh"; import { installSettingsAPI } from "@/ui/internal-api/settings-api"; import { ThemeProvider } from "@/ui/shared/ThemeProvider"; @@ -16,7 +17,9 @@ document.title = BRAND_DISPLAY_NAME; const App = () => ( - + + + ); diff --git a/src/ui/shared/ThemeProvider.tsx b/src/ui/shared/ThemeProvider.tsx index 7b4755e..706e160 100644 --- a/src/ui/shared/ThemeProvider.tsx +++ b/src/ui/shared/ThemeProvider.tsx @@ -24,6 +24,8 @@ import { type ThemeMode, type SaveSettingsResponse, } from "@/shared/types"; +import type { UiLocalePreference } from "@/shared/ui-locale"; +import { applyUiLocalePreference } from "@/ui/i18n"; import { migrateLegacyPrefs } from "@/ui/shared/preferences-migration"; import { sendRuntimeMessage, sendMessageOrThrow } from "@/ui/shared/runtime-messaging"; import { @@ -43,6 +45,8 @@ interface ThemeContextValue { setReduceMotion: (enabled: boolean) => Promise; highContrast: boolean; setHighContrast: (enabled: boolean) => Promise; + uiLocale: UiLocalePreference; + setUiLocale: (uiLocale: UiLocalePreference) => Promise; } const ThemeContext = createContext(null); @@ -83,6 +87,7 @@ type ThemeSetters = { setContrast: React.Dispatch>; setMotion: React.Dispatch>; setPreference: React.Dispatch>; + setUiLocale: React.Dispatch>; }; const applyStoredPreferences = (prefs: Preferences, setters: ThemeSetters): void => { @@ -90,6 +95,8 @@ const applyStoredPreferences = (prefs: Preferences, setters: ThemeSetters): void setters.setAccent(prefs.themeAccentPreset); setters.setMotion(prefs.reduceMotion); setters.setContrast(prefs.highContrastMode); + setters.setUiLocale(prefs.uiLocale); + applyUiLocalePreference(prefs.uiLocale); }; const useInitialPreferences = ( @@ -243,7 +250,21 @@ const useThemeActions = (setters: ThemeSetters) => { }, [persist, setters], ); - return { setAccentPreset, setHighContrast, setPreference, setReduceMotion }; + const setUiLocale = useCallback( + async (next: UiLocalePreference) => { + setters.setUiLocale(next); + applyUiLocalePreference(next); + await persist({ uiLocale: next }); + }, + [persist, setters], + ); + return { + setAccentPreset, + setHighContrast, + setPreference, + setReduceMotion, + setUiLocale, + }; }; export function ThemeProvider({ @@ -271,6 +292,9 @@ export function ThemeProvider({ const [highContrastMode, setHighContrastMode] = useState( DEFAULT_PREFERENCES.highContrastMode, ); + const [uiLocale, setUiLocaleState] = useState( + DEFAULT_PREFERENCES.uiLocale, + ); const [initialPreferencesReady, setPrefsReady] = useState(!deferInitialPaint); const theme = preference === "system" ? systemTheme : preference; const effectiveReduceMotion = reduceMotion || systemReduceMotion; @@ -280,6 +304,7 @@ export function ThemeProvider({ setContrast: setHighContrastMode, setMotion: setReduceMotionState, setPreference: setPreferenceState, + setUiLocale: setUiLocaleState, }), [], ); @@ -293,8 +318,13 @@ export function ThemeProvider({ theme, }); usePreferenceSync(setters); - const { setAccentPreset, setHighContrast, setPreference, setReduceMotion } = - useThemeActions(setters); + const { + setAccentPreset, + setHighContrast, + setPreference, + setReduceMotion, + setUiLocale, + } = useThemeActions(setters); const value = useMemo( () => ({ @@ -308,6 +338,8 @@ export function ThemeProvider({ setReduceMotion, highContrast: highContrastMode, setHighContrast, + uiLocale, + setUiLocale, }), [ theme, @@ -320,6 +352,8 @@ export function ThemeProvider({ setReduceMotion, highContrastMode, setHighContrast, + uiLocale, + setUiLocale, ], ); diff --git a/src/ui/sidebar/modules/registry.ts b/src/ui/sidebar/modules/registry.ts index 82bb542..3148b24 100644 --- a/src/ui/sidebar/modules/registry.ts +++ b/src/ui/sidebar/modules/registry.ts @@ -6,7 +6,9 @@ import { t } from "@/ui/i18n"; export const SIDEBAR_MODULES: SidebarModule[] = [ { id: "xray", - title: t.sidebar.xRayTitle, + get title() { + return t.sidebar.xRayTitle; + }, Component: XRayModule, }, ]; diff --git a/src/ui/sidebar/sidebar.tsx b/src/ui/sidebar/sidebar.tsx index 79bc1ae..adf5d25 100644 --- a/src/ui/sidebar/sidebar.tsx +++ b/src/ui/sidebar/sidebar.tsx @@ -9,6 +9,7 @@ import { SidebarShell } from "./SidebarShell"; import { BRAND_DISPLAY_NAME } from "@/shared/brand"; import { AppToaster } from "@/ui/components/ui/toast"; +import { LocaleRefresh } from "@/ui/i18n/LocaleRefresh"; import { installSettingsAPI } from "@/ui/internal-api/settings-api"; import { ThemeProvider } from "@/ui/shared/ThemeProvider"; @@ -18,8 +19,10 @@ document.title = BRAND_DISPLAY_NAME; const App = () => ( - - + + + + );