Files
scrabble-game/ui/src/lib/app.svelte.ts
T
Ilia Denisov 77a690fcf6
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
feat(ui): move in-game status to the board — highlight, score badge, full-width rack
Remove the under-board status strip and relocate its signals:
- bag count -> a badge on the exchange/pass control + the foot of the move table
- whose-turn / win-lose -> a thin strip above the score plaques
- the tentative-move caption -> the board itself: staged tiles tint green (legal) or
  pink (illegal), the board tiles a formed word runs through go a shade darker, and an
  orange score badge sits on the main word (digit sized like a tile value, clamped on-board)

The word geometry (covered cells + badge anchor) is a new pure client-side helper
(ui/src/lib/formed.ts), independent of the move evaluator, so it works on the local or
network preview path alike; the badge's number still comes from the preview score.

Rack: a seven-column grid filling the tray width in both layouts — square, full-width
tiles — with the confirm control in the fixed 7th slot.

Settings: a touch-only "Zoom the board" toggle (default on, device-local) gates the
tile-placement auto-zoom; taking a hint while zoomed in now zooms out so the highlighted
hint word is never left off-screen.

Docs (FUNCTIONAL +_ru, UI_DESIGN, ARCHITECTURE) and e2e/unit tests updated.
2026-07-10 14:58:32 +02:00

1374 lines
61 KiB
TypeScript

