feat(vk): native share/copy, auto theme, friend-code deep link, home-bar safe area
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m25s

Group B of the VK integration — the contour-verified follow-up to the launch+auth MVP:

- Share/copy inside the VK iframe go through VK Bridge: the friend-code invite shares via
  VKWebAppShare and copies via VKWebAppCopyText, since navigator.share is absent in the desktop
  iframe and navigator.clipboard is blocked there.
- The invite link is a VK Mini App direct link (vk.com/app<id>#f<code>) on VK instead of the
  Telegram link; the app id comes from vk_app_id in the launch params (no build arg needed). The
  recipient's launch routes the deep link from VK's `hash` launch query parameter.
- The app's "auto" theme follows the VK client's light/dark appearance (VKWebAppUpdateConfig),
  which the VK mobile webview's prefers-color-scheme does not track.
- The safe-area CSS vars default to env(safe-area-inset-*), so the VK mobile layout clears the
  home bar (and Capacitor/PWA too); Telegram still overrides them from its SDK.

vk.ts adds vkAppId/vkStartParam/vkShare/vkCopyText/vkOnScheme. Verified: svelte-check, 347 unit
(+ vkAppId/vkStartParam/vkShareLink), build, bundle-gate. The VK-Bridge behaviours need the live
contour (not reproducible headless).
This commit is contained in:
Ilia Denisov
2026-06-29 21:27:36 +02:00
parent 303348ed39
commit da17c18895
10 changed files with 178 additions and 26 deletions
+10 -8
View File
@@ -46,14 +46,16 @@
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
content-safe-area inset, 0 elsewhere. */
--tg-content-top: 0px;
/* Telegram device safe-area top (the notch); TG's own nav controls sit between it and
--tg-content-top, so the in-app header aligns to that band, 0 elsewhere. */
--tg-safe-top: 0px;
/* Telegram device safe-area bottom / sides (home indicator; landscape notch), 0 elsewhere —
the screen pads its bottom and left/right edges by these so content clears the cut-outs. */
--tg-safe-bottom: 0px;
--tg-safe-left: 0px;
--tg-safe-right: 0px;
/* Device safe-area top (the notch); inside Telegram TG's own nav controls sit between it and
--tg-content-top, so the in-app header aligns to that band. Inside Telegram these are set from
the SDK (lib/app.svelte.ts); elsewhere they fall back to the CSS env() safe area, so a VK Mini
App / Capacitor / PWA webview clears the device cut-outs too (a plain browser tab reports 0). */
--tg-safe-top: env(safe-area-inset-top, 0px);
/* Device safe-area bottom / sides (home indicator; landscape notch) — the screen pads its bottom
and left/right edges by these so content clears the cut-outs (e.g. the VK mobile home bar). */
--tg-safe-bottom: env(safe-area-inset-bottom, 0px);
--tg-safe-left: env(safe-area-inset-left, 0px);
--tg-safe-right: env(safe-area-inset-right, 0px);
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", "Liberation Sans", sans-serif;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
+8 -1
View File
@@ -32,7 +32,7 @@ import {
telegramCloudGet,
telegramCloudSet,
} from './telegram';
import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName } from './vk';
import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme } from './vk';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
@@ -670,6 +670,11 @@ export async function bootstrap(): Promise<void> {
// VKWebAppGetUserInfo, since VK omits it from the signed params. The /vk/ entry opened outside VK
// (no signed params — e.g. a developer hitting the URL directly) falls through to the web flow.
if (onVKPath() && insideVK()) {
// Follow the VK client's light/dark appearance while the user keeps the app's "auto" theme (the
// VK mobile webview's prefers-color-scheme does not track it).
void vkOnScheme((scheme) => {
if (app.theme === 'auto') applyTheme(scheme);
});
await vkInit();
await bootVK();
app.ready = true;
@@ -737,6 +742,8 @@ async function bootVK(): Promise<void> {
for (let attempt = 0; ; attempt++) {
try {
await adoptSession(await gateway.authVK(params, displayName));
// A VK direct-link deep link (vk.com/app<id>#<param>) rides in as the `hash` launch param.
if (!app.blocked) await routeStartParam(vkStartParam());
app.bootError = false;
return;
} catch (err) {
+15 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink';
import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink, vkShareLink } from './deeplink';
describe('parseStartParam', () => {
it('classifies game / invitation / friend code', () => {
@@ -37,6 +37,20 @@ describe('shareLink', () => {
});
});
describe('vkShareLink', () => {
afterEach(() => vi.unstubAllGlobals());
it('returns null outside a VK launch (no vk_app_id)', () => {
vi.stubGlobal('location', { search: '' });
expect(vkShareLink('f123456')).toBeNull();
});
it('wraps the payload in a vk.com/app direct link after the hash', () => {
vi.stubGlobal('location', { search: '?vk_app_id=6736218&vk_user_id=1&sign=x' });
expect(vkShareLink('f123456')).toBe('https://vk.com/app6736218#f123456');
});
});
describe('botUsername', () => {
afterEach(() => vi.unstubAllEnvs());
+15
View File
@@ -6,6 +6,8 @@
// f<6-digit code> redeem that friend code
// An empty or unrecognised parameter opens the lobby.
import { vkAppId } from './vk';
export type DeepLink =
| { kind: 'lobby' }
| { kind: 'game'; id: string }
@@ -74,3 +76,16 @@ export function shareLink(param: string): string | null {
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}startapp=${encodeURIComponent(param)}`;
}
/**
* vkShareLink wraps a deep-link start parameter in a VK Mini App direct link
* (https://vk.com/app<id>#<param>). VK forwards everything after the '#' to the app as the `hash`
* launch parameter (read back by vk.ts vkStartParam); a query string ('?') is not supported for VK
* direct links. Returns null when the VK app id is unknown (outside a VK launch), so the caller
* falls back to the Telegram/web link.
*/
export function vkShareLink(param: string): string | null {
const id = vkAppId();
if (!id) return null;
return `https://vk.com/app${id}#${param}`;
}
+21 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { insideVK, onVKPath, vkLaunchParams } from './vk';
import { insideVK, onVKPath, vkAppId, vkLaunchParams, vkStartParam } from './vk';
describe('vk launch detection', () => {
afterEach(() => vi.unstubAllGlobals());
@@ -29,3 +29,23 @@ describe('vk launch detection', () => {
expect(insideVK()).toBe(false);
});
});
describe('vk launch params', () => {
afterEach(() => vi.unstubAllGlobals());
it('reads the VK app id from the launch query', () => {
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_app_id=6736218&sign=x' });
expect(vkAppId()).toBe('6736218');
});
it('reads the direct-link deep-link payload from the hash query param', () => {
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&sign=x&hash=f123456' });
expect(vkStartParam()).toBe('f123456');
});
it('returns empty app id / start param when absent', () => {
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&sign=x' });
expect(vkAppId()).toBe('');
expect(vkStartParam()).toBe('');
});
});
+67
View File
@@ -64,3 +64,70 @@ export async function vkUserName(): Promise<string> {
return '';
}
}
/**
* vkAppId returns the launching VK app id (vk_app_id) from the signed launch parameters in the URL —
* so the app can build its own vk.com/app<id> links without a VK API call — or '' outside a VK launch.
*/
export function vkAppId(): string {
if (typeof location === 'undefined') return '';
return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_app_id') ?? '';
}
/**
* vkStartParam returns the VK direct-link deep-link payload. VK passes everything after the '#' in a
* vk.com/app<id>#<payload> link to the app as the `hash` query parameter (it is also in location.hash,
* but that collides with the app's hash router, so the query parameter is the safe source). Empty when
* the launch carried no deep link.
*/
export function vkStartParam(): string {
if (typeof location === 'undefined') return '';
return new URLSearchParams(location.search.replace(/^\?/, '')).get('hash') ?? '';
}
/**
* vkShare opens VK's native share dialog for link (VKWebAppShare) — the in-iframe replacement for
* navigator.share, which is unavailable in the desktop VK iframe. Resolves true when the share was
* handled, false on any failure or outside VK.
*/
export async function vkShare(link: string): Promise<boolean> {
try {
await (await bridge()).send('VKWebAppShare', { link });
return true;
} catch {
return false;
}
}
/**
* vkCopyText copies text to the clipboard via VKWebAppCopyText — which works inside the VK iframe,
* where navigator.clipboard is blocked. Resolves true on success, false on any failure or outside VK.
*/
export async function vkCopyText(text: string): Promise<boolean> {
try {
await (await bridge()).send('VKWebAppCopyText', { text });
return true;
} catch {
return false;
}
}
/**
* vkOnScheme subscribes to VK's appearance (VKWebAppUpdateConfig) and calls handler with the mapped
* 'light' | 'dark' scheme on launch and whenever the user switches the VK client theme — so the app's
* "auto" theme can follow the VK client instead of the (often wrong) webview prefers-color-scheme.
* A no-op outside VK.
*/
export async function vkOnScheme(handler: (scheme: 'light' | 'dark') => void): Promise<void> {
try {
const b = await bridge();
b.subscribe((e) => {
const detail = (e as { detail?: { type?: string; data?: { scheme?: string; appearance?: string } } }).detail;
if (detail?.type !== 'VKWebAppUpdateConfig') return;
const scheme = detail.data?.scheme ?? detail.data?.appearance ?? '';
handler(/dark|space_gray/i.test(scheme) ? 'dark' : 'light');
});
} catch {
// Outside VK / bridge unavailable: leave the app on its own theme.
}
}
+18 -4
View File
@@ -6,8 +6,9 @@
import { gateway } from '../lib/gateway';
import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte';
import { friendCodeParam, shareLink } from '../lib/deeplink';
import { friendCodeParam, shareLink, vkShareLink } from '../lib/deeplink';
import { insideTelegram, shareTelegramLink, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
import { insideVK, vkCopyText, vkShare } from '../lib/vk';
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
let friends = $state<AccountRef[]>([]);
@@ -140,6 +141,11 @@
async function copyCode() {
if (!code) return;
// Inside the VK iframe navigator.clipboard is blocked, so copy via VK Bridge there.
if (insideVK()) {
if (await vkCopyText(code.code)) showToast(t('friends.codeCopied'));
return;
}
try {
await navigator.clipboard.writeText(code.code);
showToast(t('friends.codeCopied'));
@@ -153,6 +159,13 @@
// copies the link to the clipboard.
async function shareInvite(url: string) {
const text = t('friends.inviteText');
// Inside VK navigator.share is absent (desktop iframe) and the clipboard is blocked: share via VK
// Bridge, falling back to a VK-Bridge clipboard copy if the share is dismissed or unavailable.
if (insideVK()) {
if (await vkShare(url)) return;
if (await vkCopyText(url)) showToast(t('friends.linkCopied'));
return;
}
if (shareTelegramLink(url, text)) return;
if (typeof navigator !== 'undefined' && navigator.share) {
try {
@@ -188,7 +201,8 @@
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
</div>
{#if code}
{@const tg = shareLink(friendCodeParam(code.code))}
{@const sharePayload = friendCodeParam(code.code)}
{@const shareUrl = insideVK() ? vkShareLink(sharePayload) : shareLink(sharePayload)}
<div class="code" data-testid="friend-code">
<div class="coderow">
<button class="codeval" onclick={copyCode}>{code.code}</button>
@@ -197,8 +211,8 @@
<span class="codehint">
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span>
{#if tg}
<button type="button" class="link" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
{#if shareUrl}
<button type="button" class="link" onclick={() => shareInvite(shareUrl)}>{t('friends.shareTelegram')}</button>
{/if}
</div>
{:else}