Files
scrabble-game/ui/src/lib/vkprefs.ts
T
Ilia Denisov 6c0daeb177
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 16s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m6s
fix(vk): persist settings via VK Bridge storage; hide competing sign-in in Mini App hosts
VK moderation follow-ups.

1. Settings (theme, language, board style, reduce-motion, zoom, first-run
   coachmark flags) were lost between reloads in VK's desktop iframe: Firefox
   partitions/blocks IndexedDB and localStorage in a cross-origin iframe, so the
   browser-local store is empty on every reload (a plain tab / PWA keeps them —
   first-party storage — and Telegram has its own CloudStorage sync). Mirror the
   prefs (plus the locale, which has no other durable client home on VK) and the
   coachmark flags to VK Bridge storage (VKWebAppStorageSet/Get), which travels
   over postMessage to vk.com and survives; reconcile them on the VK launch
   before the first paint. The VK identity re-derives from the signed launch
   params each load, so the values reload onto the same account.

2. Inside a proprietary Mini App host (VK or Telegram) the profile now surfaces
   only email linking — both the Telegram and VK link/unlink entries are hidden
   (signInProvidersVisible), so a competing sign-in is never advertised (a
   platform ToS requirement; VK forbids showing a Telegram login, mirrored in
   Telegram for VK). A provider linked earlier on the web stays attached and is
   managed from the web; web/native builds show both.

Tests: vkprefs codec + signInProvidersVisible unit tests. Docs: ARCHITECTURE,
FUNCTIONAL (+_ru). No schema/wire change.
2026-07-15 22:53:58 +02:00

105 lines
4.6 KiB
TypeScript

// 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<VKPrefs> {
const o = parseObject(raw);
if (!o) return {};
const out: Partial<VKPrefs> = {};
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<VKOnboarding> {
const o = parseObject(raw);
if (!o) return {};
const out: Partial<VKOnboarding> = {};
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<string, unknown> | null {
if (!raw) return null;
try {
const o = JSON.parse(raw) as unknown;
return o && typeof o === 'object' && !Array.isArray(o) ? (o as Record<string, unknown>) : null;
} catch {
return null;
}
}