Merge pull request 'feat(ui): настоящий share friend-code + ссылка по текущему боту (service_language)' (#66) from feat/friend-invite-share into development
This commit was merged in pull request #66.
This commit is contained in:
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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). |
|
||||||
|
|||||||
@@ -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:-}
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)}`;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': 'Разблокировать',
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user