Merge pull request 'feat(ui): настоящий share friend-code + ссылка по текущему боту (service_language)' (#66) from feat/friend-invite-share into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 13s
CI / ui (push) Successful in 47s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m0s

This commit was merged in pull request #66.
This commit is contained in:
2026-06-15 15:42:19 +00:00
31 changed files with 439 additions and 50 deletions
+2
View File
@@ -267,6 +267,8 @@ jobs:
TELEGRAM_TEST_ENV: "true" TELEGRAM_TEST_ENV: "true"
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_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_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }}
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }} VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }}
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
+9 -6
View File
@@ -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 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 discharged in Stage 14: the dict repo builds against the published solver + pinned
`dafsa`/`alphabet`, byte-identical to the fixtures. `dafsa`/`alphabet`, byte-identical to the fixtures.
- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:* - **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9, extended later:*
the deep-link scheme now exists (`f<code>`, shared Go ↔ TS), the bot redeems it on the deep-link scheme exists (`f<code>`, shared Go ↔ TS), the bot redeems it on launch, and the UI
launch, and the UI shows a **share-to-Telegram** link for an issued code when offers a real **Share** control for an issued code — Telegram's native share-to-chat picker inside
`VITE_TELEGRAM_LINK` is configured. **Still open:** render the link as a **QR** so a the Mini App (`openTelegramLink` + `t.me/share/url`), the system share sheet (`navigator.share`) on
friend can add you by scanning rather than tapping/typing. The code semantics the web, else copy-the-link. The shared link points at the **same bot the player signed in through**:
(12 h TTL, single use, one active per issuer) stay as-is; only the delivery changes. 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).** - **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 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 pick). Default it from the player's history (the variant they play most, from
+10 -8
View File
@@ -16,10 +16,11 @@ import (
// sessionResponse is the credential returned by every auth endpoint. // sessionResponse is the credential returned by every auth endpoint.
type sessionResponse struct { type sessionResponse struct {
Token string `json:"token"` Token string `json:"token"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"` IsGuest bool `json:"is_guest"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
ServiceLanguage string `json:"service_language"`
} }
// okResponse is a simple success acknowledgement. // okResponse is a simple success acknowledgement.
@@ -159,10 +160,11 @@ type errorBody struct {
// sessionResponseFor builds the credential payload for a minted session. // sessionResponseFor builds the credential payload for a minted session.
func sessionResponseFor(token string, acc account.Account) sessionResponse { func sessionResponseFor(token string, acc account.Account) sessionResponse {
return sessionResponse{ return sessionResponse{
Token: token, Token: token,
UserID: acc.ID.String(), UserID: acc.ID.String(),
IsGuest: acc.IsGuest, IsGuest: acc.IsGuest,
DisplayName: acc.DisplayName, DisplayName: acc.DisplayName,
ServiceLanguage: acc.ServiceLanguage,
} }
} }
+1
View File
@@ -48,6 +48,7 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
acc.ServiceLanguage = req.ServiceLanguage // reflect this login's bot in the session response
s.mintSession(c, acc) s.mintSession(c, acc)
} }
+3 -1
View File
@@ -29,7 +29,9 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
# --- UI build args (baked into the gateway image) --------------------------- # --- UI build args (baked into the gateway image) ---------------------------
VITE_TELEGRAM_BOT_ID= 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/<bot>/<app>)
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_EN= # landing "Play in Telegram" link, English bot
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= # landing "Play in Telegram" link, Russian bot VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= # landing "Play in Telegram" link, Russian bot
VITE_GATEWAY_URL= VITE_GATEWAY_URL=
+3 -1
View File
@@ -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`. | | `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). | | `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_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/<bot>/<app>`). | | `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: **fallback** friend-invite Mini App link (e.g. `https://t.me/<bot>/<app>`), 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/<bot>/<app>``<app>` 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_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_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). | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
+4
View File
@@ -121,6 +121,8 @@ services:
args: args:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_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_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-} VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
@@ -169,6 +171,8 @@ services:
args: args:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_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_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-} VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
+8 -2
View File
@@ -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 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 → 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 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 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). 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 - 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 - **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, 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 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** 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 (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 expires after **30 days** and may be re-sent), or **decline** — a decline is
+14 -4
View File
@@ -67,8 +67,12 @@ Login uses `Screen`.
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` / theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
`setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the `setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
native header **BackButton** drives back-navigation (the app's chevron is hidden in native header **BackButton** drives back-navigation (the app's chevron is hidden in
Telegram); **HapticFeedback** fires on tile placement / commit / error; **closing Telegram); **HapticFeedback** fires on tile placement / commit / error; on **mobile**
confirmation** is enabled while a game is open; **vertical swipes** (swipe-to-minimise) 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 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 by a background suspend reconnects silently on return — the connection banner is
suppressed while hidden and for a short grace after resume (visibilitychange + 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 every field is valid. Interface language stays in **Settings** (it writes through to
the account for durable users). the account for durable users).
- **Friend code**: the issued code sits next to a 📋 copy control; tapping the code or - **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 the icon copies it. **Share via Telegram** wraps the code in a `t.me/<bot>/<app>?startapp=`
overflowing in Safari. 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 (`@<username>`), 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 - **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 (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. header) shares or downloads the `.gcg` file and appears only once the game is finished.
+4
View File
@@ -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"). # VITE_APP_VERSION carries `git describe` for the About screen (defaults to "dev").
ARG VITE_TELEGRAM_BOT_ID= ARG VITE_TELEGRAM_BOT_ID=
ARG VITE_TELEGRAM_LINK= ARG VITE_TELEGRAM_LINK=
ARG VITE_TELEGRAM_LINK_EN=
ARG VITE_TELEGRAM_LINK_RU=
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=
ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= ARG VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=
ARG VITE_GATEWAY_URL= ARG VITE_GATEWAY_URL=
ARG VITE_APP_VERSION= ARG VITE_APP_VERSION=
ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \ VITE_TELEGRAM_LINK=$VITE_TELEGRAM_LINK \
VITE_TELEGRAM_LINK_EN=$VITE_TELEGRAM_LINK_EN \
VITE_TELEGRAM_LINK_RU=$VITE_TELEGRAM_LINK_RU \
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \ VITE_TELEGRAM_GAME_CHANNEL_NAME_EN=$VITE_TELEGRAM_GAME_CHANNEL_NAME_EN \
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \ VITE_TELEGRAM_GAME_CHANNEL_NAME_RU=$VITE_TELEGRAM_GAME_CHANNEL_NAME_RU \
VITE_GATEWAY_URL=$VITE_GATEWAY_URL \ VITE_GATEWAY_URL=$VITE_GATEWAY_URL \
+5 -4
View File
@@ -14,10 +14,11 @@ import (
// SessionResp is the credential minted by an auth operation. // SessionResp is the credential minted by an auth operation.
type SessionResp struct { type SessionResp struct {
Token string `json:"token"` Token string `json:"token"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"` IsGuest bool `json:"is_guest"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
ServiceLanguage string `json:"service_language"`
} }
// ProfileResp is an account's own profile. // ProfileResp is an account's own profile.
+2
View File
@@ -36,6 +36,7 @@ func encodeSession(s backendclient.SessionResp, supportedLangs []string) []byte
token := b.CreateString(s.Token) token := b.CreateString(s.Token)
uid := b.CreateString(s.UserID) uid := b.CreateString(s.UserID)
name := b.CreateString(s.DisplayName) name := b.CreateString(s.DisplayName)
svcLang := b.CreateString(s.ServiceLanguage)
langs := buildSupportedLanguagesVector(b, supportedLangs) langs := buildSupportedLanguagesVector(b, supportedLangs)
fb.SessionStart(b) fb.SessionStart(b)
fb.SessionAddToken(b, token) fb.SessionAddToken(b, token)
@@ -43,6 +44,7 @@ func encodeSession(s backendclient.SessionResp, supportedLangs []string) []byte
fb.SessionAddIsGuest(b, s.IsGuest) fb.SessionAddIsGuest(b, s.IsGuest)
fb.SessionAddDisplayName(b, name) fb.SessionAddDisplayName(b, name)
fb.SessionAddSupportedLanguages(b, langs) fb.SessionAddSupportedLanguages(b, langs)
fb.SessionAddServiceLanguage(b, svcLang)
b.Finish(fb.SessionEnd(b)) b.Finish(fb.SessionEnd(b))
return b.FinishedBytes() return b.FinishedBytes()
} }
@@ -43,7 +43,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
t.Errorf("unexpected path %q", r.URL.Path) t.Errorf("unexpected path %q", r.URL.Path)
} }
_ = json.NewDecoder(r.Body).Decode(&gotBody) _ = 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() defer cleanup()
@@ -67,6 +67,11 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
if got := sessionLanguages(sess); len(got) != 1 || got[0] != "ru" { if got := sessionLanguages(sess); len(got) != 1 || got[0] != "ru" {
t.Errorf("session supported_languages = %v, want [ru]", got) 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; // 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. // 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" { if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" || gotBody["service_language"] != "ru" {
+4
View File
@@ -123,6 +123,10 @@ table Session {
is_guest:bool; is_guest:bool;
display_name:string; display_name:string;
supported_languages:[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). // Ack is a simple success acknowledgement (e.g. an email-code request).
+12 -1
View File
@@ -94,8 +94,16 @@ func (rcv *Session) SupportedLanguagesLength() int {
return 0 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) { func SessionStart(builder *flatbuffers.Builder) {
builder.StartObject(5) builder.StartObject(6)
} }
func SessionAddToken(builder *flatbuffers.Builder, token flatbuffers.UOffsetT) { func SessionAddToken(builder *flatbuffers.Builder, token flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(token), 0) 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 { func SessionStartSupportedLanguagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4) 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 { func SessionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+5 -2
View File
@@ -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_URL` overrides the dev proxy target; `VITE_GATEWAY_URL` sets the runtime
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` 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 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 domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK_EN` /
share-to-Telegram deep-link base. `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` `VITE_TELEGRAM_LINK_RU` are the per-bot friend-invite Mini App links (full URL
`https://t.me/<bot>/<app>`; 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. 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 The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
+2
View File
@@ -6,6 +6,7 @@
import { t } from './lib/i18n/index.svelte'; import { t } from './lib/i18n/index.svelte';
import { insideTelegram, telegramBackButton } from './lib/telegram'; import { insideTelegram, telegramBackButton } from './lib/telegram';
import Toast from './components/Toast.svelte'; import Toast from './components/Toast.svelte';
import StaleInviteModal from './components/StaleInviteModal.svelte';
import Login from './screens/Login.svelte'; import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte'; import Lobby from './screens/Lobby.svelte';
import NewGame from './screens/NewGame.svelte'; import NewGame from './screens/NewGame.svelte';
@@ -108,6 +109,7 @@
{/if} {/if}
<Toast /> <Toast />
<StaleInviteModal />
<style> <style>
.splash { .splash {
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import { app, dismissStaleInvite } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import { botUsername } from '../lib/deeplink';
import { telegramOpenLink } from '../lib/telegram';
import Modal from './Modal.svelte';
// Point at the bot the player signed in through (its service language), falling back to
// the interface locale, so an ru player is sent to the ru bot and an en player to the en one.
const username = $derived(botUsername(app.session?.serviceLanguage || app.locale));
// Split the message around the {bot} token so the bot handle renders as an inline link.
const parts = $derived(t('friends.staleInvite').split('{bot}'));
function openBot() {
if (username) {
const url = `https://t.me/${username}`;
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
// back to a new tab.
if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank');
}
dismissStaleInvite();
}
</script>
{#if app.staleInvite}
<Modal title={t('friends.staleInviteTitle')} onclose={dismissStaleInvite}>
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
<button class="ok" onclick={dismissStaleInvite}>{t('common.ok')}</button>
</Modal>
{/if}
<style>
.msg {
margin: 0 0 16px;
line-height: 1.5;
}
.bot {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--accent);
cursor: pointer;
}
.ok {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
</style>
+14 -2
View File
@@ -58,8 +58,15 @@ supportedLanguagesLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; 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) { static startSession(builder:flatbuffers.Builder) {
builder.startObject(5); builder.startObject(6);
} }
static addToken(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset) { static addToken(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset) {
@@ -94,18 +101,23 @@ static startSupportedLanguagesVector(builder:flatbuffers.Builder, numElems:numbe
builder.startVector(4, numElems, 4); 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 { static endSession(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; 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.startSession(builder);
Session.addToken(builder, tokenOffset); Session.addToken(builder, tokenOffset);
Session.addUserId(builder, userIdOffset); Session.addUserId(builder, userIdOffset);
Session.addIsGuest(builder, isGuest); Session.addIsGuest(builder, isGuest);
Session.addDisplayName(builder, displayNameOffset); Session.addDisplayName(builder, displayNameOffset);
Session.addSupportedLanguages(builder, supportedLanguagesOffset); Session.addSupportedLanguages(builder, supportedLanguagesOffset);
Session.addServiceLanguage(builder, serviceLanguageOffset);
return Session.endSession(builder); return Session.endSession(builder);
} }
} }
+31 -2
View File
@@ -19,6 +19,7 @@ import {
telegramHaptic, telegramHaptic,
telegramLaunch, telegramLaunch,
telegramOnEvent, telegramOnEvent,
telegramRequestFullscreen,
telegramSetChrome, telegramSetChrome,
} from './telegram'; } from './telegram';
import { parseStartParam } from './deeplink'; 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 /** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
* with friend requests) and the Settings → Info badge. */ * with friend requests) and the Settings → Info badge. */
feedbackReplyUnread: boolean; 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 /** 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 * 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). */ * 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, notifications: 0,
chatUnread: {}, chatUnread: {},
feedbackReplyUnread: false, feedbackReplyUnread: false,
staleInvite: false,
resync: 0, resync: 0,
}); });
@@ -134,6 +140,11 @@ export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
toastTimer = setTimeout(() => (app.toast = null), 4000); 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). */ /** clearChatUnread resets a game's unread chat-message count (called when its chat is opened). */
export function clearChatUnread(gameId: string): void { export function clearChatUnread(gameId: string): void {
if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 }; if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 };
@@ -457,6 +468,10 @@ export async function bootstrap(): Promise<void> {
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea); telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea); telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
telegramDisableVerticalSwipes(); 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 { try {
await adoptSession(await gateway.authTelegram(launch.initData)); await adoptSession(await gateway.authTelegram(launch.initData));
// A blocked account skips deep-link routing — the blocked screen overlays every route. // A blocked account skips deep-link routing — the blocked screen overlays every route.
@@ -491,13 +506,27 @@ async function routeStartParam(param: string): Promise<void> {
navigate(`/game/${link.id}`); navigate(`/game/${link.id}`);
return; return;
case 'friendCode': case 'friendCode':
navigate('/friends');
try { try {
const friend = await gateway.friendCodeRedeem(link.code); const friend = await gateway.friendCodeRedeem(link.code);
navigate('/friends');
showToast(t('friends.added', { name: friend.displayName })); showToast(t('friends.added', { name: friend.displayName }));
void refreshNotifications(); void refreshNotifications();
} catch (err) { } 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; return;
default: default:
+2 -1
View File
@@ -171,6 +171,7 @@ describe('codec', () => {
isGuest: true, isGuest: true,
displayName: 'Me', displayName: 'Me',
supportedLanguages: ['en', 'ru'], supportedLanguages: ['en', 'ru'],
serviceLanguage: '',
}); });
}); });
@@ -311,7 +312,7 @@ describe('codec', () => {
b.finish(fb.LinkResult.endLinkResult(b)); b.finish(fb.LinkResult.endLinkResult(b));
const r = decodeLinkResult(b.asUint8Array()); const r = decodeLinkResult(b.asUint8Array());
expect(r.status).toBe('merged'); 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', () => { it('decodes an Invitation with inviter and invitees', () => {
+1
View File
@@ -292,6 +292,7 @@ function sessionFromTable(t: fb.Session): Session {
isGuest: t.isGuest(), isGuest: t.isGuest(),
displayName: s(t.displayName()), displayName: s(t.displayName()),
supportedLanguages, supportedLanguages,
serviceLanguage: s(t.serviceLanguage()),
}; };
} }
+35 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; 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', () => { describe('parseStartParam', () => {
it('classifies game / invitation / friend code', () => { it('classifies game / invitation / friend code', () => {
@@ -35,4 +35,38 @@ describe('shareLink', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app'); 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');
}); });
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');
});
}); });
+38 -5
View File
@@ -36,13 +36,46 @@ export const invitationParam = (id: string): string => 'i' + id;
/** friendCodeParam builds the start parameter that redeems a friend code. */ /** friendCodeParam builds the start parameter that redeems a friend code. */
export const friendCodeParam = (code: string): string => 'f' + code; export const friendCodeParam = (code: string): string => 'f' + code;
function envVar(name: string): string | undefined {
return (import.meta.env as Record<string, string | undefined>)[name];
}
/** /**
* shareLink wraps a deep-link start parameter in a t.me Mini App link, using the * telegramBase returns the Mini App link base (e.g. https://t.me/<bot>/<app>) for a
* VITE_TELEGRAM_LINK base (e.g. https://t.me/<bot>/<app>). It returns null when the * bot language: VITE_TELEGRAM_LINK_EN / _RU when lang is en/ru, else the
* base is not configured, so callers can hide the share affordance. * 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 { function telegramBase(lang: string): string | null {
const base = import.meta.env.VITE_TELEGRAM_LINK as string | undefined; 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/<bot>/<app>. 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; if (!base) return null;
const sep = base.includes('?') ? '&' : '?'; const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}startapp=${encodeURIComponent(param)}`; return `${base}${sep}startapp=${encodeURIComponent(param)}`;
+5
View File
@@ -234,6 +234,11 @@ export const en = {
'friends.copy': 'Copy', 'friends.copy': 'Copy',
'friends.codeCopied': 'Code copied.', 'friends.codeCopied': 'Code copied.',
'friends.shareTelegram': 'Share via Telegram', '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.added': 'Added {name}.',
'friends.blockedList': 'Blocked players', 'friends.blockedList': 'Blocked players',
'friends.unblock': 'Unblock', 'friends.unblock': 'Unblock',
+5
View File
@@ -235,6 +235,11 @@ export const ru: Record<MessageKey, string> = {
'friends.copy': 'Копировать', 'friends.copy': 'Копировать',
'friends.codeCopied': 'Код скопирован.', 'friends.codeCopied': 'Код скопирован.',
'friends.shareTelegram': 'Поделиться через Telegram', 'friends.shareTelegram': 'Поделиться через Telegram',
'friends.inviteText': 'Давай играть в Эрудит!',
'friends.linkCopied': 'Ссылка скопирована.',
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
'friends.staleInviteTitle': 'Ссылка устарела',
'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.',
'friends.added': 'Добавлен(а) {name}.', 'friends.added': 'Добавлен(а) {name}.',
'friends.blockedList': 'Заблокированные', 'friends.blockedList': 'Заблокированные',
'friends.unblock': 'Разблокировать', 'friends.unblock': 'Разблокировать',
+1
View File
@@ -25,6 +25,7 @@ export const SESSION: Session = {
displayName: 'You', displayName: 'You',
// Both languages by default, so the mock-driven UI offers every variant. // Both languages by default, so the mock-driven UI offers every variant.
supportedLanguages: ['en', 'ru'], supportedLanguages: ['en', 'ru'],
serviceLanguage: '',
}; };
export const PROFILE: Profile = { export const PROFILE: Profile = {
+3
View File
@@ -234,6 +234,9 @@ export interface Session {
// variants these languages support (en -> English; ru -> Russian + Эрудит). Empty // variants these languages support (en -> English; ru -> Russian + Эрудит). Empty
// means ungated (all variants). // means ungated (all variants).
supportedLanguages: string[]; 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 // LinkResult is the outcome of an account link/merge step. status is
+64 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; 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) { function stubWebApp(initData: string, startParam?: string) {
vi.stubGlobal('window', { vi.stubGlobal('window', {
@@ -37,3 +37,66 @@ describe('telegram launch detection', () => {
expect(launch.theme?.bg_color).toBe('#101418'); 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();
}
});
});
+57 -4
View File
@@ -8,6 +8,7 @@ import type { TelegramThemeParams } from './theme';
interface TelegramWebApp { interface TelegramWebApp {
initData: string; initData: string;
initDataUnsafe?: { start_param?: string }; initDataUnsafe?: { start_param?: string };
platform?: string;
themeParams?: TelegramThemeParams; themeParams?: TelegramThemeParams;
colorScheme?: 'light' | 'dark'; colorScheme?: 'light' | 'dark';
isFullscreen?: boolean; isFullscreen?: boolean;
@@ -15,6 +16,8 @@ interface TelegramWebApp {
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
ready?: () => void; ready?: () => void;
expand?: () => void; expand?: () => void;
requestFullscreen?: () => void;
openTelegramLink?: (url: string) => void;
onEvent?: (event: string, handler: () => void) => void; onEvent?: (event: string, handler: () => void) => void;
setHeaderColor?: (color: string) => void; setHeaderColor?: (color: string) => void;
setBackgroundColor?: (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; 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. */ /** TelegramLaunch is the data a Mini App launch carries. */
export interface TelegramLaunch { export interface TelegramLaunch {
initData: string; initData: string;
@@ -142,13 +168,40 @@ export function telegramHaptic(kind: Haptic): void {
} }
/** /**
* telegramClosingConfirmation toggles the confirmation Telegram shows when the user * isMobilePlatform reports whether the Mini App runs on a Telegram mobile client — iOS or
* swipes the Mini App closed — enabled during an active game so it is not lost by accident. * 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 { export function telegramClosingConfirmation(on: boolean): void {
const w = webApp(); const w = webApp();
if (on) w?.enableClosingConfirmation?.(); if (on) {
else w?.disableClosingConfirmation?.(); if (isMobilePlatform()) w?.enableClosingConfirmation?.();
} else {
w?.disableClosingConfirmation?.();
}
} }
let backHandler: (() => void) | null = null; let backHandler: (() => void) | null = null;
+36 -4
View File
@@ -3,8 +3,11 @@
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte'; import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; 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 { friendCodeParam, shareLink } from '../lib/deeplink';
import { shareTelegramLink } from '../lib/telegram';
import type { AccountRef, FriendCode } from '../lib/model'; import type { AccountRef, FriendCode } from '../lib/model';
let friends = $state<AccountRef[]>([]); let friends = $state<AccountRef[]>([]);
@@ -61,7 +64,11 @@
showToast(t('friends.added', { name: friend.displayName })); showToast(t('friends.added', { name: friend.displayName }));
await load(); await load();
} catch (e) { } 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. // 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.
}
}
</script> </script>
<div class="page"> <div class="page">
@@ -97,7 +128,8 @@
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button> <button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
</div> </div>
{#if code} {#if code}
{@const tg = shareLink(friendCodeParam(code.code))} {@const lang = app.session?.serviceLanguage || app.locale}
{@const tg = shareLink(friendCodeParam(code.code), lang)}
<div class="code" data-testid="friend-code"> <div class="code" data-testid="friend-code">
<div class="coderow"> <div class="coderow">
<button class="codeval" onclick={copyCode}>{code.code}</button> <button class="codeval" onclick={copyCode}>{code.code}</button>
@@ -107,7 +139,7 @@
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span> </span>
{#if tg} {#if tg}
<a class="link tgshare" href={tg} target="_blank" rel="noopener">{t('friends.shareTelegram')}</a> <button type="button" class="link tgshare" onclick={() => shareInvite(tg, lang)}>{t('friends.shareTelegram')}</button>
{/if} {/if}
</div> </div>
{:else} {:else}