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