mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-03 17:07:15 +00:00
fix(frontend): isolate subscription language preference (#6394)
* fix(frontend): isolate subscription language preference * fix(frontend): defer date locale resolution
This commit is contained in:
@@ -12,7 +12,7 @@ if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
readyI18n().then(() => {
|
||||
readyI18n('subscription').then(() => {
|
||||
const root = document.getElementById('app');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
|
||||
@@ -2,6 +2,7 @@ import i18next from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import { LanguageManager } from '@/utils';
|
||||
import type { LanguageScope } from '@/utils';
|
||||
import enUS from '../../../internal/web/translation/en-US.json';
|
||||
|
||||
const FALLBACK = 'en-US';
|
||||
@@ -15,15 +16,15 @@ function moduleKeyFor(code: string): string {
|
||||
return `../../../internal/web/translation/${code}.json`;
|
||||
}
|
||||
|
||||
let active: string = LanguageManager.getLanguage();
|
||||
if (
|
||||
active !== FALLBACK &&
|
||||
!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))
|
||||
) {
|
||||
active = FALLBACK;
|
||||
}
|
||||
export async function readyI18n(scope: LanguageScope = 'panel') {
|
||||
let active = LanguageManager.getLanguage(scope);
|
||||
if (
|
||||
active !== FALLBACK &&
|
||||
!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))
|
||||
) {
|
||||
active = FALLBACK;
|
||||
}
|
||||
|
||||
export async function readyI18n() {
|
||||
await i18next.use(initReactI18next).init({
|
||||
lng: active,
|
||||
fallbackLng: FALLBACK,
|
||||
|
||||
@@ -93,11 +93,11 @@ export default function SubPage() {
|
||||
setMessageInstance(messageApi);
|
||||
}, [messageApi]);
|
||||
const { isMobile } = useMediaQuery(576);
|
||||
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
|
||||
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription'));
|
||||
|
||||
const onLangChange = useCallback((next: string) => {
|
||||
setLang(next);
|
||||
LanguageManager.setLanguage(next);
|
||||
LanguageManager.setLanguage(next, 'subscription');
|
||||
}, []);
|
||||
|
||||
const cycleTheme = useCallback(() => {
|
||||
@@ -186,16 +186,18 @@ export default function SubPage() {
|
||||
items.push({
|
||||
key: 'lastOnline',
|
||||
label: t('lastOnline'),
|
||||
children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker) : '-',
|
||||
children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker, lang) : '-',
|
||||
});
|
||||
items.push({
|
||||
key: 'expiry',
|
||||
label: t('subscription.expiry'),
|
||||
children:
|
||||
expireMs === 0 ? t('subscription.noExpiry') : IntlUtil.formatDate(expireMs, datepicker),
|
||||
expireMs === 0
|
||||
? t('subscription.noExpiry')
|
||||
: IntlUtil.formatDate(expireMs, datepicker, lang),
|
||||
});
|
||||
return items;
|
||||
}, [t]);
|
||||
}, [t, lang]);
|
||||
|
||||
const androidMenuItems = useMemo(
|
||||
() => [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -879,6 +879,13 @@ export interface SupportedLanguage {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export type LanguageScope = 'panel' | 'subscription';
|
||||
|
||||
const languageCookieNames: Record<LanguageScope, string> = {
|
||||
panel: 'lang',
|
||||
subscription: 'subLang',
|
||||
};
|
||||
|
||||
export class LanguageManager {
|
||||
static readonly supportedLanguages: readonly SupportedLanguage[] = [
|
||||
{ name: 'العربية', value: 'ar-EG', icon: '🇪🇬' },
|
||||
@@ -896,10 +903,19 @@ export class LanguageManager {
|
||||
{ name: 'Português', value: 'pt-BR', icon: '🇧🇷' },
|
||||
];
|
||||
|
||||
static getLanguage(): string {
|
||||
let lang = CookieManager.getCookie('lang');
|
||||
static getLanguage(scope: LanguageScope = 'panel'): string {
|
||||
const cookieName = languageCookieNames[scope];
|
||||
let lang = CookieManager.getCookie(cookieName);
|
||||
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) {
|
||||
const nav = window.navigator as Navigator & { userLanguage?: string };
|
||||
lang = nav.language || nav.userLanguage || '';
|
||||
@@ -924,24 +940,24 @@ export class LanguageManager {
|
||||
});
|
||||
|
||||
if (LanguageManager.isSupportLanguage(lang)) {
|
||||
CookieManager.setCookie('lang', lang, 365);
|
||||
CookieManager.setCookie(cookieName, lang, 365);
|
||||
} else {
|
||||
CookieManager.setCookie('lang', 'en-US', 365);
|
||||
CookieManager.setCookie(cookieName, 'en-US', 365);
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
CookieManager.setCookie('lang', 'en-US', 365);
|
||||
CookieManager.setCookie(cookieName, 'en-US', 365);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return lang;
|
||||
}
|
||||
|
||||
static setLanguage(language: string): void {
|
||||
static setLanguage(language: string, scope: LanguageScope = 'panel'): void {
|
||||
if (!LanguageManager.isSupportLanguage(language)) {
|
||||
language = 'en-US';
|
||||
}
|
||||
CookieManager.setCookie('lang', language, 365);
|
||||
CookieManager.setCookie(languageCookieNames[scope], language, 365);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
@@ -977,12 +993,13 @@ export class IntlUtil {
|
||||
static formatDate(
|
||||
date: string | number | Date | null | undefined,
|
||||
calendar: CalendarKind = 'gregorian',
|
||||
language?: string,
|
||||
): string {
|
||||
if (date == null) return '';
|
||||
const d = new Date(date);
|
||||
if (!isFinite(d.getTime())) return '';
|
||||
const language = LanguageManager.getLanguage();
|
||||
const locale = calendar === 'jalalian' ? 'fa-IR' : language;
|
||||
const resolvedLanguage = language ?? LanguageManager.getLanguage();
|
||||
const locale = calendar === 'jalalian' ? 'fa-IR' : resolvedLanguage;
|
||||
|
||||
const intlOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
|
||||
Reference in New Issue
Block a user