Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master

This commit was merged in pull request #138.
This commit is contained in:
2026-06-24 11:49:44 +00:00
18 changed files with 567 additions and 46 deletions
+9 -3
View File
@@ -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
@@ -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) |
+6 -2
View File
@@ -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,7 +250,10 @@ 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
+8 -2
View File
@@ -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,7 +257,11 @@ UTC; при создании аккаунта она подставляется
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется (away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». Внутри Telegram
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
Telegram.
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты **Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
@@ -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)
}
})
}
+5
View File
@@ -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);
+17
View File
@@ -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>
+25 -2
View File
@@ -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>
+25 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+31
View File
@@ -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');
});
});
+49
View File
@@ -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;
}
+25
View File
@@ -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.');
});
});
+24
View File
@@ -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 };
}
+70
View File
@@ -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
View File
@@ -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';
+25 -9
View File
@@ -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(() => {
@@ -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}