mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-04 01:17: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 });
|
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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
() => [
|
() => [
|
||||||
|
|||||||
@@ -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;
|
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',
|
||||||
|
|||||||
Reference in New Issue
Block a user