feat(telegram): sync client display prefs via CloudStorage
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
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.
This commit is contained in:
@@ -28,7 +28,11 @@ import {
|
|||||||
type TelegramLaunch,
|
type TelegramLaunch,
|
||||||
telegramOnEvent,
|
telegramOnEvent,
|
||||||
telegramSetChrome,
|
telegramSetChrome,
|
||||||
|
telegramCloudAvailable,
|
||||||
|
telegramCloudGet,
|
||||||
|
telegramCloudSet,
|
||||||
} from './telegram';
|
} from './telegram';
|
||||||
|
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||||
import { parseStartParam } from './deeplink';
|
import { parseStartParam } from './deeplink';
|
||||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||||
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
||||||
@@ -642,6 +646,10 @@ export async function bootstrap(): Promise<void> {
|
|||||||
if (insideTelegram()) {
|
if (insideTelegram()) {
|
||||||
const launch = telegramLaunch();
|
const launch = telegramLaunch();
|
||||||
applyTelegramChrome(launch);
|
applyTelegramChrome(launch);
|
||||||
|
// Pull the device-independent display prefs (theme / reduce-motion / board labels) from
|
||||||
|
// CloudStorage in the background so a change on another device follows the user here; the local
|
||||||
|
// values applied above render instantly, so this reconciles without blocking launch.
|
||||||
|
void reconcileCloudPrefs();
|
||||||
// Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load).
|
// Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load).
|
||||||
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
|
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
|
||||||
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
|
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
|
||||||
@@ -837,6 +845,42 @@ function persistPrefs(): void {
|
|||||||
reduceMotion: app.reduceMotion,
|
reduceMotion: app.reduceMotion,
|
||||||
boardLabels: app.boardLabels,
|
boardLabels: app.boardLabels,
|
||||||
});
|
});
|
||||||
|
// Mirror the device-independent display prefs to Telegram CloudStorage so they follow the user
|
||||||
|
// across devices (no-op outside Telegram / on a client predating it). Locale is excluded — it
|
||||||
|
// syncs via the durable account (Profile.preferredLanguage) instead.
|
||||||
|
void telegramCloudSet(
|
||||||
|
CLOUD_PREFS_KEY,
|
||||||
|
encodeClientPrefs({ theme: app.theme, reduceMotion: app.reduceMotion, boardLabels: app.boardLabels }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reconcileCloudPrefs pulls the device-independent display prefs (theme / reduce-motion / board
|
||||||
|
* labels) from Telegram CloudStorage and applies any that differ from the current values, so a
|
||||||
|
* change made on another Telegram device follows the user here. The local store is the
|
||||||
|
* instant-render cache (read synchronously at boot); this runs once on launch after it and persists
|
||||||
|
* what it applied. Theme is not re-applied visually — inside Telegram the colour scheme is
|
||||||
|
* Telegram's to decide — only its stored value is updated. A no-op outside Telegram or when
|
||||||
|
* CloudStorage is unavailable; locale is never synced this way (it has its own server reconciler).
|
||||||
|
*/
|
||||||
|
async function reconcileCloudPrefs(): Promise<void> {
|
||||||
|
if (!telegramCloudAvailable()) return;
|
||||||
|
const cloud = decodeClientPrefs(await telegramCloudGet(CLOUD_PREFS_KEY));
|
||||||
|
let changed = false;
|
||||||
|
if (cloud.theme !== undefined && cloud.theme !== app.theme) {
|
||||||
|
app.theme = cloud.theme;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (cloud.reduceMotion !== undefined && cloud.reduceMotion !== app.reduceMotion) {
|
||||||
|
app.reduceMotion = cloud.reduceMotion;
|
||||||
|
applyReduceMotion(app.reduceMotion);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (cloud.boardLabels !== undefined && cloud.boardLabels !== app.boardLabels) {
|
||||||
|
app.boardLabels = cloud.boardLabels;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) persistPrefs();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setTheme(theme: ThemePref): void {
|
export function setTheme(theme: ThemePref): void {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||||
|
|
||||||
|
describe('cloudprefs', () => {
|
||||||
|
it('round-trips the synced client prefs', () => {
|
||||||
|
const p = { theme: 'dark', reduceMotion: true, boardLabels: 'classic' } as const;
|
||||||
|
expect(decodeClientPrefs(encodeClientPrefs(p))).toEqual(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never encodes the locale (it syncs via the durable account instead)', () => {
|
||||||
|
const raw = encodeClientPrefs({ theme: 'light', reduceMotion: false, boardLabels: 'none' });
|
||||||
|
expect(raw).not.toContain('locale');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty partial for missing or malformed input', () => {
|
||||||
|
expect(decodeClientPrefs(null)).toEqual({});
|
||||||
|
expect(decodeClientPrefs(undefined)).toEqual({});
|
||||||
|
expect(decodeClientPrefs('')).toEqual({});
|
||||||
|
expect(decodeClientPrefs('not json')).toEqual({});
|
||||||
|
expect(decodeClientPrefs('[1,2,3]')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps only valid fields and drops unknown or mistyped ones', () => {
|
||||||
|
const raw = JSON.stringify({ theme: 'neon', reduceMotion: 'yes', boardLabels: 'classic', locale: 'ru' });
|
||||||
|
expect(decodeClientPrefs(raw)).toEqual({ boardLabels: 'classic' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the CloudStorage key', () => {
|
||||||
|
expect(CLOUD_PREFS_KEY).toBe('prefs');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Telegram CloudStorage sync for the device-independent client display preferences — theme,
|
||||||
|
// reduce-motion and board labels — so they follow the user across their Telegram devices. The
|
||||||
|
// interface language is intentionally excluded: it has its own server-side sync
|
||||||
|
// (Profile.preferredLanguage) plus an on-launch reconciler, and mixing it in here would fight that.
|
||||||
|
// The pure encode/decode is kept free of the SDK and the DOM so it unit-tests in the node
|
||||||
|
// environment; the CloudStorage transport wrappers live in telegram.ts and the wiring (mirror on
|
||||||
|
// save, reconcile on launch) in app.svelte.ts.
|
||||||
|
|
||||||
|
import type { ThemePref } from './theme';
|
||||||
|
import type { BoardLabelMode } from './boardlabels';
|
||||||
|
|
||||||
|
/** ClientPrefs is the subset of preferences synced across devices via Telegram CloudStorage. */
|
||||||
|
export interface ClientPrefs {
|
||||||
|
theme: ThemePref;
|
||||||
|
reduceMotion: boolean;
|
||||||
|
boardLabels: BoardLabelMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CLOUD_PREFS_KEY is the Telegram CloudStorage key holding the JSON-encoded ClientPrefs. */
|
||||||
|
export const CLOUD_PREFS_KEY = 'prefs';
|
||||||
|
|
||||||
|
/** encodeClientPrefs serialises the synced client prefs (and only those — never the locale). */
|
||||||
|
export function encodeClientPrefs(p: ClientPrefs): string {
|
||||||
|
return JSON.stringify({ theme: p.theme, reduceMotion: p.reduceMotion, boardLabels: p.boardLabels });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* decodeClientPrefs parses a CloudStorage payload into a partial ClientPrefs, keeping only valid
|
||||||
|
* fields and dropping anything unknown, mistyped or malformed — so a value written by a newer or
|
||||||
|
* older build, or a corrupt entry, never throws and never applies a bad setting. A missing field
|
||||||
|
* stays absent, so the caller leaves the corresponding local value untouched.
|
||||||
|
*/
|
||||||
|
export function decodeClientPrefs(raw: string | null | undefined): Partial<ClientPrefs> {
|
||||||
|
if (!raw) return {};
|
||||||
|
let o: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
o = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!o || typeof o !== 'object') return {};
|
||||||
|
const out: Partial<ClientPrefs> = {};
|
||||||
|
if (o.theme === 'auto' || o.theme === 'light' || o.theme === 'dark') out.theme = o.theme;
|
||||||
|
if (typeof o.reduceMotion === 'boolean') out.reduceMotion = o.reduceMotion;
|
||||||
|
if (o.boardLabels === 'beginner' || o.boardLabels === 'classic' || o.boardLabels === 'none') {
|
||||||
|
out.boardLabels = o.boardLabels;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -47,6 +47,10 @@ interface TelegramWebApp {
|
|||||||
onClick?: (cb: () => void) => void;
|
onClick?: (cb: () => void) => void;
|
||||||
offClick?: (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 {
|
function webApp(): TelegramWebApp | undefined {
|
||||||
@@ -312,6 +316,37 @@ export function telegramShowSettingsButton(handler: () => void): void {
|
|||||||
b.show();
|
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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Haptic is the set of feedbacks the app triggers. */
|
/** Haptic is the set of feedbacks the app triggers. */
|
||||||
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user