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:
@@ -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 @@
|
||||
</script>
|
||||
|
||||
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the
|
||||
Telegram SDK (onExternalLinkClick uses closest('a')) so they skip the WebView confirmation. -->
|
||||
platform opener (onExternalLinkClick uses closest('a')): the Telegram SDK skips the WebView
|
||||
confirmation, the VK away-redirect keeps the Android WebView on the game. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user