refactor: migrate to @telegram-apps/sdk-react v3, remove all window.Telegram.WebApp usage

Полный переход с нативного window.Telegram.WebApp API на @telegram-apps/sdk-react v3.
Bridge SDK оборачивал receiveEvent, из-за чего popup_closed не доходил до нативного
обработчика — это было основной причиной бага с двойным открытием попапа.

Изменения:

- Удалён @telegram-apps/react-router-integration (привязан к SDK v1)
- Инициализация SDK v3 в main.tsx: init(), restoreInitData(), mount всех компонентов
- AppWithNavigator: убран TelegramRouter (initNavigator + useIntegration), все платформы
  на BrowserRouter, добавлен TelegramBackButton через SDK v3
- useTelegramSDK: детекция через retrieveLaunchParams(), реактивные значения через
  useSignal() (fullscreen, viewport, safe area insets), initData через retrieveRawInitData()
- TelegramAdapter: полная перезапись — попапы через showPopup() (Promise-based, без
  ручного onEvent/offEvent/timeout), кнопки через setMainButtonParams/showBackButton,
  haptic через hapticFeedbackImpactOccurred, тема через themeParamsState с конвертацией
  camelCase→snake_case, cloud storage через getCloudStorageItem/setCloudStorageItem
- api/client.ts: initData через retrieveRawInitData() вместо window.Telegram.WebApp.initData
- AppHeader: фото пользователя через initDataUser() сигнал
- ConnectionModal: openLink через SDK вместо window.Telegram.WebApp.openLink
- ColorPicker: детекция Telegram через isInTelegramWebApp()
- Удалён интерфейс TelegramWebApp (368 строк) и Window augmentation из vite-env.d.ts
- Убрано поле telegram из PlatformContext в types.ts
- Обновлён manualChunks в vite.config.ts
This commit is contained in:
c0mrade
2026-02-04 11:06:59 +03:00
parent 2d00a5c21f
commit 023c3ef6b3
17 changed files with 497 additions and 873 deletions

View File

