// 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 { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme'; import { insideTelegram, onTelegramPath, telegramColorScheme, telegramContentSafeAreaTop, telegramSafeAreaTop, telegramDisableVerticalSwipes, telegramHaptic, telegramLaunch, type TelegramLaunch, telegramOnEvent, telegramRequestFullscreen, 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, 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; /** 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; toast: Toast | null; lastEvent: PushEvent | null; theme: ThemePref; locale: Locale; reduceMotion: boolean; boardLabels: BoardLabelMode; /** 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; /** 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, lobbyReady: false, splashDone: false, streamAlive: false, session: null, profile: null, blocked: null, toast: null, lastEvent: null, theme: 'auto', locale: 'en', reduceMotion: false, boardLabels: 'beginner', notifications: 0, chatUnread: {}, messageUnread: {}, feedbackReplyUnread: false, 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 rise-and-fade animation (2s); an error // dwells longer (4s) so it is not missed. toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 2000); } /** 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; } /** * 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; } if (isConnectionCode(code) || !connection.online) return; telegramHaptic('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(); } 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(); } 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(); // 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(); } /** * 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); } /** * 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'; 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 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(); // On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged // listener above then re-syncs the safe-area insets. Desktop keeps the bot's full-size // window. No-op on clients predating Bot API 8.0. telegramRequestFullscreen(); await bootTelegram(launch); 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; } // Inside a Mini App the only identity is the Telegram session, so a failed launch must never fall // back to the web login screen. A transient backend outage (a deploy rolling over) is retried a // few times in silence; only then does the boot-error screen surface, from which Retry re-runs the // same path (retryTelegramBoot). const TELEGRAM_BOOT_RETRIES = 2; const TELEGRAM_BOOT_RETRY_MS = 1200; function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * bootTelegram authenticates a Mini App launch from its initData and routes any deep-link start * parameter, retrying a few times on a transient failure before raising the boot-error screen * (app.bootError). A blocked account is terminal — it switches straight to the blocked screen * without retrying. */ async function bootTelegram(launch: TelegramLaunch): Promise { for (let attempt = 0; ; attempt++) { try { await adoptSession(await gateway.authTelegram(launch.initData)); // A blocked account skips deep-link routing — the blocked screen overlays every route. if (!app.blocked) await routeStartParam(launch.startParam); app.bootError = false; return; } catch (err) { if (err instanceof GatewayError && err.code === 'account_blocked') { await enterBlocked(); return; } if (attempt >= TELEGRAM_BOOT_RETRIES) { app.bootError = true; return; } await delay(TELEGRAM_BOOT_RETRY_MS); } } } /** * retryTelegramBoot re-attempts the Mini App launch from the boot-error screen's Retry button. It * clears the error and shows the loading state again, then runs the same retrying boot; on success * the app renders normally, otherwise the boot-error screen returns. */ export async function retryTelegramBoot(): Promise { app.bootError = false; app.ready = false; await bootTelegram(telegramLaunch()); 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': try { const friend = await gateway.friendCodeRedeem(link.code); navigate('/friends'); // Inside Telegram a successful invite-link redeem welcomes the arriving player and // points them at the bot (its native convenience); elsewhere a quiet toast suffices. if (insideTelegram()) { app.welcomeRedeem = true; } else { showToast(t('friends.added', { name: friend.displayName })); } void refreshNotifications(); } catch (err) { const code = err instanceof GatewayError ? err.code : ''; if (code === 'self_relation') { // Tapping your own invite link redeems your own code: a friendly note, not the // scary "can't do that to yourself" error. navigate('/friends'); showToast(t('friends.selfInvite')); } else if (code === 'friend_code_invalid') { // A previously shared link whose single-use, 12h code is already spent or expired: // land in the lobby and gently point at the bot, rather than a red error on Friends. navigate('/'); app.staleInvite = true; } else { navigate('/friends'); 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(); // Replay the loading splash on the next cold lobby load after a re-login. app.lobbyReady = false; app.splashDone = false; 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; 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 || !languageNeedsServerSync(p, 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, variantPreferences: p.variantPreferences, }); } 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); // Mock-mode e2e seam (tree-shaken from a production build): drive the live-stream lifecycle so the // e2e can exercise the open-game fallbacks for a missed opponent_joined. `drop` tears the stream // down without scheduling a reconnect — so only the in-game poll can then recover — and `restore` // reopens it (the reconnect catch-up path). Never wired outside mock mode. if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') { (window as unknown as { __stream?: { drop(): void; restore(): void } }).__stream = { drop: closeStream, restore: openStream, }; // Drive the terminal blocked screen from the e2e (the mock never blocks of its own accord). It // mirrors enterBlocked: flip the screen and stop the live stream. (window as unknown as { __block?: (b?: Partial) => void }).__block = (b) => { closeStream(); app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b }; }; }