From 6a5b02b1a030dc0dcf91bde5c2b133ed8ecb1cdd Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:21:40 +0200 Subject: [PATCH] feat(web): phase-aware dm_call_undeliverable toast copy (TDD) --- .../useWebSocket.callUndeliverable.test.ts | 37 +++++++++++ packages/web/src/hooks/useWebSocket.ts | 37 +---------- .../web/src/utils/callUndeliverableToast.ts | 64 +++++++++++++++++++ 3 files changed, 104 insertions(+), 34 deletions(-) create mode 100644 packages/web/src/hooks/__tests__/useWebSocket.callUndeliverable.test.ts create mode 100644 packages/web/src/utils/callUndeliverableToast.ts diff --git a/packages/web/src/hooks/__tests__/useWebSocket.callUndeliverable.test.ts b/packages/web/src/hooks/__tests__/useWebSocket.callUndeliverable.test.ts new file mode 100644 index 00000000..2905ef14 --- /dev/null +++ b/packages/web/src/hooks/__tests__/useWebSocket.callUndeliverable.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { buildCallUndeliverableToast } from '../../utils/callUndeliverableToast.js'; + +describe('buildCallUndeliverableToast', () => { + const fail = (overrides: Partial<{ reason: string; peerOrigin?: string; peerLabel?: string }> = {}) => ({ + reason: 'peer_transient_failure', + peerLabel: 'nova', + ...overrides, + }); + + it('start + terminal single failure: existing copy', () => { + expect(buildCallUndeliverableToast([fail()], true, 'start')).toMatch(/Could not reach nova/); + }); + + it('start + non-terminal: "some participants" copy', () => { + expect(buildCallUndeliverableToast([fail()], false, 'start')).toMatch(/Some participants could not be reached/); + }); + + it('accept + terminal: tear-down copy', () => { + expect(buildCallUndeliverableToast([fail()], true, 'accept')) + .toMatch(/Couldn't confirm your accept with nova/); + }); + + it('reject + non-terminal: info copy', () => { + expect(buildCallUndeliverableToast([fail()], false, 'reject')) + .toMatch(/Couldn't notify nova that you declined/); + }); + + it('end + non-terminal: info copy', () => { + expect(buildCallUndeliverableToast([fail()], false, 'end')) + .toMatch(/Couldn't notify nova that you hung up/); + }); + + it('legacy two-arg signature still works', () => { + expect(buildCallUndeliverableToast([fail()], true)).toMatch(/Could not reach nova/); + }); +}); diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 80fc875c..3a5b537c 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -116,40 +116,9 @@ function buildWsUrl(origin: string): string { // ─── Call relay helpers ─────────────────────────────────────────────────────── -function buildCallUndeliverableToast( - failures: Array<{ reason: string; peerOrigin?: string; peerLabel?: string }>, - terminal: boolean, -): string { - const primary = failures[0]; - const labelFor = (f: { peerLabel?: string; peerOrigin?: string }) => - f.peerLabel ?? f.peerOrigin?.replace(/^https?:\/\//, '') ?? 'the remote instance'; +import { buildCallUndeliverableToast } from '../utils/callUndeliverableToast'; - if (!terminal) { - const labels = failures.map(labelFor).join(', '); - return `Some participants could not be reached: ${labels}.`; - } - - if (failures.length > 1) { - const labels = failures.map(labelFor).join(', '); - return `Could not reach ${failures.length} instances: ${labels}.`; - } - - if (!primary) return 'Call could not be placed.'; - - const label = labelFor(primary); - switch (primary.reason) { - case 'peer_rejected': - return `Cannot reach ${label} — this instance requires manual peering approval.`; - case 'peer_awaiting_approval': - return `Waiting for ${label} admin to approve your instance. Calls will work once approved.`; - case 'peer_transient_failure': - return `Could not reach ${label}. Try again in a moment.`; - case 'livekit_unavailable': - return 'Voice is not configured on this instance.'; - default: - return `Call to ${label} could not be placed.`; - } -} +export { buildCallUndeliverableToast }; // ─── Event handling ─────────────────────────────────────────────────────────── @@ -1030,7 +999,7 @@ function handleEvent(origin: string, event: ServerEvent): void { if (disconnectFn) disconnectFn(); } - const msg = buildCallUndeliverableToast(event.failures, event.terminal); + const msg = buildCallUndeliverableToast(event.failures, event.terminal, event.phase); addToast(msg, event.terminal ? 'warning' : 'info', 8_000); break; } diff --git a/packages/web/src/utils/callUndeliverableToast.ts b/packages/web/src/utils/callUndeliverableToast.ts new file mode 100644 index 00000000..2d14f830 --- /dev/null +++ b/packages/web/src/utils/callUndeliverableToast.ts @@ -0,0 +1,64 @@ +/** + * Builds a user-facing toast message from a `dm_call_undeliverable` event. + * + * Copy is phase-aware: + * - `start`: call-start delivery; terminal means the ring was destroyed, non-terminal + * means the call continues for other reachable recipients. + * - `accept`: the acceptor's B→host relay failed; terminal means their optimistic + * active-call state was rolled back. + * - `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. + * + * Extracted from `useWebSocket.ts` so it can be unit-tested without pulling in + * the full WS handler graph (livekit / audio deps). + */ +export function buildCallUndeliverableToast( + failures: Array<{ reason: string; peerOrigin?: string; peerLabel?: string }>, + terminal: boolean, + phase: 'start' | 'accept' | 'reject' | 'end' = 'start', +): string { + const primary = failures[0]; + const labelFor = (f: { peerLabel?: string; peerOrigin?: string }) => + f.peerLabel ?? f.peerOrigin?.replace(/^https?:\/\//, '') ?? 'the remote instance'; + + if (phase === 'accept' && terminal) { + const label = primary ? labelFor(primary) : 'the host instance'; + return `Couldn't confirm your accept with ${label} — the call was dropped.`; + } + + if (phase === 'reject') { + const labels = failures.map(labelFor).join(', ') || 'the host instance'; + return `Couldn't notify ${labels} that you declined. Caller may still see you as ringing briefly.`; + } + + if (phase === 'end') { + const labels = failures.map(labelFor).join(', ') || 'the host instance'; + return `Couldn't notify ${labels} that you hung up. Remote participants may see the call for up to 60 seconds.`; + } + + // phase === 'start' (default + legacy) + if (!terminal) { + const labels = failures.map(labelFor).join(', '); + return `Some participants could not be reached: ${labels}.`; + } + if (failures.length > 1) { + const labels = failures.map(labelFor).join(', '); + return `Could not reach ${failures.length} instances: ${labels}.`; + } + if (!primary) return 'Call could not be placed.'; + + const label = labelFor(primary); + switch (primary.reason) { + case 'peer_rejected': + return `Cannot reach ${label} — this instance requires manual peering approval.`; + case 'peer_awaiting_approval': + return `Waiting for ${label} admin to approve your instance. Calls will work once approved.`; + case 'peer_transient_failure': + return `Could not reach ${label}. Try again in a moment.`; + case 'livekit_unavailable': + return 'Voice is not configured on this instance.'; + default: + return `Call to ${label} could not be placed.`; + } +}