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';