@telnyx/react-voice-commons-sdk 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +28 -7
- package/android/src/main/java/com/telnyx/react_voice_commons/CallForegroundService.kt +16 -2
- package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxNotificationHelper.kt +345 -24
- package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnBridgeModule.kt +19 -0
- package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnManager.kt +19 -0
- package/ios/CallKitBridge.m +27 -1
- package/ios/CallKitBridge.swift +640 -125
- package/ios/README.md +2 -2
- package/ios/VoicePnBridge.m +6 -0
- package/ios/VoicePnBridge.swift +15 -0
- package/lib/callkit/callkit-coordinator.d.ts +39 -3
- package/lib/callkit/callkit-coordinator.js +561 -148
- package/lib/callkit/callkit.d.ts +7 -0
- package/lib/callkit/callkit.js +83 -11
- package/lib/hooks/useAppStateHandler.js +6 -2
- package/lib/internal/CallKitHandler.d.ts +3 -0
- package/lib/internal/CallKitHandler.js +52 -46
- package/lib/internal/calls/call-state-controller.d.ts +33 -1
- package/lib/internal/calls/call-state-controller.js +100 -28
- package/lib/internal/session/session-manager.d.ts +15 -1
- package/lib/internal/session/session-manager.js +214 -62
- package/lib/internal/voice-pn-bridge.d.ts +18 -0
- package/lib/internal/voice-pn-bridge.js +39 -0
- package/lib/models/call.js +35 -0
- package/lib/models/config.d.ts +10 -0
- package/lib/models/config.js +2 -0
- package/lib/telnyx-voice-app.js +85 -43
- package/lib/telnyx-voip-client.d.ts +46 -5
- package/lib/telnyx-voip-client.js +238 -22
- package/package.json +7 -5
- package/src/callkit/callkit-coordinator.ts +642 -160
- package/src/callkit/callkit.ts +110 -12
- package/src/hooks/useAppStateHandler.ts +7 -2
- package/src/internal/CallKitHandler.tsx +60 -55
- package/src/internal/calls/call-state-controller.ts +112 -29
- package/src/internal/session/session-manager.ts +260 -65
- package/src/internal/voice-pn-bridge.ts +47 -0
- package/src/models/call.ts +34 -0
- package/src/models/config.ts +10 -0
- package/src/telnyx-voice-app.tsx +96 -45
- package/src/telnyx-voip-client.ts +216 -24
- package/src/types/telnyx-sdk.d.ts +2 -1
package/src/callkit/callkit.ts
CHANGED
|
@@ -11,8 +11,21 @@ interface CallKitBridgeInterface {
|
|
|
11
11
|
handle: string,
|
|
12
12
|
displayName: string
|
|
13
13
|
): Promise<{ success: boolean; callUUID: string }>;
|
|
14
|
+
isCallRegistered(callUUID: string): Promise<{ registered: boolean; callUUID: string }>;
|
|
14
15
|
answerCall(callUUID: string): Promise<{ success: boolean; callUUID: string }>;
|
|
15
16
|
endCall(callUUID: string): Promise<{ success: boolean; callUUID: string }>;
|
|
17
|
+
completeHeldCallAction(
|
|
18
|
+
callUUID: string,
|
|
19
|
+
success: boolean
|
|
20
|
+
): Promise<{ success: boolean; callUUID: string }>;
|
|
21
|
+
setCallHeld(
|
|
22
|
+
callUUID: string,
|
|
23
|
+
isOnHold: boolean
|
|
24
|
+
): Promise<{ success: boolean; callUUID: string; isOnHold: boolean }>;
|
|
25
|
+
swapCalls(
|
|
26
|
+
activeCallUUID: string,
|
|
27
|
+
heldCallUUID: string
|
|
28
|
+
): Promise<{ success: boolean; activeCallUUID: string; heldCallUUID: string }>;
|
|
16
29
|
reportCallConnected(callUUID: string): Promise<{ success: boolean }>;
|
|
17
30
|
reportCallEnded(callUUID: string, reason: number): Promise<{ success: boolean }>;
|
|
18
31
|
updateCall(callUUID: string, displayName: string, handle: string): Promise<{ success: boolean }>;
|
|
@@ -31,13 +44,14 @@ export enum CallEndReason {
|
|
|
31
44
|
// CallKit event types
|
|
32
45
|
export interface CallKitEvent {
|
|
33
46
|
callUUID: string;
|
|
47
|
+
isOnHold?: boolean;
|
|
34
48
|
[key: string]: any;
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
class CallKitManager {
|
|
38
52
|
private bridge: CallKitBridgeInterface | null = null;
|
|
39
53
|
private eventEmitter: NativeEventEmitter | null = null;
|
|
40
|
-
private listeners: Map<string, (event: CallKitEvent) => void
|
|
54
|
+
private listeners: Map<string, Set<(event: CallKitEvent) => void>> = new Map();
|
|
41
55
|
|
|
42
56
|
/**
|
|
43
57
|
* Normalize UUID to lowercase for consistent handling in React Native
|
|
@@ -100,6 +114,12 @@ class CallKitManager {
|
|
|
100
114
|
this.notifyListeners('endCall', normalizedEvent);
|
|
101
115
|
});
|
|
102
116
|
|
|
117
|
+
this.eventEmitter.addListener('CallKitDidPerformHeldCallAction', (event) => {
|
|
118
|
+
const normalizedEvent = this.normalizeEvent(event);
|
|
119
|
+
console.log('CallKit: Received held call action', normalizedEvent);
|
|
120
|
+
this.notifyListeners('heldCall', normalizedEvent);
|
|
121
|
+
});
|
|
122
|
+
|
|
103
123
|
this.eventEmitter.addListener('CallKitDidReceivePush', (event) => {
|
|
104
124
|
const normalizedEvent = this.normalizeEvent(event);
|
|
105
125
|
console.log('CallKit: Received push notification event', normalizedEvent);
|
|
@@ -108,9 +128,11 @@ class CallKitManager {
|
|
|
108
128
|
}
|
|
109
129
|
|
|
110
130
|
private notifyListeners(eventType: string, event: CallKitEvent) {
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
113
|
-
listener
|
|
131
|
+
const listeners = this.listeners.get(eventType);
|
|
132
|
+
if (listeners) {
|
|
133
|
+
for (const listener of listeners) {
|
|
134
|
+
listener(event);
|
|
135
|
+
}
|
|
114
136
|
}
|
|
115
137
|
}
|
|
116
138
|
|
|
@@ -189,6 +211,23 @@ class CallKitManager {
|
|
|
189
211
|
}
|
|
190
212
|
}
|
|
191
213
|
|
|
214
|
+
public async isCallRegistered(callUUID: string): Promise<boolean> {
|
|
215
|
+
if (!this.bridge || Platform.OS !== 'ios') {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const result = await this.bridge.isCallRegistered(this.denormalizeUUID(callUUID));
|
|
221
|
+
return result.registered;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error('CallKit: Failed to check incoming call registration', {
|
|
224
|
+
callUUID,
|
|
225
|
+
error,
|
|
226
|
+
});
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
192
231
|
public async endCall(callUUID: string): Promise<boolean> {
|
|
193
232
|
if (!this.bridge || Platform.OS !== 'ios') {
|
|
194
233
|
console.warn('CallKit: Not available on this platform');
|
|
@@ -208,6 +247,52 @@ class CallKitManager {
|
|
|
208
247
|
}
|
|
209
248
|
}
|
|
210
249
|
|
|
250
|
+
public async completeHeldCallAction(callUUID: string, success: boolean): Promise<boolean> {
|
|
251
|
+
if (!this.bridge || Platform.OS !== 'ios') {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const uppercaseUUID = this.denormalizeUUID(callUUID);
|
|
257
|
+
const result = await this.bridge.completeHeldCallAction(uppercaseUUID, success);
|
|
258
|
+
return result.success;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.error('CallKit: Failed to complete held call action', error);
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
public async setCallHeld(callUUID: string, isOnHold: boolean): Promise<boolean> {
|
|
266
|
+
if (!this.bridge || Platform.OS !== 'ios') {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const result = await this.bridge.setCallHeld(this.denormalizeUUID(callUUID), isOnHold);
|
|
272
|
+
return result.success;
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.error('CallKit: Failed to change held state', { callUUID, isOnHold, error });
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
public async swapCalls(activeCallUUID: string, heldCallUUID: string): Promise<boolean> {
|
|
280
|
+
if (!this.bridge || Platform.OS !== 'ios') {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const result = await this.bridge.swapCalls(
|
|
286
|
+
this.denormalizeUUID(activeCallUUID),
|
|
287
|
+
this.denormalizeUUID(heldCallUUID)
|
|
288
|
+
);
|
|
289
|
+
return result.success;
|
|
290
|
+
} catch (error) {
|
|
291
|
+
console.error('CallKit: Failed to swap calls', error);
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
211
296
|
public async reportCallConnected(callUUID: string): Promise<boolean> {
|
|
212
297
|
if (!this.bridge || Platform.OS !== 'ios') {
|
|
213
298
|
return false;
|
|
@@ -283,23 +368,36 @@ class CallKitManager {
|
|
|
283
368
|
// Event listener management
|
|
284
369
|
|
|
285
370
|
public onStartCall(listener: (event: CallKitEvent) => void): () => void {
|
|
286
|
-
this.
|
|
287
|
-
return () => this.listeners.delete('startCall');
|
|
371
|
+
return this.addListener('startCall', listener);
|
|
288
372
|
}
|
|
289
373
|
|
|
290
374
|
public onAnswerCall(listener: (event: CallKitEvent) => void): () => void {
|
|
291
|
-
this.
|
|
292
|
-
return () => this.listeners.delete('answerCall');
|
|
375
|
+
return this.addListener('answerCall', listener);
|
|
293
376
|
}
|
|
294
377
|
|
|
295
378
|
public onEndCall(listener: (event: CallKitEvent) => void): () => void {
|
|
296
|
-
this.
|
|
297
|
-
|
|
379
|
+
return this.addListener('endCall', listener);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
public onHeldCall(listener: (event: CallKitEvent) => void): () => void {
|
|
383
|
+
return this.addListener('heldCall', listener);
|
|
298
384
|
}
|
|
299
385
|
|
|
300
386
|
public onReceivePush(listener: (event: CallKitEvent) => void): () => void {
|
|
301
|
-
this.
|
|
302
|
-
|
|
387
|
+
return this.addListener('receivePush', listener);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private addListener(eventType: string, listener: (event: CallKitEvent) => void): () => void {
|
|
391
|
+
const listeners = this.listeners.get(eventType) ?? new Set();
|
|
392
|
+
listeners.add(listener);
|
|
393
|
+
this.listeners.set(eventType, listeners);
|
|
394
|
+
|
|
395
|
+
return () => {
|
|
396
|
+
listeners.delete(listener);
|
|
397
|
+
if (listeners.size === 0) {
|
|
398
|
+
this.listeners.delete(eventType);
|
|
399
|
+
}
|
|
400
|
+
};
|
|
303
401
|
}
|
|
304
402
|
|
|
305
403
|
// Utility methods
|
|
@@ -34,8 +34,13 @@ export const useAppStateHandler = ({
|
|
|
34
34
|
log('AppStateHandler: App went to background, checking for active calls...');
|
|
35
35
|
|
|
36
36
|
const connectionState = voipClient.currentConnectionState;
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const shouldHandleBackgroundConnection =
|
|
38
|
+
connectionState === TelnyxConnectionState.CONNECTED ||
|
|
39
|
+
connectionState === TelnyxConnectionState.CONNECTING ||
|
|
40
|
+
connectionState === TelnyxConnectionState.RECONNECTING;
|
|
41
|
+
|
|
42
|
+
if (!shouldHandleBackgroundConnection) {
|
|
43
|
+
log('AppStateHandler: Not connected or connecting, skipping background handling');
|
|
39
44
|
appState.current = nextAppState;
|
|
40
45
|
return;
|
|
41
46
|
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import React, { useEffect, useRef } from 'react';
|
|
2
2
|
import { Platform, DeviceEventEmitter } from 'react-native';
|
|
3
3
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
4
|
-
import { useTelnyxVoice } from '../context/TelnyxVoiceContext';
|
|
5
|
-
import { callKitCoordinator } from '../callkit';
|
|
6
4
|
|
|
7
|
-
// Global flag to ensure only one CallKitHandler is active
|
|
5
|
+
// Global flag to ensure only one CallKitHandler is active.
|
|
8
6
|
let isCallKitHandlerActive = false;
|
|
9
7
|
|
|
10
8
|
interface CallData {
|
|
@@ -28,6 +26,9 @@ interface CallKitHandlerProps {
|
|
|
28
26
|
* Internal CallKit handler for iOS push notifications
|
|
29
27
|
* This component is automatically included in TelnyxVoiceApp
|
|
30
28
|
*
|
|
29
|
+
* The active instance removes only its own DeviceEventEmitter subscriptions on
|
|
30
|
+
* unmount via each listener's `.remove()` method.
|
|
31
|
+
*
|
|
31
32
|
* @internal - Users should not use this component directly
|
|
32
33
|
*/
|
|
33
34
|
export const CallKitHandler: React.FC<CallKitHandlerProps> = ({
|
|
@@ -35,11 +36,65 @@ export const CallKitHandler: React.FC<CallKitHandlerProps> = ({
|
|
|
35
36
|
onNavigateToDialer,
|
|
36
37
|
onNavigateBack,
|
|
37
38
|
}) => {
|
|
38
|
-
const { voipClient } = useTelnyxVoice();
|
|
39
|
-
|
|
40
39
|
// Store active calls by CallKit UUID for coordination
|
|
41
40
|
const activeCallsRef = useRef<Map<string, any>>(new Map());
|
|
42
41
|
|
|
42
|
+
// DeviceEventEmitter listeners are registered once below, so read callbacks
|
|
43
|
+
// from refs that are updated during every render rather than stale closures.
|
|
44
|
+
const onLoginRequiredRef = useRef(onLoginRequired);
|
|
45
|
+
const onNavigateToDialerRef = useRef(onNavigateToDialer);
|
|
46
|
+
const onNavigateBackRef = useRef(onNavigateBack);
|
|
47
|
+
|
|
48
|
+
onLoginRequiredRef.current = onLoginRequired;
|
|
49
|
+
onNavigateToDialerRef.current = onNavigateToDialer;
|
|
50
|
+
onNavigateBackRef.current = onNavigateBack;
|
|
51
|
+
|
|
52
|
+
const handleIncomingCall = async (eventData: CallData) => {
|
|
53
|
+
console.log('CallKitHandler: Handling incoming call', {
|
|
54
|
+
callUUID: eventData.callUUID,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Store the push notification payload
|
|
58
|
+
await AsyncStorage.setItem('@push_notification_payload', JSON.stringify(eventData.payload));
|
|
59
|
+
|
|
60
|
+
// Mark this call as being processed
|
|
61
|
+
activeCallsRef.current.set(eventData.callUUID, {
|
|
62
|
+
processing: true,
|
|
63
|
+
timestamp: Date.now(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Trigger login required callback if provided
|
|
67
|
+
if (onLoginRequiredRef.current) {
|
|
68
|
+
onLoginRequiredRef.current(eventData.payload);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleAnswerCall = async (eventData: CallData) => {
|
|
73
|
+
console.log('CallKitHandler: User answered call via CallKit', {
|
|
74
|
+
callUUID: eventData.callUUID,
|
|
75
|
+
isTrackedCall: activeCallsRef.current.has(eventData.callUUID),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (onNavigateToDialerRef.current) {
|
|
79
|
+
onNavigateToDialerRef.current();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleEndCall = async (eventData: CallData) => {
|
|
84
|
+
console.log('CallKitHandler: User ended call via CallKit', {
|
|
85
|
+
callUUID: eventData.callUUID,
|
|
86
|
+
isTrackedCall: activeCallsRef.current.has(eventData.callUUID),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Clean up our local tracking info
|
|
90
|
+
activeCallsRef.current.delete(eventData.callUUID);
|
|
91
|
+
await AsyncStorage.removeItem('@push_notification_payload');
|
|
92
|
+
|
|
93
|
+
if (onNavigateBackRef.current) {
|
|
94
|
+
onNavigateBackRef.current();
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
43
98
|
useEffect(() => {
|
|
44
99
|
if (Platform.OS !== 'ios') return;
|
|
45
100
|
|
|
@@ -51,9 +106,6 @@ export const CallKitHandler: React.FC<CallKitHandlerProps> = ({
|
|
|
51
106
|
isCallKitHandlerActive = true;
|
|
52
107
|
console.log('CallKitHandler: Setting up DeviceEventEmitter listeners (singleton instance)...');
|
|
53
108
|
|
|
54
|
-
DeviceEventEmitter.removeAllListeners('incomingVoIPCall');
|
|
55
|
-
DeviceEventEmitter.removeAllListeners('callKitAction');
|
|
56
|
-
|
|
57
109
|
const incomingCallListener = DeviceEventEmitter.addListener(
|
|
58
110
|
'incomingVoIPCall',
|
|
59
111
|
async (eventData: CallData) => {
|
|
@@ -89,53 +141,6 @@ export const CallKitHandler: React.FC<CallKitHandlerProps> = ({
|
|
|
89
141
|
};
|
|
90
142
|
}, []);
|
|
91
143
|
|
|
92
|
-
const handleIncomingCall = async (eventData: CallData) => {
|
|
93
|
-
console.log('CallKitHandler: Handling incoming call', {
|
|
94
|
-
callUUID: eventData.callUUID,
|
|
95
|
-
hasClient: !!voipClient,
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
// Store the push notification payload
|
|
99
|
-
await AsyncStorage.setItem('@push_notification_payload', JSON.stringify(eventData.payload));
|
|
100
|
-
|
|
101
|
-
// Mark this call as being processed
|
|
102
|
-
activeCallsRef.current.set(eventData.callUUID, {
|
|
103
|
-
processing: true,
|
|
104
|
-
timestamp: Date.now(),
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
// Trigger login required callback if provided
|
|
108
|
-
if (onLoginRequired) {
|
|
109
|
-
onLoginRequired(eventData.payload);
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const handleAnswerCall = async (eventData: CallData) => {
|
|
114
|
-
console.log('CallKitHandler: User answered call via CallKit', {
|
|
115
|
-
callUUID: eventData.callUUID,
|
|
116
|
-
isTrackedCall: activeCallsRef.current.has(eventData.callUUID),
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
if (onNavigateToDialer) {
|
|
120
|
-
onNavigateToDialer();
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
const handleEndCall = async (eventData: CallData) => {
|
|
125
|
-
console.log('CallKitHandler: User ended call via CallKit', {
|
|
126
|
-
callUUID: eventData.callUUID,
|
|
127
|
-
isTrackedCall: activeCallsRef.current.has(eventData.callUUID),
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
// Clean up our local tracking info
|
|
131
|
-
activeCallsRef.current.delete(eventData.callUUID);
|
|
132
|
-
await AsyncStorage.removeItem('@push_notification_payload');
|
|
133
|
-
|
|
134
|
-
if (onNavigateBack) {
|
|
135
|
-
onNavigateBack();
|
|
136
|
-
}
|
|
137
|
-
};
|
|
138
|
-
|
|
139
144
|
// This component doesn't render anything, it just handles events
|
|
140
145
|
return null;
|
|
141
146
|
};
|
|
@@ -5,7 +5,9 @@ import { Call } from '../../models/call';
|
|
|
5
5
|
import { TelnyxCallState } from '../../models/call-state';
|
|
6
6
|
import { SessionManager } from '../session/session-manager';
|
|
7
7
|
import { callKitCoordinator } from '../../callkit/callkit-coordinator';
|
|
8
|
-
|
|
8
|
+
|
|
9
|
+
export type CustomHeader = { name: string; value: string };
|
|
10
|
+
export type CustomHeaders = Record<string, string> | CustomHeader[];
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Central state machine for call management.
|
|
@@ -23,6 +25,12 @@ export class CallStateController {
|
|
|
23
25
|
private _isWaitingForInvite?: () => boolean;
|
|
24
26
|
private _onInviteAutoAccepted?: () => void;
|
|
25
27
|
|
|
28
|
+
// Explicitly-tracked active call ID for multi-call scenarios.
|
|
29
|
+
// When set, activeCall$/currentActiveCall prefer this call over the
|
|
30
|
+
// first-match heuristic. Cleared automatically when the call reaches
|
|
31
|
+
// a terminal state, or manually via clearActiveCall().
|
|
32
|
+
private _activeCallId: string | null = null;
|
|
33
|
+
|
|
26
34
|
constructor(private readonly _sessionManager: SessionManager) {
|
|
27
35
|
console.log('CallStateController: Constructor called - instance created');
|
|
28
36
|
// Don't set up client listeners here - client doesn't exist yet
|
|
@@ -42,18 +50,18 @@ export class CallStateController {
|
|
|
42
50
|
get activeCall$(): Observable<Call | null> {
|
|
43
51
|
return this.calls$.pipe(
|
|
44
52
|
map((calls) => {
|
|
45
|
-
|
|
46
|
-
return
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
call.currentState === TelnyxCallState.ACTIVE ||
|
|
52
|
-
call.currentState === TelnyxCallState.HELD
|
|
53
|
-
) || null
|
|
54
|
-
);
|
|
53
|
+
const call = this._selectActiveCall(calls);
|
|
54
|
+
return {
|
|
55
|
+
call,
|
|
56
|
+
callId: call?.callId ?? null,
|
|
57
|
+
state: call?.currentState ?? null,
|
|
58
|
+
};
|
|
55
59
|
}),
|
|
56
|
-
distinctUntilChanged(
|
|
60
|
+
distinctUntilChanged(
|
|
61
|
+
(previous, current) =>
|
|
62
|
+
previous.callId === current.callId && previous.state === current.state
|
|
63
|
+
),
|
|
64
|
+
map(({ call }) => call)
|
|
57
65
|
);
|
|
58
66
|
}
|
|
59
67
|
|
|
@@ -68,16 +76,7 @@ export class CallStateController {
|
|
|
68
76
|
* Current active call (synchronous access)
|
|
69
77
|
*/
|
|
70
78
|
get currentActiveCall(): Call | null {
|
|
71
|
-
|
|
72
|
-
return (
|
|
73
|
-
calls.find(
|
|
74
|
-
(call) =>
|
|
75
|
-
call.currentState === TelnyxCallState.RINGING ||
|
|
76
|
-
call.currentState === TelnyxCallState.CONNECTING ||
|
|
77
|
-
call.currentState === TelnyxCallState.ACTIVE ||
|
|
78
|
-
call.currentState === TelnyxCallState.HELD
|
|
79
|
-
) || null
|
|
80
|
-
);
|
|
79
|
+
return this._selectActiveCall(this.currentCalls);
|
|
81
80
|
}
|
|
82
81
|
|
|
83
82
|
/**
|
|
@@ -106,6 +105,34 @@ export class CallStateController {
|
|
|
106
105
|
}
|
|
107
106
|
}
|
|
108
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Explicitly set the active call for multi-call scenarios.
|
|
110
|
+
* When set, activeCall$ and currentActiveCall prefer this call over
|
|
111
|
+
* the first-match heuristic. The ID is cleared automatically when the
|
|
112
|
+
* call reaches a terminal state.
|
|
113
|
+
* @param callId The ID of the call to mark as active
|
|
114
|
+
*/
|
|
115
|
+
setActiveCall(callId: string): void {
|
|
116
|
+
const call = this._callMap.get(callId);
|
|
117
|
+
if (call) {
|
|
118
|
+
this._activeCallId = callId;
|
|
119
|
+
this._calls.next([...this.currentCalls]);
|
|
120
|
+
} else {
|
|
121
|
+
console.warn('CallStateController: Cannot set active call - call not found:', callId);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Clear the explicitly-tracked active call ID, reverting to the
|
|
127
|
+
* first-match heuristic for active call selection.
|
|
128
|
+
*/
|
|
129
|
+
clearActiveCall(): void {
|
|
130
|
+
if (this._activeCallId !== null) {
|
|
131
|
+
this._activeCallId = null;
|
|
132
|
+
this._calls.next([...this.currentCalls]);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
109
136
|
/**
|
|
110
137
|
* Find a call by its underlying Telnyx call ID
|
|
111
138
|
* @param telnyxCall The Telnyx call object to find
|
|
@@ -119,6 +146,33 @@ export class CallStateController {
|
|
|
119
146
|
return null;
|
|
120
147
|
}
|
|
121
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Select the active call, preferring the explicitly-tracked call ID
|
|
151
|
+
* over the first-match heuristic.
|
|
152
|
+
*/
|
|
153
|
+
private _selectActiveCall(calls: Call[]): Call | null {
|
|
154
|
+
if (this._activeCallId) {
|
|
155
|
+
const tracked = calls.find((c) => c.callId === this._activeCallId);
|
|
156
|
+
if (tracked && this._isNonTerminal(tracked)) {
|
|
157
|
+
return tracked;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Fall back to first non-terminal call (backward compatible)
|
|
161
|
+
return calls.find((c) => this._isNonTerminal(c)) || null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Check if a call is in a non-terminal (active or connecting) state.
|
|
166
|
+
*/
|
|
167
|
+
private _isNonTerminal(call: Call): boolean {
|
|
168
|
+
return (
|
|
169
|
+
call.currentState === TelnyxCallState.RINGING ||
|
|
170
|
+
call.currentState === TelnyxCallState.CONNECTING ||
|
|
171
|
+
call.currentState === TelnyxCallState.ACTIVE ||
|
|
172
|
+
call.currentState === TelnyxCallState.HELD
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
122
176
|
/**
|
|
123
177
|
* Initialize client listeners when the Telnyx client becomes available
|
|
124
178
|
* This should be called by the session manager after client creation
|
|
@@ -139,7 +193,7 @@ export class CallStateController {
|
|
|
139
193
|
destination: string,
|
|
140
194
|
callerName?: string,
|
|
141
195
|
callerNumber?: string,
|
|
142
|
-
customHeaders?:
|
|
196
|
+
customHeaders?: CustomHeaders
|
|
143
197
|
): Promise<Call> {
|
|
144
198
|
if (this._disposed) {
|
|
145
199
|
throw new Error('CallStateController has been disposed');
|
|
@@ -155,7 +209,7 @@ export class CallStateController {
|
|
|
155
209
|
destinationNumber: destination,
|
|
156
210
|
callerIdName: callerName,
|
|
157
211
|
callerIdNumber: callerNumber,
|
|
158
|
-
customHeaders,
|
|
212
|
+
customHeaders: this._normalizeCustomHeaders(customHeaders),
|
|
159
213
|
peerConnectionOptions: {
|
|
160
214
|
useTrickleIce: this._sessionManager.useTrickleIce,
|
|
161
215
|
},
|
|
@@ -183,6 +237,21 @@ export class CallStateController {
|
|
|
183
237
|
}
|
|
184
238
|
}
|
|
185
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Normalize public custom headers into the format expected by the underlying SDK.
|
|
242
|
+
*/
|
|
243
|
+
private _normalizeCustomHeaders(customHeaders?: CustomHeaders): CustomHeader[] | undefined {
|
|
244
|
+
if (!customHeaders) {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (Array.isArray(customHeaders)) {
|
|
249
|
+
return customHeaders;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return Object.entries(customHeaders).map(([name, value]) => ({ name, value }));
|
|
253
|
+
}
|
|
254
|
+
|
|
186
255
|
/**
|
|
187
256
|
* Set callbacks for waiting for invite logic (used for push notifications)
|
|
188
257
|
*/
|
|
@@ -217,6 +286,7 @@ export class CallStateController {
|
|
|
217
286
|
}
|
|
218
287
|
}
|
|
219
288
|
this._callMap.clear();
|
|
289
|
+
this._activeCallId = null;
|
|
220
290
|
this._calls.next([]);
|
|
221
291
|
}
|
|
222
292
|
|
|
@@ -234,6 +304,7 @@ export class CallStateController {
|
|
|
234
304
|
// Dispose of all calls
|
|
235
305
|
this.currentCalls.forEach((call) => call.dispose());
|
|
236
306
|
this._callMap.clear();
|
|
307
|
+
this._activeCallId = null;
|
|
237
308
|
|
|
238
309
|
// CallKit cleanup is now handled by CallKitCoordinator automatically
|
|
239
310
|
|
|
@@ -415,6 +486,11 @@ export class CallStateController {
|
|
|
415
486
|
private _addCall(call: Call): void {
|
|
416
487
|
this._callMap.set(call.callId, call);
|
|
417
488
|
|
|
489
|
+
// Auto-track as active if no active call is currently set
|
|
490
|
+
if (this._activeCallId === null) {
|
|
491
|
+
this._activeCallId = call.callId;
|
|
492
|
+
}
|
|
493
|
+
|
|
418
494
|
const currentCalls = this.currentCalls;
|
|
419
495
|
currentCalls.push(call);
|
|
420
496
|
this._calls.next([...currentCalls]);
|
|
@@ -429,9 +505,10 @@ export class CallStateController {
|
|
|
429
505
|
|
|
430
506
|
if (existingCallKitUUID) {
|
|
431
507
|
console.log(
|
|
432
|
-
'CallStateController:
|
|
508
|
+
'CallStateController: Linking push-created CallKit call to its signaling call:',
|
|
433
509
|
existingCallKitUUID
|
|
434
510
|
);
|
|
511
|
+
callKitCoordinator.linkExistingCallKitCall(telnyxCall, existingCallKitUUID);
|
|
435
512
|
} else if (call.isIncoming) {
|
|
436
513
|
// Handle incoming call with CallKit (only if not already integrated)
|
|
437
514
|
console.log('CallStateController: Reporting incoming call to CallKitCoordinator');
|
|
@@ -448,13 +525,19 @@ export class CallStateController {
|
|
|
448
525
|
// CallKitCoordinator automatically updates CallKit via setupWebRTCCallListeners
|
|
449
526
|
console.log('CallStateController: Call state changed to:', state);
|
|
450
527
|
|
|
528
|
+
// Re-emit the call list so calls$/activeCall$ subscribers see state changes.
|
|
529
|
+
// distinctUntilChanged uses reference equality; without a new array
|
|
530
|
+
// reference, non-terminal transitions leave activeCall$ stale.
|
|
531
|
+
this._calls.next([...this.currentCalls]);
|
|
532
|
+
|
|
451
533
|
// Clean up when call ends - delay to next tick so external subscribers
|
|
452
534
|
// receive the ENDED/FAILED state before the call is disposed
|
|
453
535
|
if (state === TelnyxCallState.ENDED || state === TelnyxCallState.FAILED) {
|
|
454
|
-
// Clear
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
536
|
+
// Clear active call ID if the ending call was the tracked active call
|
|
537
|
+
if (this._activeCallId === call.callId) {
|
|
538
|
+
this._activeCallId = null;
|
|
539
|
+
}
|
|
540
|
+
|
|
458
541
|
setTimeout(() => this._removeCall(call.callId), 0);
|
|
459
542
|
}
|
|
460
543
|
});
|