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/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/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/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}
- +
+ + {#if result.legal} + {t('game.lookup')} + {/if} +
{/if}
@@ -120,12 +131,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..143053b 100644 --- a/ui/src/lib/telegram.test.ts +++ b/ui/src/lib/telegram.test.ts @@ -1,5 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { insideTelegram, telegramClosingConfirmation, telegramLaunch, telegramRequestFullscreen } from './telegram'; +import { + insideTelegram, + routeExternalLinkInTelegram, + telegramClosingConfirmation, + telegramLaunch, + telegramOpenExternalLink, + telegramRequestFullscreen, +} from './telegram'; function stubWebApp(initData: string, startParam?: string) { vi.stubGlobal('window', { @@ -100,3 +107,51 @@ 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); + }); +}); + +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 252252b..25f8d41 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,62 @@ 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; +} + +/** + * 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}