From 81b716569fd97037d50c3855da413d78fcbed29e Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 22 Jun 2026 20:09:41 +0200 Subject: [PATCH] fix(i18n): reconcile preferred_language to the interface locale on every adopt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user who never changed the language in Settings kept their account at the creation-time preferred_language seed (e.g. en from the Telegram launch language_code) even after switching the device to another language: the UI followed the device (ru) but the ad banner and out-of-app push — both resolved server-side from preferred_language — stayed en. The on-adopt reconcile was gated on an explicit local choice (localeLocked), so a system-guess locale was never pushed through. Reconcile preferred_language to the active interface locale (app.locale) on every session adopt and link, regardless of how the locale was chosen; persistLanguageToServer already self-gates (a no-op for guests and when already equal), so there is no steady-state write. The banner and push are the only server-rendered language surfaces and both read preferred_language, so this keeps the whole interface consistent — not just the banner. Drop the now-dead localeLocked flag (the reconcile guards were its only readers; the saved prefs.locale still restores the UI choice per device). Trade-off: preferred_language now follows the most-recently-opened device, so an explicit choice on one device can be overwritten by a system guess on another (the "explicit" mark is local, per-device); making it globally sticky would need a DB flag. Docs: ARCHITECTURE §4 + the profile field. --- docs/ARCHITECTURE.md | 9 +++++++-- ui/src/lib/app.svelte.ts | 35 +++++++++++++++++------------------ 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3e37f7a..d7d1b29 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -158,7 +158,12 @@ arrive from a platform rather than completing a mandatory registration). rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in any bot-scoped language, and the friend-invite **share link** (and its caption) point at that one bot. First Telegram contact seeds the new account's `preferred_language` from the - launch `language_code` (§4); the interface language is otherwise edited in Settings. + launch `language_code` (§4), but the **interface language follows the device** — the system + guess, or an explicit Settings choice saved locally — and the bot never dictates the UI. + `preferred_language` is then **reconciled to the active interface locale on every session + adopt** (not only on a Settings change; a no-op for guests and when already equal), so the + server-rendered language surfaces — this push and the ad banner — always match the UI rather + than stranding a user who never opened Settings on the creation-time seed. - **Variant preferences (New Game gating).** Which variants a player may be matched into is a per-user **profile** setting — `variant_preferences`, a set of `engine.Variant` labels (`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. New @@ -640,7 +645,7 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set); **floats games with any unread entry to the top** of the your-turn and opponent-turn sections (the finished section keeps its activity order). On each clear the publish-to-read latency is recorded; the read time itself is not retained. -- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email +- **Profile**: `preferred_language` (en/ru; tracks the interface language — §4), display name, email (confirm-code binding, see §4), **timezone**, the daily **away window**, the **variant preferences** (`variant_preferences`, the matchable-variant set that gates New Game — §3, defaulting to Erudit only, at least one enforced) and the diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 06d4357..c96a82f 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -68,7 +68,6 @@ export const app = $state<{ locale: Locale; reduceMotion: boolean; boardLabels: BoardLabelMode; - localeLocked: boolean; /** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */ notifications: number; /** Per-game flag: the player has at least one unread chat entry (message or nudge) in that @@ -109,7 +108,6 @@ export const app = $state<{ locale: 'en', reduceMotion: false, boardLabels: 'beginner', - localeLocked: false, notifications: 0, chatUnread: {}, messageUnread: {}, @@ -451,18 +449,20 @@ async function adoptSession(s: Session): Promise { await saveSession(s); try { app.profile = await gateway.profileGet(); - // The live interface language follows the device — the explicit local choice (locked, saved - // in prefs) or the system guess made at bootstrap — and is no longer overridden from the - // 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. + // The live interface language follows the device — the explicit local choice (saved in + // prefs) or the system guess made at bootstrap — and is no longer overridden from the + // account here: the Telegram bot a user signs in through must not dictate the UI, so 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); + // The banner and out-of-app push are resolved server-side from preferred_language, so it + // must track whatever language the UI actually shows — the explicit choice AND the system + // guess. Reconcile it to the active locale on every adopt, not only after an explicit + // Settings choice: a user who never opened Settings would otherwise be stuck on the + // creation-time seed — e.g. an English banner under a Russian UI. This keeps every + // server-rendered, language-dependent surface (banner, out-of-app push) aligned with the + // interface, not just one. persistLanguageToServer self-gates (a no-op for guests and when + // already equal), so there is no write in the steady state. + void persistLanguageToServer(app.locale); } catch (err) { handleError(err); } @@ -483,9 +483,10 @@ 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); + // A guest who linked in place now has a durable account: push the active interface language + // so the banner + push routing follow it (see adoptSession — reconciled regardless of an + // explicit Settings choice). + void persistLanguageToServer(app.locale); } /** @@ -538,7 +539,6 @@ export async function bootstrap(): Promise { applyReduceMotion(app.reduceMotion); if (prefs.locale) { app.locale = prefs.locale; - app.localeLocked = true; setLocale(prefs.locale); } else { const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'); @@ -754,7 +754,6 @@ export function setTheme(theme: ThemePref): void { export function setLocalePref(locale: Locale): void { app.locale = locale; - app.localeLocked = true; setLocale(locale); persistPrefs(); void persistLanguageToServer(locale);