Compare commits

..

8 Commits

Author SHA1 Message Date
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer e6277dcd43 Merge pull request 'fix(ui): Android Telegram nav — windowed mode, no close guard' (#128) from feature/telegram-android-nav-fixes into development
CI / changes (push) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
CI / changes (pull_request) Successful in 2s
CI / deploy (pull_request) Has been skipped
2026-06-23 13:14:04 +00:00
Ilia Denisov 37070c3cb7 feat(ui): drop Telegram fullscreen; own back chevron everywhere; hidden debug panel
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m13s
Finalises the Telegram Mini App navigation work after on-device testing (Pixel
10 / Android 17 + iOS, fresh beta clients):

- Remove requestFullscreen entirely. Immersive fullscreen hid Telegram's native
  header (and its BackButton) and the Android system swipe-back minimised the
  app; the owner prefers the windowed full-size (expand) presentation, so the
  app never requests fullscreen on any platform now.
- The app's own back chevron (Header, showBack = !!back) drives back-navigation
  on every platform; the native Telegram BackButton is dropped — it does not
  render in the windowed Mini App (backVisible=false on iOS and Android), so
  relying on it lost back navigation (notably none on iOS).
- Replace the temporary always-on diagnostic overlay with a hidden debug panel
  (components/DebugPanel): ten quick taps on the header title open it; it shows a
  privacy-safe client diagnostic snapshot (app version, locale, online, userId,
  Telegram chrome / viewport / SDK state — no secrets, no IP) and shares it via
  the OS share sheet / clipboard; a tap anywhere except Share dismisses it.
- Drop the now-dead telegramRequestFullscreen / telegramBackButton /
  isTelegramAndroid helpers and the iOS-fullscreen unit test.

Telegram has no native non-modal notification API (only modal showPopup /
showAlert), so in-app toasts stay ours. Docs: UI_DESIGN.md.
2026-06-23 15:05:13 +02:00
Ilia Denisov 53d6883ffd fix(ui): own back chevron in Telegram on all platforms; drop the native BackButton
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 1m30s
The Telegram native BackButton does not render in the windowed Mini App (the
owner's emulator + a fresh beta TG report backVisible=false on both iOS and
Android), so relying on it lost back navigation — iOS had no back affordance at
all. Show the app's own back chevron whenever there is a back target, on every
platform (Header showBack = !!back), and drop the now-dead native BackButton
effect (App.svelte). The native close control stays — a windowed Mini App
cannot hide it (no Telegram API).

WIP: the temp lobby diagnostic overlay and the requestFullscreen no-op remain
for the owner's emulator test; finalize after confirmation.
2026-06-23 14:37:43 +02:00
Ilia Denisov 93c57b3558 test(ui): TEMP-skip the iOS fullscreen unit test (requestFullscreen is a no-op for the owner test; restore with the iOS path)
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
2026-06-23 14:09:40 +02:00
Ilia Denisov 6f00c2f41d chore(ui): TEMP disable fullscreen for testing + Android back chevron + BackButton diag
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) Failing after 8s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
WIP for the Android nav investigation (TEMP bits reverted before merge):
- telegramRequestFullscreen: temporarily a no-op (incl. iOS) so the owner can
  confirm the Android "fullscreen look" is Telegram's own Mini App presentation,
  not our requestFullscreen (isFullscreen is already false on Android).
- Header: show the app's own back chevron in Telegram on Android, where the
  native BackButton does not render — a reliable tap-back. [keep]
- Diagnostic overlay moved app-wide (pointer-events:none) and now reports
  BackButton state (req/present/visible) + viewport geometry, to see whether the
  native BackButton can capture the Android system swipe-back.
2026-06-23 14:04:21 +02:00
Ilia Denisov 79766438a2 chore(ui): TEMP lobby diagnostic for the Android fullscreen issue
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Renders Telegram viewport/fullscreen state (isFullscreen, isExpanded, viewport
heights, innerH vs screenH, safe-area insets) in the lobby, inside Telegram
only, to diagnose why the app still opens fullscreen on Android with
requestFullscreen now iOS-only and no persisted state (fresh TG + test account).
REVERT before merge.
2026-06-23 13:36:54 +02:00
Ilia Denisov 6aa5023b24 fix(ui): Android Telegram nav — windowed mode, no close guard
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Three Android Mini App issues, all in the Telegram chrome:

