diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 97059c7..909d159 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -335,6 +335,7 @@ jobs: VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} 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 }} # Unset vars render empty -> the compose ":-" defaults apply. POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml index 99602d0..af0fcbd 100644 --- a/.gitea/workflows/prod-deploy.yaml +++ b/.gitea/workflows/prod-deploy.yaml @@ -40,6 +40,7 @@ jobs: VITE_TELEGRAM_BOT_ID: ${{ vars.PROD_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} 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 }} POSTGRES_PASSWORD: ${{ secrets.PROD_POSTGRES_PASSWORD }} GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} diff --git a/deploy/.env.example b/deploy/.env.example index acd067f..376f154 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -32,6 +32,7 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt VITE_TELEGRAM_BOT_ID= VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me//) 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) VITE_GATEWAY_URL= # --- Grafana ---------------------------------------------------------------- diff --git a/deploy/README.md b/deploy/README.md index f8b4a12..e4e463a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me//` — `` 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_VK_APP_LINK` | variable | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app`). | | `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 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, 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, -VITE_TELEGRAM_GAME_CHANNEL_NAME}`. +VITE_TELEGRAM_GAME_CHANNEL_NAME, VITE_VK_APP_LINK}`. ## Host-side setup (outside this repo) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 34893cb..5ea740a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -134,6 +134,7 @@ services: VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} 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_APP_VERSION: ${APP_VERSION:-dev} # 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_LINK: ${VITE_TELEGRAM_LINK:-} 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_APP_VERSION: ${APP_VERSION:-dev} restart: unless-stopped diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index e05d9d1..be03167 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 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 -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 (it follows the system scheme, not the saved preference); its language choice is saved. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index f0d1aca..887eaf7 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -22,7 +22,7 @@ top-1 подсказку, безлимитную проверку слова с Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии и ограничивает частоту повторов. Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и -тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам +тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам `/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из системной настройки, а не из сохранённой), выбор языка сохраняется. diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index c6bc65d..b25dc6c 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -214,8 +214,7 @@ e2e and the screenshots. **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 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 - yields to the live word/score preview while tiles are pending; standard games show neither. Each **opponent**'s card also gains two + those two icons; standard games show no label. Each **opponent**'s card also gains two 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, swapping the card's score for "Add friend?" — or **"Block?" in the danger colour** — while diff --git a/gateway/Dockerfile b/gateway/Dockerfile index c8e5bf9..3cb62cc 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -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"). ARG VITE_TELEGRAM_BOT_ID= ARG VITE_TELEGRAM_LINK= -ARG VITE_TELEGRAM_LINK_EN= -ARG VITE_TELEGRAM_LINK_RU= -ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= -ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= +ARG VITE_TELEGRAM_GAME_CHANNEL_NAME= +ARG VITE_VK_APP_LINK= ARG VITE_GATEWAY_URL= ARG VITE_APP_VERSION= ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \ - VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \ - VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_RU \ - 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_TELEGRAM_GAME_CHANNEL_NAME=$VITE_TELEGRAM_GAME_CHANNEL_NAME \ + VITE_VK_APP_LINK=$VITE_VK_APP_LINK \ VITE_GATEWAY_URL=$VITE_GATEWAY_URL \ VITE_APP_VERSION=$VITE_APP_VERSION diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index e49c028..fd2512e 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -90,16 +90,16 @@ test('new game: auto-match shows the off-by-default rule toggle from the start ( 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.getByRole('button', { name: /guest/i }).click(); // g3 is a finished Russian single-word game (vs Rick); open it from the lobby. await page.getByRole('button', { name: /Rick/ }).click(); await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('.pane')).toHaveCount(1); - // The status bar carries the small "1️⃣" indicator (single-word, nothing pending). - await expect(page.locator('.oneword')).toBeVisible(); - // Tapping the scoreboard opens the history, whose header shows the spelled-out rule label. + // The status bar no longer carries a "1️⃣" badge (dropped as noise); the rule surfaces in the + // history header instead. Tapping the scoreboard opens the history with the spelled-out label. + await expect(page.locator('.oneword')).toHaveCount(0); await page.locator('.scoreboard').click(); await expect(page.locator('.oneword-label')).toBeVisible(); }); diff --git a/ui/e2e/landing.spec.ts b/ui/e2e/landing.spec.ts index 085cbda..d6177ad 100644 --- a/ui/e2e/landing.spec.ts +++ b/ui/e2e/landing.spec.ts @@ -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. await page.getByRole('button', { name: 'Language' }).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). const before = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); diff --git a/ui/public/vk-logo.svg b/ui/public/vk-logo.svg new file mode 100644 index 0000000..1c61468 --- /dev/null +++ b/ui/public/vk-logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte index e135e8b..f9d11a9 100644 --- a/ui/src/Landing.svelte +++ b/ui/src/Landing.svelte @@ -4,7 +4,7 @@ import { i18n, localeFrom, setLocale, t, type Locale } from './lib/i18n/index.svelte'; import { loadPrefs, savePrefs, type Prefs } from './lib/session'; 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 // /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 tgLink = $derived(telegramChannelLink()); + const vkLink = $derived(vkAppLink()); const locales: { code: Locale; label: string }[] = [ { code: 'en', label: '🇬🇧 English' }, { code: 'ru', label: '🇷🇺 Русский' }, @@ -72,10 +73,19 @@

