diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index e9f39ef..63f9196 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -267,6 +267,8 @@ jobs: TELEGRAM_TEST_ENV: "true" VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} + VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }} + VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_RU }} VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }} VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }} VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} diff --git a/PLAN.md b/PLAN.md index 45badb9..115de83 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1560,12 +1560,15 @@ cannot submit; three-way admin filter. unchanged). The DAWG/solver build-time agreement (the original caveat, shared with TODO-2) was discharged in Stage 14: the dict repo builds against the published solver + pinned `dafsa`/`alphabet`, byte-identical to the fixtures. -- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:* - the deep-link scheme now exists (`f`, shared Go ↔ TS), the bot redeems it on - launch, and the UI shows a **share-to-Telegram** link for an issued code when - `VITE_TELEGRAM_LINK` is configured. **Still open:** render the link as a **QR** so a - friend can add you by scanning rather than tapping/typing. The code semantics - (12 h TTL, single use, one active per issuer) stay as-is; only the delivery changes. +- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9, extended later:* + the deep-link scheme exists (`f`, shared Go ↔ TS), the bot redeems it on launch, and the UI + offers a real **Share** control for an issued code — Telegram's native share-to-chat picker inside + the Mini App (`openTelegramLink` + `t.me/share/url`), the system share sheet (`navigator.share`) on + the web, else copy-the-link. The shared link points at the **same bot the player signed in through**: + it carries the session's `service_language` (now on the `Session` wire) and picks + `VITE_TELEGRAM_LINK_EN`/`_RU`, falling back to `VITE_TELEGRAM_LINK`. **Still open:** render the link + as a **QR** so a friend can add you by scanning. The code semantics (12 h TTL, single use, one active + per issuer) stay as-is; only the delivery changes. - **TODO-6 — smart default for the friend-game "game type" (owner's idea, Stage 8).** The play-with-friends form has no preselected variant today (an empty, required pick). Default it from the player's history (the variant they play most, from diff --git a/deploy/.env.example b/deploy/.env.example index 1c9bd5d..236b4c1 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -29,7 +29,9 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt # --- UI build args (baked into the gateway image) --------------------------- VITE_TELEGRAM_BOT_ID= -VITE_TELEGRAM_LINK= +VITE_TELEGRAM_LINK= # fallback friend-invite Mini App link (per-bot below) +VITE_TELEGRAM_LINK_EN= # friend-invite Mini App link, English bot (full URL, e.g. https://t.me//) +VITE_TELEGRAM_LINK_RU= # friend-invite Mini App link, Russian bot (full URL) VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= # landing "Play in Telegram" link, English bot VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= # landing "Play in Telegram" link, Russian bot VITE_GATEWAY_URL= diff --git a/deploy/README.md b/deploy/README.md index 8f6894b..f93ed70 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -84,7 +84,9 @@ connector **fails at boot** if both are empty. | `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. | | `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). | | `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: deep-link base for share-to-Telegram (e.g. `https://t.me//`). | +| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: **fallback** friend-invite Mini App link (e.g. `https://t.me//`), used when the per-bot link below is unset. | +| `VITE_TELEGRAM_LINK_EN` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link for the **English** bot (full URL, `https://t.me//` — `` is the Mini App short name from BotFather). The friend-code share uses the link matching the bot the player signed in through. | +| `VITE_TELEGRAM_LINK_RU` | variable | _(empty)_ | UI build-arg: same for the **Russian** bot. | | `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **English** bot (e.g. `https://t.me/Scrabble_Game`). | | `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **Russian** bot (e.g. `https://t.me/Erudit_Game`). | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index ac30987..84b930c 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -121,6 +121,8 @@ services: args: 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_GATEWAY_URL: ${VITE_GATEWAY_URL:-} @@ -169,6 +171,8 @@ services: args: 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_GATEWAY_URL: ${VITE_GATEWAY_URL:-} diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 2adccd5..bfb43c5 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -23,12 +23,16 @@ 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_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_GATEWAY_URL=$VITE_GATEWAY_URL \ diff --git a/ui/README.md b/ui/README.md index b29e40f..4d5f057 100644 --- a/ui/README.md +++ b/ui/README.md @@ -28,8 +28,11 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time) `GATEWAY_URL` overrides the dev proxy target; `VITE_GATEWAY_URL` sets the runtime gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site -domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the -share-to-Telegram deep-link base. `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` +domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK_EN` / +`VITE_TELEGRAM_LINK_RU` are the per-bot friend-invite Mini App links (full URL +`https://t.me//`; the share picks the one matching the bot the player +signed in through, falling back to `VITE_TELEGRAM_LINK`). +`VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` are the per-language "Play in Telegram" links shown on the landing page. The build has **two entries**: the game SPA (`index.html`, served at `/app/` and diff --git a/ui/src/lib/deeplink.test.ts b/ui/src/lib/deeplink.test.ts index b0d2718..7ad1df4 100644 --- a/ui/src/lib/deeplink.test.ts +++ b/ui/src/lib/deeplink.test.ts @@ -35,4 +35,17 @@ describe('shareLink', () => { vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app'); expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456'); }); + + it('picks the per-bot base by language', () => { + vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/enbot/app'); + vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/rubot/app'); + expect(shareLink('f1', 'en')).toBe('https://t.me/enbot/app?startapp=f1'); + expect(shareLink('f1', 'ru')).toBe('https://t.me/rubot/app?startapp=f1'); + }); + + it('falls back to the language-agnostic base when the per-bot one is unset', () => { + vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/fallback/app'); + vi.stubEnv('VITE_TELEGRAM_LINK_RU', ''); + expect(shareLink('f1', 'ru')).toBe('https://t.me/fallback/app?startapp=f1'); + }); }); diff --git a/ui/src/lib/deeplink.ts b/ui/src/lib/deeplink.ts index b75ad45..f5cf6fc 100644 --- a/ui/src/lib/deeplink.ts +++ b/ui/src/lib/deeplink.ts @@ -36,13 +36,29 @@ export const invitationParam = (id: string): string => 'i' + id; /** friendCodeParam builds the start parameter that redeems a friend code. */ export const friendCodeParam = (code: string): string => 'f' + code; +function envVar(name: string): string | undefined { + return (import.meta.env as Record)[name]; +} + /** - * shareLink wraps a deep-link start parameter in a t.me Mini App link, using the - * VITE_TELEGRAM_LINK base (e.g. https://t.me//). It returns null when the - * base is not configured, so callers can hide the share affordance. + * telegramBase returns the Mini App link base (e.g. https://t.me//) for a + * bot language: VITE_TELEGRAM_LINK_EN / _RU when lang is en/ru, else the + * language-agnostic VITE_TELEGRAM_LINK. Returns null when none is configured, so a + * shared link points at the same bot the player signed in through. */ -export function shareLink(param: string): string | null { - const base = import.meta.env.VITE_TELEGRAM_LINK as string | undefined; +function telegramBase(lang: string): string | null { + const byLang = + lang === 'ru' ? envVar('VITE_TELEGRAM_LINK_RU') : lang === 'en' ? envVar('VITE_TELEGRAM_LINK_EN') : undefined; + return byLang || envVar('VITE_TELEGRAM_LINK') || null; +} + +/** + * shareLink wraps a deep-link start parameter in a t.me Mini App link for the given + * bot language (the session's service language). Returns null when no base is + * configured, so callers can hide the share affordance. + */ +export function shareLink(param: string, lang = ''): string | null { + const base = telegramBase(lang); if (!base) return null; const sep = base.includes('?') ? '&' : '?'; return `${base}${sep}startapp=${encodeURIComponent(param)}`; diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 1235164..48cc214 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -234,6 +234,8 @@ export const en = { 'friends.copy': 'Copy', 'friends.codeCopied': 'Code copied.', 'friends.shareTelegram': 'Share via Telegram', + 'friends.inviteText': "Let's be friends in Scrabble!", + 'friends.linkCopied': 'Link copied.', 'friends.added': 'Added {name}.', 'friends.blockedList': 'Blocked players', 'friends.unblock': 'Unblock', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index b667fee..e3fcb7e 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -235,6 +235,8 @@ export const ru: Record = { 'friends.copy': 'Копировать', 'friends.codeCopied': 'Код скопирован.', 'friends.shareTelegram': 'Поделиться через Telegram', + 'friends.inviteText': 'Давай дружить в Скрэббл!', + 'friends.linkCopied': 'Ссылка скопирована.', 'friends.added': 'Добавлен(а) {name}.', 'friends.blockedList': 'Заблокированные', 'friends.unblock': 'Разблокировать', diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index f89b41b..d714491 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -15,6 +15,7 @@ interface TelegramWebApp { contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; ready?: () => void; expand?: () => void; + openTelegramLink?: (url: string) => void; onEvent?: (event: string, handler: () => void) => void; setHeaderColor?: (color: string) => void; setBackgroundColor?: (color: string) => void; @@ -49,6 +50,19 @@ export function insideTelegram(): boolean { return !!w && typeof w.initData === 'string' && w.initData.length > 0; } +/** + * 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 + * Telegram or when the SDK lacks the method, so the caller can fall back to the Web + * Share API. Works consistently on iOS and Android within Telegram. + */ +export function shareTelegramLink(url: string, text: string): boolean { + const w = webApp(); + if (!w?.openTelegramLink) return false; + w.openTelegramLink(`https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`); + return true; +} + /** TelegramLaunch is the data a Mini App launch carries. */ export interface TelegramLaunch { initData: string; diff --git a/ui/src/screens/Friends.svelte b/ui/src/screens/Friends.svelte index 57b26dd..620efeb 100644 --- a/ui/src/screens/Friends.svelte +++ b/ui/src/screens/Friends.svelte @@ -5,6 +5,7 @@ import { gateway } from '../lib/gateway'; import { t } from '../lib/i18n/index.svelte'; import { friendCodeParam, shareLink } from '../lib/deeplink'; + import { shareTelegramLink } from '../lib/telegram'; import type { AccountRef, FriendCode } from '../lib/model'; let friends = $state([]); @@ -78,6 +79,28 @@ // Clipboard may be unavailable (insecure context); leave the code on screen. } } + + // shareInvite shares the friend-code deep link: inside Telegram via the native + // "share to chat" picker; on the web via the system share sheet; failing both, it + // copies the link to the clipboard. + async function shareInvite(url: string) { + const text = t('friends.inviteText'); + if (shareTelegramLink(url, text)) return; + if (typeof navigator !== 'undefined' && navigator.share) { + try { + await navigator.share({ text, url }); + return; + } catch { + return; // the user dismissed the share sheet + } + } + try { + await navigator.clipboard.writeText(url); + showToast(t('friends.linkCopied')); + } catch { + // Clipboard unavailable (insecure context); nothing more to do. + } + }
@@ -97,7 +120,7 @@
{#if code} - {@const tg = shareLink(friendCodeParam(code.code))} + {@const tg = shareLink(friendCodeParam(code.code), app.session?.serviceLanguage || app.locale)}
@@ -107,7 +130,7 @@ {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} {#if tg} - {t('friends.shareTelegram')} + {/if}
{:else}