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
+}