Merge pull request 'fix(ui): VK Android external links + landing VK entry + drop 1️⃣ badge' (#156) from feature/vk-links-landing into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 57s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s

This commit was merged in pull request #156.
This commit is contained in:
2026-07-01 22:39:26 +00:00
29 changed files with 290 additions and 65 deletions
+1
View File
@@ -335,6 +335,7 @@ jobs:
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
VITE_VK_APP_LINK: ${{ vars.TEST_VITE_VK_APP_LINK }}
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
# Unset vars render empty -> the compose ":-" defaults apply. # Unset vars render empty -> the compose ":-" defaults apply.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
+1
View File
@@ -40,6 +40,7 @@ jobs:
VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.PROD_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
VITE_VK_APP_LINK: ${{ vars.PROD_VITE_VK_APP_LINK }}
VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }} VITE_GATEWAY_URL: ${{ vars.PROD_VITE_GATEWAY_URL }}
POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }} POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }}
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
+1
View File
@@ -32,6 +32,7 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
VITE_TELEGRAM_BOT_ID= VITE_TELEGRAM_BOT_ID=
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>) VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
VITE_GATEWAY_URL= VITE_GATEWAY_URL=
# --- Grafana ---------------------------------------------------------------- # --- Grafana ----------------------------------------------------------------
+3 -2
View File
@@ -92,9 +92,10 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. | | `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>``<app>` is the Mini App short name from BotFather). | | `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>``<app>` is the Mini App short name from BotFather). |
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). | | `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
| `VITE_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
The five `VITE_*` are **build-args** baked into the gateway and landing images at The six `VITE_*` are **build-args** baked into the gateway and landing images at
build time (both targets share one UI build stage — keep the args identical so it is build time (both targets share one UI build stage — keep the args identical so it is
built once), so changing them requires a rebuild (`--build`), not just a restart. built once), so changing them requires a rebuild (`--build`), not just a restart.
@@ -198,7 +199,7 @@ BOTLINK_GATEWAY_CERT, BOTLINK_GATEWAY_KEY, BOTLINK_BOT_CERT, BOTLINK_BOT_KEY}`;
`PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER, `PROD_{REGISTRY_USER, MAIN_HOST, TG_HOST, CADDY_SITE_ADDRESS, GM_BASICAUTH_USER,
GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID, GRAFANA_ROOT_URL, LOG_LEVEL, DICT_VERSION, TELEGRAM_MINIAPP_URL, TELEGRAM_GAME_CHANNEL_ID,
TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK, TELEGRAM_CHAT_ID, TELEGRAM_BOT_USERNAME, VITE_TELEGRAM_BOT_ID, VITE_TELEGRAM_LINK,
VITE_TELEGRAM_GAME_CHANNEL_NAME}`. VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK}`.
## Host-side setup (outside this repo) ## Host-side setup (outside this repo)
+2
View File
@@ -134,6 +134,7 @@ services:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev} VITE_APP_VERSION: ${APP_VERSION:-dev}
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag). # Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
@@ -207,6 +208,7 @@ services:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
VITE_VK_APP_LINK: ${VITE_VK_APP_LINK:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev} VITE_APP_VERSION: ${APP_VERSION:-dev}
restart: unless-stopped restart: unless-stopped
+1 -1
View File
@@ -21,7 +21,7 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A
costs nothing when the rack has no legal move. The word-check accepts only the costs nothing when the rack has no legal move. The word-check accepts only the
variant's alphabet, remembers answers within the session and rate-limits repeats. variant's alphabet, remembers answers within the session and rate-limits repeats.
A public **landing page** at the site root introduces the game, switches language and A public **landing page** at the site root introduces the game, switches language and
theme, and links to the matching per-language Telegram channel; the game itself runs at theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
(it follows the system scheme, not the saved preference); its language choice is saved. (it follows the system scheme, not the saved preference); its language choice is saved.
+1 -1
View File
@@ -22,7 +22,7 @@ top-1 подсказку, безлимитную проверку слова с
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
и ограничивает частоту повторов. и ограничивает частоту повторов.
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из `/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
системной настройки, а не из сохранённой), выбор языка сохраняется. системной настройки, а не из сохранённой), выбор языка сохраняется.
+1 -2
View File
@@ -214,8 +214,7 @@ e2e and the screenshots.
**Drop game** (or 📤 **Export GCG** on a finished game) at the left and the comms 💬 **Drop game** (or 📤 **Export GCG** on a finished game) at the left and the comms 💬
(badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian (badged with unread chat) at the right, icon-only. A **single-word-rule** game (a Russian
game with "multiple words per turn" off) centres a **"One word per turn"** label between game with "multiple words per turn" off) centres a **"One word per turn"** label between
those two icons, and the status bar shows a small **1️⃣** in the score-preview slot that those two icons; standard games show no label. Each **opponent**'s card also gains two
yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains two
edge-pinned controls (non-guests): a 🤝 **add-friend** on the right (hidden once a friend, edge-pinned controls (non-guests): a 🤝 **add-friend** on the right (hidden once a friend,
disabled once requested) and a ✖️ **block** on the left. Each confirms via the fading-✅ tap, disabled once requested) and a ✖️ **block** on the left. Each confirms via the fading-✅ tap,
swapping the card's score for "Add friend?" — or **"Block?" in the danger colour** — while swapping the card's score for "Add friend?" — or **"Block?" in the danger colour** — while
+4 -8
View File
@@ -23,18 +23,14 @@ RUN corepack enable && corepack prepare pnpm@11.0.9 --activate
# VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev"). # VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
ARG VITE_TELEGRAM_BOT_ID= ARG VITE_TELEGRAM_BOT_ID=
ARG VITE_TELEGRAM_LINK= ARG VITE_TELEGRAM_LINK=
ARG VITE_TELEGRAM_LINK_EN= ARG VITE_TELEGRAM_GAME_CHANNEL_NAME=
ARG VITE_TELEGRAM_LINK_RU= ARG VITE_VK_APP_LINK=
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=
ARG VITE_GATEWAY_URL= ARG VITE_GATEWAY_URL=
ARG VITE_APP_VERSION= ARG VITE_APP_VERSION=
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \ VITE_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \
VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_RU \ VITE_VK_APP_LINK=$VITE_VK_APP_LINK \
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \ VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
VITE_APP_VERSION=$VITE_APP_VERSION VITE_APP_VERSION=$VITE_APP_VERSION
+4 -4
View File
@@ -90,16 +90,16 @@ test('new game: auto-match shows the off-by-default rule toggle from the start (
await expect(toggle).not.toBeChecked(); await expect(toggle).not.toBeChecked();
}); });
test('single-word game shows the one-word indicator in the status bar and the history header', async ({ page }) => { test('single-word game shows the one-word rule label in the history header', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /guest/i }).click();
// g3 is a finished Russian single-word game (vs Rick); open it from the lobby. // g3 is a finished Russian single-word game (vs Rick); open it from the lobby.
await page.getByRole('button', { name: /Rick/ }).click(); await page.getByRole('button', { name: /Rick/ }).click();
await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('[data-cell]').first()).toBeVisible();
await expect(page.locator('.pane')).toHaveCount(1); await expect(page.locator('.pane')).toHaveCount(1);
// The status bar carries the small "1️⃣" indicator (single-word, nothing pending). // The status bar no longer carries a "1️⃣" badge (dropped as noise); the rule surfaces in the
await expect(page.locator('.oneword')).toBeVisible(); // history header instead. Tapping the scoreboard opens the history with the spelled-out label.
// Tapping the scoreboard opens the history, whose header shows the spelled-out rule label. await expect(page.locator('.oneword')).toHaveCount(0);
await page.locator('.scoreboard').click(); await page.locator('.scoreboard').click();
await expect(page.locator('.oneword-label')).toBeVisible(); await expect(page.locator('.oneword-label')).toBeVisible();
}); });
+1 -1
View File
@@ -11,7 +11,7 @@ test('landing shows the pitch, switches language via the dropdown, and toggles t
// The language dropdown switches the copy to Russian. // The language dropdown switches the copy to Russian.
await page.getByRole('button', { name: 'Language' }).click(); await page.getByRole('button', { name: 'Language' }).click();
await page.getByRole('menuitem', { name: /Русский/ }).click(); await page.getByRole('menuitem', { name: /Русский/ }).click();
await expect(page.getByText(/Играй в Скрэббл/)).toBeVisible(); await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible();
// The theme toggle flips the document theme (ephemeral, light<->dark). // The theme toggle flips the document theme (ephemeral, light<->dark).
const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
+11
View File
@@ -0,0 +1,11 @@
<svg width="101" height="100" viewBox="0 0 101 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2_40)">
<path d="M0.5 48C0.5 25.3726 0.5 14.0589 7.52944 7.02944C14.5589 0 25.8726 0 48.5 0H52.5C75.1274 0 86.4411 0 93.4706 7.02944C100.5 14.0589 100.5 25.3726 100.5 48V52C100.5 74.6274 100.5 85.9411 93.4706 92.9706C86.4411 100 75.1274 100 52.5 100H48.5C25.8726 100 14.5589 100 7.52944 92.9706C0.5 85.9411 0.5 74.6274 0.5 52V48Z" fill="#0077FF"/>
<path d="M53.7085 72.042C30.9168 72.042 17.9169 56.417 17.3752 30.417H28.7919C29.1669 49.5003 37.5834 57.5836 44.25 59.2503V30.417H55.0004V46.8752C61.5837 46.1669 68.4995 38.667 70.8329 30.417H81.5832C79.7915 40.5837 72.2915 48.0836 66.9582 51.1669C72.2915 53.6669 80.8336 60.2086 84.0836 72.042H72.2499C69.7082 64.1253 63.3754 58.0003 55.0004 57.1669V72.042H53.7085Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_2_40">
<rect width="100" height="100" fill="white" transform="translate(0.5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 996 B

+25 -12
View File
@@ -4,7 +4,7 @@
import { i18n, localeFrom, setLocale, t, type Locale } from './lib/i18n/index.svelte'; import { i18n, localeFrom, setLocale, t, type Locale } from './lib/i18n/index.svelte';
import { loadPrefs, savePrefs, type Prefs } from './lib/session'; import { loadPrefs, savePrefs, type Prefs } from './lib/session';
import { aboutContent } from './lib/aboutContent'; import { aboutContent } from './lib/aboutContent';
import { telegramChannelLink } from './lib/landing'; import { telegramChannelLink, vkAppLink } from './lib/landing';
// Standalone landing page, the public entry at "/" (the game SPA lives at /app/ and // Standalone landing page, the public entry at "/" (the game SPA lives at /app/ and
// /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it // /telegram/). It reuses the app's theme/i18n/prefs leaf modules — not the app store — so it
@@ -17,6 +17,7 @@
const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock
const tgLink = $derived(telegramChannelLink()); const tgLink = $derived(telegramChannelLink());
const vkLink = $derived(vkAppLink());
const locales: { code: Locale; label: string }[] = [ const locales: { code: Locale; label: string }[] = [
{ code: 'en', label: '🇬🇧 English' }, { code: 'en', label: '🇬🇧 English' },
{ code: 'ru', label: '🇷🇺 Русский' }, { code: 'ru', label: '🇷🇺 Русский' },
@@ -72,10 +73,19 @@
<section class="hero"> <section class="hero">
<h1>{about.title}</h1> <h1>{about.title}</h1>
<p class="tagline">{t('landing.tagline')}</p> <p class="tagline">{t('landing.tagline')}</p>
{#if tgLink} {#if tgLink || vkLink}
<a class="tg" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}> <div class="entries">
<img src="telegram-logo.svg" alt="" width="64" height="64" /> {#if tgLink}
</a> <a class="entry" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
<img src="telegram-logo.svg" alt="" width="64" height="64" />
</a>
{/if}
{#if vkLink}
<a class="entry" href={vkLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playVK')}>
<img src="vk-logo.svg" alt="" width="64" height="64" />
</a>
{/if}
</div>
{/if} {/if}
</section> </section>
@@ -190,19 +200,22 @@
color: var(--text-muted); color: var(--text-muted);
font-size: 1.05rem; font-size: 1.05rem;
} }
/* The Telegram entry is just the bigger logo (no button chrome, no caption); the link /* The platform entries are just the bigger logos in a row (no button chrome, no captions);
keeps an aria-label for assistive tech. */ each link keeps an aria-label for assistive tech. */
.tg { .entries {
align-self: center; align-self: center;
display: inline-flex; display: flex;
gap: 20px;
margin-top: 8px; margin-top: 8px;
border-radius: 50%;
} }
.tg img { .entry {
display: inline-flex;
}
.entry img {
display: block; display: block;
transition: transform 0.12s ease; transition: transform 0.12s ease;
} }
.tg:hover img { .entry:hover img {
transform: scale(1.06); transform: scale(1.06);
} }
.info { .info {
+3 -2
View File
@@ -9,7 +9,7 @@
} from '../lib/bannerEngine'; } from '../lib/bannerEngine';
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner'; import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
import type { BannerCampaign, BannerTimings } from '../lib/model'; import type { BannerCampaign, BannerTimings } from '../lib/model';
import { onExternalLinkClick } from '../lib/telegram'; import { onExternalLinkClick } from '../lib/links';
let { let {
campaigns, campaigns,
@@ -112,7 +112,8 @@
</script> </script>
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the <!-- 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. --> platform opener (onExternalLinkClick uses closest('a')): the Telegram SDK skips the WebView
confirmation, the VK away-redirect keeps the Android WebView on the game. -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}> <div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
+3 -2
View File
@@ -4,6 +4,7 @@
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { botUsername } from '../lib/deeplink'; import { botUsername } from '../lib/deeplink';
import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram'; import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram';
import { openExternalUrl } from '../lib/links';
import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs'; import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs';
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
@@ -16,8 +17,8 @@
if (username) { if (username) {
const url = `https://t.me/${username}`; const url = `https://t.me/${username}`;
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall // Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
// back to a new tab. // back to a new tab (routed out of the Android VK WebView by openExternalUrl).
if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank'); if (!telegramOpenLink(url)) openExternalUrl(url);
} }
dismissStaleInvite(); dismissStaleInvite();
} }
+3 -2
View File
@@ -4,6 +4,7 @@
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { botUsername } from '../lib/deeplink'; import { botUsername } from '../lib/deeplink';
import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram'; import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram';
import { openExternalUrl } from '../lib/links';
import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs'; import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs';
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
@@ -19,8 +20,8 @@
if (username) { if (username) {
const url = `https://t.me/${username}`; const url = `https://t.me/${username}`;
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall // Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
// back to a new tab. // back to a new tab (routed out of the Android VK WebView by openExternalUrl).
if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank'); if (!telegramOpenLink(url)) openExternalUrl(url);
} }
dismissWelcomeRedeem(); dismissWelcomeRedeem();
} }
+1 -1
View File
@@ -5,7 +5,7 @@
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, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword'; import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword';
import { onExternalLinkClick } from '../lib/telegram'; import { onExternalLinkClick } from '../lib/links';
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
+1 -4
View File
@@ -1354,7 +1354,7 @@
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span> <span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
{/if} {/if}
<span class="scores"> <span class="scores">
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if} {#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
</span> </span>
</div> </div>
{/if} {/if}
@@ -1609,9 +1609,6 @@
min-width: 64px; min-width: 64px;
text-align: right; text-align: right;
} }
.oneword {
font-size: 0.95rem;
}
/* The single-word-rule label centred in the history header between its two icons. */ /* The single-word-rule label centred in the history header between its two icons. */
.oneword-label { .oneword-label {
flex: 1; flex: 1;
+2 -1
View File
@@ -203,8 +203,9 @@ export const en = {
'feedback.banned': 'Feedback submission is unavailable.', 'feedback.banned': 'Feedback submission is unavailable.',
'feedback.replyTitle': 'Reply to your last message', 'feedback.replyTitle': 'Reply to your last message',
'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.', 'landing.tagline': 'Play Scrabble with friends, random opponent or an AI.',
'landing.playTelegram': 'Play in Telegram', 'landing.playTelegram': 'Play in Telegram',
'landing.playVK': 'Play on VK',
'lang.en': 'English', 'lang.en': 'English',
'lang.ru': 'Русский', 'lang.ru': 'Русский',
+3 -2
View File
@@ -189,7 +189,7 @@ export const ru: Record<MessageKey, string> = {
'about.title': 'О программе', 'about.title': 'О программе',
'about.tab': 'Инфо', 'about.tab': 'Инфо',
'about.description': 'Мультиплатформенная игра в скрабл.', 'about.description': 'Мультиплатформенная игра в «Эрудит».',
'about.version': 'Версия {v}', 'about.version': 'Версия {v}',
'feedback.title': 'Обратная связь', 'feedback.title': 'Обратная связь',
@@ -203,8 +203,9 @@ export const ru: Record<MessageKey, string> = {
'feedback.banned': 'Отправка обратной связи недоступна', 'feedback.banned': 'Отправка обратной связи недоступна',
'feedback.replyTitle': 'Ответ на ваше последнее сообщение', 'feedback.replyTitle': 'Ответ на ваше последнее сообщение',
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.', 'landing.tagline': 'Играй в «Эрудита» с друзьями, случайным соперником или ИИ-роботом.',
'landing.playTelegram': 'Играть в Telegram', 'landing.playTelegram': 'Играть в Telegram',
'landing.playVK': 'Играть во ВКонтакте',
'lang.en': 'English', 'lang.en': 'English',
'lang.ru': 'Русский', 'lang.ru': 'Русский',
+15 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { telegramChannelLink } from './landing'; import { telegramChannelLink, vkAppLink } from './landing';
describe('telegramChannelLink', () => { describe('telegramChannelLink', () => {
afterEach(() => vi.unstubAllEnvs()); afterEach(() => vi.unstubAllEnvs());
@@ -14,3 +14,17 @@ describe('telegramChannelLink', () => {
expect(telegramChannelLink()).toBeNull(); expect(telegramChannelLink()).toBeNull();
}); });
}); });
describe('vkAppLink', () => {
afterEach(() => vi.unstubAllEnvs());
it('returns the configured VK app link verbatim', () => {
vi.stubEnv('VITE_VK_APP_LINK', 'https://vk.com/app54655464');
expect(vkAppLink()).toBe('https://vk.com/app54655464');
});
it('returns null when the link is unset or blank', () => {
vi.stubEnv('VITE_VK_APP_LINK', ' ');
expect(vkAppLink()).toBeNull();
});
});
+13 -1
View File
@@ -1,5 +1,5 @@
// Pure helpers for the public landing page, kept out of the Svelte component so the // Pure helpers for the public landing page, kept out of the Svelte component so the
// Telegram-channel link selection is unit-testable. // platform-entry link selection is unit-testable.
/** /**
* telegramChannelLink returns the t.me link for the single bot's game channel, or null * telegramChannelLink returns the t.me link for the single bot's game channel, or null
@@ -13,3 +13,15 @@ export function telegramChannelLink(): string | null {
const name = (raw as string | undefined)?.trim().replace(/^@/, ''); const name = (raw as string | undefined)?.trim().replace(/^@/, '');
return name ? `https://t.me/${name}` : null; return name ? `https://t.me/${name}` : null;
} }
/**
* vkAppLink returns the VK Mini App link for the landing "Play on VK" entry, or null when it is
* not configured. The link is a build-time var (VITE_VK_APP_LINK, the full URL
* `https://vk.com/app<id>`) because the test and prod contours run the same VK app but the var
* mirrors the per-contour Telegram entries.
*/
export function vkAppLink(): string | null {
const raw = import.meta.env.VITE_VK_APP_LINK;
const link = (raw as string | undefined)?.trim();
return link ? link : null;
}
+29
View File
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { openExternalUrl } from './links';
describe('openExternalUrl', () => {
afterEach(() => vi.unstubAllGlobals());
it('routes through the VK away redirect inside the Android VK client', () => {
const open = vi.fn();
vi.stubGlobal('location', {
pathname: '/vk/',
search: '?vk_user_id=1&vk_platform=mobile_android&sign=x',
href: 'https://app.example/vk/',
origin: 'https://app.example',
});
vi.stubGlobal('window', { open });
openExternalUrl('https://t.me/some_bot');
expect(open).toHaveBeenCalledWith(
`https://vk.com/away.php?to=${encodeURIComponent('https://t.me/some_bot')}`,
'_blank',
);
});
it('opens a plain new tab elsewhere', () => {
const open = vi.fn();
vi.stubGlobal('window', { open });
openExternalUrl('https://t.me/some_bot');
expect(open).toHaveBeenCalledWith('https://t.me/some_bot', '_blank');
});
});
+31
View File
@@ -0,0 +1,31 @@
// Platform-aware external-link opening. Both Mini App hosts mishandle a plain target=_blank
// anchor: Telegram shows the WebView's generic "open this link?" confirmation, and the Android VK
// client navigates the app's own WebView to the target with no way back to the game. The helpers
// below route an external URL through the platform's escape hatch (Telegram's openLink, VK's
// away.php redirect) and leave the anchor's own navigation / window.open in effect elsewhere.
import { routeExternalLinkInTelegram } from './telegram';
import { routeExternalLinkInVK, vkOpenExternalUrl } from './vk';
/**
* onExternalLinkClick is the anchor click handler for external links. 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 and VK 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) return;
if (routeExternalLinkInTelegram(a) || routeExternalLinkInVK(a)) e.preventDefault();
}
/**
* openExternalUrl opens an external URL from code (no anchor to click): through VK's external
* redirect inside the Android VK client, else a plain new tab. Telegram callers try their native
* opener (telegramOpenLink) first and use this as the fallback.
*/
export function openExternalUrl(url: string): void {
if (vkOpenExternalUrl(url)) return;
if (typeof window !== 'undefined') window.open(url, '_blank');
}
+3 -14
View File
@@ -179,9 +179,10 @@ export function telegramOpenExternalLink(url: string): boolean {
/** /**
* isExternalHttpUrl reports whether href is an absolute http(s) URL on a different origin than the * 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 * 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. * genuinely external link is routed through the host platform's browser (the Telegram router here
* and the VK router in vk.ts share it).
*/ */
function isExternalHttpUrl(href: string): boolean { export function isExternalHttpUrl(href: string): boolean {
try { try {
const u = new URL(href, typeof location === 'undefined' ? undefined : location.href); const u = new URL(href, typeof location === 'undefined' ? undefined : location.href);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false; if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
@@ -206,18 +207,6 @@ export function routeExternalLinkInTelegram(anchor: { href: string; target: stri
return telegramOpenExternalLink(anchor.href); 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
+70 -1
View File
@@ -1,5 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { insideVK, onVKPath, vkAppId, vkLaunchParams, vkStartParam, appearanceForBg } from './vk'; import {
insideVK,
onVKPath,
routeExternalLinkInVK,
vkAndroidWebView,
vkAppId,
vkExternalBrowserUrl,
vkLaunchParams,
vkStartParam,
appearanceForBg,
} from './vk';
describe('vk launch detection', () => { describe('vk launch detection', () => {
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
@@ -50,6 +60,65 @@ describe('vk launch params', () => {
}); });
}); });
describe('vk external links', () => {
afterEach(() => vi.unstubAllGlobals());
function stubVK(platform: string) {
const open = vi.fn();
vi.stubGlobal('location', {
pathname: '/vk/',
search: `?vk_user_id=1&vk_platform=${platform}&sign=x`,
href: 'https://app.example/vk/',
origin: 'https://app.example',
});
vi.stubGlobal('window', { open });
return open;
}
it('wraps a URL in the away.php redirect with the target encoded', () => {
const url = 'https://gramota.ru/poisk?query=кот&mode=slovari';
expect(vkExternalBrowserUrl(url)).toBe(`https://vk.com/away.php?to=${encodeURIComponent(url)}`);
});
it('detects the Android VK WebView from vk_platform', () => {
stubVK('mobile_android');
expect(vkAndroidWebView()).toBe(true);
stubVK('mobile_android_messenger');
expect(vkAndroidWebView()).toBe(true);
stubVK('mobile_iphone');
expect(vkAndroidWebView()).toBe(false);
stubVK('desktop_web');
expect(vkAndroidWebView()).toBe(false);
});
it('is not the Android WebView without a signed VK launch', () => {
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_platform=mobile_android' });
expect(vkAndroidWebView()).toBe(false);
});
it('routes an external _blank link through the away redirect on Android', () => {
const open = stubVK('mobile_android');
expect(routeExternalLinkInVK({ href: 'https://gramota.ru/poisk?query=кот', target: '_blank' })).toBe(true);
expect(open).toHaveBeenCalledWith(
`https://vk.com/away.php?to=${encodeURIComponent('https://gramota.ru/poisk?query=кот')}`,
'_blank',
);
});
it('leaves same-origin and non-_blank links to the browser', () => {
const open = stubVK('mobile_android');
expect(routeExternalLinkInVK({ href: 'https://app.example/lobby', target: '_blank' })).toBe(false);
expect(routeExternalLinkInVK({ href: 'https://gramota.ru', target: '' })).toBe(false);
expect(open).not.toHaveBeenCalled();
});
it('does nothing outside the Android VK client (iOS / desktop open _blank fine)', () => {
const open = stubVK('mobile_iphone');
expect(routeExternalLinkInVK({ href: 'https://gramota.ru', target: '_blank' })).toBe(false);
expect(open).not.toHaveBeenCalled();
});
});
describe('appearanceForBg', () => { describe('appearanceForBg', () => {
it('maps a dark background to the dark appearance (light status-bar icons)', () => { it('maps a dark background to the dark appearance (light status-bar icons)', () => {
expect(appearanceForBg('#0f1115')).toBe('dark'); expect(appearanceForBg('#0f1115')).toBe('dark');
+55 -1
View File
@@ -6,7 +6,7 @@
// (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to // (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to
// call outside VK. // call outside VK.
import type { Haptic } from './telegram'; import { isExternalHttpUrl, type Haptic } from './telegram';
async function bridge() { async function bridge() {
return (await import('@vkontakte/vk-bridge')).default; return (await import('@vkontakte/vk-bridge')).default;
@@ -87,6 +87,60 @@ export function vkStartParam(): string {
return new URLSearchParams(location.search.replace(/^\?/, '')).get('hash') ?? ''; return new URLSearchParams(location.search.replace(/^\?/, '')).get('hash') ?? '';
} }
/**
* vkPlatform returns the VK client platform from the launch parameters (vk_platform:
* mobile_android, mobile_iphone, desktop_web, mobile_web, …), or '' outside a VK launch.
*/
export function vkPlatform(): string {
if (typeof location === 'undefined') return '';
return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_platform') ?? '';
}
/**
* vkAndroidWebView reports whether the app runs inside the Android VK client's WebView
* (vk_platform mobile_android / mobile_android_messenger) — the one environment whose WebView
* ignores target=_blank and navigates the app's own window instead of opening a browser.
*/
export function vkAndroidWebView(): boolean {
return insideVK() && vkPlatform().startsWith('mobile_android');
}
/**
* vkExternalBrowserUrl wraps url in VK's own leave-VK redirect (vk.com/away.php). The VK mobile
* client intercepts vk.com navigations natively and hands the redirect target to the system
* browser, so an external link escapes the Mini App WebView instead of replacing the game.
* vk-bridge (3.x) offers no method to open an external URL, so this redirect is the supported
* escape hatch. Pure, so it is unit-tested.
*/
export function vkExternalBrowserUrl(url: string): string {
return `https://vk.com/away.php?to=${encodeURIComponent(url)}`;
}
/**
* vkOpenExternalUrl opens url in the system browser from inside the Android VK client by routing
* it through vkExternalBrowserUrl. Returns false outside the Android VK WebView — iOS and the
* desktop iframe open target=_blank correctly and are left alone — so the caller falls back to
* the anchor's own navigation or a plain window.open.
*/
export function vkOpenExternalUrl(url: string): boolean {
if (typeof window === 'undefined' || !vkAndroidWebView()) return false;
window.open(vkExternalBrowserUrl(url), '_blank');
return true;
}
/**
* routeExternalLinkInVK decides whether a clicked anchor must be opened through VK's external
* redirect rather than the WebView's default navigation: only inside the Android VK client, and
* only for an external http(s) link opened in a new tab (target=_blank). Returns true when it
* opened the link — the caller should then preventDefault — and false to let the browser handle
* the click normally.
*/
export function routeExternalLinkInVK(anchor: { href: string; target: string }): boolean {
if (anchor.target !== '_blank') return false;
if (!isExternalHttpUrl(anchor.href)) return false;
return vkOpenExternalUrl(anchor.href);
}
/** /**
* vkShare opens VK's native share dialog for link (VKWebAppShare) — the in-iframe replacement for * vkShare opens VK's native share dialog for link (VKWebAppShare) — the in-iframe replacement for
* navigator.share, which is unavailable in the desktop VK iframe. Resolves true when the share was * navigator.share, which is unavailable in the desktop VK iframe. Resolves true when the share was
+1 -1
View File
@@ -3,7 +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'; import { onExternalLinkClick } from '../lib/links';
// 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;
+1 -1
View File
@@ -7,7 +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 { onExternalLinkClick } from '../lib/links';
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