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
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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Разблокировать',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user