fix(frontend): propagate locale changes reactively to mounted shells
All checks were successful
CI / test-and-quality (push) Successful in 2m44s
All checks were successful
CI / test-and-quality (push) Successful in 2m44s
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
import { createApiClient } from '../../../../../src/api/client';
|
import { createApiClient } from '../../../../../src/api/client';
|
||||||
import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types';
|
import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types';
|
||||||
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
||||||
import { clientHasNoAudioOutput, resolvePreferredLocale, t } from '../../lobby-i18n';
|
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
|
||||||
|
|
||||||
interface SessionDetail {
|
interface SessionDetail {
|
||||||
session: { code: string; status: string; current_round: number };
|
session: { code: string; status: string; current_round: number };
|
||||||
@@ -64,7 +64,7 @@ type LeaderboardResponse = FinishGameResponse;
|
|||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class HostShellComponent implements OnInit {
|
export class HostShellComponent implements OnInit, OnDestroy {
|
||||||
locale = resolvePreferredLocale();
|
locale = resolvePreferredLocale();
|
||||||
readonly clientHasNoAudioOutput = clientHasNoAudioOutput;
|
readonly clientHasNoAudioOutput = clientHasNoAudioOutput;
|
||||||
|
|
||||||
@@ -84,8 +84,12 @@ export class HostShellComponent implements OnInit {
|
|||||||
|
|
||||||
private readonly api = createApiClient();
|
private readonly api = createApiClient();
|
||||||
private readonly controller = createVerticalSliceController(this.api);
|
private readonly controller = createVerticalSliceController(this.api);
|
||||||
|
private unsubscribeLocale: (() => void) | null = null;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
|
||||||
|
this.locale = locale;
|
||||||
|
});
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -105,6 +109,11 @@ export class HostShellComponent implements OnInit {
|
|||||||
void this.refreshSession();
|
void this.refreshSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.unsubscribeLocale?.();
|
||||||
|
this.unsubscribeLocale = null;
|
||||||
|
}
|
||||||
|
|
||||||
copy(key: string): string {
|
copy(key: string): string {
|
||||||
return t(key, this.locale);
|
return t(key, this.locale);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { createApiClient } from '../../../../../src/api/client';
|
import { createApiClient } from '../../../../../src/api/client';
|
||||||
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
|
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
|
||||||
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
||||||
import { clientHasNoAudioOutput, resolvePreferredLocale, t } from '../../lobby-i18n';
|
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
|
||||||
|
|
||||||
interface SessionDetail {
|
interface SessionDetail {
|
||||||
session: { code: string; status: string; current_round: number };
|
session: { code: string; status: string; current_round: number };
|
||||||
@@ -112,6 +112,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
|
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private stateSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
private stateSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private unsubscribeLocale: (() => void) | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||||
@@ -125,6 +126,10 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
|
||||||
|
this.locale = locale;
|
||||||
|
});
|
||||||
|
|
||||||
const hashRoute = window.location.hash.replace(/^#\/?/, '');
|
const hashRoute = window.location.hash.replace(/^#\/?/, '');
|
||||||
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
|
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
|
||||||
const codeFromRoute = match?.[1] ?? '';
|
const codeFromRoute = match?.[1] ?? '';
|
||||||
@@ -151,6 +156,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
this.clearReconnectTimer();
|
this.clearReconnectTimer();
|
||||||
this.clearStateSyncTimer();
|
this.clearStateSyncTimer();
|
||||||
|
this.unsubscribeLocale?.();
|
||||||
|
this.unsubscribeLocale = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly handleOnline = (): void => {
|
private readonly handleOnline = (): void => {
|
||||||
|
|||||||
47
frontend/angular/src/app/lobby-i18n.spec.ts
Normal file
47
frontend/angular/src/app/lobby-i18n.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
type StorageLike = {
|
||||||
|
getItem: (key: string) => string | null;
|
||||||
|
setItem: (key: string, value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function storageMock(initial: Record<string, string> = {}): StorageLike {
|
||||||
|
const data = new Map<string, string>(Object.entries(initial));
|
||||||
|
return {
|
||||||
|
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => {
|
||||||
|
data.set(key, value);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('lobby i18n locale propagation', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies subscribers immediately and on locale changes', async () => {
|
||||||
|
const localStorage = storageMock({ 'wpp.locale': 'en' });
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
location: { search: '' },
|
||||||
|
localStorage,
|
||||||
|
});
|
||||||
|
vi.stubGlobal('navigator', { language: 'en-US' });
|
||||||
|
|
||||||
|
const i18n = await import('./lobby-i18n');
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const unsubscribe = i18n.subscribeToLocaleChanges((locale) => updates.push(locale));
|
||||||
|
|
||||||
|
expect(updates).toEqual(['en']);
|
||||||
|
|
||||||
|
i18n.setPreferredLocale('da');
|
||||||
|
expect(updates).toEqual(['en', 'da']);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
i18n.setPreferredLocale('en');
|
||||||
|
expect(updates).toEqual(['en', 'da']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,9 @@ type SupportedLocale = (typeof lobbyCatalog.locales.supported)[number];
|
|||||||
const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale;
|
const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale;
|
||||||
const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[];
|
const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[];
|
||||||
|
|
||||||
|
let activeLocale: SupportedLocale | null = null;
|
||||||
|
const localeSubscribers = new Set<(locale: SupportedLocale) => void>();
|
||||||
|
|
||||||
export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
|
export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
|
||||||
const locale = (rawLocale ?? '').trim().toLowerCase();
|
const locale = (rawLocale ?? '').trim().toLowerCase();
|
||||||
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
|
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
|
||||||
@@ -20,25 +23,45 @@ export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePreferredLocale(): SupportedLocale {
|
export function resolvePreferredLocale(): SupportedLocale {
|
||||||
|
if (activeLocale) {
|
||||||
|
return activeLocale;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return DEFAULT_LOCALE;
|
activeLocale = DEFAULT_LOCALE;
|
||||||
|
return activeLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang');
|
const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang');
|
||||||
const storedLocale = window.localStorage?.getItem?.('wpp.locale');
|
const storedLocale = window.localStorage?.getItem?.('wpp.locale');
|
||||||
const browserLocale = typeof navigator !== 'undefined' ? navigator.language : '';
|
const browserLocale = typeof navigator !== 'undefined' ? navigator.language : '';
|
||||||
|
|
||||||
return normalizeLocale(queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE);
|
activeLocale = normalizeLocale(queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE);
|
||||||
|
return activeLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setPreferredLocale(locale: string): SupportedLocale {
|
export function setPreferredLocale(locale: string): SupportedLocale {
|
||||||
const normalized = normalizeLocale(locale);
|
const normalized = normalizeLocale(locale);
|
||||||
|
activeLocale = normalized;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage?.setItem?.('wpp.locale', normalized);
|
window.localStorage?.setItem?.('wpp.locale', normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const subscriber of localeSubscribers) {
|
||||||
|
subscriber(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function subscribeToLocaleChanges(callback: (locale: SupportedLocale) => void): () => void {
|
||||||
|
localeSubscribers.add(callback);
|
||||||
|
callback(resolvePreferredLocale());
|
||||||
|
return () => {
|
||||||
|
localeSubscribers.delete(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function t(key: string, locale: string): string {
|
export function t(key: string, locale: string): string {
|
||||||
const normalizedLocale = normalizeLocale(locale);
|
const normalizedLocale = normalizeLocale(locale);
|
||||||
const fallbackLocale = DEFAULT_LOCALE;
|
const fallbackLocale = DEFAULT_LOCALE;
|
||||||
|
|||||||
Reference in New Issue
Block a user