@@ -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}