From 8e0d7f9e17d191b02095798da97d177c1b996804 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 18:14:19 +0200 Subject: [PATCH 1/2] feat(ui): external dictionary lookup link on the word-check tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a checked word is found, show a 'look it up' text link beside the complaint button that opens an external reference dictionary in a new tab: gramota.ru for the Russian variants, scrabblewordfinder.org for English (word lower-cased and percent-encoded). The link hides for a word that is not found. Inside Telegram it routes through the Mini App SDK's openLink, so Telegram opens it directly instead of the WebView's 'open this link?' confirmation; in a browser the anchor's own target=_blank handles it. Relabel the complaint button to 'Возражаю' (ru); English stays 'Disagree'. --- docs/FUNCTIONAL.md | 4 +++- docs/FUNCTIONAL_ru.md | 4 +++- ui/e2e/game.spec.ts | 7 +++++++ ui/src/game/CheckScreen.svelte | 32 +++++++++++++++++++++++++++++--- ui/src/lib/checkword.test.ts | 17 ++++++++++++++++- ui/src/lib/checkword.ts | 15 +++++++++++++++ ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 3 ++- ui/src/lib/telegram.test.ts | 23 ++++++++++++++++++++++- ui/src/lib/telegram.ts | 15 +++++++++++++++ 10 files changed, 113 insertions(+), 8 deletions(-) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 36c1c57..f5193e6 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -111,7 +111,9 @@ an existing word (down a column or across a row) is accepted. A play is validate against the game's dictionary at submit time and scored; an unlimited preview reports the word(s) a tentative move would form and its score, or that it is not legal, and the move is offered for submission only once it is confirmed legal. The dictionary check tool is -unlimited and offers a complaint on any result. Hints are governed per game — +unlimited and offers a complaint on any result; for a word it finds, it also links out to an +external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for +English) to look it up. Hints are governed per game — whether they are allowed and how many each player starts with — and draw on a personal hint wallet once the per-game allowance is spent. The game ends when the bag empties and a player clears their rack, after 6 consecutive scoreless turns, diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 2a7d38a..a08598a 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -115,7 +115,9 @@ nudge) приходят от бота **этой партии** — по язы сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и -предлагает пожаловаться на любой результат. Подсказки управляются настройками +предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на +внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских), +чтобы его посмотреть. Подсказки управляются настройками партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index 35c62a1..db5c8a1 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -221,6 +221,13 @@ test('check-word sanitises input and shows a verdict', async ({ page }) => { await page.locator('.check button').click(); // Check (enabled: length 3) await expect(page.locator('.ok, .bad')).toBeVisible(); + + // A found word offers an external dictionary lookup link that opens in a new tab; the + // verdict still carries the complaint button. + const lookup = page.getByRole('link', { name: 'Look it up' }); + await expect(lookup).toBeVisible(); + await expect(lookup).toHaveAttribute('target', '_blank'); + await expect(lookup).toHaveAttribute('href', /qza$/); }); test('dropping the game ends it and shows the result', async ({ page }) => { diff --git a/ui/src/game/CheckScreen.svelte b/ui/src/game/CheckScreen.svelte index b9889dd..b4c3ea3 100644 --- a/ui/src/game/CheckScreen.svelte +++ b/ui/src/game/CheckScreen.svelte @@ -4,7 +4,8 @@ import { handleError, showToast } from '../lib/app.svelte'; import { t } from '../lib/i18n/index.svelte'; import { alphabetLetters } from '../lib/alphabet'; - import { canCheckWord, sanitizeCheckWord } from '../lib/checkword'; + import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword'; + import { telegramOpenExternalLink } from '../lib/telegram'; import type { Variant } from '../lib/model'; // Word-check on its own screen: unlimited dictionary lookups, each with a @@ -57,6 +58,12 @@ 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(); + }
@@ -75,7 +82,17 @@ ? t('game.wordLegal', { word: result.word }) : t('game.wordIllegal', { word: result.word })}

- +
+ + {#if result.legal} + {t('game.lookup')} + {/if} +
{/if}
@@ -120,12 +137,21 @@ .verdict.bad { color: var(--danger, #c0392b); } + .actions { + display: flex; + align-items: center; + gap: 12px; + } .complain { - align-self: flex-start; padding: 8px 14px; border: 1px solid var(--border); background: var(--surface); color: var(--text); border-radius: var(--radius-sm); } + .lookup { + color: var(--accent); + text-decoration: underline; + font-size: 0.95em; + } diff --git a/ui/src/lib/checkword.test.ts b/ui/src/lib/checkword.test.ts index 5a05ccf..e675160 100644 --- a/ui/src/lib/checkword.test.ts +++ b/ui/src/lib/checkword.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { canCheckWord, MAX_WORD_LEN, sanitizeCheckWord } from './checkword'; +import { canCheckWord, dictionaryLookupUrl, MAX_WORD_LEN, sanitizeCheckWord } from './checkword'; const EN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); @@ -40,3 +40,18 @@ describe('canCheckWord', () => { expect(canCheckWord(' a ', false, false)).toBe(false); }); }); + +describe('dictionaryLookupUrl', () => { + it('points the Russian variants at gramota.ru with a lower-cased, encoded query', () => { + expect(dictionaryLookupUrl('КОТ', 'scrabble_ru')).toBe( + 'https://gramota.ru/poisk?query=' + encodeURIComponent('кот'), + ); + expect(dictionaryLookupUrl('ЭРА', 'erudit_ru')).toBe( + 'https://gramota.ru/poisk?query=' + encodeURIComponent('эра'), + ); + }); + + it('points English at scrabblewordfinder.org with a lower-cased path', () => { + expect(dictionaryLookupUrl('CAT', 'scrabble_en')).toBe('https://scrabblewordfinder.org/dictionary/cat'); + }); +}); diff --git a/ui/src/lib/checkword.ts b/ui/src/lib/checkword.ts index cdb32d6..89e50af 100644 --- a/ui/src/lib/checkword.ts +++ b/ui/src/lib/checkword.ts @@ -3,6 +3,8 @@ // variant alphabet, the length bounds, the answered-word cache and the cool-down // throttle) are unit-testable and stay in lockstep with the UI. +import type { Variant } from './model'; + /** The longest word that fits on a standard 15-cell board line. */ export const MAX_WORD_LEN = 15; /** The shortest word worth checking. */ @@ -29,3 +31,16 @@ export function canCheckWord(word: string, alreadyChecked: boolean, cooling: boo const w = word.trim(); return w.length >= MIN_WORD_LEN && w.length <= MAX_WORD_LEN && !alreadyChecked && !cooling; } + +/** + * dictionaryLookupUrl returns the external reference-dictionary search URL for a checked word + * in the given variant: gramota.ru for the Russian variants (scrabble_ru, erudit_ru) and + * scrabblewordfinder.org for English. The word is lower-cased and percent-encoded, so a + * Cyrillic query travels safely. + */ +export function dictionaryLookupUrl(word: string, variant: Variant): string { + const w = encodeURIComponent(word.toLowerCase()); + return variant.endsWith('_ru') + ? `https://gramota.ru/poisk?query=${w}` + : `https://scrabblewordfinder.org/dictionary/${w}`; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 7aaabe1..7092e19 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -92,6 +92,7 @@ export const en = { 'game.wordLegal': '“{word}” is valid', 'game.wordIllegal': '“{word}” is not valid', 'game.complain': 'Disagree', + 'game.lookup': 'Look it up', 'game.complaintSent': 'Thanks, sent for review.', 'game.check': 'Check', 'game.checkWait': 'Please wait a moment.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index fa5e58f..5107cc1 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -92,7 +92,8 @@ export const ru: Record = { 'game.checkWordPrompt': 'Введите слово', 'game.wordLegal': '«{word}» допустимо', 'game.wordIllegal': '«{word}» недопустимо', - 'game.complain': 'Не согласен', + 'game.complain': 'Возражаю', + 'game.lookup': 'Поискать в словаре', 'game.complaintSent': 'Спасибо, отправлено на проверку.', 'game.check': 'Проверить', 'game.checkWait': 'Секунду, пожалуйста.', diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts index 0428034..36b6b01 100644 --- a/ui/src/lib/telegram.test.ts +++ b/ui/src/lib/telegram.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { insideTelegram, telegramClosingConfirmation, telegramLaunch, telegramRequestFullscreen } from './telegram'; +import { + insideTelegram, + telegramClosingConfirmation, + telegramLaunch, + telegramOpenExternalLink, + telegramRequestFullscreen, +} from './telegram'; function stubWebApp(initData: string, startParam?: string) { vi.stubGlobal('window', { @@ -100,3 +106,18 @@ describe('telegramRequestFullscreen', () => { } }); }); + +describe('telegramOpenExternalLink', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('routes an external URL through the SDK openLink inside Telegram', () => { + const openLink = vi.fn(); + vi.stubGlobal('window', { Telegram: { WebApp: { openLink } } }); + expect(telegramOpenExternalLink('https://gramota.ru/poisk?query=кот')).toBe(true); + expect(openLink).toHaveBeenCalledWith('https://gramota.ru/poisk?query=кот'); + }); + + it('returns false without the SDK so the caller falls back to the anchor', () => { + expect(telegramOpenExternalLink('https://x.io')).toBe(false); + }); +}); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index 252252b..3088e58 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -18,6 +18,7 @@ interface TelegramWebApp { expand?: () => void; requestFullscreen?: () => void; openTelegramLink?: (url: string) => void; + openLink?: (url: string) => void; onEvent?: (event: string, handler: () => void) => void; setHeaderColor?: (color: string) => void; setBackgroundColor?: (color: string) => void; @@ -65,6 +66,20 @@ export function telegramOpenLink(url: string): boolean { return true; } +/** + * telegramOpenExternalLink opens an arbitrary external URL through the Mini App SDK's openLink, + * so Telegram opens it directly in its in-app browser instead of the WebView's generic "open + * this link?" confirmation that a plain target=_blank navigation triggers. Returns false + * outside Telegram or when the SDK lacks the method, so the caller can fall back to a normal + * anchor. + */ +export function telegramOpenExternalLink(url: string): boolean { + const w = webApp(); + if (!w?.openLink) return false; + w.openLink(url); + return true; +} + /** * 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.52.0 From bd0482c3766bcb65e006b42fa9ddfde8eb10494a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 18:29:53 +0200 Subject: [PATCH 2/2] 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}
-- 2.52.0