From 6aa5023b240d0b5e0d1e93a222f2f076432736ae Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 23 Jun 2026 11:48:38 +0200 Subject: [PATCH 1/6] =?UTF-8?q?fix(ui):=20Android=20Telegram=20nav=20?= =?UTF-8?q?=E2=80=94=20windowed=20mode,=20no=20close=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/UI_DESIGN.md | 12 ++++---- ui/src/game/Game.svelte | 5 +--- ui/src/lib/telegram.test.ts | 56 ++++++++++--------------------------- ui/src/lib/telegram.ts | 41 +++++---------------------- 4 files changed, 28 insertions(+), 86 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 298f2d2..81de97a 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -98,12 +98,12 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` / `setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the native header **BackButton** drives back-navigation (the app's chevron is hidden in - Telegram); **HapticFeedback** fires on tile placement / commit / error; on **mobile** - clients the app enters **immersive fullscreen** on launch (`requestFullscreen`, Bot API - 8.0+) like Telegram's own Mini Apps, while desktop keeps the bot's full-size window; - **closing confirmation** is enabled while a game is open **on mobile only** (on desktop - closing is deliberate and the "changes may not be saved" dialog is just noise — move drafts - auto-save); **vertical swipes** (swipe-to-minimise) + Telegram); **HapticFeedback** fires on tile placement / commit / error; on **iOS** the app + enters **immersive fullscreen** on launch (`requestFullscreen`, Bot API 8.0+) like Telegram's + own Mini Apps, while **Android stays windowed** — there fullscreen hides the native header and + its BackButton, and the Android system swipe-back then minimises the app instead of navigating — + and desktop keeps the bot's full-size window; move drafts auto-save, so there is **no + closing-confirmation guard**; **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` so Telegram shows them in its in-app browser instead of the WebView's "open this link?" diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index cb967cc..67e1a09 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -24,7 +24,7 @@ import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; - import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram'; + import { telegramHaptic } from '../lib/telegram'; import { BLANK, newPlacement, @@ -228,8 +228,6 @@ placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack); } 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 // background. A cold open shows the loading state until load() resolves. const cached = getCachedGame(id); @@ -579,7 +577,6 @@ clearTimeout(draftSaveTimer); void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); } - telegramClosingConfirmation(false); }); function onCell(row: number, col: number) { diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts index 22a5108..f6d70af 100644 --- a/ui/src/lib/telegram.test.ts +++ b/ui/src/lib/telegram.test.ts @@ -5,7 +5,6 @@ import { loadTelegramSDK, telegramSdkOutcome, routeExternalLinkInTelegram, - telegramClosingConfirmation, telegramLaunch, telegramOpenExternalLink, telegramRequestFullscreen, @@ -48,61 +47,34 @@ 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. +// stubClient stands up a fake WebApp on the given platform with a requestFullscreen spy, 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 }; + vi.stubGlobal('window', { Telegram: { WebApp: { platform, requestFullscreen } } }); + return { 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) { + it('goes immersive fullscreen on iOS', () => { + const { requestFullscreen } = stubClient('ios'); + telegramRequestFullscreen(); + expect(requestFullscreen).toHaveBeenCalledOnce(); + }); + + it('stays windowed on Android, where fullscreen hides the native back button', () => { + for (const p of ['android', 'android_x']) { const { requestFullscreen } = stubClient(p); telegramRequestFullscreen(); - expect(requestFullscreen, `platform=${p}`).toHaveBeenCalledOnce(); + expect(requestFullscreen, `platform=${p}`).not.toHaveBeenCalled(); } }); - it('leaves desktop clients as a standard window (the bot full-size setting fills it)', () => { + it('leaves desktop clients as a standard window', () => { for (const p of desktopPlatforms) { const { requestFullscreen } = stubClient(p); telegramRequestFullscreen(); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index 8d529d2..cbc1889 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -26,8 +26,6 @@ interface TelegramWebApp { setBackgroundColor?: (color: string) => void; setBottomBarColor?: (color: string) => void; disableVerticalSwipes?: () => void; - enableClosingConfirmation?: () => void; - disableClosingConfirmation?: () => void; HapticFeedback?: { impactOccurred?: (style: string) => void; notificationOccurred?: (type: string) => void; @@ -293,40 +291,15 @@ export function telegramHaptic(kind: Haptic): void { } /** - * 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. + * telegramRequestFullscreen asks Telegram to open the Mini App in immersive fullscreen (Bot API + * 8.0+), but only on iOS. On Android, fullscreen replaces the native header — and its BackButton — + * with a bare close/menu pill: the back control disappears and the Android system swipe-back falls + * through to minimising the app instead of navigating, so the app stays windowed there to keep the + * native header + BackButton (which also captures the system back). A no-op outside Telegram, on + * Android, 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?.(); - } + if (webApp()?.platform === 'ios') webApp()?.requestFullscreen?.(); } let backHandler: (() => void) | null = null; -- 2.52.0 From 79766438a2ff52f7e7f8b24b6014aaee0dc27b09 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 23 Jun 2026 13:36:54 +0200 Subject: [PATCH 2/6] chore(ui): TEMP lobby diagnostic for the Android fullscreen issue 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. --- ui/src/lib/telegram.ts | 23 +++++++++++++++++++++++ ui/src/screens/Lobby.svelte | 7 +++++++ 2 files changed, 30 insertions(+) diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index cbc1889..9946896 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -14,6 +14,10 @@ interface TelegramWebApp { themeParams?: TelegramThemeParams; colorScheme?: 'light' | 'dark'; isFullscreen?: boolean; + isExpanded?: boolean; + viewportHeight?: number; + viewportStableHeight?: number; + exitFullscreen?: () => void; safeAreaInset?: { top: number; bottom: number; left: number; right: number }; contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; ready?: () => void; @@ -469,6 +473,25 @@ export function collectTelegramDiag(): TelegramDiag { }; } +/** + * telegramChromeDiag is a TEMPORARY on-device readout of Telegram's viewport / fullscreen state, + * rendered in the lobby to debug the Android fullscreen-persistence issue (the app still opens + * fullscreen on Android even though requestFullscreen is now iOS-only). REMOVE before merge. + */ +export function telegramChromeDiag(): string { + const w = webApp(); + if (!w) return 'no telegram'; + const ih = typeof window === 'undefined' ? 0 : window.innerHeight; + const sh = typeof screen === 'undefined' ? 0 : screen.height; + return [ + `platform: ${w.platform ?? '—'} version: ${w.version ?? '—'}`, + `isFullscreen: ${w.isFullscreen} isExpanded: ${w.isExpanded}`, + `viewportH: ${w.viewportHeight ?? '—'} stableH: ${w.viewportStableHeight ?? '—'}`, + `innerH: ${ih} screenH: ${sh}`, + `contentSafeTop: ${w.contentSafeAreaInset?.top ?? '—'} safeTop: ${w.safeAreaInset?.top ?? '—'}`, + ].join('\n'); +} + // --- 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 diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index b06ba42..af3fd3e 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -9,6 +9,7 @@ import { gateway } from '../lib/gateway'; import { navigate } from '../lib/router.svelte'; import { t } from '../lib/i18n/index.svelte'; + import { insideTelegram, telegramChromeDiag } from '../lib/telegram'; // TEMP diag — remove before merge import { resultBadge } from '../lib/result'; import { badgeKind } from '../lib/unread'; import { getLobby, setLobby } from '../lib/lobbycache'; @@ -223,6 +224,12 @@
+ + {#if insideTelegram()} +
{telegramChromeDiag()}
+ {/if} {#if invitations.length}

{t('lobby.invitations')}

-- 2.52.0 From 6f00c2f41d3a8cea9e9f083399ac4d026efb30f2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 23 Jun 2026 14:04:21 +0200 Subject: [PATCH 3/6] chore(ui): TEMP disable fullscreen for testing + Android back chevron + BackButton diag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/src/App.svelte | 10 ++++++++- ui/src/components/Header.svelte | 9 +++++---- ui/src/lib/telegram.ts | 36 ++++++++++++++++++++++++++------- ui/src/screens/Lobby.svelte | 7 ------- 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 099dbe7..b7f54ad 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -4,7 +4,7 @@ import { app, bootstrap } from './lib/app.svelte'; import { navigate, router, type RouteName } from './lib/router.svelte'; import { t } from './lib/i18n/index.svelte'; - import { insideTelegram, telegramBackButton } from './lib/telegram'; + import { insideTelegram, telegramBackButton, telegramChromeDiag } from './lib/telegram'; import Toast from './components/Toast.svelte'; import Splash from './components/Splash.svelte'; import StaleInviteModal from './components/StaleInviteModal.svelte'; @@ -138,6 +138,14 @@ {/if} + +{#if app.ready && insideTelegram()} + {#key router.route.name + (router.route.params.id ?? '')} +
{telegramChromeDiag()}
+ {/key} +{/if} + diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index de1d6f4..6579946 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -2,7 +2,7 @@ import { navigate } from '../lib/router.svelte'; import { connection } from '../lib/connection.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 AdBanner from './AdBanner.svelte'; @@ -12,6 +12,19 @@ // and out of Telegram. The native Telegram BackButton is not used: it does not render reliably in // 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(); + } + }