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