From 6679260d0a9110aea3411f10ee66a1a3c04bfd28 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 15:49:26 +0200 Subject: [PATCH 1/6] feat(session): carry the bot service_language on the Session wire Thread the Telegram bot's service language (en/ru) from the session mint response through the gateway into the FlatBuffers Session, so the UI knows which bot the player signed in through. handleTelegramAuth refreshes the account's service language onto the response before minting (it was set after the fetched copy). Empty for a non-Telegram login. --- backend/internal/server/dto.go | 18 ++++++++++-------- backend/internal/server/handlers_auth.go | 1 + gateway/internal/backendclient/api.go | 9 +++++---- gateway/internal/transcode/encode.go | 2 ++ .../transcode/transcode_telegram_test.go | 7 ++++++- pkg/fbs/scrabble.fbs | 4 ++++ pkg/fbs/scrabblefb/Session.go | 13 ++++++++++++- ui/src/gen/fbs/scrabblefb/session.ts | 16 ++++++++++++++-- ui/src/lib/codec.test.ts | 3 ++- ui/src/lib/codec.ts | 1 + ui/src/lib/mock/data.ts | 1 + ui/src/lib/model.ts | 3 +++ 12 files changed, 61 insertions(+), 17 deletions(-) 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/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/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/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/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 -- 2.52.0 From 03eb8044ff3e908cf3e813306cdd24912795d35a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 15:49:36 +0200 Subject: [PATCH 2/6] feat(ui): real friend-invite share with a per-bot link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The friend-code 'share' was an that just opened the bot. Make it a real share: 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 deep link points at the same bot the player is in — it picks VITE_TELEGRAM_LINK_EN/_RU by the session's service language, falling back to the single VITE_TELEGRAM_LINK. Adds the per-bot build args across Dockerfile / compose / ci.yaml / .env / docs; PLAN TODO-5 updated. --- .gitea/workflows/ci.yaml | 2 ++ PLAN.md | 15 +++++++++------ deploy/.env.example | 4 +++- deploy/README.md | 4 +++- deploy/docker-compose.yml | 4 ++++ gateway/Dockerfile | 4 ++++ ui/README.md | 7 +++++-- ui/src/lib/deeplink.test.ts | 13 +++++++++++++ ui/src/lib/deeplink.ts | 26 +++++++++++++++++++++----- ui/src/lib/i18n/en.ts | 2 ++ ui/src/lib/i18n/ru.ts | 2 ++ ui/src/lib/telegram.ts | 14 ++++++++++++++ ui/src/screens/Friends.svelte | 27 +++++++++++++++++++++++++-- 13 files changed, 107 insertions(+), 17 deletions(-) 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)}
{#if code} - {@const tg = shareLink(friendCodeParam(code.code), app.session?.serviceLanguage || app.locale)} + {@const lang = app.session?.serviceLanguage || app.locale} + {@const tg = shareLink(friendCodeParam(code.code), lang)}
@@ -130,7 +139,7 @@ {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} {#if tg} - + {/if}
{:else} -- 2.52.0 From 853730823b121719530dfd60d1705233132413a7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 17:22:09 +0200 Subject: [PATCH 4/6] feat(ui): refine Telegram invite & close UX - Shared invite links open the Mini App fullscreen (mode=fullscreen), so a shared link matches the bot's own fullscreen launch. - A used or expired invite deep-link now lands the visitor in the lobby with a gentle notice pointing at the right bot (@, by service language), instead of a red "code invalid/expired" error on the Friends screen. - The in-game close confirmation is armed only on Telegram mobile clients; on desktop (tdesktop/macOS/web) it is skipped, where the "changes may not be saved" dialog is just noise (drafts auto-save). --- ui/src/App.svelte | 2 + ui/src/components/StaleInviteModal.svelte | 53 +++++++++++++++++++++++ ui/src/lib/app.svelte.ts | 26 +++++++++-- ui/src/lib/deeplink.test.ts | 31 ++++++++++--- ui/src/lib/deeplink.ts | 21 ++++++++- ui/src/lib/i18n/en.ts | 2 + ui/src/lib/i18n/ru.ts | 2 + ui/src/lib/telegram.test.ts | 39 ++++++++++++++++- ui/src/lib/telegram.ts | 43 ++++++++++++++---- 9 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 ui/src/components/StaleInviteModal.svelte 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/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 94d8739..ec5e034 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -62,6 +62,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 +87,7 @@ export const app = $state<{ notifications: 0, chatUnread: {}, feedbackReplyUnread: false, + staleInvite: false, resync: 0, }); @@ -134,6 +139,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 }; @@ -487,17 +497,25 @@ 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) { - // Tapping your own invite link redeems your own code: show a friendly note, not - // the scary "can't do that to yourself" error. - if (err instanceof GatewayError && err.code === 'self_relation') { + 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); } } diff --git a/ui/src/lib/deeplink.test.ts b/ui/src/lib/deeplink.test.ts index 7ad1df4..d7c2f08 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', () => { @@ -33,19 +33,40 @@ describe('shareLink', () => { it('wraps a payload in a startapp link', () => { vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app'); - expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456'); + expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456&mode=fullscreen'); }); 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'); + expect(shareLink('f1', 'en')).toBe('https://t.me/enbot/app?startapp=f1&mode=fullscreen'); + expect(shareLink('f1', 'ru')).toBe('https://t.me/rubot/app?startapp=f1&mode=fullscreen'); }); 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'); + expect(shareLink('f1', 'ru')).toBe('https://t.me/fallback/app?startapp=f1&mode=fullscreen'); + }); +}); + +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 f5cf6fc..5431531 100644 --- a/ui/src/lib/deeplink.ts +++ b/ui/src/lib/deeplink.ts @@ -52,6 +52,23 @@ function telegramBase(lang: string): string | null { 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 @@ -61,5 +78,7 @@ 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)}`; + // mode=fullscreen opens the Mini App fullscreen, matching how the bot's own entry + // opens it, so a shared link and the bot give the same experience. + return `${base}${sep}startapp=${encodeURIComponent(param)}&mode=fullscreen`; } diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index b01d5b6..7aaabe1 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -237,6 +237,8 @@ export const en = { '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 3d6da26..fa5e58f 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -238,6 +238,8 @@ export const ru: Record = { 'friends.inviteText': 'Давай играть в Эрудит!', 'friends.linkCopied': 'Ссылка скопирована.', 'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️', + 'friends.staleInviteTitle': 'Ссылка устарела', + 'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.', 'friends.added': 'Добавлен(а) {name}.', 'friends.blockedList': 'Заблокированные', 'friends.unblock': 'Разблокировать', diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts index b08d91f..fa93728 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 } from './telegram'; function stubWebApp(initData: string, startParam?: string) { vi.stubGlobal('window', { @@ -37,3 +37,40 @@ describe('telegram launch detection', () => { expect(launch.theme?.bg_color).toBe('#101418'); }); }); + +describe('telegramClosingConfirmation', () => { + afterEach(() => vi.unstubAllGlobals()); + + // stubClient stands up a fake WebApp on the given platform with spies for the close-guard + // 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(); + vi.stubGlobal('window', { + Telegram: { WebApp: { platform, enableClosingConfirmation: enable, disableClosingConfirmation: disable } }, + }); + return { enable, disable }; + } + + it('arms the close guard on mobile clients', () => { + for (const p of ['ios', 'android']) { + 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 ['tdesktop', 'macos', 'web', undefined]) { + 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(); + }); +}); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index d714491..8e963f9 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; @@ -50,6 +51,19 @@ 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 @@ -57,10 +71,7 @@ export function insideTelegram(): boolean { * 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; + return telegramOpenLink(`https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`); } /** TelegramLaunch is the data a Mini App launch carries. */ @@ -156,13 +167,29 @@ 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), where a stray swipe-down can drop the app. Desktop clients (tdesktop, macOS, + * web) report other values and close only on a deliberate window action. + */ +function isMobilePlatform(): boolean { + const p = webApp()?.platform; + return p === 'ios' || p === 'android'; +} + +/** + * 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; -- 2.52.0 From 00129414e50f3055e72cb86114242404cfd083c5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 17:34:07 +0200 Subject: [PATCH 5/6] feat(ui): force Mini App fullscreen on mobile, not via the share link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the shared-link &mode=fullscreen (which would also force fullscreen on desktop) with an imperative requestFullscreen() on launch, gated to mobile clients (ios/android/android_x) — mirroring how Telegram's own Mini Apps go immersive on phones while desktop keeps the bot's full-size window. It triggers the existing fullscreenChanged -> safe-area resync; a no-op on clients predating Bot API 8.0. --- ui/src/lib/app.svelte.ts | 5 ++++ ui/src/lib/deeplink.test.ts | 8 +++--- ui/src/lib/deeplink.ts | 4 +-- ui/src/lib/telegram.test.ts | 54 +++++++++++++++++++++++++++---------- ui/src/lib/telegram.ts | 20 +++++++++++--- 5 files changed, 66 insertions(+), 25 deletions(-) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index ec5e034..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'; @@ -463,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. diff --git a/ui/src/lib/deeplink.test.ts b/ui/src/lib/deeplink.test.ts index d7c2f08..46d3df5 100644 --- a/ui/src/lib/deeplink.test.ts +++ b/ui/src/lib/deeplink.test.ts @@ -33,20 +33,20 @@ describe('shareLink', () => { it('wraps a payload in a startapp link', () => { vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app'); - expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456&mode=fullscreen'); + 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&mode=fullscreen'); - expect(shareLink('f1', 'ru')).toBe('https://t.me/rubot/app?startapp=f1&mode=fullscreen'); + 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&mode=fullscreen'); + 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 5431531..33f6d34 100644 --- a/ui/src/lib/deeplink.ts +++ b/ui/src/lib/deeplink.ts @@ -78,7 +78,5 @@ export function shareLink(param: string, lang = ''): string | null { const base = telegramBase(lang); if (!base) return null; const sep = base.includes('?') ? '&' : '?'; - // mode=fullscreen opens the Mini App fullscreen, matching how the bot's own entry - // opens it, so a shared link and the bot give the same experience. - return `${base}${sep}startapp=${encodeURIComponent(param)}&mode=fullscreen`; + return `${base}${sep}startapp=${encodeURIComponent(param)}`; } diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts index fa93728..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, telegramClosingConfirmation, telegramLaunch } from './telegram'; +import { insideTelegram, telegramClosingConfirmation, telegramLaunch, telegramRequestFullscreen } from './telegram'; function stubWebApp(initData: string, startParam?: string) { vi.stubGlobal('window', { @@ -38,22 +38,28 @@ describe('telegram launch detection', () => { }); }); +// 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()); - // stubClient stands up a fake WebApp on the given platform with spies for the close-guard - // 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(); - vi.stubGlobal('window', { - Telegram: { WebApp: { platform, enableClosingConfirmation: enable, disableClosingConfirmation: disable } }, - }); - return { enable, disable }; - } - it('arms the close guard on mobile clients', () => { - for (const p of ['ios', 'android']) { + for (const p of mobilePlatforms) { const { enable } = stubClient(p); telegramClosingConfirmation(true); expect(enable, `platform=${p}`).toHaveBeenCalledOnce(); @@ -61,7 +67,7 @@ describe('telegramClosingConfirmation', () => { }); it('skips the close guard on desktop clients (the dialog there is just noise)', () => { - for (const p of ['tdesktop', 'macos', 'web', undefined]) { + for (const p of desktopPlatforms) { const { enable } = stubClient(p); telegramClosingConfirmation(true); expect(enable, `platform=${p}`).not.toHaveBeenCalled(); @@ -74,3 +80,23 @@ describe('telegramClosingConfirmation', () => { 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 8e963f9..252252b 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -16,6 +16,7 @@ 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; @@ -167,13 +168,24 @@ export function telegramHaptic(kind: Haptic): void { } /** - * isMobilePlatform reports whether the Mini App runs on a Telegram mobile client (iOS or - * Android), where a stray swipe-down can drop the app. Desktop clients (tdesktop, macOS, - * web) report other values and close only on a deliberate window action. + * 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'; + 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?.(); } /** -- 2.52.0 From 8073971fca526d8c5f9fd75d21e4609a9a160875 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 17:39:35 +0200 Subject: [PATCH 6/6] docs: bake Telegram invite & launch refinements into ARCHITECTURE/UI_DESIGN - UI_DESIGN: mobile immersive fullscreen on launch (desktop keeps full-size); the close confirmation is now mobile-only; friend-code share-via-Telegram deep-link (per-bot, by service language), the friendly self-redeem note, and the outdated-link lobby notice. - ARCHITECTURE: the service language rides the Session wire so the client builds the per-bot invite link; the friend code is shared as a startapp deep-link with graceful spent/expired handling. --- docs/ARCHITECTURE.md | 10 ++++++++-- docs/UI_DESIGN.md | 18 ++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) 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. -- 2.52.0
{:else} -- 2.52.0 From 01d2d1f368acd7c6915b5f8d23bdf0ad4e580e60 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 16:09:10 +0200 Subject: [PATCH 3/6] fix(ui): per-bot invite caption + friendly self-redeem note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The share caption is now in the bot's language: 'Давай играть в Эрудит!' (ru bot), "Let's play Scrabble!" (en bot) — picked by the session service language, not the interface language. - Redeeming your own invite (deep link or manual) no longer shows the scary 'can't do that to yourself' error; it shows a friendly neutral note instead. --- ui/src/lib/app.svelte.ts | 8 +++++++- ui/src/lib/i18n/en.ts | 3 ++- ui/src/lib/i18n/ru.ts | 3 ++- ui/src/screens/Friends.svelte | 21 +++++++++++++++------ 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index a079f9f..94d8739 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -493,7 +493,13 @@ async function routeStartParam(param: string): Promise { showToast(t('friends.added', { name: friend.displayName })); void refreshNotifications(); } catch (err) { - handleError(err); + // Tapping your own invite link redeems your own code: show a friendly note, not + // the scary "can't do that to yourself" error. + if (err instanceof GatewayError && err.code === 'self_relation') { + showToast(t('friends.selfInvite')); + } else { + handleError(err); + } } return; default: diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 48cc214..b01d5b6 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -234,8 +234,9 @@ export const en = { 'friends.copy': 'Copy', 'friends.codeCopied': 'Code copied.', 'friends.shareTelegram': 'Share via Telegram', - 'friends.inviteText': "Let's be friends in Scrabble!", + 'friends.inviteText': "Let's play Scrabble!", 'friends.linkCopied': 'Link copied.', + 'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️", '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 e3fcb7e..3d6da26 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -235,8 +235,9 @@ export const ru: Record = { 'friends.copy': 'Копировать', 'friends.codeCopied': 'Код скопирован.', 'friends.shareTelegram': 'Поделиться через Telegram', - 'friends.inviteText': 'Давай дружить в Скрэббл!', + 'friends.inviteText': 'Давай играть в Эрудит!', 'friends.linkCopied': 'Ссылка скопирована.', + 'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️', 'friends.added': 'Добавлен(а) {name}.', 'friends.blockedList': 'Заблокированные', 'friends.unblock': 'Разблокировать', diff --git a/ui/src/screens/Friends.svelte b/ui/src/screens/Friends.svelte index 620efeb..13d27f5 100644 --- a/ui/src/screens/Friends.svelte +++ b/ui/src/screens/Friends.svelte @@ -3,7 +3,9 @@ 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'; @@ -62,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); + } } } @@ -83,8 +89,10 @@ // 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'); + 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 { @@ -120,7 +128,8 @@