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/backend/internal/server/dto.go b/backend/internal/server/dto.go index bf92a15..3f1b475 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -16,10 +16,11 @@ import ( // sessionResponse is the credential returned by every auth endpoint. type sessionResponse struct { - Token string `json:"token"` - UserID string `json:"user_id"` - IsGuest bool `json:"is_guest"` - DisplayName string `json:"display_name"` + Token string `json:"token"` + UserID string `json:"user_id"` + IsGuest bool `json:"is_guest"` + DisplayName string `json:"display_name"` + ServiceLanguage string `json:"service_language"` } // okResponse is a simple success acknowledgement. @@ -159,10 +160,11 @@ type errorBody struct { // sessionResponseFor builds the credential payload for a minted session. func sessionResponseFor(token string, acc account.Account) sessionResponse { return sessionResponse{ - Token: token, - UserID: acc.ID.String(), - IsGuest: acc.IsGuest, - DisplayName: acc.DisplayName, + Token: token, + UserID: acc.ID.String(), + IsGuest: acc.IsGuest, + DisplayName: acc.DisplayName, + ServiceLanguage: acc.ServiceLanguage, } } diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index 8a9b993..1fed516 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -48,6 +48,7 @@ func (s *Server) handleTelegramAuth(c *gin.Context) { s.abortErr(c, err) return } + acc.ServiceLanguage = req.ServiceLanguage // reflect this login's bot in the session response s.mintSession(c, acc) } 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0e80f7d..2cb450f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -151,7 +151,9 @@ arrive from a platform rather than completing a mandatory registration). login — last-login-wins) and routes the user's out-of-app push back through the right bot (§10) — **except a game event, which routes by the game's own language** (its variant → en/ru), so a game's notification always comes from the game's bot rather than the - recipient's latest login bot. The service language is distinct from `preferred_language` (the + recipient's latest login bot. It also rides the **Session wire** to the client, which uses it + to build the friend-invite **share link** (and its caption) for the **same bot** the player + is in. The service language is distinct from `preferred_language` (the interface language) and from a game's variant language. Non-Telegram logins (web / email / guest) carry the gateway's default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants by default). - The client holds `session_id` in memory for the app session (browser/OS @@ -425,7 +427,11 @@ English game the Latin pool. - **Friends**: two add paths over one `friendships` table. A **one-time code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric, SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem - rate-limited) is redeemed by the other player to become friends immediately. + rate-limited) is redeemed by the other player to become friends immediately. It is shared as + a Telegram `startapp` deep-link to the issuer's own bot (by service language, with a matching + caption), redeemed by the recipient's Mini App on launch; a **spent or expired** code is not + surfaced as an error there but lands the visitor in the lobby with a gentle pointer to the + right bot, since the shared link outlives the single-use code. Alternatively a **request → accept** is sent to someone you **share a game with** (active or finished); the recipient may accept, ignore (the pending row lazily expires after **30 days** and may be re-sent), or **decline** — a decline is diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index fec6d72..a5dca0a 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -67,8 +67,12 @@ Login uses `Screen`. theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` / `setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the native header **BackButton** drives back-navigation (the app's chevron is hidden in - Telegram); **HapticFeedback** fires on tile placement / commit / error; **closing - confirmation** is enabled while a game is open; **vertical swipes** (swipe-to-minimise) + Telegram); **HapticFeedback** fires on tile placement / commit / error; on **mobile** + clients the app enters **immersive fullscreen** on launch (`requestFullscreen`, Bot API + 8.0+) like Telegram's own Mini Apps, while desktop keeps the bot's full-size window; + **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 + 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 by a background suspend reconnects silently on return — the connection banner is suppressed while hidden and for a short grace after resume (visibilitychange + @@ -242,8 +246,14 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use every field is valid. Interface language stays in **Settings** (it writes through to the account for durable users). - **Friend code**: the issued code sits next to a 📋 copy control; tapping the code or - the icon copies it. Flex text inputs carry `min-width:0` so they shrink instead of - overflowing in Safari. + the icon copies it. **Share via Telegram** wraps the code in a `t.me//?startapp=` + deep-link and opens Telegram's native share-to-chat sheet (Web Share / clipboard fallback + outside Telegram); the link and its caption are for the **same bot the player signed in + through** (its service language). Redeeming your **own** invite shows a friendly note, not an + error; opening an **outdated** invite link (a used or expired single-use code) lands in the + lobby with a calm modal pointing at the right bot (`@`), not a red error on the + Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in + Safari. - **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid (the word(s) and the move score, no running total); *Export GCG* (the 📤 in the history header) shares or downloads the `.gcg` file and appears only once the game is finished. 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/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index e1e034f..4c09e45 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -14,10 +14,11 @@ import ( // SessionResp is the credential minted by an auth operation. type SessionResp struct { - Token string `json:"token"` - UserID string `json:"user_id"` - IsGuest bool `json:"is_guest"` - DisplayName string `json:"display_name"` + Token string `json:"token"` + UserID string `json:"user_id"` + IsGuest bool `json:"is_guest"` + DisplayName string `json:"display_name"` + ServiceLanguage string `json:"service_language"` } // ProfileResp is an account's own profile. diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 0a293f2..062fff4 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -36,6 +36,7 @@ func encodeSession(s backendclient.SessionResp, supportedLangs []string) []byte token := b.CreateString(s.Token) uid := b.CreateString(s.UserID) name := b.CreateString(s.DisplayName) + svcLang := b.CreateString(s.ServiceLanguage) langs := buildSupportedLanguagesVector(b, supportedLangs) fb.SessionStart(b) fb.SessionAddToken(b, token) @@ -43,6 +44,7 @@ func encodeSession(s backendclient.SessionResp, supportedLangs []string) []byte fb.SessionAddIsGuest(b, s.IsGuest) fb.SessionAddDisplayName(b, name) fb.SessionAddSupportedLanguages(b, langs) + fb.SessionAddServiceLanguage(b, svcLang) b.Finish(fb.SessionEnd(b)) return b.FinishedBytes() } diff --git a/gateway/internal/transcode/transcode_telegram_test.go b/gateway/internal/transcode/transcode_telegram_test.go index ff26edd..4849056 100644 --- a/gateway/internal/transcode/transcode_telegram_test.go +++ b/gateway/internal/transcode/transcode_telegram_test.go @@ -43,7 +43,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) { t.Errorf("unexpected path %q", r.URL.Path) } _ = json.NewDecoder(r.Body).Decode(&gotBody) - _, _ = w.Write([]byte(`{"token":"tok-tg","user_id":"u-tg","is_guest":false,"display_name":"Иван"}`)) + _, _ = w.Write([]byte(`{"token":"tok-tg","user_id":"u-tg","is_guest":false,"display_name":"Иван","service_language":"ru"}`)) }) defer cleanup() @@ -67,6 +67,11 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) { if got := sessionLanguages(sess); len(got) != 1 || got[0] != "ru" { t.Errorf("session supported_languages = %v, want [ru]", got) } + // The bot's service language rides the Session so the UI can build a share link to + // the same bot. + if got := string(sess.ServiceLanguage()); got != "ru" { + t.Errorf("session service_language = %q, want ru", got) + } // The validated launch fields are forwarded so the backend can seed a new account; // service_language is recorded to route the account's later out-of-app push. if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" || gotBody["service_language"] != "ru" { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index d451d79..b098ff0 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -123,6 +123,10 @@ table Session { is_guest:bool; display_name:string; supported_languages:[string]; + // service_language is the language tag (en/ru) of the Telegram bot the session was + // minted through; empty for a non-Telegram login. The UI uses it to build a share + // link that points at the same bot. + service_language:string; } // Ack is a simple success acknowledgement (e.g. an email-code request). diff --git a/pkg/fbs/scrabblefb/Session.go b/pkg/fbs/scrabblefb/Session.go index d1b2fc8..98a12fd 100644 --- a/pkg/fbs/scrabblefb/Session.go +++ b/pkg/fbs/scrabblefb/Session.go @@ -94,8 +94,16 @@ func (rcv *Session) SupportedLanguagesLength() int { return 0 } +func (rcv *Session) ServiceLanguage() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + func SessionStart(builder *flatbuffers.Builder) { - builder.StartObject(5) + builder.StartObject(6) } func SessionAddToken(builder *flatbuffers.Builder, token flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(token), 0) @@ -115,6 +123,9 @@ func SessionAddSupportedLanguages(builder *flatbuffers.Builder, supportedLanguag func SessionStartSupportedLanguagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } +func SessionAddServiceLanguage(builder *flatbuffers.Builder, serviceLanguage flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(serviceLanguage), 0) +} func SessionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } 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/App.svelte b/ui/src/App.svelte index e07701e..8f3bba3 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -6,6 +6,7 @@ import { t } from './lib/i18n/index.svelte'; import { insideTelegram, telegramBackButton } from './lib/telegram'; import Toast from './components/Toast.svelte'; + import StaleInviteModal from './components/StaleInviteModal.svelte'; import Login from './screens/Login.svelte'; import Lobby from './screens/Lobby.svelte'; import NewGame from './screens/NewGame.svelte'; @@ -108,6 +109,7 @@ {/if} + diff --git a/ui/src/gen/fbs/scrabblefb/session.ts b/ui/src/gen/fbs/scrabblefb/session.ts index bb33637..4b752fe 100644 --- a/ui/src/gen/fbs/scrabblefb/session.ts +++ b/ui/src/gen/fbs/scrabblefb/session.ts @@ -58,8 +58,15 @@ supportedLanguagesLength():number { return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } +serviceLanguage():string|null +serviceLanguage(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +serviceLanguage(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 14); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + static startSession(builder:flatbuffers.Builder) { - builder.startObject(5); + builder.startObject(6); } static addToken(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset) { @@ -94,18 +101,23 @@ static startSupportedLanguagesVector(builder:flatbuffers.Builder, numElems:numbe builder.startVector(4, numElems, 4); } +static addServiceLanguage(builder:flatbuffers.Builder, serviceLanguageOffset:flatbuffers.Offset) { + builder.addFieldOffset(5, serviceLanguageOffset, 0); +} + static endSession(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createSession(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset, userIdOffset:flatbuffers.Offset, isGuest:boolean, displayNameOffset:flatbuffers.Offset, supportedLanguagesOffset:flatbuffers.Offset):flatbuffers.Offset { +static createSession(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset, userIdOffset:flatbuffers.Offset, isGuest:boolean, displayNameOffset:flatbuffers.Offset, supportedLanguagesOffset:flatbuffers.Offset, serviceLanguageOffset:flatbuffers.Offset):flatbuffers.Offset { Session.startSession(builder); Session.addToken(builder, tokenOffset); Session.addUserId(builder, userIdOffset); Session.addIsGuest(builder, isGuest); Session.addDisplayName(builder, displayNameOffset); Session.addSupportedLanguages(builder, supportedLanguagesOffset); + Session.addServiceLanguage(builder, serviceLanguageOffset); return Session.endSession(builder); } } diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index a079f9f..db63f44 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -19,6 +19,7 @@ import { telegramHaptic, telegramLaunch, telegramOnEvent, + telegramRequestFullscreen, telegramSetChrome, } from './telegram'; import { parseStartParam } from './deeplink'; @@ -62,6 +63,10 @@ export const app = $state<{ /** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined * with friend requests) and the Settings → Info badge. */ feedbackReplyUnread: boolean; + /** Whether to show the "outdated invite link" notice: set when a Telegram deep-link friend + * code is already used/expired, so the visitor lands in the lobby with a gentle pointer to + * the bot instead of a scary error on the Friends screen. */ + staleInvite: boolean; /** Monotonic counter bumped when the app returns to the foreground without the live stream * having dropped. An open game watches it to refetch once, recovering an in-game event shed * from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */ @@ -83,6 +88,7 @@ export const app = $state<{ notifications: 0, chatUnread: {}, feedbackReplyUnread: false, + staleInvite: false, resync: 0, }); @@ -134,6 +140,11 @@ export function showToast(text: string, kind: Toast['kind'] = 'info'): void { toastTimer = setTimeout(() => (app.toast = null), 4000); } +/** dismissStaleInvite hides the outdated-invite-link notice (the user acknowledged it). */ +export function dismissStaleInvite(): void { + app.staleInvite = false; +} + /** clearChatUnread resets a game's unread chat-message count (called when its chat is opened). */ export function clearChatUnread(gameId: string): void { if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 }; @@ -453,6 +464,10 @@ export async function bootstrap(): Promise { telegramOnEvent('safeAreaChanged', syncTelegramSafeArea); telegramOnEvent('fullscreenChanged', syncTelegramSafeArea); telegramDisableVerticalSwipes(); + // On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged + // listener above then re-syncs the safe-area insets. Desktop keeps the bot's full-size + // window. No-op on clients predating Bot API 8.0. + telegramRequestFullscreen(); try { await adoptSession(await gateway.authTelegram(launch.initData)); // A blocked account skips deep-link routing — the blocked screen overlays every route. @@ -487,13 +502,27 @@ async function routeStartParam(param: string): Promise { navigate(`/game/${link.id}`); return; case 'friendCode': - navigate('/friends'); try { const friend = await gateway.friendCodeRedeem(link.code); + navigate('/friends'); showToast(t('friends.added', { name: friend.displayName })); void refreshNotifications(); } catch (err) { - handleError(err); + const code = err instanceof GatewayError ? err.code : ''; + if (code === 'self_relation') { + // Tapping your own invite link redeems your own code: a friendly note, not the + // scary "can't do that to yourself" error. + navigate('/friends'); + showToast(t('friends.selfInvite')); + } else if (code === 'friend_code_invalid') { + // A previously shared link whose single-use, 12h code is already spent or expired: + // land in the lobby and gently point at the bot, rather than a red error on Friends. + navigate('/'); + app.staleInvite = true; + } else { + navigate('/friends'); + handleError(err); + } } return; default: diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 5bd3516..f737ed9 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -171,6 +171,7 @@ describe('codec', () => { isGuest: true, displayName: 'Me', supportedLanguages: ['en', 'ru'], + serviceLanguage: '', }); }); @@ -311,7 +312,7 @@ describe('codec', () => { b.finish(fb.LinkResult.endLinkResult(b)); const r = decodeLinkResult(b.asUint8Array()); expect(r.status).toBe('merged'); - expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya', supportedLanguages: [] }); + expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya', supportedLanguages: [], serviceLanguage: '' }); }); it('decodes an Invitation with inviter and invitees', () => { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 4b8f916..d6537e0 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -292,6 +292,7 @@ function sessionFromTable(t: fb.Session): Session { isGuest: t.isGuest(), displayName: s(t.displayName()), supportedLanguages, + serviceLanguage: s(t.serviceLanguage()), }; } diff --git a/ui/src/lib/deeplink.test.ts b/ui/src/lib/deeplink.test.ts index b0d2718..46d3df5 100644 --- a/ui/src/lib/deeplink.test.ts +++ b/ui/src/lib/deeplink.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink'; +import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink'; describe('parseStartParam', () => { it('classifies game / invitation / friend code', () => { @@ -35,4 +35,38 @@ 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'); + }); +}); + +describe('botUsername', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('returns null without a configured base', () => { + vi.stubEnv('VITE_TELEGRAM_LINK', ''); + expect(botUsername()).toBeNull(); + }); + + it('extracts the bot handle from the Mini App link', () => { + vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app'); + expect(botUsername()).toBe('bot'); + }); + + it('picks the per-bot handle 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(botUsername('en')).toBe('enbot'); + expect(botUsername('ru')).toBe('rubot'); + }); }); diff --git a/ui/src/lib/deeplink.ts b/ui/src/lib/deeplink.ts index b75ad45..33f6d34 100644 --- a/ui/src/lib/deeplink.ts +++ b/ui/src/lib/deeplink.ts @@ -36,13 +36,46 @@ 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; +} + +/** + * botUsername extracts the bot's @username (without the @) from the configured Mini App + * link for a bot language: the first path segment of https://t.me//. Returns + * null when no base is configured or the link carries no path, so callers can fall back + * when they cannot point at a specific bot. + */ +export function botUsername(lang = ''): string | null { + const base = telegramBase(lang); + if (!base) return null; + try { + const seg = new URL(base).pathname.split('/').filter(Boolean)[0]; + return seg || null; + } catch { + return 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..7aaabe1 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -234,6 +234,11 @@ export const en = { 'friends.copy': 'Copy', 'friends.codeCopied': 'Code copied.', 'friends.shareTelegram': 'Share via Telegram', + 'friends.inviteText': "Let's play Scrabble!", + 'friends.linkCopied': 'Link copied.', + 'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️", + 'friends.staleInviteTitle': 'Link expired', + 'friends.staleInvite': 'You opened the game from an outdated link. Open the bot {bot} to play and get notifications.', '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..fa5e58f 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -235,6 +235,11 @@ export const ru: Record = { 'friends.copy': 'Копировать', 'friends.codeCopied': 'Код скопирован.', 'friends.shareTelegram': 'Поделиться через Telegram', + 'friends.inviteText': 'Давай играть в Эрудит!', + 'friends.linkCopied': 'Ссылка скопирована.', + 'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️', + 'friends.staleInviteTitle': 'Ссылка устарела', + 'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.', 'friends.added': 'Добавлен(а) {name}.', 'friends.blockedList': 'Заблокированные', 'friends.unblock': 'Разблокировать', diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index 602ec13..f5af53c 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -25,6 +25,7 @@ export const SESSION: Session = { displayName: 'You', // Both languages by default, so the mock-driven UI offers every variant. supportedLanguages: ['en', 'ru'], + serviceLanguage: '', }; export const PROFILE: Profile = { diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 5726802..d3dbfd4 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -234,6 +234,9 @@ export interface Session { // variants these languages support (en -> English; ru -> Russian + Эрудит). Empty // means ungated (all variants). supportedLanguages: string[]; + // serviceLanguage is the en/ru tag of the Telegram bot this session was minted + // through (empty for a non-Telegram login); used to share a link to the same bot. + serviceLanguage: string; } // LinkResult is the outcome of an account link/merge step. status is diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts index b08d91f..0428034 100644 --- a/ui/src/lib/telegram.test.ts +++ b/ui/src/lib/telegram.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { insideTelegram, telegramLaunch } from './telegram'; +import { insideTelegram, telegramClosingConfirmation, telegramLaunch, telegramRequestFullscreen } from './telegram'; function stubWebApp(initData: string, startParam?: string) { vi.stubGlobal('window', { @@ -37,3 +37,66 @@ describe('telegram launch detection', () => { expect(launch.theme?.bg_color).toBe('#101418'); }); }); + +// stubClient stands up a fake WebApp on the given platform with spies for the mobile-gated +// chrome toggles, so the platform gate can be asserted without a real Telegram client. +function stubClient(platform?: string) { + const enable = vi.fn(); + const disable = vi.fn(); + const requestFullscreen = vi.fn(); + vi.stubGlobal('window', { + Telegram: { + WebApp: { platform, enableClosingConfirmation: enable, disableClosingConfirmation: disable, requestFullscreen }, + }, + }); + return { enable, disable, requestFullscreen }; +} + +const mobilePlatforms = ['ios', 'android', 'android_x']; +const desktopPlatforms = ['tdesktop', 'macos', 'web', undefined]; + +describe('telegramClosingConfirmation', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('arms the close guard on mobile clients', () => { + for (const p of mobilePlatforms) { + const { enable } = stubClient(p); + telegramClosingConfirmation(true); + expect(enable, `platform=${p}`).toHaveBeenCalledOnce(); + } + }); + + it('skips the close guard on desktop clients (the dialog there is just noise)', () => { + for (const p of desktopPlatforms) { + const { enable } = stubClient(p); + telegramClosingConfirmation(true); + expect(enable, `platform=${p}`).not.toHaveBeenCalled(); + } + }); + + it('always lifts the guard on leave, regardless of platform', () => { + const { disable } = stubClient('tdesktop'); + telegramClosingConfirmation(false); + expect(disable).toHaveBeenCalledOnce(); + }); +}); + +describe('telegramRequestFullscreen', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('goes immersive fullscreen on mobile clients', () => { + for (const p of mobilePlatforms) { + const { requestFullscreen } = stubClient(p); + telegramRequestFullscreen(); + expect(requestFullscreen, `platform=${p}`).toHaveBeenCalledOnce(); + } + }); + + it('leaves desktop clients as a standard window (the bot full-size setting fills it)', () => { + for (const p of desktopPlatforms) { + const { requestFullscreen } = stubClient(p); + telegramRequestFullscreen(); + expect(requestFullscreen, `platform=${p}`).not.toHaveBeenCalled(); + } + }); +}); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index f89b41b..252252b 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -8,6 +8,7 @@ import type { TelegramThemeParams } from './theme'; interface TelegramWebApp { initData: string; initDataUnsafe?: { start_param?: string }; + platform?: string; themeParams?: TelegramThemeParams; colorScheme?: 'light' | 'dark'; isFullscreen?: boolean; @@ -15,6 +16,8 @@ interface TelegramWebApp { contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; ready?: () => void; expand?: () => void; + requestFullscreen?: () => void; + openTelegramLink?: (url: string) => void; onEvent?: (event: string, handler: () => void) => void; setHeaderColor?: (color: string) => void; setBackgroundColor?: (color: string) => void; @@ -49,6 +52,29 @@ export function insideTelegram(): boolean { return !!w && typeof w.initData === 'string' && w.initData.length > 0; } +/** + * telegramOpenLink opens a t.me link through the Mini App SDK, so Telegram navigates to + * it natively (e.g. a bot chat) rather than spawning an in-app browser tab. Returns false + * outside Telegram or when the SDK lacks the method, so the caller can fall back to a plain + * window.open. + */ +export function telegramOpenLink(url: string): boolean { + const w = webApp(); + if (!w?.openTelegramLink) return false; + w.openTelegramLink(url); + return true; +} + +/** + * shareTelegramLink opens Telegram's native "share to a chat" picker for url with a + * caption, through the Mini App SDK (https://t.me/share/url). Returns false outside + * 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 { + return telegramOpenLink(`https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`); +} + /** TelegramLaunch is the data a Mini App launch carries. */ export interface TelegramLaunch { initData: string; @@ -142,13 +168,40 @@ export function telegramHaptic(kind: Haptic): void { } /** - * telegramClosingConfirmation toggles the confirmation Telegram shows when the user - * swipes the Mini App closed — enabled during an active game so it is not lost by accident. + * isMobilePlatform reports whether the Mini App runs on a Telegram mobile client — iOS or + * Android (the latter reported as 'android' by Telegram for Android and 'android_x' by + * Telegram X). Desktop clients (tdesktop, macOS, web) report other values. Used to limit + * mobile-only chrome such as the close guard and immersive fullscreen. + */ +function isMobilePlatform(): boolean { + const p = webApp()?.platform; + return p === 'ios' || p === 'android' || p === 'android_x'; +} + +/** + * telegramRequestFullscreen asks Telegram to open the Mini App in fullscreen (Bot API 8.0+), + * but only on mobile clients — mirroring how Telegram's own Mini Apps go immersive on phones + * while staying a standard window on desktop (where the bot's full-size setting already fills + * the window). A no-op outside Telegram, on desktop, or on clients predating the method. + */ +export function telegramRequestFullscreen(): void { + if (isMobilePlatform()) webApp()?.requestFullscreen?.(); +} + +/** + * telegramClosingConfirmation toggles the confirmation Telegram shows when the user swipes + * the Mini App closed — enabled during an active game so it is not lost by accident. The + * guard is only armed on mobile clients: on desktop, closing a window is deliberate and + * Telegram surfaces a "changes may not be saved" dialog that is just noise here (drafts + * auto-save), so the confirmation is skipped there. */ export function telegramClosingConfirmation(on: boolean): void { const w = webApp(); - if (on) w?.enableClosingConfirmation?.(); - else w?.disableClosingConfirmation?.(); + if (on) { + if (isMobilePlatform()) w?.enableClosingConfirmation?.(); + } else { + w?.disableClosingConfirmation?.(); + } } let backHandler: (() => void) | null = null; diff --git a/ui/src/screens/Friends.svelte b/ui/src/screens/Friends.svelte index 57b26dd..13d27f5 100644 --- a/ui/src/screens/Friends.svelte +++ b/ui/src/screens/Friends.svelte @@ -3,8 +3,11 @@ import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; import { gateway } from '../lib/gateway'; - import { t } from '../lib/i18n/index.svelte'; + import { GatewayError } from '../lib/client'; + import { localeFrom, t } from '../lib/i18n/index.svelte'; + import { translate } from '../lib/i18n/catalog'; import { friendCodeParam, shareLink } from '../lib/deeplink'; + import { shareTelegramLink } from '../lib/telegram'; import type { AccountRef, FriendCode } from '../lib/model'; let friends = $state([]); @@ -61,7 +64,11 @@ showToast(t('friends.added', { name: friend.displayName })); await load(); } catch (e) { - handleError(e); + if (e instanceof GatewayError && e.code === 'self_relation') { + showToast(t('friends.selfInvite')); // redeeming your own code: a friendly, non-error note + } else { + handleError(e); + } } } @@ -78,6 +85,30 @@ // 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, lang: string) { + // The caption is in the bot's language (Эрудит for ru, Scrabble for en), not the + // interface language — the recipient lands in that bot. + const text = translate(localeFrom(lang), '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 +128,8 @@
{#if code} - {@const tg = shareLink(friendCodeParam(code.code))} + {@const lang = app.session?.serviceLanguage || app.locale} + {@const tg = shareLink(friendCodeParam(code.code), lang)}
@@ -107,7 +139,7 @@ {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} {#if tg} - {t('friends.shareTelegram')} + {/if}
{:else}