From 551c20180b18744cafff2ce97f7e498e374bb350 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Wed, 15 Jul 2026 11:38:02 -0400 Subject: [PATCH 1/4] feat: inbound Accept/Decline UI driven by subscribe-offer metadata Reworks the inbound-call flow onto the new gateway/SDK paradigm: the gateway rides its accept decision and caller identity on the subscribe sdpOffer that adds a call's track, and the SDK surfaces it as RtcStream.autoAccepted / from / fromType on onStreamAvailable. There is no separate streamAvailable RPC to correlate, so the two-phase ref juggling from the old branch is gone. - App: onStreamAvailable connects straight through when s.autoAccepted, else stashes the stream and prompts. Accept is purely client-side (play the stream the gateway already bridged); decline ends the call via hangupConnection. onStreamUnavailable clears all inbound state. - EndpointHandler: auto-accept toggle (a connect-time setMediaPreferences option), disabled once the endpoint exists; passes autoAccept into connect(). - IncomingCall: small presentational component + scss showing caller identity and Accept/Decline actions. - package.json: bump bandwidth-rtc to ^0.6.0 for the reworked SDK. There are no acceptStream/declineStream RPCs: accept needs no gateway signal and decline reuses the existing hangup path, so no gateway change is required. NOTE: bandwidth-rtc 0.6.0 is not yet published (this rides SDK PR #5). Test locally via `npm link bandwidth-rtc`; regenerate package-lock.json once the SDK release is cut. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- src/App.tsx | 51 +++++++++++++++++++++++----- src/components/EndpointHandler.tsx | 14 ++++++-- src/components/IncomingCall.tsx | 24 ++++++++++++++ src/css/EndpointHandler.scss | 9 +++++ src/css/IncomingCall.scss | 53 ++++++++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 src/components/IncomingCall.tsx create mode 100644 src/css/IncomingCall.scss diff --git a/package.json b/package.json index c6323df..89a70a4 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@types/node": "^16.18.126", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "bandwidth-rtc": "^0.5.0", + "bandwidth-rtc": "^0.6.0", "bandwidth-sdk": "^7.3.0", "cors": "^2.8.5", "dotenv": "^16.4.7", diff --git a/src/App.tsx b/src/App.tsx index 95f76ba..baec393 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,9 +3,10 @@ import './css/App.scss'; import Navbar from "./components/Navbar"; import EndpointHandler from "./components/EndpointHandler"; import MediaCapture from "./components/MediaCapture"; -import BandwidthRtc, {ReadyMetadata} from "bandwidth-rtc"; +import BandwidthRtc, {EndpointType, ReadyMetadata, RtcStream} from "bandwidth-rtc"; import MediaPlayer from "./components/MediaPlayer"; import CallController from "./components/CallController"; +import IncomingCall from "./components/IncomingCall"; function App() { @@ -14,6 +15,12 @@ function App() { const [readyMetadata, setReadyMetadata] = useState(null); const [inCall, setInCall] = useState(false); const [inboundStream, setInboundStream] = useState(null); + // When autoAccept is off, an incoming stream that the gateway did not + // auto-accept lands here so we can prompt the user with Accept/Decline. + const [incomingCall, setIncomingCall] = useState(null); + // Sent to the gateway at connect time via setMediaPreferences. The gateway + // echoes its decision back on each stream (RtcStream.autoAccepted). + const [autoAccept, setAutoAccept] = useState(true); const prepBrtcClient= async (reset: boolean) => { console.log("Prepping Bandwidth RTC Client") @@ -24,6 +31,7 @@ function App() { setBrtcClient(null) setInCall(false) setInboundStream(null) + setIncomingCall(null) } if (!brtcClient || reset) { console.log("Creating Bandwidth RTC Client") @@ -33,17 +41,23 @@ function App() { // readyMetadata (and therefore MediaPlayer) exists. brtcClient.onStreamAvailable((s) => { console.log("Stream available:", s); - setInboundStream(s.mediaStream); - // The stream arriving means the call actually connected (answered), - // so we're truly in-call now — not just ringing. - setInCall(true); + // The gateway rides its accept decision on the stream itself. When it + // auto-accepted, connect straight through; otherwise prompt the user + // and wait for acceptStream/declineStream before playing any audio. + if (s.autoAccepted) { + setInboundStream(s.mediaStream); + setInCall(true); + } else { + setIncomingCall(s); + } }) brtcClient.onStreamUnavailable((s) => { console.log("Stream unavailable:", s); + // The far side (or gateway) ended/declined the call; reset the UI out + // of the in-call/ringing state. setInboundStream(null); - // The far side (or gateway) ended the call; reset the UI out of - // the in-call/ringing state. setInCall(false); + setIncomingCall(null); }) brtcClient.onReady((readyMetadata: ReadyMetadata) => { console.log("Ready Metadata:", readyMetadata); @@ -62,6 +76,24 @@ function App() { await prepBrtcClient(true) } + const handleAccept = () => { + if (!incomingCall) return; + // Accept is purely client-side: the gateway already bridged the call and + // the audio is flowing, so accepting just means playing the stream. + setInboundStream(incomingCall.mediaStream); + setInCall(true); + setIncomingCall(null); + } + + const handleDecline = async () => { + if (!brtcClient) return; + // Decline ends the call through the existing hangup path. The gateway hangs + // up the active inbound call by its own tracked id, so these args are + // vestigial for a decline — we pass the caller identity for clarity. + await brtcClient.hangupConnection(incomingCall?.from ?? "", EndpointType.CALL_ID); + setIncomingCall(null); + } + // Fetch the WebSocket URL from env const gatewayUrl = process.env.REACT_APP_WSS_URL; @@ -70,9 +102,12 @@ function App() { {brtcClient && ( <> - +
{brtcClientReady} + {incomingCall && ( + + )} {readyMetadata && ( <>

Bandwidth RTC Agent Sample

diff --git a/src/components/EndpointHandler.tsx b/src/components/EndpointHandler.tsx index 3ca6c44..4ae1757 100644 --- a/src/components/EndpointHandler.tsx +++ b/src/components/EndpointHandler.tsx @@ -3,7 +3,7 @@ import BandwidthRtc from "bandwidth-rtc"; import {Endpoint} from "../../server/types" import '../css/EndpointHandler.scss'; -function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl}: {bandwidthRtcClient: BandwidthRtc, resetClient: () => void, gatewayUrl?: string }) { +function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl, autoAccept, setAutoAccept}: {bandwidthRtcClient: BandwidthRtc, resetClient: () => void, gatewayUrl?: string, autoAccept: boolean, setAutoAccept: (autoAccept: boolean) => void }) { const [endpoint, setEndpoint] = useState(null); const [banner, setBanner] = useState<{ message: string; isError: boolean } | null>(null); @@ -23,7 +23,8 @@ function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl}: {bandwid await bandwidthRtcClient.connect({ endpointToken: endpointData.token }, { - websocketUrl: gatewayUrl + websocketUrl: gatewayUrl, + autoAccept }).then(() => { console.log("WebRTC Client Connected"); }).catch((error) => { @@ -88,6 +89,15 @@ function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl}: {bandwid + {endpoint !== null && (

{endpoint.endpointId}

)} diff --git a/src/components/IncomingCall.tsx b/src/components/IncomingCall.tsx new file mode 100644 index 0000000..54cef86 --- /dev/null +++ b/src/components/IncomingCall.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import {RtcStream} from "bandwidth-rtc"; +import '../css/IncomingCall.scss'; + +function IncomingCall({stream, onAccept, onDecline}: {stream: RtcStream, onAccept: () => void, onDecline: () => void}) { + const caller = stream.from || "Unknown"; + + return ( +
+
+ Incoming call + + {caller}{stream.fromType ? ` (${stream.fromType})` : ""} + +
+
+ + +
+
+ ); +} + +export default IncomingCall; diff --git a/src/css/EndpointHandler.scss b/src/css/EndpointHandler.scss index 8fa66b9..967d779 100644 --- a/src/css/EndpointHandler.scss +++ b/src/css/EndpointHandler.scss @@ -11,3 +11,12 @@ word-break: break-all; padding: 0 8px; } + +.auto-accept-toggle { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 8px; + cursor: pointer; +} diff --git a/src/css/IncomingCall.scss b/src/css/IncomingCall.scss new file mode 100644 index 0000000..33cfcbb --- /dev/null +++ b/src/css/IncomingCall.scss @@ -0,0 +1,53 @@ +// IncomingCall.scss + +.incoming-call { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 16px; + max-width: 480px; + margin: 8px auto; + padding: 12px 24px; + background: #e3f2fd; + border: 2px solid #1976d2; + border-radius: 8px; + + .incoming-call-info { + display: flex; + flex-direction: column; + align-items: flex-start; + } + + .incoming-call-label { + font-weight: 600; + } + + .incoming-call-from { + font-size: 0.9rem; + color: #555; + word-break: break-all; + } + + .incoming-call-actions { + display: flex; + gap: 8px; + } + + button { + border: none; + padding: 8px 20px; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + color: white; + + &.accept { + background: #4caf50; + } + + &.decline { + background: #f44336; + } + } +} From f473d47c53187339ce36c43792ffc07d176514fc Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Thu, 16 Jul 2026 08:04:16 -0700 Subject: [PATCH 2/4] fix: trim vestigial comment in handleDecline --- src/App.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index baec393..8d01e7d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -87,9 +87,7 @@ function App() { const handleDecline = async () => { if (!brtcClient) return; - // Decline ends the call through the existing hangup path. The gateway hangs - // up the active inbound call by its own tracked id, so these args are - // vestigial for a decline — we pass the caller identity for clarity. + // Decline ends the call through the existing hangup path await brtcClient.hangupConnection(incomingCall?.from ?? "", EndpointType.CALL_ID); setIncomingCall(null); } From 7fc4d550bf3949eb523af18bd8f2b4c92b529ce0 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Thu, 16 Jul 2026 12:30:13 -0700 Subject: [PATCH 3/4] fix(call-handling): route accept/decline through egress gate --- src/App.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 8d01e7d..004d8f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import './css/App.scss'; import Navbar from "./components/Navbar"; import EndpointHandler from "./components/EndpointHandler"; import MediaCapture from "./components/MediaCapture"; -import BandwidthRtc, {EndpointType, ReadyMetadata, RtcStream} from "bandwidth-rtc"; +import BandwidthRtc, {ReadyMetadata, RtcStream} from "bandwidth-rtc"; import MediaPlayer from "./components/MediaPlayer"; import CallController from "./components/CallController"; import IncomingCall from "./components/IncomingCall"; @@ -76,10 +76,11 @@ function App() { await prepBrtcClient(true) } - const handleAccept = () => { - if (!incomingCall) return; - // Accept is purely client-side: the gateway already bridged the call and - // the audio is flowing, so accepting just means playing the stream. + const handleAccept = async () => { + if (!brtcClient || !incomingCall) return; + // Open the gateway's egress gate so the parked (ringing) call's audio flows, + // then play the stream and enter the in-call state. + await brtcClient.acceptStream(); setInboundStream(incomingCall.mediaStream); setInCall(true); setIncomingCall(null); @@ -87,8 +88,9 @@ function App() { const handleDecline = async () => { if (!brtcClient) return; - // Decline ends the call through the existing hangup path - await brtcClient.hangupConnection(incomingCall?.from ?? "", EndpointType.CALL_ID); + // Decline: the gateway keeps its egress gate closed and cancels the call. + // onStreamUnavailable follows and clears the rest of the state. + await brtcClient.declineStream(); setIncomingCall(null); } From 666a9adf0464bec907db3e4182b1ce79a57ac1d1 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Thu, 16 Jul 2026 14:14:24 -0700 Subject: [PATCH 4/4] fix: default auto-accept to off and drop stale gateway comments --- src/App.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 004d8f2..64709d6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,7 +20,7 @@ function App() { const [incomingCall, setIncomingCall] = useState(null); // Sent to the gateway at connect time via setMediaPreferences. The gateway // echoes its decision back on each stream (RtcStream.autoAccepted). - const [autoAccept, setAutoAccept] = useState(true); + const [autoAccept, setAutoAccept] = useState(false); const prepBrtcClient= async (reset: boolean) => { console.log("Prepping Bandwidth RTC Client") @@ -78,8 +78,6 @@ function App() { const handleAccept = async () => { if (!brtcClient || !incomingCall) return; - // Open the gateway's egress gate so the parked (ringing) call's audio flows, - // then play the stream and enter the in-call state. await brtcClient.acceptStream(); setInboundStream(incomingCall.mediaStream); setInCall(true); @@ -88,8 +86,6 @@ function App() { const handleDecline = async () => { if (!brtcClient) return; - // Decline: the gateway keeps its egress gate closed and cancels the call. - // onStreamUnavailable follows and clears the rest of the state. await brtcClient.declineStream(); setIncomingCall(null); }