Files
scrabble-game/ui/src/lib/app.svelte.ts
T
Ilia Denisov 0c5d3808d7
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) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
feat(ui): diagnostic screen on /telegram/ instead of the landing bounce
A Mini App launch on /telegram/ without sign-in data (empty initData) used to
location.replace('/') — bouncing the visitor to the marketing landing. That
destroyed all diagnosability and, on some Android clients that reach the entry
with no initData, simply looked like the app refusing to open.

Replace the bounce with a compact, privacy-safe launch-error screen
(screens/TelegramLaunchError.svelte) that renders a one-screenshot diagnostic
snapshot captured at the moment of failure: SDK/WebApp presence, Telegram
platform/version, whether initData is empty, whether the URL fragment carried
tgWebAppData, the initData field NAMES present/missing (never the signed
values, never an IP), and OS/mobile/browser via User-Agent Client Hints plus
the full User-Agent. A Share button delivers it through the OS share sheet
(clipboard copy on desktop, reusing the GCG no-webview-strand guard); Retry
re-checks in place to recover a late initData without a reload (which would
discard the launch fragment).

This is the instrument to root-cause the Android empty-initData failure, which
is not reproducible in Playwright.

Tests: collectTelegramDiag + shareText/pickTextShare unit tests; the /telegram/
e2e now asserts the diagnostic screen, not a redirect. Docs: ARCHITECTURE.md +
UI_DESIGN.md updated.
2026-06-23 09:37:36 +02:00

