Files
scrabble-game/ui/src/lib/telegram.ts
T
Ilia Denisov bd0482c376
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 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
feat(ui): route all in-app external links through Telegram openLink
Extend the openLink routing from the dictionary lookup to every external
link shown inside the Mini App, so none triggers the WebView's 'open this
link?' confirmation. A shared onExternalLinkClick handler resolves the anchor
via closest() (so it also works delegated on {@html} content), backed by a
pure routeExternalLinkInTelegram decision: only inside Telegram, only an
external http(s) target=_blank link, excluding same-origin/in-app and t.me
links (t.me keeps openTelegramLink). Applied to the word-check lookup, the
About rules link, the Feedback operator-reply links, and the feature-gated
announcement banner.

Outside Telegram every anchor keeps its native target=_blank.
2026-06-15 18:29:53 +02:00

397 lines
15 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 };
platform?: string;
themeParams?: TelegramThemeParams;
colorScheme?: 'light' | 'dark';
isFullscreen?: boolean;
safeAreaInset?: { top: number; bottom: number; left: number; right: number };
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
ready?: () => void;
expand?: () => void;
requestFullscreen?: () => void;
openTelegramLink?: (url: string) => void;
openLink?: (url: string) => 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;
}
/**
* telegramOpenLink opens a t.me link through the Mini App SDK, so Telegram navigates to
* it natively (e.g. a bot chat) rather than spawning an in-app browser tab. Returns false
* outside Telegram or when the SDK lacks the method, so the caller can fall back to a plain
* window.open.
*/
export function telegramOpenLink(url: string): boolean {
const w = webApp();
if (!w?.openTelegramLink) return false;
w.openTelegramLink(url);
return true;
}
/**
* telegramOpenExternalLink opens an arbitrary external URL through the Mini App SDK's openLink,
* so Telegram opens it directly in its in-app browser instead of the WebView's generic "open
* this link?" confirmation that a plain target=_blank navigation triggers. Returns false
* outside Telegram or when the SDK lacks the method, so the caller can fall back to a normal
* anchor.
*/
export function telegramOpenExternalLink(url: string): boolean {
const w = webApp();
if (!w?.openLink) return false;
w.openLink(url);
return true;
}
/**
* isExternalHttpUrl reports whether href is an absolute http(s) URL on a different origin than the
* app — so a same-origin or root-relative in-app (SPA) link is left to normal navigation, only a
* genuinely external link is routed through Telegram's browser.
*/
function isExternalHttpUrl(href: string): boolean {
try {
const u = new URL(href, typeof location === 'undefined' ? undefined : location.href);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
return typeof location === 'undefined' || u.origin !== location.origin;
} catch {
return false;
}
}
/**
* routeExternalLinkInTelegram decides whether a clicked anchor should be opened through the Mini
* App SDK rather than the WebView's default navigation: only inside Telegram, and only for an
* external http(s) link opened in a new tab (target=_blank) that is not a t.me link (those use
* telegramOpenLink/openTelegramLink). Returns true when it opened the link through openLink — the
* caller should then preventDefault — and false to let the browser handle the click normally.
*/
export function routeExternalLinkInTelegram(anchor: { href: string; target: string }): boolean {
if (!insideTelegram()) return false;
if (anchor.target !== '_blank') return false;
if (anchor.href.startsWith('https://t.me/')) return false;
if (!isExternalHttpUrl(anchor.href)) return false;
return telegramOpenExternalLink(anchor.href);
}
/**
* onExternalLinkClick is the anchor click handler that applies routeExternalLinkInTelegram. It
* resolves the clicked anchor with closest(), so it works both attached directly on an <a> and
* delegated on a container that renders links (e.g. {@html} markdown). Attach it as onclick on an
* external link or its container; outside Telegram it is a no-op and the anchor's own
* target=_blank handles the click.
*/
export function onExternalLinkClick(e: MouseEvent): void {
const a = (e.target as HTMLElement | null)?.closest('a');
if (a && routeExternalLinkInTelegram(a)) e.preventDefault();
}
/**
* shareTelegramLink opens Telegram's native "share to a chat" picker for url with a
* caption, through the Mini App SDK (https://t.me/share/url). Returns false outside
* Telegram or when the SDK lacks the method, so the caller can fall back to the Web
* Share API. Works consistently on iOS and Android within Telegram.
*/
export function shareTelegramLink(url: string, text: string): boolean {
return telegramOpenLink(`https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`);
}
/** 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;
}
/**
* telegramSafeAreaTop returns the device safe-area top inset (px) — the notch / status bar
* (Bot API 8.0). Telegram's own nav controls sit in the band between it and
* telegramContentSafeAreaTop, so aligning our header to that band lines it up with them. 0
* outside Telegram or on older clients.
*/
export function telegramSafeAreaTop(): number {
return webApp()?.safeAreaInset?.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);
}
/**
* isMobilePlatform reports whether the Mini App runs on a Telegram mobile client — iOS or
* Android (the latter reported as 'android' by Telegram for Android and 'android_x' by
* Telegram X). Desktop clients (tdesktop, macOS, web) report other values. Used to limit
* mobile-only chrome such as the close guard and immersive fullscreen.
*/
function isMobilePlatform(): boolean {
const p = webApp()?.platform;
return p === 'ios' || p === 'android' || p === 'android_x';
}
/**
* telegramRequestFullscreen asks Telegram to open the Mini App in fullscreen (Bot API 8.0+),
* but only on mobile clients — mirroring how Telegram's own Mini Apps go immersive on phones
* while staying a standard window on desktop (where the bot's full-size setting already fills
* the window). A no-op outside Telegram, on desktop, or on clients predating the method.
*/
export function telegramRequestFullscreen(): void {
if (isMobilePlatform()) webApp()?.requestFullscreen?.();
}
/**
* 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. The
* guard is only armed on mobile clients: on desktop, closing a window is deliberate and
* Telegram surfaces a "changes may not be saved" dialog that is just noise here (drafts
* auto-save), so the confirmation is skipped there.
*/
export function telegramClosingConfirmation(on: boolean): void {
const w = webApp();
if (on) {
if (isMobilePlatform()) 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) ---
// 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();
}