mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-27 08:02:27 +00:00
156 lines
6.6 KiB
TypeScript
156 lines
6.6 KiB
TypeScript
import React, { useEffect, useRef } from 'react'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { useSocket } from './context/socket'
|
|
import { useAppStore } from '@/stores'
|
|
import { useUserProfile } from './context/user'
|
|
import { useRealtimeEvents } from './useRealtimeEvents'
|
|
import { PresenceProvider } from './PresenceProvider'
|
|
import useUnseenCount from '@/lib/api/hooks/app/unibox/useUnseenCount'
|
|
|
|
// Heartbeat roundtrip bands behind the header's connection indicator. A healthy
|
|
// socket answers in tens of milliseconds, and the provider's own watchdog gives
|
|
// up at 8s and reconnects, so "poor" stays well under that.
|
|
const DEGRADED_LATENCY_MS = 500
|
|
const POOR_LATENCY_MS = 2000
|
|
|
|
export function RealtimeManager({ children }: { children: React.ReactNode }) {
|
|
const { isConnected, reconnectAttempt, joinChannel, leaveChannel } = useSocket()
|
|
const { user } = useUserProfile()
|
|
const currentOrg = useAppStore((s) => s.currentOrganization)
|
|
const setConnectionStatus = useAppStore((s) => s.setConnectionStatus)
|
|
const setReconnectAttempt = useAppStore((s) => s.setReconnectAttempt)
|
|
const setConnectionQuality = useAppStore((s) => s.setConnectionQuality)
|
|
const addJoinedChannel = useAppStore((s) => s.addJoinedChannel)
|
|
const removeJoinedChannel = useAppStore((s) => s.removeJoinedChannel)
|
|
const setUnseenCount = useAppStore((s) => s.setUnseenCount)
|
|
const wsLatencyMs = useAppStore((s) => s.wsLatencyMs)
|
|
|
|
const queryClient = useQueryClient()
|
|
const hadConnectionRef = useRef(false)
|
|
|
|
// Catch-up on reconnect: events are fire-and-forget, so anything emitted
|
|
// while the socket was down (laptop sleep, network blip) is gone forever.
|
|
// Rather than trusting caches that may have silently diverged, mark every
|
|
// query stale on RE-connect (not the initial connect) — active views
|
|
// refetch immediately, background ones on next focus.
|
|
//
|
|
// Gated hard: only after a real outage (>5s down, long enough to have
|
|
// actually missed events) and at most once every 30s. A flapping socket
|
|
// otherwise refetch-storms every active query and the whole dashboard
|
|
// visibly reloads over and over.
|
|
const disconnectedAtRef = useRef<number | null>(null)
|
|
const lastCatchupRef = useRef(0)
|
|
useEffect(() => {
|
|
if (!isConnected) {
|
|
if (hadConnectionRef.current && disconnectedAtRef.current === null) {
|
|
disconnectedAtRef.current = Date.now()
|
|
}
|
|
return
|
|
}
|
|
if (hadConnectionRef.current && disconnectedAtRef.current !== null) {
|
|
const gap = Date.now() - disconnectedAtRef.current
|
|
const sinceLast = Date.now() - lastCatchupRef.current
|
|
if (gap > 5_000 && sinceLast > 30_000) {
|
|
lastCatchupRef.current = Date.now()
|
|
void queryClient.invalidateQueries()
|
|
}
|
|
}
|
|
hadConnectionRef.current = true
|
|
disconnectedAtRef.current = null
|
|
}, [isConnected, queryClient])
|
|
|
|
// Sync connection status to store
|
|
useEffect(() => {
|
|
if (isConnected) {
|
|
setConnectionStatus('connected')
|
|
setConnectionQuality('good')
|
|
setReconnectAttempt(0)
|
|
} else if (reconnectAttempt > 0) {
|
|
setConnectionStatus('connecting')
|
|
setConnectionQuality('poor')
|
|
setReconnectAttempt(reconnectAttempt)
|
|
} else {
|
|
setConnectionStatus('disconnected')
|
|
setConnectionQuality('disconnected')
|
|
}
|
|
}, [isConnected, reconnectAttempt, setConnectionStatus, setConnectionQuality, setReconnectAttempt])
|
|
|
|
// Auto-join user channel. Topic uses the user UUID, not the email —
|
|
// the realtime channel handler is `def join("user:" <> user_id, ...)`
|
|
// and compares against `socket.assigns.user_id` which is the JWT
|
|
// `sub` (UUID). Using email here would refuse the join.
|
|
useEffect(() => {
|
|
if (isConnected && user?.id) {
|
|
const topic = `user:${user.id}`
|
|
joinChannel(topic)
|
|
addJoinedChannel(topic)
|
|
return () => {
|
|
leaveChannel(topic)
|
|
removeJoinedChannel(topic)
|
|
}
|
|
}
|
|
}, [isConnected, user?.id, joinChannel, leaveChannel, addJoinedChannel, removeJoinedChannel])
|
|
|
|
// Auto-join/leave org channel on org switch. Needs the cleanup: the effect
|
|
// re-runs on every reconnect, so without one the join never gets its leave.
|
|
useEffect(() => {
|
|
if (!isConnected || !currentOrg?.id) return
|
|
|
|
const topic = `org:${currentOrg.id}`
|
|
joinChannel(topic)
|
|
addJoinedChannel(topic)
|
|
|
|
return () => {
|
|
leaveChannel(topic)
|
|
removeJoinedChannel(topic)
|
|
}
|
|
}, [isConnected, currentOrg?.id, joinChannel, leaveChannel, addJoinedChannel, removeJoinedChannel])
|
|
|
|
// Connection quality, from the heartbeat roundtrip the socket provider
|
|
// measures. The previous version compared its own 5s interval against
|
|
// itself, so ordinary timer drift read as "degraded" and the header claimed
|
|
// "Slow connection" on a perfectly healthy socket.
|
|
useEffect(() => {
|
|
if (!isConnected) return
|
|
// Null until the first heartbeat reply lands: a connected socket with
|
|
// nothing to report yet, not a slow one.
|
|
if (wsLatencyMs === null) {
|
|
setConnectionQuality('good')
|
|
return
|
|
}
|
|
if (wsLatencyMs > POOR_LATENCY_MS) {
|
|
setConnectionQuality('poor')
|
|
} else if (wsLatencyMs > DEGRADED_LATENCY_MS) {
|
|
setConnectionQuality('degraded')
|
|
} else {
|
|
setConnectionQuality('good')
|
|
}
|
|
}, [isConnected, wsLatencyMs, setConnectionQuality])
|
|
|
|
// The unread inbox count comes from the server and nowhere else: realtime
|
|
// inbox events invalidate this query rather than counting, so the title,
|
|
// favicon and nav badge always match what the Inbox lists.
|
|
//
|
|
// This is a react-query (not a one-shot fetch) on purpose: /unibox/count is
|
|
// org-scoped, and on a fresh login the bootstrap fires before OrgGate has
|
|
// re-synced the server session — so the first read returns the wrong/empty
|
|
// count. OrgGate's "sync" switch invalidates org-scoped queries on success
|
|
// (root "unibox" included), which refetches this against the correct session
|
|
// and corrects the badge without a reload. Gated on a selected workspace so a
|
|
// multi-org login (which redirects to /select-org before any sync) doesn't
|
|
// fire a NULL-org read. Best-effort: a failure leaves the current count as-is.
|
|
// Keyed on every fetch, not on the value, so each refetch is applied.
|
|
const unseenQuery = useUnseenCount({ enabled: !!currentOrg })
|
|
const unseenFetched = unseenQuery.dataUpdatedAt
|
|
useEffect(() => {
|
|
const c = unseenQuery.data?.count
|
|
if (typeof c === 'number') setUnseenCount(c)
|
|
}, [unseenQuery.data, unseenFetched, setUnseenCount])
|
|
|
|
// Set up event-to-store routing
|
|
useRealtimeEvents()
|
|
|
|
// Presence rides the same org channel: who's online, who's viewing what.
|
|
return <PresenceProvider>{children}</PresenceProvider>
|
|
}
|