From 71647ebc8795fc57f958d4fdedfb2f9c0b23837e Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 08:54:23 +0300 Subject: [PATCH 1/9] fix: prevent duplicate Telegram popup opening Add popup guard in TelegramAdapter DialogController to prevent "Popup is already opened" error. Migrate PromoOffersSection from direct showPopup call to useDestructiveConfirm hook. --- src/components/PromoOffersSection.tsx | 199 ++--------------------- src/platform/adapters/TelegramAdapter.ts | 95 ++++++----- 2 files changed, 68 insertions(+), 226 deletions(-) diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx index f47622f..ebba2ef 100644 --- a/src/components/PromoOffersSection.tsx +++ b/src/components/PromoOffersSection.tsx @@ -1,11 +1,10 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { createPortal } from 'react-dom'; import { promoApi, PromoOffer } from '../api/promo'; import { ClockIcon, CheckIcon } from './icons'; -import { useTelegramSDK } from '../hooks/useTelegramSDK'; +import { useDestructiveConfirm } from '@/platform/hooks/useNativeDialog'; // Helper functions const formatTimeLeft = ( @@ -81,140 +80,6 @@ const XCircleIcon = () => ( ); -const WarningIcon = () => ( - - - -); - -// ------- Confirmation Modal ------- - -interface DeactivateConfirmModalProps { - isOpen: boolean; - discountPercent: number; - isDeactivating: boolean; - onConfirm: () => void; - onCancel: () => void; -} - -function DeactivateConfirmModal({ - isOpen, - discountPercent, - isDeactivating, - onConfirm, - onCancel, -}: DeactivateConfirmModalProps) { - const { t } = useTranslation(); - - // Escape key to close - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' && !isDeactivating) { - e.preventDefault(); - onCancel(); - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [isOpen, isDeactivating, onCancel]); - - // Scroll lock - useEffect(() => { - if (!isOpen) return; - - document.body.style.overflow = 'hidden'; - return () => { - document.body.style.overflow = ''; - }; - }, [isOpen]); - - if (!isOpen) return null; - - const modalContent = ( -
- {/* Backdrop */} -
- - {/* Modal */} -
e.stopPropagation()} - > - {/* Warning header */} -
-
- -
-

- {t('promo.deactivate.confirmTitle')} -

-

- {t('promo.deactivate.confirmDescription', { percent: discountPercent })} -

-
- - {/* Warning text */} -
-
-

{t('promo.deactivate.warning')}

