// Central app state + actions. Holds the session/profile, client preferences, a // transient toast, and the latest live event (screens react to it via $effect). All // gateway calls funnel through here so errors map to one user-facing toast and an // expired session logs out. import type { Profile, PushEvent, Session } from './model'; import { gateway } from './gateway'; import { GatewayError } from './client'; import { navigate, router } from './router.svelte'; import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte'; import { applyReduceMotion, applyTheme, type ThemePref } from './theme'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import type { BoardLabelMode } from './boardlabels'; export interface Toast { kind: 'error' | 'info'; text: string; } export const app = $state<{ ready: boolean; session: Session | null; profile: Profile | null; toast: Toast | null; lastEvent: PushEvent | null; theme: ThemePref; locale: Locale; reduceMotion: boolean; boardLabels: BoardLabelMode; localeLocked: boolean; /** Pending incoming friend requests + invitations, for the lobby badge. */ notifications: number; }>({ ready: false, session: null, profile: null, toast: null, lastEvent: null, theme: 'auto', locale: 'en', reduceMotion: false, boardLabels: 'beginner', localeLocked: false, notifications: 0, }); let unsubscribeStream: (() => void) | null = null; let toastTimer: ReturnType | null = null; export function showToast(text: string, kind: Toast['kind'] = 'info'): void { app.toast = { kind, text }; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => (app.toast = null), 4000); } /** handleError maps a GatewayError to a toast; an invalid session logs out. */ export function handleError(err: unknown): void { if (err instanceof GatewayError) { if (err.code === 'session_invalid' || err.code === 'unauthenticated') { void logout(); return; } showToast(t(errorKey(err.code)), 'error'); return; } showToast(t('error.generic'), 'error'); } function openStream(): void { closeStream(); unsubscribeStream = gateway.subscribe( (e) => { app.lastEvent = e; if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) { showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info'); } else if (e.kind === 'nudge') { showToast(t('chat.nudge'), 'info'); } else if (e.kind === 'your_turn') { showToast(t('game.yourTurn'), 'info'); } else if (e.kind === 'match_found') { navigate(`/game/${e.gameId}`); } else if (e.kind === 'notify') { void refreshNotifications(); } }, () => showToast(t('error.unavailable'), 'error'), ); } /** * refreshNotifications recomputes the lobby badge count (incoming friend requests * plus open invitations). Authoritative poll, complementing the live 'notify' push. * Guests have no social surfaces, so it is a no-op for them. */ export async function refreshNotifications(): Promise { if (!app.session || app.profile?.isGuest) { app.notifications = 0; return; } try { const [incoming, invitations] = await Promise.all([ gateway.friendsIncoming(), gateway.invitationsList(), ]); app.notifications = incoming.length + invitations.length; } catch { // Best-effort; leave the previous count on a transient failure. } } function closeStream(): void { unsubscribeStream?.(); unsubscribeStream = null; } async function adoptSession(s: Session): Promise { gateway.setToken(s.token); app.session = s; await saveSession(s); try { app.profile = await gateway.profileGet(); if (!app.localeLocked) setLocale(localeFrom(app.profile.preferredLanguage, app.locale)); } catch (err) { handleError(err); } openStream(); void refreshNotifications(); } export async function bootstrap(): Promise { const prefs = await loadPrefs(); app.theme = prefs.theme ?? 'auto'; app.reduceMotion = prefs.reduceMotion ?? false; app.boardLabels = prefs.boardLabels ?? 'beginner'; applyTheme(app.theme); applyReduceMotion(app.reduceMotion); if (prefs.locale) { app.locale = prefs.locale; app.localeLocked = true; setLocale(prefs.locale); } else { const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'); app.locale = guess; setLocale(guess); } const saved = await loadSession(); if (saved) { await adoptSession(saved); if (router.route.name === 'login') navigate('/'); } else if (router.route.name !== 'login') { navigate('/login'); } app.ready = true; } export async function loginGuest(): Promise { try { const s = await gateway.authGuest(app.locale); await adoptSession(s); navigate('/'); } catch (err) { handleError(err); } } export async function requestEmailCode(email: string): Promise { try { await gateway.authEmailRequest(email); return true; } catch (err) { handleError(err); return false; } } export async function loginEmail(email: string, code: string): Promise { try { const s = await gateway.authEmailLogin(email, code); await adoptSession(s); navigate('/'); } catch (err) { handleError(err); } } export async function logout(): Promise { closeStream(); gateway.setToken(null); await clearSession(); app.session = null; app.profile = null; navigate('/login'); } function persistPrefs(): void { void savePrefs({ theme: app.theme, locale: app.locale, reduceMotion: app.reduceMotion, boardLabels: app.boardLabels, }); } export function setTheme(theme: ThemePref): void { app.theme = theme; applyTheme(theme); persistPrefs(); } export function setLocalePref(locale: Locale): void { app.locale = locale; app.localeLocked = true; setLocale(locale); persistPrefs(); void persistLanguageToServer(locale); } /** * persistLanguageToServer writes the chosen interface language through to the * durable account's preferred_language, so the single Settings control is the * source of truth (guests keep only the client preference). Best-effort. */ async function persistLanguageToServer(locale: Locale): Promise { const p = app.profile; if (!p || p.isGuest || p.preferredLanguage === locale) return; try { app.profile = await gateway.profileUpdate({ displayName: p.displayName, preferredLanguage: locale, timeZone: p.timeZone, awayStart: p.awayStart, awayEnd: p.awayEnd, blockChat: p.blockChat, blockFriendRequests: p.blockFriendRequests, }); } catch { // The client locale already changed; the server sync is best-effort. } } export function setReduceMotion(on: boolean): void { app.reduceMotion = on; applyReduceMotion(on); persistPrefs(); } export function setBoardLabels(mode: BoardLabelMode): void { app.boardLabels = mode; persistPrefs(); } // Refresh the lobby badge when the app returns to the foreground — a push 'notify' // may have been missed while the client was hidden/closed (poll + push, see §10). if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && app.session) void refreshNotifications(); }); }