Skip to content

Commit 63fd46e

Browse files
authored
Merge pull request #37 from deltachat/wofwca/accept-call-prompt
feat: add "Answer Call" button
2 parents f3269f8 + b9f5bc5 commit 63fd46e

File tree

4 files changed

+99
-14
lines changed

4 files changed

+99
-14
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ To integrate into your Delta Chat client you need to provide a
1616
Commands are given to the app via URL hash:
1717

1818
- `#startCall`: tells the app to generate an offer payload and call `startCall()`, this is how the app should be open when the user is starting an outgoing call.
19-
- `#acceptCall=PAYLOAD`: tells the app to auto-accept the incoming offer, generating an answer payload and calling `acceptCall()`, this must be used when the user clicks "Answer" in the incoming call notification.
19+
- `#offerIncomingCall=PAYLOAD`: tells the app to show the "Incoming call. Answer?" screen. Then, if the user clicks "Answer", generate a WebRTC answer to the offer provided in `PAYLOAD`, and call `window.calls.acceptCall(webrtcAnswer)`. If the user declined the call, the app will invoke `window.calls.endCall`.
20+
- `#acceptCall=PAYLOAD`: same as `#offerIncomingCall`, but doesn't show the "Incoming call. Answer?" screen and instead automatically and immediately accepts the call.
2021
- `#onAnswer=PAYLOAD`: notifies the app that the outgoing call was accepted and provides the answer payload
2122

2223
**IMPORTANT:** `PAYLOAD` **must** be base64 encoded (NOTE: you might still need to URL-encode the base64 string to be a valid URL hash) before passing it to the app in the URL hash.

