// VK Bridge storage sync for the client display preferences that VK's desktop iframe drops between // reloads — theme, interface language, reduce-motion, board labels and the board zoom, plus the // first-run coachmark flags. In VK's cross-origin desktop iframe (notably in Firefox) the browser // partitions or blocks IndexedDB and localStorage, so the app's local store is empty on every reload // and these settings reset; a plain browser tab / PWA keeps them (first-party storage) and Telegram // has its own CloudStorage sync (cloudprefs.ts). VKWebAppStorageSet/Get travel over the VK Bridge // postMessage channel to vk.com rather than browser storage, so they survive; the VK identity is // re-derived from the signed launch parameters on every load, so the stored values reload onto the // same account. The pure encode/decode is kept free of the SDK and the DOM so it unit-tests in the // node environment; the VK storage transport wrappers live in vk.ts and the wiring (mirror on save, // reconcile on launch) in app.svelte.ts. // // Unlike the Telegram CloudStorage bundle (cloudprefs.ts), this one DOES carry the locale: on VK the // interface language has no other durable client home (the server preferred_language is written from // the client but never read back into it), so it must ride the same store as the rest. import type { ThemePref } from './theme'; import type { BoardLabelMode } from './boardlabels'; import type { Locale } from './i18n/catalog'; /** VKPrefs is the display-preference bundle synced to VK Bridge storage. */ export interface VKPrefs { theme: ThemePref; locale: Locale; reduceMotion: boolean; boardLabels: BoardLabelMode; zoomBoard: boolean; } /** VKOnboarding mirrors the first-run coachmark completion flags to VK Bridge storage. */ export interface VKOnboarding { lobbyDone: boolean; gameDone: boolean; } /** VK_PREFS_KEY is the VK Bridge storage key holding the JSON-encoded VKPrefs. */ export const VK_PREFS_KEY = 'prefs'; /** VK_ONBOARDING_KEY is the VK Bridge storage key holding the JSON-encoded VKOnboarding flags. */ export const VK_ONBOARDING_KEY = 'onboarding'; /** encodeVKPrefs serialises the VK-synced display prefs. */ export function encodeVKPrefs(p: VKPrefs): string { return JSON.stringify({ theme: p.theme, locale: p.locale, reduceMotion: p.reduceMotion, boardLabels: p.boardLabels, zoomBoard: p.zoomBoard, }); } /** * decodeVKPrefs parses a VK Bridge storage payload into a partial VKPrefs, 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 decodeVKPrefs(raw: string | null | undefined): Partial { const o = parseObject(raw); if (!o) return {}; const out: Partial = {}; if (o.theme === 'auto' || o.theme === 'light' || o.theme === 'dark') out.theme = o.theme; if (o.locale === 'en' || o.locale === 'ru') out.locale = o.locale; if (typeof o.reduceMotion === 'boolean') out.reduceMotion = o.reduceMotion; if (o.boardLabels === 'beginner' || o.boardLabels === 'classic' || o.boardLabels === 'none') { out.boardLabels = o.boardLabels; } if (typeof o.zoomBoard === 'boolean') out.zoomBoard = o.zoomBoard; return out; } /** encodeVKOnboarding serialises the first-run coachmark completion flags. */ export function encodeVKOnboarding(s: VKOnboarding): string { return JSON.stringify({ lobbyDone: s.lobbyDone, gameDone: s.gameDone }); } /** * decodeVKOnboarding parses a VK Bridge storage payload into a partial VKOnboarding, keeping only the * boolean flags and dropping anything else — a missing flag stays absent so the caller keeps its * current value. */ export function decodeVKOnboarding(raw: string | null | undefined): Partial { const o = parseObject(raw); if (!o) return {}; const out: Partial = {}; if (typeof o.lobbyDone === 'boolean') out.lobbyDone = o.lobbyDone; if (typeof o.gameDone === 'boolean') out.gameDone = o.gameDone; return out; } /** * parseObject safely parses raw JSON into a plain object, returning null for empty, malformed or * non-object input (including a JSON array), so every decoder above starts from a clean record. */ function parseObject(raw: string | null | undefined): Record | null { if (!raw) return null; try { const o = JSON.parse(raw) as unknown; return o && typeof o === 'object' && !Array.isArray(o) ? (o as Record) : null; } catch { return null; } }