From 5e509cf3df76adeef2d19f4e880161b4fd960f59 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:51:30 +0200 Subject: [PATCH] feat(web): host_unreachable phase copy for dm_call_undeliverable (TDD) --- .../useWebSocket.callUndeliverable.test.ts | 20 +++++++++++++++++++ .../web/src/utils/callUndeliverableToast.ts | 20 ++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/web/src/hooks/__tests__/useWebSocket.callUndeliverable.test.ts b/packages/web/src/hooks/__tests__/useWebSocket.callUndeliverable.test.ts index 2905ef14..2a3c9840 100644 --- a/packages/web/src/hooks/__tests__/useWebSocket.callUndeliverable.test.ts +++ b/packages/web/src/hooks/__tests__/useWebSocket.callUndeliverable.test.ts @@ -34,4 +34,24 @@ describe('buildCallUndeliverableToast', () => { it('legacy two-arg signature still works', () => { expect(buildCallUndeliverableToast([fail()], true)).toMatch(/Could not reach nova/); }); + + it('builds warning copy for host_unreachable (peer_transient_failure)', () => { + const msg = buildCallUndeliverableToast( + [{ reason: 'peer_transient_failure', peerOrigin: 'https://orbit.local', peerLabel: 'Orbit' }], + true, + 'host_unreachable', + ); + expect(msg.toLowerCase()).toContain('orbit'); + expect(msg.toLowerCase()).toContain('unreachable'); + }); + + it('builds warning copy for host_unreachable (peer_rejected)', () => { + const msg = buildCallUndeliverableToast( + [{ reason: 'peer_rejected', peerOrigin: 'https://orbit.local', peerLabel: 'Orbit' }], + true, + 'host_unreachable', + ); + expect(msg.toLowerCase()).toContain('orbit'); + expect(msg.toLowerCase()).toContain('peered'); + }); }); diff --git a/packages/web/src/utils/callUndeliverableToast.ts b/packages/web/src/utils/callUndeliverableToast.ts index 2d14f830..dd4aeefc 100644 --- a/packages/web/src/utils/callUndeliverableToast.ts +++ b/packages/web/src/utils/callUndeliverableToast.ts @@ -9,6 +9,9 @@ * - `reject`: the rejector's relay to the host failed; state was already cleared * locally, so non-terminal info toast only. * - `end`: the ender's relay to the host failed; state was already cleared locally. + * - `host_unreachable`: the call was terminated by the sentinel worker because the + * host peer became permanently unreachable. Always terminal. A single failure entry + * is expected; multiple fall back to a generic line. * * Extracted from `useWebSocket.ts` so it can be unit-tested without pulling in * the full WS handler graph (livekit / audio deps). @@ -16,7 +19,7 @@ export function buildCallUndeliverableToast( failures: Array<{ reason: string; peerOrigin?: string; peerLabel?: string }>, terminal: boolean, - phase: 'start' | 'accept' | 'reject' | 'end' = 'start', + phase: 'start' | 'accept' | 'reject' | 'end' | 'host_unreachable' = 'start', ): string { const primary = failures[0]; const labelFor = (f: { peerLabel?: string; peerOrigin?: string }) => @@ -37,6 +40,21 @@ export function buildCallUndeliverableToast( return `Couldn't notify ${labels} that you hung up. Remote participants may see the call for up to 60 seconds.`; } + // host_unreachable: call terminated because the host peer became unreachable. + // Terminal is always true in this phase. A single failure entry is expected; + // zero or multiple fall back to a generic line. + if (phase === 'host_unreachable') { + const [f] = failures; + if (!f || failures.length !== 1) { + return 'Call ended — host instance became unreachable.'; + } + const label = f.peerLabel || f.peerOrigin?.replace(/^https?:\/\//, '') || 'the host instance'; + if (f.reason === 'peer_rejected') { + return `Call ended — this instance is no longer peered with ${label}.`; + } + return `Call ended — ${label} became unreachable.`; + } + // phase === 'start' (default + legacy) if (!terminal) { const labels = failures.map(labelFor).join(', ');