fix(vk): friend-code link as ?hash, Android safe-area via bridge insets, landscape home-bar colour
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 1m22s
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 1m22s
Contour review of the VK Bridge group: - #6 invite link: VK's documented '#' direct-link payload is eaten by the vk.com SPA before it reaches the app, so the friend-code link now carries the payload as a query param (vk.com/app<id>?hash=f<code>); the recipient already reads the `hash` query param (vkStartParam). (Whether VK forwards the '?' through to the iframe is being confirmed on the contour.) - #8 Android: the VK mobile webview does not surface the home-bar inset via CSS env() (config insets are iOS-only), so subscribe to the bridge insets (VKWebAppUpdateConfig + VKWebAppUpdateInsets) and set --tg-safe-* to max(env(), the VK value). - #8 landscape colour: the home-indicator strip was the (grey) page background because the two-pane landscape game has no bottom bar. The left-panel controls bar now paints its own chrome into the inset (Screen gains a selfInset flag that drops the shell's detached padding strip), and the game-land runs flush to the edge. Verified: svelte-check, 347 unit, build, bundle-gate; the landscape safe-area painting reproduced in the mock (controls bar + board reach the edge, strip takes the bar colour). The VK-Bridge / VK launch behaviours (Android insets, the ?hash forward) need the live contour.
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user