-
-
- - {/* Action buttons */} -
- - -
-
-
- ); - - if (typeof document !== 'undefined') { - return createPortal(modalContent, document.body); - } - return modalContent; -} - // ------- Main Component ------- interface PromoOffersSectionProps { @@ -225,11 +90,10 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const { isTelegramWebApp } = useTelegramSDK(); + const destructiveConfirm = useDestructiveConfirm(); const [claimingId, setClaimingId] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); - const [showConfirmModal, setShowConfirmModal] = useState(false); // Fetch available offers const { data: offers = [], isLoading: offersLoading } = useQuery({ @@ -272,14 +136,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio queryClient.invalidateQueries({ queryKey: ['promo-offers'] }); queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); - setShowConfirmModal(false); + setSuccessMessage(t('promo.deactivate.success')); setTimeout(() => setSuccessMessage(null), 5000); }, onError: (error: unknown) => { const axiosErr = error as { response?: { data?: { detail?: string } } }; setErrorMessage(axiosErr.response?.data?.detail || t('promo.deactivate.error')); - setShowConfirmModal(false); + setTimeout(() => setErrorMessage(null), 5000); }, }); @@ -295,42 +159,20 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio navigate('/subscription', { state: { scrollToExtend: true } }); }; - const handleDeactivateClick = () => { + const handleDeactivateClick = async () => { setErrorMessage(null); setSuccessMessage(null); - // Use Telegram native popup in Mini App - if (isTelegramWebApp && window.Telegram?.WebApp?.showPopup) { - window.Telegram.WebApp.showPopup( - { - title: t('promo.deactivate.confirmTitle'), - message: t('promo.deactivate.confirmDescription', { - percent: activeDiscount?.discount_percent || 0, - }), - buttons: [ - { id: 'cancel', type: 'cancel' }, - { id: 'confirm', type: 'destructive', text: t('promo.deactivate.confirm') }, - ], - }, - (button_id: string) => { - if (button_id === 'confirm') { - deactivateMutation.mutate(); - } - }, - ); - } else { - // Fallback to modal for web - setShowConfirmModal(true); - } - }; + const confirmed = await destructiveConfirm( + t('promo.deactivate.confirmDescription', { + percent: activeDiscount?.discount_percent || 0, + }), + t('promo.deactivate.confirm'), + t('promo.deactivate.confirmTitle'), + ); - const handleConfirmDeactivate = () => { - deactivateMutation.mutate(); - }; - - const handleCloseConfirmModal = () => { - if (!deactivateMutation.isPending) { - setShowConfirmModal(false); + if (confirmed) { + deactivateMutation.mutate(); } }; @@ -485,17 +327,6 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
)} - - {/* Deactivation Confirmation Modal */} - {activeDiscount && ( - - )} ); } diff --git a/src/platform/adapters/TelegramAdapter.ts b/src/platform/adapters/TelegramAdapter.ts index 9dc3e7e..c6f36ba 100644 --- a/src/platform/adapters/TelegramAdapter.ts +++ b/src/platform/adapters/TelegramAdapter.ts @@ -183,58 +183,69 @@ function createHapticController(): HapticController { function createDialogController(): DialogController { const tg = getTelegram(); const inTelegram = isInTelegramWebApp(); + let popupOpen = false; + + function showPopupSafe( + params: Parameters>[0], + mapResult: (buttonId: string) => T, + fallback: () => T, + ): Promise { + if (!inTelegram || !tg?.showPopup) { + return Promise.resolve(fallback()); + } + + if (popupOpen) { + return Promise.resolve(mapResult('')); + } + + return new Promise((resolve) => { + popupOpen = true; + tg.showPopup(params, (buttonId) => { + popupOpen = false; + resolve(mapResult(buttonId)); + }); + }); + } return { alert(message: string, _title?: string): Promise { - return new Promise((resolve) => { - if (inTelegram && tg?.showPopup) { - tg.showPopup({ message, buttons: [{ type: 'ok' }] }, () => resolve()); - } else { + return showPopupSafe( + { message, buttons: [{ type: 'ok' }] }, + () => undefined, + () => { window.alert(message); - resolve(); - } - }); + }, + ); }, confirm(message: string, _title?: string): Promise { - return new Promise((resolve) => { - if (inTelegram && tg?.showPopup) { - tg.showPopup( - { - message, - buttons: [ - { id: 'ok', type: 'ok' }, - { id: 'cancel', type: 'cancel' }, - ], - }, - (buttonId) => resolve(buttonId === 'ok'), - ); - } else { - resolve(window.confirm(message)); - } - }); + return showPopupSafe( + { + message, + buttons: [ + { id: 'ok', type: 'ok' }, + { id: 'cancel', type: 'cancel' }, + ], + }, + (buttonId) => buttonId === 'ok', + () => window.confirm(message), + ); }, popup(options: PopupOptions): Promise { - return new Promise((resolve) => { - if (inTelegram && tg?.showPopup) { - tg.showPopup( - { - title: options.title, - message: options.message, - buttons: options.buttons?.map((btn) => ({ - id: btn.id, - type: btn.type, - text: btn.text, - })), - }, - (buttonId) => resolve(buttonId ?? null), - ); - } else { - const confirmed = window.confirm(options.message); - resolve(confirmed ? 'ok' : null); - } - }); + return showPopupSafe( + { + 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), + ); }, }; } From ef77276246fdb60255f625289542584b83c93fcc Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 09:20:49 +0300 Subject: [PATCH 2/9] fix: use direct dialog.popup call instead of useDestructiveConfirm Avoid async/await hook chain that could lose the popup callback. Call dialog.popup().then() directly from usePlatform for reliability. --- src/components/PromoOffersSection.tsx | 42 ++++++++++++++++++--------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx index ebba2ef..f14ee55 100644 --- a/src/components/PromoOffersSection.tsx +++ b/src/components/PromoOffersSection.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { promoApi, PromoOffer } from '../api/promo'; import { ClockIcon, CheckIcon } from './icons'; -import { useDestructiveConfirm } from '@/platform/hooks/useNativeDialog'; +import { usePlatform } from '@/platform/hooks/usePlatform'; // Helper functions const formatTimeLeft = ( @@ -90,7 +90,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); - const destructiveConfirm = useDestructiveConfirm(); + const { dialog, capabilities } = usePlatform(); const [claimingId, setClaimingId] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); @@ -159,20 +159,36 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio navigate('/subscription', { state: { scrollToExtend: true } }); }; - const handleDeactivateClick = async () => { + const handleDeactivateClick = () => { setErrorMessage(null); setSuccessMessage(null); - const confirmed = await destructiveConfirm( - t('promo.deactivate.confirmDescription', { - percent: activeDiscount?.discount_percent || 0, - }), - t('promo.deactivate.confirm'), - t('promo.deactivate.confirmTitle'), - ); - - if (confirmed) { - deactivateMutation.mutate(); + if (capabilities.hasNativeDialogs) { + dialog + .popup({ + title: t('promo.deactivate.confirmTitle'), + message: t('promo.deactivate.confirmDescription', { + percent: activeDiscount?.discount_percent || 0, + }), + buttons: [ + { id: 'cancel', type: 'cancel', text: '' }, + { id: 'confirm', type: 'destructive', text: t('promo.deactivate.confirm') }, + ], + }) + .then((buttonId) => { + if (buttonId === 'confirm') { + deactivateMutation.mutate(); + } + }); + } else { + const confirmed = window.confirm( + t('promo.deactivate.confirmDescription', { + percent: activeDiscount?.discount_percent || 0, + }), + ); + if (confirmed) { + deactivateMutation.mutate(); + } } }; From 792fb1ed8a1cc4d4a5350556308e00e8cad5313a Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 09:27:04 +0300 Subject: [PATCH 3/9] fix: get fresh Telegram WebApp reference on each popup call Telegram may reinitialize window.Telegram.WebApp after app startup, making cached references stale. Get a fresh reference inside showPopupSafe to ensure the callback is registered on the current WebApp instance. --- src/platform/adapters/TelegramAdapter.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/platform/adapters/TelegramAdapter.ts b/src/platform/adapters/TelegramAdapter.ts index c6f36ba..cf7f688 100644 --- a/src/platform/adapters/TelegramAdapter.ts +++ b/src/platform/adapters/TelegramAdapter.ts @@ -181,7 +181,6 @@ function createHapticController(): HapticController { } function createDialogController(): DialogController { - const tg = getTelegram(); const inTelegram = isInTelegramWebApp(); let popupOpen = false; @@ -190,6 +189,8 @@ function createDialogController(): DialogController { mapResult: (buttonId: string) => T, fallback: () => T, ): Promise { + const tg = getTelegram(); + if (!inTelegram || !tg?.showPopup) { return Promise.resolve(fallback()); } From 7ac7db4ddb2950216334be01db37f185594bea6e Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 09:45:57 +0300 Subject: [PATCH 4/9] fix: add fallback recovery for Telegram popup callback not firing Listen for popupClosed event as backup and add safety timeout to handle Telegram Desktop bug where popup_closed payload is undefined, preventing the callback from being invoked and leaving popup state permanently locked. --- src/platform/adapters/TelegramAdapter.ts | 40 ++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/platform/adapters/TelegramAdapter.ts b/src/platform/adapters/TelegramAdapter.ts index cf7f688..2a901f7 100644 --- a/src/platform/adapters/TelegramAdapter.ts +++ b/src/platform/adapters/TelegramAdapter.ts @@ -201,10 +201,46 @@ function createDialogController(): DialogController { return new Promise((resolve) => { popupOpen = true; - tg.showPopup(params, (buttonId) => { + let settled = false; + + const cleanup = () => { + if (settled) return; + settled = true; popupOpen = false; + clearTimeout(timer); + tg.offEvent?.('popupClosed', onPopupClosed); + }; + + // Fallback: listen for popupClosed event in case the callback doesn't fire + // (known Telegram Desktop bug: popup_closed payload can be undefined, + // crashing the internal handler before it invokes the callback) + 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; + + // Safety timeout: reset state if neither callback nor event arrives + const timer = setTimeout(() => { + if (settled) return; + cleanup(); + resolve(mapResult('')); + }, 30_000); + + tg.onEvent?.('popupClosed', onPopupClosed); + + try { + tg.showPopup(params, (buttonId) => { + if (settled) return; + cleanup(); + resolve(mapResult(buttonId)); + }); + } catch { + cleanup(); + resolve(fallback()); + } }); } From 9284eca810ed3d308360c5761479b61dd69edd66 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 09:56:46 +0300 Subject: [PATCH 5/9] debug: add console logging to Telegram popup flow Trace showPopup lifecycle (open, callback, popupClosed event, timeout) to diagnose why popup action doesn't execute and subsequent calls fail. Reduce safety timeout to 5s for faster debugging. --- src/platform/adapters/TelegramAdapter.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/platform/adapters/TelegramAdapter.ts b/src/platform/adapters/TelegramAdapter.ts index 2a901f7..f4f29dc 100644 --- a/src/platform/adapters/TelegramAdapter.ts +++ b/src/platform/adapters/TelegramAdapter.ts @@ -203,6 +203,8 @@ function createDialogController(): DialogController { popupOpen = true; let settled = false; + console.log('[popup] opening', JSON.stringify(params)); + const cleanup = () => { if (settled) return; settled = true; @@ -211,10 +213,8 @@ function createDialogController(): DialogController { tg.offEvent?.('popupClosed', onPopupClosed); }; - // Fallback: listen for popupClosed event in case the callback doesn't fire - // (known Telegram Desktop bug: popup_closed payload can be undefined, - // crashing the internal handler before it invokes the callback) const onPopupClosed = ((...args: unknown[]) => { + console.log('[popup] popupClosed event', JSON.stringify(args)); if (settled) return; const event = args[0] as { button_id?: string } | undefined; const buttonId = event?.button_id ?? ''; @@ -222,22 +222,25 @@ function createDialogController(): DialogController { resolve(mapResult(buttonId)); }) as (...args: unknown[]) => void; - // Safety timeout: reset state if neither callback nor event arrives const timer = setTimeout(() => { + console.log('[popup] timeout — force reset'); if (settled) return; cleanup(); resolve(mapResult('')); - }, 30_000); + }, 5_000); tg.onEvent?.('popupClosed', onPopupClosed); try { tg.showPopup(params, (buttonId) => { + console.log('[popup] callback fired, buttonId=', buttonId); if (settled) return; cleanup(); resolve(mapResult(buttonId)); }); - } catch { + console.log('[popup] showPopup called OK'); + } catch (e) { + console.error('[popup] showPopup threw', e); cleanup(); resolve(fallback()); } From 2d00a5c21fff339f229d8fe2001a898d15722cdd Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 10:10:07 +0300 Subject: [PATCH 6/9] fix: prevent popup cascade when Telegram callback doesn't fire - Add persistent popupClosed listener that resets state even after per-call listener is removed by timeout - Increase safety timeout from 5s to 30s to give user time to interact - On WebAppPopupOpened error, keep popupOpen=true to block retries instead of resetting and allowing infinite error loop - Remove callback param from showPopup to avoid dual-listener conflicts - Remove debug console logs --- src/platform/adapters/TelegramAdapter.ts | 39 ++++++++++++++++-------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/platform/adapters/TelegramAdapter.ts b/src/platform/adapters/TelegramAdapter.ts index f4f29dc..e6be1b7 100644 --- a/src/platform/adapters/TelegramAdapter.ts +++ b/src/platform/adapters/TelegramAdapter.ts @@ -184,6 +184,17 @@ 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( params: Parameters>[0], mapResult: (buttonId: string) => T, @@ -203,8 +214,6 @@ function createDialogController(): DialogController { popupOpen = true; let settled = false; - console.log('[popup] opening', JSON.stringify(params)); - const cleanup = () => { if (settled) return; settled = true; @@ -214,7 +223,6 @@ function createDialogController(): DialogController { }; const onPopupClosed = ((...args: unknown[]) => { - console.log('[popup] popupClosed event', JSON.stringify(args)); if (settled) return; const event = args[0] as { button_id?: string } | undefined; const buttonId = event?.button_id ?? ''; @@ -223,24 +231,29 @@ function createDialogController(): DialogController { }) as (...args: unknown[]) => void; const timer = setTimeout(() => { - console.log('[popup] timeout — force reset'); if (settled) return; cleanup(); resolve(mapResult('')); - }, 5_000); + }, 30_000); tg.onEvent?.('popupClosed', onPopupClosed); try { - tg.showPopup(params, (buttonId) => { - console.log('[popup] callback fired, buttonId=', buttonId); - if (settled) return; - cleanup(); - resolve(mapResult(buttonId)); - }); - console.log('[popup] showPopup called OK'); + tg.showPopup(params); } catch (e) { - console.error('[popup] showPopup threw', 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()); } From 023c3ef6b356b38ef5169c38a01e5b2b8bc4891d Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 11:06:59 +0300 Subject: [PATCH 7/9] refactor: migrate to @telegram-apps/sdk-react v3, remove all window.Telegram.WebApp usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Полный переход с нативного 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 --- package-lock.json | 25 - package.json | 1 - src/AppWithNavigator.tsx | 138 ++--- src/api/client.ts | 13 +- src/components/ColorPicker.tsx | 8 +- src/components/ConnectionModal.tsx | 19 +- src/components/layout/AppShell/AppHeader.tsx | 16 +- src/hooks/useTelegramSDK.ts | 191 ++++--- src/hooks/useTelegramWebApp.ts | 4 +- src/main.tsx | 58 ++- src/pages/Login.tsx | 2 +- src/pages/TelegramRedirect.tsx | 2 +- src/platform/adapters/TelegramAdapter.ts | 510 +++++++++---------- src/platform/adapters/WebAdapter.ts | 2 - src/platform/types.ts | 6 - src/vite-env.d.ts | 369 -------------- vite.config.ts | 6 +- 17 files changed, 497 insertions(+), 873 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0b676a4..113a599 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,6 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.8.0", - "@telegram-apps/react-router-integration": "^1.0.1", "@telegram-apps/sdk-react": "^3.3.9", "axios": "^1.6.0", "class-variance-authority": "^0.7.1", @@ -2640,30 +2639,6 @@ "integrity": "sha512-bqNgF/J8Po7ZtsELm3E1a6aPr7awwxO3sIqD8J6u12urOlGoW5+1KxKKbkPa58mgXuQdsltd8apI+OVy0IYiUA==", "license": "MIT" }, - "node_modules/@telegram-apps/react-router-integration": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@telegram-apps/react-router-integration/-/react-router-integration-1.0.1.tgz", - "integrity": "sha512-q5fC0ihXyUDqrSdLQ3u3gypmjI/rMU2mTHeAz9IU1F1bl5X+J17LxWmgLp+OlD0Y97bq2XYmc+UbKrYh+eN3Wg==", - "license": "MIT", - "peerDependencies": { - "@telegram-apps/sdk": "^1", - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0", - "react-router-dom": "^6.22.3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@telegram-apps/sdk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@telegram-apps/sdk/-/sdk-1.1.3.tgz", - "integrity": "sha512-gvcXNttbCouDNTxjDUbO5FS/BdaHYO0ibSddQxiYitShPIDZDxGon0Eh3OSsgUIVPSQSNdXqkXaeqYgA+jeY0A==", - "license": "MIT", - "peer": true - }, "node_modules/@telegram-apps/sdk-react": { "version": "3.3.9", "resolved": "https://registry.npmjs.org/@telegram-apps/sdk-react/-/sdk-react-3.3.9.tgz", diff --git a/package.json b/package.json index 9a92f4e..b10e970 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.8.0", - "@telegram-apps/react-router-integration": "^1.0.1", "@telegram-apps/sdk-react": "^3.3.9", "axios": "^1.6.0", "class-variance-authority": "^0.7.1", diff --git a/src/AppWithNavigator.tsx b/src/AppWithNavigator.tsx index fb05276..11ae689 100644 --- a/src/AppWithNavigator.tsx +++ b/src/AppWithNavigator.tsx @@ -1,102 +1,76 @@ -import { useEffect, useMemo } from 'react'; -import { BrowserRouter, Router } from 'react-router-dom'; -import { useIntegration } from '@telegram-apps/react-router-integration'; -import { initNavigator } from '@telegram-apps/sdk'; +import { useEffect } from 'react'; +import { BrowserRouter, useLocation, useNavigate } from 'react-router-dom'; +import { + showBackButton, + hideBackButton, + onBackButtonClick, + offBackButtonClick, +} from '@telegram-apps/sdk-react'; import App from './App'; import { PlatformProvider } from './platform/PlatformProvider'; import { ThemeColorsProvider } from './providers/ThemeColorsProvider'; import { WebSocketProvider } from './providers/WebSocketProvider'; import { ToastProvider } from './components/Toast'; import { TooltipProvider } from './components/primitives/Tooltip'; +import { isInTelegramWebApp } from './hooks/useTelegramSDK'; /** - * Check if running inside Telegram Mini App - * Uses multiple checks to reliably detect Telegram environment + * Manages Telegram BackButton visibility based on navigation location. + * Shows back button on non-root routes, hides on root. */ -function isTelegramMiniApp(): boolean { - if (typeof window === 'undefined') return false; - - const webApp = window.Telegram?.WebApp; - if (!webApp) return false; - - // Check 1: initDataUnsafe should have user data in real Telegram - const hasUserData = webApp.initDataUnsafe?.user?.id !== undefined; - - // Check 2: Platform should not be 'unknown' (which is default in browser) - const validPlatform = webApp.platform !== 'unknown' && webApp.platform !== ''; - - // Check 3: Version should be present (SDK loads in Telegram only) - const hasVersion = webApp.version !== undefined && webApp.version !== ''; - - return hasUserData || (validPlatform && hasVersion); -} - -/** - * Component wrapper for Telegram navigator setup. - * Integrates Telegram Mini Apps navigator with React Router to provide - * automatic BackButton management based on navigation history. - * Falls back to BrowserRouter when not in Telegram Mini App. - */ -/** - * Navigator-based router for Telegram Mini App - */ -function TelegramRouter({ children }: { children: React.ReactNode }) { - const navigator = useMemo(() => initNavigator('app-navigation-state', { hashMode: null }), []); - const [location, reactNavigator] = useIntegration(navigator); +function TelegramBackButton() { + const location = useLocation(); + const navigate = useNavigate(); useEffect(() => { + const isRoot = location.pathname === '/' || location.pathname === ''; try { - navigator.attach(); - return () => { - try { - navigator.detach(); - } catch (err) { - console.warn('Failed to detach navigator:', err); - } - }; - } catch (err) { - console.warn('Failed to attach navigator:', err); + if (isRoot) { + hideBackButton(); + } else { + showBackButton(); + } + } catch { + // Not in Telegram or back button not mounted } - }, [navigator]); + }, [location]); - return ( - - {children} - - ); + useEffect(() => { + const handler = () => navigate(-1); + try { + onBackButtonClick(handler); + } catch { + // Not in Telegram + } + return () => { + try { + offBackButtonClick(handler); + } catch { + // Not in Telegram + } + }; + }, [navigate]); + + return null; } export function AppWithNavigator() { - const isTelegram = useMemo(() => { - const result = isTelegramMiniApp(); - console.log('[AppWithNavigator] Platform detection:', { - isTelegram: result, - platform: window.Telegram?.WebApp?.platform, - hasUser: window.Telegram?.WebApp?.initDataUnsafe?.user?.id !== undefined, - version: window.Telegram?.WebApp?.version, - }); - return result; - }, []); + const isTelegram = isInTelegramWebApp(); - // Common app content - const appContent = ( - - - - - - - - - - - + return ( + + {isTelegram && } + + + + + + + + + + + + ); - - // Use Telegram navigator in Mini App, BrowserRouter elsewhere - if (isTelegram) { - return {appContent}; - } - - return {appContent}; } diff --git a/src/api/client.ts b/src/api/client.ts index 40775ac..49d7b0a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,5 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { retrieveRawInitData } from '@telegram-apps/sdk-react'; import { tokenStorage, isTokenExpired, @@ -41,10 +42,14 @@ function ensureCsrfToken(): string { const getTelegramInitData = (): string | null => { if (typeof window === 'undefined') return null; - const initData = window.Telegram?.WebApp?.initData; - if (initData) { - tokenStorage.setTelegramInitData(initData); - return initData; + try { + const raw = retrieveRawInitData(); + if (raw) { + tokenStorage.setTelegramInitData(raw); + return raw; + } + } catch { + // Not in Telegram or SDK not initialized } return tokenStorage.getTelegramInitData(); diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 69de3e5..080e96e 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; +import { isInTelegramWebApp } from '@/hooks/useTelegramSDK'; interface ColorPickerProps { value: string; @@ -9,11 +10,6 @@ interface ColorPickerProps { disabled?: boolean; } -// Check if running in Telegram WebApp -const isTelegramWebApp = (): boolean => { - return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp; -}; - // Convert hex to RGB const hexToRgb = (hex: string): { r: number; g: number; b: number } => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); @@ -117,7 +113,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C const pickerRef = useRef(null); const colorInputRef = useRef(null); - const isTelegram = useMemo(() => isTelegramWebApp(), []); + const isTelegram = useMemo(() => isInTelegramWebApp(), []); // Sync with external value useEffect(() => { diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 57254a1..7818ce3 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; +import { openLink as sdkOpenLink } from '@telegram-apps/sdk-react'; import { subscriptionApi } from '../api/subscription'; import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; import { useBackButton, useHaptic } from '@/platform'; @@ -300,19 +301,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // actual deep link URL. This works for both http(s) and custom schemes. const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(deepLink)}&lang=${i18n.language || 'en'}`; - const tg = ( - window as unknown as { - Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } }; - } - ).Telegram?.WebApp; - - if (tg?.openLink) { - try { - tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }); - return; - } catch { - /* fallback */ - } + try { + sdkOpenLink(redirectUrl, { tryInstantView: false }); + return; + } catch { + // SDK not available, fallback } window.location.href = redirectUrl; }; diff --git a/src/components/layout/AppShell/AppHeader.tsx b/src/components/layout/AppShell/AppHeader.tsx index 002b32d..68fc13d 100644 --- a/src/components/layout/AppShell/AppHeader.tsx +++ b/src/components/layout/AppShell/AppHeader.tsx @@ -2,6 +2,7 @@ import { Link, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { useState, useEffect } from 'react'; +import { initDataUser } from '@telegram-apps/sdk-react'; import { useAuthStore } from '@/store/auth'; import { useTheme } from '@/hooks/useTheme'; @@ -113,17 +114,12 @@ export function AppHeader({ // Get user photo from Telegram useEffect(() => { try { - const tg = ( - window as unknown as { - Telegram?: { WebApp?: { initDataUnsafe?: { user?: { photo_url?: string } } } }; - } - ).Telegram?.WebApp; - const photoUrl = tg?.initDataUnsafe?.user?.photo_url; - if (photoUrl) { - setUserPhotoUrl(photoUrl); + const user = initDataUser(); + if (user?.photo_url) { + setUserPhotoUrl(user.photo_url); } - } catch (e) { - console.warn('Failed to get Telegram user photo:', e); + } catch { + // Not in Telegram or init data not available } }, []); diff --git a/src/hooks/useTelegramSDK.ts b/src/hooks/useTelegramSDK.ts index 73cdcbe..162dc54 100644 --- a/src/hooks/useTelegramSDK.ts +++ b/src/hooks/useTelegramSDK.ts @@ -1,4 +1,20 @@ import { useCallback, useMemo } from 'react'; +import { + useSignal, + isFullscreen as isFullscreenSignal, + viewportHeight as viewportHeightSignal, + viewportStableHeight as viewportStableHeightSignal, + isViewportExpanded as isViewportExpandedSignal, + viewportSafeAreaInsets, + viewportContentSafeAreaInsets, + requestFullscreen as sdkRequestFullscreen, + exitFullscreen as sdkExitFullscreen, + disableVerticalSwipes as sdkDisableVerticalSwipes, + enableVerticalSwipes as sdkEnableVerticalSwipes, + expandViewport, + retrieveLaunchParams, + retrieveRawInitData, +} from '@telegram-apps/sdk-react'; const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'; @@ -24,60 +40,47 @@ export const setCachedFullscreenEnabled = (enabled: boolean) => { } }; +// Cached detection result (evaluated once at module load) +let _isInTelegram: boolean | null = null; +function detectTelegram(): boolean { + if (_isInTelegram === null) { + try { + retrieveLaunchParams(); + _isInTelegram = true; + } catch { + _isInTelegram = false; + } + } + return _isInTelegram; +} + /** * Check if we're actually running inside Telegram Mini App */ export function isInTelegramWebApp(): boolean { - const webApp = window.Telegram?.WebApp; - return Boolean(webApp?.initData && webApp.initData.length > 0); + return detectTelegram(); } /** * Check if running on mobile Telegram client (iOS/Android) */ export function isTelegramMobile(): boolean { - const webApp = window.Telegram?.WebApp; - if (!webApp?.platform) return false; - return webApp.platform === 'ios' || webApp.platform === 'android'; + try { + const { tgWebAppPlatform } = retrieveLaunchParams(); + return tgWebAppPlatform === 'ios' || tgWebAppPlatform === 'android'; + } catch { + return false; + } } /** * Get Telegram init data for authentication */ export function getTelegramInitData(): string | null { - const webApp = window.Telegram?.WebApp; - return webApp?.initData || null; -} - -/** - * Initialize Telegram WebApp (basic setup without SDK) - */ -export function initTelegramSDK() { - if (!isInTelegramWebApp()) { - return; - } - - const tg = window.Telegram?.WebApp; - if (!tg) return; - - // Basic initialization - tg.ready(); - tg.expand(); - - // Disable closing confirmation by default - tg.disableClosingConfirmation?.(); - - // Disable vertical swipes to prevent accidental closures - tg.disableVerticalSwipes?.(); - - // Auto-enter fullscreen if enabled in settings (mobile only) - const fullscreenEnabled = getCachedFullscreenEnabled(); - if (fullscreenEnabled && isTelegramMobile() && tg.requestFullscreen) { - setTimeout(() => { - if (!tg.isFullscreen) { - tg.requestFullscreen?.(); - } - }, 100); + try { + return retrieveRawInitData() || null; + } catch { + return null; } } @@ -100,54 +103,72 @@ const defaultInsets = { top: 0, bottom: 0, left: 0, right: 0 }; /** * Hook for Telegram WebApp integration - * Uses native window.Telegram.WebApp API + * Uses @telegram-apps/sdk-react v3 signals */ export function useTelegramSDK() { - const inTelegram = isInTelegramWebApp(); - const tg = window.Telegram?.WebApp; + const inTelegram = detectTelegram(); const platform = useMemo(() => { - if (!inTelegram) return undefined; - return tg?.platform as TelegramPlatform; - }, [inTelegram, tg?.platform]); + try { + return retrieveLaunchParams().tgWebAppPlatform as TelegramPlatform; + } catch { + return undefined; + } + }, []); const isMobile = platform === 'ios' || platform === 'android'; - // Safe area insets from Telegram WebApp + // Always call useSignal unconditionally (Rules of Hooks). + // When not in Telegram, the signals will have their default values. + const fullscreenValue = useSignal(isFullscreenSignal); + const heightValue = useSignal(viewportHeightSignal); + const stableHeightValue = useSignal(viewportStableHeightSignal); + const expandedValue = useSignal(isViewportExpandedSignal); + const safeInsets = useSignal(viewportSafeAreaInsets); + const contentSafeInsets = useSignal(viewportContentSafeAreaInsets); + + const isFullscreen = inTelegram ? (fullscreenValue ?? false) : false; + const viewportHeight = inTelegram ? (heightValue ?? 0) : 0; + const viewportStableHeight = inTelegram ? (stableHeightValue ?? 0) : 0; + const isExpanded = inTelegram ? (expandedValue ?? true) : true; + const safeAreaInset = useMemo(() => { - if (!inTelegram || !tg?.safeAreaInset) return defaultInsets; + if (!inTelegram || !safeInsets) return defaultInsets; return { - top: tg.safeAreaInset.top || 0, - bottom: tg.safeAreaInset.bottom || 0, - left: tg.safeAreaInset.left || 0, - right: tg.safeAreaInset.right || 0, + top: safeInsets.top || 0, + bottom: safeInsets.bottom || 0, + left: safeInsets.left || 0, + right: safeInsets.right || 0, }; - }, [inTelegram, tg?.safeAreaInset]); + }, [inTelegram, safeInsets]); const contentSafeAreaInset = useMemo(() => { - if (!inTelegram || !tg?.contentSafeAreaInset) return defaultInsets; + if (!inTelegram || !contentSafeInsets) return defaultInsets; return { - top: tg.contentSafeAreaInset.top || 0, - bottom: tg.contentSafeAreaInset.bottom || 0, - left: tg.contentSafeAreaInset.left || 0, - right: tg.contentSafeAreaInset.right || 0, + top: contentSafeInsets.top || 0, + bottom: contentSafeInsets.bottom || 0, + left: contentSafeInsets.left || 0, + right: contentSafeInsets.right || 0, }; - }, [inTelegram, tg?.contentSafeAreaInset]); - - const isFullscreen = tg?.isFullscreen ?? false; - const viewportHeight = tg?.viewportHeight ?? 0; - const viewportStableHeight = tg?.viewportStableHeight ?? 0; - const isExpanded = tg?.isExpanded ?? true; + }, [inTelegram, contentSafeInsets]); const requestFullscreen = useCallback(() => { - if (!inTelegram || !tg?.requestFullscreen) return; - tg.requestFullscreen(); - }, [inTelegram, tg]); + if (!inTelegram) return; + try { + sdkRequestFullscreen(); + } catch { + // Not supported + } + }, [inTelegram]); const exitFullscreen = useCallback(() => { - if (!inTelegram || !tg?.exitFullscreen) return; - tg.exitFullscreen(); - }, [inTelegram, tg]); + if (!inTelegram) return; + try { + sdkExitFullscreen(); + } catch { + // Not supported + } + }, [inTelegram]); const toggleFullscreen = useCallback(() => { if (isFullscreen) { @@ -158,21 +179,33 @@ export function useTelegramSDK() { }, [isFullscreen, requestFullscreen, exitFullscreen]); const expand = useCallback(() => { - if (!inTelegram || !tg?.expand) return; - tg.expand(); - }, [inTelegram, tg]); + if (!inTelegram) return; + try { + expandViewport(); + } catch { + // Not supported + } + }, [inTelegram]); const disableVerticalSwipes = useCallback(() => { - if (!inTelegram || !tg?.disableVerticalSwipes) return; - tg.disableVerticalSwipes(); - }, [inTelegram, tg]); + if (!inTelegram) return; + try { + sdkDisableVerticalSwipes(); + } catch { + // Not supported + } + }, [inTelegram]); const enableVerticalSwipes = useCallback(() => { - if (!inTelegram || !tg?.enableVerticalSwipes) return; - tg.enableVerticalSwipes(); - }, [inTelegram, tg]); + if (!inTelegram) return; + try { + sdkEnableVerticalSwipes(); + } catch { + // Not supported + } + }, [inTelegram]); - const isFullscreenSupported = inTelegram && typeof tg?.requestFullscreen === 'function'; + const isFullscreenSupported = inTelegram; return { isTelegramWebApp: inTelegram, @@ -182,7 +215,7 @@ export function useTelegramSDK() { contentSafeAreaInset, viewportHeight, viewportStableHeight, - viewportWidth: 0, // Not available in native API + viewportWidth: 0, isExpanded, platform, isMobile, diff --git a/src/hooks/useTelegramWebApp.ts b/src/hooks/useTelegramWebApp.ts index 0178b89..7fdf0be 100644 --- a/src/hooks/useTelegramWebApp.ts +++ b/src/hooks/useTelegramWebApp.ts @@ -11,7 +11,6 @@ export { setCachedFullscreenEnabled, isInTelegramWebApp, isTelegramMobile, - initTelegramSDK as initTelegramWebApp, // Alias for backward compatibility } from './useTelegramSDK'; /** @@ -32,7 +31,6 @@ export function useTelegramWebApp() { toggleFullscreen: sdk.toggleFullscreen, disableVerticalSwipes: sdk.disableVerticalSwipes, enableVerticalSwipes: sdk.enableVerticalSwipes, - // For backward compatibility, expose webApp as the raw Telegram object - webApp: window.Telegram?.WebApp ?? null, + webApp: null, }; } diff --git a/src/main.tsx b/src/main.tsx index ba8673c..d4ee76d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,14 +1,66 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + init, + restoreInitData, + mountMiniApp, + miniAppReady, + mountThemeParams, + mountViewport, + expandViewport, + mountSwipeBehavior, + disableVerticalSwipes, + mountClosingBehavior, + disableClosingConfirmation, + mountBackButton, + mountMainButton, + bindThemeParamsCssVars, + bindViewportCssVars, +} from '@telegram-apps/sdk-react'; import { AppWithNavigator } from './AppWithNavigator'; import { initLogoPreload } from './api/branding'; -import { initTelegramSDK } from './hooks/useTelegramSDK'; +import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegramSDK'; import './i18n'; import './styles/globals.css'; -// Initialize Telegram SDK (init, viewport mount, CSS vars binding, swipe control) -initTelegramSDK(); +// Initialize Telegram SDK v3 +try { + init(); + restoreInitData(); + + mountMiniApp(); + mountThemeParams(); + bindThemeParamsCssVars(); + mountSwipeBehavior(); + disableVerticalSwipes(); + mountClosingBehavior(); + disableClosingConfirmation(); + mountBackButton(); + mountMainButton(); + + mountViewport() + .then(() => { + bindViewportCssVars(); + expandViewport(); + }) + .catch(() => {}); + + miniAppReady(); + + // Auto-enter fullscreen if enabled in settings (mobile only) + if (getCachedFullscreenEnabled() && isTelegramMobile()) { + import('@telegram-apps/sdk-react').then(({ requestFullscreen, isFullscreen }) => { + setTimeout(() => { + if (!isFullscreen()) { + requestFullscreen(); + } + }, 100); + }); + } +} catch { + // Not in Telegram — ok +} // Preload logo from cache immediately on page load initLogoPreload(); diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index d1231d7..6c8aada 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -115,7 +115,7 @@ export default function Login() { const initData = getTelegramInitData(); if (isInTelegramWebApp() && initData) { setIsTelegramWebApp(true); - // Note: ready() and expand() are already called by initTelegramSDK in main.tsx + // Note: ready() and expand() are already called by SDK init in main.tsx setIsLoading(true); try { await loginWithTelegram(initData); diff --git a/src/pages/TelegramRedirect.tsx b/src/pages/TelegramRedirect.tsx index 1342519..468e25e 100644 --- a/src/pages/TelegramRedirect.tsx +++ b/src/pages/TelegramRedirect.tsx @@ -74,7 +74,7 @@ export default function TelegramRedirect() { return; } - // Note: ready(), expand(), and theme CSS vars are already handled by initTelegramSDK in main.tsx + // Note: ready(), expand(), and theme CSS vars are already handled by SDK init in main.tsx try { await loginWithTelegram(initData); diff --git a/src/platform/adapters/TelegramAdapter.ts b/src/platform/adapters/TelegramAdapter.ts index e6be1b7..07c19f2 100644 --- a/src/platform/adapters/TelegramAdapter.ts +++ b/src/platform/adapters/TelegramAdapter.ts @@ -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( - params: Parameters>[0], - mapResult: (buttonId: string) => T, - fallback: () => T, - ): Promise { - 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 { - return showPopupSafe( - { message, buttons: [{ type: 'ok' }] }, - () => undefined, - () => { - window.alert(message); - }, - ); + async alert(message: string, _title?: string): Promise { + 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 { - return showPopupSafe( - { + async confirm(message: string, _title?: string): Promise { + 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 { - return showPopupSafe( - { + async popup(options: PopupOptions): Promise { + 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[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 { - 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 { - 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 { - return new Promise((resolve, reject) => { - storage.removeItem(key, (error) => { - if (error) reject(new Error(String(error))); - else resolve(); - }); - }); + await deleteCloudStorageItem(key); }, async getKeys(): Promise { - 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 { - 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; + } 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, }; } diff --git a/src/platform/adapters/WebAdapter.ts b/src/platform/adapters/WebAdapter.ts index 68c6e37..8f52b77 100644 --- a/src/platform/adapters/WebAdapter.ts +++ b/src/platform/adapters/WebAdapter.ts @@ -271,7 +271,5 @@ export function createWebAdapter(): PlatformContext { window.onbeforeunload = null; } }, - - telegram: null, }; } diff --git a/src/platform/types.ts b/src/platform/types.ts index 3c7ebab..1f01b10 100644 --- a/src/platform/types.ts +++ b/src/platform/types.ts @@ -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 diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 3b70828..47caca1 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -10,372 +10,3 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } - -// Telegram WebApp types - comprehensive type definitions for Bot API 8.0+ -interface TelegramWebApp { - // Init data - initData: string; - initDataUnsafe: { - query_id?: string; - user?: { - id: number; - is_bot?: boolean; - first_name: string; - last_name?: string; - username?: string; - language_code?: string; - is_premium?: boolean; - added_to_attachment_menu?: boolean; - allows_write_to_pm?: boolean; - photo_url?: string; - }; - receiver?: { - id: number; - is_bot?: boolean; - first_name: string; - last_name?: string; - username?: string; - language_code?: string; - is_premium?: boolean; - added_to_attachment_menu?: boolean; - allows_write_to_pm?: boolean; - photo_url?: string; - }; - chat?: { - id: number; - type: 'group' | 'supergroup' | 'channel'; - title: string; - username?: string; - photo_url?: string; - }; - chat_type?: 'sender' | 'private' | 'group' | 'supergroup' | 'channel'; - chat_instance?: string; - start_param?: string; - can_send_after?: number; - auth_date: number; - hash: string; - }; - - // Platform info - version: string; - platform: string; - colorScheme: 'light' | 'dark'; - - // App state - isExpanded: boolean; - isClosingConfirmationEnabled: boolean; - isVerticalSwipesEnabled: boolean; - isFullscreen: boolean; - isOrientationLocked: boolean; - isActive: boolean; - - // Viewport - viewportHeight: number; - viewportStableHeight: number; - - // Safe areas - safeAreaInset: { top: number; bottom: number; left: number; right: number }; - contentSafeAreaInset: { top: number; bottom: number; left: number; right: number }; - - // Theme colors (settable) - headerColor: string; - backgroundColor: string; - bottomBarColor?: string; - - // Theme params (from Telegram) - themeParams: { - bg_color?: string; - text_color?: string; - hint_color?: string; - link_color?: string; - button_color?: string; - button_text_color?: string; - secondary_bg_color?: string; - header_bg_color?: string; - bottom_bar_bg_color?: string; - accent_text_color?: string; - section_bg_color?: string; - section_header_text_color?: string; - section_separator_color?: string; - subtitle_text_color?: string; - destructive_text_color?: string; - }; - - // Lifecycle methods - ready: () => void; - expand: () => void; - close: () => void; - - // Links - openLink: (url: string, options?: { try_instant_view?: boolean; try_browser?: boolean }) => void; - openTelegramLink: (url: string) => void; - - // Invoice - openInvoice: ( - url: string, - callback?: (status: 'paid' | 'cancelled' | 'failed' | 'pending') => void, - ) => void; - - // Fullscreen API (Bot API 8.0+) - requestFullscreen: () => void; - exitFullscreen: () => void; - lockOrientation: () => void; - unlockOrientation: () => void; - - // Vertical swipes control - disableVerticalSwipes: () => void; - enableVerticalSwipes: () => void; - - // Closing confirmation - enableClosingConfirmation: () => void; - disableClosingConfirmation: () => void; - - // Theme color control - setHeaderColor: (color: string) => void; - setBackgroundColor: (color: string) => void; - setBottomBarColor?: (color: string) => void; - - // Event handlers - onEvent: (eventType: string, callback: (...args: unknown[]) => void) => void; - offEvent: (eventType: string, callback: (...args: unknown[]) => void) => void; - - // Data sending - sendData: (data: string) => void; - - // QR Scanner - showScanQrPopup: (params: { text?: string }, callback?: (text: string) => boolean | void) => void; - closeScanQrPopup: () => void; - - // Clipboard - readTextFromClipboard: (callback?: (text: string | null) => void) => void; - - // Write access - requestWriteAccess: (callback?: (granted: boolean) => void) => void; - - // Contact - requestContact: (callback?: (granted: boolean) => void) => void; - - // Emoji status (Bot API 8.0+) - requestEmojiStatusAccess: (callback?: (granted: boolean) => void) => void; - setEmojiStatus: ( - customEmojiId: string, - params?: { duration?: number }, - callback?: (success: boolean) => void, - ) => void; - - // Download file (Bot API 8.0+) - downloadFile: ( - params: { url: string; file_name: string }, - callback?: (success: boolean) => void, - ) => void; - - // Share story (Bot API 7.8+) - shareToStory?: ( - mediaUrl: string, - params?: { text?: string; widget_link?: { url: string; name?: string } }, - ) => void; - - // Version check helper - isVersionAtLeast: (version: string) => boolean; - - // Main Button - MainButton: { - text: string; - color: string; - textColor: string; - isVisible: boolean; - isActive: boolean; - isProgressVisible: boolean; - show: () => void; - hide: () => void; - enable: () => void; - disable: () => void; - showProgress: (leaveActive?: boolean) => void; - hideProgress: () => void; - setText: (text: string) => void; - setParams: (params: { - text?: string; - color?: string; - text_color?: string; - has_shine_effect?: boolean; - is_active?: boolean; - is_visible?: boolean; - }) => void; - onClick: (callback: () => void) => void; - offClick: (callback: () => void) => void; - }; - - // Secondary Button (Bot API 7.10+) - SecondaryButton?: { - text: string; - color: string; - textColor: string; - isVisible: boolean; - isActive: boolean; - isProgressVisible: boolean; - position: 'left' | 'right' | 'top' | 'bottom'; - show: () => void; - hide: () => void; - enable: () => void; - disable: () => void; - showProgress: (leaveActive?: boolean) => void; - hideProgress: () => void; - setText: (text: string) => void; - setParams: (params: { - text?: string; - color?: string; - text_color?: string; - has_shine_effect?: boolean; - is_active?: boolean; - is_visible?: boolean; - position?: 'left' | 'right' | 'top' | 'bottom'; - }) => void; - onClick: (callback: () => void) => void; - offClick: (callback: () => void) => void; - }; - - // Back Button - BackButton: { - isVisible: boolean; - show: () => void; - hide: () => void; - onClick: (callback: () => void) => void; - offClick: (callback: () => void) => void; - }; - - // Settings Button (Bot API 7.0+) - SettingsButton?: { - isVisible: boolean; - show: () => void; - hide: () => void; - onClick: (callback: () => void) => void; - offClick: (callback: () => void) => void; - }; - - // Haptic Feedback (Bot API 6.1+) - HapticFeedback: { - impactOccurred: (style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft') => void; - notificationOccurred: (type: 'success' | 'warning' | 'error') => void; - selectionChanged: () => void; - }; - - // Popups (Bot API 6.2+) - showPopup: ( - params: { - title?: string; - message: string; - buttons?: Array<{ - id?: string; - type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive'; - text?: string; - }>; - }, - callback?: (buttonId: string) => void, - ) => void; - showAlert: (message: string, callback?: () => void) => void; - showConfirm: (message: string, callback?: (confirmed: boolean) => void) => void; - - // Cloud Storage (Bot API 6.9+) - CloudStorage: { - setItem: ( - key: string, - value: string, - callback?: (error: Error | null, stored: boolean) => void, - ) => void; - getItem: (key: string, callback: (error: Error | null, value: string) => void) => void; - getItems: ( - keys: string[], - callback: (error: Error | null, values: Record) => void, - ) => void; - removeItem: (key: string, callback?: (error: Error | null, removed: boolean) => void) => void; - removeItems: ( - keys: string[], - callback?: (error: Error | null, removed: boolean) => void, - ) => void; - getKeys: (callback: (error: Error | null, keys: string[]) => void) => void; - }; - - // Biometric (Bot API 7.2+) - BiometricManager?: { - isInited: boolean; - isBiometricAvailable: boolean; - biometricType: 'finger' | 'face' | 'unknown'; - isAccessRequested: boolean; - isAccessGranted: boolean; - isBiometricTokenSaved: boolean; - deviceId: string; - init: (callback?: () => void) => void; - requestAccess: (params: { reason?: string }, callback?: (granted: boolean) => void) => void; - authenticate: ( - params: { reason?: string }, - callback?: (success: boolean, token?: string) => void, - ) => void; - updateBiometricToken: (token: string, callback?: (success: boolean) => void) => void; - openSettings: () => void; - }; - - // Accelerometer (Bot API 8.0+) - Accelerometer?: { - isStarted: boolean; - x: number | null; - y: number | null; - z: number | null; - start: (params?: { refresh_rate?: number }, callback?: (started: boolean) => void) => void; - stop: (callback?: (stopped: boolean) => void) => void; - }; - - // DeviceOrientation (Bot API 8.0+) - DeviceOrientation?: { - isStarted: boolean; - absolute: boolean; - alpha: number | null; - beta: number | null; - gamma: number | null; - start: ( - params?: { refresh_rate?: number; need_absolute?: boolean }, - callback?: (started: boolean) => void, - ) => void; - stop: (callback?: (stopped: boolean) => void) => void; - }; - - // Gyroscope (Bot API 8.0+) - Gyroscope?: { - isStarted: boolean; - x: number | null; - y: number | null; - z: number | null; - start: (params?: { refresh_rate?: number }, callback?: (started: boolean) => void) => void; - stop: (callback?: (stopped: boolean) => void) => void; - }; - - // Location Manager (Bot API 8.0+) - LocationManager?: { - isInited: boolean; - isLocationAvailable: boolean; - isAccessRequested: boolean; - isAccessGranted: boolean; - init: (callback?: () => void) => void; - getLocation: ( - callback: ( - location: { - latitude: number; - longitude: number; - altitude?: number; - course?: number; - speed?: number; - horizontal_accuracy?: number; - vertical_accuracy?: number; - course_accuracy?: number; - speed_accuracy?: number; - } | null, - ) => void, - ) => void; - openSettings: () => void; - }; -} - -interface Window { - Telegram?: { - WebApp: TelegramWebApp; - }; -} diff --git a/vite.config.ts b/vite.config.ts index 9d07f2e..f131284 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -49,11 +49,7 @@ export default defineConfig({ '@radix-ui/react-visually-hidden', ], 'vendor-dnd': ['@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/utilities'], - 'vendor-telegram': [ - '@telegram-apps/sdk', - '@telegram-apps/sdk-react', - '@telegram-apps/react-router-integration', - ], + 'vendor-telegram': ['@telegram-apps/sdk-react'], 'vendor-utils': [ 'axios', 'zustand', From 61e3910981e401fbc0b968615307e5101f6f96e9 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 11:18:49 +0300 Subject: [PATCH 8/9] fix: resolve SDK v3 mount errors, back button and fullscreen not working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Обернуть каждый mount-вызов в safeMountSync() для защиты от ConcurrentCallError при HMR/повторной инициализации - Перенести requestFullscreen() внутрь mountViewport().then() — fullscreen требует смонтированный viewport, ранее вызывался через dynamic import с setTimeout до завершения mount - Стабилизировать обработчик BackButton через useRef + useCallback — navigate меняется каждый рендер, что вызывало постоянную пере-подписку и потерю обработчика --- src/AppWithNavigator.tsx | 18 +++++++++----- src/main.tsx | 54 +++++++++++++++++++++++++--------------- 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/src/AppWithNavigator.tsx b/src/AppWithNavigator.tsx index 11ae689..33ae2a3 100644 --- a/src/AppWithNavigator.tsx +++ b/src/AppWithNavigator.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef, useCallback } from 'react'; import { BrowserRouter, useLocation, useNavigate } from 'react-router-dom'; import { showBackButton, @@ -21,6 +21,8 @@ import { isInTelegramWebApp } from './hooks/useTelegramSDK'; function TelegramBackButton() { const location = useLocation(); const navigate = useNavigate(); + const navigateRef = useRef(navigate); + navigateRef.current = navigate; useEffect(() => { const isRoot = location.pathname === '/' || location.pathname === ''; @@ -31,25 +33,29 @@ function TelegramBackButton() { showBackButton(); } } catch { - // Not in Telegram or back button not mounted + // Back button not mounted } }, [location]); + // Stable handler — ref prevents re-subscription on every render + const handler = useCallback(() => { + navigateRef.current(-1); + }, []); + useEffect(() => { - const handler = () => navigate(-1); try { onBackButtonClick(handler); } catch { - // Not in Telegram + // Back button not mounted } return () => { try { offBackButtonClick(handler); } catch { - // Not in Telegram + // Back button not mounted } }; - }, [navigate]); + }, [handler]); return null; } diff --git a/src/main.tsx b/src/main.tsx index d4ee76d..1e60f53 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -17,6 +17,8 @@ import { mountMainButton, bindThemeParamsCssVars, bindViewportCssVars, + requestFullscreen, + isFullscreen, } from '@telegram-apps/sdk-react'; import { AppWithNavigator } from './AppWithNavigator'; import { initLogoPreload } from './api/branding'; @@ -24,40 +26,52 @@ import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegra import './i18n'; import './styles/globals.css'; +// Safe mount helper — ignores "already mounted/mounting" errors (HMR, StrictMode) +function safeMountSync(fn: () => void) { + try { + fn(); + } catch { + // Already mounted or not available + } +} + // Initialize Telegram SDK v3 try { init(); restoreInitData(); - mountMiniApp(); - mountThemeParams(); - bindThemeParamsCssVars(); - mountSwipeBehavior(); - disableVerticalSwipes(); - mountClosingBehavior(); - disableClosingConfirmation(); - mountBackButton(); - mountMainButton(); + safeMountSync(() => mountMiniApp()); + safeMountSync(() => { + mountThemeParams(); + bindThemeParamsCssVars(); + }); + safeMountSync(() => { + mountSwipeBehavior(); + disableVerticalSwipes(); + }); + safeMountSync(() => { + mountClosingBehavior(); + disableClosingConfirmation(); + }); + safeMountSync(() => mountBackButton()); + safeMountSync(() => mountMainButton()); + // Viewport — async, fullscreen зависит от смонтированного viewport mountViewport() .then(() => { bindViewportCssVars(); expandViewport(); + + // Auto-enter fullscreen if enabled in settings (mobile only) + if (getCachedFullscreenEnabled() && isTelegramMobile()) { + if (!isFullscreen()) { + requestFullscreen(); + } + } }) .catch(() => {}); miniAppReady(); - - // Auto-enter fullscreen if enabled in settings (mobile only) - if (getCachedFullscreenEnabled() && isTelegramMobile()) { - import('@telegram-apps/sdk-react').then(({ requestFullscreen, isFullscreen }) => { - setTimeout(() => { - if (!isFullscreen()) { - requestFullscreen(); - } - }, 100); - }); - } } catch { // Not in Telegram — ok } From bcbda17220357815ee2df269e293db0fecee7bd3 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 4 Feb 2026 11:25:10 +0300 Subject: [PATCH 9/9] fix: add HMR guard to prevent ConcurrentCallError on SDK double-init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При HMR Vite перевыполняет main.tsx, вызывая повторный init() и mount*() компонентов SDK. mountThemeParams() и другие бросают ConcurrentCallError асинхронно, что не ловится синхронным try/catch. Решение: флаг на window.__tg_sdk_initialized предотвращает повторную инициализацию при горячей перезагрузке модуля. --- src/main.tsx | 101 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 59 insertions(+), 42 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 1e60f53..30280ca 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -26,54 +26,71 @@ import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegra import './i18n'; import './styles/globals.css'; -// Safe mount helper — ignores "already mounted/mounting" errors (HMR, StrictMode) -function safeMountSync(fn: () => void) { +// HMR guard — prevent double init when Vite hot-reloads the module +const HMR_KEY = '__tg_sdk_initialized'; +const alreadyInitialized = (window as unknown as Record)[HMR_KEY] === true; + +if (!alreadyInitialized) { + (window as unknown as Record)[HMR_KEY] = true; + try { - fn(); - } catch { - // Already mounted or not available - } -} + init(); + restoreInitData(); -// Initialize Telegram SDK v3 -try { - init(); - restoreInitData(); + // Mount components — each in its own try/catch so one failure doesn't block others + try { + mountMiniApp(); + } catch { + /* already mounted */ + } + try { + mountThemeParams(); + bindThemeParamsCssVars(); + } catch { + /* already mounted */ + } + try { + mountSwipeBehavior(); + disableVerticalSwipes(); + } catch { + /* already mounted */ + } + try { + mountClosingBehavior(); + disableClosingConfirmation(); + } catch { + /* already mounted */ + } + try { + mountBackButton(); + } catch { + /* already mounted */ + } + try { + mountMainButton(); + } catch { + /* already mounted */ + } - safeMountSync(() => mountMiniApp()); - safeMountSync(() => { - mountThemeParams(); - bindThemeParamsCssVars(); - }); - safeMountSync(() => { - mountSwipeBehavior(); - disableVerticalSwipes(); - }); - safeMountSync(() => { - mountClosingBehavior(); - disableClosingConfirmation(); - }); - safeMountSync(() => mountBackButton()); - safeMountSync(() => mountMainButton()); + // Viewport — async, fullscreen зависит от смонтированного viewport + mountViewport() + .then(() => { + bindViewportCssVars(); + expandViewport(); - // Viewport — async, fullscreen зависит от смонтированного viewport - mountViewport() - .then(() => { - bindViewportCssVars(); - expandViewport(); - - // Auto-enter fullscreen if enabled in settings (mobile only) - if (getCachedFullscreenEnabled() && isTelegramMobile()) { - if (!isFullscreen()) { - requestFullscreen(); + // Auto-enter fullscreen if enabled in settings (mobile only) + if (getCachedFullscreenEnabled() && isTelegramMobile()) { + if (!isFullscreen()) { + requestFullscreen(); + } } - } - }) - .catch(() => {}); + }) + .catch(() => {}); - miniAppReady(); -} catch { - // Not in Telegram — ok + miniAppReady(); + } catch { + // Not in Telegram — ok + } } // Preload logo from cache immediately on page load