From bd0482c3766bcb65e006b42fa9ddfde8eb10494a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 18:29:53 +0200 Subject: [PATCH] feat(ui): route all in-app external links through Telegram openLink Extend the openLink routing from the dictionary lookup to every external link shown inside the Mini App, so none triggers the WebView's 'open this link?' confirmation. A shared onExternalLinkClick handler resolves the anchor via closest() (so it also works delegated on {@html} content), backed by a pure routeExternalLinkInTelegram decision: only inside Telegram, only an external http(s) target=_blank link, excluding same-origin/in-app and t.me links (t.me keeps openTelegramLink). Applied to the word-check lookup, the About rules link, the Feedback operator-reply links, and the feature-gated announcement banner. Outside Telegram every anchor keeps its native target=_blank. --- docs/UI_DESIGN.md | 5 +++- ui/src/components/AdBanner.svelte | 7 +++++- ui/src/game/CheckScreen.svelte | 10 ++------ ui/src/lib/telegram.test.ts | 34 +++++++++++++++++++++++++ ui/src/lib/telegram.ts | 42 +++++++++++++++++++++++++++++++ ui/src/screens/About.svelte | 3 ++- ui/src/screens/Feedback.svelte | 3 ++- 7 files changed, 92 insertions(+), 12 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index a5dca0a..2a6bfd3 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -73,7 +73,10 @@ Login uses `Screen`. **closing confirmation** is enabled while a game is open **on mobile only** (on desktop closing is deliberate and the "changes may not be saved" dialog is just noise — move drafts auto-save); **vertical swipes** (swipe-to-minimise) - are disabled so they don't fight tile drag or the board scroll; and a live stream dropped + are disabled so they don't fight tile drag or the board scroll; **external links** (the word-check + dictionary lookup, the rules link, operator-reply links) open through `Telegram.WebApp.openLink` + so Telegram shows them in its in-app browser instead of the WebView's "open this link?" + confirmation a plain `target=_blank` triggers; and a live stream dropped by a background suspend reconnects silently on return — the connection banner is suppressed while hidden and for a short grace after resume (visibilitychange + pageshow/pagehide + Telegram `activated`/`deactivated`). diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index e5d2e04..b9e37fc 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -9,6 +9,7 @@ type BannerConfig, type BannerItem, } from '../lib/banner'; + import { onExternalLinkClick } from '../lib/telegram'; let { items = mockBanners(), config = defaultBannerConfig }: { items?: BannerItem[]; config?: BannerConfig } = $props(); @@ -39,7 +40,11 @@ onDestroy(() => rotator?.stop()); -
+ + + +
{#key current}
@@ -90,7 +84,7 @@ href={dictionaryLookupUrl(result.word, variant)} target="_blank" rel="noopener noreferrer" - onclick={openLookup}>{t('game.lookup')} + onclick={onExternalLinkClick}>{t('game.lookup')} {/if}
{/if} diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts index 36b6b01..143053b 100644 --- a/ui/src/lib/telegram.test.ts +++ b/ui/src/lib/telegram.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { insideTelegram, + routeExternalLinkInTelegram, telegramClosingConfirmation, telegramLaunch, telegramOpenExternalLink, @@ -121,3 +122,36 @@ describe('telegramOpenExternalLink', () => { expect(telegramOpenExternalLink('https://x.io')).toBe(false); }); }); + +describe('routeExternalLinkInTelegram', () => { + afterEach(() => vi.unstubAllGlobals()); + + function stubInside(openLink = vi.fn(), origin = 'https://app.example') { + vi.stubGlobal('window', { Telegram: { WebApp: { initData: 'query_id=abc', openLink } } }); + vi.stubGlobal('location', { href: origin + '/', origin }); + return openLink; + } + + it('routes an external http(s) _blank link through openLink inside Telegram', () => { + const openLink = stubInside(); + expect(routeExternalLinkInTelegram({ href: 'https://gramota.ru/poisk?query=кот', target: '_blank' })).toBe(true); + expect(openLink).toHaveBeenCalledWith('https://gramota.ru/poisk?query=кот'); + }); + + it('leaves a t.me link to the native handler (openTelegramLink owns those)', () => { + const openLink = stubInside(); + expect(routeExternalLinkInTelegram({ href: 'https://t.me/some_bot', target: '_blank' })).toBe(false); + expect(openLink).not.toHaveBeenCalled(); + }); + + it('leaves same-origin and non-_blank links to in-app navigation', () => { + const openLink = stubInside(vi.fn(), 'https://app.example'); + expect(routeExternalLinkInTelegram({ href: 'https://app.example/lobby', target: '_blank' })).toBe(false); + expect(routeExternalLinkInTelegram({ href: 'https://gramota.ru', target: '' })).toBe(false); + expect(openLink).not.toHaveBeenCalled(); + }); + + it('does nothing outside Telegram (the anchor opens natively)', () => { + expect(routeExternalLinkInTelegram({ href: 'https://x.io', target: '_blank' })).toBe(false); + }); +}); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index 3088e58..25f8d41 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -80,6 +80,48 @@ export function telegramOpenExternalLink(url: string): boolean { return true; } +/** + * 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. + */ +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; + return typeof location === 'undefined' || u.origin !== location.origin; + } catch { + return false; + } +} + +/** + * routeExternalLinkInTelegram decides whether a clicked anchor should be opened through the Mini + * App SDK rather than the WebView's default navigation: only inside Telegram, and only for an + * external http(s) link opened in a new tab (target=_blank) that is not a t.me link (those use + * telegramOpenLink/openTelegramLink). Returns true when it opened the link through openLink — the + * caller should then preventDefault — and false to let the browser handle the click normally. + */ +export function routeExternalLinkInTelegram(anchor: { href: string; target: string }): boolean { + if (!insideTelegram()) return false; + if (anchor.target !== '_blank') return false; + if (anchor.href.startsWith('https://t.me/')) return false; + if (!isExternalHttpUrl(anchor.href)) return false; + 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/screens/About.svelte b/ui/src/screens/About.svelte index 0daa6f3..bb1c541 100644 --- a/ui/src/screens/About.svelte +++ b/ui/src/screens/About.svelte @@ -3,6 +3,7 @@ import { app } from '../lib/app.svelte'; import { navigate } from '../lib/router.svelte'; import { aboutContent } from '../lib/aboutContent'; + import { onExternalLinkClick } from '../lib/telegram'; // The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h). const AUTO_MATCH_HOURS = 24; @@ -13,7 +14,7 @@

{c.title}

- {c.rulesPrefix}{c.rulesLink}. + {c.rulesPrefix}{c.rulesLink}.

diff --git a/ui/src/screens/Feedback.svelte b/ui/src/screens/Feedback.svelte index 8a39455..af6b2b5 100644 --- a/ui/src/screens/Feedback.svelte +++ b/ui/src/screens/Feedback.svelte @@ -7,6 +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 type { FeedbackState } from '../lib/model'; // The feedback screen: a message (+ optional single attachment) the player sends to @@ -123,7 +124,7 @@ {#if view?.reply}

{t('feedback.replyTitle')}

-

{#each replySegments as seg, i (i)}{#if seg.href}{seg.text}{:else}{seg.text}{/if}{/each}

+

{#each replySegments as seg, i (i)}{#if seg.href}{seg.text}{:else}{seg.text}{/if}{/each}

{/if}