Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/__tests__/telnyx-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
13 changes: 13 additions & 0 deletions src/__tests__/use-presence-heartbeat.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
11 changes: 10 additions & 1 deletion src/app/(app)/dialer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
<html lang="en" suppressHydrationWarning>
<body
Expand All @@ -39,6 +42,7 @@ export default function RootLayout({
defaultTheme="system"
enableSystem
disableTransitionOnChange
nonce={nonce}
>
<TooltipProvider>{children}</TooltipProvider>
</ThemeProvider>
Expand Down
4 changes: 2 additions & 2 deletions src/components/leads/lead-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,9 @@ export function LeadTable() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
onClick={async (e) => {
e.stopPropagation()
if (lead.phone) makeCall(lead.phone)
if (lead.phone) await makeCall(lead.phone)
}}
disabled={connectionStatus !== 'connected' || !lead.phone}
>
Expand Down
8 changes: 5 additions & 3 deletions src/components/phone/softphone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('')
}
}
Expand Down
123 changes: 101 additions & 22 deletions src/hooks/use-telnyx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -41,7 +46,7 @@ export interface UseTelnyxReturn {
isRegistered: boolean
hasInitialized: boolean
currentCall: TelnyxCallInfo | null
makeCall: (destinationNumber: string) => void
makeCall: (destinationNumber: string) => Promise<boolean>
answer: () => void
reject: () => void
hangup: () => void
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -141,6 +147,9 @@ export function useTelnyx(): UseTelnyxReturn {
const fallbackLastCheckedRef = useRef(0)
const channelHealthyRef = useRef(false)
const notificationUserIdRef = useRef<string | null>(null)
const ensureRegisteredRef = useRef<((reason: string) => Promise<boolean>) | 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<void>) | null>(null)

Expand Down Expand Up @@ -355,6 +364,7 @@ export function useTelnyx(): UseTelnyxReturn {
if (!destroyed) {
setConnectionStatus('error')
setRegistrationState(false)
runLifecycleRecovery('sdk-error', getLifecycleRecoveryDelay('sdk-error'))
}
})

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -878,24 +895,58 @@ export function useTelnyx(): UseTelnyxReturn {
}, delayMs)
}

ensureRegisteredRef.current = ensureRegistered
runLifecycleRecoveryRef.current = runLifecycleRecovery

let registrationCheckTimer: ReturnType<typeof setTimeout> | 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()
})
Comment on lines +919 to +921
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent duplicate health-check timers after visibility flips

This reschedules the periodic registration timeout unconditionally after ensureRegistered('periodic') finishes, but handleVisibilityChange also starts a new timer whenever the tab visibility changes. If visibility changes while a periodic check is already in flight, you can end up with two independent timeout chains, which then keep running and cause redundant ensureRegistered calls/reconnect attempts. This is reproducible under slow network checks plus quick hide/show tab transitions.

Useful? React with 👍 / 👎.

}, 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'))
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function handleOnline() {
console.log('[useTelnyx] Network online, checking registration...')
runLifecycleRecovery('online', 1000)
runLifecycleRecovery('online', getLifecycleRecoveryDelay('online'))
}

function handleOffline() {
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -1099,22 +1169,31 @@ 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<boolean> => {
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,
callerNumber: process.env.NEXT_PUBLIC_TELNYX_PHONE_NUMBER || '',
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', {
Expand Down
Loading
Loading