mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* test: reproduce issue #866 * feat(web): OLED Black theme + per-appearance custom colors (closes #866) Add an explicit OLED Black appearance (true #000 canvas, border-based elevation) alongside system/dark/light, and a curated "key color" customizer. Each key color (background, surface, text, hint, accent, border, user bubble) cascades to its --app-* tokens and is stored per appearance so a color tuned for light never leaks onto pure black. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run>
458 lines
17 KiB
TypeScript
458 lines
17 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
import { Outlet, useLocation, useMatchRoute, useRouter } from '@tanstack/react-router'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram'
|
|
import { initializeChatSurfaceColors } from '@/hooks/useChatSurfaceColors'
|
|
import { initializeTheme } from '@/hooks/useTheme'
|
|
import { initializeThemeColors } from '@/hooks/useThemeColors'
|
|
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 { useViewportHeight } from '@/hooks/useViewportHeight'
|
|
import { useVisibilityReporter } from '@/hooks/useVisibilityReporter'
|
|
import { queryKeys } from '@/lib/query-keys'
|
|
import { AppContextProvider } from '@/lib/app-context'
|
|
import { clearMessageWindow, fetchLatestMessages } from '@/lib/message-window-store'
|
|
import { useAppGoBack } from '@/hooks/useAppGoBack'
|
|
import { useTranslation } from '@/lib/use-translation'
|
|
import { VoiceProvider } from '@/lib/voice-context'
|
|
import { requireHubUrlForLogin } from '@/lib/runtime-config'
|
|
import { getAppGlobalSseSubscription, getAppSessionSseSubscription } from '@/lib/appSseSubscriptions'
|
|
import { LoginPrompt } from '@/components/LoginPrompt'
|
|
import { InstallPrompt } from '@/components/InstallPrompt'
|
|
import { OfflineBanner } from '@/components/OfflineBanner'
|
|
import { PwaUpdateBanner, PwaUpdateBannerWithStatusOffset } from '@/components/PwaUpdateBanner'
|
|
import { SyncingBanner } from '@/components/SyncingBanner'
|
|
import { ReconnectingBanner } from '@/components/ReconnectingBanner'
|
|
import { VoiceErrorBanner } from '@/components/VoiceErrorBanner'
|
|
import { LoadingState } from '@/components/LoadingState'
|
|
import { ToastContainer } from '@/components/ToastContainer'
|
|
import { PwaUpdateProvider } from '@/lib/pwa-update-context'
|
|
import { ToastProvider, useToast } from '@/lib/toast-context'
|
|
import type { SyncEvent } from '@/types/api'
|
|
|
|
type ToastEvent = Extract<SyncEvent, { type: 'toast' }>
|
|
|
|
const REQUIRE_SERVER_URL = requireHubUrlForLogin()
|
|
|
|
function withPwaBanner(content: ReactNode) {
|
|
return (
|
|
<>
|
|
<PwaUpdateBanner />
|
|
{content}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function App() {
|
|
return (
|
|
<ToastProvider>
|
|
<PwaUpdateProvider>
|
|
<AppInner />
|
|
</PwaUpdateProvider>
|
|
</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()
|
|
initializeThemeColors()
|
|
initializeChatSurfaceColors()
|
|
}, [])
|
|
|
|
// Track visual viewport height for mobile keyboard avoidance (see useViewportHeight.ts)
|
|
useViewportHeight()
|
|
|
|
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 !== 'new' ? sessionMatch.sessionId : null
|
|
const { isSyncing, startSync, endSync } = useSyncingState()
|
|
const [sseDisconnected, setSseDisconnected] = useState(false)
|
|
const [sseDisconnectReason, setSseDisconnectReason] = useState<string | null>(null)
|
|
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('hub') && !searchParams.has('token')) {
|
|
return
|
|
}
|
|
searchParams.delete('server')
|
|
searchParams.delete('hub')
|
|
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(() => {
|
|
// Clear disconnected state on successful connection
|
|
setSseDisconnected(false)
|
|
setSseDisconnectReason(null)
|
|
|
|
// 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 }),
|
|
// Invalidate ALL cached session-detail entries on reconnect, not just
|
|
// the selected one. With `SESSION_DETAIL_STALE_TIME_MS` extending the
|
|
// freshness window on `useSession`, a previously-viewed session that
|
|
// received updates during the SSE gap would otherwise serve stale
|
|
// cached data on remount. See tiann/hapi#884.
|
|
queryClient.invalidateQueries({ queryKey: ['session'] })
|
|
]
|
|
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 handleSseDisconnect = useCallback((reason: string) => {
|
|
// Only show reconnecting banner if we've already connected once
|
|
if (!isFirstConnectRef.current) {
|
|
setSseDisconnected(true)
|
|
setSseDisconnectReason(reason)
|
|
}
|
|
}, [])
|
|
|
|
const handleSseEvent = useCallback((event: SyncEvent) => {
|
|
if (event.type !== 'messages-invalidated') {
|
|
return
|
|
}
|
|
if (!api || event.sessionId !== selectedSessionId) {
|
|
return
|
|
}
|
|
clearMessageWindow(event.sessionId)
|
|
void fetchLatestMessages(api, event.sessionId)
|
|
}, [api, selectedSessionId])
|
|
const translateIncomingToast = useCallback((title: string, body: string): { title: string; body: string } => {
|
|
const normalizedTitle = title.trim()
|
|
const normalizedBody = body.trim()
|
|
|
|
if (normalizedTitle === 'Ready for input') {
|
|
const waitingMatch = normalizedBody.match(/^(.+)\s+is waiting in\s+(.+)$/i)
|
|
if (waitingMatch) {
|
|
const agent = waitingMatch[1]?.trim() ?? ''
|
|
const sessionName = waitingMatch[2]?.trim() ?? ''
|
|
return {
|
|
title: t('toast.ready.title'),
|
|
body: t('toast.ready.body', { agent, session: sessionName })
|
|
}
|
|
}
|
|
return {
|
|
title: t('toast.ready.title'),
|
|
body: normalizedBody
|
|
}
|
|
}
|
|
|
|
if (normalizedTitle === 'Permission Request') {
|
|
return {
|
|
title: t('toast.permission.title'),
|
|
body: normalizedBody
|
|
}
|
|
}
|
|
|
|
if (normalizedTitle === 'Task completed') {
|
|
return {
|
|
title: t('toast.task.completed'),
|
|
body: normalizedBody
|
|
}
|
|
}
|
|
|
|
if (normalizedTitle === 'Task failed') {
|
|
return {
|
|
title: t('toast.task.failed'),
|
|
body: normalizedBody
|
|
}
|
|
}
|
|
|
|
return { title, body }
|
|
}, [t])
|
|
|
|
const handleToast = useCallback((event: ToastEvent) => {
|
|
const localized = translateIncomingToast(event.data.title, event.data.body)
|
|
addToast({
|
|
title: localized.title,
|
|
body: localized.body,
|
|
sessionId: event.data.sessionId,
|
|
url: event.data.url
|
|
})
|
|
}, [addToast, translateIncomingToast])
|
|
|
|
const globalEventSubscription = useMemo(() => getAppGlobalSseSubscription(), [])
|
|
const sessionEventSubscription = useMemo(
|
|
() => getAppSessionSseSubscription(selectedSessionId),
|
|
[selectedSessionId]
|
|
)
|
|
const sseEnabled = Boolean(api && token)
|
|
|
|
const { subscriptionId: globalSubscriptionId } = useSSE({
|
|
enabled: sseEnabled,
|
|
token: token ?? '',
|
|
baseUrl,
|
|
subscription: globalEventSubscription,
|
|
scope: 'global',
|
|
onConnect: handleSseConnect,
|
|
onDisconnect: handleSseDisconnect,
|
|
onEvent: () => {},
|
|
onToast: handleToast
|
|
})
|
|
|
|
const { subscriptionId: sessionSubscriptionId } = useSSE({
|
|
enabled: sseEnabled && Boolean(sessionEventSubscription),
|
|
token: token ?? '',
|
|
baseUrl,
|
|
subscription: sessionEventSubscription ?? undefined,
|
|
scope: 'full',
|
|
onEvent: handleSseEvent
|
|
})
|
|
|
|
useVisibilityReporter({
|
|
api,
|
|
subscriptionId: globalSubscriptionId,
|
|
enabled: sseEnabled
|
|
})
|
|
|
|
useVisibilityReporter({
|
|
api,
|
|
subscriptionId: sessionSubscriptionId,
|
|
enabled: sseEnabled && Boolean(sessionEventSubscription)
|
|
})
|
|
|
|
// Loading auth source
|
|
if (isAuthSourceLoading) {
|
|
return withPwaBanner(
|
|
<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 withPwaBanner(
|
|
<LoginPrompt
|
|
onLogin={setAccessToken}
|
|
baseUrl={baseUrl}
|
|
serverUrl={serverUrl}
|
|
setServerUrl={setServerUrl}
|
|
clearServerUrl={clearServerUrl}
|
|
requireServerUrl={REQUIRE_SERVER_URL}
|
|
/>,
|
|
)
|
|
}
|
|
|
|
if (needsBinding) {
|
|
return withPwaBanner(
|
|
<LoginPrompt
|
|
mode="bind"
|
|
onBind={bind}
|
|
baseUrl={baseUrl}
|
|
serverUrl={serverUrl}
|
|
setServerUrl={setServerUrl}
|
|
clearServerUrl={clearServerUrl}
|
|
requireServerUrl={REQUIRE_SERVER_URL}
|
|
error={authError ?? undefined}
|
|
/>,
|
|
)
|
|
}
|
|
|
|
// Authenticating (also covers the gap before useAuth effect starts)
|
|
if (isAuthLoading || (authSource && !token && !authError)) {
|
|
return withPwaBanner(
|
|
<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 withPwaBanner(
|
|
<LoginPrompt
|
|
onLogin={setAccessToken}
|
|
baseUrl={baseUrl}
|
|
serverUrl={serverUrl}
|
|
setServerUrl={setServerUrl}
|
|
clearServerUrl={clearServerUrl}
|
|
requireServerUrl={REQUIRE_SERVER_URL}
|
|
error={authError ?? t('login.error.authFailed')}
|
|
/>,
|
|
)
|
|
}
|
|
|
|
// Telegram auth failed
|
|
return withPwaBanner(
|
|
<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 }}>
|
|
<VoiceProvider>
|
|
<PwaUpdateBannerWithStatusOffset
|
|
isSyncing={isSyncing}
|
|
isReconnecting={sseDisconnected && !isSyncing}
|
|
/>
|
|
<SyncingBanner isSyncing={isSyncing} />
|
|
<ReconnectingBanner
|
|
isReconnecting={sseDisconnected && !isSyncing}
|
|
reason={sseDisconnectReason}
|
|
/>
|
|
<VoiceErrorBanner />
|
|
<OfflineBanner />
|
|
<div className="h-full min-h-0 flex flex-col">
|
|
<Outlet />
|
|
</div>
|
|
<ToastContainer />
|
|
<InstallPrompt />
|
|
</VoiceProvider>
|
|
</AppContextProvider>
|
|
)
|
|
}
|