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'; import type { AppInfo, AppConfig, LocalizedText } from '../types'; interface ConnectionModalProps { onClose: () => void; } // Icons const CloseIcon = () => ( ); const CopyIcon = () => ( ); const CheckIcon = () => ( ); const LinkIcon = () => ( ); const ChevronIcon = () => ( ); const BackIcon = () => ( ); // App icons const HappIcon = () => ( ); const ClashMetaIcon = () => ( ); const ShadowrocketIcon = () => ( ); const StreisandIcon = () => ( ); const getAppIcon = (appName: string): React.ReactNode => { const name = appName.toLowerCase(); if (name.includes('happ')) return ; if (name.includes('shadowrocket') || name.includes('rocket')) return ; if (name.includes('streisand')) return ; if (name.includes('clash') || name.includes('meta') || name.includes('verge')) return ; return πŸ“¦; }; const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']; // eslint-disable-next-line no-script-url -- listing dangerous schemes to block them const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']; function isValidExternalUrl(url: string | undefined): boolean { if (!url) return false; const lowerUrl = url.toLowerCase().trim(); if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false; return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://'); } function isValidDeepLink(url: string | undefined): boolean { if (!url) return false; const lowerUrl = url.toLowerCase().trim(); if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false; return lowerUrl.includes('://'); } function detectPlatform(): string | null { if (typeof window === 'undefined' || !navigator?.userAgent) return null; const ua = navigator.userAgent.toLowerCase(); if (/iphone|ipad|ipod/.test(ua)) return 'ios'; if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android'; if (/macintosh|mac os x/.test(ua)) return 'macos'; if (/windows/.test(ua)) return 'windows'; if (/linux/.test(ua)) return 'linux'; return null; } function useIsMobile() { const [isMobile, setIsMobile] = useState(() => { if (typeof window === 'undefined') return false; return window.innerWidth < 768; }); useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768); window.addEventListener('resize', check); return () => window.removeEventListener('resize', check); }, []); return isMobile; } export default function ConnectionModal({ onClose }: ConnectionModalProps) { const { t, i18n } = useTranslation(); const [selectedApp, setSelectedApp] = useState(null); const [copied, setCopied] = useState(false); const [showAppSelector, setShowAppSelector] = useState(false); const [selectedPlatform, setSelectedPlatform] = useState(null); const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp(); const { impact: hapticImpact } = useHaptic(); const isMobileScreen = useIsMobile(); const isMobile = isMobileScreen; const scrollContainerRef = useRef(null); // Ref для хранСния Π°ΠΊΡ‚ΡƒΠ°Π»ΡŒΠ½ΠΎΠ³ΠΎ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠ° BackButton (фикс мигания) const backButtonHandlerRef = useRef<() => void>(() => {}); // Ref for haptic to avoid recreating handleBackButton const hapticRef = useRef(hapticImpact); hapticRef.current = hapticImpact; // Prevent scroll events from bubbling to parent/Telegram const handleScrollContainerWheel = useCallback((e: React.WheelEvent) => { const container = e.currentTarget; const { scrollTop, scrollHeight, clientHeight } = container; const isAtTop = scrollTop === 0; const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1; // Prevent scroll propagation when not at boundaries, or when scrolling away from boundary if ((!isAtTop && !isAtBottom) || (isAtTop && e.deltaY > 0) || (isAtBottom && e.deltaY < 0)) { e.stopPropagation(); } }, []); const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0; const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) : 0; const { data: appConfig, isLoading, error, } = useQuery({ queryKey: ['appConfig'], queryFn: () => subscriptionApi.getAppConfig(), }); const detectedPlatform = useMemo(() => detectPlatform(), []); useEffect(() => { if (!appConfig?.platforms || selectedApp) return; let platform = detectedPlatform; if (!platform || !appConfig.platforms[platform]?.length) { platform = platformOrder.find((p) => appConfig.platforms[p]?.length > 0) || null; } if (!platform || !appConfig.platforms[platform]?.length) return; const apps = appConfig.platforms[platform]; const app = apps.find((a) => a.isFeatured) || apps[0]; if (app) setSelectedApp(app); }, [appConfig, detectedPlatform, selectedApp]); const handleClose = useCallback(() => { onClose(); }, [onClose]); const handleBack = useCallback(() => { if (selectedPlatform) { setSelectedPlatform(null); } else { setShowAppSelector(false); } }, [selectedPlatform]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); if (showAppSelector) handleBack(); else handleClose(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [handleClose, handleBack, showAppSelector]); // ОбновляСм ref ΠΏΡ€ΠΈ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΈ Π»ΠΎΠ³ΠΈΠΊΠΈ (Π±Π΅Π· пСрСзапуска эффСкта BackButton) useEffect(() => { backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose; }, [showAppSelector, handleBack, handleClose]); // BackButton using platform hook - always close/back, ref provides current handler const handleBackButton = useCallback(() => { hapticRef.current('light'); backButtonHandlerRef.current(); }, []); useBackButton(handleBackButton); useEffect(() => { document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; }, []); const getLocalizedText = (text: LocalizedText | undefined): string => { if (!text) return ''; const lang = i18n.language || 'en'; return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || ''; }; const availablePlatforms = useMemo(() => { if (!appConfig?.platforms) return []; const available = platformOrder.filter((key) => appConfig.platforms[key]?.length > 0); if (detectedPlatform && available.includes(detectedPlatform)) { return [detectedPlatform, ...available.filter((p) => p !== detectedPlatform)]; } return available; }, [appConfig, detectedPlatform]); const copySubscriptionLink = async () => { if (!appConfig?.subscriptionUrl) return; try { await navigator.clipboard.writeText(appConfig.subscriptionUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { const textarea = document.createElement('textarea'); textarea.value = appConfig.subscriptionUrl; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); setCopied(true); setTimeout(() => setCopied(false), 2000); } }; const handleConnect = (app: AppInfo) => { if (!app.deepLink || !isValidDeepLink(app.deepLink)) return; const deepLink = app.deepLink; // All deep links (including custom schemes like happ://, clash://, etc.) // go through redirect.html. Telegram's openLink opens the redirect page // in an external browser, which then performs window.location.href to the // 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'}`; try { sdkOpenLink(redirectUrl, { tryInstantView: false }); return; } catch { // SDK not available, fallback } window.location.href = redirectUrl; }; // Wrapper component const Wrapper = ({ children }: { children: React.ReactNode }) => { if (isMobile) { const content = (
{children}
); if (typeof document !== 'undefined') return createPortal(content, document.body); return content; } // Desktop centered - positioned higher return (
e.stopPropagation()} > {children}
); }; // Loading if (isLoading) { return (
); } // Error if (error || !appConfig) { return (

