From 0fb6004a8b12f18885e47160fcb98c17fb2947f1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 24 Jun 2026 09:20:41 +0200 Subject: [PATCH 1/7] feat(telegram): re-apply theme live on themeChanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mini App read Telegram's themeParams/colorScheme only once at launch, so switching Telegram's light/dark theme (or its auto day/night) while the app was open left the SPA on stale colours until a relaunch. Subscribe to the themeChanged WebApp event and re-apply the theme live. Extract the launch-time token + colour-scheme + chrome application into syncTelegramTheme (reading live themeParams when no launch snapshot is passed) and call it from both applyTelegramChrome (launch) and the new event handler. Add a telegramThemeParams live getter (+ unit test). Drop the stale 'immersive fullscreen' note from applyTelegramChrome — the app deliberately does not request fullscreen. --- ui/src/lib/app.svelte.ts | 32 +++++++++++++++++++++++--------- ui/src/lib/telegram.test.ts | 7 +++++++ ui/src/lib/telegram.ts | 9 +++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 962a0dc..5233ae1 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -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,6 +18,7 @@ import { hasLaunchFragment, loadTelegramSDK, telegramColorScheme, + telegramThemeParams, telegramContentSafeAreaTop, telegramSafeAreaTop, telegramDisableVerticalSwipes, @@ -554,20 +555,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(); } @@ -625,6 +637,8 @@ export async function bootstrap(): Promise { 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()); await bootTelegram(launch); app.ready = true; return; diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts index 7999800..b6f4816 100644 --- a/ui/src/lib/telegram.test.ts +++ b/ui/src/lib/telegram.test.ts @@ -7,6 +7,7 @@ import { routeExternalLinkInTelegram, telegramLaunch, telegramOpenExternalLink, + telegramThemeParams, } from './telegram'; function stubWebApp(initData: string, startParam?: string) { @@ -44,6 +45,12 @@ 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'); + }); }); describe('telegramOpenExternalLink', () => { diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index 3a50c60..f0d85cc 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -243,6 +243,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 From b84bd1297e23244abf38e6b3e7cae710ddc37554 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 24 Jun 2026 10:24:56 +0200 Subject: [PATCH 2/7] feat(telegram): clear bottom and side safe-area insets Only the device safe-area TOP inset was mirrored, so on phones with a home indicator the rack / bottom bar sat under it, and in landscape the notch clipped the screen edges. Mirror the full device safe-area inset (bottom / left / right) into new --tg-safe-bottom / --tg-safe-left / --tg-safe-right CSS vars (0 outside Telegram) and pad the shared Screen wrapper by them, so every screen clears the home indicator and the landscape notch; the top inset stays owned by the header. Replace telegramSafeAreaTop with telegramSafeAreaInset (the full inset object), with a unit test. --- ui/src/app.css | 5 +++++ ui/src/components/Screen.svelte | 5 +++++ ui/src/lib/app.svelte.ts | 24 ++++++++++++++++-------- ui/src/lib/telegram.test.ts | 9 +++++++++ ui/src/lib/telegram.ts | 14 ++++++++------ 5 files changed, 43 insertions(+), 14 deletions(-) diff --git a/ui/src/app.css b/ui/src/app.css index c2fb4ba..46a1d44 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -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); diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte index f428b59..b7baf57 100644 --- a/ui/src/components/Screen.svelte +++ b/ui/src/components/Screen.svelte @@ -101,6 +101,11 @@ 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 device safe-area cut-outs inside Telegram — the home indicator at the bottom and + the notch sides in landscape; all 0 elsewhere. The top inset is owned by the header. */ + padding-bottom: var(--tg-safe-bottom, 0px); + padding-left: var(--tg-safe-left, 0px); + padding-right: var(--tg-safe-right, 0px); } .content { flex: 0 1 auto; diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 5233ae1..64bebe3 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -20,7 +20,7 @@ import { telegramColorScheme, telegramThemeParams, telegramContentSafeAreaTop, - telegramSafeAreaTop, + telegramSafeAreaInset, telegramDisableVerticalSwipes, telegramHaptic, telegramLaunch, @@ -528,17 +528,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); } /** diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts index b6f4816..fec4340 100644 --- a/ui/src/lib/telegram.test.ts +++ b/ui/src/lib/telegram.test.ts @@ -8,6 +8,7 @@ import { telegramLaunch, telegramOpenExternalLink, telegramThemeParams, + telegramSafeAreaInset, } from './telegram'; function stubWebApp(initData: string, startParam?: string) { @@ -51,6 +52,14 @@ describe('telegram launch detection', () => { 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', () => { diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index f0d85cc..40b3efe 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -274,13 +274,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 }; } /** From d0f60ee41d247c97588b6bf1eb264da6510793cc Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 24 Jun 2026 10:41:26 +0200 Subject: [PATCH 3/7] fix(telegram): paint the home-indicator strip with the bottom bar's colour The safe-area bottom inset was reserved on the Screen wrapper, so the strip that holds space for the home indicator showed the content background and read as detached from the coloured bottom bar / header above it. Move the bottom inset onto the bottom bar: the Screen .tabbar wrapper now paints --bg-elev and pads itself by --tg-safe-bottom, so the strip continues the TabBar's chrome; a screen with no tab bar pads its bottom-most content (.content:last-child) instead, so the strip takes that content's own colour. --- ui/src/components/Screen.svelte | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte index b7baf57..1fa31c0 100644 --- a/ui/src/components/Screen.svelte +++ b/ui/src/components/Screen.svelte @@ -101,9 +101,10 @@ 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 device safe-area cut-outs inside Telegram — the home indicator at the bottom and - the notch sides in landscape; all 0 elsewhere. The top inset is owned by the header. */ - padding-bottom: var(--tg-safe-bottom, 0px); + /* 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); } @@ -121,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); } From ea931c6680485d300c5c7a8409e114dfa71ea007 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 24 Jun 2026 10:47:05 +0200 Subject: [PATCH 4/7] feat(telegram): native SettingsButton opens our Settings screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside the Mini App, reveal Telegram's native Settings button (Bot API 7.0) and route its taps to the Settings screen — the standard Mini App affordance. The in-app gear entry stays the primary path (two entry points by design). No-op outside Telegram or on a client predating the button. --- ui/src/lib/app.svelte.ts | 4 ++++ ui/src/lib/telegram.test.ts | 19 +++++++++++++++++++ ui/src/lib/telegram.ts | 19 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 64bebe3..8b251fd 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -22,6 +22,7 @@ import { telegramContentSafeAreaTop, telegramSafeAreaInset, telegramDisableVerticalSwipes, + telegramShowSettingsButton, telegramHaptic, telegramLaunch, type TelegramLaunch, @@ -647,6 +648,9 @@ export async function bootstrap(): Promise { 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; diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts index fec4340..35ceb3a 100644 --- a/ui/src/lib/telegram.test.ts +++ b/ui/src/lib/telegram.test.ts @@ -9,6 +9,7 @@ import { telegramOpenExternalLink, telegramThemeParams, telegramSafeAreaInset, + telegramShowSettingsButton, } from './telegram'; function stubWebApp(initData: string, startParam?: string) { @@ -77,6 +78,24 @@ 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('routeExternalLinkInTelegram', () => { afterEach(() => vi.unstubAllGlobals()); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index 40b3efe..9efbb92 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -41,6 +41,12 @@ interface TelegramWebApp { onClick?: (cb: () => void) => void; offClick?: (cb: () => void) => void; }; + SettingsButton?: { + show?: () => void; + hide?: () => void; + onClick?: (cb: () => void) => void; + offClick?: (cb: () => void) => void; + }; } function webApp(): TelegramWebApp | undefined { @@ -293,6 +299,19 @@ 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(); +} + /** Haptic is the set of feedbacks the app triggers. */ export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy'; From 8a5a5d6c4d7bcb5cd2374ea5575096fa7981f42c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 24 Jun 2026 11:39:29 +0200 Subject: [PATCH 5/7] feat(telegram): sync client display prefs via CloudStorage Theme, reduce-motion and board labels lived only in local IndexedDB/localStorage, so they did not follow the user across devices and could be lost when the Telegram WebView cleared storage. Mirror these three device-independent prefs to Telegram CloudStorage (Bot API 6.9) from the single local-persist point (persistPrefs), and reconcile them from CloudStorage in the background on launch (reconcileCloudPrefs) so a change made on another device follows the user here. The local store stays the instant-render cache; the interface language is intentionally excluded (it syncs via the durable account). Pure encode/decode extracted to cloudprefs.ts with unit tests; the CloudStorage transport wrappers are added to telegram.ts. No-op outside Telegram or on a client predating CloudStorage. --- ui/src/lib/app.svelte.ts | 44 +++++++++++++++++++++++++++++++ ui/src/lib/cloudprefs.test.ts | 31 ++++++++++++++++++++++ ui/src/lib/cloudprefs.ts | 49 +++++++++++++++++++++++++++++++++++ ui/src/lib/telegram.ts | 35 +++++++++++++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 ui/src/lib/cloudprefs.test.ts create mode 100644 ui/src/lib/cloudprefs.ts diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 8b251fd..cd8902d 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -28,7 +28,11 @@ import { 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'; @@ -642,6 +646,10 @@ export async function bootstrap(): Promise { 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); @@ -837,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 { + 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 { diff --git a/ui/src/lib/cloudprefs.test.ts b/ui/src/lib/cloudprefs.test.ts new file mode 100644 index 0000000..ae8da01 --- /dev/null +++ b/ui/src/lib/cloudprefs.test.ts @@ -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'); + }); +}); diff --git a/ui/src/lib/cloudprefs.ts b/ui/src/lib/cloudprefs.ts new file mode 100644 index 0000000..71f20d0 --- /dev/null +++ b/ui/src/lib/cloudprefs.ts @@ -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 { + if (!raw) return {}; + let o: Record; + try { + o = JSON.parse(raw) as Record; + } catch { + return {}; + } + if (!o || typeof o !== 'object') return {}; + const out: Partial = {}; + 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; +} diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index 9efbb92..6ac5d8a 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -47,6 +47,10 @@ interface TelegramWebApp { 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; + }; } function webApp(): TelegramWebApp | undefined { @@ -312,6 +316,37 @@ export function telegramShowSettingsButton(handler: () => void): void { 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 { + 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 { + const cs = webApp()?.CloudStorage; + if (!cs?.setItem) return Promise.resolve(); + return new Promise((resolve) => { + cs.setItem!(key, value, () => resolve()); + }); +} + /** Haptic is the set of feedbacks the app triggers. */ export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy'; From 8de9fb1ecdcdb184cb5c61092cc2be363e4d1dee Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 24 Jun 2026 11:55:30 +0200 Subject: [PATCH 6/7] feat(telegram): deny bot users at initData validation The validator parsed only id/username/first_name/language_code from the signed Telegram user, so a WebAppUser flagged is_bot would have been provisioned a normal account. The HMAC already proves Telegram signed the payload, so is_bot==true is Telegram itself attesting the launching principal is a bot. Parse is_bot and reject it (ErrInvalidInitData -> gateway 4xx -> launch error). A real user opening the Mini App never carries it, so this is a defensive deny. Tests cover both the denied (is_bot true) and allowed (is_bot false) paths. --- .../telegram/internal/initdata/validator.go | 10 +++++++- .../internal/initdata/validator_test.go | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/platform/telegram/internal/initdata/validator.go b/platform/telegram/internal/initdata/validator.go index d09c8e3..a4d35ba 100644 --- a/platform/telegram/internal/initdata/validator.go +++ b/platform/telegram/internal/initdata/validator.go @@ -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, diff --git a/platform/telegram/internal/initdata/validator_test.go b/platform/telegram/internal/initdata/validator_test.go index 9f525cd..82b4065 100644 --- a/platform/telegram/internal/initdata/validator_test.go +++ b/platform/telegram/internal/initdata/validator_test.go @@ -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) + } + }) +} From 29b6c7e4d8aa6cf420fc6f16e76db547559fb8d0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 24 Jun 2026 12:19:50 +0200 Subject: [PATCH 7/7] docs(telegram): document the Mini App embedding enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the current state in ARCHITECTURE and FUNCTIONAL (+ _ru mirror): inside the Mini App the client tracks Telegram's live theme switch, fits the full device safe-area, exposes a native Settings button into the in-app settings, and syncs the device-independent display prefs (theme, reduce-motion, board labels — not the interface language) across the user's Telegram devices via CloudStorage; the validator denies a bot user (is_bot) before provisioning an account. --- docs/ARCHITECTURE.md | 12 +++++++++--- docs/FUNCTIONAL.md | 8 ++++++-- docs/FUNCTIONAL_ru.md | 10 ++++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0079a17..2c0a3f4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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) | diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index c21c38e..4b242b0 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index c611b7a..f06af8c 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -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, показанные **сначала Эрудит**, — в