da25eac070
When a guest links an identity (email/TG/VK) that already belongs to a durable account, the guest-primary rule already made the durable account the survivor and switched the session — but the client still showed an irreversible 'merge two accounts?' confirmation, which is nonsense from the user's side (they are simply signing into their account). The confirm step now merges inline for a GUEST initiator and returns the completed merge (the switched token); a durable initiator still gets the explicit confirmation (consolidating two real accounts is consequential). The active-game guard still refuses, surfaced as the clear error.merge_active_game_conflict message rather than swallowed. Also fixes the merged-away device-local games: their human/host seat was recorded under the retired account id, so after the switch the lobby and game header could not identify 'me' and showed every seat as an opponent (the game still played — turn logic is seat-index based). applyLinkResult now re-points local game seats from the retired id to the survivor (repointLocalGameSeats). Docs: FUNCTIONAL.md (+_ru).
1408 lines
64 KiB
TypeScript
1408 lines
64 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 { createStreamWatchdog } from './streamwatchdog';
|
|
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
|
|
import {
|
|
insideTelegram,
|
|
telegramClose,
|
|
telegramLaunchDiag,
|
|
onTelegramPath,
|
|
hasLaunchFragment,
|
|
loadTelegramSDK,
|
|
telegramColorScheme,
|
|
telegramThemeParams,
|
|
telegramContentSafeAreaTop,
|
|
telegramSafeAreaInset,
|
|
telegramDisableVerticalSwipes,
|
|
telegramShowSettingsButton,
|
|
telegramLaunch,
|
|
type TelegramLaunch,
|
|
telegramOnEvent,
|
|
telegramSetChrome,
|
|
telegramCloudAvailable,
|
|
telegramCloudGet,
|
|
telegramCloudSet,
|
|
} from './telegram';
|
|
import { onVKPath, insideVK, vkLaunchDiag, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
|
|
import type { LaunchDiag } from './launchdiag';
|
|
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 { bootOffline, emit, initNetSignals, registerNetNudge, registerNetToast, registerRecovery } from './netstate.svelte';
|
|
import { latchUpdateNudge } from './update.svelte';
|
|
import { clearOfflinePref } from './offline';
|
|
import { initNativeNetwork } from './native';
|
|
import { clientChannel } from './channel';
|
|
import { localGuestId } from './localguest';
|
|
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;
|
|
/** On a dedicated Mini App entry (/telegram/ or /vk/) opened without a valid launch, set to a
|
|
* privacy-safe diagnostic snapshot. App.svelte then renders the compact, shareable launch-error
|
|
* screen (screens/LaunchError) — a probe for why Telegram delivered no initData (seen on some
|
|
* Android clients), or why /vk/ carried no signed launch — instead of bouncing to the landing
|
|
* (Telegram) or silently proceeding as a guest (VK). */
|
|
launchError: LaunchDiag | 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,
|
|
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;
|
|
streamWatchdog.pause(); // do not count silence against a suspended (throttled) stream
|
|
}
|
|
|
|
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++;
|
|
streamWatchdog.resume(); // resume liveness watching paused on the way to the background
|
|
}
|
|
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;
|
|
}
|
|
|
|
// The live stream carries a heartbeat every GATEWAY_PUSH_HEARTBEAT_INTERVAL (10 s by default), so a
|
|
// healthy stream delivers an event well inside this window even when the game is idle. A silence
|
|
// past it means the stream died without erroring (e.g. airplane mode cut the radio): tear the dead
|
|
// subscription down and drop, so the machine leaves "online" instead of waiting for the next call.
|
|
// 25 s tolerates a couple of missed heartbeats; it must stay above the server interval + slack.
|
|
const STREAM_IDLE_TIMEOUT_MS = 25000;
|
|
const streamWatchdog = createStreamWatchdog({
|
|
timeoutMs: STREAM_IDLE_TIMEOUT_MS,
|
|
onDead: () => {
|
|
// Do not trip while suspended: timers are throttled and a backgrounded stream is legitimately
|
|
// silent (the reopen on resume covers a genuine drop).
|
|
if (backgrounded || documentHidden()) return;
|
|
// Abort the dead subscription explicitly — an aborted stream skips onError (transport gates on
|
|
// signal.aborted) — then follow the same drop path as an error close.
|
|
unsubscribeStream?.();
|
|
unsubscribeStream = null;
|
|
app.streamAlive = false;
|
|
if (!bannerSuppressed()) reportOffline();
|
|
scheduleReconnect();
|
|
},
|
|
});
|
|
|
|
function openStream(): void {
|
|
closeStream();
|
|
app.streamAlive = true;
|
|
unsubscribeStream = gateway.subscribe(
|
|
(e) => {
|
|
reportOnline(); // a delivered event proves the gateway is reachable
|
|
streamWatchdog.reset(); // any event (incl. the heartbeat) proves the stream is still live
|
|
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();
|
|
}
|
|
},
|
|
() => {
|
|
streamWatchdog.clear();
|
|
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();
|
|
},
|
|
);
|
|
streamWatchdog.reset(); // start the liveness watch for this stream (armed by the first heartbeat)
|
|
}
|
|
|
|
/** 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 {
|
|
streamWatchdog.clear();
|
|
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 reachability probe budget: how long the boot waits for the gateway to answer before
|
|
// launching from the cache into the implicit offline lobby (offline is auto-detected now — no dialog).
|
|
const BOOT_REACHABILITY_TIMEOUT_MS = 3000;
|
|
|
|
/**
|
|
* recover is the smart recovery the net-state store's watcher runs while connecting/offline: a
|
|
* session-less native local guest reconciles a server guest (the reconcile attempt IS the reachability
|
|
* probe — it needs no prior session), while any cached-session launch does a cheap authenticated read.
|
|
* It resolves when the gateway is reachable (the store then heals to online) and rejects to stay offline.
|
|
* bootstrap wires it to the store, which owns the poll/backoff timer and the OS-signal listeners.
|
|
*/
|
|
async function recover(): Promise<void> {
|
|
const channel = clientChannel();
|
|
if ((channel === 'android' || channel === 'ios') && !app.session) {
|
|
await reconcileServerGuest();
|
|
if (!app.session) throw new Error('still a local guest'); // reconcile did not adopt — stay offline
|
|
return;
|
|
}
|
|
if (!(await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) throw new Error('gateway unreachable');
|
|
}
|
|
|
|
// A concurrency guard: the boot kick, the recovery poll and the online event can all try to reconcile
|
|
// at once — only the first proceeds; the rest see the in-flight flag (or the adopted session) and return.
|
|
let reconcileInFlight = false;
|
|
|
|
/**
|
|
* reconcileServerGuest lazily mints a server guest for a native local-guest launch once the gateway is
|
|
* reachable: with no cached session it silently calls auth.guest, adopts the returned session and clears
|
|
* the auto-offline so online features (matchmaking, friends, the Profile sign-in surface) light up. It is
|
|
* idempotent and best-effort — it never mints a second server guest (guarded on the absence of a session
|
|
* and the in-flight flag), and a too-old client (a swallowed update_required — no overlay) or an
|
|
* unreachable gateway just leaves the device a local guest until the next attempt. Local (device-only)
|
|
* games are never migrated. Native channel only; a no-op elsewhere.
|
|
*/
|
|
async function reconcileServerGuest(): Promise<void> {
|
|
if (reconcileInFlight || app.session) return;
|
|
const channel = clientChannel();
|
|
if (channel !== 'android' && channel !== 'ios') return;
|
|
reconcileInFlight = true;
|
|
try {
|
|
const s = await gateway.authGuestSilent(app.locale);
|
|
// The gateway answered, so heal the machine to online BEFORE adopting: adoptSession's profile fetch
|
|
// and live stream ride the normal (online) transport, which the offline kill switch would otherwise
|
|
// refuse. emit('callOk') is idempotent when already online (a re-entrant recover after adoption).
|
|
emit('callOk');
|
|
await adoptSession(s);
|
|
} catch {
|
|
// A too-old client (update_required, swallowed — stay a local guest, no overlay) or an unreachable
|
|
// gateway: remain offline; the recovery poll / online event retries when the network returns.
|
|
} finally {
|
|
reconcileInFlight = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
// A guest initiator's merge switches the session to the surviving durable account. Its
|
|
// device-local games (vs_ai / hotseat) were seated under the retired account's id, so
|
|
// re-point them to the survivor: the lobby and the game header identify "me" by the active
|
|
// account, and without this the merged-away game shows every seat as an opponent (the
|
|
// in-game turn logic is seat-index based, so it still plays — the summary just misreads).
|
|
const retiredId = app.session?.userId;
|
|
await adoptSession(r.session);
|
|
const survivorId = app.session?.userId;
|
|
if (retiredId && survivorId && retiredId !== survivorId) {
|
|
const { repointLocalGameSeats } = await import('./localgame/store');
|
|
await repointLocalGameSeats(retiredId, survivorId);
|
|
}
|
|
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();
|
|
// One-time cleanup of the retired deliberate-offline preference (offline is implicit now — the key is
|
|
// never read again). Runs for every channel, before the Mini-App branches return.
|
|
clearOfflinePref();
|
|
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 = telegramLaunchDiag();
|
|
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;
|
|
}
|
|
|
|
// The dedicated /vk/ entry opened without a signed VK launch (a plain browser tab, or a VK client
|
|
// that delivered no launch parameters) shows the compact, shareable launch diagnostic instead of
|
|
// silently proceeding as a guest — the VK counterpart of the /telegram/ launch-error screen. VK puts
|
|
// its signed parameters in the launch URL at load, so there is nothing to wait for (hence no Retry).
|
|
if (onVKPath() && !insideVK()) {
|
|
app.launchError = vkLaunchDiag();
|
|
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.
|
|
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();
|
|
// Wire the net-state machine's signal sources for the rest of the session: the OS connectivity hints
|
|
// (web navigator + native @capacitor/network), the smart recovery the store's watcher runs, and the
|
|
// offline/online toast. Reached only on web / native — the Telegram/VK branches returned above, so
|
|
// those channels are never fed an offline signal and the machine stays inert (always online) there.
|
|
registerNetToast((toast) => showToast(t(toast === 'offline' ? 'net.offline' : 'net.online')));
|
|
registerNetNudge(latchUpdateNudge);
|
|
registerRecovery(recover);
|
|
initNetSignals();
|
|
void initNativeNetwork();
|
|
|
|
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. 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: if the interface is
|
|
// down, or the gateway does not answer within a short window, launch straight from the cache into
|
|
// the implicit offline lobby (no dialog — offline is detected now). The store's recovery poll heals
|
|
// to online when the network returns. 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) {
|
|
bootOffline();
|
|
app.session = saved;
|
|
app.profile = cachedProfile;
|
|
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
|
|
app.ready = true;
|
|
return;
|
|
}
|
|
}
|
|
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 (clientChannel() === 'android' || clientChannel() === 'ios') {
|
|
// Native offline-first cold boot: no cached server session, so enter as a device-local guest in the
|
|
// implicit offline lobby and land straight there — never /login. bootOffline(true) drops into offline
|
|
// and kicks the recovery poll now; recover() mints + adopts a server guest and heals to online once
|
|
// the gateway is reachable, and until then the guest plays local vs_ai / hotseat with the APK's
|
|
// bundled dictionaries. Web / PWA / Telegram / VK keep the online-session rule below.
|
|
localGuestId(); // establish + persist the device-local identity from the very first launch
|
|
bootOffline(true); // offline-first + kick reconciliation now, then poll until the network returns
|
|
if (router.route.name === 'login' || router.route.name === 'confirm') 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 = telegramLaunchDiag();
|
|
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,
|
|
};
|
|
// Drive the native offline-first reconciliation from the e2e: the net-state store's recovery poll that
|
|
// fires it in a real build is inert under the mock, so the spec calls this to simulate "the network
|
|
// returned" and prove a server guest is minted + adopted and the machine heals to online.
|
|
(window as unknown as { __native?: { reconcile(): void } }).__native = {
|
|
reconcile: () => void reconcileServerGuest(),
|
|
};
|
|
}
|