release: v1.7.0 — Telegram Mini App embedding enhancements #138
@@ -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<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);
|
||||
@@ -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<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;
|
||||
}
|
||||
@@ -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<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. */
|
||||
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user