Merge pull request 'feat(ui): external dictionary lookup link on the word-check tool' (#67) from feat/dict-lookup-link into development
This commit was merged in pull request #67.
This commit is contained in:
+3
-1
@@ -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
|
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
|
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
|
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
|
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
|
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,
|
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ nudge) приходят от бота **этой партии** — по язы
|
|||||||
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
|
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
|
||||||
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
|
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
|
||||||
отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и
|
отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и
|
||||||
предлагает пожаловаться на любой результат. Подсказки управляются настройками
|
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
||||||
|
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||||
|
чтобы его посмотреть. Подсказки управляются настройками
|
||||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
|
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
|
||||||
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
||||||
|
|||||||
+4
-1
@@ -73,7 +73,10 @@ Login uses `Screen`.
|
|||||||
**closing confirmation** is enabled while a game is open **on mobile only** (on desktop
|
**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
|
closing is deliberate and the "changes may not be saved" dialog is just noise — move drafts
|
||||||
auto-save); **vertical swipes** (swipe-to-minimise)
|
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
|
by a background suspend reconnects silently on return — the connection banner is
|
||||||
suppressed while hidden and for a short grace after resume (visibilitychange +
|
suppressed while hidden and for a short grace after resume (visibilitychange +
|
||||||
pageshow/pagehide + Telegram `activated`/`deactivated`).
|
pageshow/pagehide + Telegram `activated`/`deactivated`).
|
||||||
|
|||||||
@@ -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 page.locator('.check button').click(); // Check (enabled: length 3)
|
||||||
await expect(page.locator('.ok, .bad')).toBeVisible();
|
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 }) => {
|
test('dropping the game ends it and shows the result', async ({ page }) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
type BannerConfig,
|
type BannerConfig,
|
||||||
type BannerItem,
|
type BannerItem,
|
||||||
} from '../lib/banner';
|
} from '../lib/banner';
|
||||||
|
import { onExternalLinkClick } from '../lib/telegram';
|
||||||
|
|
||||||
let { items = mockBanners(), config = defaultBannerConfig }: { items?: BannerItem[]; config?: BannerConfig } =
|
let { items = mockBanners(), config = defaultBannerConfig }: { items?: BannerItem[]; config?: BannerConfig } =
|
||||||
$props();
|
$props();
|
||||||
@@ -39,7 +40,11 @@
|
|||||||
onDestroy(() => rotator?.stop());
|
onDestroy(() => rotator?.stop());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="ad" bind:this={viewport}>
|
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the
|
||||||
|
Telegram SDK (onExternalLinkClick uses closest('a')) so they skip the WebView confirmation. -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
|
||||||
{#key current}
|
{#key current}
|
||||||
<div
|
<div
|
||||||
class="track"
|
class="track"
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
import { handleError, showToast } from '../lib/app.svelte';
|
import { handleError, showToast } from '../lib/app.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { alphabetLetters } from '../lib/alphabet';
|
import { alphabetLetters } from '../lib/alphabet';
|
||||||
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
|
import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword';
|
||||||
|
import { onExternalLinkClick } from '../lib/telegram';
|
||||||
import type { Variant } from '../lib/model';
|
import type { Variant } from '../lib/model';
|
||||||
|
|
||||||
// Word-check on its own screen: unlimited dictionary lookups, each with a
|
// Word-check on its own screen: unlimited dictionary lookups, each with a
|
||||||
@@ -75,7 +76,17 @@
|
|||||||
? t('game.wordLegal', { word: result.word })
|
? t('game.wordLegal', { word: result.word })
|
||||||
: t('game.wordIllegal', { word: result.word })}
|
: t('game.wordIllegal', { word: result.word })}
|
||||||
</p>
|
</p>
|
||||||
<button class="complain" onclick={complain}>{t('game.complain')}</button>
|
<div class="actions">
|
||||||
|
<button class="complain" onclick={complain}>{t('game.complain')}</button>
|
||||||
|
{#if result.legal}
|
||||||
|
<a
|
||||||
|
class="lookup"
|
||||||
|
href={dictionaryLookupUrl(result.word, variant)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onclick={onExternalLinkClick}>{t('game.lookup')}</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,12 +131,21 @@
|
|||||||
.verdict.bad {
|
.verdict.bad {
|
||||||
color: var(--danger, #c0392b);
|
color: var(--danger, #c0392b);
|
||||||
}
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
.complain {
|
.complain {
|
||||||
align-self: flex-start;
|
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
.lookup {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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('');
|
const EN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||||
|
|
||||||
@@ -40,3 +40,18 @@ describe('canCheckWord', () => {
|
|||||||
expect(canCheckWord(' a ', false, false)).toBe(false);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
// variant alphabet, the length bounds, the answered-word cache and the cool-down
|
// variant alphabet, the length bounds, the answered-word cache and the cool-down
|
||||||
// throttle) are unit-testable and stay in lockstep with the UI.
|
// 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. */
|
/** The longest word that fits on a standard 15-cell board line. */
|
||||||
export const MAX_WORD_LEN = 15;
|
export const MAX_WORD_LEN = 15;
|
||||||
/** The shortest word worth checking. */
|
/** The shortest word worth checking. */
|
||||||
@@ -29,3 +31,16 @@ export function canCheckWord(word: string, alreadyChecked: boolean, cooling: boo
|
|||||||
const w = word.trim();
|
const w = word.trim();
|
||||||
return w.length >= MIN_WORD_LEN && w.length <= MAX_WORD_LEN && !alreadyChecked && !cooling;
|
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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export const en = {
|
|||||||
'game.wordLegal': '“{word}” is valid',
|
'game.wordLegal': '“{word}” is valid',
|
||||||
'game.wordIllegal': '“{word}” is not valid',
|
'game.wordIllegal': '“{word}” is not valid',
|
||||||
'game.complain': 'Disagree',
|
'game.complain': 'Disagree',
|
||||||
|
'game.lookup': 'Look it up',
|
||||||
'game.complaintSent': 'Thanks, sent for review.',
|
'game.complaintSent': 'Thanks, sent for review.',
|
||||||
'game.check': 'Check',
|
'game.check': 'Check',
|
||||||
'game.checkWait': 'Please wait a moment.',
|
'game.checkWait': 'Please wait a moment.',
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.checkWordPrompt': 'Введите слово',
|
'game.checkWordPrompt': 'Введите слово',
|
||||||
'game.wordLegal': '«{word}» допустимо',
|
'game.wordLegal': '«{word}» допустимо',
|
||||||
'game.wordIllegal': '«{word}» недопустимо',
|
'game.wordIllegal': '«{word}» недопустимо',
|
||||||
'game.complain': 'Не согласен',
|
'game.complain': 'Возражаю',
|
||||||
|
'game.lookup': 'Поискать в словаре',
|
||||||
'game.complaintSent': 'Спасибо, отправлено на проверку.',
|
'game.complaintSent': 'Спасибо, отправлено на проверку.',
|
||||||
'game.check': 'Проверить',
|
'game.check': 'Проверить',
|
||||||
'game.checkWait': 'Секунду, пожалуйста.',
|
'game.checkWait': 'Секунду, пожалуйста.',
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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) {
|
function stubWebApp(initData: string, startParam?: string) {
|
||||||
vi.stubGlobal('window', {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface TelegramWebApp {
|
|||||||
expand?: () => void;
|
expand?: () => void;
|
||||||
requestFullscreen?: () => void;
|
requestFullscreen?: () => void;
|
||||||
openTelegramLink?: (url: string) => void;
|
openTelegramLink?: (url: string) => void;
|
||||||
|
openLink?: (url: string) => void;
|
||||||
onEvent?: (event: string, handler: () => void) => void;
|
onEvent?: (event: string, handler: () => void) => void;
|
||||||
setHeaderColor?: (color: string) => void;
|
setHeaderColor?: (color: string) => void;
|
||||||
setBackgroundColor?: (color: string) => void;
|
setBackgroundColor?: (color: string) => void;
|
||||||
@@ -65,6 +66,62 @@ export function telegramOpenLink(url: string): boolean {
|
|||||||
return true;
|
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 <a> 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
|
* 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
|
* caption, through the Mini App SDK (https://t.me/share/url). Returns false outside
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { app } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { aboutContent } from '../lib/aboutContent';
|
import { aboutContent } from '../lib/aboutContent';
|
||||||
|
import { onExternalLinkClick } from '../lib/telegram';
|
||||||
|
|
||||||
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
|
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
|
||||||
const AUTO_MATCH_HOURS = 24;
|
const AUTO_MATCH_HOURS = 24;
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>{c.title}</h1>
|
<h1>{c.title}</h1>
|
||||||
<p>
|
<p>
|
||||||
{c.rulesPrefix}<a href={c.rulesUrl} target="_blank" rel="noopener noreferrer">{c.rulesLink}</a>.
|
{c.rulesPrefix}<a href={c.rulesUrl} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{c.rulesLink}</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { clientChannel } from '../lib/channel';
|
import { clientChannel } from '../lib/channel';
|
||||||
import { attachmentError, linkify, MAX_BODY } from '../lib/feedback';
|
import { attachmentError, linkify, MAX_BODY } from '../lib/feedback';
|
||||||
|
import { onExternalLinkClick } from '../lib/telegram';
|
||||||
import type { FeedbackState } from '../lib/model';
|
import type { FeedbackState } from '../lib/model';
|
||||||
|
|
||||||
// The feedback screen: a message (+ optional single attachment) the player sends to
|
// The feedback screen: a message (+ optional single attachment) the player sends to
|
||||||
@@ -123,7 +124,7 @@
|
|||||||
{#if view?.reply}
|
{#if view?.reply}
|
||||||
<section class="reply">
|
<section class="reply">
|
||||||
<h2>{t('feedback.replyTitle')}</h2>
|
<h2>{t('feedback.replyTitle')}</h2>
|
||||||
<p class="replybody">{#each replySegments as seg, i (i)}{#if seg.href}<a href={seg.href} target="_blank" rel="noopener noreferrer">{seg.text}</a>{:else}{seg.text}{/if}{/each}</p>
|
<p class="replybody">{#each replySegments as seg, i (i)}{#if seg.href}<a href={seg.href} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{seg.text}</a>{:else}{seg.text}{/if}{/each}</p>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user