@@ -1,4 +1,31 @@
import { isInTelegramWebApp } from '@/hooks/useTelegramSDK';
import {
showBackButton,
hideBackButton,
onBackButtonClick,
offBackButtonClick,
isBackButtonVisible,
setMainButtonParams,
onMainButtonClick,
offMainButtonClick,
hapticFeedbackImpactOccurred,
hapticFeedbackNotificationOccurred,
hapticFeedbackSelectionChanged,
showPopup,
setMiniAppHeaderColor,
setMiniAppBottomBarColor,
themeParamsState,
getCloudStorageItem,
setCloudStorageItem,
deleteCloudStorageItem,
getCloudStorageKeys,
openInvoice,
openLink,
openTelegramLink,
shareURL,
enableClosingConfirmation,
disableClosingConfirmation,
} from '@telegram-apps/sdk-react';
import type {
PlatformContext,
PlatformCapabilities,
@@ -15,383 +42,342 @@ import type {
HapticNotificationType,
} from '@/platform/types';
// Use raw Telegram WebApp API directly - SDK has initialization issues
function getTelegram(): TelegramWebApp | null {
return window.Telegram?.WebApp ?? null;
}
function createCapabilities(): PlatformCapabilities {
const tg = getTelegram();
const inTelegram = isInTelegramWebApp();
return {
hasBackButton: inTelegram && !!tg?.BackButton,
hasMainButton: inTelegram && !!tg?.MainButton,
hasHapticFeedback: inTelegram && !!tg?.HapticFeedback,
hasNativeDialogs: inTelegram && !!tg?.showPopup,
hasBackButton: inTelegram,
hasMainButton: inTelegram,
hasHapticFeedback: inTelegram,
hasNativeDialogs: inTelegram,
hasThemeSync: inTelegram,
hasInvoice: !!tg?.openInvoice,
hasCloudStorage: inTelegram && !!tg?.CloudStorage,
hasInvoice: inTelegram,
hasCloudStorage: inTelegram,
hasShare: true,
version: tg?.version,
version: undefined,
};
}
function createBackButtonController(): BackButtonController {
const tg = getTelegram();
const inTelegram = isInTelegramWebApp();
let currentCallback: (() => void) | null = null;
return {
get isVisible() {
if (!inTelegram || !tg?.BackButton) return false;
return tg.BackButton.isVisible;
if (!inTelegram) return false;
try {
return isBackButtonVisible();
} catch {
return false;
}
},
show(onClick: () => void) {
if (!inTelegram || !tg?.BackButton) return;
if (!inTelegram) return;
// Remove previous callback if exists
if (currentCallback) {
tg.BackButton.offClick(currentCallback);
try {
offBackButtonClick(currentCallback);
} catch {
// ignore
}
}
currentCallback = onClick;
tg.BackButton.onClick(onClick);
tg.BackButton.show();
try {
onBackButtonClick(onClick);
showBackButton();
} catch {
// Back button not mounted
}
},
hide() {
if (!inTelegram || !tg?.BackButton) return;
if (!inTelegram) return;
if (currentCallback) {
tg.BackButton.offClick(currentCallback);
try {
offBackButtonClick(currentCallback);
} catch {
// ignore
}
currentCallback = null;
}
tg.BackButton.hide();
try {
hideBackButton();
} catch {
// Back button not mounted
}
},
};
}
function createMainButtonController(): MainButtonController {
const tg = getTelegram();
const inTelegram = isInTelegramWebApp();
let currentCallback: (() => void) | null = null;
return {
get isVisible() {
if (!inTelegram || !tg?.MainButton) return false;
return tg.MainButton.isVisible;
return false; // SDK v3 doesn't expose this as a simple getter
},
show(config: MainButtonConfig) {
if (!inTelegram || !tg?.MainButton) return;
if (!inTelegram) return;
// Remove previous callback if exists
if (currentCallback) {
tg.MainButton.offClick(currentCallback);
}
// Set button parameters
tg.MainButton.setText(config.text);
if (config.color) {
tg.MainButton.color = config.color;
}
if (config.textColor) {
tg.MainButton.textColor = config.textColor;
}
if (config.isActive === false) {
tg.MainButton.disable();
} else {
tg.MainButton.enable();
}
if (config.isLoading) {
tg.MainButton.showProgress();
} else {
tg.MainButton.hideProgress();
try {
offMainButtonClick(currentCallback);
} catch {
// ignore
}
}
currentCallback = config.onClick;
tg.MainButton.onClick(config.onClick);
tg.MainButton.show();
try {
setMainButtonParams({
text: config.text,
isVisible: true,
isEnabled: config.isActive !== false,
isLoaderVisible: config.isLoading ?? false,
backgroundColor: config.color as `#${string}` | undefined,
textColor: config.textColor as `#${string}` | undefined,
});
onMainButtonClick(config.onClick);
} catch {
// Main button not mounted
}
},
hide() {
if (!inTelegram || !tg?.MainButton) return;
if (!inTelegram) return;
if (currentCallback) {
tg.MainButton.offClick(currentCallback);
try {
offMainButtonClick(currentCallback);
} catch {
// ignore
}
currentCallback = null;
}
tg.MainButton.hideProgress();
tg.MainButton.hide();
try {
setMainButtonParams({ isVisible: false, isLoaderVisible: false });
} catch {
// Main button not mounted
}
},
showProgress(show: boolean) {
if (!inTelegram || !tg?.MainButton) return;
if (show) {
tg.MainButton.showProgress();
} else {
tg.MainButton.hideProgress();
if (!inTelegram) return;
try {
setMainButtonParams({ isLoaderVisible: show });
} catch {
// Main button not mounted
}
},
setText(text: string) {
if (!inTelegram || !tg?.MainButton) return;
tg.MainButton.setText(text);
if (!inTelegram) return;
try {
setMainButtonParams({ text });
} catch {
// Main button not mounted
}
},
setActive(active: boolean) {
if (!inTelegram || !tg?.MainButton) return;
if (active) {
tg.MainButton.enable();
} else {
tg.MainButton.disable();
if (!inTelegram) return;
try {
setMainButtonParams({ isEnabled: active });
} catch {
// Main button not mounted
}
},
};
}
function createHapticController(): HapticController {
const tg = getTelegram();
const inTelegram = isInTelegramWebApp();
const haptic = tg?.HapticFeedback;
return {
impact(style: HapticImpactStyle = 'medium') {
if (!inTelegram || !haptic) return;
haptic.impactOccurred(style);
if (!inTelegram) return;
try {
hapticFeedbackImpactOccurred(style);
} catch {
// Haptic not available
}
},
notification(type: HapticNotificationType) {
if (!inTelegram || !haptic) return;
haptic.notificationOccurred(type);
if (!inTelegram) return;
try {
hapticFeedbackNotificationOccurred(type);
} catch {
// Haptic not available
}
},
selection() {
if (!inTelegram || !haptic) return;
haptic.selectionChanged();
if (!inTelegram) return;
try {
hapticFeedbackSelectionChanged();
} catch {
// Haptic not available
}
},
};
}
function createDialogController(): DialogController {
const inTelegram = isInTelegramWebApp();
let popupOpen = false;
// Persistent listener: resets popupOpen whenever Telegram reports popup closed,
// even if our per-call listener was already removed.
if (inTelegram) {
const tg = getTelegram();
if (tg?.onEvent) {
tg.onEvent('popupClosed', (() => {
popupOpen = false;
}) as (...args: unknown[]) => void);
}
}
function showPopupSafe<T>(
params: Parameters<NonNullable<TelegramWebApp['showPopup']>>[0],
mapResult: (buttonId: string) => T,
fallback: () => T,
): Promise<T> {
const tg = getTelegram();
if (!inTelegram || !tg?.showPopup) {
return Promise.resolve(fallback());
}
if (popupOpen) {
return Promise.resolve(mapResult(''));
}
return new Promise((resolve) => {
popupOpen = true;
let settled = false;
const cleanup = () => {
if (settled) return;
settled = true;
popupOpen = false;
clearTimeout(timer);
tg.offEvent?.('popupClosed', onPopupClosed);
};
const onPopupClosed = ((...args: unknown[]) => {
if (settled) return;
const event = args[0] as { button_id?: string } | undefined;
const buttonId = event?.button_id ?? '';
cleanup();
resolve(mapResult(buttonId));
}) as (...args: unknown[]) => void;
const timer = setTimeout(() => {
if (settled) return;
cleanup();
resolve(mapResult(''));
}, 30_000);
tg.onEvent?.('popupClosed', onPopupClosed);
try {
tg.showPopup(params);
} catch (e) {
// Telegram SDK says a popup is already open — don't reset popupOpen,
// so subsequent calls are silently blocked until the popup actually closes.
const isAlreadyOpen = e instanceof Error && e.message?.includes('WebAppPopupOpened');
if (isAlreadyOpen) {
settled = true;
clearTimeout(timer);
tg.offEvent?.('popupClosed', onPopupClosed);
// popupOpen stays true — the persistent listener will reset it
resolve(mapResult(''));
return;
}
cleanup();
resolve(fallback());
}
});
}
return {
alert(message: string, _title?: string): Promise<void> {
return showPopupSafe(
{ message, buttons: [{ type: 'ok' }] },
() => undefined,
() => {
window.alert(message);
},
);
async alert(message: string, _title?: string): Promise<void> {
if (!inTelegram) {
window.alert(message);
return;
}
try {
await showPopup({
message,
buttons: [{ type: 'ok', id: 'ok' }],
});
} catch {
window.alert(message);
}
},
confirm(message: string, _title?: string): Promise<boolean> {
return showPopupSafe(
{
async confirm(message: string, _title?: string): Promise<boolean> {
if (!inTelegram) {
return window.confirm(message);
}
try {
const buttonId = await showPopup({
message,
buttons: [
{ id: 'ok', type: 'ok' },
{ id: 'cancel', type: 'cancel' },
{ type: 'ok', id: 'ok' },
{ type: 'cancel', id: 'cancel' },
],
},
(buttonId) => buttonId === 'ok',
() => window.confirm(message),
);
});
return buttonId === 'ok';
} catch {
return window.confirm(message);
}
},
popup(options: PopupOptions): Promise<string | null> {
return showPopupSafe(
{
async popup(options: PopupOptions): Promise<string | null> {
if (!inTelegram) {
return window.confirm(options.message) ? 'ok' : null;
}
try {
const buttons = options.buttons?.map((btn) => {
// For 'ok', 'close', 'cancel' types: do NOT include text
// For 'default', 'destructive' types: text is required
if (btn.type === 'ok' || btn.type === 'close' || btn.type === 'cancel') {
return { type: btn.type, id: btn.id };
}
return { type: btn.type ?? ('default' as const), id: btn.id, text: btn.text };
});
const buttonId = await showPopup({
title: options.title,
message: options.message,
buttons: options.buttons?.map((btn) => ({
id: btn.id,
type: btn.type,
text: btn.text,
})),
},
(buttonId) => buttonId || null,
() => (window.confirm(options.message) ? 'ok' : null),
);
buttons: buttons as Parameters<typeof showPopup>[0]['buttons'],
});
return buttonId || null;
} catch {
return window.confirm(options.message) ? 'ok' : null;
}
},
};
}
function createThemeController(): ThemeController {
const tg = getTelegram();
const inTelegram = isInTelegramWebApp();
return {
setHeaderColor(color: string) {
if (!inTelegram || !tg?.setHeaderColor) return;
tg.setHeaderColor(color as `#${string}`);
if (!inTelegram) return;
try {
setMiniAppHeaderColor(color as `#${string}`);
} catch {
// Not supported
}
},
setBottomBarColor(color: string) {
if (!inTelegram || !tg?.setBottomBarColor) return;
if (!inTelegram) return;
try {
tg.setBottomBarColor(color as `#${string}`);
setMiniAppBottomBarColor(color as `#${string}`);
} catch {
// Not supported in this version
// Not supported
}
},
getThemeParams() {
if (!inTelegram || !tg?.themeParams) return null;
const params = tg.themeParams;
return {
bg_color: params.bg_color,
text_color: params.text_color,
hint_color: params.hint_color,
link_color: params.link_color,
button_color: params.button_color,
button_text_color: params.button_text_color,
secondary_bg_color: params.secondary_bg_color,
header_bg_color: params.header_bg_color,
bottom_bar_bg_color: params.bottom_bar_bg_color,
accent_text_color: params.accent_text_color,
section_bg_color: params.section_bg_color,
section_header_text_color: params.section_header_text_color,
subtitle_text_color: params.subtitle_text_color,
destructive_text_color: params.destructive_text_color,
};
if (!inTelegram) return null;
try {
const params = themeParamsState();
if (!params) return null;
// SDK v3 uses camelCase — convert to snake_case for our interface
return {
bg_color: params.bgColor,
text_color: params.textColor,
hint_color: params.hintColor,
link_color: params.linkColor,
button_color: params.buttonColor,
button_text_color: params.buttonTextColor,
secondary_bg_color: params.secondaryBgColor,
header_bg_color: params.headerBgColor,
bottom_bar_bg_color: params.bottomBarBgColor,
accent_text_color: params.accentTextColor,
section_bg_color: params.sectionBgColor,
section_header_text_color: params.sectionHeaderTextColor,
subtitle_text_color: params.subtitleTextColor,
destructive_text_color: params.destructiveTextColor,
};
} catch {
return null;
}
},
};
}
function createCloudStorageController(): CloudStorageController | null {
const tg = getTelegram();
const inTelegram = isInTelegramWebApp();
const storage = tg?.CloudStorage;
if (!inTelegram || !storage) return null;
if (!inTelegram) return null;
return {
async getItem(key: string): Promise<string | null> {
return new Promise((resolve) => {
storage.getItem(key, (error, value) => {
resolve(error ? null : value || null);
});
});
try {
const value = await getCloudStorageItem(key);
return value === '' ? null : value;
} catch {
return null;
}
},
async setItem(key: string, value: string): Promise<void> {
return new Promise((resolve, reject) => {
storage.setItem(key, value, (error) => {
if (error) reject(new Error(String(error)));
else resolve();
});
});
await setCloudStorageItem(key, value);
},
async removeItem(key: string): Promise<void> {
return new Promise((resolve, reject) => {
storage.removeItem(key, (error) => {
if (error) reject(new Error(String(error)));
else resolve();
});
});
await deleteCloudStorageItem(key);
},
async getKeys(): Promise<string[]> {
return new Promise((resolve) => {
storage.getKeys((error, keys) => {
resolve(error ? [] : keys || []);
});
});
try {
return await getCloudStorageKeys();
} catch {
return [];
}
},
};
}
export function createTelegramAdapter(): PlatformContext {
const tg = getTelegram();
return {
platform: 'telegram',
capabilities: createCapabilities(),
@@ -403,28 +389,26 @@ export function createTelegramAdapter(): PlatformContext {
cloudStorage: createCloudStorageController(),
openInvoice(url: string): Promise<InvoiceStatus> {
return new Promise((resolve) => {
if (tg?.openInvoice) {
tg.openInvoice(url, (status) => resolve(status));
} else {
window.open(url, '_blank');
resolve('pending');
}
});
try {
return openInvoice(url, 'url') as Promise<InvoiceStatus>;
} catch {
window.open(url, '_blank');
return Promise.resolve('pending');
}
},
openLink(url: string, options?: { tryInstantView?: boolean }) {
if (tg?.openLink) {
tg.openLink(url, { try_instant_view: options?.tryInstantView });
} else {
try {
openLink(url, { tryInstantView: options?.tryInstantView });
} catch {
window.open(url, '_blank');
}
},
openTelegramLink(url: string) {
if (tg?.openTelegramLink) {
tg.openTelegramLink(url);
} else {
try {
openTelegramLink(url);
} catch {
window.open(url, '_blank');
}
},
@@ -441,24 +425,24 @@ export function createTelegramAdapter(): PlatformContext {
}
}
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME;
if (botUsername && tg?.openTelegramLink) {
const encoded = encodeURIComponent(shareText);
tg.openTelegramLink(`https://t.me/share/url?url=${encoded}`);
try {
shareURL(url || shareText, text);
return true;
} catch {
return false;
}
return false;
},
setClosingConfirmation(enabled: boolean) {
if (enabled) {
tg?.enableClosingConfirmation?.();
} else {
tg?.disableClosingConfirmation?.();
try {
if (enabled) {
enableClosingConfirmation();
} else {
disableClosingConfirmation();
}
} catch {
// Not supported
}
},
telegram: tg,
};
}

View File

@@ -271,7 +271,5 @@ export function createWebAdapter(): PlatformContext {
window.onbeforeunload = null;
}
},
telegram: null,
};
}

View File

@@ -131,10 +131,4 @@ export interface PlatformContext {
// Closing confirmation
setClosingConfirmation: (enabled: boolean) => void;
// Raw Telegram WebApp access (null in web)
telegram: TelegramWebApp | null;
}
// Note: TelegramWebApp types are defined in vite-env.d.ts
// This file only re-exports the interface from the global scope