{t('common.error')}

); } // No subscription if (!appConfig.hasSubscription) { return (

{t('subscription.connection.title')}

{t('subscription.connection.noSubscription')}

); } // App selector if (showAppSelector) { const platformNames: Record = { ios: 'iOS', android: 'Android', windows: 'Windows', macos: 'macOS', linux: 'Linux', androidTV: 'Android TV', appleTV: 'Apple TV', }; const platformIcons: Record = { ios: ( ), android: ( ), windows: ( ), macos: ( ), linux: ( ), androidTV: ( ), appleTV: ( ), }; // Step 1: Platform selection if (!selectedPlatform) { return (
{!isTelegramWebApp && ( )}

{t('subscription.connection.selectPlatform')}

{availablePlatforms.map((platform) => { const apps = appConfig.platforms[platform]; if (!apps?.length) return null; const isCurrentPlatform = platform === detectedPlatform; const appCount = apps.length; return ( ); })}
); } // Step 2: App selection for chosen platform const apps = appConfig.platforms[selectedPlatform] || []; const isCurrentPlatform = selectedPlatform === detectedPlatform; return (
{!isTelegramWebApp && ( )}

{platformNames[selectedPlatform] || selectedPlatform}

{isCurrentPlatform && ( {t('subscription.connection.yourDevice')} )}
{apps.map((app) => { const isSelected = selectedApp?.id === app.id; return ( ); })}
); } // Main view return (

{t('subscription.connection.title')}

{!isTelegramWebApp && ( )}
{selectedApp?.installationStep && (
1 {t('subscription.connection.installApp')}

{getLocalizedText(selectedApp.installationStep.description)}

{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
{selectedApp.installationStep.buttons .filter((btn) => isValidExternalUrl(btn.buttonLink)) .map((btn, idx) => ( {getLocalizedText(btn.buttonText)} ))}
)}
)} {selectedApp?.addSubscriptionStep && (
2 {t('subscription.connection.addSubscription')}

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

{selectedApp.deepLink && ( )} {/* Copy link button - hidden when hideLink is true */} {!appConfig?.hideLink && ( )}
)} {selectedApp?.connectAndUseStep && (
3 {t('subscription.connection.connectVpn')}

{getLocalizedText(selectedApp.connectAndUseStep.description)}

)}
); }