// 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<string, boolean>;
/** 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<string, boolean>;
/** 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<typeof setTimeout> | null = null;
let toastTimer: ReturnType<typeof setTimeout> | 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<void> {
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 "<opponent>: …" 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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
app.offlinePrompt = true;
return new Promise<boolean>((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<typeof setTimeout> | 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<void> {
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<void> {
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<void> {
// 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 <input> dismisses the soft keyboard (blur): the keyboard's default
// return/"Go" key otherwise does nothing on a bare input, which reads as stuck. A <textarea> (the
// feedback form) is excluded so Enter still inserts a newline; a form's own Enter→submit still
// fires first (this listener is on the document, so it runs after the field's own handler).
if (typeof document !== 'undefined') {
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target instanceof HTMLInputElement) e.target.blur();
});
}
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
// longer a render-blocking <script> in index.html, so a network that blocks telegram.org (common
// where Telegram itself reaches users only over a proxy) cannot hang the page and strand the app
// or the diagnostic screen below. Skipped on a plain web / native entry, which never needs it.
if (onTelegramPath() || hasLaunchFragment()) {
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
}
// Telegram Mini App launch: apply the platform theme, authenticate via initData, and route any
// deep-link start parameter. On the dedicated /telegram/ entry without sign-in data (no/empty
// initData — outside Telegram, or a Mini App launch that delivered none, as seen on some Android
// clients), render the compact launch-error screen with a diagnostic snapshot the user can share
// with the developer, instead of bouncing the visitor to the marketing landing.
if (onTelegramPath() && !insideTelegram()) {
app.launchError = collectTelegramDiag();
app.ready = true;
return;
}
if (insideTelegram()) {
const launch = telegramLaunch();
applyTelegramChrome(launch);
// Pull the device-independent display prefs (theme / reduce-motion / board labels) from
// CloudStorage in the background so a change on another device follows the user here; the local
// values applied above render instantly, so this reconciles without blocking launch.
void reconcileCloudPrefs();
// Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load).
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
// Re-apply the theme live when the user switches Telegram's light/dark mode while the app is open.
telegramOnEvent('themeChanged', () => syncTelegramTheme());
// Telegram's native Settings button (Bot API 7.0) opens our Settings screen; the in-app gear
// entry stays the primary path. No-op on clients predating the button.
telegramShowSettingsButton(() => navigate('/settings'));
await bootTelegram(launch);
app.ready = true;
return;
}
// VK Mini App launch: signal readiness to the VK client (which dismisses its loading cover), then
// authenticate from the signed launch parameters in the URL — the display name comes from
// VKWebAppGetUserInfo, since VK omits it from the signed params. The /vk/ entry opened outside VK
// (no signed params — e.g. a developer hitting the URL directly) falls through to the web flow.
if (onVKPath() && insideVK()) {
// Follow the VK client's light/dark appearance while the user keeps the app's "auto" theme (the
// VK mobile webview's prefers-color-scheme does not track it).
void vkOnScheme((scheme) => {
if (app.theme === 'auto') applyTheme(scheme);
// Repaint VK's status/nav bars to the resolved theme; this handler also fires on launch, so it
// covers the initial paint (auto → the VK scheme just applied, explicit → the theme from boot).
syncVKChrome();
});
// Clear the device safe area from VK's insets — the VK mobile webview does not surface them via
// CSS env() (notably the Android home bar). max() keeps whichever of env() / the VK value is real.
void vkOnInsets((i) => {
const root = document.documentElement;
root.style.setProperty('--tg-safe-top', `max(env(safe-area-inset-top, 0px), ${i.top}px)`);
root.style.setProperty('--tg-safe-bottom', `max(env(safe-area-inset-bottom, 0px), ${i.bottom}px)`);
root.style.setProperty('--tg-safe-left', `max(env(safe-area-inset-left, 0px), ${i.left}px)`);
root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`);
});
await vkInit();
// The app owns navigation (its own back chevron), so silence VK's horizontal swipe-back to keep
// it off our edge-swipe-back and on-board tile drag — parity with telegramDisableVerticalSwipes.
void vkDisableSwipeBack();
await bootVK();
app.ready = true;
return;
}
// On the plain web (both Mini-App branches returned above) register the install-only service
// worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only
// and skipped in the mock build; see lib/pwa.svelte.
registerServiceWorker();
// React to mid-session network changes (e.g. flight mode) for the rest of the session.
initNetworkReactivity();
// Deliberate offline mode carried over from a prior session: with no network the session adoption
// + profile fetch below would hang the splash, so skip them and launch straight from the cached
// session + profile into the offline lobby. Without a cached profile (e.g. storage cleared) fall
// through to the normal online flow but KEEP the sticky flag — the mode is the player's choice, and
// an online boot re-persists the profile so the next launch goes offline. (A truly offline launch
// with no cached profile is unreachable: enabling offline requires a prior online session.)
if (offlineMode.active) {
const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]);
if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) {
gateway.setToken(offlineSession!.token);
app.session = offlineSession!;
app.profile = offlineProfile!;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
}
const saved = await loadSession();
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
// from VK; capture and clear it here (it needs the restored session to link against).
const vkcb = pendingVKLink();
if (saved) {
// Cold-start network auto-detect (deliberate offline is handled above). With a cached profile to
// fall back on and no pending VK-link, do not hang on adoptSession's retrying profile fetch when
// the network is down: a device with no network interface goes offline for the session (no
// dialog); an interface that is up but cannot reach the gateway within a short window asks the
// player. Web-only reaches here (the Mini-App branches returned above), so isStandalone gates it.
const cachedProfile = await loadProfile();
const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
if (canOffline) {
const interfaceOffline = typeof navigator !== 'undefined' && navigator.onLine === false;
gateway.setToken(saved.token);
const reachable = interfaceOffline ? false : await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS);
if (!reachable) {
// No network interface → go offline for the session (no dialog); interface up but the
// gateway is unreachable → ambiguous, so ask.
const goOffline = interfaceOffline ? true : await promptOfflineChoice();
if (goOffline) {
// A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only.
setOfflineMode(true, !interfaceOffline);
if (interfaceOffline) scheduleRecovery(4000); // auto-offline: poll for the network to return
app.session = saved;
app.profile = cachedProfile;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
// "Нет" — keep trying online: fall through to adoptSession (it retries; the connection
// watcher shows "Connecting…").
}
}
await adoptSession(saved);
if (vkcb) {
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
// finishes the link or merge on mount.
app.vkLinkPending = vkcb;
navigate('/profile');
} else if (router.route.name === 'login') {
navigate('/');
}
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
navigate('/login');
}
app.ready = true;
}
// Inside a Mini App the only identity is the platform session (Telegram or VK), 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 (retryMiniAppBoot).
const TELEGRAM_BOOT_RETRIES = 2;
const TELEGRAM_BOOT_RETRY_MS = 1200;
function delay(ms: number): Promise<void> {
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<void> {
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);
}
}
}
/**
* bootVK authenticates a VK Mini App launch from the signed launch parameters in the URL, seeding a
* brand-new account's display name from VKWebAppGetUserInfo. Like bootTelegram it retries a few
* times on a transient failure before raising the boot-error screen, and a blocked account is
* terminal. This MVP carries no VK deep-link routing.
*/
async function bootVK(): Promise<void> {
const params = vkLaunchParams();
const displayName = await vkUserName();
for (let attempt = 0; ; attempt++) {
try {
await adoptSession(await gateway.authVK(params, displayName));
// VK forwards only the signed vk_* params to the iframe — a custom payload on the app link
// (whether after '#', eaten by the vk.com SPA, or as a '?' query, dropped) never reaches it,
// confirmed on the contour. So there is no friend-code auto-redeem on VK in test mode: the
// launch lands on the lobby. vkStartParam stays as the reader for a future (post-moderation)
// deep-link channel, and routes the lobby for an empty payload today.
if (!app.blocked) await routeStartParam(vkStartParam());
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);
}
}
}
/**
* retryMiniAppBoot re-attempts a Mini App launch from the boot-error screen's Retry button — the VK
* boot on the /vk/ entry, the Telegram boot otherwise. 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 retryMiniAppBoot(): Promise<void> {
app.bootError = false;
app.ready = false;
if (onVKPath()) {
await vkInit();
await bootVK();
} else {
await bootTelegram(telegramLaunch());
}
app.ready = true;
}
/**
* retryTelegramLaunch re-attempts a Mini App launch from the launch-error screen's Retry button. If
* sign-in data is present now (e.g. it arrived late on a slow client) it clears the error and runs
* the normal launch; otherwise it refreshes the diagnostic snapshot so the screen reflects the
* current state. It never reloads — telegram-web-app.js consumes the launch fragment on first load,
* so a reload could discard the very data we are waiting for.
*/
export async function retryTelegramLaunch(): Promise<void> {
// Re-attempt the SDK load — the network may have recovered since the launch-error screen showed.
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
if (!insideTelegram()) {
app.launchError = collectTelegramDiag();
return;
}
app.launchError = null;
app.ready = false;
const launch = telegramLaunch();
applyTelegramChrome(launch);
await bootTelegram(launch);
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<void> {
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<void> {
try {
const s = await gateway.authGuest(app.locale);
await adoptSession(s);
navigate('/');
} catch (err) {
handleError(err);
}
}
export async function requestEmailCode(email: string): Promise<boolean> {
try {
await gateway.authEmailRequest(email, app.locale, isStandalone());
return true;
} catch (err) {
handleError(err);
return false;
}
}
export async function loginEmail(email: string, code: string): Promise<void> {
try {
const s = await gateway.authEmailLogin(email, code);
await adoptSession(s);
navigate('/');
} catch (err) {
handleError(err);
}
}
// confirmDeeplink verifies a one-tap email deeplink token opened from a confirmation
// email. A login adopts the minted session and enters the app; a link reports whether
// it confirmed or needs the interactive merge. It throws on an invalid/expired token,
// which the confirm screen surfaces. This browser need not be signed in.
export async function confirmDeeplink(token: string): Promise<'login' | 'linked' | 'merge_required'> {
const r = await gateway.confirmEmailLink(token);
if (r.purpose === 'login' && r.session) {
await adoptSession(r.session);
navigate('/');
return 'login';
}
return r.status === 'merge_required' ? 'merge_required' : 'linked';
}
export async function logout(): Promise<void> {
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();
await clearProfile();
app.session = null;
app.profile = null;
navigate('/login');
}
/**
* markOnboardingDone records that a first-run coachmark series has been completed (its last hint
* was shown) and persists the flag, so the series never replays for this client. A series
* interrupted before its last hint is left unmarked and replays from the start next launch.
*/
export function markOnboardingDone(series: CoachSeries): void {
if (series === 'lobby') app.onboarding.lobbyDone = true;
else app.onboarding.gameDone = true;
void saveOnboarding({ ...app.onboarding });
}
/**
* resetOnboarding clears both coachmark completion flags (the hidden DebugPanel control), so the
* onboarding replays on the next launch — a manual re-test aid.
*/
export function resetOnboarding(): void {
app.onboarding = { lobbyDone: false, gameDone: false };
void clearOnboarding();
}
function persistPrefs(): void {
void savePrefs({
theme: app.theme,
locale: app.locale,
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
zoomBoard: app.zoomBoard,
});
// Mirror the device-independent display prefs to Telegram CloudStorage so they follow the user
// across devices (no-op outside Telegram / on a client predating it). Locale is excluded — it
// syncs via the durable account (Profile.preferredLanguage) instead.
void telegramCloudSet(
CLOUD_PREFS_KEY,
encodeClientPrefs({ theme: app.theme, reduceMotion: app.reduceMotion, boardLabels: app.boardLabels }),
);
}
/**
* reconcileCloudPrefs pulls the device-independent display prefs (theme / reduce-motion / board
* labels) from Telegram CloudStorage and applies any that differ from the current values, so a
* change made on another Telegram device follows the user here. The local store is the
* instant-render cache (read synchronously at boot); this runs once on launch after it and persists
* what it applied. Theme is not re-applied visually — inside Telegram the colour scheme is
* Telegram's to decide — only its stored value is updated. A no-op outside Telegram or when
* CloudStorage is unavailable; locale is never synced this way (it has its own server reconciler).
*/
async function reconcileCloudPrefs(): Promise<void> {
if (!telegramCloudAvailable()) return;
const cloud = decodeClientPrefs(await telegramCloudGet(CLOUD_PREFS_KEY));
let changed = false;
if (cloud.theme !== undefined && cloud.theme !== app.theme) {
app.theme = cloud.theme;
changed = true;
}
if (cloud.reduceMotion !== undefined && cloud.reduceMotion !== app.reduceMotion) {
app.reduceMotion = cloud.reduceMotion;
applyReduceMotion(app.reduceMotion);
changed = true;
}
if (cloud.boardLabels !== undefined && cloud.boardLabels !== app.boardLabels) {
app.boardLabels = cloud.boardLabels;
changed = true;
}
if (changed) persistPrefs();
}
export function setTheme(theme: ThemePref): void {
app.theme = theme;
applyTheme(theme);
syncVKChrome();
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<void> {
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();
}
export function setZoomBoard(on: boolean): void {
app.zoomBoard = 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);
// 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<BlockStatus>) => void }).__block = (b) => {
closeStream();
app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b };
};
// Expose the router so the e2e can assert navigate() updates the route synchronously (no
// hashchange lag): a route that trailed the hash for a frame let bootstrap's `app.ready` flip
// render the previous route under the new screen (the empty-hash lobby flashing under login).
(window as unknown as { __router?: { navigate(p: string): void; route(): string } }).__router = {
navigate,
route: () => router.route.name,
};
}