12d128f1cc
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
The "waiting for your move" popup was the nudge (chat.nudge), shown without a
sender name. Resolve the nudger's per-game seat name server-side and carry it on
a new NudgeEvent.sender_name field, so:
- the in-app toast reads "<opponent>: Waiting for your move 🤭" (chat.nudgeBy);
- the out-of-app Telegram push names the sender too (render nudgeBy);
falling back to the plain phrase when the name is absent (RU mirrored). The
your_turn toast already named the opponent and is unchanged.
Lobby: when a card transitions into "your turn" or "finished" while the lobby is
open, its status emoji blinks twice (two 1s fades); the opponent's-turn change
stays in place. Blink state is keyed by game id (SvelteSet + per-id nonce/timer)
so overlapping events animate in isolation; suppressed under reduce-motion.
Toast: a per-message seq re-keys Toast.svelte, so the freshest toast cancels the
previous one and replays its entrance, uniformly on every screen.
Tests: notify.Nudge round-trip + render named/fallback (Go), game.Service.SeatName
(integration), codec/i18n/gamePhase/shouldBlink (UI). Docs (FUNCTIONAL +_ru,
UI_DESIGN) + PLAN TODO-7 (deferred FLIP card-relocation animation) updated.
700 lines
27 KiB
TypeScript
700 lines
27 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 { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
|
|
import {
|
|
insideTelegram,
|
|
onTelegramPath,
|
|
telegramColorScheme,
|
|
telegramContentSafeAreaTop,
|
|
telegramSafeAreaTop,
|
|
telegramDisableVerticalSwipes,
|
|
telegramHaptic,
|
|
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;
|
|
/** 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;
|
|
/** Draw grid lines between board cells; off (default) is a gapless checkerboard. */
|
|
boardLines: boolean;
|
|
localeLocked: boolean;
|
|
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
|
|
notifications: number;
|
|
/** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */
|
|
chatUnread: Record<string, number>;
|
|
/** 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;
|
|
/** 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,
|
|
streamAlive: false,
|
|
session: null,
|
|
profile: null,
|
|
blocked: null,
|
|
toast: null,
|
|
lastEvent: null,
|
|
theme: 'auto',
|
|
locale: 'en',
|
|
reduceMotion: false,
|
|
boardLabels: 'beginner',
|
|
boardLines: false,
|
|
localeLocked: false,
|
|
notifications: 0,
|
|
chatUnread: {},
|
|
feedbackReplyUnread: false,
|
|
staleInvite: 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);
|
|
toastTimer = setTimeout(() => (app.toast = null), 4000);
|
|
}
|
|
|
|
/** dismissStaleInvite hides the outdated-invite-link notice (the user acknowledged it). */
|
|
export function dismissStaleInvite(): void {
|
|
app.staleInvite = false;
|
|
}
|
|
|
|
/** clearChatUnread resets a game's unread chat-message count (called when its chat is opened). */
|
|
export function clearChatUnread(gameId: string): void {
|
|
if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 };
|
|
}
|
|
|
|
/** 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) {
|
|
if (e.message.kind !== 'nudge') {
|
|
const gid = e.message.gameId;
|
|
app.chatUnread = { ...app.chatUnread, [gid]: (app.chatUnread[gid] ?? 0) + 1 };
|
|
}
|
|
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
|
|
}
|
|
} else if (e.kind === 'nudge') {
|
|
// Name the nudger (their per-game seat name, carried on the event), so the toast reads
|
|
// "<opponent>: …" like the your-turn toast; fall back to the plain phrase when absent.
|
|
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 (locked, saved
|
|
// in prefs) or the system guess made at bootstrap — and is no longer overridden from the
|
|
// account here. preferred_language stays the user's saved choice (written from Settings,
|
|
// and used for out-of-app push routing), but the Telegram bot a user signs in through must
|
|
// not dictate the UI: a ru-bot launch on an English system stays English.
|
|
} 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();
|
|
}
|
|
|
|
/**
|
|
* syncTelegramChrome paints Telegram's header/background/bottom bar from the app's live
|
|
* theme tokens, so the surrounding chrome matches the UI. Called after the theme is applied.
|
|
*/
|
|
function syncTelegramChrome(): void {
|
|
if (typeof document === 'undefined') return;
|
|
const cs = getComputedStyle(document.documentElement);
|
|
telegramSetChrome(
|
|
cs.getPropertyValue('--bg-elev').trim(),
|
|
cs.getPropertyValue('--bg').trim(),
|
|
cs.getPropertyValue('--bg-elev').trim(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native
|
|
* nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a
|
|
* `tg-fullscreen` class, so the header can drop below the nav and centre the title in its
|
|
* band. Called on launch and on Telegram's safe-area / fullscreen change events.
|
|
*/
|
|
function syncTelegramSafeArea(): void {
|
|
if (typeof document === 'undefined') return;
|
|
const top = telegramContentSafeAreaTop();
|
|
document.documentElement.style.setProperty('--tg-content-top', `${top}px`);
|
|
document.documentElement.style.setProperty('--tg-safe-top', `${telegramSafeAreaTop()}px`);
|
|
document.documentElement.classList.toggle('tg-fullscreen', top > 0);
|
|
}
|
|
|
|
/**
|
|
* syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can
|
|
* fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard).
|
|
* On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible
|
|
* without the page scrolling, so the layout no longer jumps when the keyboard appears.
|
|
*/
|
|
function syncViewportHeight(): void {
|
|
if (typeof document === 'undefined') return;
|
|
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
|
const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0;
|
|
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
|
|
}
|
|
|
|
export async function bootstrap(): Promise<void> {
|
|
const prefs = await loadPrefs();
|
|
app.theme = prefs.theme ?? 'auto';
|
|
app.reduceMotion = prefs.reduceMotion ?? false;
|
|
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
|
app.boardLines = prefs.boardLines ?? false;
|
|
applyTheme(app.theme);
|
|
applyReduceMotion(app.reduceMotion);
|
|
if (prefs.locale) {
|
|
app.locale = prefs.locale;
|
|
app.localeLocked = true;
|
|
setLocale(prefs.locale);
|
|
} else {
|
|
const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en');
|
|
app.locale = guess;
|
|
setLocale(guess);
|
|
}
|
|
|
|
// Track the visual-viewport height so screens fit above an open soft keyboard (--vvh).
|
|
syncViewportHeight();
|
|
if (typeof window !== 'undefined' && window.visualViewport) {
|
|
window.visualViewport.addEventListener('resize', syncViewportHeight);
|
|
window.visualViewport.addEventListener('scroll', syncViewportHeight);
|
|
}
|
|
|
|
// Telegram Mini App launch: apply the platform theme, authenticate via initData,
|
|
// and route any deep-link start parameter. On the dedicated /telegram/ entry path
|
|
// outside Telegram (no initData), refuse to render and send the visitor to the
|
|
// site root.
|
|
if (onTelegramPath() && !insideTelegram()) {
|
|
if (typeof location !== 'undefined') location.replace('/');
|
|
return;
|
|
}
|
|
if (insideTelegram()) {
|
|
const launch = telegramLaunch();
|
|
if (launch.theme) applyTelegramTheme(launch.theme);
|
|
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly
|
|
// so the OS prefers-color-scheme (which leaks into the Telegram Desktop webview)
|
|
// cannot fight it. Falls back to the stored preference when the SDK omits it.
|
|
applyTheme(telegramColorScheme() ?? app.theme);
|
|
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from
|
|
// fighting tile drag / board scroll.
|
|
syncTelegramChrome();
|
|
syncTelegramSafeArea();
|
|
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
|
|
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
|
|
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
|
|
telegramDisableVerticalSwipes();
|
|
// On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged
|
|
// listener above then re-syncs the safe-area insets. Desktop keeps the bot's full-size
|
|
// window. No-op on clients predating Bot API 8.0.
|
|
telegramRequestFullscreen();
|
|
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);
|
|
} catch (err) {
|
|
handleError(err);
|
|
navigate('/login');
|
|
}
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
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();
|
|
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,
|
|
boardLines: app.boardLines,
|
|
});
|
|
}
|
|
|
|
export function setTheme(theme: ThemePref): void {
|
|
app.theme = theme;
|
|
applyTheme(theme);
|
|
persistPrefs();
|
|
}
|
|
|
|
export function setLocalePref(locale: Locale): void {
|
|
app.locale = locale;
|
|
app.localeLocked = true;
|
|
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 || p.isGuest || p.preferredLanguage === 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,
|
|
});
|
|
} 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 setBoardLines(on: boolean): void {
|
|
app.boardLines = 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 };
|
|
};
|
|
}
|