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:
@@ -42,7 +42,12 @@ Three executables plus per-platform side-services:
|
||||
users, a weighted fair rotation — §10),
|
||||
and a client **board-style** setting (bonus-label
|
||||
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
|
||||
`scrabble/platform/telegram`), split into two binaries that share the bot token
|
||||
(**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
|
||||
game channel), split into a home **validator** and a remote **bot** that share the
|
||||
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
|
||||
out-of-app push are
|
||||
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 |
|
||||
| --- | --- |
|
||||
| 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 → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||
|
||||
+6
-2
@@ -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
|
||||
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
|
||||
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
|
||||
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.
|
||||
@@ -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
|
||||
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 &
|
||||
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
|
||||
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
||||
|
||||
@@ -31,7 +31,9 @@ top-1 подсказку, безлимитную проверку слова с
|
||||
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
||||
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
||||
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
|
||||
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
|
||||
в цвета Telegram (перекрашиваясь вживую при смене светлой/тёмной темы Telegram) и
|
||||
вписывается в безопасные зоны экрана (safe-area), а — при первом контакте — задаёт язык
|
||||
интерфейса нового аккаунта по
|
||||
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
|
||||
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
|
||||
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
|
||||
@@ -255,7 +257,11 @@ UTC; при создании аккаунта она подставляется
|
||||
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
|
||||
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
||||
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
|
||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». Внутри Telegram
|
||||
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
|
||||
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
|
||||
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
|
||||
Telegram.
|
||||
|
||||
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
||||
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
||||
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
)
|
||||
|
||||
// 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")
|
||||
|
||||
// defaultMaxAge bounds how old a validated initData payload may be.
|
||||
@@ -120,6 +121,7 @@ func parseUser(userJSON string) (User, error) {
|
||||
}
|
||||
var u struct {
|
||||
ID int64 `json:"id"`
|
||||
IsBot bool `json:"is_bot"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
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 {
|
||||
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{
|
||||
ExternalID: strconv.FormatInt(u.ID, 10),
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,6 +49,11 @@
|
||||
/* 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-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,
|
||||
"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);
|
||||
|
||||
@@ -101,6 +101,12 @@
|
||||
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. */
|
||||
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 {
|
||||
flex: 0 1 auto;
|
||||
@@ -116,7 +122,18 @@
|
||||
display: flex;
|
||||
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 {
|
||||
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>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { app, dismissStaleInvite } from '../lib/app.svelte';
|
||||
import { router } from '../lib/router.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
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';
|
||||
|
||||
// The single bot's @username, for the deep link.
|
||||
@@ -19,9 +21,30 @@
|
||||
}
|
||||
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>
|
||||
|
||||
{#if app.staleInvite}
|
||||
{#if app.staleInvite && ready && !(insideTelegram() && telegramDialogsAvailable())}
|
||||
<Modal title={t('friends.staleInviteTitle')} onclose={dismissStaleInvite}>
|
||||
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
|
||||
<button class="ok" onclick={dismissStaleInvite}>{t('common.ok')}</button>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { app, dismissWelcomeRedeem } from '../lib/app.svelte';
|
||||
import { router } from '../lib/router.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
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';
|
||||
|
||||
// The single bot's @username, for the deep link.
|
||||
@@ -22,9 +24,30 @@
|
||||
}
|
||||
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>
|
||||
|
||||
{#if app.welcomeRedeem}
|
||||
{#if app.welcomeRedeem && ready && !(insideTelegram() && telegramDialogsAvailable())}
|
||||
<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>
|
||||
<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 { patchLobbyGame } from '../lib/lobbycache';
|
||||
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
||||
import { telegramHaptic } from '../lib/telegram';
|
||||
import { insideTelegram, telegramDialogsAvailable, telegramHaptic, telegramShowConfirm } from '../lib/telegram';
|
||||
import {
|
||||
BLANK,
|
||||
newPlacement,
|
||||
@@ -712,6 +712,16 @@
|
||||
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() {
|
||||
resignOpen = false;
|
||||
busy = true;
|
||||
@@ -1149,7 +1159,7 @@
|
||||
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
|
||||
{/if}
|
||||
{: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 !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
|
||||
|
||||
+87
-17
@@ -9,7 +9,7 @@ import { GatewayError } from './client';
|
||||
import { navigate, router } from './router.svelte';
|
||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
||||
import { languageNeedsServerSync } from './language';
|
||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
|
||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
|
||||
import {
|
||||
insideTelegram,
|
||||
collectTelegramDiag,
|
||||
@@ -18,15 +18,21 @@ import {
|
||||
hasLaunchFragment,
|
||||
loadTelegramSDK,
|
||||
telegramColorScheme,
|
||||
telegramThemeParams,
|
||||
telegramContentSafeAreaTop,
|
||||
telegramSafeAreaTop,
|
||||
telegramSafeAreaInset,
|
||||
telegramDisableVerticalSwipes,
|
||||
telegramShowSettingsButton,
|
||||
telegramHaptic,
|
||||
telegramLaunch,
|
||||
type TelegramLaunch,
|
||||
telegramOnEvent,
|
||||
telegramSetChrome,
|
||||
telegramCloudAvailable,
|
||||
telegramCloudGet,
|
||||
telegramCloudSet,
|
||||
} from './telegram';
|
||||
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||
import { parseStartParam } from './deeplink';
|
||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||
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
|
||||
* nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a
|
||||
* `tg-fullscreen` class, so the header can drop below the nav and centre the title in its
|
||||
* band. Called on launch and on Telegram's safe-area / fullscreen change events.
|
||||
* syncTelegramSafeArea mirrors Telegram's safe-area insets into CSS vars: the content-safe-area top
|
||||
* (the height Telegram's native nav overlays the viewport in fullscreen) into --tg-content-top
|
||||
* (which also toggles the `tg-fullscreen` class so the header drops below the nav and centres the
|
||||
* 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 {
|
||||
if (typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
const top = telegramContentSafeAreaTop();
|
||||
document.documentElement.style.setProperty('--tg-content-top', `${top}px`);
|
||||
document.documentElement.style.setProperty('--tg-safe-top', `${telegramSafeAreaTop()}px`);
|
||||
document.documentElement.classList.toggle('tg-fullscreen', top > 0);
|
||||
const safe = telegramSafeAreaInset();
|
||||
root.style.setProperty('--tg-content-top', `${top}px`);
|
||||
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
|
||||
* colour scheme and theme, the matching header / background / bottom chrome, the safe-area insets,
|
||||
* the swipe-down guard, and immersive fullscreen on mobile. It is idempotent, so both the initial
|
||||
* bootstrap and a manual launch retry call it.
|
||||
* syncTelegramTheme re-applies Telegram's theme integration — the themeParams token overrides,
|
||||
* Telegram's authoritative colour scheme, and the matching chrome — from theme, or from the SDK's
|
||||
* current themeParams when omitted. Called on launch with the launch snapshot and live on the
|
||||
* 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 {
|
||||
if (launch.theme) applyTelegramTheme(launch.theme);
|
||||
function syncTelegramTheme(theme: TelegramThemeParams | undefined = telegramThemeParams()): void {
|
||||
if (theme) applyTelegramTheme(theme);
|
||||
// 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
|
||||
// back to the stored preference when the SDK omits it.
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
telegramDisableVerticalSwipes();
|
||||
}
|
||||
@@ -621,10 +646,19 @@ export async function bootstrap(): Promise<void> {
|
||||
if (insideTelegram()) {
|
||||
const launch = telegramLaunch();
|
||||
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).
|
||||
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
|
||||
telegramOnEvent('safeAreaChanged', 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);
|
||||
app.ready = true;
|
||||
return;
|
||||
@@ -811,6 +845,42 @@ function persistPrefs(): void {
|
||||
reduceMotion: app.reduceMotion,
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
telegramLaunch,
|
||||
telegramOpenExternalLink,
|
||||
telegramThemeParams,
|
||||
telegramSafeAreaInset,
|
||||
telegramShowSettingsButton,
|
||||
telegramDialogsAvailable,
|
||||
telegramShowConfirm,
|
||||
telegramShowPopup,
|
||||
} from './telegram';
|
||||
|
||||
function stubWebApp(initData: string, startParam?: string) {
|
||||
@@ -44,6 +50,20 @@ describe('telegram launch detection', () => {
|
||||
expect(launch.startParam).toBe('g123');
|
||||
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', () => {
|
||||
@@ -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', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
|
||||
+115
-6
@@ -6,6 +6,20 @@
|
||||
|
||||
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 {
|
||||
initData: string;
|
||||
initDataUnsafe?: { start_param?: string };
|
||||
@@ -41,6 +55,18 @@ interface TelegramWebApp {
|
||||
onClick?: (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 {
|
||||
@@ -243,6 +269,15 @@ export function telegramColorScheme(): 'light' | 'dark' | undefined {
|
||||
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
|
||||
* 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
|
||||
* (Bot API 8.0). Telegram's own nav controls sit in the band between it and
|
||||
* telegramContentSafeAreaTop, so aligning our header to that band lines it up with them. 0
|
||||
* outside Telegram or on older clients.
|
||||
* telegramSafeAreaInset returns the device safe-area insets (px) — the notch / status bar (top),
|
||||
* the home indicator (bottom) and, in landscape, the notch sides (left / right) — from the SDK's
|
||||
* safeAreaInset (Bot API 8.0). All 0 outside Telegram or on a client predating it, so callers can
|
||||
* 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 {
|
||||
return webApp()?.safeAreaInset?.top ?? 0;
|
||||
export function telegramSafeAreaInset(): { top: number; bottom: number; left: number; right: number } {
|
||||
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?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { GatewayError } from '../lib/client';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
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';
|
||||
|
||||
let friends = $state<AccountRef[]>([]);
|
||||
@@ -66,20 +66,36 @@
|
||||
|
||||
// confirmBlock / confirmUnfriend run the pending action once its modal is
|
||||
// accepted, then clear the target and the revealed row.
|
||||
function confirmBlock(): void {
|
||||
const target = blockTarget;
|
||||
function confirmBlock(target = blockTarget): void {
|
||||
blockTarget = null;
|
||||
revealedId = null;
|
||||
if (target) void blockUser(target.accountId);
|
||||
}
|
||||
|
||||
function confirmUnfriend(): void {
|
||||
const target = unfriendTarget;
|
||||
function confirmUnfriend(target = unfriendTarget): void {
|
||||
unfriendTarget = null;
|
||||
revealedId = null;
|
||||
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
|
||||
// closes it again. Taps on a kebab are skipped so its own toggle stays in charge.
|
||||
$effect(() => {
|
||||
@@ -216,8 +232,8 @@
|
||||
{#each friends as f (f.accountId)}
|
||||
<div class="rowwrap" class:revealed={revealedId === f.accountId}>
|
||||
<div class="acts">
|
||||
<button class="iconbtn" onclick={() => (blockTarget = 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={() => onBlockClick(f)} disabled={!connection.online} aria-label={t('friends.block')}>🚫</button>
|
||||
<button class="iconbtn" onclick={() => onUnfriendClick(f)} disabled={!connection.online} aria-label={t('friends.unfriend')}>✖️</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="who">{f.displayName}</span>
|
||||
@@ -264,7 +280,7 @@
|
||||
<p class="confirm-name">{blockTarget.displayName}</p>
|
||||
<div class="confirm-row">
|
||||
<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>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -273,7 +289,7 @@
|
||||
<p class="confirm-name">{unfriendTarget.displayName}</p>
|
||||
<div class="confirm-row">
|
||||
<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>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user