871 lines
36 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 { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import {
insideTelegram,
collectTelegramDiag,
type TelegramDiag,
onTelegramPath,
telegramColorScheme,
telegramContentSafeAreaTop,
telegramSafeAreaTop,
telegramDisableVerticalSwipes,
telegramHaptic,
telegramLaunch,
type TelegramLaunch,
telegramOnEvent,
telegramRequestFullscreen,
telegramSetChrome,
} from './telegram';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
import { isConnectionCode } from './retry';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
import { advanceCached } from './gamedelta';
import { clearLobby, patchLobbyGame, patchLobbyInvitation } from './lobbycache';
import type { BoardLabelMode } from './boardlabels';
export interface Toast {
kind: 'error' | 'info';
text: string;
/** A monotonic id bumped on every showToast, so the Toast component re-keys and replays its
* entrance — the freshest toast cancels the previous one and starts its own appear cycle. */
seq: number;
}
export const app = $state<{
ready: boolean;
/** Inside a Mini App, set when the launch failed to authenticate after its retries (e.g. the
* backend was down during a deploy). App.svelte then renders the boot-error retry screen
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
bootError: boolean;
/** 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 lobby's first cold load has settled (success or error). The loading splash
* (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
lobbyReady: boolean;
/** Whether the loading splash has finished and removed itself. Gates the App-level overlay
* so a warm intra-session return to the lobby shows no splash again. */
splashDone: boolean;
/** Whether the live-event stream is connected. The event hub is best-effort and never replays a
* missed event, so an open game waiting for an opponent uses this to recover: it polls the game
* state while the stream is down and refetches once on reconnect (see game/Game.svelte). */
streamAlive: boolean;
session: Session | null;
profile: Profile | null;
/** The caller's active manual block, or null. When set, App.svelte replaces every screen with
* the terminal blocked screen and all push/poll is stopped. */
blocked: BlockStatus | null;
toast: Toast | null;
lastEvent: PushEvent | null;
theme: ThemePref;
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
notifications: number;
/** Per-game flag: the player has at least one unread chat entry (message or nudge) in that
* game, for the lobby and in-game unread dot. Seeded from the authoritative REST views and
* raised by live chat/nudge events; cleared (with a backend ack) on opening the history/chat. */
chatUnread: Record<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;
/** 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,
lobbyReady: false,
splashDone: false,
streamAlive: false,
session: null,
profile: null,
blocked: null,
toast: null,
lastEvent: null,
theme: 'auto',
locale: 'en',
reduceMotion: false,
boardLabels: 'beginner',
notifications: 0,
chatUnread: {},
messageUnread: {},
feedbackReplyUnread: false,
staleInvite: false,
welcomeRedeem: false,
resync: 0,
});
let unsubscribeStream: (() => void) | null = null;
let reconnectTimer: ReturnType<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 rise-and-fade animation (2s); an error
// dwells longer (4s) so it is not missed.
toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 2000);
}
/** dismissToast hides the current toast at once — a tap on the bubble dismisses it. */
export function dismissToast(): void {
if (toastTimer) {
clearTimeout(toastTimer);
toastTimer = null;
}
app.toast = null;
}
/** dismissStaleInvite hides the outdated-invite-link notice (the user acknowledged it). */
export function dismissStaleInvite(): void {
app.staleInvite = false;
}
/** dismissWelcomeRedeem hides the Telegram welcome window (the user acknowledged it). */
export function dismissWelcomeRedeem(): void {
app.welcomeRedeem = false;
}
/**
* seedChatUnread sets a game's unread flags from an authoritative per-viewer REST view (the lobby
* list, a game's state, or a move result): unread is any unread entry, message whether one of them
* is a real message (the badge colour). The live-event GameView omits both, so the live stream
* raises them on receive rather than seeding from a GameView.
*/
export function seedChatUnread(gameId: string, unread: boolean, message: boolean): void {
if ((app.chatUnread[gameId] ?? false) !== unread) {
app.chatUnread = { ...app.chatUnread, [gameId]: unread };
}
if ((app.messageUnread[gameId] ?? false) !== message) {
app.messageUnread = { ...app.messageUnread, [gameId]: message };
}
}
/**
* markChatRead clears a game's unread badge and tells the backend the player has read the chat —
* called when the move history or the chat opens. It hits the backend only when something is
* actually unread, so opening the history does not call the backend on every tap. The clear is
* optimistic and the ack is best-effort: a failed ack leaves the server bit set, which the next
* authoritative REST view (lobby list / game state) re-seeds, so the badge self-heals — it is
* deliberately not restored here, since the in-game effect re-runs while the history is shown and
* a restore would loop on a persistently failing ack.
*/
export function markChatRead(gameId: string): void {
if (!app.chatUnread[gameId]) return;
app.chatUnread = { ...app.chatUnread, [gameId]: false };
if (app.messageUnread[gameId]) app.messageUnread = { ...app.messageUnread, [gameId]: false };
void gateway.markChatRead(gameId).catch(() => {});
}
/** handleError maps a GatewayError to a toast; an invalid session logs out. A connectivity
* failure — or anything raised while the app is mid-reconnect — is shown by the "Connecting…"
* header indicator (and auto-retried), never a red toast. */
export function handleError(err: unknown): void {
const code = err instanceof GatewayError ? err.code : '';
if (code === 'session_invalid' || code === 'unauthenticated') {
void logout();
return;
}
if (code === 'account_blocked') {
void enterBlocked();
return;
}
if (isConnectionCode(code) || !connection.online) return;
telegramHaptic('error');
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
}
/**
* enterBlocked switches the app to the terminal blocked screen and stops all push/poll. It is
* triggered whenever any call reports the "account_blocked" code. It flips the screen
* synchronously (a generic placeholder), then fetches the block's expiry and reason — the one
* endpoint a blocked client may still reach — to render the precise message. If that fetch reports
* the block was already lifted (a race), it recovers: clears the blocked screen and reopens the
* stream.
*/
export async function enterBlocked(): Promise<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();
}
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();
} 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();
// The live interface language follows the device — the explicit local choice (saved in
// prefs) or the system guess made at bootstrap — and is no longer overridden from the
// account here: the Telegram bot a user signs in through must not dictate the UI, so a
// ru-bot launch on an English system stays English.
//
// The banner and out-of-app push are resolved server-side from preferred_language, so it
// must track whatever language the UI actually shows — the explicit choice AND the system
// guess. Reconcile it to the active locale on every adopt, not only after an explicit
// Settings choice: a user who never opened Settings would otherwise be stuck on the
// creation-time seed — e.g. an English banner under a Russian UI. This keeps every
// server-rendered, language-dependent surface (banner, out-of-app push) aligned with the
// interface, not just one. persistLanguageToServer self-gates (a no-op for guests and when
// already equal), so there is no write in the steady state.
void persistLanguageToServer(app.locale);
} catch (err) {
handleError(err);
}
// A blocked account stays on the blocked screen: no live stream, no notification poll.
if (app.blocked) return;
openStream();
void refreshNotifications();
}
/**
* applyLinkResult applies a completed account link or merge: it adopts a
* switched session (a guest initiator whose durable counterpart won, so the active
* account changed) or, otherwise, refreshes the current profile in place.
*/
export async function applyLinkResult(r: LinkResult): Promise<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);
}
/**
* syncTelegramChrome paints Telegram's header/background/bottom bar from the app's live
* theme tokens, so the surrounding chrome matches the UI. Called after the theme is applied.
*/
function syncTelegramChrome(): void {
if (typeof document === 'undefined') return;
const cs = getComputedStyle(document.documentElement);
telegramSetChrome(
cs.getPropertyValue('--bg-elev').trim(),
cs.getPropertyValue('--bg').trim(),
cs.getPropertyValue('--bg-elev').trim(),
);
}
/**
* syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native
* nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a
* `tg-fullscreen` class, so the header can drop below the nav and centre the title in its
* band. Called on launch and on Telegram's safe-area / fullscreen change events.
*/
function syncTelegramSafeArea(): void {
if (typeof document === 'undefined') return;
const top = telegramContentSafeAreaTop();
document.documentElement.style.setProperty('--tg-content-top', `${top}px`);
document.documentElement.style.setProperty('--tg-safe-top', `${telegramSafeAreaTop()}px`);
document.documentElement.classList.toggle('tg-fullscreen', top > 0);
}
/**
* syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can
* fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard).
* On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible
* without the page scrolling, so the layout no longer jumps when the keyboard appears.
*/
function syncViewportHeight(): void {
if (typeof document === 'undefined') return;
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0;
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
}
/**
* applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative
* colour scheme and theme, the matching header / background / bottom chrome, the safe-area insets,
* the swipe-down guard, and immersive fullscreen on mobile. It is idempotent, so both the initial
* bootstrap and a manual launch retry call it.
*/
function applyTelegramChrome(launch: TelegramLaunch): void {
if (launch.theme) applyTelegramTheme(launch.theme);
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly so the OS
// prefers-color-scheme (which leaks into the Telegram Desktop webview) cannot fight it. Falls
// back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme);
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from fighting tile
// drag / board scroll.
syncTelegramChrome();
syncTelegramSafeArea();
telegramDisableVerticalSwipes();
// On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged
// listener (registered at bootstrap) then re-syncs the safe-area insets. Desktop keeps the bot's
// full-size window. No-op on clients predating Bot API 8.0.
telegramRequestFullscreen();
}
export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false;
app.boardLabels = prefs.boardLabels ?? 'beginner';
applyTheme(app.theme);
applyReduceMotion(app.reduceMotion);
if (prefs.locale) {
app.locale = prefs.locale;
setLocale(prefs.locale);
} else {
const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en');
app.locale = guess;
setLocale(guess);
}
// Track the visual-viewport height so screens fit above an open soft keyboard (--vvh).
syncViewportHeight();
if (typeof window !== 'undefined' && window.visualViewport) {
window.visualViewport.addEventListener('resize', syncViewportHeight);
window.visualViewport.addEventListener('scroll', syncViewportHeight);
}
// Telegram Mini App launch: apply the platform theme, authenticate via initData, and route any
// deep-link start parameter. On the dedicated /telegram/ entry 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);
// Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load).
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
await bootTelegram(launch);
app.ready = true;
return;
}
const saved = await loadSession();
if (saved) {
await adoptSession(saved);
if (router.route.name === 'login') navigate('/');
} else if (router.route.name !== 'login') {
navigate('/login');
}
app.ready = true;
}
// Inside a Mini App the only identity is the Telegram session, so a failed launch must never fall
// back to the web login screen. A transient backend outage (a deploy rolling over) is retried a
// few times in silence; only then does the boot-error screen surface, from which Retry re-runs the
// same path (retryTelegramBoot).
const TELEGRAM_BOOT_RETRIES = 2;
const TELEGRAM_BOOT_RETRY_MS = 1200;
function delay(ms: number): Promise<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);
}
}
}
/**
* retryTelegramBoot re-attempts the Mini App launch from the boot-error screen's Retry button. It
* clears the error and shows the loading state again, then runs the same retrying boot; on success
* the app renders normally, otherwise the boot-error screen returns.
*/
export async function retryTelegramBoot(): Promise<void> {
app.bootError = false;
app.ready = false;
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> {
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);
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);
}
}
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();
app.session = null;
app.profile = null;
navigate('/login');
}
function persistPrefs(): void {
void savePrefs({
theme: app.theme,
locale: app.locale,
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
});
}
export function setTheme(theme: ThemePref): void {
app.theme = theme;
applyTheme(theme);
persistPrefs();
}
export function setLocalePref(locale: Locale): void {
app.locale = locale;
setLocale(locale);
persistPrefs();
void persistLanguageToServer(locale);
}
/**
* persistLanguageToServer writes the chosen interface language through to the
* durable account's preferred_language, so the single Settings control is the
* source of truth (guests keep only the client preference). Best-effort.
*/
async function persistLanguageToServer(locale: Locale): Promise<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();
}
// 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 };
};
}