Compare commits
8 Commits
e6277dcd43
..
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
+8
-10
@@ -97,16 +97,14 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
|
|||||||
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
|
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
|
||||||
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
|
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
|
||||||
`setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
|
`setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
|
||||||
app's **own back chevron** (Header) drives back-navigation on every platform — the native
|
native header **BackButton** drives back-navigation (the app's chevron is hidden in
|
||||||
Telegram BackButton is not used, as it does not render reliably in the windowed Mini App;
|
Telegram); **HapticFeedback** fires on tile placement / commit / error; on **mobile**
|
||||||
**HapticFeedback** fires on tile placement / commit / error; the app calls `expand()` for the
|
clients the app enters **immersive fullscreen** on launch (`requestFullscreen`, Bot API
|
||||||
bot's full-size (max-height) window but **never `requestFullscreen`** — immersive fullscreen hid
|
8.0+) like Telegram's own Mini Apps, while desktop keeps the bot's full-size window;
|
||||||
the native header (and its BackButton) and the Android system swipe-back then minimised the app,
|
**closing confirmation** is enabled while a game is open **on mobile only** (on desktop
|
||||||
so it stays windowed with Telegram's thin native header (close) above the app's own header; move
|
closing is deliberate and the "changes may not be saved" dialog is just noise — move drafts
|
||||||
drafts auto-save, so there is **no closing-confirmation guard**; a hidden **debug panel** (ten
|
auto-save); **vertical swipes** (swipe-to-minimise)
|
||||||
quick taps on the header title) shows and shares a privacy-safe client diagnostic snapshot for
|
are disabled so they don't fight tile drag or the board scroll; **external links** (the word-check
|
||||||
support; **vertical swipes** (swipe-to-minimise) are disabled so they don't fight tile drag or
|
|
||||||
the board scroll; **external links** (the word-check
|
|
||||||
dictionary lookup, the rules link, operator-reply links) open through `Telegram.WebApp.openLink`
|
dictionary lookup, the rules link, operator-reply links) open through `Telegram.WebApp.openLink`
|
||||||
so Telegram shows them in its in-app browser instead of the WebView's "open this link?"
|
so Telegram shows them in its in-app browser instead of the WebView's "open this link?"
|
||||||
confirmation a plain `target=_blank` triggers; and a live stream dropped
|
confirmation a plain `target=_blank` triggers; and a live stream dropped
|
||||||
|
|||||||
+15
-6
@@ -2,13 +2,13 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { app, bootstrap } from './lib/app.svelte';
|
import { app, bootstrap } from './lib/app.svelte';
|
||||||
import { router, type RouteName } from './lib/router.svelte';
|
import { navigate, router, type RouteName } from './lib/router.svelte';
|
||||||
import { t } from './lib/i18n/index.svelte';
|
import { t } from './lib/i18n/index.svelte';
|
||||||
|
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
||||||
import Toast from './components/Toast.svelte';
|
import Toast from './components/Toast.svelte';
|
||||||
import Splash from './components/Splash.svelte';
|
import Splash from './components/Splash.svelte';
|
||||||
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
||||||
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||||
import DebugPanel from './components/DebugPanel.svelte';
|
|
||||||
import Login from './screens/Login.svelte';
|
import Login from './screens/Login.svelte';
|
||||||
import Lobby from './screens/Lobby.svelte';
|
import Lobby from './screens/Lobby.svelte';
|
||||||
import NewGame from './screens/NewGame.svelte';
|
import NewGame from './screens/NewGame.svelte';
|
||||||
@@ -30,6 +30,19 @@
|
|||||||
// another screen is not covered.
|
// another screen is not covered.
|
||||||
const routeIsLobby = $derived(router.route.name === 'lobby');
|
const routeIsLobby = $derived(router.route.name === 'lobby');
|
||||||
|
|
||||||
|
// Inside Telegram, drive its native header back button: show it on any sub-screen
|
||||||
|
// (everything returns to the lobby root), hide it on the lobby/login. The app's own
|
||||||
|
// back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
|
||||||
|
$effect(() => {
|
||||||
|
if (!insideTelegram()) return;
|
||||||
|
const r = router.route;
|
||||||
|
// The chat / check sub-screens step back to their game; every other sub-screen to the lobby.
|
||||||
|
let target = '/';
|
||||||
|
if (r.name === 'gameChat' || r.name === 'gameCheck') target = `/game/${r.params.id}`;
|
||||||
|
else if (r.name === 'feedback') target = '/about'; // back to the Settings → Info tab
|
||||||
|
telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target));
|
||||||
|
});
|
||||||
|
|
||||||
// Screen transitions: the lobby is the navigation root. Entering a screen from the
|
// Screen transitions: the lobby is the navigation root. Entering a screen from the
|
||||||
// lobby slides it in from the right (forward); returning to the lobby slides the
|
// lobby slides it in from the right (forward); returning to the lobby slides the
|
||||||
// screen out to the right and reveals the lobby (back). Transitions are local, so
|
// screen out to the right and reveals the lobby (back). Transitions are local, so
|
||||||
@@ -125,10 +138,6 @@
|
|||||||
<Splash />
|
<Splash />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if app.debugOpen}
|
|
||||||
<DebugPanel />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.splash {
|
.splash {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
// Hidden on-device debug panel, opened by tapping the header title ten times (Header.svelte) and
|
|
||||||
// closed by tapping anywhere except the Share control. It shows a privacy-safe client diagnostic
|
|
||||||
// snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a
|
|
||||||
// clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the
|
|
||||||
// Telegram Android presentation quirks. Drawn from the top, just under the app header.
|
|
||||||
import { app, closeDebug } from '../lib/app.svelte';
|
|
||||||
import { connection } from '../lib/connection.svelte';
|
|
||||||
import { shareText } from '../lib/share';
|
|
||||||
import { telegramChromeDiag } from '../lib/telegram';
|
|
||||||
|
|
||||||
const report = [
|
|
||||||
`app: ${__APP_VERSION__}`,
|
|
||||||
`locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`,
|
|
||||||
`online: ${connection.online} streamAlive: ${app.streamAlive}`,
|
|
||||||
`userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`,
|
|
||||||
telegramChromeDiag(),
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
let label = $state('Share');
|
|
||||||
async function share(e: MouseEvent): Promise<void> {
|
|
||||||
e.stopPropagation(); // a tap on Share shares; it must not also close the panel
|
|
||||||
const r = await shareText(report, `Scrabble debug ${__APP_VERSION__}`);
|
|
||||||
if (r === 'copied') {
|
|
||||||
label = 'Copied';
|
|
||||||
setTimeout(() => (label = 'Share'), 1500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div class="overlay" onclick={closeDebug}>
|
|
||||||
<button class="share" onclick={share}>{label}</button>
|
|
||||||
<pre class="body">{report}</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 10000;
|
|
||||||
/* Drawn from the top; the content clears the app header (~56px + the device safe-area). */
|
|
||||||
padding: calc(var(--tg-safe-top, 0px) + 56px) 12px 16px;
|
|
||||||
background: rgba(0, 0, 0, 0.82);
|
|
||||||
overflow: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.share {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 7px 16px;
|
|
||||||
border: 1px solid var(--accent);
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-text);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.body {
|
|
||||||
margin: 0;
|
|
||||||
width: 100%;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: #d6e6ff;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,30 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
|
import { insideTelegram } from '../lib/telegram';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { app, openDebug } from '../lib/app.svelte';
|
import { app } from '../lib/app.svelte';
|
||||||
import Spinner from './Spinner.svelte';
|
import Spinner from './Spinner.svelte';
|
||||||
import AdBanner from './AdBanner.svelte';
|
import AdBanner from './AdBanner.svelte';
|
||||||
|
|
||||||
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
|
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
|
||||||
|
|
||||||
// The app always shows its own back chevron when there is a back target — on every platform, in
|
// Inside Telegram the native header back button (App.svelte) is the back control, so
|
||||||
// and out of Telegram. The native Telegram BackButton is not used: it does not render reliably in
|
// the app's own chevron is hidden to avoid two back affordances.
|
||||||
// the windowed Mini App (relying on it would lose back navigation there).
|
const showBack = $derived(!!back && !insideTelegram());
|
||||||
const showBack = $derived(!!back);
|
|
||||||
|
|
||||||
// Ten quick taps on the title open the hidden debug panel (components/DebugPanel) — a support aid.
|
|
||||||
let titleTaps = 0;
|
|
||||||
let lastTitleTap = 0;
|
|
||||||
function onTitleTap(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
titleTaps = now - lastTitleTap < 400 ? titleTaps + 1 : 1;
|
|
||||||
lastTitleTap = now;
|
|
||||||
if (titleTaps >= 10) {
|
|
||||||
titleTaps = 0;
|
|
||||||
openDebug();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="nav" class:grow>
|
<header class="nav" class:grow>
|
||||||
@@ -37,9 +24,7 @@
|
|||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if connection.online}
|
{#if connection.online}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<h1>{title}</h1>
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
||||||
<h1 onclick={onTitleTap}>{title}</h1>
|
|
||||||
{:else}
|
{:else}
|
||||||
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
|
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||||
import { patchLobbyGame } from '../lib/lobbycache';
|
import { patchLobbyGame } from '../lib/lobbycache';
|
||||||
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
||||||
import { telegramHaptic } from '../lib/telegram';
|
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
|
||||||
import {
|
import {
|
||||||
BLANK,
|
BLANK,
|
||||||
newPlacement,
|
newPlacement,
|
||||||
@@ -228,6 +228,8 @@
|
|||||||
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
|
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
|
||||||
}
|
}
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
// Guard against an accidental swipe-close losing the open game (Telegram).
|
||||||
|
telegramClosingConfirmation(true);
|
||||||
// Render instantly from the cache (a game opened before), then refresh in the
|
// Render instantly from the cache (a game opened before), then refresh in the
|
||||||
// background. A cold open shows the loading state until load() resolves.
|
// background. A cold open shows the loading state until load() resolves.
|
||||||
const cached = getCachedGame(id);
|
const cached = getCachedGame(id);
|
||||||
@@ -577,6 +579,7 @@
|
|||||||
clearTimeout(draftSaveTimer);
|
clearTimeout(draftSaveTimer);
|
||||||
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
telegramClosingConfirmation(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
function onCell(row: number, col: number) {
|
function onCell(row: number, col: number) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
telegramLaunch,
|
telegramLaunch,
|
||||||
type TelegramLaunch,
|
type TelegramLaunch,
|
||||||
telegramOnEvent,
|
telegramOnEvent,
|
||||||
|
telegramRequestFullscreen,
|
||||||
telegramSetChrome,
|
telegramSetChrome,
|
||||||
} from './telegram';
|
} from './telegram';
|
||||||
import { parseStartParam } from './deeplink';
|
import { parseStartParam } from './deeplink';
|
||||||
@@ -55,9 +56,6 @@ export const app = $state<{
|
|||||||
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
|
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
|
||||||
* delivered no initData (seen on some Android clients) — instead of bouncing to the landing. */
|
* delivered no initData (seen on some Android clients) — instead of bouncing to the landing. */
|
||||||
launchError: TelegramDiag | null;
|
launchError: TelegramDiag | null;
|
||||||
/** Whether the hidden on-device debug panel (components/DebugPanel) is open — toggled by tapping
|
|
||||||
* the header title ten times in quick succession. A support aid; carries no secrets. */
|
|
||||||
debugOpen: boolean;
|
|
||||||
/** Whether the lobby's first cold load has settled (success or error). The loading splash
|
/** Whether the lobby's first cold load has settled (success or error). The loading splash
|
||||||
* (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
|
* (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
|
||||||
lobbyReady: boolean;
|
lobbyReady: boolean;
|
||||||
@@ -108,7 +106,6 @@ export const app = $state<{
|
|||||||
ready: false,
|
ready: false,
|
||||||
bootError: false,
|
bootError: false,
|
||||||
launchError: null,
|
launchError: null,
|
||||||
debugOpen: false,
|
|
||||||
lobbyReady: false,
|
lobbyReady: false,
|
||||||
splashDone: false,
|
splashDone: false,
|
||||||
streamAlive: false,
|
streamAlive: false,
|
||||||
@@ -200,16 +197,6 @@ export function dismissWelcomeRedeem(): void {
|
|||||||
app.welcomeRedeem = false;
|
app.welcomeRedeem = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** openDebug / closeDebug toggle the hidden on-device debug panel (components/DebugPanel), opened
|
|
||||||
* by tapping the header title ten times — a support aid that shows and shares client diagnostics. */
|
|
||||||
export function openDebug(): void {
|
|
||||||
app.debugOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closeDebug(): void {
|
|
||||||
app.debugOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* seedChatUnread sets a game's unread flags from an authoritative per-viewer REST view (the lobby
|
* seedChatUnread sets a game's unread flags from an authoritative per-viewer REST view (the lobby
|
||||||
* list, a game's state, or a move result): unread is any unread entry, message whether one of them
|
* list, a game's state, or a move result): unread is any unread entry, message whether one of them
|
||||||
@@ -570,6 +557,10 @@ function applyTelegramChrome(launch: TelegramLaunch): void {
|
|||||||
syncTelegramChrome();
|
syncTelegramChrome();
|
||||||
syncTelegramSafeArea();
|
syncTelegramSafeArea();
|
||||||
telegramDisableVerticalSwipes();
|
telegramDisableVerticalSwipes();
|
||||||
|
// On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged
|
||||||
|
// listener (registered at bootstrap) then re-syncs the safe-area insets. Desktop keeps the bot's
|
||||||
|
// full-size window. No-op on clients predating Bot API 8.0.
|
||||||
|
telegramRequestFullscreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How long to wait for the dynamically loaded Telegram Mini App SDK before giving up and showing
|
/** How long to wait for the dynamically loaded Telegram Mini App SDK before giving up and showing
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {
|
|||||||
loadTelegramSDK,
|
loadTelegramSDK,
|
||||||
telegramSdkOutcome,
|
telegramSdkOutcome,
|
||||||
routeExternalLinkInTelegram,
|
routeExternalLinkInTelegram,
|
||||||
|
telegramClosingConfirmation,
|
||||||
telegramLaunch,
|
telegramLaunch,
|
||||||
telegramOpenExternalLink,
|
telegramOpenExternalLink,
|
||||||
|
telegramRequestFullscreen,
|
||||||
} from './telegram';
|
} from './telegram';
|
||||||
|
|
||||||
function stubWebApp(initData: string, startParam?: string) {
|
function stubWebApp(initData: string, startParam?: string) {
|
||||||
@@ -46,6 +48,69 @@ describe('telegram launch detection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// stubClient stands up a fake WebApp on the given platform with spies for the mobile-gated
|
||||||
|
// chrome toggles, so the platform gate can be asserted without a real Telegram client.
|
||||||
|
function stubClient(platform?: string) {
|
||||||
|
const enable = vi.fn();
|
||||||
|
const disable = vi.fn();
|
||||||
|
const requestFullscreen = vi.fn();
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
Telegram: {
|
||||||
|
WebApp: { platform, enableClosingConfirmation: enable, disableClosingConfirmation: disable, requestFullscreen },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { enable, disable, requestFullscreen };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobilePlatforms = ['ios', 'android', 'android_x'];
|
||||||
|
const desktopPlatforms = ['tdesktop', 'macos', 'web', undefined];
|
||||||
|
|
||||||
|
describe('telegramClosingConfirmation', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('arms the close guard on mobile clients', () => {
|
||||||
|
for (const p of mobilePlatforms) {
|
||||||
|
const { enable } = stubClient(p);
|
||||||
|
telegramClosingConfirmation(true);
|
||||||
|
expect(enable, `platform=${p}`).toHaveBeenCalledOnce();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the close guard on desktop clients (the dialog there is just noise)', () => {
|
||||||
|
for (const p of desktopPlatforms) {
|
||||||
|
const { enable } = stubClient(p);
|
||||||
|
telegramClosingConfirmation(true);
|
||||||
|
expect(enable, `platform=${p}`).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always lifts the guard on leave, regardless of platform', () => {
|
||||||
|
const { disable } = stubClient('tdesktop');
|
||||||
|
telegramClosingConfirmation(false);
|
||||||
|
expect(disable).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('telegramRequestFullscreen', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('goes immersive fullscreen on mobile clients', () => {
|
||||||
|
for (const p of mobilePlatforms) {
|
||||||
|
const { requestFullscreen } = stubClient(p);
|
||||||
|
telegramRequestFullscreen();
|
||||||
|
expect(requestFullscreen, `platform=${p}`).toHaveBeenCalledOnce();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves desktop clients as a standard window (the bot full-size setting fills it)', () => {
|
||||||
|
for (const p of desktopPlatforms) {
|
||||||
|
const { requestFullscreen } = stubClient(p);
|
||||||
|
telegramRequestFullscreen();
|
||||||
|
expect(requestFullscreen, `platform=${p}`).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('telegramOpenExternalLink', () => {
|
describe('telegramOpenExternalLink', () => {
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
|||||||
+63
-33
@@ -14,14 +14,11 @@ interface TelegramWebApp {
|
|||||||
themeParams?: TelegramThemeParams;
|
themeParams?: TelegramThemeParams;
|
||||||
colorScheme?: 'light' | 'dark';
|
colorScheme?: 'light' | 'dark';
|
||||||
isFullscreen?: boolean;
|
isFullscreen?: boolean;
|
||||||
isExpanded?: boolean;
|
|
||||||
viewportHeight?: number;
|
|
||||||
viewportStableHeight?: number;
|
|
||||||
exitFullscreen?: () => void;
|
|
||||||
safeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
safeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||||
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||||
ready?: () => void;
|
ready?: () => void;
|
||||||
expand?: () => void;
|
expand?: () => void;
|
||||||
|
requestFullscreen?: () => void;
|
||||||
openTelegramLink?: (url: string) => void;
|
openTelegramLink?: (url: string) => void;
|
||||||
openLink?: (url: string) => void;
|
openLink?: (url: string) => void;
|
||||||
onEvent?: (event: string, handler: () => void) => void;
|
onEvent?: (event: string, handler: () => void) => void;
|
||||||
@@ -29,13 +26,14 @@ interface TelegramWebApp {
|
|||||||
setBackgroundColor?: (color: string) => void;
|
setBackgroundColor?: (color: string) => void;
|
||||||
setBottomBarColor?: (color: string) => void;
|
setBottomBarColor?: (color: string) => void;
|
||||||
disableVerticalSwipes?: () => void;
|
disableVerticalSwipes?: () => void;
|
||||||
|
enableClosingConfirmation?: () => void;
|
||||||
|
disableClosingConfirmation?: () => void;
|
||||||
HapticFeedback?: {
|
HapticFeedback?: {
|
||||||
impactOccurred?: (style: string) => void;
|
impactOccurred?: (style: string) => void;
|
||||||
notificationOccurred?: (type: string) => void;
|
notificationOccurred?: (type: string) => void;
|
||||||
selectionChanged?: () => void;
|
selectionChanged?: () => void;
|
||||||
};
|
};
|
||||||
BackButton?: {
|
BackButton?: {
|
||||||
isVisible?: boolean;
|
|
||||||
show?: () => void;
|
show?: () => void;
|
||||||
hide?: () => void;
|
hide?: () => void;
|
||||||
onClick?: (cb: () => void) => void;
|
onClick?: (cb: () => void) => void;
|
||||||
@@ -294,6 +292,66 @@ export function telegramHaptic(kind: Haptic): void {
|
|||||||
else h.impactOccurred?.(kind);
|
else h.impactOccurred?.(kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* isMobilePlatform reports whether the Mini App runs on a Telegram mobile client — iOS or
|
||||||
|
* Android (the latter reported as 'android' by Telegram for Android and 'android_x' by
|
||||||
|
* Telegram X). Desktop clients (tdesktop, macOS, web) report other values. Used to limit
|
||||||
|
* mobile-only chrome such as the close guard and immersive fullscreen.
|
||||||
|
*/
|
||||||
|
function isMobilePlatform(): boolean {
|
||||||
|
const p = webApp()?.platform;
|
||||||
|
return p === 'ios' || p === 'android' || p === 'android_x';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramRequestFullscreen asks Telegram to open the Mini App in fullscreen (Bot API 8.0+),
|
||||||
|
* but only on mobile clients — mirroring how Telegram's own Mini Apps go immersive on phones
|
||||||
|
* while staying a standard window on desktop (where the bot's full-size setting already fills
|
||||||
|
* the window). A no-op outside Telegram, on desktop, or on clients predating the method.
|
||||||
|
*/
|
||||||
|
export function telegramRequestFullscreen(): void {
|
||||||
|
if (isMobilePlatform()) webApp()?.requestFullscreen?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramClosingConfirmation toggles the confirmation Telegram shows when the user swipes
|
||||||
|
* the Mini App closed — enabled during an active game so it is not lost by accident. The
|
||||||
|
* guard is only armed on mobile clients: on desktop, closing a window is deliberate and
|
||||||
|
* Telegram surfaces a "changes may not be saved" dialog that is just noise here (drafts
|
||||||
|
* auto-save), so the confirmation is skipped there.
|
||||||
|
*/
|
||||||
|
export function telegramClosingConfirmation(on: boolean): void {
|
||||||
|
const w = webApp();
|
||||||
|
if (on) {
|
||||||
|
if (isMobilePlatform()) w?.enableClosingConfirmation?.();
|
||||||
|
} else {
|
||||||
|
w?.disableClosingConfirmation?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let backHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramBackButton shows or hides Telegram's native header back button, wiring its
|
||||||
|
* click to onClick (replacing any previous handler). The app hides its own back chevron
|
||||||
|
* inside Telegram so only the native control shows.
|
||||||
|
*/
|
||||||
|
export function telegramBackButton(show: boolean, onClick?: () => void): void {
|
||||||
|
const b = webApp()?.BackButton;
|
||||||
|
if (!b) return;
|
||||||
|
if (backHandler) b.offClick?.(backHandler);
|
||||||
|
backHandler = null;
|
||||||
|
if (show) {
|
||||||
|
if (onClick) {
|
||||||
|
backHandler = onClick;
|
||||||
|
b.onClick?.(onClick);
|
||||||
|
}
|
||||||
|
b.show?.();
|
||||||
|
} else {
|
||||||
|
b.hide?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
|
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
|
||||||
* launch button carries the deep-link there rather than in initDataUnsafe.
|
* launch button carries the deep-link there rather than in initDataUnsafe.
|
||||||
@@ -438,34 +496,6 @@ export function collectTelegramDiag(): TelegramDiag {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* telegramChromeDiag returns a compact, privacy-safe readout of Telegram's viewport / chrome state
|
|
||||||
* (platform, version, fullscreen/expanded, viewport geometry, safe-area insets, SDK-load outcome,
|
|
||||||
* back-button state, UA). It feeds the hidden debug panel (components/DebugPanel), opened by tapping
|
|
||||||
* the header title ten times. No secrets: no initData values, no IP.
|
|
||||||
*/
|
|
||||||
export function telegramChromeDiag(): string {
|
|
||||||
const w = webApp();
|
|
||||||
if (!w) return 'no telegram';
|
|
||||||
const win = typeof window === 'undefined' ? undefined : window;
|
|
||||||
const scr = typeof screen === 'undefined' ? undefined : screen;
|
|
||||||
const vv = win?.visualViewport ?? undefined;
|
|
||||||
const bar = typeof document === 'undefined' ? null : (document.querySelector('.bar')?.getBoundingClientRect() ?? null);
|
|
||||||
const sa = w.safeAreaInset;
|
|
||||||
const csa = w.contentSafeAreaInset;
|
|
||||||
const n = (v: number | undefined): string => (v === undefined ? '—' : String(Math.round(v)));
|
|
||||||
return [
|
|
||||||
`platform: ${w.platform ?? '—'} version: ${w.version ?? '—'} scheme: ${w.colorScheme ?? '—'}`,
|
|
||||||
`isFullscreen: ${w.isFullscreen} isExpanded: ${w.isExpanded}`,
|
|
||||||
`inTG: ${insideTelegram()} sdkLoad: ${sdkLoadOutcome} backPresent: ${!!w.BackButton} backVisible: ${w.BackButton?.isVisible}`,
|
|
||||||
`innerH: ${n(win?.innerHeight)} outerH: ${n(win?.outerHeight)} screenH: ${n(scr?.height)} availH: ${n(scr?.availHeight)}`,
|
|
||||||
`screenY: ${n(win?.screenY)} vv.offTop: ${n(vv?.offsetTop)} vv.h: ${n(vv?.height)}`,
|
|
||||||
`tgViewportH: ${n(w.viewportHeight)} stableH: ${n(w.viewportStableHeight)}`,
|
|
||||||
`safeArea T/B: ${n(sa?.top)}/${n(sa?.bottom)} contentSafe T/B: ${n(csa?.top)}/${n(csa?.bottom)}`,
|
|
||||||
`appHeader top/h: ${bar ? Math.round(bar.top) : '—'}/${bar ? Math.round(bar.height) : '—'}`,
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Login Widget (web sign-in for account linking) ---
|
// --- Login Widget (web sign-in for account linking) ---
|
||||||
|
|
||||||
// The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to
|
// The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to
|
||||||
|
|||||||
Reference in New Issue
Block a user