feat(vk): native share/copy, auto theme, friend-code deep link, home-bar safe area #142

Merged
developer merged 4 commits from feature/vk-bridge-integration into development 2026-06-29 21:04:53 +00:00
6 changed files with 70 additions and 12 deletions
Showing only changes of commit 6a5ce12fab - Show all commits
+11 -1
View File
@@ -14,6 +14,7 @@
scroll = true, scroll = true,
growNav = false, growNav = false,
column = false, column = false,
selfInset = false,
}: { }: {
title: string; title: string;
back?: string; back?: string;
@@ -21,6 +22,10 @@
children?: Snippet; children?: Snippet;
scroll?: boolean; scroll?: boolean;
growNav?: boolean; growNav?: boolean;
// selfInset: the content paints the bottom home-indicator inset itself (a tab-bar-less screen
// whose own bottom element owns the strip, e.g. the landscape game's left-panel controls), so
// the shell does not add the detached .content padding-bottom below it.
selfInset?: boolean;
// column lays the content out as a flex column so a child can own the vertical fit // column lays the content out as a flex column so a child can own the vertical fit
// (the game makes only its board scroll while the score/rack/tab bar stay put). // (the game makes only its board scroll while the score/rack/tab bar stay put).
column?: boolean; column?: boolean;
@@ -87,7 +92,7 @@
<div class="screen"> <div class="screen">
<Header {title} {back} grow={growNav} /> <Header {title} {back} grow={growNav} />
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main> <main class="content" class:scroll class:fill={!growNav} class:column class:selfinset={selfInset}>{@render children?.()}</main>
{#if tabbar} {#if tabbar}
<nav class="tabbar">{@render tabbar()}</nav> <nav class="tabbar">{@render tabbar()}</nav>
{/if} {/if}
@@ -128,6 +133,11 @@
.content:last-child { .content:last-child {
padding-bottom: var(--tg-safe-bottom, 0px); padding-bottom: var(--tg-safe-bottom, 0px);
} }
/* selfInset: the content's own bottom element paints the home-indicator strip (the landscape
game's left-panel controls), so the shell does not add a detached padding strip below it. */
.content.selfinset:last-child {
padding-bottom: 0;
}
.tabbar { .tabbar {
flex: 0 0 auto; flex: 0 0 auto;
/* Extend the bottom bar's chrome (the TabBar's --bg-elev) under the device home indicator /* Extend the bottom bar's chrome (the TabBar's --bg-elev) under the device home indicator
+12 -2
View File
@@ -1074,6 +1074,7 @@
column column
scroll={false} scroll={false}
tabbar={landscape ? undefined : bottomBar} tabbar={landscape ? undefined : bottomBar}
selfInset={landscape}
> >
{#if view} {#if view}
{#if landscape} {#if landscape}
@@ -1738,8 +1739,10 @@
grid-template-columns: clamp(260px, 32%, 360px) 1fr; grid-template-columns: clamp(260px, 32%, 360px) 1fr;
gap: 4px; gap: 4px;
/* The left-column children carry their own horizontal --pad (matching portrait), so the /* The left-column children carry their own horizontal --pad (matching portrait), so the
grid only pads its right edge; the board centres in the right pane. */ grid only pads its right edge; the board centres in the right pane. The bottom is flush so the
padding: 4px var(--pad) 6px 0; controls bar and the board reach the screen edge — the controls bar owns the home-indicator
inset (see the .leftpane .tabbar rule), the board runs under the thin indicator. */
padding: 4px var(--pad) 0 0;
overflow: hidden; overflow: hidden;
} }
.leftpane { .leftpane {
@@ -1747,6 +1750,13 @@
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
} }
/* The home-indicator inset on the controls side: the left panel's controls bar paints its own
chrome (--bg-elev) into it so the buttons clear the cut-out (the shell's detached content strip
is suppressed here via Screen selfInset); the board pane runs to the edge under the thin
indicator. */
.game-land .leftpane :global(.tabbar) {
padding-bottom: calc(8px + var(--tg-safe-bottom, 0px));
}
.rightpane { .rightpane {
/* A size container spanning the whole right area: the board (Board.svelte's .viewport.land) /* A size container spanning the whole right area: the board (Board.svelte's .viewport.land)
fills it and fits the square board by HEIGHT via min(100cqw,100cqh); on zoom-in the board fills it and fits the square board by HEIGHT via min(100cqw,100cqh); on zoom-in the board
+10 -1
View File
@@ -32,7 +32,7 @@ import {
telegramCloudGet, telegramCloudGet,
telegramCloudSet, telegramCloudSet,
} from './telegram'; } from './telegram';
import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme } from './vk'; import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import { parseStartParam } from './deeplink'; import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
@@ -675,6 +675,15 @@ export async function bootstrap(): Promise<void> {
void vkOnScheme((scheme) => { void vkOnScheme((scheme) => {
if (app.theme === 'auto') applyTheme(scheme); if (app.theme === 'auto') applyTheme(scheme);
}); });
// Clear the device safe area from VK's insets — the VK mobile webview does not surface them via
// CSS env() (notably the Android home bar). max() keeps whichever of env() / the VK value is real.
void vkOnInsets((i) => {
const root = document.documentElement;
root.style.setProperty('--tg-safe-top', `max(env(safe-area-inset-top, 0px), ${i.top}px)`);
root.style.setProperty('--tg-safe-bottom', `max(env(safe-area-inset-bottom, 0px), ${i.bottom}px)`);
root.style.setProperty('--tg-safe-left', `max(env(safe-area-inset-left, 0px), ${i.left}px)`);
root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`);
});
await vkInit(); await vkInit();
await bootVK(); await bootVK();
app.ready = true; app.ready = true;
+2 -2
View File
@@ -45,9 +45,9 @@ describe('vkShareLink', () => {
expect(vkShareLink('f123456')).toBeNull(); expect(vkShareLink('f123456')).toBeNull();
}); });
it('wraps the payload in a vk.com/app direct link after the hash', () => { it('wraps the payload in a vk.com/app link as the hash query param', () => {
vi.stubGlobal('location', { search: '?vk_app_id=6736218&vk_user_id=1&sign=x' }); vi.stubGlobal('location', { search: '?vk_app_id=6736218&vk_user_id=1&sign=x' });
expect(vkShareLink('f123456')).toBe('https://vk.com/app6736218#f123456'); expect(vkShareLink('f123456')).toBe('https://vk.com/app6736218?hash=f123456');
}); });
}); });
+6 -6
View File
@@ -78,14 +78,14 @@ export function shareLink(param: string): string | null {
} }
/** /**
* vkShareLink wraps a deep-link start parameter in a VK Mini App direct link * vkShareLink wraps a deep-link start parameter in a VK Mini App link
* (https://vk.com/app<id>#<param>). VK forwards everything after the '#' to the app as the `hash` * (https://vk.com/app<id>?hash=<param>), read back by vk.ts vkStartParam (the `hash` query param).
* launch parameter (read back by vk.ts vkStartParam); a query string ('?') is not supported for VK * VK's documented '#' direct-link form is consumed by the vk.com SPA before it reaches the app, so
* direct links. Returns null when the VK app id is unknown (outside a VK launch), so the caller * we pass the payload as a query parameter instead. Returns null when the VK app id is unknown
* falls back to the Telegram/web link. * (outside a VK launch), so the caller falls back to the Telegram/web link.
*/ */
export function vkShareLink(param: string): string | null { export function vkShareLink(param: string): string | null {
const id = vkAppId(); const id = vkAppId();
if (!id) return null; if (!id) return null;
return `https://vk.com/app${id}#${param}`; return `https://vk.com/app${id}?hash=${encodeURIComponent(param)}`;
} }
+29
View File
@@ -131,3 +131,32 @@ export async function vkOnScheme(handler: (scheme: 'light' | 'dark') => void): P
// Outside VK / bridge unavailable: leave the app on its own theme. // Outside VK / bridge unavailable: leave the app on its own theme.
} }
} }
/** VKInsets is the device safe-area the VK client reports (px). */
export interface VKInsets {
top: number;
bottom: number;
left: number;
right: number;
}
/**
* vkOnInsets subscribes to VK's safe-area insets and calls handler with them on launch and on change.
* VK reports them via VKWebAppUpdateConfig (iOS) and the dedicated VKWebAppUpdateInsets event; the VK
* mobile webview does not expose them through CSS env() the way iOS Safari does, so the app reads them
* from the bridge to clear the home bar (notably on Android). A no-op outside VK.
*/
export async function vkOnInsets(handler: (insets: VKInsets) => void): Promise<void> {
try {
const b = await bridge();
b.subscribe((e) => {
const d = (e as { detail?: { type?: string; data?: { insets?: Partial<VKInsets> } } }).detail;
if (d?.type !== 'VKWebAppUpdateConfig' && d?.type !== 'VKWebAppUpdateInsets') return;
const i = d.data?.insets;
if (!i) return;
handler({ top: i.top ?? 0, bottom: i.bottom ?? 0, left: i.left ?? 0, right: i.right ?? 0 });
});
} catch {
// Outside VK / bridge unavailable: the CSS env() safe-area fallback applies.
}
}