Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93d086a8a3 | |||
| b03e012011 | |||
| 2495446a47 | |||
| 35705f7d1e | |||
| 4f0cc81dfb | |||
| 2ba7cc3086 | |||
| 29b6c7e4d8 | |||
| 8de9fb1ecd | |||
| 8a5a5d6c4d | |||
| ea931c6680 | |||
| d0f60ee41d | |||
| b84bd1297e | |||
| 0fb6004a8b | |||
| 8fe1bdba6b | |||
| c1d1c1624b | |||
| 9207664fbd | |||
| a4581663f4 | |||
| 03dfc29a54 | |||
| c02262fcf7 | |||
| f8fab4a4c2 |
@@ -101,6 +101,66 @@ func validateVariantPreferences(prefs []string) ([]string, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// variantSeedPrefix marks a Telegram start-param payload that seeds a brand-new
|
||||||
|
// account's variant preferences (e.g. "verudit_ru-scrabble_en"): the prefix, then the
|
||||||
|
// canonical variant labels joined by "-". It is deliberately distinct from the routing
|
||||||
|
// deep links (g/i/f; see platform/telegram .../deeplink) so the client's start-param
|
||||||
|
// router falls through to the lobby for it.
|
||||||
|
const variantSeedPrefix = "v"
|
||||||
|
|
||||||
|
// SeedVariantsFromStartParam decodes a promo deep-link start-param into the variant
|
||||||
|
// preference set to seed onto a brand-new account: the variantSeedPrefix followed by
|
||||||
|
// the canonical variant labels joined by "-" (e.g. "verudit_ru-scrabble_en"). It
|
||||||
|
// returns nil for any payload that is not a variant-seed link or that fails validation
|
||||||
|
// against the known variants, so a malformed, empty or unrelated start-param simply
|
||||||
|
// leaves the account on its default preferences rather than failing the login.
|
||||||
|
func SeedVariantsFromStartParam(startParam string) []string {
|
||||||
|
if !strings.HasPrefix(startParam, variantSeedPrefix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body := strings.TrimPrefix(startParam, variantSeedPrefix)
|
||||||
|
if body == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prefs, err := validateVariantPreferences(strings.Split(body, "-"))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVariantPreferences overwrites only the variant-preference set of the account,
|
||||||
|
// cleaning it to a deduplicated, canonically ordered subset of the known variants
|
||||||
|
// (rejecting an empty or unknown set with ErrInvalidProfile) and bumping updated_at; it
|
||||||
|
// reports ErrNotFound when no account matches id. It is the narrow counterpart to
|
||||||
|
// UpdateProfile used to seed a promo-onboarded account's variants at first contact
|
||||||
|
// without disturbing its other profile fields.
|
||||||
|
func (s *Store) SetVariantPreferences(ctx context.Context, id uuid.UUID, prefs []string) (Account, error) {
|
||||||
|
clean, err := validateVariantPreferences(prefs)
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, err
|
||||||
|
}
|
||||||
|
stmt := table.Accounts.UPDATE(
|
||||||
|
table.Accounts.VariantPreferences, table.Accounts.UpdatedAt,
|
||||||
|
).SET(
|
||||||
|
// clean is validated against the closed knownVariants set; bind as a text[]
|
||||||
|
// parameter (lib/pq encodes the array, the cast pins the column type), mirroring
|
||||||
|
// UpdateProfile.
|
||||||
|
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(clean)}),
|
||||||
|
postgres.TimestampzT(time.Now().UTC()),
|
||||||
|
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||||
|
RETURNING(table.Accounts.AllColumns)
|
||||||
|
|
||||||
|
var row model.Accounts
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return Account{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Account{}, fmt.Errorf("account: set variant preferences %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return modelToAccount(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateProfile validates and overwrites the editable fields of the account, then
|
// UpdateProfile validates and overwrites the editable fields of the account, then
|
||||||
// returns the stored row. It reports ErrInvalidProfile for a bad language,
|
// returns the stored row. It reports ErrInvalidProfile for a bad language,
|
||||||
// timezone or display name and ErrNotFound when no account matches id.
|
// timezone or display name and ErrNotFound when no account matches id.
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSeedVariantsFromStartParam covers decoding a promo deep-link start-param into the
|
||||||
|
// variant-preference set to seed: a valid "v"-prefixed, "-"-joined label list is cleaned
|
||||||
|
// to the canonical order and deduplicated, while anything that is not a variant-seed link
|
||||||
|
// or that names an unknown variant yields nil (leaving the account on its defaults).
|
||||||
|
func TestSeedVariantsFromStartParam(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
param string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"english promo", "verudit_ru-scrabble_en", []string{"erudit_ru", "scrabble_en"}},
|
||||||
|
{"single variant", "vscrabble_en", []string{"scrabble_en"}},
|
||||||
|
{"canonical order regardless of payload order", "vscrabble_en-erudit_ru", []string{"erudit_ru", "scrabble_en"}},
|
||||||
|
{"deduplicated", "verudit_ru-erudit_ru", []string{"erudit_ru"}},
|
||||||
|
{"empty", "", nil},
|
||||||
|
{"prefix only", "v", nil},
|
||||||
|
{"routing game link is not a seed", "g0190abcd", nil},
|
||||||
|
{"friend code link is not a seed", "f123456", nil},
|
||||||
|
{"unknown variant rejected", "vscrabble_de", nil},
|
||||||
|
{"one unknown label rejects the whole set", "verudit_ru-scrabble_de", nil},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := SeedVariantsFromStartParam(tc.param)
|
||||||
|
if !slices.Equal(got, tc.want) {
|
||||||
|
t.Errorf("SeedVariantsFromStartParam(%q) = %v, want %v", tc.param, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/server"
|
||||||
|
"scrabble/backend/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint
|
||||||
|
// to confirm a promo deep-link start-param seeds a brand-new account's variant
|
||||||
|
// preferences (English Scrabble alongside the default Erudit), that a new account with no
|
||||||
|
// such payload keeps the Erudit-only default, and that an existing account is never
|
||||||
|
// re-seeded on a later login (the new-user-only contract).
|
||||||
|
func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
|
||||||
|
srv := server.New(":0", server.Deps{
|
||||||
|
Logger: zaptest.NewLogger(t),
|
||||||
|
DB: testDB,
|
||||||
|
Accounts: account.NewStore(testDB),
|
||||||
|
Sessions: session.NewService(session.NewStore(testDB), session.NewCache()),
|
||||||
|
Notifier: &captureNotifier{},
|
||||||
|
})
|
||||||
|
h := srv.Handler()
|
||||||
|
|
||||||
|
post := func(ext, startParam string) {
|
||||||
|
body := `{"external_id":"` + ext + `","language_code":"en","first_name":"Promo"`
|
||||||
|
if startParam != "" {
|
||||||
|
body += `,"start_param":"` + startParam + `"`
|
||||||
|
}
|
||||||
|
body += `}`
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("telegram auth = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
reload := func(ext string) []string {
|
||||||
|
acc, err := store.AccountByIdentity(context.Background(), account.KindTelegram, ext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup %s: %v", ext, err)
|
||||||
|
}
|
||||||
|
return acc.VariantPreferences
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brand-new account reached through a promo deep-link is seeded with English
|
||||||
|
// Scrabble alongside the default Erudit.
|
||||||
|
promoExt := "tg-" + uuid.NewString()
|
||||||
|
post(promoExt, "verudit_ru-scrabble_en")
|
||||||
|
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("promo new account variants = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brand-new account with no promo payload keeps the Erudit-only default.
|
||||||
|
plainExt := "tg-" + uuid.NewString()
|
||||||
|
post(plainExt, "")
|
||||||
|
if got, want := reload(plainExt), []string{"erudit_ru"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("plain new account variants = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later login of the promo account, even via a different payload, must not re-seed:
|
||||||
|
// the seed is first-contact only.
|
||||||
|
post(promoExt, "vscrabble_ru")
|
||||||
|
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("existing account re-seeded = %v, want unchanged %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
)
|
)
|
||||||
@@ -19,13 +20,16 @@ import (
|
|||||||
// telegramAuthRequest carries the identity the connector extracted from a
|
// telegramAuthRequest carries the identity the connector extracted from a
|
||||||
// validated initData payload. Username, FirstName and LanguageCode seed a
|
// validated initData payload. Username, FirstName and LanguageCode seed a
|
||||||
// brand-new account's display name and language; BrowserTZ (the client's detected
|
// brand-new account's display name and language; BrowserTZ (the client's detected
|
||||||
// "±HH:MM" UTC offset) seeds its time zone (first contact only).
|
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch
|
||||||
|
// deep-link payload, which may seed the new account's variant preferences (first
|
||||||
|
// contact only).
|
||||||
type telegramAuthRequest struct {
|
type telegramAuthRequest struct {
|
||||||
ExternalID string `json:"external_id"`
|
ExternalID string `json:"external_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
LanguageCode string `json:"language_code"`
|
LanguageCode string `json:"language_code"`
|
||||||
BrowserTZ string `json:"browser_tz"`
|
BrowserTZ string `json:"browser_tz"`
|
||||||
|
StartParam string `json:"start_param"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
||||||
@@ -47,6 +51,16 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
|
|||||||
// joined the chat before registering is granted on the spot (no chat_member
|
// joined the chat before registering is granted on the spot (no chat_member
|
||||||
// event fires on registration).
|
// event fires on registration).
|
||||||
s.publishChatAccessChange(acc.ID)
|
s.publishChatAccessChange(acc.ID)
|
||||||
|
// A promo deep-link may seed this brand-new account's variant preferences (e.g.
|
||||||
|
// English Scrabble alongside the default Erudit). Best-effort: an absent or
|
||||||
|
// malformed payload leaves the account on its defaults, and a write failure must
|
||||||
|
// not block the session mint.
|
||||||
|
if seed := account.SeedVariantsFromStartParam(req.StartParam); len(seed) > 0 {
|
||||||
|
if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, seed); err != nil {
|
||||||
|
s.log.Warn("telegram: seed variant preferences failed",
|
||||||
|
zap.String("account", acc.ID.String()), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.mintSession(c, acc)
|
s.mintSession(c, acc)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ TELEGRAM_SUPPORT_CHAT_ID= # private forum supergroup for the suppor
|
|||||||
TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; empty disables it
|
TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; empty disables it
|
||||||
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
|
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
|
||||||
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
|
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
|
||||||
|
TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload
|
||||||
TELEGRAM_MINIAPP_URL= # required
|
TELEGRAM_MINIAPP_URL= # required
|
||||||
TELEGRAM_TEST_ENV=false
|
TELEGRAM_TEST_ENV=false
|
||||||
TELEGRAM_API_BASE_URL=
|
TELEGRAM_API_BASE_URL=
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ services:
|
|||||||
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
|
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
|
||||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
|
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
|
||||||
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
|
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
|
||||||
|
TELEGRAM_PROMO_START_PARAM: ${TELEGRAM_PROMO_START_PARAM:-}
|
||||||
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
|
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
|
||||||
# Real Bot API in prod (the test contour pins TELEGRAM_TEST_ENV=true instead).
|
# Real Bot API in prod (the test contour pins TELEGRAM_TEST_ENV=true instead).
|
||||||
TELEGRAM_TEST_ENV: "false"
|
TELEGRAM_TEST_ENV: "false"
|
||||||
|
|||||||
+10
-4
@@ -42,7 +42,12 @@ Three executables plus per-platform side-services:
|
|||||||
users, a weighted fair rotation — §10),
|
users, a weighted fair rotation — §10),
|
||||||
and a client **board-style** setting (bonus-label
|
and a client **board-style** setting (bonus-label
|
||||||
mode). The visual/interaction design system is documented in
|
mode). The visual/interaction design system is documented in
|
||||||
[`UI_DESIGN.md`](UI_DESIGN.md).
|
[`UI_DESIGN.md`](UI_DESIGN.md). Inside the Telegram Mini App the client additionally
|
||||||
|
tracks Telegram's live theme switch (`themeChanged`), fits the full device safe-area
|
||||||
|
insets (the bottom/home-indicator strip taking the bottom bar's colour), exposes
|
||||||
|
Telegram's native **Settings** button into the in-app settings, and syncs the
|
||||||
|
device-independent display preferences (theme, reduce-motion, board labels — **not** the
|
||||||
|
interface language) across the user's Telegram devices via **CloudStorage**.
|
||||||
- **`platform/telegram`** — the Telegram side-service (module
|
- **`platform/telegram`** — the Telegram side-service (module
|
||||||
`scrabble/platform/telegram`), split into two binaries that share the bot token
|
`scrabble/platform/telegram`), split into two binaries that share the bot token
|
||||||
(**one bot**, one optional game channel, §3):
|
(**one bot**, one optional game channel, §3):
|
||||||
@@ -152,7 +157,8 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
- **Single bot.** The platform side-service runs **one bot** (one token + one optional
|
- **Single bot.** The platform side-service runs **one bot** (one token + one optional
|
||||||
game channel), split into a home **validator** and a remote **bot** that share the
|
game channel), split into a home **validator** and a remote **bot** that share the
|
||||||
token. `ValidateInitData` (the validator) validates `initData` against that single
|
token. `ValidateInitData` (the validator) validates `initData` against that single
|
||||||
token and returns only the Telegram user identity — there is no per-bot "service
|
token, **rejects a bot user** (the signed `is_bot` flag), and returns only the Telegram
|
||||||
|
user identity — there is no per-bot "service
|
||||||
language" and no supported-languages set on the wire. The bot's chat messages and
|
language" and no supported-languages set on the wire. The bot's chat messages and
|
||||||
out-of-app push are
|
out-of-app push are
|
||||||
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
|
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
|
||||||
@@ -204,7 +210,7 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
> recipient's interface language (`preferred_language`), with no per-bot routing. New
|
> recipient's interface language (`preferred_language`), with no per-bot routing. New
|
||||||
> Game variant gating moved off the login language onto a per-user profile setting
|
> Game variant gating moved off the login language onto a per-user profile setting
|
||||||
> `variant_preferences` (default Erudit only, server-enforced on the caller's create
|
> `variant_preferences` (default Erudit only, server-enforced on the caller's create
|
||||||
> paths; an invited friend may still accept any variant). The per-bot env vars and
|
> paths; an invited friend may still accept any variant, and a Telegram **promo deep-link** seeds extra variants — e.g. English Scrabble — onto a brand-new account via the validated `start_param`). The per-bot env vars and
|
||||||
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped
|
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped
|
||||||
> `service_language`/`supported_languages` and the push `language` routing field, and
|
> `service_language`/`supported_languages` and the push `language` routing field, and
|
||||||
> gained `variant_preferences` on Profile/UpdateProfile.
|
> gained `variant_preferences` on Profile/UpdateProfile.
|
||||||
@@ -972,7 +978,7 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
|
|||||||
| Concern | Enforced by |
|
| Concern | Enforced by |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
|
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
|
||||||
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway |
|
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned |
|
||||||
| Session minting; email-code / guest validation | gateway (with backend) |
|
| Session minting; email-code / guest validation | gateway (with backend) |
|
||||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||||
|
|||||||
+9
-4
@@ -30,7 +30,8 @@ A player arrives from a platform (Telegram first), via email login, or as an
|
|||||||
ephemeral guest. The gateway validates the credential once and mints a thin
|
ephemeral guest. The gateway validates the credential once and mints a thin
|
||||||
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
|
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
|
||||||
App** launch authenticates from the platform's signed `initData`, themes the UI to
|
App** launch authenticates from the platform's signed `initData`, themes the UI to
|
||||||
the Telegram colours, and — on first contact — seeds the new account's interface
|
the Telegram colours (re-theming live if you switch Telegram's light/dark mode) and fits
|
||||||
|
the device safe-area, and — on first contact — seeds the new account's interface
|
||||||
language from the Telegram client. If a launch cannot reach the backend (for example during a
|
language from the Telegram client. If a launch cannot reach the backend (for example during a
|
||||||
deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a
|
deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a
|
||||||
**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram.
|
**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram.
|
||||||
@@ -249,12 +250,16 @@ is first created — so robot games are timed correctly before you ever open thi
|
|||||||
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
||||||
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
||||||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||||||
merge".
|
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
|
||||||
|
entry that opens this screen, and your display preferences (theme, board-label style and
|
||||||
|
reduce-motion — not the interface language, which follows your account) sync across your
|
||||||
|
Telegram devices.
|
||||||
|
|
||||||
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
||||||
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
||||||
yourself to be matched into; a **new account starts with Erudite only**, and you must keep **at
|
yourself to be matched into; a **new account starts with Erudite only** — unless it was created
|
||||||
least one** selected. This list is exactly what **New Game** offers when you start a game
|
through the **promo bot's deep link**, which also enables **English Scrabble** — and you must keep
|
||||||
|
**at least one** selected. This list is exactly what **New Game** offers when you start a game
|
||||||
(auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is
|
(auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is
|
||||||
not offered, and the server refuses it. It does not restrict games you are **invited** to: an
|
not offered, and the server refuses it. It does not restrict games you are **invited** to: an
|
||||||
invited friend may accept an invitation in **any** variant, and you can always open and play
|
invited friend may accept an invitation in **any** variant, and you can always open and play
|
||||||
|
|||||||
+11
-4
@@ -31,7 +31,9 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
||||||
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
||||||
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
|
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
|
||||||
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
|
в цвета Telegram (перекрашиваясь вживую при смене светлой/тёмной темы Telegram) и
|
||||||
|
вписывается в безопасные зоны экрана (safe-area), а — при первом контакте — задаёт язык
|
||||||
|
интерфейса нового аккаунта по
|
||||||
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
|
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
|
||||||
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
|
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
|
||||||
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
|
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
|
||||||
@@ -255,12 +257,17 @@ UTC; при создании аккаунта она подставляется
|
|||||||
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
|
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
|
||||||
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
||||||
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
||||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
|
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». Внутри Telegram
|
||||||
|
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
|
||||||
|
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
|
||||||
|
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
|
||||||
|
Telegram.
|
||||||
|
|
||||||
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
||||||
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
||||||
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом**, и нужно
|
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом** — если только
|
||||||
оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
|
он не создан по **диплинку промо-бота**, который дополнительно включает **английский Scrabble**, —
|
||||||
|
и нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
|
||||||
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
|
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
|
||||||
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
|
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
|
||||||
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
|
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
|
||||||
|
|||||||
@@ -184,10 +184,10 @@ type ChatResp struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
|
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
|
||||||
// brand-new account's display name and language from the validated launch fields and
|
// brand-new account's display name and language from the validated launch fields, its
|
||||||
// its time zone from browserTz (the client's detected "±HH:MM" UTC offset; first
|
// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the
|
||||||
// contact only).
|
// validated launch deep-link startParam, its variant preferences (first contact only).
|
||||||
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz string) (SessionResp, error) {
|
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam string) (SessionResp, error) {
|
||||||
var out SessionResp
|
var out SessionResp
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
@@ -196,6 +196,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
|||||||
"username": username,
|
"username": username,
|
||||||
"first_name": firstName,
|
"first_name": firstName,
|
||||||
"browser_tz": browserTz,
|
"browser_tz": browserTz,
|
||||||
|
"start_param": startParam,
|
||||||
}, &out)
|
}, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
"scrabble/gateway/internal/connector"
|
"scrabble/gateway/internal/connector"
|
||||||
@@ -154,11 +155,19 @@ func DomainCode(err error) (string, bool) {
|
|||||||
func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler {
|
func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0)
|
in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0)
|
||||||
user, err := tg.ValidateInitData(ctx, string(in.InitData()))
|
initData := string(in.InitData())
|
||||||
|
user, err := tg.ValidateInitData(ctx, initData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()))
|
// start_param rides inside the signed initData validated just above, so the launch
|
||||||
|
// deep-link payload can be trusted here without a separate wire field; the backend
|
||||||
|
// uses it to seed a brand-new account's variant preferences.
|
||||||
|
startParam := ""
|
||||||
|
if q, perr := url.ParseQuery(initData); perr == nil {
|
||||||
|
startParam = q.Get("start_param")
|
||||||
|
}
|
||||||
|
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
|
|||||||
t.Fatal("auth.telegram not registered")
|
t.Fatal("auth.telegram not registered")
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("init")})
|
payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("start_param=verudit_ru-scrabble_en&query_id=abc")})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("handler: %v", err)
|
t.Fatalf("handler: %v", err)
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,11 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
|
|||||||
if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" {
|
if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" {
|
||||||
t.Errorf("forwarded body = %+v, want external_id=42 language_code=ru first_name=Иван", gotBody)
|
t.Errorf("forwarded body = %+v, want external_id=42 language_code=ru first_name=Иван", gotBody)
|
||||||
}
|
}
|
||||||
|
// start_param is parsed out of the validated initData and forwarded so the backend can
|
||||||
|
// seed the new account's variant preferences from a promo deep link.
|
||||||
|
if gotBody["start_param"] != "verudit_ru-scrabble_en" {
|
||||||
|
t.Errorf("forwarded start_param = %q, want verudit_ru-scrabble_en", gotBody["start_param"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTelegramAuthInvalidInitData(t *testing.T) {
|
func TestTelegramAuthInvalidInitData(t *testing.T) {
|
||||||
|
|||||||
@@ -83,7 +83,12 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
|
|||||||
the main bot's direct link (`TELEGRAM_BOT_LINK`, the same link the UI uses) with
|
the main bot's direct link (`TELEGRAM_BOT_LINK`, the same link the UI uses) with
|
||||||
`?startapp` — a `web_app` button would launch under the promo bot's identity (its token
|
`?startapp` — a `web_app` button would launch under the promo bot's identity (its token
|
||||||
would sign the initData), which the main bot's validator rejects. It is fully
|
would sign the initData), which the main bot's validator rejects. It is fully
|
||||||
self-contained: no bot-link, no gateway, no game.
|
self-contained: no bot-link, no gateway, no game. Its `?startapp` payload
|
||||||
|
(`TELEGRAM_PROMO_START_PARAM`, default `verudit_ru-scrabble_en`) is a variant-seed deep
|
||||||
|
link the backend decodes to seed a brand-new user's variant preferences (English Scrabble
|
||||||
|
alongside the default Erudit). The message body also renders the bot's `@username` as that
|
||||||
|
same deep link (an HTML `text_link`), so tapping the mention — not just the button — opens
|
||||||
|
the seeded Mini App rather than the bot profile.
|
||||||
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
|
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
|
||||||
default 25) to respect the Bot API flood limits.
|
default 25) to respect the Bot API flood limits.
|
||||||
|
|
||||||
@@ -151,6 +156,7 @@ Bot (`cmd/bot`):
|
|||||||
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it |
|
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it |
|
||||||
| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs |
|
| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs |
|
||||||
| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs |
|
| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs |
|
||||||
|
| `TELEGRAM_PROMO_START_PARAM` | `verudit_ru-scrabble_en` | the promo button's `startapp` payload — a variant-seed deep link the backend decodes to seed a brand-new user's variant preferences; empty forwards the user's own `/start` payload |
|
||||||
| `TELEGRAM_OWNS_UPDATES` | `true` | run the exclusive `getUpdates` long-poll (one bot per token) |
|
| `TELEGRAM_OWNS_UPDATES` | `true` | run the exclusive `getUpdates` long-poll (one bot per token) |
|
||||||
| `TELEGRAM_SEND_RATE_PER_SECOND` | `25` | outbound Bot API send cap (0 disables) |
|
| `TELEGRAM_SEND_RATE_PER_SECOND` | `25` | outbound Bot API send cap (0 disables) |
|
||||||
| `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway |
|
| `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway |
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
|||||||
TestEnv: cfg.TestEnv,
|
TestEnv: cfg.TestEnv,
|
||||||
BotUsername: cfg.BotUsername,
|
BotUsername: cfg.BotUsername,
|
||||||
BotLinkURL: cfg.BotLinkURL,
|
BotLinkURL: cfg.BotLinkURL,
|
||||||
|
StartParam: cfg.PromoStartParam,
|
||||||
SendRatePerSecond: cfg.SendRatePerSecond,
|
SendRatePerSecond: cfg.SendRatePerSecond,
|
||||||
}, logger)
|
}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ type BotConfig struct {
|
|||||||
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
|
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
|
||||||
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
|
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
|
||||||
BotLinkURL string
|
BotLinkURL string
|
||||||
|
// PromoStartParam is the promo button's launch payload, appended as
|
||||||
|
// ?startapp=<PromoStartParam> (TELEGRAM_PROMO_START_PARAM, default
|
||||||
|
// "verudit_ru-scrabble_en"). It is a variant-seed deep link the backend decodes to
|
||||||
|
// seed a brand-new user's variant preferences; its labels must match the backend's
|
||||||
|
// known variants. Empty falls back to forwarding the user's own /start payload.
|
||||||
|
PromoStartParam string
|
||||||
// MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is
|
// MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is
|
||||||
// the base of every launch button (TELEGRAM_MINIAPP_URL, required).
|
// the base of every launch button (TELEGRAM_MINIAPP_URL, required).
|
||||||
MiniAppURL string
|
MiniAppURL string
|
||||||
@@ -113,6 +119,10 @@ const (
|
|||||||
defaultValidatorGRPCAddr = ":9091"
|
defaultValidatorGRPCAddr = ":9091"
|
||||||
defaultBotReconnectDelay = 2 * time.Second
|
defaultBotReconnectDelay = 2 * time.Second
|
||||||
defaultSendRatePerSecond = 25
|
defaultSendRatePerSecond = 25
|
||||||
|
// defaultPromoStartParam is the promo button's default launch payload: a variant-seed
|
||||||
|
// deep link the backend decodes to add English Scrabble to a brand-new user's variant
|
||||||
|
// preferences alongside the default Erudit. Override with TELEGRAM_PROMO_START_PARAM.
|
||||||
|
defaultPromoStartParam = "verudit_ru-scrabble_en"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadValidator reads the validator configuration from the environment.
|
// LoadValidator reads the validator configuration from the environment.
|
||||||
@@ -145,6 +155,7 @@ func LoadBot() (BotConfig, error) {
|
|||||||
PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"),
|
PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"),
|
||||||
BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"),
|
BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"),
|
||||||
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
|
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
|
||||||
|
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
|
||||||
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
|
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
|
||||||
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
|
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
|
||||||
BotLink: BotLinkClientConfig{
|
BotLink: BotLinkClientConfig{
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
|
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
|
||||||
// the hash, is malformed, or is older than the freshness window.
|
// the hash, is malformed, is older than the freshness window, or identifies a bot
|
||||||
|
// user (is_bot), which is denied.
|
||||||
var ErrInvalidInitData = errors.New("initdata: invalid telegram init data")
|
var ErrInvalidInitData = errors.New("initdata: invalid telegram init data")
|
||||||
|
|
||||||
// defaultMaxAge bounds how old a validated initData payload may be.
|
// defaultMaxAge bounds how old a validated initData payload may be.
|
||||||
@@ -120,6 +121,7 @@ func parseUser(userJSON string) (User, error) {
|
|||||||
}
|
}
|
||||||
var u struct {
|
var u struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
IsBot bool `json:"is_bot"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
LanguageCode string `json:"language_code"`
|
LanguageCode string `json:"language_code"`
|
||||||
@@ -127,6 +129,12 @@ func parseUser(userJSON string) (User, error) {
|
|||||||
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
|
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
|
||||||
return User{}, ErrInvalidInitData
|
return User{}, ErrInvalidInitData
|
||||||
}
|
}
|
||||||
|
// Deny bot principals: the HMAC has already proved Telegram signed this payload, so is_bot==true
|
||||||
|
// is Telegram itself attesting the launching user is a bot. A real user opening the Mini App
|
||||||
|
// never carries it, so reject defensively rather than provision an account for a bot.
|
||||||
|
if u.IsBot {
|
||||||
|
return User{}, ErrInvalidInitData
|
||||||
|
}
|
||||||
return User{
|
return User{
|
||||||
ExternalID: strconv.FormatInt(u.ID, 10),
|
ExternalID: strconv.FormatInt(u.ID, 10),
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
|
|||||||
@@ -83,3 +83,28 @@ func TestValidateRejects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateBotUser(t *testing.T) {
|
||||||
|
t.Run("is_bot true is denied", func(t *testing.T) {
|
||||||
|
initData := signInitData(testToken, map[string]string{
|
||||||
|
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||||
|
"user": `{"id":42,"is_bot":true,"first_name":"Robo"}`,
|
||||||
|
})
|
||||||
|
if _, err := NewHMACValidator(testToken).Validate(initData); !errors.Is(err, ErrInvalidInitData) {
|
||||||
|
t.Errorf("err = %v, want ErrInvalidInitData", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("is_bot false is allowed", func(t *testing.T) {
|
||||||
|
initData := signInitData(testToken, map[string]string{
|
||||||
|
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||||
|
"user": `{"id":42,"is_bot":false,"first_name":"Thomas"}`,
|
||||||
|
})
|
||||||
|
u, err := NewHMACValidator(testToken).Validate(initData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate: %v", err)
|
||||||
|
}
|
||||||
|
if u.ExternalID != "42" || u.FirstName != "Thomas" {
|
||||||
|
t.Errorf("user = %+v, want {42 Thomas}", u)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
package promobot
|
package promobot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"html"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -33,6 +35,11 @@ type Config struct {
|
|||||||
// BotLinkURL is the main bot's Mini App direct link; the button appends
|
// BotLinkURL is the main bot's Mini App direct link; the button appends
|
||||||
// ?startapp=<payload> to it.
|
// ?startapp=<payload> to it.
|
||||||
BotLinkURL string
|
BotLinkURL string
|
||||||
|
// StartParam is the campaign payload appended as ?startapp=<StartParam> to the launch
|
||||||
|
// button — e.g. a variant-seed deep link ("verudit_ru-scrabble_en") the backend
|
||||||
|
// decodes to seed a brand-new user's variant preferences. Empty falls back to
|
||||||
|
// forwarding the user's own /start payload.
|
||||||
|
StartParam string
|
||||||
// SendRatePerSecond caps outbound sends to respect the Bot API flood limits; 0
|
// SendRatePerSecond caps outbound sends to respect the Bot API flood limits; 0
|
||||||
// disables the limiter. The burst equals the per-second rate.
|
// disables the limiter. The burst equals the per-second rate.
|
||||||
SendRatePerSecond int
|
SendRatePerSecond int
|
||||||
@@ -43,6 +50,7 @@ type Bot struct {
|
|||||||
api *tgbot.Bot
|
api *tgbot.Bot
|
||||||
username string
|
username string
|
||||||
linkURL string
|
linkURL string
|
||||||
|
startParam string
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
limiter *rate.Limiter
|
limiter *rate.Limiter
|
||||||
}
|
}
|
||||||
@@ -53,7 +61,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
|||||||
if log == nil {
|
if log == nil {
|
||||||
log = zap.NewNop()
|
log = zap.NewNop()
|
||||||
}
|
}
|
||||||
t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, log: log}
|
t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, startParam: cfg.StartParam, log: log}
|
||||||
if cfg.SendRatePerSecond > 0 {
|
if cfg.SendRatePerSecond > 0 {
|
||||||
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
|
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
|
||||||
}
|
}
|
||||||
@@ -87,7 +95,8 @@ func (t *Bot) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleStart replies to any message (typically /start) with the localized promo text
|
// handleStart replies to any message (typically /start) with the localized promo text
|
||||||
// and a button that opens the main bot's Mini App, forwarding any /start payload.
|
// and a button that opens the main bot's Mini App at the configured campaign payload
|
||||||
|
// (falling back to forwarding any /start payload the user arrived with).
|
||||||
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
|
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
|
||||||
if update.Message == nil {
|
if update.Message == nil {
|
||||||
return
|
return
|
||||||
@@ -104,11 +113,16 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up
|
|||||||
if update.Message.From != nil {
|
if update.Message.From != nil {
|
||||||
lang = update.Message.From.LanguageCode
|
lang = update.Message.From.LanguageCode
|
||||||
}
|
}
|
||||||
text, button := promoText(lang, t.username)
|
// The configured campaign payload (a variant-seed deep link) takes precedence; absent
|
||||||
|
// one, fall back to forwarding any /start payload the user arrived with. The same
|
||||||
|
// payload backs both the inline button and the @username link in the body.
|
||||||
|
param := cmp.Or(t.startParam, startPayload(update.Message.Text))
|
||||||
|
text, button := promoText(lang, t.username, t.launchURL(param))
|
||||||
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
|
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||||
ChatID: update.Message.Chat.ID,
|
ChatID: update.Message.Chat.ID,
|
||||||
Text: text,
|
Text: text,
|
||||||
ReplyMarkup: t.launchMarkup(button, startPayload(update.Message.Text)),
|
ParseMode: models.ParseModeHTML,
|
||||||
|
ReplyMarkup: t.launchMarkup(button, param),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.log.Warn("promo: reply to start failed", zap.Error(err))
|
t.log.Warn("promo: reply to start failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -159,11 +173,15 @@ func startPayload(text string) string {
|
|||||||
return strings.TrimSpace(strings.TrimPrefix(text, cmd))
|
return strings.TrimSpace(strings.TrimPrefix(text, cmd))
|
||||||
}
|
}
|
||||||
|
|
||||||
// promoText returns the localized message body and button label, naming the main bot
|
// promoText returns the localized message body and button label. The body names the main
|
||||||
// (Russian for a "ru" language code, English otherwise).
|
// bot as a clickable @username whose link is the Mini App deep link (launchURL, the same
|
||||||
func promoText(lang, username string) (text, button string) {
|
// target as the button), so tapping the mention opens the seeded Mini App rather than the
|
||||||
|
// bot profile. The body is sent with ParseMode HTML; only the link carries markup, so the
|
||||||
|
// static sentences need no escaping. Russian for a "ru" language code, English otherwise.
|
||||||
|
func promoText(lang, username, launchURL string) (text, button string) {
|
||||||
|
mention := `<a href="` + html.EscapeString(launchURL) + `">@` + html.EscapeString(username) + `</a>`
|
||||||
if strings.HasPrefix(strings.ToLower(lang), "ru") {
|
if strings.HasPrefix(strings.ToLower(lang), "ru") {
|
||||||
return "Откройте @" + username + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
|
return "Откройте " + mention + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
|
||||||
}
|
}
|
||||||
return "Open @" + username + " and choose your game variant in the profile settings.", "🤩 I want to play!"
|
return "Open " + mention + " and choose your game variant in the profile settings.", "🤩 I want to play!"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,25 +13,30 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestPromoTextLocalization(t *testing.T) {
|
func TestPromoTextLocalization(t *testing.T) {
|
||||||
en, enBtn := promoText("en", "ScrabbleBot")
|
const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en"
|
||||||
if !strings.Contains(en, "@ScrabbleBot") || !strings.Contains(en, "profile settings") {
|
// The @username is rendered as a clickable deep link (the same target as the button),
|
||||||
|
// not a plain mention, so tapping it opens the seeded Mini App.
|
||||||
|
wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
|
||||||
|
|
||||||
|
en, enBtn := promoText("en", "ScrabbleBot", url)
|
||||||
|
if !strings.Contains(en, wantLink) || !strings.Contains(en, "profile settings") {
|
||||||
t.Errorf("en text = %q", en)
|
t.Errorf("en text = %q", en)
|
||||||
}
|
}
|
||||||
if enBtn != "🤩 I want to play!" {
|
if enBtn != "🤩 I want to play!" {
|
||||||
t.Errorf("en button = %q", enBtn)
|
t.Errorf("en button = %q", enBtn)
|
||||||
}
|
}
|
||||||
|
|
||||||
ru, ruBtn := promoText("ru-RU", "ScrabbleBot")
|
ru, ruBtn := promoText("ru-RU", "ScrabbleBot", url)
|
||||||
if !strings.Contains(ru, "@ScrabbleBot") || !strings.Contains(ru, "Откройте") {
|
if !strings.Contains(ru, wantLink) || !strings.Contains(ru, "Откройте") {
|
||||||
t.Errorf("ru text = %q", ru)
|
t.Errorf("ru text = %q", ru)
|
||||||
}
|
}
|
||||||
if ruBtn != "🤩 Хочу играть!" {
|
if ruBtn != "🤩 Хочу играть!" {
|
||||||
t.Errorf("ru button = %q", ruBtn)
|
t.Errorf("ru button = %q", ruBtn)
|
||||||
}
|
}
|
||||||
|
|
||||||
// An unknown language falls back to English.
|
// An unknown language falls back to English, still with the linked mention.
|
||||||
if got, _ := promoText("de", "B"); !strings.Contains(got, "Open @B") {
|
if got, _ := promoText("de", "B", url); !strings.Contains(got, "Open ") || !strings.Contains(got, `">@B</a>`) {
|
||||||
t.Errorf("fallback text = %q, want English", got)
|
t.Errorf("fallback text = %q, want English with a linked mention", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +98,8 @@ func TestHandleStartReplies(t *testing.T) {
|
|||||||
if api.chatID != "42" {
|
if api.chatID != "42" {
|
||||||
t.Errorf("chat_id = %q, want 42", api.chatID)
|
t.Errorf("chat_id = %q, want 42", api.chatID)
|
||||||
}
|
}
|
||||||
if !strings.Contains(api.text, "@ScrabbleBot") {
|
if !strings.Contains(api.text, `<a href="https://t.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
|
||||||
t.Errorf("text = %q, want the @mention", api.text)
|
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
|
||||||
}
|
}
|
||||||
if strings.Contains(api.replyMarkup, "web_app") {
|
if strings.Contains(api.replyMarkup, "web_app") {
|
||||||
t.Errorf("reply_markup = %q has a web_app button; want a url button", api.replyMarkup)
|
t.Errorf("reply_markup = %q has a web_app button; want a url button", api.replyMarkup)
|
||||||
|
|||||||
@@ -49,6 +49,11 @@
|
|||||||
/* Telegram device safe-area top (the notch); TG's own nav controls sit between it and
|
/* Telegram device safe-area top (the notch); TG's own nav controls sit between it and
|
||||||
--tg-content-top, so the in-app header aligns to that band, 0 elsewhere. */
|
--tg-content-top, so the in-app header aligns to that band, 0 elsewhere. */
|
||||||
--tg-safe-top: 0px;
|
--tg-safe-top: 0px;
|
||||||
|
/* Telegram device safe-area bottom / sides (home indicator; landscape notch), 0 elsewhere —
|
||||||
|
the screen pads its bottom and left/right edges by these so content clears the cut-outs. */
|
||||||
|
--tg-safe-bottom: 0px;
|
||||||
|
--tg-safe-left: 0px;
|
||||||
|
--tg-safe-right: 0px;
|
||||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||||
"Noto Sans", "Liberation Sans", sans-serif;
|
"Noto Sans", "Liberation Sans", sans-serif;
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--gap);
|
gap: var(--gap);
|
||||||
padding: 10px var(--pad);
|
padding: 5px var(--pad);
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
@@ -139,15 +139,15 @@
|
|||||||
back chevron is hidden), so min-height (the nav-band height) doesn't bind. Without the
|
back chevron is hidden), so min-height (the nav-band height) doesn't bind. Without the
|
||||||
(removed) hamburger that content shrank and the bar sat flush under Telegram's native nav
|
(removed) hamburger that content shrank and the bar sat flush under Telegram's native nav
|
||||||
band. So **padding-top** is the lever: it drops the title clear of the band — the notch
|
band. So **padding-top** is the lever: it drops the title clear of the band — the notch
|
||||||
plus a **10px** gap (was 6). A fixed px (not rem/em) gap so the clearance from Telegram's
|
plus an **8px** gap (halved from 16). A fixed px (not rem/em) gap so the clearance from
|
||||||
native controls stays constant if the user scales up the font (the title then grows
|
Telegram's native controls stays constant if the user scales up the font (the title then
|
||||||
downward and the bar with it). (Owner-tunable: the 10px.) */
|
grows downward and the bar with it). (Owner-tunable: the 8px.) */
|
||||||
min-height: var(--tg-content-top);
|
min-height: var(--tg-content-top);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: calc(var(--tg-safe-top) + 16px);
|
padding-top: calc(var(--tg-safe-top) + 8px);
|
||||||
padding-bottom: 6px;
|
padding-bottom: 3px;
|
||||||
}
|
}
|
||||||
:global(html.tg-fullscreen) .spacer {
|
:global(html.tg-fullscreen) .spacer {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -101,6 +101,12 @@
|
|||||||
bottom input — chat, word-check — stays above an open soft keyboard without the page
|
bottom input — chat, word-check — stays above an open soft keyboard without the page
|
||||||
scrolling; falls back to the full height where the var is unset. */
|
scrolling; falls back to the full height where the var is unset. */
|
||||||
height: var(--vvh, 100%);
|
height: var(--vvh, 100%);
|
||||||
|
/* Clear the landscape notch sides inside Telegram (0 elsewhere). The top inset is owned by the
|
||||||
|
header; the home-indicator (bottom) inset is owned by the bottom bar — the .tabbar paints its
|
||||||
|
own chrome into it, and a screen with no tab bar pads its content (.content:last-child) — so
|
||||||
|
the strip takes the bar's colour rather than the detached content background. */
|
||||||
|
padding-left: var(--tg-safe-left, 0px);
|
||||||
|
padding-right: var(--tg-safe-right, 0px);
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
@@ -116,7 +122,18 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
/* No tab bar → the content is the bottom-most element: pad it by the device home-indicator inset
|
||||||
|
so it clears the cut-out, the strip taking the content's own background. With a tab bar the
|
||||||
|
.tabbar owns that inset instead (and content is not the last child, so this does not apply). */
|
||||||
|
.content:last-child {
|
||||||
|
padding-bottom: var(--tg-safe-bottom, 0px);
|
||||||
|
}
|
||||||
.tabbar {
|
.tabbar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
/* Extend the bottom bar's chrome (the TabBar's --bg-elev) under the device home indicator
|
||||||
|
inside Telegram (the inset is 0 elsewhere), so the safe-area strip reads as part of the bar
|
||||||
|
instead of the content background showing through. */
|
||||||
|
background: var(--bg-elev);
|
||||||
|
padding-bottom: var(--tg-safe-bottom, 0px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { app, dismissStaleInvite } from '../lib/app.svelte';
|
import { app, dismissStaleInvite } from '../lib/app.svelte';
|
||||||
|
import { router } from '../lib/router.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { botUsername } from '../lib/deeplink';
|
import { botUsername } from '../lib/deeplink';
|
||||||
import { telegramOpenLink } from '../lib/telegram';
|
import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram';
|
||||||
|
import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs';
|
||||||
import Modal from './Modal.svelte';
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
// The single bot's @username, for the deep link.
|
// The single bot's @username, for the deep link.
|
||||||
@@ -19,9 +21,30 @@
|
|||||||
}
|
}
|
||||||
dismissStaleInvite();
|
dismissStaleInvite();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Native path: when the notice fires inside the Mini App with native popups, present Telegram's
|
||||||
|
// own popup instead of the in-app modal. insideTelegram()/dialogs are evaluated at fire time, not
|
||||||
|
// captured at init: this component mounts before bootstrap loads the SDK, so a captured value
|
||||||
|
// would be stale. The popup's "open bot" button opens the bot chat; any other dismissal clears the
|
||||||
|
// notice; the `shown` guard fires it once per notice.
|
||||||
|
// The notice is raised during boot; wait until the loading cover for the current route is gone —
|
||||||
|
// the tile splash on the lobby (splashDone), the plain loading screen elsewhere (app.ready) — so
|
||||||
|
// the native popup never appears over the splash.
|
||||||
|
const ready = $derived(router.route.name === 'lobby' ? app.splashDone : app.ready);
|
||||||
|
let shown = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (app.staleInvite && !shown && ready && insideTelegram() && telegramDialogsAvailable()) {
|
||||||
|
shown = true;
|
||||||
|
void telegramShowPopup(
|
||||||
|
botInfoPopup(t('friends.staleInviteTitle'), t('friends.staleInvite'), username ?? '', t('common.ok')),
|
||||||
|
).then((id) => (id === BOT_BUTTON_ID ? openBot() : dismissStaleInvite()));
|
||||||
|
} else if (!app.staleInvite) {
|
||||||
|
shown = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if app.staleInvite}
|
{#if app.staleInvite && ready && !(insideTelegram() && telegramDialogsAvailable())}
|
||||||
<Modal title={t('friends.staleInviteTitle')} onclose={dismissStaleInvite}>
|
<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>
|
<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>
|
<button class="ok" onclick={dismissStaleInvite}>{t('common.ok')}</button>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { app, dismissWelcomeRedeem } from '../lib/app.svelte';
|
import { app, dismissWelcomeRedeem } from '../lib/app.svelte';
|
||||||
|
import { router } from '../lib/router.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { botUsername } from '../lib/deeplink';
|
import { botUsername } from '../lib/deeplink';
|
||||||
import { telegramOpenLink } from '../lib/telegram';
|
import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram';
|
||||||
|
import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs';
|
||||||
import Modal from './Modal.svelte';
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
// The single bot's @username, for the deep link.
|
// The single bot's @username, for the deep link.
|
||||||
@@ -22,9 +24,30 @@
|
|||||||
}
|
}
|
||||||
dismissWelcomeRedeem();
|
dismissWelcomeRedeem();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Native path: when the greeting fires inside the Mini App with native popups, present Telegram's
|
||||||
|
// own popup instead of the in-app modal. insideTelegram()/dialogs are evaluated at fire time, not
|
||||||
|
// captured at init: this component mounts before bootstrap loads the SDK, so a captured value
|
||||||
|
// would be stale. The popup's "open bot" button opens the bot chat; any other dismissal clears it;
|
||||||
|
// the `shown` guard fires it once per greeting.
|
||||||
|
// The greeting is raised during boot; wait until the loading cover for the current route is gone —
|
||||||
|
// the tile splash on the lobby (splashDone), the plain loading screen elsewhere (app.ready) — so
|
||||||
|
// the native popup never appears over the splash.
|
||||||
|
const ready = $derived(router.route.name === 'lobby' ? app.splashDone : app.ready);
|
||||||
|
let shown = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (app.welcomeRedeem && !shown && ready && insideTelegram() && telegramDialogsAvailable()) {
|
||||||
|
shown = true;
|
||||||
|
void telegramShowPopup(
|
||||||
|
botInfoPopup(t('friends.welcomeRedeemTitle'), t('friends.welcomeRedeem', { name }), username ?? '', t('common.ok')),
|
||||||
|
).then((id) => (id === BOT_BUTTON_ID ? openBot() : dismissWelcomeRedeem()));
|
||||||
|
} else if (!app.welcomeRedeem) {
|
||||||
|
shown = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if app.welcomeRedeem}
|
{#if app.welcomeRedeem && ready && !(insideTelegram() && telegramDialogsAvailable())}
|
||||||
<Modal title={t('friends.welcomeRedeemTitle')} onclose={dismissWelcomeRedeem}>
|
<Modal title={t('friends.welcomeRedeemTitle')} onclose={dismissWelcomeRedeem}>
|
||||||
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
|
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
|
||||||
<button class="ok" onclick={dismissWelcomeRedeem}>{t('common.ok')}</button>
|
<button class="ok" onclick={dismissWelcomeRedeem}>{t('common.ok')}</button>
|
||||||
|
|||||||
+12
-2
@@ -24,7 +24,7 @@
|
|||||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||||
import { patchLobbyGame } from '../lib/lobbycache';
|
import { patchLobbyGame } from '../lib/lobbycache';
|
||||||
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
||||||
import { telegramHaptic } from '../lib/telegram';
|
import { insideTelegram, telegramDialogsAvailable, telegramHaptic, telegramShowConfirm } from '../lib/telegram';
|
||||||
import {
|
import {
|
||||||
BLANK,
|
BLANK,
|
||||||
newPlacement,
|
newPlacement,
|
||||||
@@ -712,6 +712,16 @@
|
|||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// onResignClick: inside the Mini App (and online) confirm with Telegram's native dialog and resign
|
||||||
|
// on accept; otherwise open the in-app confirm modal (which also carries the offline-disabled action).
|
||||||
|
async function onResignClick(): Promise<void> {
|
||||||
|
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
|
||||||
|
if (await telegramShowConfirm(t('game.confirmResign'))) doResign();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resignOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function doResign() {
|
async function doResign() {
|
||||||
resignOpen = false;
|
resignOpen = false;
|
||||||
busy = true;
|
busy = true;
|
||||||
@@ -1149,7 +1159,7 @@
|
|||||||
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
|
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
||||||
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
|
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
|
||||||
|
|||||||
+87
-17
@@ -9,7 +9,7 @@ import { GatewayError } from './client';
|
|||||||
import { navigate, router } from './router.svelte';
|
import { navigate, router } from './router.svelte';
|
||||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
||||||
import { languageNeedsServerSync } from './language';
|
import { languageNeedsServerSync } from './language';
|
||||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
|
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
|
||||||
import {
|
import {
|
||||||
insideTelegram,
|
insideTelegram,
|
||||||
collectTelegramDiag,
|
collectTelegramDiag,
|
||||||
@@ -18,15 +18,21 @@ import {
|
|||||||
hasLaunchFragment,
|
hasLaunchFragment,
|
||||||
loadTelegramSDK,
|
loadTelegramSDK,
|
||||||
telegramColorScheme,
|
telegramColorScheme,
|
||||||
|
telegramThemeParams,
|
||||||
telegramContentSafeAreaTop,
|
telegramContentSafeAreaTop,
|
||||||
telegramSafeAreaTop,
|
telegramSafeAreaInset,
|
||||||
telegramDisableVerticalSwipes,
|
telegramDisableVerticalSwipes,
|
||||||
|
telegramShowSettingsButton,
|
||||||
telegramHaptic,
|
telegramHaptic,
|
||||||
telegramLaunch,
|
telegramLaunch,
|
||||||
type TelegramLaunch,
|
type TelegramLaunch,
|
||||||
telegramOnEvent,
|
telegramOnEvent,
|
||||||
telegramSetChrome,
|
telegramSetChrome,
|
||||||
|
telegramCloudAvailable,
|
||||||
|
telegramCloudGet,
|
||||||
|
telegramCloudSet,
|
||||||
} from './telegram';
|
} from './telegram';
|
||||||
|
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||||
import { parseStartParam } from './deeplink';
|
import { parseStartParam } from './deeplink';
|
||||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||||
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
||||||
@@ -527,17 +533,25 @@ function syncTelegramChrome(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native
|
* syncTelegramSafeArea mirrors Telegram's safe-area insets into CSS vars: the content-safe-area top
|
||||||
* nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a
|
* (the height Telegram's native nav overlays the viewport in fullscreen) into --tg-content-top
|
||||||
* `tg-fullscreen` class, so the header can drop below the nav and centre the title in its
|
* (which also toggles the `tg-fullscreen` class so the header drops below the nav and centres the
|
||||||
* band. Called on launch and on Telegram's safe-area / fullscreen change events.
|
* title in its band), and the device safe-area insets — notch / status bar (top), home indicator
|
||||||
|
* (bottom) and the landscape notch sides (left / right) — into --tg-safe-top / --tg-safe-bottom /
|
||||||
|
* --tg-safe-left / --tg-safe-right, so the header, rack and screen edges clear the device cut-outs.
|
||||||
|
* Called on launch and on Telegram's safe-area / fullscreen change events.
|
||||||
*/
|
*/
|
||||||
function syncTelegramSafeArea(): void {
|
function syncTelegramSafeArea(): void {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
|
const root = document.documentElement;
|
||||||
const top = telegramContentSafeAreaTop();
|
const top = telegramContentSafeAreaTop();
|
||||||
document.documentElement.style.setProperty('--tg-content-top', `${top}px`);
|
const safe = telegramSafeAreaInset();
|
||||||
document.documentElement.style.setProperty('--tg-safe-top', `${telegramSafeAreaTop()}px`);
|
root.style.setProperty('--tg-content-top', `${top}px`);
|
||||||
document.documentElement.classList.toggle('tg-fullscreen', top > 0);
|
root.style.setProperty('--tg-safe-top', `${safe.top}px`);
|
||||||
|
root.style.setProperty('--tg-safe-bottom', `${safe.bottom}px`);
|
||||||
|
root.style.setProperty('--tg-safe-left', `${safe.left}px`);
|
||||||
|
root.style.setProperty('--tg-safe-right', `${safe.right}px`);
|
||||||
|
root.classList.toggle('tg-fullscreen', top > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -554,20 +568,31 @@ function syncViewportHeight(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative
|
* syncTelegramTheme re-applies Telegram's theme integration — the themeParams token overrides,
|
||||||
* colour scheme and theme, the matching header / background / bottom chrome, the safe-area insets,
|
* Telegram's authoritative colour scheme, and the matching chrome — from theme, or from the SDK's
|
||||||
* the swipe-down guard, and immersive fullscreen on mobile. It is idempotent, so both the initial
|
* current themeParams when omitted. Called on launch with the launch snapshot and live on the
|
||||||
* bootstrap and a manual launch retry call it.
|
* themeChanged event, so switching Telegram's light/dark theme while the app is open is picked up
|
||||||
|
* without a relaunch.
|
||||||
*/
|
*/
|
||||||
function applyTelegramChrome(launch: TelegramLaunch): void {
|
function syncTelegramTheme(theme: TelegramThemeParams | undefined = telegramThemeParams()): void {
|
||||||
if (launch.theme) applyTelegramTheme(launch.theme);
|
if (theme) applyTelegramTheme(theme);
|
||||||
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly so the OS
|
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly so the OS
|
||||||
// prefers-color-scheme (which leaks into the Telegram Desktop webview) cannot fight it. Falls
|
// prefers-color-scheme (which leaks into the Telegram Desktop webview) cannot fight it. Falls
|
||||||
// back to the stored preference when the SDK omits it.
|
// back to the stored preference when the SDK omits it.
|
||||||
applyTheme(telegramColorScheme() ?? app.theme);
|
applyTheme(telegramColorScheme() ?? app.theme);
|
||||||
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from fighting tile
|
|
||||||
// drag / board scroll.
|
|
||||||
syncTelegramChrome();
|
syncTelegramChrome();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative
|
||||||
|
* colour scheme and theme (syncTelegramTheme), the matching header / background / bottom chrome,
|
||||||
|
* the safe-area insets, and the swipe-down-to-minimise guard. It is idempotent, so both the
|
||||||
|
* initial bootstrap and a manual launch retry call it.
|
||||||
|
*/
|
||||||
|
function applyTelegramChrome(launch: TelegramLaunch): void {
|
||||||
|
syncTelegramTheme(launch.theme);
|
||||||
|
// Mirror the safe-area insets and stop Telegram's swipe-down-to-minimise from fighting tile drag
|
||||||
|
// / board scroll.
|
||||||
syncTelegramSafeArea();
|
syncTelegramSafeArea();
|
||||||
telegramDisableVerticalSwipes();
|
telegramDisableVerticalSwipes();
|
||||||
}
|
}
|
||||||
@@ -621,10 +646,19 @@ export async function bootstrap(): Promise<void> {
|
|||||||
if (insideTelegram()) {
|
if (insideTelegram()) {
|
||||||
const launch = telegramLaunch();
|
const launch = telegramLaunch();
|
||||||
applyTelegramChrome(launch);
|
applyTelegramChrome(launch);
|
||||||
|
// Pull the device-independent display prefs (theme / reduce-motion / board labels) from
|
||||||
|
// CloudStorage in the background so a change on another device follows the user here; the local
|
||||||
|
// values applied above render instantly, so this reconciles without blocking launch.
|
||||||
|
void reconcileCloudPrefs();
|
||||||
// Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load).
|
// Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load).
|
||||||
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
|
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
|
||||||
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
|
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
|
||||||
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
|
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
|
||||||
|
// Re-apply the theme live when the user switches Telegram's light/dark mode while the app is open.
|
||||||
|
telegramOnEvent('themeChanged', () => syncTelegramTheme());
|
||||||
|
// Telegram's native Settings button (Bot API 7.0) opens our Settings screen; the in-app gear
|
||||||
|
// entry stays the primary path. No-op on clients predating the button.
|
||||||
|
telegramShowSettingsButton(() => navigate('/settings'));
|
||||||
await bootTelegram(launch);
|
await bootTelegram(launch);
|
||||||
app.ready = true;
|
app.ready = true;
|
||||||
return;
|
return;
|
||||||
@@ -811,6 +845,42 @@ function persistPrefs(): void {
|
|||||||
reduceMotion: app.reduceMotion,
|
reduceMotion: app.reduceMotion,
|
||||||
boardLabels: app.boardLabels,
|
boardLabels: app.boardLabels,
|
||||||
});
|
});
|
||||||
|
// Mirror the device-independent display prefs to Telegram CloudStorage so they follow the user
|
||||||
|
// across devices (no-op outside Telegram / on a client predating it). Locale is excluded — it
|
||||||
|
// syncs via the durable account (Profile.preferredLanguage) instead.
|
||||||
|
void telegramCloudSet(
|
||||||
|
CLOUD_PREFS_KEY,
|
||||||
|
encodeClientPrefs({ theme: app.theme, reduceMotion: app.reduceMotion, boardLabels: app.boardLabels }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reconcileCloudPrefs pulls the device-independent display prefs (theme / reduce-motion / board
|
||||||
|
* labels) from Telegram CloudStorage and applies any that differ from the current values, so a
|
||||||
|
* change made on another Telegram device follows the user here. The local store is the
|
||||||
|
* instant-render cache (read synchronously at boot); this runs once on launch after it and persists
|
||||||
|
* what it applied. Theme is not re-applied visually — inside Telegram the colour scheme is
|
||||||
|
* Telegram's to decide — only its stored value is updated. A no-op outside Telegram or when
|
||||||
|
* CloudStorage is unavailable; locale is never synced this way (it has its own server reconciler).
|
||||||
|
*/
|
||||||
|
async function reconcileCloudPrefs(): Promise<void> {
|
||||||
|
if (!telegramCloudAvailable()) return;
|
||||||
|
const cloud = decodeClientPrefs(await telegramCloudGet(CLOUD_PREFS_KEY));
|
||||||
|
let changed = false;
|
||||||
|
if (cloud.theme !== undefined && cloud.theme !== app.theme) {
|
||||||
|
app.theme = cloud.theme;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (cloud.reduceMotion !== undefined && cloud.reduceMotion !== app.reduceMotion) {
|
||||||
|
app.reduceMotion = cloud.reduceMotion;
|
||||||
|
applyReduceMotion(app.reduceMotion);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (cloud.boardLabels !== undefined && cloud.boardLabels !== app.boardLabels) {
|
||||||
|
app.boardLabels = cloud.boardLabels;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) persistPrefs();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setTheme(theme: ThemePref): void {
|
export function setTheme(theme: ThemePref): void {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||||
|
|
||||||
|
describe('cloudprefs', () => {
|
||||||
|
it('round-trips the synced client prefs', () => {
|
||||||
|
const p = { theme: 'dark', reduceMotion: true, boardLabels: 'classic' } as const;
|
||||||
|
expect(decodeClientPrefs(encodeClientPrefs(p))).toEqual(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never encodes the locale (it syncs via the durable account instead)', () => {
|
||||||
|
const raw = encodeClientPrefs({ theme: 'light', reduceMotion: false, boardLabels: 'none' });
|
||||||
|
expect(raw).not.toContain('locale');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty partial for missing or malformed input', () => {
|
||||||
|
expect(decodeClientPrefs(null)).toEqual({});
|
||||||
|
expect(decodeClientPrefs(undefined)).toEqual({});
|
||||||
|
expect(decodeClientPrefs('')).toEqual({});
|
||||||
|
expect(decodeClientPrefs('not json')).toEqual({});
|
||||||
|
expect(decodeClientPrefs('[1,2,3]')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps only valid fields and drops unknown or mistyped ones', () => {
|
||||||
|
const raw = JSON.stringify({ theme: 'neon', reduceMotion: 'yes', boardLabels: 'classic', locale: 'ru' });
|
||||||
|
expect(decodeClientPrefs(raw)).toEqual({ boardLabels: 'classic' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the CloudStorage key', () => {
|
||||||
|
expect(CLOUD_PREFS_KEY).toBe('prefs');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Telegram CloudStorage sync for the device-independent client display preferences — theme,
|
||||||
|
// reduce-motion and board labels — so they follow the user across their Telegram devices. The
|
||||||
|
// interface language is intentionally excluded: it has its own server-side sync
|
||||||
|
// (Profile.preferredLanguage) plus an on-launch reconciler, and mixing it in here would fight that.
|
||||||
|
// The pure encode/decode is kept free of the SDK and the DOM so it unit-tests in the node
|
||||||
|
// environment; the CloudStorage transport wrappers live in telegram.ts and the wiring (mirror on
|
||||||
|
// save, reconcile on launch) in app.svelte.ts.
|
||||||
|
|
||||||
|
import type { ThemePref } from './theme';
|
||||||
|
import type { BoardLabelMode } from './boardlabels';
|
||||||
|
|
||||||
|
/** ClientPrefs is the subset of preferences synced across devices via Telegram CloudStorage. */
|
||||||
|
export interface ClientPrefs {
|
||||||
|
theme: ThemePref;
|
||||||
|
reduceMotion: boolean;
|
||||||
|
boardLabels: BoardLabelMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CLOUD_PREFS_KEY is the Telegram CloudStorage key holding the JSON-encoded ClientPrefs. */
|
||||||
|
export const CLOUD_PREFS_KEY = 'prefs';
|
||||||
|
|
||||||
|
/** encodeClientPrefs serialises the synced client prefs (and only those — never the locale). */
|
||||||
|
export function encodeClientPrefs(p: ClientPrefs): string {
|
||||||
|
return JSON.stringify({ theme: p.theme, reduceMotion: p.reduceMotion, boardLabels: p.boardLabels });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* decodeClientPrefs parses a CloudStorage payload into a partial ClientPrefs, keeping only valid
|
||||||
|
* fields and dropping anything unknown, mistyped or malformed — so a value written by a newer or
|
||||||
|
* older build, or a corrupt entry, never throws and never applies a bad setting. A missing field
|
||||||
|
* stays absent, so the caller leaves the corresponding local value untouched.
|
||||||
|
*/
|
||||||
|
export function decodeClientPrefs(raw: string | null | undefined): Partial<ClientPrefs> {
|
||||||
|
if (!raw) return {};
|
||||||
|
let o: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
o = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!o || typeof o !== 'object') return {};
|
||||||
|
const out: Partial<ClientPrefs> = {};
|
||||||
|
if (o.theme === 'auto' || o.theme === 'light' || o.theme === 'dark') out.theme = o.theme;
|
||||||
|
if (typeof o.reduceMotion === 'boolean') out.reduceMotion = o.reduceMotion;
|
||||||
|
if (o.boardLabels === 'beginner' || o.boardLabels === 'classic' || o.boardLabels === 'none') {
|
||||||
|
out.boardLabels = o.boardLabels;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -247,7 +247,7 @@ export const en = {
|
|||||||
'friends.redeem': 'Add',
|
'friends.redeem': 'Add',
|
||||||
'friends.copy': 'Copy',
|
'friends.copy': 'Copy',
|
||||||
'friends.codeCopied': 'Code copied.',
|
'friends.codeCopied': 'Code copied.',
|
||||||
'friends.shareTelegram': 'Share via Telegram',
|
'friends.shareTelegram': 'Share',
|
||||||
'friends.inviteText': "Let's play Scrabble!",
|
'friends.inviteText': "Let's play Scrabble!",
|
||||||
'friends.linkCopied': 'Link copied.',
|
'friends.linkCopied': 'Link copied.',
|
||||||
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
|
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'friends.redeem': 'Добавить',
|
'friends.redeem': 'Добавить',
|
||||||
'friends.copy': 'Копировать',
|
'friends.copy': 'Копировать',
|
||||||
'friends.codeCopied': 'Код скопирован.',
|
'friends.codeCopied': 'Код скопирован.',
|
||||||
'friends.shareTelegram': 'Поделиться через Telegram',
|
'friends.shareTelegram': 'Поделиться',
|
||||||
'friends.inviteText': 'Давай играть в Эрудит!',
|
'friends.inviteText': 'Давай играть в Эрудит!',
|
||||||
'friends.linkCopied': 'Ссылка скопирована.',
|
'friends.linkCopied': 'Ссылка скопирована.',
|
||||||
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
|
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { BOT_BUTTON_ID, botInfoPopup } from './nativedialogs';
|
||||||
|
|
||||||
|
describe('botInfoPopup', () => {
|
||||||
|
it('inlines the bot handle and adds an open-bot button', () => {
|
||||||
|
const p = botInfoPopup('Title', 'Open the bot {bot} to play.', 'erudit_bot', 'OK');
|
||||||
|
expect(p.title).toBe('Title');
|
||||||
|
expect(p.message).toBe('Open the bot @erudit_bot to play.');
|
||||||
|
expect(p.buttons).toEqual([
|
||||||
|
{ id: BOT_BUTTON_ID, text: '@erudit_bot' },
|
||||||
|
{ id: 'ok', text: 'OK' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the token and the open-bot button when no username is known', () => {
|
||||||
|
const p = botInfoPopup('Title', 'Open the bot {bot} to play.', '', 'OK');
|
||||||
|
expect(p.message).toBe('Open the bot to play.');
|
||||||
|
expect(p.buttons).toEqual([{ id: 'ok', text: 'OK' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves newlines in the message', () => {
|
||||||
|
const p = botInfoPopup('Hi', 'Welcome!\n\nUse {bot} now.', 'b', 'OK');
|
||||||
|
expect(p.message).toBe('Welcome!\n\nUse @b now.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Pure builder for the Telegram native popup (showPopup) used by the deep-link info modals
|
||||||
|
// (StaleInviteModal / WelcomeRedeemModal) inside the Mini App. Kept free of the SDK and the DOM so
|
||||||
|
// it unit-tests in the node environment; the showPopup transport wrapper lives in telegram.ts and
|
||||||
|
// the wiring (native inside Telegram, the in-app Modal elsewhere) in the modal components.
|
||||||
|
|
||||||
|
import type { TelegramPopupParams } from './telegram';
|
||||||
|
|
||||||
|
/** BOT_BUTTON_ID is the showPopup button id that means "open the bot chat". */
|
||||||
|
export const BOT_BUTTON_ID = 'bot';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* botInfoPopup builds the native popup for a deep-link info modal: the message with the `{bot}`
|
||||||
|
* token replaced by the `@username` as plain text (a native popup has no inline link, unlike the
|
||||||
|
* in-app Modal), plus an "open bot" button when a username is known and a closing OK button. With
|
||||||
|
* no username it is the message (token removed) and OK only.
|
||||||
|
*/
|
||||||
|
export function botInfoPopup(title: string, message: string, username: string, okText: string): TelegramPopupParams {
|
||||||
|
const handle = username ? `@${username}` : '';
|
||||||
|
const text = (username ? message.replace('{bot}', handle) : message.replace('{bot}', '').replace(' ', ' ')).trim();
|
||||||
|
const buttons = username
|
||||||
|
? [{ id: BOT_BUTTON_ID, text: handle }, { id: 'ok', text: okText }]
|
||||||
|
: [{ id: 'ok', text: okText }];
|
||||||
|
return { title, message: text, buttons };
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ import {
|
|||||||
routeExternalLinkInTelegram,
|
routeExternalLinkInTelegram,
|
||||||
telegramLaunch,
|
telegramLaunch,
|
||||||
telegramOpenExternalLink,
|
telegramOpenExternalLink,
|
||||||
|
telegramThemeParams,
|
||||||
|
telegramSafeAreaInset,
|
||||||
|
telegramShowSettingsButton,
|
||||||
|
telegramDialogsAvailable,
|
||||||
|
telegramShowConfirm,
|
||||||
|
telegramShowPopup,
|
||||||
} from './telegram';
|
} from './telegram';
|
||||||
|
|
||||||
function stubWebApp(initData: string, startParam?: string) {
|
function stubWebApp(initData: string, startParam?: string) {
|
||||||
@@ -44,6 +50,20 @@ describe('telegram launch detection', () => {
|
|||||||
expect(launch.startParam).toBe('g123');
|
expect(launch.startParam).toBe('g123');
|
||||||
expect(launch.theme?.bg_color).toBe('#101418');
|
expect(launch.theme?.bg_color).toBe('#101418');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('telegramThemeParams reads the live palette (undefined outside Telegram)', () => {
|
||||||
|
expect(telegramThemeParams()).toBeUndefined();
|
||||||
|
stubWebApp('query_id=abc');
|
||||||
|
expect(telegramThemeParams()?.bg_color).toBe('#101418');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telegramSafeAreaInset returns zeros outside Telegram and the SDK insets inside', () => {
|
||||||
|
expect(telegramSafeAreaInset()).toEqual({ top: 0, bottom: 0, left: 0, right: 0 });
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
Telegram: { WebApp: { initData: 'x', safeAreaInset: { top: 59, bottom: 34, left: 0, right: 0 } } },
|
||||||
|
});
|
||||||
|
expect(telegramSafeAreaInset()).toEqual({ top: 59, bottom: 34, left: 0, right: 0 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('telegramOpenExternalLink', () => {
|
describe('telegramOpenExternalLink', () => {
|
||||||
@@ -61,6 +81,56 @@ describe('telegramOpenExternalLink', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('telegramShowSettingsButton', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('shows the native Settings button and wires its click inside Telegram', () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const show = vi.fn();
|
||||||
|
const handler = vi.fn();
|
||||||
|
vi.stubGlobal('window', { Telegram: { WebApp: { SettingsButton: { onClick, show } } } });
|
||||||
|
telegramShowSettingsButton(handler);
|
||||||
|
expect(onClick).toHaveBeenCalledWith(handler);
|
||||||
|
expect(show).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op without the SDK button (older client / outside Telegram)', () => {
|
||||||
|
expect(() => telegramShowSettingsButton(() => {})).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('native dialogs', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('telegramDialogsAvailable reflects showPopup presence', () => {
|
||||||
|
expect(telegramDialogsAvailable()).toBe(false);
|
||||||
|
vi.stubGlobal('window', { Telegram: { WebApp: { showPopup: () => {} } } });
|
||||||
|
expect(telegramDialogsAvailable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telegramShowConfirm resolves the user choice inside Telegram', async () => {
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
Telegram: { WebApp: { showConfirm: (_m: string, cb: (ok: boolean) => void) => cb(true) } },
|
||||||
|
});
|
||||||
|
await expect(telegramShowConfirm('Sure?')).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telegramShowConfirm resolves false without the SDK dialog', async () => {
|
||||||
|
await expect(telegramShowConfirm('Sure?')).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telegramShowPopup resolves the pressed button id', async () => {
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
Telegram: { WebApp: { showPopup: (_p: unknown, cb: (id: string) => void) => cb('bot') } },
|
||||||
|
});
|
||||||
|
await expect(telegramShowPopup({ message: 'Hi' })).resolves.toBe('bot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telegramShowPopup resolves null without the SDK', async () => {
|
||||||
|
await expect(telegramShowPopup({ message: 'Hi' })).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('routeExternalLinkInTelegram', () => {
|
describe('routeExternalLinkInTelegram', () => {
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
|||||||
+115
-6
@@ -6,6 +6,20 @@
|
|||||||
|
|
||||||
import type { TelegramThemeParams } from './theme';
|
import type { TelegramThemeParams } from './theme';
|
||||||
|
|
||||||
|
/** TelegramPopupButton is one button of a native showPopup (Bot API 6.2). */
|
||||||
|
export interface TelegramPopupButton {
|
||||||
|
id?: string;
|
||||||
|
type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive';
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TelegramPopupParams configures a native showPopup (title optional, message required, up to 3 buttons). */
|
||||||
|
export interface TelegramPopupParams {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
buttons?: TelegramPopupButton[];
|
||||||
|
}
|
||||||
|
|
||||||
interface TelegramWebApp {
|
interface TelegramWebApp {
|
||||||
initData: string;
|
initData: string;
|
||||||
initDataUnsafe?: { start_param?: string };
|
initDataUnsafe?: { start_param?: string };
|
||||||
@@ -41,6 +55,18 @@ interface TelegramWebApp {
|
|||||||
onClick?: (cb: () => void) => void;
|
onClick?: (cb: () => void) => void;
|
||||||
offClick?: (cb: () => void) => void;
|
offClick?: (cb: () => void) => void;
|
||||||
};
|
};
|
||||||
|
SettingsButton?: {
|
||||||
|
show?: () => void;
|
||||||
|
hide?: () => void;
|
||||||
|
onClick?: (cb: () => void) => void;
|
||||||
|
offClick?: (cb: () => void) => void;
|
||||||
|
};
|
||||||
|
CloudStorage?: {
|
||||||
|
getItem?: (key: string, cb: (err: string | null, value?: string) => void) => void;
|
||||||
|
setItem?: (key: string, value: string, cb?: (err: string | null, ok?: boolean) => void) => void;
|
||||||
|
};
|
||||||
|
showConfirm?: (message: string, cb?: (ok: boolean) => void) => void;
|
||||||
|
showPopup?: (params: TelegramPopupParams, cb?: (buttonId: string) => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function webApp(): TelegramWebApp | undefined {
|
function webApp(): TelegramWebApp | undefined {
|
||||||
@@ -243,6 +269,15 @@ export function telegramColorScheme(): 'light' | 'dark' | undefined {
|
|||||||
return webApp()?.colorScheme;
|
return webApp()?.colorScheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramThemeParams returns Telegram's current theme palette (WebApp.themeParams), or undefined
|
||||||
|
* outside Telegram. It reads the live value rather than a launch snapshot, so the themeChanged
|
||||||
|
* event can re-apply the palette when the user switches Telegram's light/dark theme mid-session.
|
||||||
|
*/
|
||||||
|
export function telegramThemeParams(): TelegramThemeParams | undefined {
|
||||||
|
return webApp()?.themeParams;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* telegramSetChrome paints Telegram's own header, background and bottom bar to match the
|
* telegramSetChrome paints Telegram's own header, background and bottom bar to match the
|
||||||
* app's colours, so the surrounding Telegram chrome does not clash with the UI. No-op
|
* app's colours, so the surrounding Telegram chrome does not clash with the UI. No-op
|
||||||
@@ -265,13 +300,15 @@ export function telegramContentSafeAreaTop(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* telegramSafeAreaTop returns the device safe-area top inset (px) — the notch / status bar
|
* telegramSafeAreaInset returns the device safe-area insets (px) — the notch / status bar (top),
|
||||||
* (Bot API 8.0). Telegram's own nav controls sit in the band between it and
|
* the home indicator (bottom) and, in landscape, the notch sides (left / right) — from the SDK's
|
||||||
* telegramContentSafeAreaTop, so aligning our header to that band lines it up with them. 0
|
* safeAreaInset (Bot API 8.0). All 0 outside Telegram or on a client predating it, so callers can
|
||||||
* outside Telegram or on older clients.
|
* pad defensively. Telegram's own nav controls sit in the band between the top inset and
|
||||||
|
* telegramContentSafeAreaTop, so aligning our header to that band lines it up with them.
|
||||||
*/
|
*/
|
||||||
export function telegramSafeAreaTop(): number {
|
export function telegramSafeAreaInset(): { top: number; bottom: number; left: number; right: number } {
|
||||||
return webApp()?.safeAreaInset?.top ?? 0;
|
const i = webApp()?.safeAreaInset;
|
||||||
|
return { top: i?.top ?? 0, bottom: i?.bottom ?? 0, left: i?.left ?? 0, right: i?.right ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -282,6 +319,78 @@ export function telegramDisableVerticalSwipes(): void {
|
|||||||
webApp()?.disableVerticalSwipes?.();
|
webApp()?.disableVerticalSwipes?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramShowSettingsButton reveals Telegram's native Settings button (in the Mini App's ⋮ menu,
|
||||||
|
* Bot API 7.0) and routes its taps to handler. A no-op outside Telegram or on a client predating
|
||||||
|
* the button, so the app's own in-app settings entry stays the primary path. The app registers it
|
||||||
|
* once per launch (Telegram hides the button when the Mini App closes), so there is no offClick.
|
||||||
|
*/
|
||||||
|
export function telegramShowSettingsButton(handler: () => void): void {
|
||||||
|
const b = webApp()?.SettingsButton;
|
||||||
|
if (!b?.show) return;
|
||||||
|
b.onClick?.(handler);
|
||||||
|
b.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** telegramCloudAvailable reports whether Telegram CloudStorage (Bot API 6.9) is usable. */
|
||||||
|
export function telegramCloudAvailable(): boolean {
|
||||||
|
return !!webApp()?.CloudStorage?.getItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramCloudGet reads a value from Telegram CloudStorage, resolving null when the key is absent,
|
||||||
|
* CloudStorage is unavailable (outside Telegram / a client predating Bot API 6.9), or the read
|
||||||
|
* errors — so the caller can fall back to the local value.
|
||||||
|
*/
|
||||||
|
export function telegramCloudGet(key: string): Promise<string | null> {
|
||||||
|
const cs = webApp()?.CloudStorage;
|
||||||
|
if (!cs?.getItem) return Promise.resolve(null);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
cs.getItem!(key, (err, value) => resolve(err ? null : (value ?? null)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramCloudSet writes a value to Telegram CloudStorage, resolving once the write settles. It is
|
||||||
|
* best-effort: a no-op outside Telegram / on an older client, and it swallows write errors, since
|
||||||
|
* the local store remains the source of truth.
|
||||||
|
*/
|
||||||
|
export function telegramCloudSet(key: string, value: string): Promise<void> {
|
||||||
|
const cs = webApp()?.CloudStorage;
|
||||||
|
if (!cs?.setItem) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
cs.setItem!(key, value, () => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** telegramDialogsAvailable reports whether Telegram's native dialogs (showConfirm / showPopup, Bot
|
||||||
|
* API 6.2) are usable, so a caller can choose the native path over its own modal. */
|
||||||
|
export function telegramDialogsAvailable(): boolean {
|
||||||
|
return !!webApp()?.showPopup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramShowConfirm shows Telegram's native confirm dialog and resolves true when the user
|
||||||
|
* accepts. Resolves false outside Telegram or on a client predating the dialog, so callers should
|
||||||
|
* gate on telegramDialogsAvailable and fall back to their own modal otherwise.
|
||||||
|
*/
|
||||||
|
export function telegramShowConfirm(message: string): Promise<boolean> {
|
||||||
|
const w = webApp();
|
||||||
|
if (!w?.showConfirm) return Promise.resolve(false);
|
||||||
|
return new Promise((resolve) => w.showConfirm!(message, (ok) => resolve(!!ok)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramShowPopup shows Telegram's native popup and resolves the pressed button id (the empty
|
||||||
|
* string when dismissed without pressing a button). Resolves null outside Telegram or on a client
|
||||||
|
* predating the popup, so callers can fall back to their own modal.
|
||||||
|
*/
|
||||||
|
export function telegramShowPopup(params: TelegramPopupParams): Promise<string | null> {
|
||||||
|
const w = webApp();
|
||||||
|
if (!w?.showPopup) return Promise.resolve(null);
|
||||||
|
return new Promise((resolve) => w.showPopup!(params, (id) => resolve(id ?? '')));
|
||||||
|
}
|
||||||
|
|
||||||
/** Haptic is the set of feedbacks the app triggers. */
|
/** Haptic is the set of feedbacks the app triggers. */
|
||||||
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { GatewayError } from '../lib/client';
|
import { GatewayError } from '../lib/client';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { friendCodeParam, shareLink } from '../lib/deeplink';
|
import { friendCodeParam, shareLink } from '../lib/deeplink';
|
||||||
import { shareTelegramLink } from '../lib/telegram';
|
import { insideTelegram, shareTelegramLink, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
|
||||||
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
|
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
|
||||||
|
|
||||||
let friends = $state<AccountRef[]>([]);
|
let friends = $state<AccountRef[]>([]);
|
||||||
@@ -66,20 +66,36 @@
|
|||||||
|
|
||||||
// confirmBlock / confirmUnfriend run the pending action once its modal is
|
// confirmBlock / confirmUnfriend run the pending action once its modal is
|
||||||
// accepted, then clear the target and the revealed row.
|
// accepted, then clear the target and the revealed row.
|
||||||
function confirmBlock(): void {
|
function confirmBlock(target = blockTarget): void {
|
||||||
const target = blockTarget;
|
|
||||||
blockTarget = null;
|
blockTarget = null;
|
||||||
revealedId = null;
|
revealedId = null;
|
||||||
if (target) void blockUser(target.accountId);
|
if (target) void blockUser(target.accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmUnfriend(): void {
|
function confirmUnfriend(target = unfriendTarget): void {
|
||||||
const target = unfriendTarget;
|
|
||||||
unfriendTarget = null;
|
unfriendTarget = null;
|
||||||
revealedId = null;
|
revealedId = null;
|
||||||
if (target) void remove(target.accountId);
|
if (target) void remove(target.accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onBlockClick / onUnfriendClick: inside the Mini App (and online) confirm with Telegram's native
|
||||||
|
// dialog and act on accept; otherwise open the in-app confirm modal (the offline / web path).
|
||||||
|
async function onBlockClick(f: AccountRef): Promise<void> {
|
||||||
|
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
|
||||||
|
if (await telegramShowConfirm(`${t('friends.blockConfirm')}\n${f.displayName}`)) confirmBlock(f);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blockTarget = f;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUnfriendClick(f: AccountRef): Promise<void> {
|
||||||
|
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
|
||||||
|
if (await telegramShowConfirm(`${t('friends.unfriendConfirm')}\n${f.displayName}`)) confirmUnfriend(f);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unfriendTarget = f;
|
||||||
|
}
|
||||||
|
|
||||||
// While a friend row is slid open, a tap anywhere outside its action buttons
|
// While a friend row is slid open, a tap anywhere outside its action buttons
|
||||||
// closes it again. Taps on a kebab are skipped so its own toggle stays in charge.
|
// closes it again. Taps on a kebab are skipped so its own toggle stays in charge.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -182,7 +198,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}
|
||||||
<button type="button" class="link tgshare" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
|
<button type="button" class="link" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -216,8 +232,8 @@
|
|||||||
{#each friends as f (f.accountId)}
|
{#each friends as f (f.accountId)}
|
||||||
<div class="rowwrap" class:revealed={revealedId === f.accountId}>
|
<div class="rowwrap" class:revealed={revealedId === f.accountId}>
|
||||||
<div class="acts">
|
<div class="acts">
|
||||||
<button class="iconbtn" onclick={() => (blockTarget = f)} disabled={!connection.online} aria-label={t('friends.block')}>🚫</button>
|
<button class="iconbtn" onclick={() => onBlockClick(f)} disabled={!connection.online} aria-label={t('friends.block')}>🚫</button>
|
||||||
<button class="iconbtn" onclick={() => (unfriendTarget = f)} disabled={!connection.online} aria-label={t('friends.unfriend')}>✖️</button>
|
<button class="iconbtn" onclick={() => onUnfriendClick(f)} disabled={!connection.online} aria-label={t('friends.unfriend')}>✖️</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="who">{f.displayName}</span>
|
<span class="who">{f.displayName}</span>
|
||||||
@@ -264,7 +280,7 @@
|
|||||||
<p class="confirm-name">{blockTarget.displayName}</p>
|
<p class="confirm-name">{blockTarget.displayName}</p>
|
||||||
<div class="confirm-row">
|
<div class="confirm-row">
|
||||||
<button class="cancel" onclick={() => (blockTarget = null)}>{t('common.cancel')}</button>
|
<button class="cancel" onclick={() => (blockTarget = null)}>{t('common.cancel')}</button>
|
||||||
<button class="danger" onclick={confirmBlock} disabled={!connection.online}>{t('friends.block')}</button>
|
<button class="danger" onclick={() => confirmBlock()} disabled={!connection.online}>{t('friends.block')}</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -273,7 +289,7 @@
|
|||||||
<p class="confirm-name">{unfriendTarget.displayName}</p>
|
<p class="confirm-name">{unfriendTarget.displayName}</p>
|
||||||
<div class="confirm-row">
|
<div class="confirm-row">
|
||||||
<button class="cancel" onclick={() => (unfriendTarget = null)}>{t('common.cancel')}</button>
|
<button class="cancel" onclick={() => (unfriendTarget = null)}>{t('common.cancel')}</button>
|
||||||
<button class="danger" onclick={confirmUnfriend} disabled={!connection.online}>{t('friends.unfriend')}</button>
|
<button class="danger" onclick={() => confirmUnfriend()} disabled={!connection.online}>{t('friends.unfriend')}</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user