34385240b9
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
Backlog item 2 of ~4 (owner review pass): - USSR flag emblem redrawn (canonical hammer & sickle, scaled down 1.5x below the star). - Touch drag-and-drop: enlarge the drag ghost 1.5x on touch only (the finger hides the tile); suppress the iOS tap-highlight that lingered on a rack tile sliding into a dragged tile's slot. - Telegram fullscreen: its native nav no longer hides our header -- the header drops below the content-safe-area top inset and the menu (hamburger) lifts into the nav band, centred (--tg-content-top from the SDK inset + a tg-fullscreen class; new telegram.ts helper + app wiring). Tests: UI check/test:unit/build + full e2e (60) green. The iOS tap-highlight fix and the TG-fullscreen layout want on-device verification on the deploy.
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
// Telegram Mini App SDK access. The official telegram-web-app.js (loaded in
|
|
// index.html) exposes window.Telegram.WebApp; this wraps the subset the app uses:
|
|
// launch detection, initData (for auth.telegram), the deep-link start parameter,
|
|
// theme params, and ready()/expand(). Every helper is safe to call outside Telegram.
|
|
|
|
import type { TelegramThemeParams } from './theme';
|
|
|
|
interface TelegramWebApp {
|
|
initData: string;
|
|
initDataUnsafe?: { start_param?: string };
|
|
themeParams?: TelegramThemeParams;
|
|
colorScheme?: 'light' | 'dark';
|
|
isFullscreen?: boolean;
|
|
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
|
ready?: () => void;
|
|
expand?: () => void;
|
|
onEvent?: (event: string, handler: () => void) => void;
|
|
setHeaderColor?: (color: string) => void;
|
|
setBackgroundColor?: (color: string) => void;
|
|
setBottomBarColor?: (color: string) => void;
|
|
disableVerticalSwipes?: () => void;
|
|
enableClosingConfirmation?: () => void;
|
|
disableClosingConfirmation?: () => void;
|
|
HapticFeedback?: {
|
|
impactOccurred?: (style: string) => void;
|
|
notificationOccurred?: (type: string) => void;
|
|
selectionChanged?: () => void;
|
|
};
|
|
BackButton?: {
|
|
show?: () => void;
|
|
hide?: () => void;
|
|
onClick?: (cb: () => void) => void;
|
|
offClick?: (cb: () => void) => void;
|
|
};
|
|
}
|
|
|
|
function webApp(): TelegramWebApp | undefined {
|
|
if (typeof window === 'undefined') return undefined;
|
|
return (window as unknown as { Telegram?: { WebApp?: TelegramWebApp } }).Telegram?.WebApp;
|
|
}
|
|
|
|
/**
|
|
* insideTelegram reports whether the app launched as a Telegram Mini App — the SDK
|
|
* is present and carries non-empty initData (an ordinary browser tab has neither).
|
|
*/
|
|
export function insideTelegram(): boolean {
|
|
const w = webApp();
|
|
return !!w && typeof w.initData === 'string' && w.initData.length > 0;
|
|
}
|
|
|
|
/** TelegramLaunch is the data a Mini App launch carries. */
|
|
export interface TelegramLaunch {
|
|
initData: string;
|
|
startParam: string;
|
|
theme: TelegramThemeParams | undefined;
|
|
}
|
|
|
|
/**
|
|
* telegramLaunch readies the Mini App (full-height, ready signal) and returns its
|
|
* launch data: the raw initData (for auth.telegram), the deep-link start parameter
|
|
* (from the SDK or, for a bot web_app button, the page URL), and the theme params.
|
|
*/
|
|
export function telegramLaunch(): TelegramLaunch {
|
|
const w = webApp();
|
|
if (!w) return { initData: '', startParam: startParamFromURL(), theme: undefined };
|
|
w.ready?.();
|
|
w.expand?.();
|
|
const startParam = w.initDataUnsafe?.start_param ?? startParamFromURL();
|
|
return { initData: w.initData, startParam, theme: w.themeParams };
|
|
}
|
|
|
|
/**
|
|
* telegramOnEvent subscribes to a Telegram WebApp lifecycle event (e.g. 'activated' /
|
|
* 'deactivated', added in Bot API 8.0). It is a no-op outside Telegram or on a client
|
|
* that predates the event, so callers can register defensively.
|
|
*/
|
|
export function telegramOnEvent(event: string, handler: () => void): void {
|
|
webApp()?.onEvent?.(event, handler);
|
|
}
|
|
|
|
/**
|
|
* telegramColorScheme returns Telegram's active colour scheme ('light' | 'dark'),
|
|
* or undefined outside Telegram. Inside the Mini App this — not the OS
|
|
* prefers-color-scheme — is the authoritative theme: on some clients (Telegram
|
|
* Desktop) the OS scheme leaks into the webview and fights Telegram's own setting,
|
|
* so the app forces this value on launch.
|
|
*/
|
|
export function telegramColorScheme(): 'light' | 'dark' | undefined {
|
|
return webApp()?.colorScheme;
|
|
}
|
|
|
|
/**
|
|
* telegramSetChrome paints Telegram's own header, background and bottom bar to match the
|
|
* app's colours, so the surrounding Telegram chrome does not clash with the UI. No-op
|
|
* outside Telegram or on a client predating a given setter.
|
|
*/
|
|
export function telegramSetChrome(header: string, background: string, bottom: string): void {
|
|
const w = webApp();
|
|
if (header) w?.setHeaderColor?.(header);
|
|
if (background) w?.setBackgroundColor?.(background);
|
|
if (bottom) w?.setBottomBarColor?.(bottom);
|
|
}
|
|
|
|
/**
|
|
* telegramContentSafeAreaTop returns the height (px) Telegram's own UI overlays at the top of
|
|
* the viewport in fullscreen (its nav band; the content-safe area, Bot API 8.0). It is 0
|
|
* outside Telegram or on clients predating it, so callers can pad/position defensively.
|
|
*/
|
|
export function telegramContentSafeAreaTop(): number {
|
|
return webApp()?.contentSafeAreaInset?.top ?? 0;
|
|
}
|
|
|
|
/**
|
|
* telegramDisableVerticalSwipes turns off Telegram's swipe-down-to-minimise gesture so
|
|
* it does not fight tile drag-and-drop or the board's vertical scroll.
|
|
*/
|
|
export function telegramDisableVerticalSwipes(): void {
|
|
webApp()?.disableVerticalSwipes?.();
|
|
}
|
|
|
|
/** Haptic is the set of feedbacks the app triggers. */
|
|
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
|
|
|
/** telegramHaptic fires a Telegram haptic; a no-op outside Telegram or on older clients. */
|
|
export function telegramHaptic(kind: Haptic): void {
|
|
const h = webApp()?.HapticFeedback;
|
|
if (!h) return;
|
|
if (kind === 'select') h.selectionChanged?.();
|
|
else if (kind === 'success' || kind === 'error' || kind === 'warning') h.notificationOccurred?.(kind);
|
|
else h.impactOccurred?.(kind);
|
|
}
|
|
|
|
/**
|
|
* telegramClosingConfirmation toggles the confirmation Telegram shows when the user
|
|
* swipes the Mini App closed — enabled during an active game so it is not lost by accident.
|
|
*/
|
|
export function telegramClosingConfirmation(on: boolean): void {
|
|
const w = webApp();
|
|
if (on) w?.enableClosingConfirmation?.();
|
|
else w?.disableClosingConfirmation?.();
|
|
}
|
|
|
|
let backHandler: (() => void) | null = null;
|
|
|
|
/**
|
|
* telegramBackButton shows or hides Telegram's native header back button, wiring its
|
|
* click to onClick (replacing any previous handler). The app hides its own back chevron
|
|
* inside Telegram so only the native control shows.
|
|
*/
|
|
export function telegramBackButton(show: boolean, onClick?: () => void): void {
|
|
const b = webApp()?.BackButton;
|
|
if (!b) return;
|
|
if (backHandler) b.offClick?.(backHandler);
|
|
backHandler = null;
|
|
if (show) {
|
|
if (onClick) {
|
|
backHandler = onClick;
|
|
b.onClick?.(onClick);
|
|
}
|
|
b.show?.();
|
|
} else {
|
|
b.hide?.();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
|
|
* launch button carries the deep-link there rather than in initDataUnsafe.
|
|
*/
|
|
function startParamFromURL(): string {
|
|
if (typeof location === 'undefined') return '';
|
|
return new URLSearchParams(location.search).get('startapp') ?? '';
|
|
}
|
|
|
|
/**
|
|
* onTelegramPath reports whether the app is served under the dedicated Telegram
|
|
* entry path (/telegram/); outside Telegram on that path the app refuses to render.
|
|
*/
|
|
export function onTelegramPath(): boolean {
|
|
if (typeof location === 'undefined') return false;
|
|
return location.pathname.startsWith('/telegram/');
|
|
}
|
|
|
|
// --- Login Widget (web sign-in for account linking, Stage 11) ---
|
|
|
|
// The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to
|
|
// attach a Telegram identity to an existing account from a browser; inside the Mini
|
|
// App the session is already a Telegram identity. It needs the bot id (numeric,
|
|
// VITE_TELEGRAM_BOT_ID) and, in production, the site domain registered with BotFather
|
|
// (/setdomain) — without that Telegram refuses to render. The connector validates the
|
|
// returned data (HMAC under SHA-256(bot_token)).
|
|
|
|
const widgetScriptSrc = 'https://telegram.org/js/telegram-widget.js?22';
|
|
|
|
interface telegramAuthUser {
|
|
id: number;
|
|
first_name?: string;
|
|
last_name?: string;
|
|
username?: string;
|
|
photo_url?: string;
|
|
auth_date: number;
|
|
hash: string;
|
|
}
|
|
|
|
interface telegramLoginSDK {
|
|
auth(opts: { bot_id: string; request_access?: string }, cb: (user: telegramAuthUser | false) => void): void;
|
|
}
|
|
|
|
function isMock(): boolean {
|
|
return import.meta.env.MODE === 'mock';
|
|
}
|
|
|
|
function botID(): string {
|
|
return (import.meta.env.VITE_TELEGRAM_BOT_ID as string | undefined) ?? '';
|
|
}
|
|
|
|
/**
|
|
* loginWidgetAvailable reports whether the "Link Telegram" control should be shown:
|
|
* not already inside the Mini App, and either the mock build or a configured bot id.
|
|
*/
|
|
export function loginWidgetAvailable(): boolean {
|
|
if (insideTelegram()) return false;
|
|
return isMock() || botID() !== '';
|
|
}
|
|
|
|
let widgetLoad: Promise<void> | null = null;
|
|
|
|
function loadWidget(): Promise<void> {
|
|
if (typeof document === 'undefined') return Promise.reject(new Error('telegram: no document'));
|
|
const sdk = (window as unknown as { Telegram?: { Login?: telegramLoginSDK } }).Telegram?.Login;
|
|
if (sdk) return Promise.resolve();
|
|
if (!widgetLoad) {
|
|
widgetLoad = new Promise<void>((resolve, reject) => {
|
|
const s = document.createElement('script');
|
|
s.src = widgetScriptSrc;
|
|
s.async = true;
|
|
s.onload = () => resolve();
|
|
s.onerror = () => reject(new Error('telegram: widget load failed'));
|
|
document.head.appendChild(s);
|
|
});
|
|
}
|
|
return widgetLoad;
|
|
}
|
|
|
|
/**
|
|
* requestTelegramLogin drives the Login Widget popup and resolves with the auth data
|
|
* serialized as a URL query string (id=...&auth_date=...&hash=...) — the form the
|
|
* connector validates — or null when the user cancels. In the mock build it returns
|
|
* a fixed payload without loading the real widget (telegram.org is blocked in tests).
|
|
*/
|
|
export async function requestTelegramLogin(): Promise<string | null> {
|
|
if (isMock()) {
|
|
return `id=42&first_name=Telegram&auth_date=${Math.floor(Date.now() / 1000)}&hash=mock`;
|
|
}
|
|
await loadWidget();
|
|
const login = (window as unknown as { Telegram?: { Login?: telegramLoginSDK } }).Telegram?.Login;
|
|
if (!login) throw new Error('telegram: login unavailable');
|
|
const user = await new Promise<telegramAuthUser | false>((resolve) => {
|
|
login.auth({ bot_id: botID(), request_access: 'write' }, resolve);
|
|
});
|
|
if (!user) return null;
|
|
return serializeTelegramAuth(user);
|
|
}
|
|
|
|
function serializeTelegramAuth(u: telegramAuthUser): string {
|
|
const params = new URLSearchParams();
|
|
params.set('id', String(u.id));
|
|
if (u.first_name) params.set('first_name', u.first_name);
|
|
if (u.last_name) params.set('last_name', u.last_name);
|
|
if (u.username) params.set('username', u.username);
|
|
if (u.photo_url) params.set('photo_url', u.photo_url);
|
|
params.set('auth_date', String(u.auth_date));
|
|
params.set('hash', u.hash);
|
|
return params.toString();
|
|
}
|