feat(ui): route all in-app external links through Telegram openLink
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s

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.
This commit is contained in:
Ilia Denisov
2026-06-15 18:29:53 +02:00
parent 8e0d7f9e17
commit bd0482c376
7 changed files with 92 additions and 12 deletions
+4 -1
View File
@@ -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`).
+6 -1
View File
@@ -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());
</script>
<div class="ad" bind:this={viewport}>
<!-- 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. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
{#key current}
<div
class="track"
+2 -8
View File
@@ -5,7 +5,7 @@
import { t } from '../lib/i18n/index.svelte';
import { alphabetLetters } from '../lib/alphabet';
import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword';
import { telegramOpenExternalLink } from '../lib/telegram';
import { onExternalLinkClick } from '../lib/telegram';
import type { Variant } from '../lib/model';
// Word-check on its own screen: unlimited dictionary lookups, each with a
@@ -58,12 +58,6 @@
handleError(e);
}
}
// Inside Telegram, route the dictionary link through the Mini App SDK so Telegram opens it in
// its in-app browser instead of the WebView's "open this link?" confirmation; in a plain
// browser the anchor's own target=_blank handles it.
function openLookup(e: MouseEvent) {
if (telegramOpenExternalLink((e.currentTarget as HTMLAnchorElement).href)) e.preventDefault();
}
</script>
<div class="wrap">
@@ -90,7 +84,7 @@
href={dictionaryLookupUrl(result.word, variant)}
target="_blank"
rel="noopener noreferrer"
onclick={openLookup}>{t('game.lookup')}</a>
onclick={onExternalLinkClick}>{t('game.lookup')}</a>
{/if}
</div>
{/if}
+34
View File
@@ -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);
});
});
+42
View File
@@ -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 <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
+2 -1
View File
@@ -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 @@
<div class="page">
<h1>{c.title}</h1>
<p>
{c.rulesPrefix}<a href={c.rulesUrl} target="_blank" rel="noopener noreferrer">{c.rulesLink}</a>.
{c.rulesPrefix}<a href={c.rulesUrl} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{c.rulesLink}</a>.
</p>
<section>
+2 -1
View File
@@ -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}
<section class="reply">
<h2>{t('feedback.replyTitle')}</h2>
<p class="replybody">{#each replySegments as seg, i (i)}{#if seg.href}<a href={seg.href} target="_blank" rel="noopener noreferrer">{seg.text}</a>{:else}{seg.text}{/if}{/each}</p>
<p class="replybody">{#each replySegments as seg, i (i)}{#if seg.href}<a href={seg.href} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{seg.text}</a>{:else}{seg.text}{/if}{/each}</p>
</section>
{/if}
</div>