Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c0daeb177 |
+16
-2
@@ -47,7 +47,16 @@ Three executables plus per-platform side-services:
|
||||
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
|
||||
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
|
||||
`scrabble/platform/telegram`), split into two binaries that share the bot token
|
||||
(**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
|
||||
backend **refuses removing the last identity** (`ErrLastIdentity`), so an account
|
||||
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
|
||||
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
|
||||
|
||||
+7
-4
@@ -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
|
||||
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
|
||||
row is hidden — the platform the player is currently signed in through cannot be unlinked
|
||||
from there, only the other provider's row is shown. Linking a provider that
|
||||
back); inside a proprietary Mini App host (VK or Telegram) the player is offered **only email
|
||||
linking** — both providers' rows and add buttons are hidden there, so a competing sign-in is
|
||||
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
|
||||
linking. A linked provider can be **unlinked** — except the last
|
||||
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**
|
||||
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
|
||||
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
|
||||
per-device preference and is hidden on desktop, where the board already fits and never auto-zooms.
|
||||
|
||||
|
||||
@@ -132,9 +132,10 @@ Telegram держит **единого бота**: все игроки поль
|
||||
|
||||
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
||||
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
||||
обратно); внутри Mini App платформа-хозяин уже привязана, и её собственная плашка способа
|
||||
входа скрыта — платформу, через которую игрок сейчас вошёл, отсюда отвязать нельзя,
|
||||
показывается только плашка другого провайдера. Привязка провайдера, уже
|
||||
обратно); внутри проприетарного Mini App хоста (VK или Telegram) игроку предлагается **только
|
||||
привязка email** — плашки обоих провайдеров и кнопки добавления там скрыты, чтобы конкурирующий
|
||||
способ входа не показывался (требование ToS платформы). Провайдер, привязанный ранее в вебе,
|
||||
остаётся привязанным и управляется из веба. Привязка провайдера, уже
|
||||
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
|
||||
email. Привязанного провайдера можно **отвязать** — кроме последнего
|
||||
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
|
||||
@@ -431,7 +432,9 @@ UTC; при создании аккаунта она подставляется
|
||||
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
|
||||
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
|
||||
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
|
||||
Telegram. На сенсорном устройстве в настройках также есть переключатель **«Приближать
|
||||
Telegram. Внутри VK Mini App те же настройки — и язык интерфейса — так же запоминаются между
|
||||
сессиями, в том числе в десктопной веб-версии VK, где браузер иначе забывал бы их при каждой
|
||||
перезагрузке. На сенсорном устройстве в настройках также есть переключатель **«Приближать
|
||||
доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не
|
||||
приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе,
|
||||
где доска и так помещается целиком и авто-зума нет.
|
||||
|
||||
@@ -33,13 +33,21 @@ import {
|
||||
telegramCloudGet,
|
||||
telegramCloudSet,
|
||||
} 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 { pendingVKLink, type VKLinkCallback } from './vkid';
|
||||
import { isStandalone } from './pwa';
|
||||
import { registerServiceWorker } from './pwa.svelte';
|
||||
import { haptic } from './haptics';
|
||||
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 {
|
||||
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)`);
|
||||
});
|
||||
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
|
||||
// it off our edge-swipe-back and on-board tile drag — parity with telegramDisableVerticalSwipes.
|
||||
void vkDisableSwipeBack();
|
||||
@@ -1252,6 +1264,9 @@ export function markOnboardingDone(series: CoachSeries): void {
|
||||
if (series === 'lobby') app.onboarding.lobbyDone = true;
|
||||
else app.onboarding.gameDone = true;
|
||||
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 {
|
||||
app.onboarding = { lobbyDone: false, gameDone: false };
|
||||
void clearOnboarding();
|
||||
void vkStorageSet(VK_ONBOARDING_KEY, encodeVKOnboarding({ lobbyDone: false, gameDone: false }));
|
||||
}
|
||||
|
||||
function persistPrefs(): void {
|
||||
@@ -1278,6 +1294,19 @@ function persistPrefs(): void {
|
||||
CLOUD_PREFS_KEY,
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
app.theme = theme;
|
||||
applyTheme(theme);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
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', () => {
|
||||
it.each([
|
||||
@@ -61,3 +68,14 @@ describe('timezone helpers', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,3 +87,14 @@ export const awayHours: string[] = Array.from({ length: 24 }, (_, i) => String(i
|
||||
|
||||
/** Minute options on a 10-minute step. */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* from the signed launch params. Returns '' on any failure or outside VK, so the backend falls back
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
awayMinutes,
|
||||
browserOffset,
|
||||
isOffsetZone,
|
||||
signInProvidersVisible,
|
||||
timezoneOffsets,
|
||||
validDisplayName,
|
||||
validEmail,
|
||||
@@ -67,11 +68,11 @@
|
||||
const nativeShell = clientChannel() === 'android' || clientChannel() === 'ios';
|
||||
const telegramLinkable = loginWidgetAvailable() && !nativeShell;
|
||||
const vkLinkable = vkWebLinkAvailable() && !nativeShell;
|
||||
// Inside a host Mini App the current platform's own identity must not be managed from the
|
||||
// profile — unlinking the very platform you are signed in through is meaningless — so that
|
||||
// provider's linked-account row is hidden here (the web / native builds still show both).
|
||||
const hostTelegram = insideTelegram();
|
||||
const hostVK = insideVK();
|
||||
// Inside a proprietary Mini App host (VK or Telegram) the profile shows only email linking — the
|
||||
// competing provider entries are hidden there (a platform ToS requirement; see
|
||||
// signInProvidersVisible). A provider linked earlier on the web stays linked in the account and is
|
||||
// managed from the web.
|
||||
const providersVisible = signInProvidersVisible(insideTelegram(), insideVK());
|
||||
|
||||
function defaultTz(): string {
|
||||
const b = browserOffset();
|
||||
@@ -429,9 +430,9 @@
|
||||
|
||||
<!-- 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
|
||||
login widget or VK via VK ID web login (a full-page redirect); inside a Mini App the
|
||||
host provider is already linked, so its own row is hidden — the platform you are signed
|
||||
in through cannot be unlinked from here (the other provider's row still shows). Email is
|
||||
login widget or VK via VK ID web login (a full-page redirect). Inside a proprietary Mini
|
||||
App host (VK or Telegram) only email is offered — both provider rows and Link buttons are
|
||||
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
|
||||
(the backend also refuses the last one). -->
|
||||
<section class="accounts">
|
||||
@@ -480,30 +481,32 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if p.telegramLinked && !hostTelegram}
|
||||
<div class="acctrow">
|
||||
<span class="prov"><img src="telegram-logo.svg" alt="" width="18" height="18" />Telegram</span>
|
||||
{#if canUnlink}
|
||||
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'telegram', label: 'Telegram' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if telegramLinkable}
|
||||
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>
|
||||
<img src="telegram-logo.svg" alt="" width="18" height="18" />{t('profile.linkTelegram')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if providersVisible}
|
||||
{#if p.telegramLinked}
|
||||
<div class="acctrow">
|
||||
<span class="prov"><img src="telegram-logo.svg" alt="" width="18" height="18" />Telegram</span>
|
||||
{#if canUnlink}
|
||||
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'telegram', label: 'Telegram' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if telegramLinkable}
|
||||
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>
|
||||
<img src="telegram-logo.svg" alt="" width="18" height="18" />{t('profile.linkTelegram')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if p.vkLinked && !hostVK}
|
||||
<div class="acctrow">
|
||||
<span class="prov"><img src="vk-logo.svg" alt="" width="18" height="18" />VK</span>
|
||||
{#if canUnlink}
|
||||
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if vkLinkable}
|
||||
<button class="ghost tg" onclick={addVK} disabled={!connection.online}>
|
||||
<img src="vk-logo.svg" alt="" width="18" height="18" />{t('profile.linkVK')}
|
||||
</button>
|
||||
{#if p.vkLinked}
|
||||
<div class="acctrow">
|
||||
<span class="prov"><img src="vk-logo.svg" alt="" width="18" height="18" />VK</span>
|
||||
{#if canUnlink}
|
||||
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if vkLinkable}
|
||||
<button class="ghost tg" onclick={addVK} disabled={!connection.online}>
|
||||
<img src="vk-logo.svg" alt="" width="18" height="18" />{t('profile.linkVK')}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user