fix(frontend): isolate subscription language preference (#6394)

* fix(frontend): isolate subscription language preference

* fix(frontend): defer date locale resolution
This commit is contained in:
dawn
2026-09-03 22:33:37 +08:00
committed by GitHub
parent 0c72dd8384
commit ded2aa150c
5 changed files with 95 additions and 23 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer }); message.config({ getContainer: () => messageContainer });
} }
readyI18n().then(() => { readyI18n('subscription').then(() => {
const root = document.getElementById('app'); const root = document.getElementById('app');
if (root) { if (root) {
createRoot(root).render( createRoot(root).render(
+9 -8
View File
@@ -2,6 +2,7 @@ import i18next from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import { LanguageManager } from '@/utils'; import { LanguageManager } from '@/utils';
import type { LanguageScope } from '@/utils';
import enUS from '../../../internal/web/translation/en-US.json'; import enUS from '../../../internal/web/translation/en-US.json';
const FALLBACK = 'en-US'; const FALLBACK = 'en-US';
@@ -15,15 +16,15 @@ function moduleKeyFor(code: string): string {
return `../../../internal/web/translation/${code}.json`; return `../../../internal/web/translation/${code}.json`;
} }
let active: string = LanguageManager.getLanguage(); export async function readyI18n(scope: LanguageScope = 'panel') {
if ( let active = LanguageManager.getLanguage(scope);
active !== FALLBACK && if (
!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active)) active !== FALLBACK &&
) { !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))
active = FALLBACK; ) {
} active = FALLBACK;
}
export async function readyI18n() {
await i18next.use(initReactI18next).init({ await i18next.use(initReactI18next).init({
lng: active, lng: active,
fallbackLng: FALLBACK, fallbackLng: FALLBACK,
+7 -5
View File
@@ -93,11 +93,11 @@ export default function SubPage() {
setMessageInstance(messageApi); setMessageInstance(messageApi);
}, [messageApi]); }, [messageApi]);
const { isMobile } = useMediaQuery(576); const { isMobile } = useMediaQuery(576);
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage()); const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription'));
const onLangChange = useCallback((next: string) => { const onLangChange = useCallback((next: string) => {
setLang(next); setLang(next);
LanguageManager.setLanguage(next); LanguageManager.setLanguage(next, 'subscription');
}, []); }, []);
const cycleTheme = useCallback(() => { const cycleTheme = useCallback(() => {
@@ -186,16 +186,18 @@ export default function SubPage() {
items.push({ items.push({
key: 'lastOnline', key: 'lastOnline',
label: t('lastOnline'), label: t('lastOnline'),
children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker) : '-', children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker, lang) : '-',
}); });
items.push({ items.push({
key: 'expiry', key: 'expiry',
label: t('subscription.expiry'), label: t('subscription.expiry'),
children: children:
expireMs === 0 ? t('subscription.noExpiry') : IntlUtil.formatDate(expireMs, datepicker), expireMs === 0
? t('subscription.noExpiry')
: IntlUtil.formatDate(expireMs, datepicker, lang),
}); });
return items; return items;
}, [t]); }, [t, lang]);
const androidMenuItems = useMemo( const androidMenuItems = useMemo(
() => [ () => [
+52
View File
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('subscription language scope', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('initializes lazily and changes the subscription language without changing the panel', async () => {
vi.resetModules();
const utils = await import('@/utils');
const cookies = new Map<string, string>([['lang', 'en-US']]);
vi.spyOn(utils.CookieManager, 'getCookie').mockImplementation(
(name) => cookies.get(name) ?? '',
);
vi.spyOn(utils.CookieManager, 'setCookie').mockImplementation((name, value) => {
cookies.set(name, value);
});
const getLanguage = vi.spyOn(utils.LanguageManager, 'getLanguage');
const reload = vi.fn();
vi.stubGlobal('window', { navigator: { language: 'en-US' }, location: { reload } });
const { readyI18n } = await import('@/i18n/react');
expect(getLanguage).not.toHaveBeenCalled();
await readyI18n('subscription');
expect(cookies.get('subLang')).toBe('en-US');
utils.LanguageManager.setLanguage('fa-IR', 'subscription');
expect(cookies.get('lang')).toBe('en-US');
expect(cookies.get('subLang')).toBe('fa-IR');
expect(reload).toHaveBeenCalledOnce();
const dateTimeFormat = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(function (
locale?: Intl.LocalesArgument,
) {
return { format: () => String(locale) } as Intl.DateTimeFormat;
} as typeof Intl.DateTimeFormat);
expect(utils.IntlUtil.formatDate(0, 'gregorian', 'fa-IR')).toBe('fa-IR');
expect(dateTimeFormat).toHaveBeenLastCalledWith('fa-IR', expect.any(Object));
});
it('does not resolve the language for empty or invalid dates', async () => {
const utils = await import('@/utils');
const getLanguage = vi.spyOn(utils.LanguageManager, 'getLanguage').mockReturnValue('en-US');
expect(utils.IntlUtil.formatDate(null)).toBe('');
expect(utils.IntlUtil.formatDate(undefined)).toBe('');
expect(utils.IntlUtil.formatDate('not-a-date')).toBe('');
expect(getLanguage).not.toHaveBeenCalled();
});
});
+26 -9
View File
@@ -879,6 +879,13 @@ export interface SupportedLanguage {
icon: string; icon: string;
} }
export type LanguageScope = 'panel' | 'subscription';
const languageCookieNames: Record<LanguageScope, string> = {
panel: 'lang',
subscription: 'subLang',
};
export class LanguageManager { export class LanguageManager {
static readonly supportedLanguages: readonly SupportedLanguage[] = [ static readonly supportedLanguages: readonly SupportedLanguage[] = [
{ name: 'العربية', value: 'ar-EG', icon: '🇪🇬' }, { name: 'العربية', value: 'ar-EG', icon: '🇪🇬' },
@@ -896,10 +903,19 @@ export class LanguageManager {
{ name: 'Português', value: 'pt-BR', icon: '🇧🇷' }, { name: 'Português', value: 'pt-BR', icon: '🇧🇷' },
]; ];
static getLanguage(): string { static getLanguage(scope: LanguageScope = 'panel'): string {
let lang = CookieManager.getCookie('lang'); const cookieName = languageCookieNames[scope];
let lang = CookieManager.getCookie(cookieName);
if (lang) return lang; if (lang) return lang;
if (scope === 'subscription') {
const legacyLang = CookieManager.getCookie(languageCookieNames.panel);
if (LanguageManager.isSupportLanguage(legacyLang)) {
CookieManager.setCookie(cookieName, legacyLang, 365);
return legacyLang;
}
}
if (window.navigator) { if (window.navigator) {
const nav = window.navigator as Navigator & { userLanguage?: string }; const nav = window.navigator as Navigator & { userLanguage?: string };
lang = nav.language || nav.userLanguage || ''; lang = nav.language || nav.userLanguage || '';
@@ -924,24 +940,24 @@ export class LanguageManager {
}); });
if (LanguageManager.isSupportLanguage(lang)) { if (LanguageManager.isSupportLanguage(lang)) {
CookieManager.setCookie('lang', lang, 365); CookieManager.setCookie(cookieName, lang, 365);
} else { } else {
CookieManager.setCookie('lang', 'en-US', 365); CookieManager.setCookie(cookieName, 'en-US', 365);
window.location.reload(); window.location.reload();
} }
} else { } else {
CookieManager.setCookie('lang', 'en-US', 365); CookieManager.setCookie(cookieName, 'en-US', 365);
window.location.reload(); window.location.reload();
} }
return lang; return lang;
} }
static setLanguage(language: string): void { static setLanguage(language: string, scope: LanguageScope = 'panel'): void {
if (!LanguageManager.isSupportLanguage(language)) { if (!LanguageManager.isSupportLanguage(language)) {
language = 'en-US'; language = 'en-US';
} }
CookieManager.setCookie('lang', language, 365); CookieManager.setCookie(languageCookieNames[scope], language, 365);
window.location.reload(); window.location.reload();
} }
@@ -977,12 +993,13 @@ export class IntlUtil {
static formatDate( static formatDate(
date: string | number | Date | null | undefined, date: string | number | Date | null | undefined,
calendar: CalendarKind = 'gregorian', calendar: CalendarKind = 'gregorian',
language?: string,
): string { ): string {
if (date == null) return ''; if (date == null) return '';
const d = new Date(date); const d = new Date(date);
if (!isFinite(d.getTime())) return ''; if (!isFinite(d.getTime())) return '';
const language = LanguageManager.getLanguage(); const resolvedLanguage = language ?? LanguageManager.getLanguage();
const locale = calendar === 'jalalian' ? 'fa-IR' : language; const locale = calendar === 'jalalian' ? 'fa-IR' : resolvedLanguage;
const intlOptions: Intl.DateTimeFormatOptions = { const intlOptions: Intl.DateTimeFormatOptions = {
year: 'numeric', year: 'numeric',