diff --git a/web/index.html b/web/index.html index 3b67a0cd..ad01fa5b 100644 --- a/web/index.html +++ b/web/index.html @@ -33,7 +33,6 @@ } } -
diff --git a/web/src/App.tsx b/web/src/App.tsx index 54f2004a..4ac04cd9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { getTelegramWebApp } from '@/hooks/useTelegram' +import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram' import { initializeTheme } from '@/hooks/useTheme' import { useAuth } from '@/hooks/useAuth' import { useAuthSource } from '@/hooks/useAuthSource' @@ -138,7 +138,7 @@ export function App() { // Navigate and sync browser history (browser only, not Telegram) const navigateTo = useCallback((newScreen: Screen) => { setScreen(newScreen) - if (!getTelegramWebApp()?.initData) { + if (!isTelegramApp()) { history.pushState({ screen: newScreen }, '') } }, []) @@ -209,7 +209,7 @@ export function App() { // Handle browser back button (browser only, not Telegram) useEffect(() => { - if (getTelegramWebApp()?.initData) return + if (isTelegramApp()) return const handlePopState = (event: PopStateEvent) => { if (event.state?.screen) { @@ -227,7 +227,7 @@ export function App() { const goBack = useCallback(() => { // Browser: use native back (triggers popstate) - if (!getTelegramWebApp()?.initData) { + if (!isTelegramApp()) { history.back() return } diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index ffd7a26a..1bb6c2aa 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' import type { Session } from '@/types/api' -import { getTelegramWebApp } from '@/hooks/useTelegram' +import { isTelegramApp } from '@/hooks/useTelegram' function getSessionTitle(session: Session): string { if (session.metadata?.name) { @@ -20,11 +20,10 @@ export function SessionHeader(props: { session: Session onBack: () => void }) { - const isTelegram = getTelegramWebApp() !== null const title = useMemo(() => getSessionTitle(props.session), [props.session]) // In Telegram, don't render header (Telegram provides its own) - if (isTelegram) { + if (isTelegramApp()) { return null } diff --git a/web/src/hooks/useAuthSource.ts b/web/src/hooks/useAuthSource.ts index bc36bafb..c3150f41 100644 --- a/web/src/hooks/useAuthSource.ts +++ b/web/src/hooks/useAuthSource.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { getTelegramWebApp } from './useTelegram' +import { getTelegramWebApp, isTelegramEnvironment } from './useTelegram' import type { AuthSource } from './useAuth' const ACCESS_TOKEN_KEY = 'hapi_access_token' @@ -77,15 +77,8 @@ export function useAuthSource(): { return } - // Check if we're likely in a Telegram environment before polling - // Only use URL params (tgWebApp) as reliable indicator - const hasTelegramHint = - typeof window !== 'undefined' && ( - window.location.search.includes('tgWebApp') || - window.location.hash.includes('tgWebApp') - ) - - if (!hasTelegramHint) { + // Check if we're in a Telegram environment before polling + if (!isTelegramEnvironment()) { // Plain browser - show login prompt immediately setIsLoading(false) return diff --git a/web/src/hooks/usePlatform.ts b/web/src/hooks/usePlatform.ts index 7b9fd510..3c7dcb51 100644 --- a/web/src/hooks/usePlatform.ts +++ b/web/src/hooks/usePlatform.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { getTelegramWebApp } from './useTelegram' +import { getTelegramWebApp, isTelegramApp } from './useTelegram' export type HapticStyle = 'light' | 'medium' | 'heavy' | 'rigid' | 'soft' export type HapticNotification = 'error' | 'success' | 'warning' @@ -20,62 +20,53 @@ export type Platform = { haptic: PlatformHaptic } -function createHaptic(): PlatformHaptic { - const tg = getTelegramWebApp() +// Vibration patterns for web fallback (in ms) +const vibrationPatterns = { + light: 10, + medium: 20, + heavy: 30, + rigid: 15, + soft: 10, + success: 20, + warning: [20, 50, 20] as number | number[], + error: [30, 50, 30] as number | number[], + selection: 5, +} - // Vibration patterns for web fallback (in ms) - const vibrationPatterns = { - light: 10, - medium: 20, - heavy: 30, - rigid: 15, - soft: 10, - success: 20, - warning: [20, 50, 20] as number | number[], - error: [30, 50, 30] as number | number[], - selection: 5, - } +function vibrate(pattern: number | number[]) { + navigator.vibrate?.(pattern) +} - const vibrate = (pattern: number | number[]) => { - navigator.vibrate?.(pattern) - } - - return { - impact: (style: HapticStyle) => { - if (tg?.HapticFeedback) { - tg.HapticFeedback.impactOccurred(style) - } else { - vibrate(vibrationPatterns[style]) - } - }, - notification: (type: HapticNotification) => { - if (tg?.HapticFeedback) { - tg.HapticFeedback.notificationOccurred(type) - } else { - vibrate(vibrationPatterns[type]) - } - }, - selection: () => { - if (tg?.HapticFeedback) { - tg.HapticFeedback.selectionChanged() - } else { - vibrate(vibrationPatterns.selection) - } +// Lazy haptic - checks for Telegram SDK on each call +const haptic: PlatformHaptic = { + impact: (style: HapticStyle) => { + const tg = getTelegramWebApp() + if (tg?.HapticFeedback) { + tg.HapticFeedback.impactOccurred(style) + } else { + vibrate(vibrationPatterns[style]) + } + }, + notification: (type: HapticNotification) => { + const tg = getTelegramWebApp() + if (tg?.HapticFeedback) { + tg.HapticFeedback.notificationOccurred(type) + } else { + vibrate(vibrationPatterns[type]) + } + }, + selection: () => { + const tg = getTelegramWebApp() + if (tg?.HapticFeedback) { + tg.HapticFeedback.selectionChanged() + } else { + vibrate(vibrationPatterns.selection) } } } -// Singleton haptic instance (functions are stable) -const haptic = createHaptic() - -function checkIsTelegram(): boolean { - const tg = getTelegramWebApp() - // SDK is always loaded, but initData is only present in actual Telegram environment - return tg !== null && Boolean(tg.initData) -} - export function usePlatform(): Platform { - const isTelegram = useMemo(() => checkIsTelegram(), []) + const isTelegram = useMemo(() => isTelegramApp(), []) return { isTelegram, @@ -86,7 +77,7 @@ export function usePlatform(): Platform { // Non-hook version for use outside React components export function getPlatform(): Platform { return { - isTelegram: checkIsTelegram(), + isTelegram: isTelegramApp(), haptic } } diff --git a/web/src/hooks/useTelegram.ts b/web/src/hooks/useTelegram.ts index 4a58f4c6..4d52d088 100644 --- a/web/src/hooks/useTelegram.ts +++ b/web/src/hooks/useTelegram.ts @@ -1,3 +1,30 @@ +/** + * Detects if the current environment is Telegram Mini App + * by checking URL hash/query parameters that Telegram passes. + * This works BEFORE the SDK is loaded. + */ +export function isTelegramEnvironment(): boolean { + if (typeof window === 'undefined') return false + + // Telegram passes launch params via window.location.hash + // Format: #tgWebAppVersion=...&tgWebAppData=...&tgWebAppPlatform=... + const hash = window.location.hash.slice(1) + const hashParams = new URLSearchParams(hash) + + // Primary detection: check hash parameters + if (hashParams.has('tgWebAppVersion') || hashParams.has('tgWebAppData')) { + return true + } + + // Fallback: check query parameters (alternative flow) + const search = window.location.search + if (search.includes('tgWebApp') || search.includes('initData')) { + return true + } + + return false +} + export type TelegramWebAppThemeParams = { bg_color?: string text_color?: string @@ -75,3 +102,43 @@ declare global { export function getTelegramWebApp(): TelegramWebApp | null { return window.Telegram?.WebApp ?? null } + +/** + * Checks if running inside a real Telegram Mini App. + * Requires SDK to be loaded. Returns true only if initData is present. + */ +export function isTelegramApp(): boolean { + const tg = getTelegramWebApp() + return tg !== null && Boolean(tg.initData) +} + +/** + * Dynamically loads the Telegram Web App SDK with timeout. + * Only call this if isTelegramEnvironment() returns true. + */ +export function loadTelegramSdk(timeoutMs = 3000): Promise { + return new Promise((resolve) => { + if (window.Telegram?.WebApp) { + resolve() + return + } + + let settled = false + const settle = () => { + if (!settled) { + settled = true + resolve() + } + } + + // Timeout - don't block app indefinitely + setTimeout(settle, timeoutMs) + + const script = document.createElement('script') + script.src = 'https://telegram.org/js/telegram-web-app.js' + script.async = true + script.onload = settle + script.onerror = settle + document.head.appendChild(script) + }) +} diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts index 3a5ab58e..0085562d 100644 --- a/web/src/hooks/useTheme.ts +++ b/web/src/hooks/useTheme.ts @@ -53,19 +53,8 @@ function updateScheme(): void { } } -// Initialize theme on module load -applyTheme(currentScheme) - -// Listen for theme changes -const tg = getTelegramWebApp() -if (tg?.onEvent) { - // Telegram theme changes - tg.onEvent('themeChanged', updateScheme) -} else if (typeof window !== 'undefined' && window.matchMedia) { - // Browser system preference changes - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') - mediaQuery.addEventListener('change', updateScheme) -} +// Track if theme listeners have been set up +let listenersInitialized = false export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } { const colorScheme = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) @@ -76,8 +65,22 @@ export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } { } } -// Call this once at app startup to ensure theme is applied +// Call this once at app startup to ensure theme is applied and listeners attached export function initializeTheme(): void { currentScheme = getColorScheme() applyTheme(currentScheme) + + // Set up listeners only once (after SDK may have loaded) + if (!listenersInitialized) { + listenersInitialized = true + const tg = getTelegramWebApp() + if (tg?.onEvent) { + // Telegram theme changes + tg.onEvent('themeChanged', updateScheme) + } else if (typeof window !== 'undefined' && window.matchMedia) { + // Browser system preference changes + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + mediaQuery.addEventListener('change', updateScheme) + } + } } diff --git a/web/src/main.tsx b/web/src/main.tsx index 6026c031..c15daa5d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -3,28 +3,38 @@ import ReactDOM from 'react-dom/client' import { App } from './App' import './index.css' import { registerSW } from 'virtual:pwa-register' +import { isTelegramEnvironment, loadTelegramSdk } from './hooks/useTelegram' -registerSW({ - immediate: true, - onOfflineReady() { - console.log('App ready for offline use') - }, - onRegistered(registration) { - if (registration) { - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { - registration.update() - } - }) - } - }, - onRegisterError(error) { - console.error('SW registration error:', error) +async function bootstrap() { + // Only load Telegram SDK in Telegram environment (with 3s timeout) + if (isTelegramEnvironment()) { + await loadTelegramSdk() } -}) -ReactDOM.createRoot(document.getElementById('root')!).render( - - - -) + registerSW({ + immediate: true, + onOfflineReady() { + console.log('App ready for offline use') + }, + onRegistered(registration) { + if (registration) { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + registration.update() + } + }) + } + }, + onRegisterError(error) { + console.error('SW registration error:', error) + } + }) + + ReactDOM.createRoot(document.getElementById('root')!).render( + + + + ) +} + +bootstrap()