diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 48a1a9b..f9d8c72 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -1,11 +1,11 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; -import { clientHasNoAudioOutput, resolvePreferredLocale, t } from '../../lobby-i18n'; +import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; interface SessionDetail { session: { code: string; status: string; current_round: number }; @@ -64,7 +64,7 @@ type LeaderboardResponse = FinishGameResponse; `, }) -export class HostShellComponent implements OnInit { +export class HostShellComponent implements OnInit, OnDestroy { locale = resolvePreferredLocale(); readonly clientHasNoAudioOutput = clientHasNoAudioOutput; @@ -84,8 +84,12 @@ export class HostShellComponent implements OnInit { private readonly api = createApiClient(); private readonly controller = createVerticalSliceController(this.api); + private unsubscribeLocale: (() => void) | null = null; ngOnInit(): void { + this.unsubscribeLocale = subscribeToLocaleChanges((locale) => { + this.locale = locale; + }); if (typeof window === 'undefined') { return; } @@ -105,6 +109,11 @@ export class HostShellComponent implements OnInit { void this.refreshSession(); } + ngOnDestroy(): void { + this.unsubscribeLocale?.(); + this.unsubscribeLocale = null; + } + copy(key: string): string { return t(key, this.locale); } diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index d768eab..3d4a099 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -5,7 +5,7 @@ import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; import { createSessionContextStore } from '../../../../../src/spa/session-context-store'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; -import { clientHasNoAudioOutput, resolvePreferredLocale, t } from '../../lobby-i18n'; +import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; interface SessionDetail { 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 reconnectTimer: ReturnType | null = null; private stateSyncTimer: ReturnType | null = null; + private unsubscribeLocale: (() => void) | null = null; constructor() { if (typeof navigator !== 'undefined' && !navigator.onLine) { @@ -125,6 +126,10 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } ngOnInit(): void { + this.unsubscribeLocale = subscribeToLocaleChanges((locale) => { + this.locale = locale; + }); + const hashRoute = window.location.hash.replace(/^#\/?/, ''); const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i); const codeFromRoute = match?.[1] ?? ''; @@ -151,6 +156,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } this.clearReconnectTimer(); this.clearStateSyncTimer(); + this.unsubscribeLocale?.(); + this.unsubscribeLocale = null; } private readonly handleOnline = (): void => { diff --git a/frontend/angular/src/app/lobby-i18n.spec.ts b/frontend/angular/src/app/lobby-i18n.spec.ts new file mode 100644 index 0000000..8ba00b2 --- /dev/null +++ b/frontend/angular/src/app/lobby-i18n.spec.ts @@ -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 = {}): StorageLike { + const data = new Map(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']); + }); +}); diff --git a/frontend/angular/src/app/lobby-i18n.ts b/frontend/angular/src/app/lobby-i18n.ts index 56b8d50..1637560 100644 --- a/frontend/angular/src/app/lobby-i18n.ts +++ b/frontend/angular/src/app/lobby-i18n.ts @@ -5,6 +5,9 @@ type SupportedLocale = (typeof lobbyCatalog.locales.supported)[number]; const DEFAULT_LOCALE = lobbyCatalog.locales.default as 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 { const locale = (rawLocale ?? '').trim().toLowerCase(); if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) { @@ -20,25 +23,45 @@ export function normalizeLocale(rawLocale?: string | null): SupportedLocale { } export function resolvePreferredLocale(): SupportedLocale { + if (activeLocale) { + return activeLocale; + } + if (typeof window === 'undefined') { - return DEFAULT_LOCALE; + activeLocale = DEFAULT_LOCALE; + return activeLocale; } const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang'); const storedLocale = window.localStorage?.getItem?.('wpp.locale'); 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 { const normalized = normalizeLocale(locale); + activeLocale = normalized; if (typeof window !== 'undefined') { window.localStorage?.setItem?.('wpp.locale', normalized); } + + for (const subscriber of localeSubscribers) { + subscriber(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 { const normalizedLocale = normalizeLocale(locale); const fallbackLocale = DEFAULT_LOCALE;