mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): Add reconnecting feedback when SSE connection is lost (#125)
- Add ReconnectingBanner component to show "Reconnecting..." status - Track SSE connection state in App.tsx with onDisconnect handler - Add onBlocked callback to useSendMessage for handling blocked send attempts - Provide haptic feedback when send is blocked due to no API or session - Show toast notification when message cannot be sent due to server disconnection - Add translation keys for reconnecting message and send blocked feedback in en/zh-CN close #125
This commit is contained in:
+15
-1
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Outlet, useLocation, useMatchRoute, useRouter } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram'
|
||||
@@ -20,6 +20,7 @@ import { LoginPrompt } from '@/components/LoginPrompt'
|
||||
import { InstallPrompt } from '@/components/InstallPrompt'
|
||||
import { OfflineBanner } from '@/components/OfflineBanner'
|
||||
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'
|
||||
@@ -112,6 +113,7 @@ function AppInner() {
|
||||
const sessionMatch = matchRoute({ to: '/sessions/$sessionId' })
|
||||
const selectedSessionId = sessionMatch ? sessionMatch.sessionId : null
|
||||
const { isSyncing, startSync, endSync } = useSyncingState()
|
||||
const [sseDisconnected, setSseDisconnected] = useState(false)
|
||||
const syncTokenRef = useRef(0)
|
||||
const isFirstConnectRef = useRef(true)
|
||||
const baseUrlRef = useRef(baseUrl)
|
||||
@@ -174,6 +176,9 @@ function AppInner() {
|
||||
}, [api, isPushSupported, pushPermission, requestPermission, subscribe, token])
|
||||
|
||||
const handleSseConnect = useCallback(() => {
|
||||
// Clear disconnected state on successful connection
|
||||
setSseDisconnected(false)
|
||||
|
||||
// Increment token to track this specific connection
|
||||
const token = ++syncTokenRef.current
|
||||
|
||||
@@ -207,6 +212,13 @@ function AppInner() {
|
||||
})
|
||||
}, [api, queryClient, selectedSessionId, startSync, endSync])
|
||||
|
||||
const handleSseDisconnect = useCallback(() => {
|
||||
// Only show reconnecting banner if we've already connected once
|
||||
if (!isFirstConnectRef.current) {
|
||||
setSseDisconnected(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSseEvent = useCallback(() => {}, [])
|
||||
const handleToast = useCallback((event: ToastEvent) => {
|
||||
addToast({
|
||||
@@ -230,6 +242,7 @@ function AppInner() {
|
||||
baseUrl,
|
||||
subscription: eventSubscription,
|
||||
onConnect: handleSseConnect,
|
||||
onDisconnect: handleSseDisconnect,
|
||||
onEvent: handleSseEvent,
|
||||
onToast: handleToast
|
||||
})
|
||||
@@ -319,6 +332,7 @@ function AppInner() {
|
||||
<AppContextProvider value={{ api, token, baseUrl }}>
|
||||
<VoiceProvider>
|
||||
<SyncingBanner isSyncing={isSyncing} />
|
||||
<ReconnectingBanner isReconnecting={sseDisconnected && !isSyncing} />
|
||||
<VoiceErrorBanner />
|
||||
<OfflineBanner />
|
||||
<div className="h-full flex flex-col">
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useOnlineStatus } from '@/hooks/useOnlineStatus'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
|
||||
export function ReconnectingBanner({ isReconnecting }: { isReconnecting: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const isOnline = useOnlineStatus()
|
||||
|
||||
// Don't show if offline (OfflineBanner takes precedence) or if not reconnecting
|
||||
if (!isReconnecting || !isOnline) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 bg-amber-500 text-white text-center py-2 text-sm font-medium z-50 flex items-center justify-center gap-2">
|
||||
<span className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full" />
|
||||
{t('reconnecting.message')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,9 +18,12 @@ type SendMessageInput = {
|
||||
attachments?: AttachmentMetadata[]
|
||||
}
|
||||
|
||||
type BlockedReason = 'no-api' | 'no-session' | 'pending'
|
||||
|
||||
type UseSendMessageOptions = {
|
||||
resolveSessionId?: (sessionId: string) => Promise<string>
|
||||
onSessionResolved?: (sessionId: string) => void
|
||||
onBlocked?: (reason: BlockedReason) => void
|
||||
}
|
||||
|
||||
function findMessageByLocalId(
|
||||
@@ -88,8 +91,20 @@ export function useSendMessage(
|
||||
})
|
||||
|
||||
const sendMessage = (text: string, attachments?: AttachmentMetadata[]) => {
|
||||
if (!api || !sessionId) return
|
||||
if (mutation.isPending || resolveGuardRef.current) return
|
||||
if (!api) {
|
||||
options?.onBlocked?.('no-api')
|
||||
haptic.notification('error')
|
||||
return
|
||||
}
|
||||
if (!sessionId) {
|
||||
options?.onBlocked?.('no-session')
|
||||
haptic.notification('error')
|
||||
return
|
||||
}
|
||||
if (mutation.isPending || resolveGuardRef.current) {
|
||||
options?.onBlocked?.('pending')
|
||||
return
|
||||
}
|
||||
const localId = makeClientSideId('local')
|
||||
const createdAt = Date.now()
|
||||
void (async () => {
|
||||
@@ -123,8 +138,20 @@ export function useSendMessage(
|
||||
}
|
||||
|
||||
const retryMessage = (localId: string) => {
|
||||
if (!api || !sessionId) return
|
||||
if (mutation.isPending || resolveGuardRef.current) return
|
||||
if (!api) {
|
||||
options?.onBlocked?.('no-api')
|
||||
haptic.notification('error')
|
||||
return
|
||||
}
|
||||
if (!sessionId) {
|
||||
options?.onBlocked?.('no-session')
|
||||
haptic.notification('error')
|
||||
return
|
||||
}
|
||||
if (mutation.isPending || resolveGuardRef.current) {
|
||||
options?.onBlocked?.('pending')
|
||||
return
|
||||
}
|
||||
|
||||
const message = findMessageByLocalId(sessionId, localId)
|
||||
if (!message?.originalText) return
|
||||
|
||||
@@ -219,6 +219,11 @@ export default {
|
||||
'offline.message': 'You are currently offline. Some features may be limited.',
|
||||
'syncing.title': 'Syncing…',
|
||||
'syncing.message': 'Your data is being synchronized.',
|
||||
'reconnecting.message': 'Reconnecting...',
|
||||
|
||||
// Send blocked
|
||||
'send.blocked.title': 'Cannot send message',
|
||||
'send.blocked.noConnection': 'Not connected to server',
|
||||
|
||||
// Install prompt
|
||||
'install.title': 'Install HAPI',
|
||||
|
||||
@@ -221,6 +221,11 @@ export default {
|
||||
'offline.message': '您当前处于离线状态。某些功能可能受限。',
|
||||
'syncing.title': '同步中…',
|
||||
'syncing.message': '正在同步您的数据。',
|
||||
'reconnecting.message': '正在重新连接...',
|
||||
|
||||
// Send blocked
|
||||
'send.blocked.title': '无法发送消息',
|
||||
'send.blocked.noConnection': '未连接到服务器',
|
||||
|
||||
// Install prompt
|
||||
'install.title': '安装 HAPI',
|
||||
|
||||
@@ -179,6 +179,7 @@ function SessionsIndexPage() {
|
||||
|
||||
function SessionPage() {
|
||||
const { api } = useAppContext()
|
||||
const { t } = useTranslation()
|
||||
const goBack = useAppGoBack()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -249,6 +250,17 @@ function SessionPage() {
|
||||
replace: true
|
||||
})
|
||||
})()
|
||||
},
|
||||
onBlocked: (reason) => {
|
||||
if (reason === 'no-api') {
|
||||
addToast({
|
||||
title: t('send.blocked.title'),
|
||||
body: t('send.blocked.noConnection'),
|
||||
sessionId: sessionId ?? '',
|
||||
url: ''
|
||||
})
|
||||
}
|
||||
// 'no-session' and 'pending' don't need toast - either invalid state or expected behavior
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user