fix(ui): route external links out of the Android VK WebView

The Android VK client's WebView ignores target=_blank and navigates the
Mini App's own window to the target, stranding the player outside the
game with no way back (the dictionary lookup, About/Feedback links, the
ad banner and the bot-link modal fallbacks). vk-bridge 3.x has no method
to open an external URL, so external links are routed through VK's own
leave-VK redirect (vk.com/away.php), which the client intercepts
natively and hands to the system browser.

onExternalLinkClick moves from lib/telegram to a new lib/links that
composes the Telegram and VK routers; iOS and desktop VK open _blank
correctly and are left alone.
This commit is contained in:
Ilia Denisov
2026-07-02 00:09:52 +02:00
parent c864147982
commit db17287113
11 changed files with 200 additions and 25 deletions
+29
View File
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { openExternalUrl } from './links';
describe('openExternalUrl', () => {
afterEach(() => vi.unstubAllGlobals());
it('routes through the VK away redirect inside the Android VK client', () => {
const open = vi.fn();
vi.stubGlobal('location', {
pathname: '/vk/',
search: '?vk_user_id=1&vk_platform=mobile_android&sign=x',
href: 'https://app.example/vk/',
origin: 'https://app.example',
});
vi.stubGlobal('window', { open });
openExternalUrl('https://t.me/some_bot');
expect(open).toHaveBeenCalledWith(
`https://vk.com/away.php?to=${encodeURIComponent('https://t.me/some_bot')}`,
'_blank',
);
});
it('opens a plain new tab elsewhere', () => {
const open = vi.fn();
vi.stubGlobal('window', { open });
openExternalUrl('https://t.me/some_bot');
expect(open).toHaveBeenCalledWith('https://t.me/some_bot', '_blank');
});
});
+31
View File
@@ -0,0 +1,31 @@
// Platform-aware external-link opening. Both Mini App hosts mishandle a plain target=_blank
// anchor: Telegram shows the WebView's generic "open this link?" confirmation, and the Android VK
// client navigates the app's own WebView to the target with no way back to the game. The helpers
// below route an external URL through the platform's escape hatch (Telegram's openLink, VK's
// away.php redirect) and leave the anchor's own navigation / window.open in effect elsewhere.
import { routeExternalLinkInTelegram } from './telegram';
import { routeExternalLinkInVK, vkOpenExternalUrl } from './vk';
/**
* onExternalLinkClick is the anchor click handler for external links. 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 and VK 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) return;
if (routeExternalLinkInTelegram(a) || routeExternalLinkInVK(a)) e.preventDefault();
}
/**
* openExternalUrl opens an external URL from code (no anchor to click): through VK's external
* redirect inside the Android VK client, else a plain new tab. Telegram callers try their native
* opener (telegramOpenLink) first and use this as the fallback.
*/
export function openExternalUrl(url: string): void {
if (vkOpenExternalUrl(url)) return;
if (typeof window !== 'undefined') window.open(url, '_blank');
}
+3 -14
View File
@@ -179,9 +179,10 @@ export function telegramOpenExternalLink(url: string): boolean {
/**
* 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.
* genuinely external link is routed through the host platform's browser (the Telegram router here
* and the VK router in vk.ts share it).
*/
function isExternalHttpUrl(href: string): boolean {
export 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;
@@ -206,18 +207,6 @@ export function routeExternalLinkInTelegram(anchor: { href: string; target: stri
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
+70 -1
View File
@@ -1,5 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { insideVK, onVKPath, vkAppId, vkLaunchParams, vkStartParam, appearanceForBg } from './vk';
import {
insideVK,
onVKPath,
routeExternalLinkInVK,
vkAndroidWebView,
vkAppId,
vkExternalBrowserUrl,
vkLaunchParams,
vkStartParam,
appearanceForBg,
} from './vk';
describe('vk launch detection', () => {
afterEach(() => vi.unstubAllGlobals());
@@ -50,6 +60,65 @@ describe('vk launch params', () => {
});
});
describe('vk external links', () => {
afterEach(() => vi.unstubAllGlobals());
function stubVK(platform: string) {
const open = vi.fn();
vi.stubGlobal('location', {
pathname: '/vk/',
search: `?vk_user_id=1&vk_platform=${platform}&sign=x`,
href: 'https://app.example/vk/',
origin: 'https://app.example',
});
vi.stubGlobal('window', { open });
return open;
}
it('wraps a URL in the away.php redirect with the target encoded', () => {
const url = 'https://gramota.ru/poisk?query=кот&mode=slovari';
expect(vkExternalBrowserUrl(url)).toBe(`https://vk.com/away.php?to=${encodeURIComponent(url)}`);
});
it('detects the Android VK WebView from vk_platform', () => {
stubVK('mobile_android');
expect(vkAndroidWebView()).toBe(true);
stubVK('mobile_android_messenger');
expect(vkAndroidWebView()).toBe(true);
stubVK('mobile_iphone');
expect(vkAndroidWebView()).toBe(false);
stubVK('desktop_web');
expect(vkAndroidWebView()).toBe(false);
});
it('is not the Android WebView without a signed VK launch', () => {
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_platform=mobile_android' });
expect(vkAndroidWebView()).toBe(false);
});
it('routes an external _blank link through the away redirect on Android', () => {
const open = stubVK('mobile_android');
expect(routeExternalLinkInVK({ href: 'https://gramota.ru/poisk?query=кот', target: '_blank' })).toBe(true);
expect(open).toHaveBeenCalledWith(
`https://vk.com/away.php?to=${encodeURIComponent('https://gramota.ru/poisk?query=кот')}`,
'_blank',
);
});
it('leaves same-origin and non-_blank links to the browser', () => {
const open = stubVK('mobile_android');
expect(routeExternalLinkInVK({ href: 'https://app.example/lobby', target: '_blank' })).toBe(false);
expect(routeExternalLinkInVK({ href: 'https://gramota.ru', target: '' })).toBe(false);
expect(open).not.toHaveBeenCalled();
});
it('does nothing outside the Android VK client (iOS / desktop open _blank fine)', () => {
const open = stubVK('mobile_iphone');
expect(routeExternalLinkInVK({ href: 'https://gramota.ru', target: '_blank' })).toBe(false);
expect(open).not.toHaveBeenCalled();
});
});
describe('appearanceForBg', () => {
it('maps a dark background to the dark appearance (light status-bar icons)', () => {
expect(appearanceForBg('#0f1115')).toBe('dark');
+55 -1
View File
@@ -6,7 +6,7 @@
// (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to
// call outside VK.
import type { Haptic } from './telegram';
import { isExternalHttpUrl, type Haptic } from './telegram';
async function bridge() {
return (await import('@vkontakte/vk-bridge')).default;
@@ -87,6 +87,60 @@ export function vkStartParam(): string {
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