diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3ea716a..916f54e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 1eb9bcf..69f6dbe 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index ee61c06..8254c5e 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -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, где браузер иначе забывал бы их при каждой +перезагрузке. На сенсорном устройстве в настройках также есть переключатель **«Приближать доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе, где доска и так помещается целиком и авто-зума нет. diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index f333135..6a484c8 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -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 { 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 { 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 { + 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); diff --git a/ui/src/lib/profileValidation.test.ts b/ui/src/lib/profileValidation.test.ts index eb87458..11c3e1c 100644 --- a/ui/src/lib/profileValidation.test.ts +++ b/ui/src/lib/profileValidation.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/profileValidation.ts b/ui/src/lib/profileValidation.ts index a7d12de..3bb8a79 100644 --- a/ui/src/lib/profileValidation.ts +++ b/ui/src/lib/profileValidation.ts @@ -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; +} diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts index b1cf75b..9ae956e 100644 --- a/ui/src/lib/vk.ts +++ b/ui/src/lib/vk.ts @@ -65,6 +65,40 @@ export async function vkClose(): Promise { } } +/** + * 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 { + 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 { + 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 diff --git a/ui/src/lib/vkprefs.test.ts b/ui/src/lib/vkprefs.test.ts new file mode 100644 index 0000000..c3b010d --- /dev/null +++ b/ui/src/lib/vkprefs.test.ts @@ -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'); + }); +}); diff --git a/ui/src/lib/vkprefs.ts b/ui/src/lib/vkprefs.ts new file mode 100644 index 0000000..47bb6fa --- /dev/null +++ b/ui/src/lib/vkprefs.ts @@ -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 { + const o = parseObject(raw); + if (!o) return {}; + const out: Partial = {}; + if (o.theme === 'auto' || o.theme === 'light' || o.theme === 'dark') out.theme = o.theme; + if (o.locale === 'en' || o.locale === 'ru') out.locale = o.locale; + if (typeof o.reduceMotion === 'boolean') out.reduceMotion = o.reduceMotion; + if (o.boardLabels === 'beginner' || o.boardLabels === 'classic' || o.boardLabels === 'none') { + out.boardLabels = o.boardLabels; + } + if (typeof o.zoomBoard === 'boolean') out.zoomBoard = o.zoomBoard; + return out; +} + +/** encodeVKOnboarding serialises the first-run coachmark completion flags. */ +export function encodeVKOnboarding(s: VKOnboarding): string { + return JSON.stringify({ lobbyDone: s.lobbyDone, gameDone: s.gameDone }); +} + +/** + * decodeVKOnboarding parses a VK Bridge storage payload into a partial VKOnboarding, keeping only the + * boolean flags and dropping anything else — a missing flag stays absent so the caller keeps its + * current value. + */ +export function decodeVKOnboarding(raw: string | null | undefined): Partial { + const o = parseObject(raw); + if (!o) return {}; + const out: Partial = {}; + if (typeof o.lobbyDone === 'boolean') out.lobbyDone = o.lobbyDone; + if (typeof o.gameDone === 'boolean') out.gameDone = o.gameDone; + return out; +} + +/** + * parseObject safely parses raw JSON into a plain object, returning null for empty, malformed or + * non-object input (including a JSON array), so every decoder above starts from a clean record. + */ +function parseObject(raw: string | null | undefined): Record | null { + if (!raw) return null; + try { + const o = JSON.parse(raw) as unknown; + return o && typeof o === 'object' && !Array.isArray(o) ? (o as Record) : null; + } catch { + return null; + } +} diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index bb7644e..317f884 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -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 @@
@@ -480,30 +481,32 @@ {/if} - {#if p.telegramLinked && !hostTelegram} -
- Telegram - {#if canUnlink} - - {/if} -
- {:else if telegramLinkable} - - {/if} + {#if providersVisible} + {#if p.telegramLinked} +
+ Telegram + {#if canUnlink} + + {/if} +
+ {:else if telegramLinkable} + + {/if} - {#if p.vkLinked && !hostVK} -
- VK - {#if canUnlink} - - {/if} -
- {:else if vkLinkable} - + {#if p.vkLinked} +
+ VK + {#if canUnlink} + + {/if} +
+ {:else if vkLinkable} + + {/if} {/if}