From 853730823b121719530dfd60d1705233132413a7 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 15 Jun 2026 17:22:09 +0200 Subject: [PATCH] feat(ui): refine Telegram invite & close UX - Shared invite links open the Mini App fullscreen (mode=fullscreen), so a shared link matches the bot's own fullscreen launch. - A used or expired invite deep-link now lands the visitor in the lobby with a gentle notice pointing at the right bot (@, by service language), instead of a red "code invalid/expired" error on the Friends screen. - The in-game close confirmation is armed only on Telegram mobile clients; on desktop (tdesktop/macOS/web) it is skipped, where the "changes may not be saved" dialog is just noise (drafts auto-save). --- ui/src/App.svelte | 2 + ui/src/components/StaleInviteModal.svelte | 53 +++++++++++++++++++++++ ui/src/lib/app.svelte.ts | 26 +++++++++-- ui/src/lib/deeplink.test.ts | 31 ++++++++++--- ui/src/lib/deeplink.ts | 21 ++++++++- ui/src/lib/i18n/en.ts | 2 + ui/src/lib/i18n/ru.ts | 2 + ui/src/lib/telegram.test.ts | 39 ++++++++++++++++- ui/src/lib/telegram.ts | 43 ++++++++++++++---- 9 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 ui/src/components/StaleInviteModal.svelte diff --git a/ui/src/App.svelte b/ui/src/App.svelte index e07701e..8f3bba3 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -6,6 +6,7 @@ import { t } from './lib/i18n/index.svelte'; import { insideTelegram, telegramBackButton } from './lib/telegram'; import Toast from './components/Toast.svelte'; + import StaleInviteModal from './components/StaleInviteModal.svelte'; import Login from './screens/Login.svelte'; import Lobby from './screens/Lobby.svelte'; import NewGame from './screens/NewGame.svelte'; @@ -108,6 +109,7 @@ {/if} + diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 94d8739..ec5e034 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -62,6 +62,10 @@ export const app = $state<{ /** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined * with friend requests) and the Settings → Info badge. */ feedbackReplyUnread: boolean; + /** Whether to show the "outdated invite link" notice: set when a Telegram deep-link friend + * code is already used/expired, so the visitor lands in the lobby with a gentle pointer to + * the bot instead of a scary error on the Friends screen. */ + staleInvite: boolean; /** Monotonic counter bumped when the app returns to the foreground without the live stream * having dropped. An open game watches it to refetch once, recovering an in-game event shed * from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */ @@ -83,6 +87,7 @@ export const app = $state<{ notifications: 0, chatUnread: {}, feedbackReplyUnread: false, + staleInvite: false, resync: 0, }); @@ -134,6 +139,11 @@ export function showToast(text: string, kind: Toast['kind'] = 'info'): void { toastTimer = setTimeout(() => (app.toast = null), 4000); } +/** dismissStaleInvite hides the outdated-invite-link notice (the user acknowledged it). */ +export function dismissStaleInvite(): void { + app.staleInvite = false; +} + /** clearChatUnread resets a game's unread chat-message count (called when its chat is opened). */ export function clearChatUnread(gameId: string): void { if (app.chatUnread[gameId]) app.chatUnread = { ...app.chatUnread, [gameId]: 0 }; @@ -487,17 +497,25 @@ async function routeStartParam(param: string): Promise { navigate(`/game/${link.id}`); return; case 'friendCode': - navigate('/friends'); try { const friend = await gateway.friendCodeRedeem(link.code); + navigate('/friends'); showToast(t('friends.added', { name: friend.displayName })); void refreshNotifications(); } catch (err) { - // Tapping your own invite link redeems your own code: show a friendly note, not - // the scary "can't do that to yourself" error. - if (err instanceof GatewayError && err.code === 'self_relation') { + const code = err instanceof GatewayError ? err.code : ''; + if (code === 'self_relation') { + // Tapping your own invite link redeems your own code: a friendly note, not the + // scary "can't do that to yourself" error. + navigate('/friends'); showToast(t('friends.selfInvite')); + } else if (code === 'friend_code_invalid') { + // A previously shared link whose single-use, 12h code is already spent or expired: + // land in the lobby and gently point at the bot, rather than a red error on Friends. + navigate('/'); + app.staleInvite = true; } else { + navigate('/friends'); handleError(err); } } diff --git a/ui/src/lib/deeplink.test.ts b/ui/src/lib/deeplink.test.ts index 7ad1df4..d7c2f08 100644 --- a/ui/src/lib/deeplink.test.ts +++ b/ui/src/lib/deeplink.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink'; +import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink'; describe('parseStartParam', () => { it('classifies game / invitation / friend code', () => { @@ -33,19 +33,40 @@ 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'); + expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456&mode=fullscreen'); }); 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'); - expect(shareLink('f1', 'ru')).toBe('https://t.me/rubot/app?startapp=f1'); + 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'); }); 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'); + expect(shareLink('f1', 'ru')).toBe('https://t.me/fallback/app?startapp=f1&mode=fullscreen'); + }); +}); + +describe('botUsername', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('returns null without a configured base', () => { + vi.stubEnv('VITE_TELEGRAM_LINK', ''); + expect(botUsername()).toBeNull(); + }); + + it('extracts the bot handle from the Mini App link', () => { + vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app'); + expect(botUsername()).toBe('bot'); + }); + + it('picks the per-bot handle 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(botUsername('en')).toBe('enbot'); + expect(botUsername('ru')).toBe('rubot'); }); }); diff --git a/ui/src/lib/deeplink.ts b/ui/src/lib/deeplink.ts index f5cf6fc..5431531 100644 --- a/ui/src/lib/deeplink.ts +++ b/ui/src/lib/deeplink.ts @@ -52,6 +52,23 @@ function telegramBase(lang: string): string | null { return byLang || envVar('VITE_TELEGRAM_LINK') || null; } +/** + * botUsername extracts the bot's @username (without the @) from the configured Mini App + * link for a bot language: the first path segment of https://t.me//. Returns + * null when no base is configured or the link carries no path, so callers can fall back + * when they cannot point at a specific bot. + */ +export function botUsername(lang = ''): string | null { + const base = telegramBase(lang); + if (!base) return null; + try { + const seg = new URL(base).pathname.split('/').filter(Boolean)[0]; + return seg || null; + } catch { + return null; + } +} + /** * shareLink wraps a deep-link start parameter in a t.me Mini App link for the given * bot language (the session's service language). Returns null when no base is @@ -61,5 +78,7 @@ export function shareLink(param: string, lang = ''): string | null { const base = telegramBase(lang); if (!base) return null; const sep = base.includes('?') ? '&' : '?'; - return `${base}${sep}startapp=${encodeURIComponent(param)}`; + // 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`; } diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index b01d5b6..7aaabe1 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -237,6 +237,8 @@ export const en = { 'friends.inviteText': "Let's play Scrabble!", 'friends.linkCopied': 'Link copied.', 'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️", + 'friends.staleInviteTitle': 'Link expired', + 'friends.staleInvite': 'You opened the game from an outdated link. Open the bot {bot} to play and get notifications.', 'friends.added': 'Added {name}.', 'friends.blockedList': 'Blocked players', 'friends.unblock': 'Unblock', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 3d6da26..fa5e58f 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -238,6 +238,8 @@ export const ru: Record = { 'friends.inviteText': 'Давай играть в Эрудит!', 'friends.linkCopied': 'Ссылка скопирована.', 'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️', + 'friends.staleInviteTitle': 'Ссылка устарела', + 'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.', 'friends.added': 'Добавлен(а) {name}.', 'friends.blockedList': 'Заблокированные', 'friends.unblock': 'Разблокировать', diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts index b08d91f..fa93728 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, telegramLaunch } from './telegram'; +import { insideTelegram, telegramClosingConfirmation, telegramLaunch } from './telegram'; function stubWebApp(initData: string, startParam?: string) { vi.stubGlobal('window', { @@ -37,3 +37,40 @@ describe('telegram launch detection', () => { expect(launch.theme?.bg_color).toBe('#101418'); }); }); + +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']) { + 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 ['tdesktop', 'macos', 'web', undefined]) { + 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(); + }); +}); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index d714491..8e963f9 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -8,6 +8,7 @@ import type { TelegramThemeParams } from './theme'; interface TelegramWebApp { initData: string; initDataUnsafe?: { start_param?: string }; + platform?: string; themeParams?: TelegramThemeParams; colorScheme?: 'light' | 'dark'; isFullscreen?: boolean; @@ -50,6 +51,19 @@ export function insideTelegram(): boolean { return !!w && typeof w.initData === 'string' && w.initData.length > 0; } +/** + * telegramOpenLink opens a t.me link through the Mini App SDK, so Telegram navigates to + * it natively (e.g. a bot chat) rather than spawning an in-app browser tab. Returns false + * outside Telegram or when the SDK lacks the method, so the caller can fall back to a plain + * window.open. + */ +export function telegramOpenLink(url: string): boolean { + const w = webApp(); + if (!w?.openTelegramLink) return false; + w.openTelegramLink(url); + return true; +} + /** * shareTelegramLink opens Telegram's native "share to a chat" picker for url with a * caption, through the Mini App SDK (https://t.me/share/url). Returns false outside @@ -57,10 +71,7 @@ export function insideTelegram(): boolean { * Share API. Works consistently on iOS and Android within Telegram. */ export function shareTelegramLink(url: string, text: string): boolean { - const w = webApp(); - if (!w?.openTelegramLink) return false; - w.openTelegramLink(`https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`); - return true; + return telegramOpenLink(`https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`); } /** TelegramLaunch is the data a Mini App launch carries. */ @@ -156,13 +167,29 @@ export function telegramHaptic(kind: Haptic): void { } /** - * 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. + * 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. + */ +function isMobilePlatform(): boolean { + const p = webApp()?.platform; + return p === 'ios' || p === 'android'; +} + +/** + * 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) w?.enableClosingConfirmation?.(); - else w?.disableClosingConfirmation?.(); + if (on) { + if (isMobilePlatform()) w?.enableClosingConfirmation?.(); + } else { + w?.disableClosingConfirmation?.(); + } } let backHandler: (() => void) | null = null;