// VK Mini App SDK access via @vkontakte/vk-bridge. The bridge is imported lazily inside the // functions that need it — not at module top level — because the SDK reads browser globals on // import: the lazy import keeps the pure URL helpers below importable in the node test environment, // and code-splits the bridge into a chunk loaded only on the /vk/ entry. This wraps the subset the // app uses: launch detection, the signed launch parameters (for auth.vk) and the user's display name // (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to // call outside VK. import { isExternalHttpUrl, type Haptic } from './telegram'; async function bridge() { return (await import('@vkontakte/vk-bridge')).default; } /** * onVKPath reports whether the app is served under the dedicated VK entry path (/vk/). */ export function onVKPath(): boolean { if (typeof location === 'undefined') return false; return location.pathname.startsWith('/vk/'); } /** * vkLaunchParams returns the raw signed VK launch query string (the vk_* parameters plus sign) from * the page URL — the exact form the gateway verifies — or '' when the URL carries no signed launch * (an ordinary browser tab, or the /vk/ path opened directly). */ export function vkLaunchParams(): string { if (typeof location === 'undefined') return ''; const query = location.search.replace(/^\?/, ''); return new URLSearchParams(query).has('sign') ? query : ''; } /** * insideVK reports whether the app launched as a VK Mini App — the URL carries signed launch * parameters (an ordinary browser tab has none). */ export function insideVK(): boolean { return vkLaunchParams() !== ''; } /** * vkInit signals to the VK client that the Mini App has loaded (VKWebAppInit), dismissing VK's own * loading cover. Best-effort: it resolves even if the bridge is unavailable (outside VK), so the * caller can await it unconditionally. */ export async function vkInit(): Promise { try { await (await bridge()).send('VKWebAppInit', {}); } catch { // Outside VK there is no client to receive it; the launch continues regardless. } } /** * vkClose closes the VK Mini App (VKWebAppClose), used on the terminal account-deleted * screen. Best-effort: a no-op outside VK. */ export async function vkClose(): Promise { try { await (await bridge()).send('VKWebAppClose', { status: 'success' }); } catch { // Outside VK there is no client to receive it. } } /** * vkUserName fetches the launching user's display name via VKWebAppGetUserInfo, since VK omits it * from the signed launch params. Returns '' on any failure or outside VK, so the backend falls back * to a generated placeholder. The value is a cosmetic seed only — being unsigned, the gateway never * trusts it for identity. */ export async function vkUserName(): Promise { try { const u = await (await bridge()).send('VKWebAppGetUserInfo', {}); return [u.first_name, u.last_name].filter(Boolean).join(' ').trim(); } catch { return ''; } } /** * vkAppId returns the launching VK app id (vk_app_id) from the signed launch parameters in the URL — * so the app can build its own vk.com/app links without a VK API call — or '' outside a VK launch. */ export function vkAppId(): string { if (typeof location === 'undefined') return ''; return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_app_id') ?? ''; } /** * vkStartParam returns the VK direct-link deep-link payload. VK passes everything after the '#' in a * vk.com/app# link to the app as the `hash` query parameter (it is also in location.hash, * but that collides with the app's hash router, so the query parameter is the safe source). Empty when * the launch carried no deep link. */ export function vkStartParam(): string { if (typeof location === 'undefined') return ''; return new URLSearchParams(location.search.replace(/^\?/, '')).get('hash') ?? ''; } /** * vkPlatform returns the VK client platform from the launch parameters (vk_platform: * mobile_android, mobile_iphone, desktop_web, mobile_web, …), or '' outside a VK launch. */ export function vkPlatform(): string { if (typeof location === 'undefined') return ''; return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_platform') ?? ''; } /** * vkAndroidWebView reports whether the app runs inside the Android VK client's WebView * (vk_platform mobile_android / mobile_android_messenger) — the one environment whose WebView * ignores target=_blank and navigates the app's own window instead of opening a browser. */ export function vkAndroidWebView(): boolean { return insideVK() && vkPlatform().startsWith('mobile_android'); } /** * vkExternalBrowserUrl wraps url in VK's own leave-VK redirect (vk.com/away.php). The VK mobile * client intercepts vk.com navigations natively and hands the redirect target to the system * browser, so an external link escapes the Mini App WebView instead of replacing the game. * vk-bridge (3.x) offers no method to open an external URL, so this redirect is the supported * escape hatch. Pure, so it is unit-tested. */ export function vkExternalBrowserUrl(url: string): string { return `https://vk.com/away.php?to=${encodeURIComponent(url)}`; } /** * vkOpenExternalUrl opens url in the system browser from inside the Android VK client by routing * it through vkExternalBrowserUrl. Returns false outside the Android VK WebView — iOS and the * desktop iframe open target=_blank correctly and are left alone — so the caller falls back to * the anchor's own navigation or a plain window.open. */ export function vkOpenExternalUrl(url: string): boolean { if (typeof window === 'undefined' || !vkAndroidWebView()) return false; window.open(vkExternalBrowserUrl(url), '_blank'); return true; } /** * routeExternalLinkInVK decides whether a clicked anchor must be opened through VK's external * redirect rather than the WebView's default navigation: only inside the Android VK client, and * only for an external http(s) link opened in a new tab (target=_blank). Returns true when it * opened the link — the caller should then preventDefault — and false to let the browser handle * the click normally. */ export function routeExternalLinkInVK(anchor: { href: string; target: string }): boolean { if (anchor.target !== '_blank') return false; if (!isExternalHttpUrl(anchor.href)) return false; return vkOpenExternalUrl(anchor.href); } /** * vkShare opens VK's native share dialog for link (VKWebAppShare) — the in-iframe replacement for * navigator.share, which is unavailable in the desktop VK iframe. Resolves true when the share was * handled, false on any failure or outside VK. */ export async function vkShare(link: string): Promise { try { await (await bridge()).send('VKWebAppShare', { link }); return true; } catch { return false; } } /** * vkShowImages opens VK's native image viewer on url — on the Android client this is the * PNG delivery: the viewer's own "save" works there, while VKWebAppDownloadFile hangs * indefinitely (on-device finding). Resolves false on any failure, so the caller can * fall back. */ export async function vkShowImages(url: string): Promise { try { await (await bridge()).send('VKWebAppShowImages', { images: [url] }); return true; } catch { return false; } } /** * vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) — * the mobile in-app file delivery, where the webview ignores . Resolves false * on any failure or where the method is unavailable (notably the desktop iframe), so the * caller falls back to a plain browser download of the same URL. */ export async function vkDownloadFile(url: string, filename: string): Promise { try { await (await bridge()).send('VKWebAppDownloadFile', { url, filename }); return true; } catch { return false; } } /** * vkCopyText copies text to the clipboard via VKWebAppCopyText — which works inside the VK iframe, * where navigator.clipboard is blocked. Resolves true on success, false on any failure or outside VK. */ export async function vkCopyText(text: string): Promise { try { await (await bridge()).send('VKWebAppCopyText', { text }); return true; } catch { return false; } } /** * vkOnScheme subscribes to VK's appearance (VKWebAppUpdateConfig) and calls handler with the mapped * 'light' | 'dark' scheme on launch and whenever the user switches the VK client theme — so the app's * "auto" theme can follow the VK client instead of the (often wrong) webview prefers-color-scheme. * A no-op outside VK. */ export async function vkOnScheme(handler: (scheme: 'light' | 'dark') => void): Promise { try { const b = await bridge(); b.subscribe((e) => { const detail = (e as { detail?: { type?: string; data?: { scheme?: string; appearance?: string } } }).detail; if (detail?.type !== 'VKWebAppUpdateConfig') return; const scheme = detail.data?.scheme ?? detail.data?.appearance ?? ''; handler(/dark|space_gray/i.test(scheme) ? 'dark' : 'light'); }); } catch { // Outside VK / bridge unavailable: leave the app on its own theme. } } /** VKInsets is the device safe-area the VK client reports (px). */ export interface VKInsets { top: number; bottom: number; left: number; right: number; } /** * vkOnInsets subscribes to VK's safe-area insets and calls handler with them on launch and on change. * VK reports them via VKWebAppUpdateConfig (iOS) and the dedicated VKWebAppUpdateInsets event; the VK * mobile webview does not expose them through CSS env() the way iOS Safari does, so the app reads them * from the bridge to clear the home bar (notably on Android). A no-op outside VK. */ export async function vkOnInsets(handler: (insets: VKInsets) => void): Promise { try { const b = await bridge(); b.subscribe((e) => { const d = (e as { detail?: { type?: string; data?: { insets?: Partial } } }).detail; if (d?.type !== 'VKWebAppUpdateConfig' && d?.type !== 'VKWebAppUpdateInsets') return; const i = d.data?.insets; if (!i) return; handler({ top: i.top ?? 0, bottom: i.bottom ?? 0, left: i.left ?? 0, right: i.right ?? 0 }); }); } catch { // Outside VK / bridge unavailable: the CSS env() safe-area fallback applies. } } /** * vkHaptic fires a VK Bridge haptic mirroring the Telegram set: a selection tick * (VKWebAppTapticSelectionChanged), a success/warning/error notification * (VKWebAppTapticNotificationOccurred) or a light/medium/heavy impact (VKWebAppTapticImpactOccurred). * Best-effort and a no-op outside VK or on a client without taptic support (the send rejects and is * swallowed), so callers fire it unconditionally. */ export async function vkHaptic(kind: Haptic): Promise { try { const b = await bridge(); if (kind === 'select') { await b.send('VKWebAppTapticSelectionChanged', {}); } else if (kind === 'success' || kind === 'warning' || kind === 'error') { await b.send('VKWebAppTapticNotificationOccurred', { type: kind }); } else { await b.send('VKWebAppTapticImpactOccurred', { style: kind }); } } catch { // Outside VK / no taptic support: silent, like the Telegram haptic off-platform. } } /** * vkDisableSwipeBack turns off VK's horizontal swipe-back gesture (VKWebAppSetSwipeSettings with * history:false) so it does not fight the app's own edge-swipe-back and on-board tile drag — the * app owns navigation through its back chevron, as on Telegram (telegramDisableVerticalSwipes). * Best-effort; a no-op outside VK or on a client without the method. */ export async function vkDisableSwipeBack(): Promise { try { await (await bridge()).send('VKWebAppSetSwipeSettings', { history: false }); } catch { // Outside VK / unsupported: VK's default gesture handling stays as-is. } } /** * appearanceForBg maps a background colour to the VK view appearance: a dark background wants the * 'dark' appearance (light status-bar icons), a light one 'light'. It accepts a `#rgb` / `#rrggbb` * string (the app's `--bg` token); anything unparseable defaults to 'light'. Pure, so it is * unit-tested. */ export function appearanceForBg(bg: string): 'light' | 'dark' { const hex = bg.trim().replace(/^#/, ''); const full = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex; if (full.length !== 6 || /[^0-9a-fA-F]/.test(full)) return 'light'; const r = parseInt(full.slice(0, 2), 16); const g = parseInt(full.slice(2, 4), 16); const b = parseInt(full.slice(4, 6), 16); // Rec. 601 luma; below the midpoint is a dark background. return 0.299 * r + 0.587 * g + 0.114 * b < 128 ? 'dark' : 'light'; } /** * vkSetViewSettings paints VK's status bar — and, on Android, the action and navigation bars — to * match the app. appearance is the VK status-bar appearance ('light' | 'dark'); actionBarColor and * navigationBarColor are Android-only hex colours (ignored elsewhere). Best-effort; a no-op outside * VK or on a client without the method. */ export async function vkSetViewSettings( appearance: 'light' | 'dark', actionBarColor: string, navigationBarColor: string, ): Promise { try { await (await bridge()).send('VKWebAppSetViewSettings', { status_bar_style: appearance, action_bar_color: actionBarColor, navigation_bar_color: navigationBarColor, }); } catch { // Outside VK / unsupported: the client keeps its default bars. } }