mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
Previously, useTerminalSocket used a relative path `/terminal` for Socket.IO, which would connect to the frontend host instead of the actual HAPI server. Now it uses the baseUrl from context to connect to the correct server, enabling scenarios like hosting the frontend on GitHub Pages while the server runs elsewhere.
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
import { Outlet, useLocation, useMatchRoute, useRouter } 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 { useVisibilityReporter } from '@/hooks/useVisibilityReporter'
|
|
import { queryKeys } from '@/lib/query-keys'
|
|
import { AppContextProvider } from '@/lib/app-context'
|
|
import { fetchLatestMessages } from '@/lib/message-window-store'
|
|
import { useAppGoBack } from '@/hooks/useAppGoBack'
|
|
import { useTranslation } from '@/lib/use-translation'
|
|
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'
|
|
import { ToastContainer } from '@/components/ToastContainer'
|
|
import { ToastProvider, useToast } from '@/lib/toast-context'
|
|
import type { SyncEvent } from '@/types/api'
|
|
|
|
type ToastEvent = Extract<SyncEvent, { type: 'toast' }>
|
|
|
|
export function App() {
|
|
return (
|
|
<ToastProvider>
|
|
<AppInner />
|
|
</ToastProvider>
|
|
)
|
|
}
|
|
|
|
function AppInner() {
|
|
const { t } = useTranslation()
|
|
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()
|
|
const router = useRouter()
|
|
const { addToast } = useToast()
|
|
|
|
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])
|
|
|
|
// Clean up URL params after successful auth (for direct access links)
|
|
useEffect(() => {
|
|
if (!token || !api) return
|
|
const { pathname, search, hash, state } = router.history.location
|
|
const searchParams = new URLSearchParams(search)
|
|
if (!searchParams.has('server') && !searchParams.has('token')) {
|
|
return
|
|
}
|
|
searchParams.delete('server')
|
|
searchParams.delete('token')
|
|
const nextSearch = searchParams.toString()
|
|
const nextHref = `${pathname}${nextSearch ? `?${nextSearch}` : ''}${hash}`
|
|
router.history.replace(nextHref, state)
|
|
}, [token, api, router])
|
|
|
|
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) })
|
|
] : [])
|
|
]
|
|
const refreshMessages = (selectedSessionId && api)
|
|
? fetchLatestMessages(api, selectedSessionId)
|
|
: Promise.resolve()
|
|
Promise.all([...invalidations, refreshMessages])
|
|
.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()
|
|
}
|
|
})
|
|
}, [api, queryClient, selectedSessionId, startSync, endSync])
|
|
|
|
const handleSseEvent = useCallback(() => {}, [])
|
|
const handleToast = useCallback((event: ToastEvent) => {
|
|
addToast({
|
|
title: event.data.title,
|
|
body: event.data.body,
|
|
sessionId: event.data.sessionId,
|
|
url: event.data.url
|
|
})
|
|
}, [addToast])
|
|
|
|
const eventSubscription = useMemo(() => {
|
|
if (selectedSessionId) {
|
|
return { sessionId: selectedSessionId }
|
|
}
|
|
return { all: true }
|
|
}, [selectedSessionId])
|
|
|
|
const { subscriptionId } = useSSE({
|
|
enabled: Boolean(api && token),
|
|
token: token ?? '',
|
|
baseUrl,
|
|
subscription: eventSubscription,
|
|
onConnect: handleSseConnect,
|
|
onEvent: handleSseEvent,
|
|
onToast: handleToast
|
|
})
|
|
|
|
useVisibilityReporter({
|
|
api,
|
|
subscriptionId,
|
|
enabled: Boolean(api && token)
|
|
})
|
|
|
|
// Loading auth source
|
|
if (isAuthSourceLoading) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center p-4">
|
|
<LoadingState label={t('loading')} className="text-sm" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// No auth source (browser environment, not logged in)
|
|
if (!authSource) {
|
|
return (
|
|
<LoginPrompt
|
|
onLogin={setAccessToken}
|
|
baseUrl={baseUrl}
|
|
serverUrl={serverUrl}
|
|
setServerUrl={setServerUrl}
|
|
clearServerUrl={clearServerUrl}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (needsBinding) {
|
|
return (
|
|
<LoginPrompt
|
|
mode="bind"
|
|
onBind={bind}
|
|
baseUrl={baseUrl}
|
|
serverUrl={serverUrl}
|
|
setServerUrl={setServerUrl}
|
|
clearServerUrl={clearServerUrl}
|
|
error={authError ?? undefined}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Authenticating (also covers the gap before useAuth effect starts)
|
|
if (isAuthLoading || (authSource && !token && !authError)) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center p-4">
|
|
<LoadingState label={t('authorizing')} className="text-sm" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Auth error
|
|
if (authError || !token || !api) {
|
|
// If using access token and auth failed, show login again
|
|
if (authSource.type === 'accessToken') {
|
|
return (
|
|
<LoginPrompt
|
|
onLogin={setAccessToken}
|
|
baseUrl={baseUrl}
|
|
serverUrl={serverUrl}
|
|
setServerUrl={setServerUrl}
|
|
clearServerUrl={clearServerUrl}
|
|
error={authError ?? t('login.error.authFailed')}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Telegram auth failed
|
|
return (
|
|
<div className="p-4 space-y-3">
|
|
<div className="text-base font-semibold">{t('login.title')}</div>
|
|
<div className="text-sm text-red-600">
|
|
{authError ?? t('login.error.authFailed')}
|
|
</div>
|
|
<div className="text-xs text-[var(--app-hint)]">
|
|
Open this page from Telegram using the bot's "Open App" button (not "Open in browser").
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<AppContextProvider value={{ api, token, baseUrl }}>
|
|
<SyncingBanner isSyncing={isSyncing} />
|
|
<OfflineBanner />
|
|
<div className="h-full flex flex-col">
|
|
<Outlet />
|
|
</div>
|
|
<ToastContainer />
|
|
<InstallPrompt />
|
|
</AppContextProvider>
|
|
)
|
|
}
|