Merge pull request 'fix(i18n): banner/push language follows the device's saved choice' (#113) from feature/banner-language-sync into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 54s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / deploy (push) Successful in 1m19s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 54s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / deploy (push) Successful in 1m19s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
This commit was merged in pull request #113.
This commit is contained in:
@@ -8,6 +8,7 @@ import { gateway } from './gateway';
|
|||||||
import { GatewayError } from './client';
|
import { GatewayError } from './client';
|
||||||
import { navigate, router } from './router.svelte';
|
import { navigate, router } from './router.svelte';
|
||||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.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 { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
|
||||||
import {
|
import {
|
||||||
insideTelegram,
|
insideTelegram,
|
||||||
@@ -455,6 +456,13 @@ async function adoptSession(s: Session): Promise<void> {
|
|||||||
// account here. preferred_language stays the user's saved choice (written from Settings,
|
// 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
|
// 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.
|
// 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) {
|
} catch (err) {
|
||||||
handleError(err);
|
handleError(err);
|
||||||
}
|
}
|
||||||
@@ -475,6 +483,9 @@ export async function applyLinkResult(r: LinkResult): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
app.profile = await gateway.profileGet();
|
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<void> {
|
async function persistLanguageToServer(locale: Locale): Promise<void> {
|
||||||
const p = app.profile;
|
const p = app.profile;
|
||||||
if (!p || p.isGuest || p.preferredLanguage === locale) return;
|
if (!p || !languageNeedsServerSync(p, locale)) return;
|
||||||
try {
|
try {
|
||||||
app.profile = await gateway.profileUpdate({
|
app.profile = await gateway.profileUpdate({
|
||||||
displayName: p.displayName,
|
displayName: p.displayName,
|
||||||
|
|||||||
@@ -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>): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user