calls.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ window.webxdc.setUpdateListener(
5050
if (peer === selfAddr) {
5151
} else if (cmd === "start") {
5252
console.log("INCOMING CALL!");
53-
window.location.hash = "#acceptCall=" + payload;
53+
const autoAccept = false;
54+
window.location.hash =
55+
(autoAccept ? "#acceptCall=" : "#offerIncomingCall=") + payload;
5456
} else if (cmd === "accept") {
5557
console.log("CALL ACCEPTED!");
5658
window.location.hash = "#onAnswer=" + payload;

src/App.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import EndCallButton from "~/components/EndCallButton";
88
import AvatarPlaceholder from "~/components/AvatarPlaceholder";
99
import AvatarImage from "~/components/AvatarImage";
1010
import Button from "~/components/Button";
11+
import MaterialSymbolsCall from "~icons/material-symbols/call";
1112
import MaterialSymbolsVideocam from "~icons/material-symbols/videocam";
1213
import MaterialSymbolsVideocamOff from "~icons/material-symbols/videocam-off";
1314
import MaterialSymbolsMic from "~icons/material-symbols/mic";
@@ -119,15 +120,27 @@ export default function App() {
119120
const outStreamHasVideoTrack =
120121
outStream == undefined || outStream.getVideoTracks().length >= 1;
121122

123+
const acceptCall: null | (() => void) =
124+
state === "promptingUserToAcceptCall" ? () => manager.acceptCall() : null;
125+
122126
const endCall = useCallback(() => {
123127
manager.endCall();
124128
}, [manager]);
125129

126-
let status = "";
127-
if (state === "connecting") {
128-
status = "Connecting...";
129-
} else if (state === "ringing") {
130-
status = "Ringing...";
130+
let status: string;
131+
switch (state) {
132+
case "promptingUserToAcceptCall":
133+
status = "Incoming call";
134+
break;
135+
case "connecting":
136+
status = "Connecting...";
137+
break;
138+
case "ringing":
139+
status = "Ringing...";
140+
break;
141+
case "in-call":
142+
status = "";
143+
break;
131144
}
132145

133146
const inCall = state === "in-call";
@@ -145,7 +158,7 @@ export default function App() {
145158
color: "white",
146159
borderRadius: "50%",
147160
fontSize: "1.5em",
148-
margin: "0 1rem",
161+
margin: "0.25em 0.625em",
149162
};
150163

151164
return (
@@ -189,11 +202,24 @@ export default function App() {
189202
<div
190203
style={{
191204
position: "absolute",
192-
bottom: "1em",
205+
bottom: "0.75em",
193206
width: "100%",
194207
textAlign: "center",
195208
}}
196209
>
210+
{acceptCall != null && (
211+
<Button
212+
aria-label="Answer call"
213+
title="Answer call"
214+
onClick={acceptCall}
215+
style={{
216+
backgroundColor: "#00b000",
217+
...buttonsStyle,
218+
}}
219+
>
220+
<MaterialSymbolsCall />
221+
</Button>
222+
)}
197223
<Button
198224
aria-label={toggleAudioLabel}
199225
title={toggleAudioLabel}

src/lib/calls.ts

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,20 @@ const initialRtcConfiguration = {
3434
iceCandidatePoolSize: 1,
3535
} as RTCConfiguration;
3636

37-
export type CallState = "connecting" | "ringing" | "in-call";
37+
export type CallState =
38+
| "promptingUserToAcceptCall"
39+
| "connecting"
40+
| "ringing"
41+
| "in-call";
3842

3943
export class CallsManager {
4044
private peerConnection: RTCPeerConnection;
4145
private setIceServersPromise: Promise<void>;
4246
private state: CallState;
4347
static initialState = "connecting" as const;
4448

49+
resolveCallAcceptedPromise?: (accepted: boolean) => void;
50+
4551
private iceTricklingDataChannel: RTCDataChannel;
4652
/**
4753
* Stores local ICE candidates to be sent to the remote peer
@@ -104,7 +110,10 @@ export class CallsManager {
104110
this.onStateChanged(this.state);
105111
};
106112

107-
const onIncomingCall = async (payload: string) => {
113+
const acceptCall = async (payload: string) => {
114+
this.state = "connecting";
115+
this.onStateChanged(this.state);
116+
108117
await this.setIceServersPromise;
109118
const gatheredEnoughIceP = gatheredEnoughIce(this.peerConnection);
110119

@@ -130,7 +139,7 @@ export class CallsManager {
130139
logSDP("Answering incoming call with answer:", answer);
131140
window.calls.acceptCall(answer);
132141
};
133-
const onAcceptedCall = (payload: string) => {
142+
const onAnswer = (payload: string) => {
134143
const answerObject = {
135144
type: "answer",
136145
sdp: payload,
@@ -146,17 +155,54 @@ export class CallsManager {
146155
console.log("empty URL hash: ", window.location.href);
147156
return;
148157
}
158+
159+
// A new command, most likely `#acceptCall`, has been issued.
160+
// Let's interrupt the previously started `#offerIncomingCall`,
161+
// if any.
162+
this.resolveCallAcceptedPromise?.(false);
163+
149164
if (hash === "startCall") {
150165
console.log("URL hash CMD: ", hash);
151166
await this.startCall();
167+
} else if (hash.startsWith("offerIncomingCall=")) {
168+
const offer = window.atob(hash.split("offerIncomingCall=", 2)[1]);
169+
logSDP("Incoming call (with user prompt) with offer:", offer);
170+
171+
this.state = "promptingUserToAcceptCall";
172+
this.onStateChanged(this.state);
173+
174+
// Wait for the user to accept the call, do _not_ do anything
175+
// with the other party's offer yet,
176+
// i.e. don't establish the connection, for privacy reasons.
177+
const accepted: boolean = await new Promise((r) => {
178+
const fn = (val: boolean) => {
179+
r(val);
180+
if (this.resolveCallAcceptedPromise === fn) {
181+
this.resolveCallAcceptedPromise = undefined;
182+
}
183+
};
184+
this.resolveCallAcceptedPromise = fn;
185+
});
186+
console.log(
187+
"Accept call prompt resolved: " +
188+
(accepted ? "accepted" : "not accepted"),
189+
);
190+
191+
if (!accepted) {
192+
// "Not accepted" doesn't mean "declined".
193+
// If "declined", `window.calls.endCall()` will be called
194+
// in a separate place.
195+
return;
196+
}
197+
await acceptCall(offer);
152198
} else if (hash.startsWith("acceptCall=")) {
153199
const offer = window.atob(hash.substring(11));
154200
logSDP("Incoming call with offer:", offer);
155-
await onIncomingCall(offer);
201+
await acceptCall(offer);
156202
} else if (hash.startsWith("onAnswer=")) {
157203
const answer = window.atob(hash.substring(9));
158204
logSDP("Outgoing call was accepted with answer:", answer);
159-
onAcceptedCall(answer);
205+
onAnswer(answer);
160206
} else {
161207
console.log("unexpected URL hash: ", hash);
162208
}
@@ -186,9 +232,19 @@ export class CallsManager {
186232
this.onStateChanged(this.state);
187233
}
188234

235+
acceptCall() {
236+
if (this.resolveCallAcceptedPromise == undefined) {
237+
console.warn("acceptCall invoked, but we were not waiting for it");
238+
return;
239+
}
240+
this.resolveCallAcceptedPromise(true);
241+
}
242+
189243
async endCall(): Promise<void> {
190244
this.peerConnection.close();
191245
window.calls.endCall();
246+
247+
this.resolveCallAcceptedPromise?.(false);
192248
}
193249

194250
getState() {

0 commit comments

Comments
 (0)