diff --git a/src/__tests__/telnyx-lifecycle.test.ts b/src/__tests__/telnyx-lifecycle.test.ts new file mode 100644 index 00000000..4dc94720 --- /dev/null +++ b/src/__tests__/telnyx-lifecycle.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { + getLifecycleRecoveryDelay, + getRegistrationHealthCheckIntervalMs, + shouldRunInteractionRecovery, +} from '@/lib/telnyx-lifecycle' + +describe('telnyx lifecycle helpers', () => { + it('returns the expected recovery delay for each lifecycle trigger', () => { + expect(getLifecycleRecoveryDelay('pageshow')).toBe(500) + expect(getLifecycleRecoveryDelay('online')).toBe(1000) + expect(getLifecycleRecoveryDelay('sdk-error')).toBe(1000) + expect(getLifecycleRecoveryDelay('socket-close-active')).toBe(1000) + expect(getLifecycleRecoveryDelay('socket-close-idle')).toBe(1000) + expect(getLifecycleRecoveryDelay('focus')).toBe(1000) + expect(getLifecycleRecoveryDelay('interaction')).toBe(1000) + expect(getLifecycleRecoveryDelay('makeCall')).toBe(1000) + expect(getLifecycleRecoveryDelay('visibilitychange')).toBe(1500) + expect(getLifecycleRecoveryDelay('post-call')).toBe(8000) + }) + + it('uses the hidden interval when the tab is backgrounded', () => { + expect(getRegistrationHealthCheckIntervalMs('hidden', 60_000, 15_000)).toBe(15_000) + }) + + it('uses the visible interval when the tab is visible or unknown', () => { + expect(getRegistrationHealthCheckIntervalMs('visible', 60_000, 15_000)).toBe(60_000) + expect(getRegistrationHealthCheckIntervalMs(undefined, 60_000, 15_000)).toBe(60_000) + }) + + it('rate limits interaction-driven recovery checks', () => { + expect(shouldRunInteractionRecovery(10_000, 0)).toBe(true) + expect(shouldRunInteractionRecovery(4_999, 0)).toBe(false) + expect(shouldRunInteractionRecovery(6_000, 2_000)).toBe(false) + expect(shouldRunInteractionRecovery(7_001, 2_000)).toBe(true) + }) + + it('accepts a custom cooldown for edge-case throttling', () => { + expect(shouldRunInteractionRecovery(2_000, 0, 2_500)).toBe(false) + expect(shouldRunInteractionRecovery(2_500, 0, 2_500)).toBe(true) + }) +}) diff --git a/src/__tests__/use-presence-heartbeat.test.ts b/src/__tests__/use-presence-heartbeat.test.ts new file mode 100644 index 00000000..1e930995 --- /dev/null +++ b/src/__tests__/use-presence-heartbeat.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' + +import { shouldSendPresenceHeartbeat } from '@/hooks/usePresence' + +describe('shouldSendPresenceHeartbeat', () => { + it('allows heartbeats for the leader tab', () => { + expect(shouldSendPresenceHeartbeat(true)).toBe(true) + }) + + it('blocks heartbeats for non-leader tabs', () => { + expect(shouldSendPresenceHeartbeat(false)).toBe(false) + }) +}) diff --git a/src/app/(app)/dialer/page.tsx b/src/app/(app)/dialer/page.tsx index b7e424a1..6325330b 100644 --- a/src/app/(app)/dialer/page.tsx +++ b/src/app/(app)/dialer/page.tsx @@ -309,7 +309,16 @@ export default function DialerPage() { }) // Make the call via Telnyx WebRTC - makeCall(currentLead.phone) + const didStartCall = await makeCall(currentLead.phone) + if (!didStartCall) { + setDialing(false) + await fetch('/api/dialer/attempt', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: session.id, lead_id: currentLead.id, status: 'failed' }), + }) + return + } // Don't clear dialing here — keep button locked until SDK emits callActive } catch (err) { console.error('Dial failed:', err) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 543da5e4..71002fb7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic' import type { Metadata } from "next"; +import { headers } from 'next/headers' import { Plus_Jakarta_Sans, Geist_Mono } from "next/font/google"; import { TooltipProvider } from "@/components/ui/tooltip"; import { ThemeProvider } from "@/components/theme-provider"; @@ -24,11 +25,13 @@ export const metadata: Metadata = { "The inbound-first dialer + CRM that insurance agencies actually need. IVR, ACD, screen pop, and power dialer -- built for Final Expense and Life Insurance.", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const nonce = (await headers()).get('x-nonce') ?? undefined + return ( {children} diff --git a/src/components/leads/lead-table.tsx b/src/components/leads/lead-table.tsx index a9d3016a..2aef3dcb 100644 --- a/src/components/leads/lead-table.tsx +++ b/src/components/leads/lead-table.tsx @@ -246,9 +246,9 @@ export function LeadTable() { { + onClick={async (e) => { e.stopPropagation() - if (lead.phone) makeCall(lead.phone) + if (lead.phone) await makeCall(lead.phone) }} disabled={connectionStatus !== 'connected' || !lead.phone} > diff --git a/src/components/phone/softphone.tsx b/src/components/phone/softphone.tsx index eab84b40..dac9a575 100644 --- a/src/components/phone/softphone.tsx +++ b/src/components/phone/softphone.tsx @@ -130,10 +130,12 @@ export function Softphone() { const handleBackspace = () => setDialInput((prev) => prev.slice(0, -1)) - const handleDial = () => { + const handleDial = async () => { const number = dialInput.trim() - if (number) { - makeCall(number) + if (!number) return + + const didStartCall = await makeCall(number) + if (didStartCall) { setDialInput('') } } diff --git a/src/hooks/use-telnyx.ts b/src/hooks/use-telnyx.ts index 9fefbea1..fec76745 100644 --- a/src/hooks/use-telnyx.ts +++ b/src/hooks/use-telnyx.ts @@ -5,6 +5,11 @@ import { createClient } from '@/lib/supabase/client' // Realtime uses the SSR client directly import type { CallNotification, ScreenPopData } from '@/types' import { showPresenceToast } from '@/hooks/usePresence' +import { + getLifecycleRecoveryDelay, + getRegistrationHealthCheckIntervalMs, + shouldRunInteractionRecovery, +} from '@/lib/telnyx-lifecycle' // --------------------------------------------------------------------------- // Types @@ -41,7 +46,7 @@ export interface UseTelnyxReturn { isRegistered: boolean hasInitialized: boolean currentCall: TelnyxCallInfo | null - makeCall: (destinationNumber: string) => void + makeCall: (destinationNumber: string) => Promise answer: () => void reject: () => void hangup: () => void @@ -70,6 +75,7 @@ export interface UseTelnyxReturn { const MIN_REINIT_INTERVAL_MS = 5_000 // never reinit more than once per 5s const SOCKET_RECOVERY_GRACE_MS = 20_000 const REGISTRATION_CHECK_INTERVAL_MS = 60_000 +const HIDDEN_REGISTRATION_CHECK_INTERVAL_MS = 15_000 // Keep manual reconnect cooldown shorter than the registration check interval // so one failed attempt doesn't suppress the next periodic health check. const MANUAL_RECONNECT_COOLDOWN_MS = REGISTRATION_CHECK_INTERVAL_MS / 2 @@ -141,6 +147,9 @@ export function useTelnyx(): UseTelnyxReturn { const fallbackLastCheckedRef = useRef(0) const channelHealthyRef = useRef(false) const notificationUserIdRef = useRef(null) + const ensureRegisteredRef = useRef<((reason: string) => Promise) | null>(null) + const runLifecycleRecoveryRef = useRef<((reason: string, delayMs?: number) => void) | null>(null) + const lastInteractionRecoveryAtRef = useRef(0) // Ref to expose triggerManualReconnect to external callers (e.g. reconnect button) const manualReconnectFnRef = useRef<((reason: string) => Promise) | null>(null) @@ -355,6 +364,7 @@ export function useTelnyx(): UseTelnyxReturn { if (!destroyed) { setConnectionStatus('error') setRegistrationState(false) + runLifecycleRecovery('sdk-error', getLifecycleRecoveryDelay('sdk-error')) } }) @@ -374,6 +384,10 @@ export function useTelnyx(): UseTelnyxReturn { }) ) + // Start reconnecting immediately in parallel so the call can survive + // if the browser was only background-throttled for a moment. + runLifecycleRecovery('socket-close-active', getLifecycleRecoveryDelay('socket-close-active')) + // After grace period, if still disconnected, THEN end the call if (graceTimerRef.current) clearTimeout(graceTimerRef.current) graceTimerRef.current = setTimeout(async () => { @@ -402,7 +416,10 @@ export function useTelnyx(): UseTelnyxReturn { await ensureRegistered('socket-close-grace') }, SOCKET_RECOVERY_GRACE_MS) } else { - // No active call - just try to reconnect + // No active call - reconnect right away, then keep the grace follow-up + // as a second chance if the browser was suspended. + runLifecycleRecovery('socket-close-idle', getLifecycleRecoveryDelay('socket-close-idle')) + if (graceTimerRef.current) clearTimeout(graceTimerRef.current) graceTimerRef.current = setTimeout(async () => { graceTimerRef.current = null @@ -878,24 +895,58 @@ export function useTelnyx(): UseTelnyxReturn { }, delayMs) } + ensureRegisteredRef.current = ensureRegistered + runLifecycleRecoveryRef.current = runLifecycleRecovery + + let registrationCheckTimer: ReturnType | null = null + const scheduleRegistrationHealthCheck = () => { + if (destroyed) return + const visibilityState = typeof document !== 'undefined' ? document.visibilityState : 'visible' + const delay = getRegistrationHealthCheckIntervalMs( + visibilityState, + REGISTRATION_CHECK_INTERVAL_MS, + HIDDEN_REGISTRATION_CHECK_INTERVAL_MS + ) + + registrationCheckTimer = setTimeout(() => { + registrationCheckTimer = null + if (destroyed || connectionStatusRef.current === 'config_error') { + scheduleRegistrationHealthCheck() + return + } + + console.log('[useTelnyx] Periodic registration check...', { visibilityState }) + void ensureRegistered('periodic').finally(() => { + scheduleRegistrationHealthCheck() + }) + }, delay) + } + // Recover when tab becomes visible again function handleVisibilityChange() { + if (registrationCheckTimer) { + clearTimeout(registrationCheckTimer) + registrationCheckTimer = null + } + + scheduleRegistrationHealthCheck() + if (document.visibilityState === 'visible') { console.log('[useTelnyx] Tab visible, checking registration...') if (connectionStatusRef.current === 'disconnected' || connectionStatusRef.current === 'error') { void triggerManualReconnect('visibility-resume') - runLifecycleRecovery('visibilitychange', 500) + runLifecycleRecovery('visibilitychange', Math.min(500, getLifecycleRecoveryDelay('visibilitychange'))) return } - runLifecycleRecovery('visibilitychange', 1500) + runLifecycleRecovery('visibilitychange', getLifecycleRecoveryDelay('visibilitychange')) } } function handleOnline() { console.log('[useTelnyx] Network online, checking registration...') - runLifecycleRecovery('online', 1000) + runLifecycleRecovery('online', getLifecycleRecoveryDelay('online')) } function handleOffline() { @@ -907,30 +958,46 @@ export function useTelnyx(): UseTelnyxReturn { } function handlePageShow(event: PageTransitionEvent) { - if (event.persisted) { - console.log('[useTelnyx] Page restored from bfcache, checking registration...') - runLifecycleRecovery('pageshow', 500) - } + if (!event.persisted && connectionStatusRef.current === 'connected') return + console.log('[useTelnyx] Page show, checking registration...') + runLifecycleRecovery('pageshow', getLifecycleRecoveryDelay('pageshow')) + } + + function handleWindowFocus() { + console.log('[useTelnyx] Window focused, checking registration...') + runLifecycleRecovery('focus', getLifecycleRecoveryDelay('focus')) + } + + function handleUserInteraction() { + const now = Date.now() + if (!shouldRunInteractionRecovery(now, lastInteractionRecoveryAtRef.current)) return + lastInteractionRecoveryAtRef.current = now + runLifecycleRecovery('interaction', getLifecycleRecoveryDelay('interaction')) } document.addEventListener('visibilitychange', handleVisibilityChange) window.addEventListener('online', handleOnline) window.addEventListener('offline', handleOffline) window.addEventListener('pageshow', handlePageShow) + window.addEventListener('focus', handleWindowFocus) + window.addEventListener('pointerdown', handleUserInteraction, { passive: true }) + window.addEventListener('keydown', handleUserInteraction) - // Periodic registration health check (SDK reconnect remains primary) - const registrationCheckInterval = setInterval(() => { - if (destroyed) return - if (connectionStatusRef.current === 'config_error') return - console.log('[useTelnyx] Periodic registration check...') - void ensureRegistered('periodic') - }, REGISTRATION_CHECK_INTERVAL_MS) + // Periodic registration health check (SDK reconnect remains primary). + // Hidden tabs use a shorter cadence because browsers may silently suspend + // the WebRTC websocket/SIP registration after a period of inactivity. + scheduleRegistrationHealthCheck() return () => { destroyed = true + ensureRegisteredRef.current = null + runLifecycleRecoveryRef.current = null ensureRegisteredScheduledRef.current = false stopDurationTimer() - clearInterval(registrationCheckInterval) + if (registrationCheckTimer) { + clearTimeout(registrationCheckTimer) + registrationCheckTimer = null + } if (graceTimerRef.current) { clearTimeout(graceTimerRef.current) graceTimerRef.current = null @@ -949,6 +1016,9 @@ export function useTelnyx(): UseTelnyxReturn { window.removeEventListener('online', handleOnline) window.removeEventListener('offline', handleOffline) window.removeEventListener('pageshow', handlePageShow) + window.removeEventListener('focus', handleWindowFocus) + window.removeEventListener('pointerdown', handleUserInteraction) + window.removeEventListener('keydown', handleUserInteraction) keepaliveLockReleaseRef.current?.() cleanupRemoteAudio() if (client) { @@ -1099,11 +1169,17 @@ export function useTelnyx(): UseTelnyxReturn { // ---- Call controls ---- - const makeCall = useCallback((destinationNumber: string) => { - if (!clientRef.current) { - console.warn('[useTelnyx] Client not connected') - return + const makeCall = useCallback(async (destinationNumber: string): Promise => { + if (!clientRef.current || connectionStatusRef.current !== 'connected' || !isRegistered) { + console.warn('[useTelnyx] Dial requested while not ready, attempting recovery first') + runLifecycleRecoveryRef.current?.('makeCall', getLifecycleRecoveryDelay('makeCall')) + const recovered = await ensureRegisteredRef.current?.('makeCall') + if (!recovered || !clientRef.current) { + showPresenceToast('Phone connection is recovering. Try again in a moment.', 'error') + return false + } } + try { const call = clientRef.current.newCall({ destinationNumber, @@ -1111,10 +1187,13 @@ export function useTelnyx(): UseTelnyxReturn { callerName: 'PolicyJar', }) callRef.current = call + return true } catch (err) { console.error('[useTelnyx] makeCall error:', err) + showPresenceToast('Unable to start the call. Please try again.', 'error') + return false } - }, []) + }, [isRegistered]) const answer = useCallback(() => { console.log('[useTelnyx] answer() called', { diff --git a/src/hooks/usePresence.ts b/src/hooks/usePresence.ts index b0991066..b7e19ab7 100644 --- a/src/hooks/usePresence.ts +++ b/src/hooks/usePresence.ts @@ -20,6 +20,11 @@ const PRESENCE_CONNECTION_EVENT = 'presence:connection-state' type PresenceConnectionState = 'online' | 'degraded' | 'offline' +// Exported for focused unit tests around leader-only heartbeats. +export function shouldSendPresenceHeartbeat(isLeader: boolean) { + return isLeader +} + let sharedWrapUpEndsAt: number | null = null let presenceSingletonRefCount = 0 let sharedHeartbeatInterval: ReturnType | null = null @@ -234,6 +239,10 @@ export function usePresence() { }, []) const sendHeartbeat = useCallback(async () => { + if (!shouldSendPresenceHeartbeat(presenceLeaderRef.current)) { + return + } + const heartbeatRequestId = ++heartbeatRequestCounterRef.current try { // In background tabs, browsers throttle fetch/XHR but not sendBeacon. @@ -438,6 +447,11 @@ export function usePresence() { } } + const handleBecameLeader = () => { + staleCheckNeededRef.current = true + void sendHeartbeat() + } + const electLeader = () => { const channel = leadershipChannelRef.current if (!channel) { @@ -482,6 +496,7 @@ export function usePresence() { // Became leader: start broadcasting heartbeats for non-leader watchdogs. startBcLeaderHeartbeat() stopBcNonLeaderWatchdog() + handleBecameLeader() } else { // Lost election: watch for leader heartbeats to detect future crashes. stopBcLeaderHeartbeat() @@ -542,8 +557,12 @@ export function usePresence() { const leaderFresh = Boolean(leader?.timestamp && now - Number(leader.timestamp) < PRESENCE_LEADER_FRESH_MS) const sameTab = leader?.tabId === tabIdRef.current const canClaim = !leaderFresh || sameTab + const becameLeader = canClaim && !presenceLeaderRef.current setLeader(canClaim) if (canClaim) { + if (becameLeader) { + handleBecameLeader() + } try { window.localStorage.setItem( PRESENCE_LEADER_KEY, diff --git a/src/lib/telnyx-lifecycle.ts b/src/lib/telnyx-lifecycle.ts new file mode 100644 index 00000000..806ee6cb --- /dev/null +++ b/src/lib/telnyx-lifecycle.ts @@ -0,0 +1,50 @@ +export type TelnyxRecoveryReason = + | 'sdk-error' + | 'socket-close-active' + | 'socket-close-idle' + | 'visibilitychange' + | 'online' + | 'pageshow' + | 'focus' + | 'interaction' + | 'post-call' + | 'makeCall' + +const INTERACTION_RECOVERY_COOLDOWN_MS = 5_000 + +export function getLifecycleRecoveryDelay(reason: TelnyxRecoveryReason): number { + switch (reason) { + case 'pageshow': + return 500 + case 'online': + case 'sdk-error': + case 'socket-close-active': + case 'socket-close-idle': + case 'focus': + case 'interaction': + case 'makeCall': + return 1_000 + case 'visibilitychange': + return 1_500 + case 'post-call': + return 8_000 + default: + return 1_000 + } +} + +export function getRegistrationHealthCheckIntervalMs( + visibilityState: string | undefined, + visibleIntervalMs: number, + hiddenIntervalMs: number +): number { + return visibilityState === 'hidden' ? hiddenIntervalMs : visibleIntervalMs +} + +export function shouldRunInteractionRecovery( + now: number, + lastInteractionRecoveryAt: number, + cooldownMs = INTERACTION_RECOVERY_COOLDOWN_MS +): boolean { + return now - lastInteractionRecoveryAt >= cooldownMs +}