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}