71c3411276
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
- Tap-highlight: add -webkit-tap-highlight-color: transparent on #app. Android in-app WebViews (Telegram, VK) flashed a momentary selection-like box on tappable nodes on tap — seen on the header title and the back chevron; user-select (already none) does not govern it. Inherited, so this clears it app-wide. - VK haptics: mirror the Telegram haptic set on VK via VK Bridge taptic (impact / notification / selection), routed through a new shared lib/haptics.ts dispatcher. VK users previously got no haptics; the game and error call sites now fire haptic() instead of telegramHaptic(). - VK swipe-back: disable VK's horizontal swipe-back at launch (VKWebAppSetSwipeSettings history:false) so it does not fight the app's own edge-swipe-back and on-board tile drag — parity with Telegram's disabled vertical swipes; the app owns navigation via its back chevron. Docs: UI_DESIGN (no-select / tap-highlight, VK integration).
201 lines
8.0 KiB
TypeScript
201 lines
8.0 KiB
TypeScript
// 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 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<void> {
|
|
try {
|
|
await (await bridge()).send('VKWebAppInit', {});
|
|
} catch {
|
|
// Outside VK there is no client to receive it; the launch continues regardless.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<string> {
|
|
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<id> 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<id>#<payload> 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') ?? '';
|
|
}
|
|
|
|
/**
|
|
* 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<boolean> {
|
|
try {
|
|
await (await bridge()).send('VKWebAppShare', { link });
|
|
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<boolean> {
|
|
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<void> {
|
|
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<void> {
|
|
try {
|
|
const b = await bridge();
|
|
b.subscribe((e) => {
|
|
const d = (e as { detail?: { type?: string; data?: { insets?: Partial<VKInsets> } } }).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<void> {
|
|
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<void> {
|
|
try {
|
|
await (await bridge()).send('VKWebAppSetSwipeSettings', { history: false });
|
|
} catch {
|
|
// Outside VK / unsupported: VK's default gesture handling stays as-is.
|
|
}
|
|
}
|