import { useCallback, useEffect, useMemo, useRef } from 'react' import { Outlet, useLocation, useMatchRoute } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram' import { initializeTheme } from '@/hooks/useTheme' import { useAuth } from '@/hooks/useAuth' import { useAuthSource } from '@/hooks/useAuthSource' import { useServerUrl } from '@/hooks/useServerUrl' import { useSSE } from '@/hooks/useSSE' import { useSyncingState } from '@/hooks/useSyncingState' import { usePushNotifications } from '@/hooks/usePushNotifications' import { queryKeys } from '@/lib/query-keys' import { AppContextProvider } from '@/lib/app-context' import { useAppGoBack } from '@/hooks/useAppGoBack' import { LoginPrompt } from '@/components/LoginPrompt' import { InstallPrompt } from '@/components/InstallPrompt' import { OfflineBanner } from '@/components/OfflineBanner' import { SyncingBanner } from '@/components/SyncingBanner' import { LoadingState } from '@/components/LoadingState' export function App() { const { serverUrl, baseUrl, setServerUrl, clearServerUrl } = useServerUrl() const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource(baseUrl) const { token, api, isLoading: isAuthLoading, error: authError, needsBinding, bind } = useAuth(authSource, baseUrl) const goBack = useAppGoBack() const pathname = useLocation({ select: (location) => location.pathname }) const matchRoute = useMatchRoute() useEffect(() => { const tg = getTelegramWebApp() tg?.ready() tg?.expand() initializeTheme() }, []) useEffect(() => { const preventDefault = (event: Event) => { event.preventDefault() } const onWheel = (event: WheelEvent) => { if (event.ctrlKey) { event.preventDefault() } } const onKeyDown = (event: KeyboardEvent) => { const modifier = event.ctrlKey || event.metaKey if (!modifier) return if (event.key === '+' || event.key === '-' || event.key === '=' || event.key === '0') { event.preventDefault() } } document.addEventListener('gesturestart', preventDefault as EventListener, { passive: false }) document.addEventListener('gesturechange', preventDefault as EventListener, { passive: false }) document.addEventListener('gestureend', preventDefault as EventListener, { passive: false }) window.addEventListener('wheel', onWheel, { passive: false }) window.addEventListener('keydown', onKeyDown) return () => { document.removeEventListener('gesturestart', preventDefault as EventListener) document.removeEventListener('gesturechange', preventDefault as EventListener) document.removeEventListener('gestureend', preventDefault as EventListener) window.removeEventListener('wheel', onWheel) window.removeEventListener('keydown', onKeyDown) } }, []) useEffect(() => { const tg = getTelegramWebApp() const backButton = tg?.BackButton if (!backButton) return if (pathname === '/' || pathname === '/sessions') { backButton.offClick(goBack) backButton.hide() return } backButton.show() backButton.onClick(goBack) return () => { backButton.offClick(goBack) backButton.hide() } }, [goBack, pathname]) const queryClient = useQueryClient() const sessionMatch = matchRoute({ to: '/sessions/$sessionId' }) const selectedSessionId = sessionMatch ? sessionMatch.sessionId : null const { isSyncing, startSync, endSync } = useSyncingState() const syncTokenRef = useRef(0) const isFirstConnectRef = useRef(true) const baseUrlRef = useRef(baseUrl) const pushPromptedRef = useRef(false) const { isSupported: isPushSupported, permission: pushPermission, requestPermission, subscribe } = usePushNotifications(api) useEffect(() => { if (baseUrlRef.current === baseUrl) { return } baseUrlRef.current = baseUrl isFirstConnectRef.current = true syncTokenRef.current = 0 queryClient.clear() }, [baseUrl, queryClient]) useEffect(() => { if (!api || !token) { pushPromptedRef.current = false return } if (isTelegramApp() || !isPushSupported) { return } if (pushPromptedRef.current) { return } pushPromptedRef.current = true const run = async () => { if (pushPermission === 'granted') { await subscribe() return } if (pushPermission === 'default') { const granted = await requestPermission() if (granted) { await subscribe() } } } void run() }, [api, isPushSupported, pushPermission, requestPermission, subscribe, token]) const handleSseConnect = useCallback(() => { // Increment token to track this specific connection const token = ++syncTokenRef.current // Only force show banner on first connect (page load) // Subsequent connects (session switches) use non-forced mode // which only shows banner when returning from background if (isFirstConnectRef.current) { isFirstConnectRef.current = false startSync({ force: true }) } else { startSync() } const invalidations = [ queryClient.invalidateQueries({ queryKey: queryKeys.sessions }), ...(selectedSessionId ? [ queryClient.invalidateQueries({ queryKey: queryKeys.session(selectedSessionId) }), queryClient.invalidateQueries({ queryKey: queryKeys.messages(selectedSessionId) }) ] : []) ] Promise.all(invalidations) .catch((error) => { console.error('Failed to invalidate queries on SSE connect:', error) }) .finally(() => { // Only end sync if this is still the latest connection if (syncTokenRef.current === token) { endSync() } }) }, [queryClient, selectedSessionId, startSync, endSync]) const handleSseEvent = useCallback(() => {}, []) const eventSubscription = useMemo(() => { if (selectedSessionId) { return { sessionId: selectedSessionId } } return { all: true } }, [selectedSessionId]) useSSE({ enabled: Boolean(api && token), token: token ?? '', baseUrl, subscription: eventSubscription, onConnect: handleSseConnect, onEvent: handleSseEvent, }) // Loading auth source if (isAuthSourceLoading) { return (
) } // No auth source (browser environment, not logged in) if (!authSource) { return ( ) } if (needsBinding) { return ( ) } // Authenticating (also covers the gap before useAuth effect starts) if (isAuthLoading || (authSource && !token && !authError)) { return (
) } // Auth error if (authError || !token || !api) { // If using access token and auth failed, show login again if (authSource.type === 'accessToken') { return ( ) } // Telegram auth failed return (
HAPI
{authError ?? 'Not authorized'}
Open this page from Telegram using the bot's "Open App" button (not "Open in browser").
) } return (
) }