From 90f0424de25d3e0e567c4d86c1a5c6cd7315d7b8 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 22 Jun 2026 16:16:33 +0200 Subject: [PATCH] fix(i18n): reconcile preferred_language with the device's saved language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI language follows the device (the local choice / system guess) and is deliberately not overridden from the account, but the advertising banner and out-of-app push routing are resolved server-side from preferred_language. A saved device choice the account had not recorded — picked while a guest, or differing from the Telegram system-language seed — left the banner (and pushes) in the wrong language until a Settings change rewrote preferred_language. On profile load (adoptSession and the in-place link path) push the saved local choice to the account when it differs (new pure helper languageNeedsServerSync; no-op for guests and when already equal), so the banner and pushes match the visible UI from the first open. Unit-tested. --- ui/src/lib/app.svelte.ts | 13 ++++++++++++- ui/src/lib/language.test.ts | 27 +++++++++++++++++++++++++++ ui/src/lib/language.ts | 22 ++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 ui/src/lib/language.test.ts create mode 100644 ui/src/lib/language.ts diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index f494291..06d4357 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -8,6 +8,7 @@ import { gateway } from './gateway'; import { GatewayError } from './client'; import { navigate, router } from './router.svelte'; import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte'; +import { languageNeedsServerSync } from './language'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme'; import { insideTelegram, @@ -455,6 +456,13 @@ async function adoptSession(s: Session): Promise { // account here. preferred_language stays the user's saved choice (written from Settings, // and used for out-of-app push routing), but the Telegram bot a user signs in through must // not dictate the UI: a ru-bot launch on an English system stays English. + // + // But the banner and out-of-app push routing ARE resolved from preferred_language, so an + // explicit device choice the account has not recorded yet (picked while a guest, or + // differing from the Telegram system-language seed) would otherwise leave them in the wrong + // language until the next Settings change. Reconcile the account to the saved local choice + // here; persistLanguageToServer no-ops for guests and when already equal. + if (app.localeLocked) void persistLanguageToServer(app.locale); } catch (err) { handleError(err); } @@ -475,6 +483,9 @@ export async function applyLinkResult(r: LinkResult): Promise { return; } app.profile = await gateway.profileGet(); + // A guest who chose a language and then linked in place now has a durable account: push the + // saved choice so the banner + push routing follow it (see adoptSession). + if (app.localeLocked) void persistLanguageToServer(app.locale); } /** @@ -756,7 +767,7 @@ export function setLocalePref(locale: Locale): void { */ async function persistLanguageToServer(locale: Locale): Promise { const p = app.profile; - if (!p || p.isGuest || p.preferredLanguage === locale) return; + if (!p || !languageNeedsServerSync(p, locale)) return; try { app.profile = await gateway.profileUpdate({ displayName: p.displayName, diff --git a/ui/src/lib/language.test.ts b/ui/src/lib/language.test.ts new file mode 100644 index 0000000..3e725a3 --- /dev/null +++ b/ui/src/lib/language.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; + +import { languageNeedsServerSync } from './language'; +import type { Profile } from './model'; + +// The reconciler only reads isGuest + preferredLanguage; a partial cast keeps the fixture small. +const profile = (over: Partial): Profile => ({ isGuest: false, preferredLanguage: 'en', ...over }) as Profile; + +describe('languageNeedsServerSync', () => { + it('is false without a profile', () => { + expect(languageNeedsServerSync(null, 'ru')).toBe(false); + expect(languageNeedsServerSync(undefined, 'ru')).toBe(false); + }); + + it('is false for a guest — guests keep only the client preference', () => { + expect(languageNeedsServerSync(profile({ isGuest: true, preferredLanguage: 'en' }), 'ru')).toBe(false); + }); + + it('is false when the account already matches the locale', () => { + expect(languageNeedsServerSync(profile({ preferredLanguage: 'ru' }), 'ru')).toBe(false); + }); + + it('is true for a real account whose stored language differs (banner + push follow it)', () => { + expect(languageNeedsServerSync(profile({ preferredLanguage: 'en' }), 'ru')).toBe(true); + expect(languageNeedsServerSync(profile({ preferredLanguage: 'ru' }), 'en')).toBe(true); + }); +}); diff --git a/ui/src/lib/language.ts b/ui/src/lib/language.ts new file mode 100644 index 0000000..514987e --- /dev/null +++ b/ui/src/lib/language.ts @@ -0,0 +1,22 @@ +// Interface-language reconciliation. Kept out of app.svelte.ts (a runes module that the +// node-env Vitest layer cannot import) so the decision is unit-testable. + +import type { Locale } from './i18n/catalog'; +import type { Profile } from './model'; + +/** + * languageNeedsServerSync reports whether the durable account's `preferred_language` should be + * rewritten to the chosen interface `locale`. It is true only for a real (non-guest) account + * whose stored language differs from the locale; guests keep only the client-side preference, + * and an already-matching account is a no-op. + * + * The UI language follows the device (the local choice / system guess), but the advertising + * banner and out-of-app push routing are resolved server-side from `preferred_language`. A saved + * device choice the account has not yet recorded — picked while a guest, or differing from the + * Telegram system-language seed — would otherwise leave the banner and pushes in the wrong + * language until the next Settings change. Both the Settings control and the on-load reconciler + * gate their write on this. + */ +export function languageNeedsServerSync(profile: Profile | null | undefined, locale: Locale): boolean { + return !!profile && !profile.isGuest && profile.preferredLanguage !== locale; +} -- 2.52.0