Compare commits

...

1 Commits

Author SHA1 Message Date
Ilia Denisov 6c0daeb177 fix(vk): persist settings via VK Bridge storage; hide competing sign-in in Mini App hosts
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
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
10 changed files with 376 additions and 43 deletions
+16 -2
View File
@@ -47,7 +47,16 @@ Three executables plus per-platform side-services:
insets (the bottom/home-indicator strip taking the bottom bar's colour), exposes insets (the bottom/home-indicator strip taking the bottom bar's colour), exposes
Telegram's native **Settings** button into the in-app settings, and syncs the Telegram's native **Settings** button into the in-app settings, and syncs the
device-independent display preferences (theme, reduce-motion, board labels — **not** the device-independent display preferences (theme, reduce-motion, board labels — **not** the
interface language) across the user's Telegram devices via **CloudStorage**. interface language) across the user's Telegram devices via **CloudStorage**. Inside the **VK
Mini App** the same preferences — **plus** the interface language and the first-run coachmark
flags — are instead mirrored to **VK Bridge storage** (`VKWebAppStorageSet`/`Get`,
`lib/vkprefs.ts` + `lib/vk.ts`, reconciled on launch): VK's cross-origin desktop iframe
(notably in Firefox) partitions or blocks the browser's IndexedDB/localStorage, so the local
store is lost across reloads there, while VK Bridge storage travels over the postMessage channel
to vk.com and survives; the VK identity re-derives from the signed launch parameters each load,
so the values reload onto the same account. The locale rides this store because on VK it has no
other durable client home (`preferred_language` is written from the client but never read back).
A plain browser tab / PWA keeps everything in first-party storage.
- **`platform/telegram`** — the Telegram side-service (module - **`platform/telegram`** — the Telegram side-service (module
`scrabble/platform/telegram`), split into two binaries that share the bot token `scrabble/platform/telegram`), split into two binaries that share the bot token
(**one bot**, one optional game channel, §3): (**one bot**, one optional game channel, §3):
@@ -361,7 +370,12 @@ arrive from a platform rather than completing a mandatory registration).
- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The - **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The
backend **refuses removing the last identity** (`ErrLastIdentity`), so an account backend **refuses removing the last identity** (`ErrLastIdentity`), so an account
never becomes unreachable; the UI mirrors the guard by hiding Unlink when only one never becomes unreachable; the UI mirrors the guard by hiding Unlink when only one
method remains. **Email is never unlinked — it is changed.** method remains. **Email is never unlinked — it is changed.** **Inside a proprietary Mini App
host (VK or Telegram) the profile surfaces only email linking** — both providers' link and
unlink entries are hidden there (`profileValidation.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; the web and native builds show both providers.
- **Change email** mails a confirm-code (`purpose=change`) to the new address on the - **Change email** mails a confirm-code (`purpose=change`) to the new address on the
authenticated account and, on confirm (code or one-tap deeplink), **atomically authenticated account and, on confirm (code or one-tap deeplink), **atomically
replaces** the account's email identity with the new one, freeing the old address. A replaces** the account's email identity with the new one, freeing the old address. A
+7 -4
View File
@@ -129,9 +129,10 @@ to finish that shared game before signing in.
The profile lists the account's **sign-in methods**. On the web a player can add The profile lists the account's **sign-in methods**. On the web a player can add
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
back); inside a Mini App the host platform is already linked, and its own sign-in-method back); inside a proprietary Mini App host (VK or Telegram) the player is offered **only email
row is hidden — the platform the player is currently signed in through cannot be unlinked linking**both providers' rows and add buttons are hidden there, so a competing sign-in is
from there, only the other provider's row is shown. Linking a provider that never shown (a platform ToS rule). A provider linked earlier on the web stays attached and is
managed from the web. Linking a provider that
already belongs to another account offers the same irreversible **merge** as email already belongs to another account offers the same irreversible **merge** as email
linking. A linked provider can be **unlinked** — except the last linking. A linked provider can be **unlinked** — except the last
remaining sign-in method, which is refused so the account stays reachable. **Email is remaining sign-in method, which is refused so the account stays reachable. **Email is
@@ -425,7 +426,9 @@ an email or Telegram and merging accounts are covered under "Accounts, linking &
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings** merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
entry that opens this screen, and your display preferences (theme, board-label style and entry that opens this screen, and your display preferences (theme, board-label style and
reduce-motion — not the interface language, which follows your account) sync across your reduce-motion — not the interface language, which follows your account) sync across your
Telegram devices. On a touch device the settings also offer a **Zoom the board** toggle (on by Telegram devices. Inside the VK Mini App the same preferences — and the interface language —
are remembered between sessions this way too, including VK's desktop web version, where the
browser would otherwise forget them on every reload. On a touch device the settings also offer a **Zoom the board** toggle (on by
default): with it off, dropping a tile no longer auto-magnifies the board toward it. It is a default): with it off, dropping a tile no longer auto-magnifies the board toward it. It is a
per-device preference and is hidden on desktop, where the board already fits and never auto-zooms. per-device preference and is hidden on desktop, where the board already fits and never auto-zooms.
+7 -4
View File
@@ -132,9 +132,10 @@ Telegram держит **единого бота**: все игроки поль
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
обратно); внутри Mini App платформа-хозяин уже привязана, и её собственная плашка способа обратно); внутри проприетарного Mini App хоста (VK или Telegram) игроку предлагается **только
входа скрыта — платформу, через которую игрок сейчас вошёл, отсюда отвязать нельзя, привязка email** — плашки обоих провайдеров и кнопки добавления там скрыты, чтобы конкурирующий
показывается только плашка другого провайдера. Привязка провайдера, уже способ входа не показывался (требование ToS платформы). Провайдер, привязанный ранее в вебе,
остаётся привязанным и управляется из веба. Привязка провайдера, уже
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
email. Привязанного провайдера можно **отвязать** — кроме последнего email. Привязанного провайдера можно **отвязать** — кроме последнего
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым. оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
@@ -431,7 +432,9 @@ UTC; при создании аккаунта она подставляется
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
Telegram. На сенсорном устройстве в настройках также есть переключатель **«Приближать Telegram. Внутри VK Mini App те же настройки — и язык интерфейса — так же запоминаются между
сессиями, в том числе в десктопной веб-версии VK, где браузер иначе забывал бы их при каждой
перезагрузке. На сенсорном устройстве в настройках также есть переключатель **«Приближать
доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не
приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе, приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе,
где доска и так помещается целиком и авто-зума нет. где доска и так помещается целиком и авто-зума нет.
+75 -1
View File
@@ -33,13 +33,21 @@ import {
telegramCloudGet, telegramCloudGet,
telegramCloudSet, telegramCloudSet,
} from './telegram'; } from './telegram';
import { onVKPath, insideVK, vkLaunchDiag, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk'; import { onVKPath, insideVK, vkLaunchDiag, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, vkStorageGet, vkStorageSet, appearanceForBg } from './vk';
import type { LaunchDiag } from './launchdiag'; import type { LaunchDiag } from './launchdiag';
import { pendingVKLink, type VKLinkCallback } from './vkid'; import { pendingVKLink, type VKLinkCallback } from './vkid';
import { isStandalone } from './pwa'; import { isStandalone } from './pwa';
import { registerServiceWorker } from './pwa.svelte'; import { registerServiceWorker } from './pwa.svelte';
import { haptic } from './haptics'; import { haptic } from './haptics';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import {
VK_ONBOARDING_KEY,
VK_PREFS_KEY,
decodeVKOnboarding,
decodeVKPrefs,
encodeVKOnboarding,
encodeVKPrefs,
} from './vkprefs';
import { parseStartParam } from './deeplink'; import { parseStartParam } from './deeplink';
import { import {
clearOnboarding, clearOnboarding,
@@ -948,6 +956,10 @@ export async function bootstrap(): Promise<void> {
root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`); root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`);
}); });
await vkInit(); await vkInit();
// Restore the display prefs + coachmark flags from VK Bridge storage before the first paint — VK's
// desktop iframe drops the browser-local store on reload (Firefox), so this is where the theme,
// language and board settings come back. Awaited so the initial render is already correct.
await reconcileVKPrefs();
// The app owns navigation (its own back chevron), so silence VK's horizontal swipe-back to keep // The app owns navigation (its own back chevron), so silence VK's horizontal swipe-back to keep
// it off our edge-swipe-back and on-board tile drag — parity with telegramDisableVerticalSwipes. // it off our edge-swipe-back and on-board tile drag — parity with telegramDisableVerticalSwipes.
void vkDisableSwipeBack(); void vkDisableSwipeBack();
@@ -1252,6 +1264,9 @@ export function markOnboardingDone(series: CoachSeries): void {
if (series === 'lobby') app.onboarding.lobbyDone = true; if (series === 'lobby') app.onboarding.lobbyDone = true;
else app.onboarding.gameDone = true; else app.onboarding.gameDone = true;
void saveOnboarding({ ...app.onboarding }); void saveOnboarding({ ...app.onboarding });
// Mirror to VK Bridge storage too (a no-op outside VK), so the first-run coachmarks do not replay
// on every reload in VK's iframe, where the browser-local flag is dropped.
void vkStorageSet(VK_ONBOARDING_KEY, encodeVKOnboarding({ ...app.onboarding }));
} }
/** /**
@@ -1261,6 +1276,7 @@ export function markOnboardingDone(series: CoachSeries): void {
export function resetOnboarding(): void { export function resetOnboarding(): void {
app.onboarding = { lobbyDone: false, gameDone: false }; app.onboarding = { lobbyDone: false, gameDone: false };
void clearOnboarding(); void clearOnboarding();
void vkStorageSet(VK_ONBOARDING_KEY, encodeVKOnboarding({ lobbyDone: false, gameDone: false }));
} }
function persistPrefs(): void { function persistPrefs(): void {
@@ -1278,6 +1294,19 @@ function persistPrefs(): void {
CLOUD_PREFS_KEY, CLOUD_PREFS_KEY,
encodeClientPrefs({ theme: app.theme, reduceMotion: app.reduceMotion, boardLabels: app.boardLabels }), encodeClientPrefs({ theme: app.theme, reduceMotion: app.reduceMotion, boardLabels: app.boardLabels }),
); );
// Mirror the same display prefs — plus the locale — to VK Bridge storage, so they follow the user
// in VK's desktop iframe where Firefox drops IndexedDB / localStorage (a no-op outside VK). The
// locale is included here, unlike the Telegram bundle: on VK it has no other durable client home.
void vkStorageSet(
VK_PREFS_KEY,
encodeVKPrefs({
theme: app.theme,
locale: app.locale,
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
zoomBoard: app.zoomBoard,
}),
);
} }
/** /**
@@ -1309,6 +1338,51 @@ async function reconcileCloudPrefs(): Promise<void> {
if (changed) persistPrefs(); if (changed) persistPrefs();
} }
/**
* reconcileVKPrefs pulls the display prefs (theme / locale / reduce-motion / board labels / board
* zoom) and the first-run coachmark flags from VK Bridge storage and applies any that are present,
* then refreshes the local cache. It is the VK counterpart of reconcileCloudPrefs: VK's desktop
* iframe loses IndexedDB / localStorage across reloads (Firefox), so the browser-local values read at
* boot are only the defaults, while VK Bridge storage survives — this restores what the user last
* chose onto the same VK identity. Awaited in the VK launch branch before the app renders, so the
* first paint already carries the stored theme and language (no flash); VK's own loading cover is
* still up until vkInit. The locale is applied through setLocale, not setLocalePref, so restoring it
* never re-pushes to the server. A no-op outside VK.
*/
async function reconcileVKPrefs(): Promise<void> {
if (!insideVK()) return;
const p = decodeVKPrefs(await vkStorageGet(VK_PREFS_KEY));
if (p.theme !== undefined && p.theme !== app.theme) {
app.theme = p.theme;
applyTheme(app.theme);
syncVKChrome();
}
if (p.locale !== undefined && p.locale !== app.locale) {
app.locale = p.locale;
setLocale(p.locale);
}
if (p.reduceMotion !== undefined && p.reduceMotion !== app.reduceMotion) {
app.reduceMotion = p.reduceMotion;
applyReduceMotion(app.reduceMotion);
}
if (p.boardLabels !== undefined) app.boardLabels = p.boardLabels;
if (p.zoomBoard !== undefined) app.zoomBoard = p.zoomBoard;
const ob = decodeVKOnboarding(await vkStorageGet(VK_ONBOARDING_KEY));
if (ob.lobbyDone !== undefined) app.onboarding.lobbyDone = ob.lobbyDone;
if (ob.gameDone !== undefined) app.onboarding.gameDone = ob.gameDone;
// Refresh the browser-local cache with what we restored (best-effort — it may be blocked in this
// iframe, and VK Bridge storage stays the durable copy). savePrefs directly, not persistPrefs, to
// avoid writing straight back to the VK/Telegram stores we just read from.
void savePrefs({
theme: app.theme,
locale: app.locale,
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
zoomBoard: app.zoomBoard,
});
void saveOnboarding({ ...app.onboarding });
}
export function setTheme(theme: ThemePref): void { export function setTheme(theme: ThemePref): void {
app.theme = theme; app.theme = theme;
applyTheme(theme); applyTheme(theme);
+19 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { awayDurationOk, browserOffset, isOffsetZone, validDisplayName, validEmail } from './profileValidation'; import {
awayDurationOk,
browserOffset,
isOffsetZone,
signInProvidersVisible,
validDisplayName,
validEmail,
} from './profileValidation';
describe('validDisplayName', () => { describe('validDisplayName', () => {
it.each([ it.each([
@@ -61,3 +68,14 @@ describe('timezone helpers', () => {
expect(browserOffset()).toMatch(/^[+-]\d{2}:\d{2}$/); expect(browserOffset()).toMatch(/^[+-]\d{2}:\d{2}$/);
}); });
}); });
describe('signInProvidersVisible', () => {
it('shows both providers off a proprietary host (web / native)', () => {
expect(signInProvidersVisible(false, false)).toBe(true);
});
it('hides all providers inside a Telegram or VK host (email-only, ToS)', () => {
expect(signInProvidersVisible(true, false)).toBe(false);
expect(signInProvidersVisible(false, true)).toBe(false);
expect(signInProvidersVisible(true, true)).toBe(false);
});
});
+11
View File
@@ -87,3 +87,14 @@ export const awayHours: string[] = Array.from({ length: 24 }, (_, i) => String(i
/** Minute options on a 10-minute step. */ /** Minute options on a 10-minute step. */
export const awayMinutes: string[] = ['00', '10', '20', '30', '40', '50']; export const awayMinutes: string[] = ['00', '10', '20', '30', '40', '50'];
/**
* signInProvidersVisible reports whether the profile may surface competing sign-in providers — the
* Telegram and VK link / unlink entries. Inside a proprietary Mini App host (Telegram or VK) only
* email linking is offered, a platform ToS requirement: VK forbids showing a Telegram login, and we
* mirror it in Telegram for VK. The web and native builds are not proprietary hosts, so they show
* both providers. Pure, so it is unit-tested.
*/
export function signInProvidersVisible(insideTelegramHost: boolean, insideVKHost: boolean): boolean {
return !insideTelegramHost && !insideVKHost;
}
+34
View File
@@ -65,6 +65,40 @@ export async function vkClose(): Promise<void> {
} }
} }
/**
* vkStorageGet reads a value from the user's VK Bridge storage (VKWebAppStorageGet), resolving null
* when the key is unset, VK is unavailable (outside a VK launch / the bridge cannot answer) or the
* read errors — so the caller can fall back to the local value. VK Bridge storage travels over the
* postMessage channel to vk.com rather than browser storage, so it survives the desktop iframe's
* partitioned or blocked IndexedDB and localStorage (notably in Firefox), unlike the local store.
*/
export async function vkStorageGet(key: string): Promise<string | null> {
if (!insideVK()) return null;
try {
const r = (await (await bridge()).send('VKWebAppStorageGet', { keys: [key] })) as {
keys?: { key: string; value: string }[];
};
const item = r.keys?.find((k) => k.key === key);
return item && item.value !== '' ? item.value : null;
} catch {
return null;
}
}
/**
* vkStorageSet writes value to the user's VK Bridge storage under key (VKWebAppStorageSet). It is
* best-effort: a no-op outside VK, and it swallows write errors, since the browser-local store stays
* the in-session source of truth.
*/
export async function vkStorageSet(key: string, value: string): Promise<void> {
if (!insideVK()) return;
try {
await (await bridge()).send('VKWebAppStorageSet', { key, value });
} catch {
// Outside VK / unsupported / over quota: the local store keeps the value for this session.
}
}
/** /**
* vkUserName fetches the launching user's display name via VKWebAppGetUserInfo, since VK omits it * vkUserName fetches the launching user's display name via VKWebAppGetUserInfo, since VK omits it
* from the signed launch params. Returns '' on any failure or outside VK, so the backend falls back * from the signed launch params. Returns '' on any failure or outside VK, so the backend falls back
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import {
VK_ONBOARDING_KEY,
VK_PREFS_KEY,
decodeVKOnboarding,
decodeVKPrefs,
encodeVKOnboarding,
encodeVKPrefs,
} from './vkprefs';
describe('vkprefs', () => {
it('round-trips the synced display prefs', () => {
const p = {
theme: 'dark',
locale: 'ru',
reduceMotion: true,
boardLabels: 'classic',
zoomBoard: false,
} as const;
expect(decodeVKPrefs(encodeVKPrefs(p))).toEqual(p);
});
it('carries the locale (unlike the Telegram bundle)', () => {
const raw = encodeVKPrefs({
theme: 'light',
locale: 'en',
reduceMotion: false,
boardLabels: 'none',
zoomBoard: true,
});
expect(raw).toContain('locale');
expect(decodeVKPrefs(raw).locale).toBe('en');
});
it('returns an empty partial for missing or malformed prefs input', () => {
expect(decodeVKPrefs(null)).toEqual({});
expect(decodeVKPrefs(undefined)).toEqual({});
expect(decodeVKPrefs('')).toEqual({});
expect(decodeVKPrefs('not json')).toEqual({});
expect(decodeVKPrefs('[1,2,3]')).toEqual({});
});
it('keeps only valid prefs fields and drops unknown or mistyped ones', () => {
const raw = JSON.stringify({
theme: 'neon',
locale: 'de',
reduceMotion: 'yes',
boardLabels: 'classic',
zoomBoard: 1,
extra: true,
});
expect(decodeVKPrefs(raw)).toEqual({ boardLabels: 'classic' });
});
it('round-trips the onboarding flags', () => {
const s = { lobbyDone: true, gameDone: false } as const;
expect(decodeVKOnboarding(encodeVKOnboarding(s))).toEqual(s);
});
it('keeps only boolean onboarding flags', () => {
expect(decodeVKOnboarding(null)).toEqual({});
expect(decodeVKOnboarding(JSON.stringify({ lobbyDone: 1, gameDone: true }))).toEqual({ gameDone: true });
});
it('exposes the VK Bridge storage keys', () => {
expect(VK_PREFS_KEY).toBe('prefs');
expect(VK_ONBOARDING_KEY).toBe('onboarding');
});
});
+104
View File
@@ -0,0 +1,104 @@
// 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;
}
}
+13 -10
View File
@@ -26,6 +26,7 @@
awayMinutes, awayMinutes,
browserOffset, browserOffset,
isOffsetZone, isOffsetZone,
signInProvidersVisible,
timezoneOffsets, timezoneOffsets,
validDisplayName, validDisplayName,
validEmail, validEmail,
@@ -67,11 +68,11 @@
const nativeShell = clientChannel() === 'android' || clientChannel() === 'ios'; const nativeShell = clientChannel() === 'android' || clientChannel() === 'ios';
const telegramLinkable = loginWidgetAvailable() && !nativeShell; const telegramLinkable = loginWidgetAvailable() && !nativeShell;
const vkLinkable = vkWebLinkAvailable() && !nativeShell; const vkLinkable = vkWebLinkAvailable() && !nativeShell;
// Inside a host Mini App the current platform's own identity must not be managed from the // Inside a proprietary Mini App host (VK or Telegram) the profile shows only email linking — the
// profile — unlinking the very platform you are signed in through is meaningless — so that // competing provider entries are hidden there (a platform ToS requirement; see
// provider's linked-account row is hidden here (the web / native builds still show both). // signInProvidersVisible). A provider linked earlier on the web stays linked in the account and is
const hostTelegram = insideTelegram(); // managed from the web.
const hostVK = insideVK(); const providersVisible = signInProvidersVisible(insideTelegram(), insideVK());
function defaultTz(): string { function defaultTz(): string {
const b = browserOffset(); const b = browserOffset();
@@ -429,9 +430,9 @@
<!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email <!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email
(add) triggers the merge dialog below. On the web an account can add Telegram via the (add) triggers the merge dialog below. On the web an account can add Telegram via the
login widget or VK via VK ID web login (a full-page redirect); inside a Mini App the login widget or VK via VK ID web login (a full-page redirect). Inside a proprietary Mini
host provider is already linked, so its own row is hidden — the platform you are signed App host (VK or Telegram) only email is offeredboth provider rows and Link buttons are
in through cannot be unlinked from here (the other provider's row still shows). Email is hidden (signInProvidersVisible), a ToS requirement not to surface a competing sign-in there. Email is
never unlinked — it is changed. Unlink is offered only when another identity remains never unlinked — it is changed. Unlink is offered only when another identity remains
(the backend also refuses the last one). --> (the backend also refuses the last one). -->
<section class="accounts"> <section class="accounts">
@@ -480,7 +481,8 @@
</div> </div>
{/if} {/if}
{#if p.telegramLinked && !hostTelegram} {#if providersVisible}
{#if p.telegramLinked}
<div class="acctrow"> <div class="acctrow">
<span class="prov"><img src="telegram-logo.svg" alt="" width="18" height="18" />Telegram</span> <span class="prov"><img src="telegram-logo.svg" alt="" width="18" height="18" />Telegram</span>
{#if canUnlink} {#if canUnlink}
@@ -493,7 +495,7 @@
</button> </button>
{/if} {/if}
{#if p.vkLinked && !hostVK} {#if p.vkLinked}
<div class="acctrow"> <div class="acctrow">
<span class="prov"><img src="vk-logo.svg" alt="" width="18" height="18" />VK</span> <span class="prov"><img src="vk-logo.svg" alt="" width="18" height="18" />VK</span>
{#if canUnlink} {#if canUnlink}
@@ -505,6 +507,7 @@
<img src="vk-logo.svg" alt="" width="18" height="18" />{t('profile.linkVK')} <img src="vk-logo.svg" alt="" width="18" height="18" />{t('profile.linkVK')}
</button> </button>
{/if} {/if}
{/if}
</section> </section>
{#if !p.isGuest} {#if !p.isGuest}