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;