// 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 { LinkResult, 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, applyTelegramTheme, applyTheme, type ThemePref } from './theme'; import { insideTelegram, onTelegramPath, telegramColorScheme, telegramDisableVerticalSwipes, telegramHaptic, telegramLaunch, telegramOnEvent, telegramSetChrome, } from './telegram'; import { parseStartParam } from './deeplink'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { clearGameCache } from './gamecache'; import { clearLobby } from './lobbycache'; 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 streamAlive = false; let reconnectTimer: ReturnType | null = null; let toastTimer: ReturnType | null = null; // Background/foreground tracking, to silence the reconnect banner during a normal app // suspend (iOS lock / home, Telegram tab switch) and reconnect quietly on return. let backgrounded = false; let foregroundedAt = 0; const reconnectGraceMs = 4000; /** documentHidden reports whether the page is currently hidden. */ function documentHidden(): boolean { return typeof document !== 'undefined' && document.visibilityState === 'hidden'; } /** * bannerSuppressed reports whether the connection banner should stay hidden: while * backgrounded, and for a short grace after returning to the foreground — a connection * dropped while suspended surfaces its error on resume, before the silent reconnect lands. */ function bannerSuppressed(): boolean { return backgrounded || documentHidden() || Date.now() - foregroundedAt < reconnectGraceMs; } function goBackground(): void { backgrounded = true; } function goForeground(): void { backgrounded = false; foregroundedAt = Date.now(); if (!app.session) return; if (!streamAlive) openStream(); // silently re-establish a stream dropped while away void refreshNotifications(); } 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 { telegramHaptic('error'); 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(); streamAlive = true; 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(); } }, () => { streamAlive = false; // A background suspend drops the single-shot stream. Keep the banner hidden while // backgrounded or just-resumed (bannerSuppressed); always schedule a retry. if (!bannerSuppressed()) showToast(t('error.unavailable'), 'error'); scheduleReconnect(); }, ); } /** scheduleReconnect reopens a dropped stream once, after a short delay, while the * app is in the foreground (a single pending attempt at a time). */ function scheduleReconnect(): void { if (reconnectTimer || !app.session) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; if (app.session && !streamAlive && !backgrounded && !documentHidden()) openStream(); }, 4000); } /** * 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 { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } unsubscribeStream?.(); unsubscribeStream = null; streamAlive = false; } 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(); } /** * applyLinkResult applies a completed account link or merge (Stage 11): it adopts a * switched session (a guest initiator whose durable counterpart won, so the active * account changed) or, otherwise, refreshes the current profile in place. */ export async function applyLinkResult(r: LinkResult): Promise { if (r.session && r.session.token) { await adoptSession(r.session); return; } app.profile = await gateway.profileGet(); } /** * syncTelegramChrome paints Telegram's header/background/bottom bar from the app's live * theme tokens, so the surrounding chrome matches the UI. Called after the theme is applied. */ function syncTelegramChrome(): void { if (typeof document === 'undefined') return; const cs = getComputedStyle(document.documentElement); telegramSetChrome( cs.getPropertyValue('--bg-elev').trim(), cs.getPropertyValue('--bg').trim(), cs.getPropertyValue('--bg-elev').trim(), ); } 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); } // Telegram Mini App launch: apply the platform theme, authenticate via initData, // and route any deep-link start parameter. On the dedicated /telegram/ entry path // outside Telegram (no initData), refuse to render and send the visitor to the // site root. if (onTelegramPath() && !insideTelegram()) { if (typeof location !== 'undefined') location.replace('/'); return; } if (insideTelegram()) { const launch = telegramLaunch(); if (launch.theme) applyTelegramTheme(launch.theme); // Inside Telegram the colour scheme is Telegram's to decide; force it explicitly // so the OS prefers-color-scheme (which leaks into the Telegram Desktop webview) // cannot fight it. Falls back to the stored preference when the SDK omits it. applyTheme(telegramColorScheme() ?? app.theme); // Match Telegram's chrome to the app and stop its swipe-down-to-minimise from // fighting tile drag / board scroll. syncTelegramChrome(); telegramDisableVerticalSwipes(); try { await adoptSession(await gateway.authTelegram(launch.initData)); await routeStartParam(launch.startParam); } catch (err) { handleError(err); navigate('/login'); } app.ready = true; return; } 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; } /** * routeStartParam navigates a Telegram deep-link start parameter to its target: a * specific game, the friends screen with a friend-code redemption, or the lobby * (where invitations surface as a badge). */ async function routeStartParam(param: string): Promise { const link = parseStartParam(param); switch (link.kind) { case 'game': navigate(`/game/${link.id}`); return; case 'friendCode': navigate('/friends'); try { const friend = await gateway.friendCodeRedeem(link.code); showToast(t('friends.added', { name: friend.displayName })); void refreshNotifications(); } catch (err) { handleError(err); } return; default: navigate('/'); } } 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(); clearGameCache(); clearLobby(); 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, notificationsInAppOnly: p.notificationsInAppOnly, }); } 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(); } // Background/foreground lifecycle: silence the reconnect banner during a suspend and // reconnect quietly on return (and refresh the lobby badge for any push missed while // hidden, §10). Several signals cover the platforms: the page Visibility API, the // pageshow/pagehide pair (iOS), and Telegram's own activated/deactivated (Bot API 8.0). if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => document.visibilityState === 'visible' ? goForeground() : goBackground(), ); } if (typeof window !== 'undefined') { window.addEventListener('pageshow', goForeground); window.addEventListener('pagehide', goBackground); } telegramOnEvent('activated', goForeground); telegramOnEvent('deactivated', goBackground);