diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte
index 2a527fa..28b1e10 100644
--- a/ui/src/components/AdBanner.svelte
+++ b/ui/src/components/AdBanner.svelte
@@ -9,7 +9,7 @@
} from '../lib/bannerEngine';
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
import type { BannerCampaign, BannerTimings } from '../lib/model';
- import { onExternalLinkClick } from '../lib/telegram';
+ import { onExternalLinkClick } from '../lib/links';
let {
campaigns,
@@ -112,7 +112,8 @@
+ platform opener (onExternalLinkClick uses closest('a')): the Telegram SDK skips the WebView
+ confirmation, the VK away-redirect keeps the Android WebView on the game. -->
diff --git a/ui/src/components/StaleInviteModal.svelte b/ui/src/components/StaleInviteModal.svelte
index 1ababd8..b06579b 100644
--- a/ui/src/components/StaleInviteModal.svelte
+++ b/ui/src/components/StaleInviteModal.svelte
@@ -4,6 +4,7 @@
import { t } from '../lib/i18n/index.svelte';
import { botUsername } from '../lib/deeplink';
import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram';
+ import { openExternalUrl } from '../lib/links';
import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs';
import Modal from './Modal.svelte';
@@ -16,8 +17,8 @@
if (username) {
const url = `https://t.me/${username}`;
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
- // back to a new tab.
- if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank');
+ // back to a new tab (routed out of the Android VK WebView by openExternalUrl).
+ if (!telegramOpenLink(url)) openExternalUrl(url);
}
dismissStaleInvite();
}
diff --git a/ui/src/components/WelcomeRedeemModal.svelte b/ui/src/components/WelcomeRedeemModal.svelte
index 9cbc2d9..3bd0c29 100644
--- a/ui/src/components/WelcomeRedeemModal.svelte
+++ b/ui/src/components/WelcomeRedeemModal.svelte
@@ -4,6 +4,7 @@
import { t } from '../lib/i18n/index.svelte';
import { botUsername } from '../lib/deeplink';
import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram';
+ import { openExternalUrl } from '../lib/links';
import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs';
import Modal from './Modal.svelte';
@@ -19,8 +20,8 @@
if (username) {
const url = `https://t.me/${username}`;
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
- // back to a new tab.
- if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank');
+ // back to a new tab (routed out of the Android VK WebView by openExternalUrl).
+ if (!telegramOpenLink(url)) openExternalUrl(url);
}
dismissWelcomeRedeem();
}
diff --git a/ui/src/game/CheckScreen.svelte b/ui/src/game/CheckScreen.svelte
index 173afae..67a5a2a 100644
--- a/ui/src/game/CheckScreen.svelte
+++ b/ui/src/game/CheckScreen.svelte
@@ -5,7 +5,7 @@
import { t } from '../lib/i18n/index.svelte';
import { alphabetLetters } from '../lib/alphabet';
import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword';
- import { onExternalLinkClick } from '../lib/telegram';
+ import { onExternalLinkClick } from '../lib/links';
import type { Variant } from '../lib/model';
// Word-check on its own screen: unlimited dictionary lookups, each with a
diff --git a/ui/src/lib/links.test.ts b/ui/src/lib/links.test.ts
new file mode 100644
index 0000000..895c5f9
--- /dev/null
+++ b/ui/src/lib/links.test.ts
@@ -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');
+ });
+});
diff --git a/ui/src/lib/links.ts b/ui/src/lib/links.ts
new file mode 100644
index 0000000..c520e09
--- /dev/null
+++ b/ui/src/lib/links.ts
@@ -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
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');
+}
diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts
index c47f414..0cff5b3 100644
--- a/ui/src/lib/telegram.ts
+++ b/ui/src/lib/telegram.ts
@@ -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 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
diff --git a/ui/src/lib/vk.test.ts b/ui/src/lib/vk.test.ts
index c3a9c41..82a7df8 100644
--- a/ui/src/lib/vk.test.ts
+++ b/ui/src/lib/vk.test.ts
@@ -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');
diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts
index 806a034..f45a896 100644
--- a/ui/src/lib/vk.ts
+++ b/ui/src/lib/vk.ts
@@ -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
diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte
index bb1c541..62f3abc 100644
--- a/ui/src/screens/About.svelte
+++ b/ui/src/screens/About.svelte
@@ -3,7 +3,7 @@
import { app } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte';
import { aboutContent } from '../lib/aboutContent';
- import { onExternalLinkClick } from '../lib/telegram';
+ import { onExternalLinkClick } from '../lib/links';
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
const AUTO_MATCH_HOURS = 24;
diff --git a/ui/src/screens/Feedback.svelte b/ui/src/screens/Feedback.svelte
index af6b2b5..2067a1d 100644
--- a/ui/src/screens/Feedback.svelte
+++ b/ui/src/screens/Feedback.svelte
@@ -7,7 +7,7 @@
import { connection } from '../lib/connection.svelte';
import { clientChannel } from '../lib/channel';
import { attachmentError, linkify, MAX_BODY } from '../lib/feedback';
- import { onExternalLinkClick } from '../lib/telegram';
+ import { onExternalLinkClick } from '../lib/links';
import type { FeedbackState } from '../lib/model';
// The feedback screen: a message (+ optional single attachment) the player sends to