Merge pull request 'feat(vk): native share/copy, auto theme, friend-code deep link, home-bar safe area' (#142) from feature/vk-bridge-integration into development
This commit was merged in pull request #142.
This commit is contained in:
@@ -77,14 +77,28 @@ incl. the `%2C` comma case for `vk_access_token_settings`).
|
||||
- `VKWebAppGetLaunchParams` — parsed `vk_*` **without** `sign` (so NOT usable for our server
|
||||
verification — read `window.location.search` instead, which carries `sign`).
|
||||
- `VKWebAppGetAuthToken` — OAuth access token for VK API calls (only if we ever call VK API).
|
||||
- `VKWebAppShare` — native share dialog (deferred).
|
||||
- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile).
|
||||
- `VKWebAppUpdateConfig` (subscribe) — light/dark scheme + theme changes.
|
||||
- `VKWebAppShare` — native share dialog (the friend-code invite uses it; `navigator.share` is absent
|
||||
in the desktop VK iframe). **Used.**
|
||||
- `VKWebAppCopyText` — clipboard copy that works inside the VK iframe, where `navigator.clipboard` is
|
||||
blocked. **Used** as the copy-code / copy-link path.
|
||||
- `VKWebAppUpdateConfig` (subscribe) — light/dark scheme; the app follows it while the theme pref is
|
||||
"auto" (the VK webview's prefers-color-scheme does not track it). **Used.**
|
||||
- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile); not used —
|
||||
the bottom home-bar safe area is handled by CSS `env(safe-area-inset-*)` (viewport-fit=cover).
|
||||
|
||||
The bridge talks to the embedding VK client over postMessage; it is NOT an external fetch, so
|
||||
it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import
|
||||
it **lazily** so the pure URL helpers stay node-test-importable.
|
||||
|
||||
**Deep links — NOT possible on VK (confirmed on the contour).** The VK iframe receives ONLY the signed
|
||||
`vk_*` launch params (+ `sign`); VK strips any custom data from the app link. The documented
|
||||
`vk.com/app<id>#<payload>` form is eaten by the vk.com SPA (which owns the URL hash), and a
|
||||
`vk.com/app<id>?hash=<payload>` query is dropped (the diagnostic showed `rawSearch` with only `vk_*`
|
||||
and an empty `hash`). So the friend-code invite link is just `vk.com/app<id>` (`vkShareLink`, app id
|
||||
from `vk_app_id`); the recipient enters the **copied code by hand** (`VKWebAppCopyText` works). The
|
||||
`vkStartParam` reader + the `bootVK` routing stay as a no-op today, ready if a post-moderation VK
|
||||
channel (e.g. an invite API) ever delivers a payload.
|
||||
|
||||
## 5. Test mode (to verify before moderation)
|
||||
|
||||
1. App already registered (we have the App ID).
|
||||
@@ -138,15 +152,17 @@ without VK-specific code. Theme/viewport fine-tuning is best verified live in th
|
||||
- **Backend**: `internal/account` `KindVK` + `ProvisionVK`/`vkSeed` + `confirmed` for platform
|
||||
kinds; `internal/server/handlers_auth.go` `handleVKAuth` + route; migration
|
||||
`00005_vk_identity.sql` (widen `identities_kind_chk` to include `'vk'`, expand-contract).
|
||||
- **UI**: `src/lib/vk.ts` (`onVKPath`/`vkLaunchParams`/`insideVK`/`vkInit`/`vkUserName`),
|
||||
`app.svelte.ts` `bootVK` + the `/vk/` dispatch branch + shared `retryMiniAppBoot`,
|
||||
`codec.ts` `encodeVKLogin`, `transport.ts`/`client.ts`/`mock/client.ts` `authVK`.
|
||||
- **UI**: `src/lib/vk.ts` (`onVKPath`/`vkLaunchParams`/`insideVK`/`vkInit`/`vkUserName` plus `vkAppId`/
|
||||
`vkStartParam`/`vkShare`/`vkCopyText`/`vkOnScheme`), `app.svelte.ts` `bootVK` (+ deep-link routing
|
||||
and VK scheme→theme) + the `/vk/` dispatch branch + shared `retryMiniAppBoot`, `codec.ts`
|
||||
`encodeVKLogin`, `transport.ts`/`client.ts`/`mock/client.ts` `authVK`, `deeplink.ts` `vkShareLink`,
|
||||
`Friends.svelte` (VK share/copy), `app.css` `--tg-safe-*` defaulting to `env(safe-area-inset-*)`.
|
||||
- **Edge/deploy**: `deploy/caddy/Caddyfile` `/vk` path; `GATEWAY_VK_APP_SECRET` in
|
||||
`docker-compose.yml` + `.env.example` + `ci.yaml` (`TEST_…` secret) + `prod-deploy.yaml`
|
||||
(`PROD_…` secret, deploy-main).
|
||||
- **Deferred** (not in the test-mode MVP): `VKWebAppShare`, deep-links (`vk_ref`/startapp),
|
||||
VK theme/viewport forcing, `VITE_VK_APP_ID` build arg, payments, account-linking a vk identity
|
||||
to an existing account.
|
||||
- **Deferred**: payments (VK Pay / votes), native (Capacitor) VK, account-linking a vk identity to an
|
||||
existing account, VK push. (Done after the launch+auth MVP — Group B: native share + clipboard via
|
||||
the bridge, the friend-code deep link, the auto-theme follow, and the home-bar safe area.)
|
||||
|
||||
## Sources
|
||||
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ and any incidental perpendicular words are ignored and not scored — while on i
|
||||
standard Scrabble. English games are always standard and show no such toggle. In auto-match
|
||||
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) are
|
||||
formed by inviting players from the friend list (an invitation, like a friend code,
|
||||
is shareable as a Telegram deep link that opens it directly): the inviter chooses the
|
||||
is shareable as a Telegram or VK deep link that opens it directly): the inviter chooses the
|
||||
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||||
expires after seven days.
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ _Вход сейчас только через провайдера, поэто
|
||||
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
|
||||
кто выбрал то же правило. Игры с друзьями (2–4)
|
||||
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
||||
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
|
||||
можно отправить deep-link'ом в Telegram или VK, который откроет его сразу): инициатор
|
||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||
ответа приглашение протухает через семь дней.
|
||||
|
||||
|
||||
+10
-8
@@ -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);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
scroll = true,
|
||||
growNav = false,
|
||||
column = false,
|
||||
selfInset = false,
|
||||
}: {
|
||||
title: string;
|
||||
back?: string;
|
||||
@@ -21,6 +22,10 @@
|
||||
children?: Snippet;
|
||||
scroll?: 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
|
||||
// (the game makes only its board scroll while the score/rack/tab bar stay put).
|
||||
column?: boolean;
|
||||
@@ -87,7 +92,7 @@
|
||||
|
||||
<div class="screen">
|
||||
<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}
|
||||
<nav class="tabbar">{@render tabbar()}</nav>
|
||||
{/if}
|
||||
@@ -128,6 +133,11 @@
|
||||
.content:last-child {
|
||||
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 {
|
||||
flex: 0 0 auto;
|
||||
/* Extend the bottom bar's chrome (the TabBar's --bg-elev) under the device home indicator
|
||||
|
||||
+12
-2
@@ -1074,6 +1074,7 @@
|
||||
column
|
||||
scroll={false}
|
||||
tabbar={landscape ? undefined : bottomBar}
|
||||
selfInset={landscape}
|
||||
>
|
||||
{#if view}
|
||||
{#if landscape}
|
||||
@@ -1738,8 +1739,10 @@
|
||||
grid-template-columns: clamp(260px, 32%, 360px) 1fr;
|
||||
gap: 4px;
|
||||
/* 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. */
|
||||
padding: 4px var(--pad) 6px 0;
|
||||
grid only pads its right edge; the board centres in the right pane. The bottom is flush so the
|
||||
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;
|
||||
}
|
||||
.leftpane {
|
||||
@@ -1747,6 +1750,13 @@
|
||||
flex-direction: column;
|
||||
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 {
|
||||
/* 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
|
||||
|
||||
@@ -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, vkOnInsets } 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,20 @@ 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);
|
||||
});
|
||||
// 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 bootVK();
|
||||
app.ready = true;
|
||||
@@ -737,6 +751,12 @@ async function bootVK(): Promise<void> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await adoptSession(await gateway.authVK(params, displayName));
|
||||
// VK forwards only the signed vk_* params to the iframe — a custom payload on the app link
|
||||
// (whether after '#', eaten by the vk.com SPA, or as a '?' query, dropped) never reaches it,
|
||||
// confirmed on the contour. So there is no friend-code auto-redeem on VK in test mode: the
|
||||
// launch lands on the lobby. vkStartParam stays as the reader for a future (post-moderation)
|
||||
// deep-link channel, and routes the lobby for an empty payload today.
|
||||
if (!app.blocked) await routeStartParam(vkStartParam());
|
||||
app.bootError = false;
|
||||
return;
|
||||
} catch (err) {
|
||||
|
||||
@@ -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()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the plain vk.com/app link (VK drops a custom query payload, so the code is not carried)', () => {
|
||||
vi.stubGlobal('location', { search: '?vk_app_id=6736218&vk_user_id=1&sign=x' });
|
||||
expect(vkShareLink()).toBe('https://vk.com/app6736218');
|
||||
});
|
||||
});
|
||||
|
||||
describe('botUsername', () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
|
||||
@@ -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 returns the VK Mini App link (https://vk.com/app<id>) to share on VK, or null when the
|
||||
* VK app id is unknown (outside a VK launch). VK forwards no custom payload through the app link to
|
||||
* the iframe — the '#' form is eaten by the vk.com SPA and a '?' query is dropped (confirmed on the
|
||||
* contour: only the signed vk_* params arrive) — so the link cannot carry the friend code; the
|
||||
* recipient enters the copied code by hand.
|
||||
*/
|
||||
export function vkShareLink(): string | null {
|
||||
const id = vkAppId();
|
||||
if (!id) return null;
|
||||
return `https://vk.com/app${id}`;
|
||||
}
|
||||
|
||||
+21
-1
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,3 +64,99 @@ 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.
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 @@
|
||||
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
|
||||
</div>
|
||||
{#if code}
|
||||
{@const tg = shareLink(friendCodeParam(code.code))}
|
||||
{@const shareUrl = insideVK() ? vkShareLink() : shareLink(friendCodeParam(code.code))}
|
||||
<div class="code" data-testid="friend-code">
|
||||
<div class="coderow">
|
||||
<button class="codeval" onclick={copyCode}>{code.code}</button>
|
||||
@@ -197,8 +210,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}
|
||||
|
||||
Reference in New Issue
Block a user