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);
+}