// 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 { BlockStatus, 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 { languageNeedsServerSync } from './language'; import { startLocalEvalMetrics } from './localeval-metrics'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme'; import { insideTelegram, telegramClose, collectTelegramDiag, type TelegramDiag, onTelegramPath, hasLaunchFragment, loadTelegramSDK, telegramColorScheme, telegramThemeParams, telegramContentSafeAreaTop, telegramSafeAreaInset, telegramDisableVerticalSwipes, telegramShowSettingsButton, telegramLaunch, type TelegramLaunch, telegramOnEvent, telegramSetChrome, telegramCloudAvailable, telegramCloudGet, telegramCloudSet, } from './telegram'; import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk'; import { pendingVKLink, type VKLinkCallback } from './vkid'; import { isStandalone } from './pwa'; import { registerServiceWorker } from './pwa.svelte'; import { haptic } from './haptics'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { parseStartParam } from './deeplink'; import { clearOnboarding, clearSession, clearProfile, loadProfile, saveProfile, loadOnboarding, loadPrefs, loadSession, type OnboardingState, saveOnboarding, saveSession, savePrefs, } from './session'; import type { CoachSeries } from './coachmark'; import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte'; import { offlineMode, setOfflineMode } from './offline.svelte'; import { shouldBootOffline } from './offline'; import { isConnectionCode } from './retry'; import { clearGameCache, getCachedGame, setCachedGame } from './gamecache'; import { advanceCached } from './gamedelta'; import { clearLobby, patchLobbyGame, patchLobbyInvitation } from './lobbycache'; import type { BoardLabelMode } from './boardlabels'; export interface Toast { kind: 'error' | 'info'; text: string; /** A monotonic id bumped on every showToast, so the Toast component re-keys and replays its * entrance — the freshest toast cancels the previous one and starts its own appear cycle. */ seq: number; } export const app = $state<{ ready: boolean; /** Inside a Mini App, set when the launch failed to authenticate after its retries (e.g. the * backend was down during a deploy). App.svelte then renders the boot-error retry screen * instead of the web login — a Mini App has no manual sign-in to fall back to. */ bootError: boolean; /** True while the cold-start "no connection — go offline?" dialog is up (the reachability check * timed out with the network interface reportedly online). App.svelte renders it over the splash; * bootstrap awaits the choice via resolveOfflinePrompt. */ offlinePrompt: boolean; /** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App * launch carried no sign-in data (empty initData). App.svelte then renders the compact * launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram * delivered no initData (seen on some Android clients) — instead of bouncing to the landing. */ launchError: TelegramDiag | null; /** Whether the hidden on-device debug panel (components/DebugPanel) is open — toggled by tapping * the header title ten times in quick succession. A support aid; carries no secrets. */ debugOpen: boolean; /** Whether the lobby's first cold load has settled (success or error). The loading splash * (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */ lobbyReady: boolean; /** Whether the loading splash has finished and removed itself. Gates the App-level overlay * so a warm intra-session return to the lobby shows no splash again. */ splashDone: boolean; /** Whether the live-event stream is connected. The event hub is best-effort and never replays a * missed event, so an open game waiting for an opponent uses this to recover: it polls the game * state while the stream is down and refetches once on reconnect (see game/Game.svelte). */ streamAlive: boolean; session: Session | null; profile: Profile | null; /** The caller's active manual block, or null. When set, App.svelte replaces every screen with * the terminal blocked screen and all push/poll is stopped. */ blocked: BlockStatus | null; /** Set once the account has been deleted: App.svelte shows the terminal "account deleted" * screen and all push/poll is stopped. Reopening the app just creates a fresh account. */ accountDeleted: boolean; /** A VK ID web-link authorization callback captured on boot (see lib/vkid): the browser * returned from VK with an auth code. Profile consumes it on mount to finish the link or * merge (a full-page redirect loses the route, so boot routes here). Null on a normal load. */ vkLinkPending: VKLinkCallback | null; toast: Toast | null; lastEvent: PushEvent | null; theme: ThemePref; locale: Locale; reduceMotion: boolean; boardLabels: BoardLabelMode; /** Auto-zoom the board toward a tile when it is placed (touch/mobile only; the wide desktop and * landscape layouts already fit the whole board and never auto-zoom). On by default. */ zoomBoard: boolean; /** Completion flags for the two first-run coachmark series (components/Coachmark.svelte). */ onboarding: OnboardingState; /** Whether a first-run coachmark overlay is currently on screen. While set, the scrolling promo * banner (components/PromoBanner) hides so it does not run above the dimmed onboarding layer, * and reappears (per its own show logic) once onboarding closes. */ coachActive: boolean; /** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */ notifications: number; /** Per-game flag: the player has at least one unread chat entry (message or nudge) in that * game, for the lobby and in-game unread dot. Seeded from the authoritative REST views and * raised by live chat/nudge events; cleared (with a backend ack) on opening the history/chat. */ chatUnread: Record; /** Per-game flag: at least one unread entry is a real message (not just a nudge), so the dot * can be coloured apart from the nudge-only case. Same lifecycle as chatUnread; nudge events * raise only chatUnread, message events raise both. */ messageUnread: Record; /** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined * with friend requests) and the Settings → Info badge. */ feedbackReplyUnread: boolean; /** A monotone counter bumped when a payment-intake push signals the wallet changed; an open * Wallet screen watches it and re-fetches in place. */ walletRefresh: number; /** Whether to show the "outdated invite link" notice: set when a Telegram deep-link friend * code is already used/expired, so the visitor lands in the lobby with a gentle pointer to * the bot instead of a scary error on the Friends screen. */ staleInvite: boolean; /** Whether to show the Telegram "welcome" window: set when a deep-link friend code is * redeemed successfully inside Telegram, greeting the arriving player and pointing them at * the bot for the most convenient way to play. */ welcomeRedeem: boolean; /** Monotonic counter bumped when the app returns to the foreground without the live stream * having dropped. An open game watches it to refetch once, recovering an in-game event shed * from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */ resync: number; }>({ ready: false, bootError: false, offlinePrompt: false, launchError: null, debugOpen: false, lobbyReady: false, splashDone: false, streamAlive: false, session: null, profile: null, blocked: null, accountDeleted: false, vkLinkPending: null, toast: null, lastEvent: null, theme: 'auto', locale: 'en', reduceMotion: false, boardLabels: 'beginner', zoomBoard: true, onboarding: { lobbyDone: false, gameDone: false }, coachActive: false, notifications: 0, chatUnread: {}, messageUnread: {}, feedbackReplyUnread: false, walletRefresh: 0, staleInvite: false, welcomeRedeem: false, resync: 0, }); let unsubscribeStream: (() => void) | null = null; let reconnectTimer: ReturnType | null = null; let toastTimer: ReturnType | null = null; let toastSeq = 0; // 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(); // re-establish a stream dropped while away (its reconnect refetch then runs) } else { // The stream stayed alive across the suspend, so the reconnect refetch will not fire — but an // event may have been shed from a full hub buffer while away; nudge an open game to refetch. app.resync++; } void refreshNotifications(); } export function showToast(text: string, kind: Toast['kind'] = 'info'): void { app.toast = { kind, text, seq: ++toastSeq }; if (toastTimer) clearTimeout(toastTimer); // An info bubble lives exactly as long as its appear + ~1s hold + rise-and-fade animation (3s); // an error dwells longer (4s) so it is not missed. toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 3000); } /** dismissToast hides the current toast at once — a tap on the bubble dismisses it. */ export function dismissToast(): void { if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; } app.toast = null; } /** dismissStaleInvite hides the outdated-invite-link notice (the user acknowledged it). */ export function dismissStaleInvite(): void { app.staleInvite = false; } /** dismissWelcomeRedeem hides the Telegram welcome window (the user acknowledged it). */ export function dismissWelcomeRedeem(): void { app.welcomeRedeem = false; } /** openDebug / closeDebug toggle the hidden on-device debug panel (components/DebugPanel), opened * by tapping the header title ten times — a support aid that shows and shares client diagnostics. */ export function openDebug(): void { app.debugOpen = true; } export function closeDebug(): void { app.debugOpen = false; } /** * seedChatUnread sets a game's unread flags from an authoritative per-viewer REST view (the lobby * list, a game's state, or a move result): unread is any unread entry, message whether one of them * is a real message (the badge colour). The live-event GameView omits both, so the live stream * raises them on receive rather than seeding from a GameView. */ export function seedChatUnread(gameId: string, unread: boolean, message: boolean): void { if ((app.chatUnread[gameId] ?? false) !== unread) { app.chatUnread = { ...app.chatUnread, [gameId]: unread }; } if ((app.messageUnread[gameId] ?? false) !== message) { app.messageUnread = { ...app.messageUnread, [gameId]: message }; } } /** * markChatRead clears a game's unread badge and tells the backend the player has read the chat — * called when the move history or the chat opens. It hits the backend only when something is * actually unread, so opening the history does not call the backend on every tap. The clear is * optimistic and the ack is best-effort: a failed ack leaves the server bit set, which the next * authoritative REST view (lobby list / game state) re-seeds, so the badge self-heals — it is * deliberately not restored here, since the in-game effect re-runs while the history is shown and * a restore would loop on a persistently failing ack. */ export function markChatRead(gameId: string): void { if (!app.chatUnread[gameId]) return; app.chatUnread = { ...app.chatUnread, [gameId]: false }; if (app.messageUnread[gameId]) app.messageUnread = { ...app.messageUnread, [gameId]: false }; void gateway.markChatRead(gameId).catch(() => {}); } /** 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 (code === 'account_blocked') { void enterBlocked(); return; } // A blocked call in offline mode ('offline', the transport kill switch) is expected, not an error // to surface — the offline chrome already signals the state; never a red toast. if (isConnectionCode(code) || code === 'offline' || !connection.online) return; haptic('error'); showToast(t(code ? errorKey(code) : 'error.generic'), 'error'); } /** * enterBlocked switches the app to the terminal blocked screen and stops all push/poll. It is * triggered whenever any call reports the "account_blocked" code. It flips the screen * synchronously (a generic placeholder), then fetches the block's expiry and reason — the one * endpoint a blocked client may still reach — to render the precise message. If that fetch reports * the block was already lifted (a race), it recovers: clears the blocked screen and reopens the * stream. */ export async function enterBlocked(): Promise { closeStream(); // stop the live stream; the blocked screen makes no further calls app.blocked ??= { blocked: true, permanent: true, until: '', reason: '' }; try { const s = await gateway.blockStatus(); if (s.blocked) { app.blocked = s; } else { app.blocked = null; if (app.session) openStream(); } } catch { // Keep the placeholder blocked screen even if the details cannot be fetched. } } /** * viewingGame reports whether the game board for the given game id is the current route. That screen * maintains its own cache from live events, so the global stream handler must not also advance it (a * double application). The chat / dictionary sub-screens leave the board unmounted, so they do not * count as viewing — there the global handler keeps the cache warm for the return to the board. */ function viewingGame(gameId: string): boolean { return router.route.name === 'game' && router.route.params.id === gameId; } 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) { app.chatUnread = { ...app.chatUnread, [e.message.gameId]: true }; // Only a real message colours the badge; a nudge delivered as a chat_message must not // raise the message flag (the separate nudge event below also leaves it untouched). if (e.message.kind !== 'nudge') { app.messageUnread = { ...app.messageUnread, [e.message.gameId]: true }; } showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info'); } } else if (e.kind === 'nudge') { // A nudge only reaches the awaited player; raise their unread badge for the game (unless // they are already in its chat), then toast — naming the nudger (their per-game seat name, // carried on the event), so it reads ": …" like the your-turn toast; fall back to // the plain phrase when absent. const inComms = (router.route.name === 'gameChat' || router.route.name === 'gameCheck') && router.route.params.id === e.gameId; if (!inComms) app.chatUnread = { ...app.chatUnread, [e.gameId]: true }; showToast(e.senderName ? t('chat.nudgeBy', { name: e.senderName }) : t('chat.nudge'), 'info'); } else if (e.kind === 'your_turn') { // Name the player who moved just before this one (the previous seat in turn order), so the // toast reads the same in games with any number of players. Fall back to the bare label when // no name is present (an older peer, or a gap event without one). showToast(e.opponentName ? t('game.yourTurnBy', { name: e.opponentName }) : t('game.yourTurn'), 'info'); } else if (e.kind === 'opponent_moved' || e.kind === 'game_over') { // Keep both caches fresh from any screen, so neither the lobby nor the game flashes a stale // frame on the next navigation. The lobby snapshot is patched from the event's own GameView // (independent of whether this device has the game cached). The per-game cache is advanced // only for a game not currently in view — the mounted game board owns its own cache (see // game/Game.svelte), so skipping it avoids a double apply. if (e.game) patchLobbyGame(e.game); if (!viewingGame(e.gameId)) { const c = advanceCached(getCachedGame(e.gameId), e); if (c) setCachedGame(e.gameId, c.view, c.moves); } } else if (e.kind === 'opponent_joined') { // The opponent took an open game's empty seat: mirror the new status into the lobby snapshot, // and warm a not-currently-viewed game's own cache, so opening it from any screen is flash-free. // The mounted game board owns its cache (game/Game.svelte), so skip the game in view. if (e.state) { patchLobbyGame(e.state.game); if (!viewingGame(e.gameId)) { const c = advanceCached(getCachedGame(e.gameId), e); if (c) setCachedGame(e.gameId, c.view, c.moves); } } } else if (e.kind === 'match_found') { // Seed both caches from the event's initial state so the game renders instantly on arrival // and the new game is already in the lobby on a later return, then navigate. if (e.state) { setCachedGame(e.state.game.id, e.state, []); patchLobbyGame(e.state.game); } navigate(`/game/${e.gameId}`); } else if (e.kind === 'notify') { // A started invited game seeds its cache so opening it is instant and lands in the lobby // snapshot from any screen; the lobby badge stays on the authoritative refresh. if (e.sub === 'game_started' && e.state) { setCachedGame(e.state.game.id, e.state, []); patchLobbyGame(e.state.game); } // An invitation created / updated / withdrawn elsewhere: keep the lobby's invitations list // fresh from any screen — a new pending one is added, a consumed (started), declined, // cancelled or expired one is removed (see patchLobbyInvitation). if ((e.sub === 'invitation' || e.sub === 'invitation_update') && e.invitation) { patchLobbyInvitation(e.invitation); } // An operator answered the player's feedback: raise the Settings/Info badge. if (e.sub === 'admin_reply') { app.feedbackReplyUnread = true; } // The viewer's ad-banner eligibility may have changed (paid/hints/no_banner): re-fetch // the profile, which carries the banner block, so the banner shows or hides in place. if (e.sub === 'banner') { void refreshProfile(); } // The viewer's own profile changed out of band (an email confirmed via the // one-tap deeplink opened in another browser): re-fetch it. if (e.sub === 'profile') { void refreshProfile(); } // A payment-intake event credited (or refunded) the viewer's wallet: bump the signal an // open Wallet screen watches, so it re-fetches in place (the return-focus poll is the // fallback when the live stream is down). if (e.sub === 'payment') { app.walletRefresh++; } 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); } /** * refreshProfile re-fetches the account profile (which carries the ad-banner block), * after a `banner` notify signals the viewer's banner eligibility changed. Best-effort: * a transient failure leaves the previous profile in place. */ export async function refreshProfile(): Promise { if (!app.session) return; try { app.profile = await gateway.profileGet(); void saveProfile($state.snapshot(app.profile)); // keep the offline-boot cache fresh (plain snapshot) } catch { // Best-effort; the banner just stays as it was until the next fetch. } } /** * 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. } } /** * refreshFeedbackBadge recomputes whether an operator feedback reply awaits the * player (the Settings → Info badge, folded into the lobby ⚙️ badge). Authoritative * poll, complementing the live 'admin_reply' push. Guests cannot submit feedback, so * it is a no-op for them. */ export async function refreshFeedbackBadge(): Promise { if (!app.session || app.profile?.isGuest) { app.feedbackReplyUnread = false; return; } try { app.feedbackReplyUnread = await gateway.feedbackUnread(); } catch { // Best-effort; leave the previous flag 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(); // Persist the profile so a later offline cold start can launch from it (its variant // preferences, dictionary versions and display name) without a network fetch. Snapshot to a // plain object first — the reactive $state proxy is not structured-cloneable, so it would fail // the IndexedDB write and fall back to a localStorage entry that loadProfile never reads. void saveProfile($state.snapshot(app.profile)); // The live interface language follows the device — the explicit local choice (saved in // prefs) or the system guess made at bootstrap — and is no longer overridden from the // account here: the Telegram bot a user signs in through must not dictate the UI, so a // ru-bot launch on an English system stays English. // // The banner and out-of-app push are resolved server-side from preferred_language, so it // must track whatever language the UI actually shows — the explicit choice AND the system // guess. Reconcile it to the active locale on every adopt, not only after an explicit // Settings choice: a user who never opened Settings would otherwise be stuck on the // creation-time seed — e.g. an English banner under a Russian UI. This keeps every // server-rendered, language-dependent surface (banner, out-of-app push) aligned with the // interface, not just one. persistLanguageToServer self-gates (a no-op for guests and when // already equal), so there is no write in the steady state. void persistLanguageToServer(app.locale); } catch (err) { handleError(err); } // A blocked account stays on the blocked screen: no live stream, no notification poll. if (app.blocked) return; openStream(); void refreshNotifications(); } // The cold-start "no connection — go offline?" dialog. bootstrap raises it and awaits the choice // here; App.svelte's dialog buttons call resolveOfflinePrompt. const BOOT_REACHABILITY_TIMEOUT_MS = 3000; let offlinePromptResolve: ((goOffline: boolean) => void) | null = null; /** resolveOfflinePrompt answers the cold-start "no connection — go offline?" dialog (App.svelte). */ export function resolveOfflinePrompt(goOffline: boolean): void { app.offlinePrompt = false; const resolve = offlinePromptResolve; offlinePromptResolve = null; resolve?.(goOffline); } /** promptOfflineChoice raises the dialog and resolves to the player's choice (true = go offline). */ function promptOfflineChoice(): Promise { app.offlinePrompt = true; return new Promise((resolve) => { offlinePromptResolve = resolve; }); } /** * initNetworkReactivity reacts to mid-session network changes (e.g. the player toggling flight mode): * losing the network interface enters offline mode automatically (session-only); regaining it * verifies the gateway is really reachable (an interface being up does not guarantee it) and returns * to online — but only if the offline was auto. A deliberate offline (the Settings toggle or the * cold-start dialog) is left as the player's choice. These are passive OS events — no polling, no * battery cost. Web-only and skipped in the mock (the e2e drives connectivity directly). */ // While in auto-offline, poll for the network to return so the app comes back online — robust to the // unreliable `online` event (which often does not fire in an installed PWA). Each tick is cheap: it // reads navigator.onLine (no radio) and only hits the network (a reachability check) when the // interface is actually up, so while flight mode is on it costs nothing. The poll runs ONLY in // auto-offline (a deliberate offline is the player's choice) and stops on returning online. let recoveryTimer: ReturnType | null = null; export function scheduleRecovery(delayMs: number): void { if (recoveryTimer) { clearTimeout(recoveryTimer); recoveryTimer = null; } if (typeof window === 'undefined' || !offlineMode.active || !offlineMode.auto) return; recoveryTimer = setTimeout(async () => { recoveryTimer = null; if (!offlineMode.active || !offlineMode.auto) return; const interfaceUp = typeof navigator === 'undefined' || navigator.onLine; if (interfaceUp && (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) { if (offlineMode.active && offlineMode.auto) setOfflineMode(false); return; } scheduleRecovery(4000); }, delayMs); } export function initNetworkReactivity(): void { if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return; window.addEventListener('offline', () => { if (!offlineMode.active) { setOfflineMode(true, false); // auto (session) scheduleRecovery(4000); // poll for the network to return } }); // The online event, when it fires, just kicks an immediate reachability check; the poll above is // the reliable fallback for platforms where it does not fire. window.addEventListener('online', () => { if (offlineMode.active && offlineMode.auto) scheduleRecovery(0); }); } /** * 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(); // A guest who linked in place now has a durable account: push the active interface language // so the banner + push routing follow it (see adoptSession — reconciled regardless of an // explicit Settings choice). void persistLanguageToServer(app.locale); } /** * requestDeleteAccount starts account deletion and reports which step-up the account uses: * 'email' (a code was mailed) or 'phrase' (type the confirmation phrase). */ export async function requestDeleteAccount(): Promise<'email' | 'phrase'> { return (await gateway.deleteRequest()).method; } /** * confirmDeleteAccount confirms deletion with the mailed code or the typed phrase. On * success it switches to the terminal "account deleted" screen and stops all push/poll; it * throws on a bad code/phrase so the caller can let the user retry. */ export async function confirmDeleteAccount(code: string, phrase: string): Promise { await gateway.deleteConfirm(code, phrase); closeStream(); app.accountDeleted = true; } /** * closeDeletedApp closes the host Mini App from the terminal deleted screen (Telegram / VK). * A plain browser has nothing to close, so it is a no-op there — the terminal screen stays. */ export function closeDeletedApp(): void { if (insideTelegram()) { telegramClose(); return; } if (insideVK()) { void vkClose(); } } /** * 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(), ); } /** * syncVKChrome paints VK's status bar (and, on Android, the action/navigation bars) from the app's * live theme tokens, so the surrounding VK chrome matches the UI. A no-op outside VK. Parity with * syncTelegramChrome. */ function syncVKChrome(): void { if (!insideVK() || typeof document === 'undefined') return; const cs = getComputedStyle(document.documentElement); const bg = cs.getPropertyValue('--bg').trim(); const bgElev = cs.getPropertyValue('--bg-elev').trim(); void vkSetViewSettings(appearanceForBg(bg), bgElev, bg); } /** * syncTelegramSafeArea mirrors Telegram's safe-area insets into CSS vars: the content-safe-area top * (the height Telegram's native nav overlays the viewport in fullscreen) into --tg-content-top * (which also toggles the `tg-fullscreen` class so the header drops below the nav and centres the * title in its band), and the device safe-area insets — notch / status bar (top), home indicator * (bottom) and the landscape notch sides (left / right) — into --tg-safe-top / --tg-safe-bottom / * --tg-safe-left / --tg-safe-right, so the header, rack and screen edges clear the device cut-outs. * Called on launch and on Telegram's safe-area / fullscreen change events. */ function syncTelegramSafeArea(): void { if (typeof document === 'undefined') return; const root = document.documentElement; const top = telegramContentSafeAreaTop(); const safe = telegramSafeAreaInset(); root.style.setProperty('--tg-content-top', `${top}px`); root.style.setProperty('--tg-safe-top', `${safe.top}px`); root.style.setProperty('--tg-safe-bottom', `${safe.bottom}px`); root.style.setProperty('--tg-safe-left', `${safe.left}px`); root.style.setProperty('--tg-safe-right', `${safe.right}px`); root.classList.toggle('tg-fullscreen', top > 0); } // The soft-keyboard height threshold (px) that distinguishes an open keyboard from browser chrome // (an address bar shrinks the visual viewport by far less), used to gate the focus-into-view scroll. const KEYBOARD_MIN_PX = 120; /** * syncViewport mirrors the visual viewport into CSS vars so the pinned app-shell (app.css * `html.app-shell body`) both FITS (`--vvh`) and FOLLOWS (`--vv-top`) the visible area above the soft * keyboard. iOS Safari / WKWebView does not shrink the layout viewport for the keyboard (Android * Chrome does); instead the visual viewport shrinks AND offsets down to reveal the focused field, and * those values do not always cleanly revert. Tracking only the height left the top-anchored shell * misaligned on iOS — empty space below the content, worst on a repeat focus; tracking offsetTop too * keeps the shell on the visible area on every platform. On the keyboard-OPEN transition it also * scrolls the focused field into view, since iOS does not reliably scroll a pinned document to it. */ let keyboardOpen = false; function syncViewport(): void { if (typeof document === 'undefined') return; const win = typeof window !== 'undefined' ? window : null; const vv = win ? win.visualViewport : null; const h = vv ? vv.height : win ? win.innerHeight : 0; const top = vv ? vv.offsetTop : 0; if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`); document.documentElement.style.setProperty('--vv-top', `${top}px`); const open = !!vv && !!win && win.innerHeight - h > KEYBOARD_MIN_PX; if (open && !keyboardOpen) { const el = document.activeElement; if (el instanceof HTMLElement && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) { requestAnimationFrame(() => el.scrollIntoView({ block: 'center' })); } } keyboardOpen = open; } /** * syncTelegramTheme re-applies Telegram's theme integration — the themeParams token overrides, * Telegram's authoritative colour scheme, and the matching chrome — from theme, or from the SDK's * current themeParams when omitted. Called on launch with the launch snapshot and live on the * themeChanged event, so switching Telegram's light/dark theme while the app is open is picked up * without a relaunch. */ function syncTelegramTheme(theme: TelegramThemeParams | undefined = telegramThemeParams()): void { if (theme) applyTelegramTheme(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); syncTelegramChrome(); } /** * applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative * colour scheme and theme (syncTelegramTheme), the matching header / background / bottom chrome, * the safe-area insets, and the swipe-down-to-minimise guard. It is idempotent, so both the * initial bootstrap and a manual launch retry call it. */ function applyTelegramChrome(launch: TelegramLaunch): void { syncTelegramTheme(launch.theme); // Mirror the safe-area insets and stop Telegram's swipe-down-to-minimise from fighting tile drag // / board scroll. syncTelegramSafeArea(); telegramDisableVerticalSwipes(); } /** How long to wait for the dynamically loaded Telegram Mini App SDK before giving up and showing * the launch-error screen. A network that blocks telegram.org makes the script hang rather than * fail fast (a connection refusal resolves immediately via the script's error event), so this only * bounds a true hang; it is generous enough not to misfire on a slow but working network. */ const TELEGRAM_SDK_TIMEOUT_MS = 10000; export async function bootstrap(): Promise { // Record the cold start and start the adoption beacon (skipped under the mock harness, which // has no real backend). Deliberately before any await, so even an early-return launch path // (e.g. the Telegram launch-error screen) still counts as an app open. if (import.meta.env.MODE !== 'mock') startLocalEvalMetrics(); const prefs = await loadPrefs(); app.theme = prefs.theme ?? 'auto'; app.reduceMotion = prefs.reduceMotion ?? false; app.boardLabels = prefs.boardLabels ?? 'beginner'; app.zoomBoard = prefs.zoomBoard ?? true; app.onboarding = await loadOnboarding(); applyTheme(app.theme); applyReduceMotion(app.reduceMotion); if (prefs.locale) { app.locale = prefs.locale; setLocale(prefs.locale); } else { const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'); app.locale = guess; setLocale(guess); } // Track the visual viewport (height + offset) so screens fit and follow the visible area above an // open soft keyboard (--vvh / --vv-top). Both resize AND scroll fire on iOS when the keyboard moves // the visual viewport. syncViewport(); if (typeof window !== 'undefined' && window.visualViewport) { window.visualViewport.addEventListener('resize', syncViewport); window.visualViewport.addEventListener('scroll', syncViewport); } // Enter on a single-line dismisses the soft keyboard (blur): the keyboard's default // return/"Go" key otherwise does nothing on a bare input, which reads as stuck. A