diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte index 0efd61fe7b..47e1c88a69 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -14,9 +14,10 @@ sidebarSettings, } from '$lib/stores/preferences.store'; import { findLocale } from '$lib/utils'; + import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n'; import { onMount } from 'svelte'; + import { locale as i18nLocale, t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; - import { t, init } from 'svelte-i18n'; import { invalidateAll } from '$app/navigation'; let time = new Date(); @@ -37,6 +38,7 @@ value: findLocale(editedLocale).code || fallbackLocale.code, label: findLocale(editedLocale).name || fallbackLocale.name, }; + $: closestLanguage = getClosestAvailableLocale([$lang], langCodes); onMount(() => { const interval = setInterval(() => { @@ -78,13 +80,7 @@ const handleLanguageChange = async (newLang: string | undefined) => { if (newLang) { $lang = newLang; - - if (newLang === 'dev') { - // Reload required, because fallbackLocale cannot be cleared. - window.location.reload(); - } - - await init({ fallbackLocale: defaultLang.code, initialLocale: newLang }); + await i18nLocale.set(newLang); await invalidateAll(); } }; @@ -111,7 +107,7 @@
value === $lang) || defaultLangOption} + selectedOption={langOptions.find(({ value }) => value === closestLanguage) || defaultLangOption} options={langOptions} title={$t('language')} subtitle={$t('language_setting_description')} diff --git a/web/src/lib/i18n.spec.ts b/web/src/lib/i18n.spec.ts index cb030437db..c9261dcec5 100644 --- a/web/src/lib/i18n.spec.ts +++ b/web/src/lib/i18n.spec.ts @@ -1,5 +1,6 @@ import { langs } from '$lib/constants'; import messages from '$lib/i18n/en.json'; +import { getClosestAvailableLocale } from '$lib/utils/i18n'; import { exec as execCallback } from 'node:child_process'; import { readFileSync, readdirSync } from 'node:fs'; import { promisify } from 'node:util'; @@ -52,4 +53,30 @@ describe('i18n', () => { }); } }); + + describe('getClosestAvailableLocale', () => { + const allLocales = ['ar', 'bg', 'en', 'en-US', 'en-DE', 'zh-Hans', 'zh-Hans-HK']; + + it('returns undefined on mismatch', () => { + expect(getClosestAvailableLocale([], allLocales)).toBeUndefined(); + expect(getClosestAvailableLocale(['invalid'], allLocales)).toBeUndefined(); + }); + + it('returns the first matching locale', () => { + expect(getClosestAvailableLocale(['invalid', 'ar', 'bg'], allLocales)).toBe('ar'); + expect(getClosestAvailableLocale(['bg'], allLocales)).toBe('bg'); + expect(getClosestAvailableLocale(['bg', 'invalid', 'ar'], allLocales)).toBe('bg'); + }); + + it('returns the locale for a less specific match', () => { + expect(getClosestAvailableLocale(['ar-AE'], allLocales)).toBe('ar-AE'); + expect(getClosestAvailableLocale(['ar-AE', 'en'], allLocales)).toBe('ar-AE'); + expect(getClosestAvailableLocale(['zh-Hans-HK', 'zh-Hans'], allLocales)).toBe('zh-Hans-HK'); + }); + + it('ignores the locale for a more specific match', () => { + expect(getClosestAvailableLocale(['zh'], allLocales)).toBeUndefined(); + expect(getClosestAvailableLocale(['de', 'zh', 'en-US'], allLocales)).toBe('en-US'); + }); + }); }); diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index b9c89fa92c..11473f8061 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -1,5 +1,6 @@ import { browser } from '$app/environment'; import { Theme, defaultLang } from '$lib/constants'; +import { getPreferredLocale } from '$lib/utils/i18n'; import { persisted } from 'svelte-local-storage-store'; import { get } from 'svelte/store'; @@ -42,7 +43,8 @@ export const locale = persisted('locale', undefined, { }, }); -export const lang = persisted('lang', defaultLang.code, { +const preferredLocale = browser ? getPreferredLocale() : undefined; +export const lang = persisted('lang', preferredLocale || defaultLang.code, { serializer: { parse: (text) => text, stringify: (object) => object ?? '', diff --git a/web/src/lib/utils/i18n.ts b/web/src/lib/utils/i18n.ts index fae4454922..83fe11875c 100644 --- a/web/src/lib/utils/i18n.ts +++ b/web/src/lib/utils/i18n.ts @@ -1,3 +1,4 @@ +import { langs } from '$lib/constants'; import { locale, t, waitLocale } from 'svelte-i18n'; import { get, type Unsubscriber } from 'svelte/store'; @@ -11,3 +12,22 @@ export async function getFormatter() { await waitLocale(); return get(t); } + +// https://github.com/kaisermann/svelte-i18n/blob/780932a3e1270d521d348aac8ba03be9df309f04/src/runtime/stores/locale.ts#L11 +function getSubLocales(refLocale: string) { + return refLocale + .split('-') + .map((_, i, arr) => arr.slice(0, i + 1).join('-')) + .reverse(); +} + +export function getClosestAvailableLocale(locales: readonly string[], allLocales: readonly string[]) { + const allLocalesSet = new Set(allLocales); + return locales.find((locale) => getSubLocales(locale).some((subLocale) => allLocalesSet.has(subLocale))); +} + +export const langCodes = langs.map((lang) => lang.code); + +export function getPreferredLocale() { + return getClosestAvailableLocale(navigator.languages, langCodes); +}