{about.title}

{t('landing.tagline')}

- {#if tgLink} - - - + {#if tgLink || vkLink} +
+ {#if tgLink} + + + + {/if} + {#if vkLink} + + + + {/if} +
{/if}
@@ -190,19 +200,22 @@ color: var(--text-muted); font-size: 1.05rem; } - /* The Telegram entry is just the bigger logo (no button chrome, no caption); the link - keeps an aria-label for assistive tech. */ - .tg { + /* The platform entries are just the bigger logos in a row (no button chrome, no captions); + each link keeps an aria-label for assistive tech. */ + .entries { align-self: center; - display: inline-flex; + display: flex; + gap: 20px; margin-top: 8px; - border-radius: 50%; } - .tg img { + .entry { + display: inline-flex; + } + .entry img { display: block; transition: transform 0.12s ease; } - .tg:hover img { + .entry:hover img { transform: scale(1.06); } .info { diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index 2a527fa..28b1e10 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -9,7 +9,7 @@ } from '../lib/bannerEngine'; import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner'; import type { BannerCampaign, BannerTimings } from '../lib/model'; - import { onExternalLinkClick } from '../lib/telegram'; + import { onExternalLinkClick } from '../lib/links'; let { campaigns, @@ -112,7 +112,8 @@ + platform opener (onExternalLinkClick uses closest('a')): the Telegram SDK skips the WebView + confirmation, the VK away-redirect keeps the Android WebView on the game. -->
diff --git a/ui/src/components/StaleInviteModal.svelte b/ui/src/components/StaleInviteModal.svelte index 1ababd8..b06579b 100644 --- a/ui/src/components/StaleInviteModal.svelte +++ b/ui/src/components/StaleInviteModal.svelte @@ -4,6 +4,7 @@ import { t } from '../lib/i18n/index.svelte'; import { botUsername } from '../lib/deeplink'; import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram'; + import { openExternalUrl } from '../lib/links'; import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs'; import Modal from './Modal.svelte'; @@ -16,8 +17,8 @@ if (username) { const url = `https://t.me/${username}`; // Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall - // back to a new tab. - if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank'); + // back to a new tab (routed out of the Android VK WebView by openExternalUrl). + if (!telegramOpenLink(url)) openExternalUrl(url); } dismissStaleInvite(); } diff --git a/ui/src/components/WelcomeRedeemModal.svelte b/ui/src/components/WelcomeRedeemModal.svelte index 9cbc2d9..3bd0c29 100644 --- a/ui/src/components/WelcomeRedeemModal.svelte +++ b/ui/src/components/WelcomeRedeemModal.svelte @@ -4,6 +4,7 @@ import { t } from '../lib/i18n/index.svelte'; import { botUsername } from '../lib/deeplink'; import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram'; + import { openExternalUrl } from '../lib/links'; import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs'; import Modal from './Modal.svelte'; @@ -19,8 +20,8 @@ if (username) { const url = `https://t.me/${username}`; // Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall - // back to a new tab. - if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank'); + // back to a new tab (routed out of the Android VK WebView by openExternalUrl). + if (!telegramOpenLink(url)) openExternalUrl(url); } dismissWelcomeRedeem(); } diff --git a/ui/src/game/CheckScreen.svelte b/ui/src/game/CheckScreen.svelte index 173afae..67a5a2a 100644 --- a/ui/src/game/CheckScreen.svelte +++ b/ui/src/game/CheckScreen.svelte @@ -5,7 +5,7 @@ import { t } from '../lib/i18n/index.svelte'; import { alphabetLetters } from '../lib/alphabet'; import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword'; - import { onExternalLinkClick } from '../lib/telegram'; + import { onExternalLinkClick } from '../lib/links'; import type { Variant } from '../lib/model'; // Word-check on its own screen: unlimited dictionary lookups, each with a diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index c100585..21082a3 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1354,7 +1354,7 @@ {isMyTurn ? t('game.yourTurn') : turnLabel()} {/if} - {#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}1️⃣{/if} + {#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
{/if} @@ -1609,9 +1609,6 @@ min-width: 64px; text-align: right; } - .oneword { - font-size: 0.95rem; - } /* The single-word-rule label centred in the history header between its two icons. */ .oneword-label { flex: 1; diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 2254855..4970328 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -203,8 +203,9 @@ export const en = { 'feedback.banned': 'Feedback submission is unavailable.', '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.playVK': 'Play on VK', 'lang.en': 'English', 'lang.ru': 'Русский', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 53fba60..10f2850 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -189,7 +189,7 @@ export const ru: Record = { 'about.title': 'О программе', 'about.tab': 'Инфо', - 'about.description': 'Мультиплатформенная игра в скрабл.', + 'about.description': 'Мультиплатформенная игра в «Эрудит».', 'about.version': 'Версия {v}', 'feedback.title': 'Обратная связь', @@ -203,8 +203,9 @@ export const ru: Record = { 'feedback.banned': 'Отправка обратной связи недоступна', 'feedback.replyTitle': 'Ответ на ваше последнее сообщение', - 'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.', + 'landing.tagline': 'Играй в «Эрудита» с друзьями, случайным соперником или ИИ-роботом.', 'landing.playTelegram': 'Играть в Telegram', + 'landing.playVK': 'Играть во ВКонтакте', 'lang.en': 'English', 'lang.ru': 'Русский', diff --git a/ui/src/lib/landing.test.ts b/ui/src/lib/landing.test.ts index 71a9c56..803ff14 100644 --- a/ui/src/lib/landing.test.ts +++ b/ui/src/lib/landing.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { telegramChannelLink } from './landing'; +import { telegramChannelLink, vkAppLink } from './landing'; describe('telegramChannelLink', () => { afterEach(() => vi.unstubAllEnvs()); @@ -14,3 +14,17 @@ describe('telegramChannelLink', () => { 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(); + }); +}); diff --git a/ui/src/lib/landing.ts b/ui/src/lib/landing.ts index 7d5ed43..2943705 100644 --- a/ui/src/lib/landing.ts +++ b/ui/src/lib/landing.ts @@ -1,5 +1,5 @@ // 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 @@ -13,3 +13,15 @@ export function telegramChannelLink(): string | null { const name = (raw as string | undefined)?.trim().replace(/^@/, ''); 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`) 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; +} diff --git a/ui/src/lib/links.test.ts b/ui/src/lib/links.test.ts new file mode 100644 index 0000000..895c5f9 --- /dev/null +++ b/ui/src/lib/links.test.ts @@ -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'); + }); +}); diff --git a/ui/src/lib/links.ts b/ui/src/lib/links.ts new file mode 100644 index 0000000..c520e09 --- /dev/null +++ b/ui/src/lib/links.ts @@ -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 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'); +} diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index c47f414..0cff5b3 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -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 * 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 { const u = new URL(href, typeof location === 'undefined' ? undefined : location.href); 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); } -/** - * 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/lib/vk.test.ts b/ui/src/lib/vk.test.ts index c3a9c41..82a7df8 100644 --- a/ui/src/lib/vk.test.ts +++ b/ui/src/lib/vk.test.ts @@ -1,5 +1,15 @@ 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', () => { 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', () => { it('maps a dark background to the dark appearance (light status-bar icons)', () => { expect(appearanceForBg('#0f1115')).toBe('dark'); diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index 806a034..f45a896 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -6,7 +6,7 @@ // (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to // call outside VK. -import type { Haptic } from './telegram'; +import { isExternalHttpUrl, type Haptic } from './telegram'; async function bridge() { return (await import('@vkontakte/vk-bridge')).default; @@ -87,6 +87,60 @@ export function vkStartParam(): string { 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 * navigator.share, which is unavailable in the desktop VK iframe. Resolves true when the share was diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte index bb1c541..62f3abc 100644 --- a/ui/src/screens/About.svelte +++ b/ui/src/screens/About.svelte @@ -3,7 +3,7 @@ import { app } from '../lib/app.svelte'; import { navigate } from '../lib/router.svelte'; 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). const AUTO_MATCH_HOURS = 24; diff --git a/ui/src/screens/Feedback.svelte b/ui/src/screens/Feedback.svelte index af6b2b5..2067a1d 100644 --- a/ui/src/screens/Feedback.svelte +++ b/ui/src/screens/Feedback.svelte @@ -7,7 +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 { onExternalLinkClick } from '../lib/links'; import type { FeedbackState } from '../lib/model'; // The feedback screen: a message (+ optional single attachment) the player sends to