feat(telegram): native dialogs for confirms and deep-link notices
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s

Inside the Mini App, route the destructive confirmations (resign, block, unfriend)
through Telegram's native showConfirm, and present the deep-link info modals (stale
invite, welcome-on-redeem) as a native showPopup whose button opens the bot chat.
Outside Telegram, or on a client predating the dialogs, the existing in-app Modal is
used unchanged; offline also keeps the modal so the action retains its disabled state.

Adds showConfirm/showPopup wrappers to telegram.ts and a pure popup-params builder
(nativedialogs.ts) with unit tests; the deep-link modal components choose native vs
in-app via an effect gated on insideTelegram + dialog availability.
This commit is contained in:
Ilia Denisov
2026-06-24 12:36:52 +02:00
parent 29b6c7e4d8
commit 2ba7cc3086
8 changed files with 209 additions and 15 deletions
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { BOT_BUTTON_ID, botInfoPopup } from './nativedialogs';
describe('botInfoPopup', () => {
it('inlines the bot handle and adds an open-bot button', () => {
const p = botInfoPopup('Title', 'Open the bot {bot} to play.', 'erudit_bot', 'OK');
expect(p.title).toBe('Title');
expect(p.message).toBe('Open the bot @erudit_bot to play.');
expect(p.buttons).toEqual([
{ id: BOT_BUTTON_ID, text: '@erudit_bot' },
{ id: 'ok', text: 'OK' },
]);
});
it('drops the token and the open-bot button when no username is known', () => {
const p = botInfoPopup('Title', 'Open the bot {bot} to play.', '', 'OK');
expect(p.message).toBe('Open the bot to play.');
expect(p.buttons).toEqual([{ id: 'ok', text: 'OK' }]);
});
it('preserves newlines in the message', () => {
const p = botInfoPopup('Hi', 'Welcome!\n\nUse {bot} now.', 'b', 'OK');
expect(p.message).toBe('Welcome!\n\nUse @b now.');
});
});
+24
View File
@@ -0,0 +1,24 @@
// Pure builder for the Telegram native popup (showPopup) used by the deep-link info modals
// (StaleInviteModal / WelcomeRedeemModal) inside the Mini App. Kept free of the SDK and the DOM so
// it unit-tests in the node environment; the showPopup transport wrapper lives in telegram.ts and
// the wiring (native inside Telegram, the in-app Modal elsewhere) in the modal components.
import type { TelegramPopupParams } from './telegram';
/** BOT_BUTTON_ID is the showPopup button id that means "open the bot chat". */
export const BOT_BUTTON_ID = 'bot';
/**
* botInfoPopup builds the native popup for a deep-link info modal: the message with the `{bot}`
* token replaced by the `@username` as plain text (a native popup has no inline link, unlike the
* in-app Modal), plus an "open bot" button when a username is known and a closing OK button. With
* no username it is the message (token removed) and OK only.
*/
export function botInfoPopup(title: string, message: string, username: string, okText: string): TelegramPopupParams {
const handle = username ? `@${username}` : '';
const text = (username ? message.replace('{bot}', handle) : message.replace('{bot}', '').replace(' ', ' ')).trim();
const buttons = username
? [{ id: BOT_BUTTON_ID, text: handle }, { id: 'ok', text: okText }]
: [{ id: 'ok', text: okText }];
return { title, message: text, buttons };
}
+35
View File
@@ -10,6 +10,9 @@ import {
telegramThemeParams,
telegramSafeAreaInset,
telegramShowSettingsButton,
telegramDialogsAvailable,
telegramShowConfirm,
telegramShowPopup,
} from './telegram';
function stubWebApp(initData: string, startParam?: string) {
@@ -96,6 +99,38 @@ describe('telegramShowSettingsButton', () => {
});
});
describe('native dialogs', () => {
afterEach(() => vi.unstubAllGlobals());
it('telegramDialogsAvailable reflects showPopup presence', () => {
expect(telegramDialogsAvailable()).toBe(false);
vi.stubGlobal('window', { Telegram: { WebApp: { showPopup: () => {} } } });
expect(telegramDialogsAvailable()).toBe(true);
});
it('telegramShowConfirm resolves the user choice inside Telegram', async () => {
vi.stubGlobal('window', {
Telegram: { WebApp: { showConfirm: (_m: string, cb: (ok: boolean) => void) => cb(true) } },
});
await expect(telegramShowConfirm('Sure?')).resolves.toBe(true);
});
it('telegramShowConfirm resolves false without the SDK dialog', async () => {
await expect(telegramShowConfirm('Sure?')).resolves.toBe(false);
});
it('telegramShowPopup resolves the pressed button id', async () => {
vi.stubGlobal('window', {
Telegram: { WebApp: { showPopup: (_p: unknown, cb: (id: string) => void) => cb('bot') } },
});
await expect(telegramShowPopup({ message: 'Hi' })).resolves.toBe('bot');
});
it('telegramShowPopup resolves null without the SDK', async () => {
await expect(telegramShowPopup({ message: 'Hi' })).resolves.toBeNull();
});
});
describe('routeExternalLinkInTelegram', () => {
afterEach(() => vi.unstubAllGlobals());
+44
View File
@@ -6,6 +6,20 @@
import type { TelegramThemeParams } from './theme';
/** TelegramPopupButton is one button of a native showPopup (Bot API 6.2). */
export interface TelegramPopupButton {
id?: string;
type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive';
text?: string;
}
/** TelegramPopupParams configures a native showPopup (title optional, message required, up to 3 buttons). */
export interface TelegramPopupParams {
title?: string;
message: string;
buttons?: TelegramPopupButton[];
}
interface TelegramWebApp {
initData: string;
initDataUnsafe?: { start_param?: string };
@@ -51,6 +65,8 @@ interface TelegramWebApp {
getItem?: (key: string, cb: (err: string | null, value?: string) => void) => void;
setItem?: (key: string, value: string, cb?: (err: string | null, ok?: boolean) => void) => void;
};
showConfirm?: (message: string, cb?: (ok: boolean) => void) => void;
showPopup?: (params: TelegramPopupParams, cb?: (buttonId: string) => void) => void;
}
function webApp(): TelegramWebApp | undefined {
@@ -347,6 +363,34 @@ export function telegramCloudSet(key: string, value: string): Promise<void> {
});
}
/** telegramDialogsAvailable reports whether Telegram's native dialogs (showConfirm / showPopup, Bot
* API 6.2) are usable, so a caller can choose the native path over its own modal. */
export function telegramDialogsAvailable(): boolean {
return !!webApp()?.showPopup;
}
/**
* telegramShowConfirm shows Telegram's native confirm dialog and resolves true when the user
* accepts. Resolves false outside Telegram or on a client predating the dialog, so callers should
* gate on telegramDialogsAvailable and fall back to their own modal otherwise.
*/
export function telegramShowConfirm(message: string): Promise<boolean> {
const w = webApp();
if (!w?.showConfirm) return Promise.resolve(false);
return new Promise((resolve) => w.showConfirm!(message, (ok) => resolve(!!ok)));
}
/**
* telegramShowPopup shows Telegram's native popup and resolves the pressed button id (the empty
* string when dismissed without pressing a button). Resolves null outside Telegram or on a client
* predating the popup, so callers can fall back to their own modal.
*/
export function telegramShowPopup(params: TelegramPopupParams): Promise<string | null> {
const w = webApp();
if (!w?.showPopup) return Promise.resolve(null);
return new Promise((resolve) => w.showPopup!(params, (id) => resolve(id ?? '')));
}
/** Haptic is the set of feedbacks the app triggers. */
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';