feat(ui): refine Telegram invite & close UX
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s

- 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 (@<username>, 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).
This commit is contained in:
Ilia Denisov
2026-06-15 17:22:09 +02:00
parent 01d2d1f368
commit 853730823b
9 changed files with 200 additions and 19 deletions
+2
View File
@@ -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}
<Toast />
<StaleInviteModal />
<style>
.splash {
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import { app, dismissStaleInvite } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import { botUsername } from '../lib/deeplink';
import { telegramOpenLink } from '../lib/telegram';
import Modal from './Modal.svelte';
// Point at the bot the player signed in through (its service language), falling back to
// the interface locale, so an ru player is sent to the ru bot and an en player to the en one.
const username = $derived(botUsername(app.session?.serviceLanguage || app.locale));
// Split the message around the {bot} token so the bot handle renders as an inline link.
const parts = $derived(t('friends.staleInvite').split('{bot}'));
function openBot() {
if (username) {
const url = `https://t.me/${username}`;
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
// back to a new tab.
if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank');
}
dismissStaleInvite();
}
</script>
{#if app.staleInvite}
<Modal title={t('friends.staleInviteTitle')} onclose={dismissStaleInvite}>
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
<button class="ok" onclick={dismissStaleInvite}>{t('common.ok')}</button>
</Modal>
{/if}
<style>
.msg {
margin: 0 0 16px;
line-height: 1.5;
}
.bot {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--accent);
cursor: pointer;
}
.ok {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
</style>
+22 -4
View File
@@ -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<void> {
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);
}
}
+26 -5
View File
@@ -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');
});
});
+20 -1
View File
@@ -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/<bot>/<app>. 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`;
}
+2
View File
@@ -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',
+2
View File
@@ -238,6 +238,8 @@ export const ru: Record<MessageKey, string> = {
'friends.inviteText': 'Давай играть в Эрудит!',
'friends.linkCopied': 'Ссылка скопирована.',
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
'friends.staleInviteTitle': 'Ссылка устарела',
'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.',
'friends.added': 'Добавлен(а) {name}.',
'friends.blockedList': 'Заблокированные',
'friends.unblock': 'Разблокировать',
+38 -1
View File
@@ -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();
});
});
+35 -8
View File
@@ -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;