- Entering a game showed no native back button, and the Android system
  swipe-back minimised the app instead of navigating. Root cause: immersive
  fullscreen (requestFullscreen). On Android, fullscreen replaces the native
  header — and its BackButton, which also captures the system back — with a bare
  close/menu pill, so back navigation has no control to land on. Request
  fullscreen on iOS only; Android stays windowed, keeping the native header +
  BackButton (and the system swipe-back that routes to it). iOS is unchanged.

- Closing the game board always prompted "changes that you made may not be
  saved", even on a board just opened and untouched. The close-confirmation was
  armed unconditionally on game mount. Remove it entirely: move drafts auto-save
  (debounced during play + flushed on destroy), so nothing is lost on close.

Drops the now-unused telegramClosingConfirmation + isMobilePlatform helpers and
their SDK interface fields. Docs: UI_DESIGN.md.
2026-06-23 11:48:38 +02:00
8 changed files with 156 additions and 166 deletions
+10 -8
View File
@@ -97,14 +97,16 @@ 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
native header **BackButton** drives back-navigation (the app's chevron is hidden in app's **own back chevron** (Header) drives back-navigation on every platform — the native
Telegram); **HapticFeedback** fires on tile placement / commit / error; on **mobile** Telegram BackButton is not used, as it does not render reliably in the windowed Mini App;
clients the app enters **immersive fullscreen** on launch (`requestFullscreen`, Bot API **HapticFeedback** fires on tile placement / commit / error; the app calls `expand()` for the
8.0+) like Telegram's own Mini Apps, while desktop keeps the bot's full-size window; bot's full-size (max-height) window but **never `requestFullscreen`** — immersive fullscreen hid
**closing confirmation** is enabled while a game is open **on mobile only** (on desktop the native header (and its BackButton) and the Android system swipe-back then minimised the app,
closing is deliberate and the "changes may not be saved" dialog is just noise — move drafts so it stays windowed with Telegram's thin native header (close) above the app's own header; move
auto-save); **vertical swipes** (swipe-to-minimise) drafts auto-save, so there is **no closing-confirmation guard**; a hidden **debug panel** (ten
are disabled so they don't fight tile drag or the board scroll; **external links** (the word-check quick taps on the header title) shows and shares a privacy-safe client diagnostic snapshot for
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
+6 -15
View File
@@ -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 { navigate, router, type RouteName } from './lib/router.svelte'; import { 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,19 +30,6 @@
// 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
@@ -138,6 +125,10 @@
<Splash /> <Splash />
{/if} {/if}
{#if app.debugOpen}
<DebugPanel />
{/if}
<style> <style>
.splash { .splash {
height: 100%; height: 100%;
+71
View File
@@ -0,0 +1,71 @@
<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>
+21 -6
View File
@@ -1,17 +1,30 @@
<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 } from '../lib/app.svelte'; import { app, openDebug } 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();
// Inside Telegram the native header back button (App.svelte) is the back control, so // The app always shows its own back chevron when there is a back target — on every platform, in
// the app's own chevron is hidden to avoid two back affordances. // and out of Telegram. The native Telegram BackButton is not used: it does not render reliably in
const showBack = $derived(!!back && !insideTelegram()); // the windowed Mini App (relying on it would lose back navigation there).
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>
@@ -24,7 +37,9 @@
<span class="spacer"></span> <span class="spacer"></span>
{/if} {/if}
{#if connection.online} {#if connection.online}
<h1>{title}</h1> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- 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}
+1 -4
View File
@@ -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 { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram'; import { telegramHaptic } from '../lib/telegram';
import { import {
BLANK, BLANK,
newPlacement, newPlacement,
@@ -228,8 +228,6 @@
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);
@@ -579,7 +577,6 @@
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) {
+14 -5
View File
@@ -25,7 +25,6 @@ import {
telegramLaunch, telegramLaunch,
type TelegramLaunch, type TelegramLaunch,
telegramOnEvent, telegramOnEvent,
telegramRequestFullscreen,
telegramSetChrome, telegramSetChrome,
} from './telegram'; } from './telegram';
import { parseStartParam } from './deeplink'; import { parseStartParam } from './deeplink';
@@ -56,6 +55,9 @@ 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;
@@ -106,6 +108,7 @@ 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,
@@ -197,6 +200,16 @@ 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
@@ -557,10 +570,6 @@ 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
-65
View File
@@ -5,10 +5,8 @@ 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) {
@@ -48,69 +46,6 @@ 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());
+33 -63
View File
@@ -14,11 +14,14 @@ 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;
@@ -26,14 +29,13 @@ 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;
@@ -292,66 +294,6 @@ 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.
@@ -496,6 +438,34 @@ 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