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
|
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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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, где браузер иначе забывал бы их при каждой
|
||||||
|
перезагрузке. На сенсорном устройстве в настройках также есть переключатель **«Приближать
|
||||||
доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не
|
доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не
|
||||||
приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе,
|
приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе,
|
||||||
где доска и так помещается целиком и авто-зума нет.
|
где доска и так помещается целиком и авто-зума нет.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
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 offered — both 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,30 +481,32 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if p.telegramLinked && !hostTelegram}
|
{#if providersVisible}
|
||||||
<div class="acctrow">
|
{#if p.telegramLinked}
|
||||||
<span class="prov"><img src="telegram-logo.svg" alt="" width="18" height="18" />Telegram</span>
|
<div class="acctrow">
|
||||||
{#if canUnlink}
|
<span class="prov"><img src="telegram-logo.svg" alt="" width="18" height="18" />Telegram</span>
|
||||||
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'telegram', label: 'Telegram' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
{#if canUnlink}
|
||||||
{/if}
|
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'telegram', label: 'Telegram' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
||||||
</div>
|
{/if}
|
||||||
{:else if telegramLinkable}
|
</div>
|
||||||
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>
|
{:else if telegramLinkable}
|
||||||
<img src="telegram-logo.svg" alt="" width="18" height="18" />{t('profile.linkTelegram')}
|
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>
|
||||||
</button>
|
<img src="telegram-logo.svg" alt="" width="18" height="18" />{t('profile.linkTelegram')}
|
||||||
{/if}
|
</button>
|
||||||
|
{/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}
|
||||||
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if vkLinkable}
|
{:else if vkLinkable}
|
||||||
<button class="ghost tg" onclick={addVK} disabled={!connection.online}>
|
<button class="ghost tg" onclick={addVK} disabled={!connection.online}>
|
||||||
<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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user