feat(ui): external dictionary lookup link on the word-check tool
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s

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'.
This commit is contained in:
Ilia Denisov
2026-06-15 18:14:19 +02:00
parent 4d6df4bd8b
commit 8e0d7f9e17
10 changed files with 113 additions and 8 deletions
+16 -1
View File
@@ -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');
});
});
+15
View File
@@ -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}`;
}
+1
View File
@@ -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.',
+2 -1
View File
@@ -92,7 +92,8 @@ export const ru: Record<MessageKey, string> = {
'game.checkWordPrompt': 'Введите слово',
'game.wordLegal': '«{word}» допустимо',
'game.wordIllegal': '«{word}» недопустимо',
'game.complain': 'Не согласен',
'game.complain': 'Возражаю',
'game.lookup': 'Поискать в словаре',
'game.complaintSent': 'Спасибо, отправлено на проверку.',
'game.check': 'Проверить',
'game.checkWait': 'Секунду, пожалуйста.',
+22 -1
View File
@@ -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);
});
});
+15
View File
@@ -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