// 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, telegramContentSafeAreaTop, telegramSafeAreaTop, telegramDisableVerticalSwipes, telegramHaptic, telegramLaunch, telegramOnEvent, telegramSetChrome, } from './telegram'; import { parseStartParam } from './deeplink'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte'; import { isConnectionCode } from './retry'; import { clearGameCache, setCachedGame } 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; /** Whether the live-event stream is connected; drives the matchmaking poll fallback. */ streamAlive: boolean; session: Session | null; profile: Profile | null; toast: Toast | null; lastEvent: PushEvent | null; theme: ThemePref; locale: Locale; reduceMotion: boolean; boardLabels: BoardLabelMode; /** Draw grid lines between board cells; off (default) is a gapless checkerboard. */ boardLines: boolean; localeLocked: boolean; /** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */ notifications: number; /** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */ chatUnread: Record; }>({ ready: false, streamAlive: false, session: null, profile: null, toast: null, lastEvent: null, theme: 'auto', locale: 'en', reduceMotion: false, boardLabels: 'beginner', boardLines: false, localeLocked: false, notifications: 0, chatUnread: {}, }); let unsubscribeStream: (() => void) | null = null; 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 (!app.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); } /** clearChatUnread resets a game's unread chat-message count (called when its chat is opened). */ export function clearChatUnread(gameId: string): void { if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 }; } /** handleError maps a GatewayError to a toast; an invalid session logs out. A connectivity * failure — or anything raised while the app is mid-reconnect — is shown by the "Connecting…" * header indicator (and auto-retried), never a red toast. */ export function handleError(err: unknown): void { const code = err instanceof GatewayError ? err.code : ''; if (code === 'session_invalid' || code === 'unauthenticated') { void logout(); return; } if (isConnectionCode(code) || !connection.online) return; telegramHaptic('error'); showToast(t(code ? errorKey(code) : 'error.generic'), 'error'); } function openStream(): void { closeStream(); app.streamAlive = true; unsubscribeStream = gateway.subscribe( (e) => { reportOnline(); // a delivered event proves the gateway is reachable app.lastEvent = e; if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) { // While the player is in that game's comms hub (chat or dictionary tab), neither // toast nor bump the unread — the chat is a tap away and reloads on open. const inComms = (router.route.name === 'gameChat' || router.route.name === 'gameCheck') && router.route.params.id === e.message.gameId; if (!inComms) { if (e.message.kind !== 'nudge') { const gid = e.message.gameId; app.chatUnread = { ...app.chatUnread, [gid]: (app.chatUnread[gid] ?? 0) + 1 }; } 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') { // Seed the cache from the event's initial state so the game renders instantly on arrival, // then navigate. if (e.state) setCachedGame(e.state.game.id, e.state, []); navigate(`/game/${e.gameId}`); } else if (e.kind === 'notify') { // A started invited game seeds its cache so opening it is instant; the lobby badge stays // on the authoritative refresh. if (e.sub === 'game_started' && e.state) setCachedGame(e.state.game.id, e.state, []); void refreshNotifications(); } }, () => { app.streamAlive = false; // A background suspend drops the single-shot stream. Keep the indicator hidden while // backgrounded or just-resumed (bannerSuppressed); otherwise show "Connecting…" (the // reachability watcher and this scheduled retry recover it). Always schedule a retry. if (!bannerSuppressed()) reportOffline(); 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 && !app.streamAlive && !backgrounded && !documentHidden()) openStream(); }, 4000); } /** * refreshNotifications recomputes the badge count (incoming friend requests). * Authoritative poll, complementing the live 'notify' push. Game invitations have * their own lobby section, so they are not counted here. 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 { app.notifications = (await gateway.friendsIncoming()).length; } catch { // Best-effort; leave the previous count on a transient failure. } } function closeStream(): void { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } unsubscribeStream?.(); unsubscribeStream = null; app.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: 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(), ); } /** * syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native * nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a * `tg-fullscreen` class, so the header can drop below the nav and centre the title in its * band. Called on launch and on Telegram's safe-area / fullscreen change events. */ function syncTelegramSafeArea(): void { if (typeof document === 'undefined') return; const top = telegramContentSafeAreaTop(); document.documentElement.style.setProperty('--tg-content-top', `${top}px`); document.documentElement.style.setProperty('--tg-safe-top', `${telegramSafeAreaTop()}px`); document.documentElement.classList.toggle('tg-fullscreen', top > 0); } /** * syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can * fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard). * On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible * without the page scrolling, so the layout no longer jumps when the keyboard appears. */ function syncViewportHeight(): void { if (typeof document === 'undefined') return; const vv = typeof window !== 'undefined' ? window.visualViewport : null; const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0; if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`); } export async function bootstrap(): Promise { const prefs = await loadPrefs(); app.theme = prefs.theme ?? 'auto'; app.reduceMotion = prefs.reduceMotion ?? false; app.boardLabels = prefs.boardLabels ?? 'beginner'; app.boardLines = prefs.boardLines ?? false; 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); } // Track the visual-viewport height so screens fit above an open soft keyboard (--vvh). syncViewportHeight(); if (typeof window !== 'undefined' && window.visualViewport) { window.visualViewport.addEventListener('resize', syncViewportHeight); window.visualViewport.addEventListener('scroll', syncViewportHeight); } // 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(); syncTelegramSafeArea(); telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea); telegramOnEvent('safeAreaChanged', syncTelegramSafeArea); telegramOnEvent('fullscreenChanged', syncTelegramSafeArea); 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(); resetConnection(); 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, boardLines: app.boardLines, }); } 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(); } export function setBoardLines(on: boolean): void { app.boardLines = on; 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);