diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index ec5e034..db63f44 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -19,6 +19,7 @@ import { telegramHaptic, telegramLaunch, telegramOnEvent, + telegramRequestFullscreen, telegramSetChrome, } from './telegram'; import { parseStartParam } from './deeplink'; @@ -463,6 +464,10 @@ export async function bootstrap(): Promise { telegramOnEvent('safeAreaChanged', syncTelegramSafeArea); telegramOnEvent('fullscreenChanged', syncTelegramSafeArea); telegramDisableVerticalSwipes(); + // On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged + // listener above 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(); try { await adoptSession(await gateway.authTelegram(launch.initData)); // A blocked account skips deep-link routing — the blocked screen overlays every route. diff --git a/ui/src/lib/deeplink.test.ts b/ui/src/lib/deeplink.test.ts index d7c2f08..46d3df5 100644 --- a/ui/src/lib/deeplink.test.ts +++ b/ui/src/lib/deeplink.test.ts @@ -33,20 +33,20 @@ describe('shareLink', () => { it('wraps a payload in a startapp link', () => { vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app'); - expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456&mode=fullscreen'); + expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456'); }); it('picks the per-bot base by language', () => { vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/enbot/app'); vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/rubot/app'); - expect(shareLink('f1', 'en')).toBe('https://t.me/enbot/app?startapp=f1&mode=fullscreen'); - expect(shareLink('f1', 'ru')).toBe('https://t.me/rubot/app?startapp=f1&mode=fullscreen'); + expect(shareLink('f1', 'en')).toBe('https://t.me/enbot/app?startapp=f1'); + expect(shareLink('f1', 'ru')).toBe('https://t.me/rubot/app?startapp=f1'); }); it('falls back to the language-agnostic base when the per-bot one is unset', () => { vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/fallback/app'); vi.stubEnv('VITE_TELEGRAM_LINK_RU', ''); - expect(shareLink('f1', 'ru')).toBe('https://t.me/fallback/app?startapp=f1&mode=fullscreen'); + expect(shareLink('f1', 'ru')).toBe('https://t.me/fallback/app?startapp=f1'); }); }); diff --git a/ui/src/lib/deeplink.ts b/ui/src/lib/deeplink.ts index 5431531..33f6d34 100644 --- a/ui/src/lib/deeplink.ts +++ b/ui/src/lib/deeplink.ts @@ -78,7 +78,5 @@ export function shareLink(param: string, lang = ''): string | null { const base = telegramBase(lang); if (!base) return null; const sep = base.includes('?') ? '&' : '?'; - // mode=fullscreen opens the Mini App fullscreen, matching how the bot's own entry - // opens it, so a shared link and the bot give the same experience. - return `${base}${sep}startapp=${encodeURIComponent(param)}&mode=fullscreen`; + return `${base}${sep}startapp=${encodeURIComponent(param)}`; } diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts index fa93728..0428034 100644 --- a/ui/src/lib/telegram.test.ts +++ b/ui/src/lib/telegram.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { insideTelegram, telegramClosingConfirmation, telegramLaunch } from './telegram'; +import { insideTelegram, telegramClosingConfirmation, telegramLaunch, telegramRequestFullscreen } from './telegram'; function stubWebApp(initData: string, startParam?: string) { vi.stubGlobal('window', { @@ -38,22 +38,28 @@ 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()); - // stubClient stands up a fake WebApp on the given platform with spies for the close-guard - // 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(); - vi.stubGlobal('window', { - Telegram: { WebApp: { platform, enableClosingConfirmation: enable, disableClosingConfirmation: disable } }, - }); - return { enable, disable }; - } - it('arms the close guard on mobile clients', () => { - for (const p of ['ios', 'android']) { + for (const p of mobilePlatforms) { const { enable } = stubClient(p); telegramClosingConfirmation(true); expect(enable, `platform=${p}`).toHaveBeenCalledOnce(); @@ -61,7 +67,7 @@ describe('telegramClosingConfirmation', () => { }); it('skips the close guard on desktop clients (the dialog there is just noise)', () => { - for (const p of ['tdesktop', 'macos', 'web', undefined]) { + for (const p of desktopPlatforms) { const { enable } = stubClient(p); telegramClosingConfirmation(true); expect(enable, `platform=${p}`).not.toHaveBeenCalled(); @@ -74,3 +80,23 @@ describe('telegramClosingConfirmation', () => { 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(); + } + }); +}); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index 8e963f9..252252b 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -16,6 +16,7 @@ interface TelegramWebApp { contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; ready?: () => void; expand?: () => void; + requestFullscreen?: () => void; openTelegramLink?: (url: string) => void; onEvent?: (event: string, handler: () => void) => void; setHeaderColor?: (color: string) => void; @@ -167,13 +168,24 @@ export function telegramHaptic(kind: Haptic): void { } /** - * isMobilePlatform reports whether the Mini App runs on a Telegram mobile client (iOS or - * Android), where a stray swipe-down can drop the app. Desktop clients (tdesktop, macOS, - * web) report other values and close only on a deliberate window action. + * 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'; + 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?.(); } /**