@nativetalkcommunications/react-native-call-sdk 0.1.0
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/LICENSE +21 -0
- package/NativetalkCallSdk.podspec +31 -0
- package/README.md +494 -0
- package/android/build.gradle +58 -0
- package/android/gradle.properties +2 -0
- package/android/src/main/AndroidManifest.xml +84 -0
- package/android/src/main/java/io/nativetalk/callsdk/BackgroundService.kt +149 -0
- package/android/src/main/java/io/nativetalk/callsdk/CallActionReceiver.kt +24 -0
- package/android/src/main/java/io/nativetalk/callsdk/CallService.kt +45 -0
- package/android/src/main/java/io/nativetalk/callsdk/Compatibility.kt +96 -0
- package/android/src/main/java/io/nativetalk/callsdk/CoreManager.kt +801 -0
- package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallScreeningService.kt +105 -0
- package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallSdkModule.kt +205 -0
- package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallSdkPackage.kt +18 -0
- package/android/src/main/java/io/nativetalk/callsdk/TelephonyMonitor.kt +229 -0
- package/android/src/main/java/io/nativetalk/callsdk/Utils.kt +42 -0
- package/android/src/main/res/drawable/ic_nativetalk_call.xml +9 -0
- package/android/src/main/res/values/strings.xml +9 -0
- package/app.plugin.js +1 -0
- package/ios/NativetalkCallSdk-Bridging-Header.h +4 -0
- package/ios/NativetalkCallSdk.swift +738 -0
- package/ios/NativetalkCallSdkBridge.m +35 -0
- package/lib/commonjs/CallProvider.js +602 -0
- package/lib/commonjs/helpers.js +173 -0
- package/lib/commonjs/index.js +96 -0
- package/lib/commonjs/native.js +146 -0
- package/lib/commonjs/types.js +8 -0
- package/lib/commonjs/ui/Avatar.js +29 -0
- package/lib/commonjs/ui/Dialer.js +189 -0
- package/lib/commonjs/ui/IncomingCallView.js +128 -0
- package/lib/commonjs/ui/OutgoingCallView.js +117 -0
- package/lib/commonjs/ui/index.js +22 -0
- package/lib/commonjs/ui/theme.js +21 -0
- package/lib/module/CallProvider.js +573 -0
- package/lib/module/helpers.js +161 -0
- package/lib/module/index.js +57 -0
- package/lib/module/native.js +123 -0
- package/lib/module/types.js +7 -0
- package/lib/module/ui/Avatar.js +22 -0
- package/lib/module/ui/Dialer.js +162 -0
- package/lib/module/ui/IncomingCallView.js +101 -0
- package/lib/module/ui/OutgoingCallView.js +110 -0
- package/lib/module/ui/index.js +13 -0
- package/lib/module/ui/theme.js +17 -0
- package/lib/typescript/CallProvider.d.ts +46 -0
- package/lib/typescript/helpers.d.ts +52 -0
- package/lib/typescript/index.d.ts +77 -0
- package/lib/typescript/native.d.ts +53 -0
- package/lib/typescript/types.d.ts +155 -0
- package/lib/typescript/ui/Avatar.d.ts +13 -0
- package/lib/typescript/ui/Dialer.d.ts +29 -0
- package/lib/typescript/ui/IncomingCallView.d.ts +39 -0
- package/lib/typescript/ui/OutgoingCallView.d.ts +28 -0
- package/lib/typescript/ui/index.d.ts +13 -0
- package/lib/typescript/ui/theme.d.ts +20 -0
- package/linphonesw-pod/Sources/LinphoneSdkInfos.swift +4 -0
- package/linphonesw-pod/Sources/LinphoneWrapper.swift +42949 -0
- package/linphonesw-pod/linphonesw.podspec +46 -0
- package/package.json +90 -0
- package/plugin/build/index.js +12 -0
- package/plugin/build/withAndroid.js +78 -0
- package/plugin/build/withIos.js +66 -0
- package/src/CallProvider.tsx +675 -0
- package/src/helpers.ts +179 -0
- package/src/index.ts +84 -0
- package/src/native.ts +185 -0
- package/src/types.ts +202 -0
- package/src/ui/Avatar.tsx +46 -0
- package/src/ui/Dialer.tsx +248 -0
- package/src/ui/IncomingCallView.tsx +161 -0
- package/src/ui/OutgoingCallView.tsx +203 -0
- package/src/ui/index.ts +13 -0
- package/src/ui/theme.ts +36 -0
- package/ui/package.json +6 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <CallProvider> — the single React component the host app mounts to enable
|
|
3
|
+
* calling. It wires the native event stream into React state and exposes
|
|
4
|
+
* everything through `useCall()`.
|
|
5
|
+
*
|
|
6
|
+
* ──────────────────────────────────────────────────────────────────────────
|
|
7
|
+
* Mental model
|
|
8
|
+
* ──────────────────────────────────────────────────────────────────────────
|
|
9
|
+
*
|
|
10
|
+
* Linphone Core ──events──► Native module ──RN bridge──► CallProvider
|
|
11
|
+
* ▲ │
|
|
12
|
+
* │ ▼
|
|
13
|
+
* └────── method calls (dial/answer/end) ──────────── useCall() hook
|
|
14
|
+
*
|
|
15
|
+
* - The Linphone `Core` is the single source of truth for "what state is the
|
|
16
|
+
* call in". CallProvider just mirrors that state into React.
|
|
17
|
+
* - Provider → native flows via plain method calls. Native → provider flows
|
|
18
|
+
* via NativeEventEmitter events (RegistrationChanged, CallIncoming, etc.).
|
|
19
|
+
*
|
|
20
|
+
* ──────────────────────────────────────────────────────────────────────────
|
|
21
|
+
* Design rules
|
|
22
|
+
* ──────────────────────────────────────────────────────────────────────────
|
|
23
|
+
* - No coupling to auth, navigation, or any specific HTTP client. SIP config
|
|
24
|
+
* is passed in as a prop; lifecycle events (incoming, ended) are exposed as
|
|
25
|
+
* callbacks so the host app decides how to navigate / display screens.
|
|
26
|
+
* - Safe to mount with `config={null}` while you fetch credentials — the SDK
|
|
27
|
+
* simply idles until a real config arrives, then auto-registers (unless
|
|
28
|
+
* `autoRegister={false}`).
|
|
29
|
+
* - All event callbacks are read through refs (see `*Ref` block) so the host
|
|
30
|
+
* app can pass fresh closures on every render WITHOUT re-subscribing the
|
|
31
|
+
* native events — that would drop in-flight calls during a re-render.
|
|
32
|
+
*/
|
|
33
|
+
import React, {
|
|
34
|
+
createContext,
|
|
35
|
+
useCallback,
|
|
36
|
+
useContext,
|
|
37
|
+
useEffect,
|
|
38
|
+
useMemo,
|
|
39
|
+
useRef,
|
|
40
|
+
useState,
|
|
41
|
+
} from 'react';
|
|
42
|
+
import { Platform } from 'react-native';
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
callStateName,
|
|
46
|
+
callStatusLabel,
|
|
47
|
+
destinationToSipUri,
|
|
48
|
+
formatDuration,
|
|
49
|
+
formatTenantDomain,
|
|
50
|
+
initialsFrom,
|
|
51
|
+
parseSipUser,
|
|
52
|
+
regStateName,
|
|
53
|
+
} from './helpers';
|
|
54
|
+
import * as Native from './native';
|
|
55
|
+
import type {
|
|
56
|
+
CallApi,
|
|
57
|
+
CallLogEntry,
|
|
58
|
+
CallProviderProps,
|
|
59
|
+
CallState,
|
|
60
|
+
DeclineReason,
|
|
61
|
+
IncomingCallInfo,
|
|
62
|
+
RegistrationEvent,
|
|
63
|
+
SipConfig,
|
|
64
|
+
} from './types';
|
|
65
|
+
|
|
66
|
+
const CallContext = createContext<CallApi | null>(null);
|
|
67
|
+
|
|
68
|
+
// ── State-group lookups for the call lifecycle FSM ────────────────────────
|
|
69
|
+
//
|
|
70
|
+
// Linphone exposes ~20 fine-grained call states. The UI only cares about a
|
|
71
|
+
// handful of behavioural buckets, so we precompute them here and look them
|
|
72
|
+
// up in O(1) inside the CallState event handler.
|
|
73
|
+
//
|
|
74
|
+
// ACTIVE → audio is flowing; start the duration timer
|
|
75
|
+
// TERMINAL → call is gone; freeze the timer & reset audio toggles
|
|
76
|
+
// HELD → on hold (initiated by us OR by the remote peer)
|
|
77
|
+
// RESUMED → coming out of hold OR media flowing again
|
|
78
|
+
//
|
|
79
|
+
// Note: `Connected` and `StreamsRunning` appear in BOTH active and resumed —
|
|
80
|
+
// they're listed twice on purpose. "Active" treats them as "start timer";
|
|
81
|
+
// "Resumed" treats them as "clear the held flag".
|
|
82
|
+
|
|
83
|
+
const ACTIVE_STATES: ReadonlyArray<CallState> = ['Connected', 'StreamsRunning'];
|
|
84
|
+
const TERMINAL_STATES: ReadonlyArray<CallState> = [
|
|
85
|
+
'End', // local hangup, normal termination
|
|
86
|
+
'Released', // SIP dialog fully torn down
|
|
87
|
+
'Error', // SIP error (busy, not-found, network drop, etc.)
|
|
88
|
+
];
|
|
89
|
+
const HELD_STATES: ReadonlyArray<CallState> = [
|
|
90
|
+
'Pausing', // local pause in progress
|
|
91
|
+
'Paused', // local pause complete
|
|
92
|
+
'PausedByRemote', // peer put us on hold
|
|
93
|
+
];
|
|
94
|
+
const RESUMED_STATES: ReadonlyArray<CallState> = [
|
|
95
|
+
'Resuming',
|
|
96
|
+
'Connected',
|
|
97
|
+
'StreamsRunning',
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
export function CallProvider(props: CallProviderProps): React.JSX.Element {
|
|
101
|
+
const {
|
|
102
|
+
children,
|
|
103
|
+
config = null,
|
|
104
|
+
autoRegister = true,
|
|
105
|
+
enableNativeServices = true,
|
|
106
|
+
requestMicPermission = true,
|
|
107
|
+
onIncomingCall,
|
|
108
|
+
onOutgoingCall,
|
|
109
|
+
onCallEnded,
|
|
110
|
+
onCallStateChanged,
|
|
111
|
+
onRegistrationStateChanged,
|
|
112
|
+
onError,
|
|
113
|
+
} = props;
|
|
114
|
+
|
|
115
|
+
// ── Stable refs for the event callbacks ──────────────────────────────────
|
|
116
|
+
//
|
|
117
|
+
// The host app typically passes inline arrow functions:
|
|
118
|
+
//
|
|
119
|
+
// <CallProvider onIncomingCall={(i) => navigation.navigate(…)} />
|
|
120
|
+
//
|
|
121
|
+
// Each render produces a NEW function identity. If we depended on these
|
|
122
|
+
// directly in the subscription useEffect, every render would tear down the
|
|
123
|
+
// native event subscriptions and re-create them — guaranteed to drop
|
|
124
|
+
// events that happen mid-render. So we mirror them into refs and let the
|
|
125
|
+
// event handlers read `.current` at fire time.
|
|
126
|
+
//
|
|
127
|
+
// The trailing `useEffect` (no deps array) runs after every commit, which
|
|
128
|
+
// keeps `current` aligned with the latest props.
|
|
129
|
+
const onIncomingCallRef = useRef(onIncomingCall);
|
|
130
|
+
const onOutgoingCallRef = useRef(onOutgoingCall);
|
|
131
|
+
const onCallEndedRef = useRef(onCallEnded);
|
|
132
|
+
const onCallStateChangedRef = useRef(onCallStateChanged);
|
|
133
|
+
const onRegistrationStateChangedRef = useRef(onRegistrationStateChanged);
|
|
134
|
+
const onErrorRef = useRef(onError);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
onIncomingCallRef.current = onIncomingCall;
|
|
138
|
+
onOutgoingCallRef.current = onOutgoingCall;
|
|
139
|
+
onCallEndedRef.current = onCallEnded;
|
|
140
|
+
onCallStateChangedRef.current = onCallStateChanged;
|
|
141
|
+
onRegistrationStateChangedRef.current = onRegistrationStateChanged;
|
|
142
|
+
onErrorRef.current = onError;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ----- State -----
|
|
146
|
+
const [registration, setRegistration] = useState<RegistrationEvent | null>(
|
|
147
|
+
null
|
|
148
|
+
);
|
|
149
|
+
const [callStatus, setCallStatus] = useState<CallState>('Idle');
|
|
150
|
+
const [incoming, setIncoming] = useState(false);
|
|
151
|
+
const [incomingInfo, setIncomingInfo] = useState<IncomingCallInfo | null>(
|
|
152
|
+
null
|
|
153
|
+
);
|
|
154
|
+
const [muted, setMuted] = useState(false);
|
|
155
|
+
const [speakerOn, setSpeakerOn] = useState(false);
|
|
156
|
+
const [held, setHeld] = useState(false);
|
|
157
|
+
const [durationSec, setDurationSec] = useState(0);
|
|
158
|
+
const [ending, setEnding] = useState(false);
|
|
159
|
+
|
|
160
|
+
// ── Duration timer ────────────────────────────────────────────────────────
|
|
161
|
+
//
|
|
162
|
+
// We anchor the timer to a wall-clock timestamp (`startTsRef`) rather than
|
|
163
|
+
// incrementing a counter. Why? `setInterval` drifts when the device is
|
|
164
|
+
// backgrounded, the JS thread stalls, or the user switches apps. By
|
|
165
|
+
// re-computing `now - start` on every tick we always show the true elapsed
|
|
166
|
+
// time, even if 30 intervals were skipped while the screen was off.
|
|
167
|
+
//
|
|
168
|
+
// The 500ms tick is half a second of "lag" worst-case but spares the JS
|
|
169
|
+
// thread the overhead of a 30Hz refresh — UI just renders to second
|
|
170
|
+
// precision anyway.
|
|
171
|
+
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
172
|
+
const startTsRef = useRef<number | null>(null);
|
|
173
|
+
|
|
174
|
+
const clearTimer = useCallback(() => {
|
|
175
|
+
if (tickRef.current) clearInterval(tickRef.current);
|
|
176
|
+
tickRef.current = null;
|
|
177
|
+
}, []);
|
|
178
|
+
|
|
179
|
+
// Called when a new call starts — wipes the previous duration so the UI
|
|
180
|
+
// doesn't briefly show the old call's timer.
|
|
181
|
+
const resetDuration = useCallback(() => {
|
|
182
|
+
clearTimer();
|
|
183
|
+
startTsRef.current = null;
|
|
184
|
+
setDurationSec(0);
|
|
185
|
+
}, [clearTimer]);
|
|
186
|
+
|
|
187
|
+
// Idempotent: calling startDuration() multiple times during the same call
|
|
188
|
+
// doesn't restart the clock. This matters because Linphone can fire
|
|
189
|
+
// `Connected` and `StreamsRunning` back-to-back, and we'd otherwise reset
|
|
190
|
+
// the start time on the second event.
|
|
191
|
+
const startDuration = useCallback(() => {
|
|
192
|
+
if (tickRef.current) return;
|
|
193
|
+
startTsRef.current = Date.now();
|
|
194
|
+
tickRef.current = setInterval(() => {
|
|
195
|
+
if (startTsRef.current != null) {
|
|
196
|
+
setDurationSec((Date.now() - startTsRef.current) / 1000);
|
|
197
|
+
}
|
|
198
|
+
}, 500);
|
|
199
|
+
}, []);
|
|
200
|
+
|
|
201
|
+
// ── Initial boot sequence: native init → mic permission → services ──────
|
|
202
|
+
//
|
|
203
|
+
// Order matters:
|
|
204
|
+
// 1. Native.init() — boots the Linphone Core. Required before anything
|
|
205
|
+
// else; safe to call multiple times (the native side is idempotent).
|
|
206
|
+
// 2. Mic permission — Linphone won't open the mic without it. We do this
|
|
207
|
+
// eagerly so the user is prompted once on first launch rather than
|
|
208
|
+
// mid-call. Set `requestMicPermission={false}` to skip and handle it
|
|
209
|
+
// yourself.
|
|
210
|
+
// 3. Background service (Android only) — keeps the SIP socket alive when
|
|
211
|
+
// the app is backgrounded. On iOS this is the host app's job via
|
|
212
|
+
// VoIP push, so the call is a no-op.
|
|
213
|
+
//
|
|
214
|
+
// `cancelled` guards against the provider unmounting mid-await. Without
|
|
215
|
+
// it, a fast remount-then-unmount could fire `onError` callbacks against
|
|
216
|
+
// a stale provider instance.
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
let cancelled = false;
|
|
219
|
+
(async () => {
|
|
220
|
+
try {
|
|
221
|
+
await Native.init();
|
|
222
|
+
if (cancelled) return;
|
|
223
|
+
if (requestMicPermission) {
|
|
224
|
+
const granted = await Native.ensureMicPermission();
|
|
225
|
+
if (!granted) {
|
|
226
|
+
onErrorRef.current?.({
|
|
227
|
+
code: 'MIC_PERMISSION_DENIED',
|
|
228
|
+
message:
|
|
229
|
+
'Microphone permission denied. Calls cannot be made or received.',
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (enableNativeServices && Platform.OS === 'android') {
|
|
234
|
+
Native.startNativeServices();
|
|
235
|
+
}
|
|
236
|
+
} catch (e: any) {
|
|
237
|
+
onErrorRef.current?.({
|
|
238
|
+
code: 'INIT_FAILED',
|
|
239
|
+
message: e?.message ?? String(e),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
})();
|
|
243
|
+
return () => {
|
|
244
|
+
cancelled = true;
|
|
245
|
+
};
|
|
246
|
+
}, [enableNativeServices, requestMicPermission]);
|
|
247
|
+
|
|
248
|
+
// ── Native event subscriptions ───────────────────────────────────────────
|
|
249
|
+
//
|
|
250
|
+
// This effect runs once and never re-runs (its deps array contains only
|
|
251
|
+
// stable useCallback refs). All five events from the native module are
|
|
252
|
+
// bridged into React state here:
|
|
253
|
+
//
|
|
254
|
+
// RegistrationChanged → setRegistration + onRegistrationStateChanged
|
|
255
|
+
// CallIncoming → setIncoming(true) + onIncomingCall
|
|
256
|
+
// CallState → setCallStatus + timer/hold/cleanup based on FSM
|
|
257
|
+
// CallEnded → reset transient state + onCallEnded
|
|
258
|
+
//
|
|
259
|
+
// The cleanup function removes all subscriptions on unmount. Critically,
|
|
260
|
+
// we DO NOT call `Native.stopNativeServices()` here — that would kill the
|
|
261
|
+
// foreground service even on a transient unmount. Apps that want to fully
|
|
262
|
+
// shut down call functionality (e.g. on logout) should call
|
|
263
|
+
// `stopNativeServices(true)` explicitly via the hook.
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
// Registration state changes — `progress`, `ok`, `failed`, etc.
|
|
266
|
+
// `regStateName()` normalises the raw value to a canonical lowercase
|
|
267
|
+
// string. We surface `failed` as a structured error so the host app can
|
|
268
|
+
// distinguish "user hasn't logged in yet" from "credentials are bad".
|
|
269
|
+
const subReg = Native.on.RegistrationChanged((e: any) => {
|
|
270
|
+
const pretty = regStateName(e?.state);
|
|
271
|
+
const event: RegistrationEvent = { ...e, state: pretty, pretty };
|
|
272
|
+
setRegistration(event);
|
|
273
|
+
onRegistrationStateChangedRef.current?.(event);
|
|
274
|
+
if (pretty === 'failed') {
|
|
275
|
+
onErrorRef.current?.({
|
|
276
|
+
code: 'REGISTRATION_FAILED',
|
|
277
|
+
message: e?.message || 'SIP registration failed',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Incoming call — pick the best display name we have. SIP can deliver
|
|
283
|
+
// any of: displayName ("Jane Doe"), username ("100"), or full URI
|
|
284
|
+
// ("sip:+234…@gateway"). Some servers send "anonymous" as the display
|
|
285
|
+
// name when caller-ID is suppressed; we treat that as missing and fall
|
|
286
|
+
// back to the username instead.
|
|
287
|
+
const subIncoming = Native.on.CallIncoming((e: any = {}) => {
|
|
288
|
+
const display = (e.displayName || '').trim();
|
|
289
|
+
const user = (e.username || '').trim();
|
|
290
|
+
const parsed = parseSipUser(e.uri || '');
|
|
291
|
+
const phone =
|
|
292
|
+
display && display.toLowerCase() !== 'anonymous'
|
|
293
|
+
? display
|
|
294
|
+
: user || parsed || 'Unknown';
|
|
295
|
+
const info: IncomingCallInfo = {
|
|
296
|
+
name: phone,
|
|
297
|
+
phone,
|
|
298
|
+
initials: initialsFrom(phone),
|
|
299
|
+
callId: e?.callId,
|
|
300
|
+
uri: e?.uri,
|
|
301
|
+
};
|
|
302
|
+
setIncoming(true);
|
|
303
|
+
setIncomingInfo(info);
|
|
304
|
+
setCallStatus('IncomingReceived');
|
|
305
|
+
onIncomingCallRef.current?.(info);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Generic call state transitions — drives the FSM defined at the top of
|
|
309
|
+
// the file. The four set-membership checks below are mutually exclusive
|
|
310
|
+
// for any single event (e.g. a state can't be both ACTIVE and TERMINAL),
|
|
311
|
+
// so the if-chain is intentional, not a fall-through bug.
|
|
312
|
+
const subState = Native.on.CallState((e: any) => {
|
|
313
|
+
const pretty = callStateName(e?.state);
|
|
314
|
+
setCallStatus(pretty);
|
|
315
|
+
onCallStateChangedRef.current?.(pretty);
|
|
316
|
+
|
|
317
|
+
if (HELD_STATES.includes(pretty)) setHeld(true);
|
|
318
|
+
if (RESUMED_STATES.includes(pretty)) setHeld(false);
|
|
319
|
+
|
|
320
|
+
if (ACTIVE_STATES.includes(pretty)) {
|
|
321
|
+
// Media is flowing. If this was an incoming call, clear the
|
|
322
|
+
// "ringing" flag so the UI moves from incoming-screen → in-call.
|
|
323
|
+
setIncoming(false);
|
|
324
|
+
startDuration();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (TERMINAL_STATES.includes(pretty)) {
|
|
328
|
+
// Freeze the duration (don't reset to 0) so the UI can show
|
|
329
|
+
// "Call ended · 2:34" for a moment before navigation pops the
|
|
330
|
+
// screen. Audio toggles reset because they don't persist across
|
|
331
|
+
// calls — mute is per-session.
|
|
332
|
+
clearTimer();
|
|
333
|
+
setIncoming(false);
|
|
334
|
+
setIncomingInfo(null);
|
|
335
|
+
setMuted(false);
|
|
336
|
+
setSpeakerOn(false);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// CallEnded fires AFTER the CallState transition to End/Released. It's
|
|
341
|
+
// a convenience event — listeners that only care about "call is gone"
|
|
342
|
+
// can subscribe to this without parsing every state transition.
|
|
343
|
+
const subEnd = Native.on.CallEnded(() => {
|
|
344
|
+
clearTimer();
|
|
345
|
+
setIncoming(false);
|
|
346
|
+
setIncomingInfo(null);
|
|
347
|
+
setMuted(false);
|
|
348
|
+
setSpeakerOn(false);
|
|
349
|
+
onCallEndedRef.current?.();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
return () => {
|
|
353
|
+
subReg.remove();
|
|
354
|
+
subIncoming.remove();
|
|
355
|
+
subState.remove();
|
|
356
|
+
subEnd.remove();
|
|
357
|
+
clearTimer();
|
|
358
|
+
};
|
|
359
|
+
}, [clearTimer, startDuration]);
|
|
360
|
+
|
|
361
|
+
// ── Registration ─────────────────────────────────────────────────────────
|
|
362
|
+
//
|
|
363
|
+
// Three entry points into the SIP registration flow:
|
|
364
|
+
//
|
|
365
|
+
// - Auto-register effect (below) — fires whenever `config` changes
|
|
366
|
+
// - register(cfg?) — host app forces a re-register
|
|
367
|
+
// - refreshRegistration() — light-touch refresh, skips full setup
|
|
368
|
+
// when the existing session looks healthy
|
|
369
|
+
|
|
370
|
+
// Internal helper — strips `https://` and trailing `/` from the domain
|
|
371
|
+
// (a common user-input mistake) before handing the params to native.
|
|
372
|
+
// Native is permissive about transport, but defaults to TCP if unset
|
|
373
|
+
// because most production PBXs allow TCP and it's NAT-friendlier than UDP.
|
|
374
|
+
const registerWith = useCallback(
|
|
375
|
+
async (cfg: SipConfig) => {
|
|
376
|
+
const domain = formatTenantDomain(cfg.domain);
|
|
377
|
+
|
|
378
|
+
// Strip port before domain check (e.g. "charles.nativetalk.io:5060")
|
|
379
|
+
const domainHost = domain.split(':')[0]!;
|
|
380
|
+
if (!domainHost.endsWith('.nativetalk.io') && domainHost !== 'nativetalk.io') {
|
|
381
|
+
onErrorRef.current?.({
|
|
382
|
+
code: 'INVALID_DOMAIN',
|
|
383
|
+
message: `Domain "${domainHost}" is not a valid Nativetalk domain. Only *.nativetalk.io domains are supported.`,
|
|
384
|
+
});
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const transport = cfg.transport ?? 'tcp';
|
|
389
|
+
Native.register({
|
|
390
|
+
username: cfg.username,
|
|
391
|
+
password: cfg.password,
|
|
392
|
+
domain,
|
|
393
|
+
transport,
|
|
394
|
+
});
|
|
395
|
+
},
|
|
396
|
+
[]
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
// Auto-register whenever `config` changes. Identity comparison only —
|
|
400
|
+
// passing the same SIP config object on every render is fine, but passing
|
|
401
|
+
// `{...sip}` will re-register every time, so don't do that.
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
if (!autoRegister) return;
|
|
404
|
+
if (!config || !config.username) return;
|
|
405
|
+
registerWith(config).catch((e) =>
|
|
406
|
+
onErrorRef.current?.({
|
|
407
|
+
code: 'REGISTER_FAILED',
|
|
408
|
+
message: e?.message ?? String(e),
|
|
409
|
+
})
|
|
410
|
+
);
|
|
411
|
+
}, [config, autoRegister, registerWith]);
|
|
412
|
+
|
|
413
|
+
// ── Public actions returned from useCall() ───────────────────────────────
|
|
414
|
+
|
|
415
|
+
// Force a re-register. Use this when:
|
|
416
|
+
// - You disabled `autoRegister` and want to control timing yourself.
|
|
417
|
+
// - You want to switch accounts at runtime — pass the new config as arg.
|
|
418
|
+
// - The user toggled a "Reconnect" button after a network blip.
|
|
419
|
+
const register = useCallback(
|
|
420
|
+
async (overrideCfg?: SipConfig) => {
|
|
421
|
+
const next = overrideCfg ?? config ?? undefined;
|
|
422
|
+
if (!next || !next.username) {
|
|
423
|
+
onErrorRef.current?.({
|
|
424
|
+
code: 'NO_CONFIG',
|
|
425
|
+
message: 'No SIP config available — pass `config` to <CallProvider> or call register({...}).',
|
|
426
|
+
});
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
await registerWith(next);
|
|
430
|
+
},
|
|
431
|
+
[config, registerWith]
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
// Smart refresh — if registration was already in a working state, we use
|
|
435
|
+
// Linphone's lightweight `refreshRegisters()` (it just re-pings the
|
|
436
|
+
// server). If it was failed/cleared, we do a full re-register from
|
|
437
|
+
// scratch since the lightweight refresh won't recover from a 401 or DNS
|
|
438
|
+
// blackhole.
|
|
439
|
+
const refreshRegistration = useCallback(async () => {
|
|
440
|
+
const state = registration?.state;
|
|
441
|
+
if (!state || state === 'none' || state === 'cleared' || state === 'failed') {
|
|
442
|
+
if (config) await registerWith(config);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
Native.refreshRegisters();
|
|
446
|
+
}, [registration, config, registerWith]);
|
|
447
|
+
|
|
448
|
+
const unregister = useCallback(async () => {
|
|
449
|
+
Native.setRegisterEnabled(false);
|
|
450
|
+
}, []);
|
|
451
|
+
|
|
452
|
+
// Place an outgoing call. Accepts either:
|
|
453
|
+
// - a plain extension/number ("100" or "+2348012345678") — combined with
|
|
454
|
+
// `config.domain` to form `sip:<dest>@<domain>`
|
|
455
|
+
// - a fully-qualified SIP URI ("sip:user@gateway.example.com") — passed
|
|
456
|
+
// through unchanged, useful when dialling external PSTN gateways
|
|
457
|
+
//
|
|
458
|
+
// We optimistically set status to OutgoingInit before the native call
|
|
459
|
+
// returns so the UI can render immediately — the real state will be
|
|
460
|
+
// confirmed by the next CallState event in ~50ms.
|
|
461
|
+
const dial = useCallback(
|
|
462
|
+
async (destination: string) => {
|
|
463
|
+
if (!destination || destination.length < 1) {
|
|
464
|
+
onErrorRef.current?.({
|
|
465
|
+
code: 'INVALID_NUMBER',
|
|
466
|
+
message: 'Cannot dial an empty number.',
|
|
467
|
+
});
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (!config?.domain) {
|
|
471
|
+
onErrorRef.current?.({
|
|
472
|
+
code: 'NO_CONFIG',
|
|
473
|
+
message: 'Cannot dial: no SIP domain configured.',
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
resetDuration();
|
|
478
|
+
const uri = destinationToSipUri(destination, config.domain);
|
|
479
|
+
setCallStatus('OutgoingInit');
|
|
480
|
+
Native.call(uri);
|
|
481
|
+
onOutgoingCallRef.current?.({
|
|
482
|
+
phone: destination,
|
|
483
|
+
initials: initialsFrom(destination),
|
|
484
|
+
});
|
|
485
|
+
},
|
|
486
|
+
[config?.domain, resetDuration]
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const answer = useCallback(async () => {
|
|
490
|
+
setIncoming(false);
|
|
491
|
+
setCallStatus('Connected');
|
|
492
|
+
resetDuration();
|
|
493
|
+
Native.answer();
|
|
494
|
+
}, [resetDuration]);
|
|
495
|
+
|
|
496
|
+
// Hangup is debounced via the `ending` flag — Linphone takes ~500ms to
|
|
497
|
+
// settle, and tapping the end-call button twice in that window can put it
|
|
498
|
+
// into a weird state. The 600ms timer is empirical: long enough to swallow
|
|
499
|
+
// double-taps, short enough that the next call attempt isn't blocked.
|
|
500
|
+
const hangup = useCallback(async () => {
|
|
501
|
+
if (ending) return;
|
|
502
|
+
setEnding(true);
|
|
503
|
+
try {
|
|
504
|
+
Native.end();
|
|
505
|
+
} finally {
|
|
506
|
+
setTimeout(() => setEnding(false), 600);
|
|
507
|
+
}
|
|
508
|
+
}, [ending]);
|
|
509
|
+
|
|
510
|
+
// Decline an incoming call with a specific SIP response code. Default is
|
|
511
|
+
// "busy" (486) which is how voicemail systems usually decide to take a
|
|
512
|
+
// message; use "declined" if you don't want the caller routed to VM.
|
|
513
|
+
const decline = useCallback(async (reason: DeclineReason = 'busy') => {
|
|
514
|
+
Native.decline(reason);
|
|
515
|
+
}, []);
|
|
516
|
+
|
|
517
|
+
// Toggle helpers use the functional setState form so concurrent taps don't
|
|
518
|
+
// race — without it, `next = !muted` could read stale state if React is
|
|
519
|
+
// batching renders.
|
|
520
|
+
const toggleMute = useCallback(() => {
|
|
521
|
+
setMuted((current) => {
|
|
522
|
+
const next = !current;
|
|
523
|
+
Native.mute(next);
|
|
524
|
+
return next;
|
|
525
|
+
});
|
|
526
|
+
}, []);
|
|
527
|
+
|
|
528
|
+
const toggleSpeaker = useCallback(() => {
|
|
529
|
+
setSpeakerOn((current) => {
|
|
530
|
+
const next = !current;
|
|
531
|
+
Native.speaker(next);
|
|
532
|
+
return next;
|
|
533
|
+
});
|
|
534
|
+
}, []);
|
|
535
|
+
|
|
536
|
+
// No optimistic update here — `held` is driven by HELD_STATES / RESUMED_STATES
|
|
537
|
+
// from the native CallState event, so we just kick off the action and let
|
|
538
|
+
// the event update React state.
|
|
539
|
+
const toggleHold = useCallback(() => {
|
|
540
|
+
if (held) Native.resume();
|
|
541
|
+
else Native.hold();
|
|
542
|
+
}, [held]);
|
|
543
|
+
|
|
544
|
+
// Send a DTMF (touch-tone) digit IN BAND on the active call — used for
|
|
545
|
+
// navigating IVRs ("Press 1 for support…"). Different from playKeyTone,
|
|
546
|
+
// which only plays a UI feedback tone locally.
|
|
547
|
+
const sendDtmf = useCallback((digit: string) => {
|
|
548
|
+
if (!digit) return;
|
|
549
|
+
Native.sendDtmf(String(digit));
|
|
550
|
+
}, []);
|
|
551
|
+
|
|
552
|
+
// Local DTMF feedback tone — plays the standard touch-tone sound through
|
|
553
|
+
// the device speaker when the user taps a key on the dial-pad. Does NOT
|
|
554
|
+
// send anything over the SIP call; for that, use sendDtmf().
|
|
555
|
+
const playKeyTone = useCallback((digit: string) => {
|
|
556
|
+
if (!digit) return;
|
|
557
|
+
Native.playKeyTone(String(digit));
|
|
558
|
+
}, []);
|
|
559
|
+
|
|
560
|
+
const getCallLogs = useCallback(async (): Promise<CallLogEntry[]> => {
|
|
561
|
+
try {
|
|
562
|
+
return await Native.getCallLogs();
|
|
563
|
+
} catch (e: any) {
|
|
564
|
+
onErrorRef.current?.({
|
|
565
|
+
code: 'GET_CALL_LOGS_FAILED',
|
|
566
|
+
message: e?.message ?? String(e),
|
|
567
|
+
});
|
|
568
|
+
return [];
|
|
569
|
+
}
|
|
570
|
+
}, []);
|
|
571
|
+
|
|
572
|
+
const getRegistrationStatus = useCallback(
|
|
573
|
+
async (): Promise<RegistrationEvent> => {
|
|
574
|
+
const raw = await Native.getRegistrationStatus();
|
|
575
|
+
const pretty = regStateName(raw?.state);
|
|
576
|
+
return { ...raw, state: pretty, pretty };
|
|
577
|
+
},
|
|
578
|
+
[]
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
const startServices = useCallback(() => Native.startNativeServices(), []);
|
|
582
|
+
const stopServices = useCallback(
|
|
583
|
+
(logout = false) => Native.stopNativeServices(logout),
|
|
584
|
+
[]
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
const formattedDuration = useMemo(
|
|
588
|
+
() => formatDuration(durationSec),
|
|
589
|
+
[durationSec]
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
const value: CallApi = useMemo(
|
|
593
|
+
() => ({
|
|
594
|
+
config,
|
|
595
|
+
registration,
|
|
596
|
+
callStatus,
|
|
597
|
+
durationSec,
|
|
598
|
+
formattedDuration,
|
|
599
|
+
incoming,
|
|
600
|
+
incomingInfo,
|
|
601
|
+
isMuted: muted,
|
|
602
|
+
isSpeaker: speakerOn,
|
|
603
|
+
isHeld: held,
|
|
604
|
+
dial,
|
|
605
|
+
answer,
|
|
606
|
+
hangup,
|
|
607
|
+
decline,
|
|
608
|
+
toggleMute,
|
|
609
|
+
toggleSpeaker,
|
|
610
|
+
toggleHold,
|
|
611
|
+
sendDtmf,
|
|
612
|
+
playKeyTone,
|
|
613
|
+
register,
|
|
614
|
+
refreshRegistration,
|
|
615
|
+
unregister,
|
|
616
|
+
getCallLogs,
|
|
617
|
+
getRegistrationStatus,
|
|
618
|
+
startNativeServices: startServices,
|
|
619
|
+
stopNativeServices: stopServices,
|
|
620
|
+
}),
|
|
621
|
+
[
|
|
622
|
+
config,
|
|
623
|
+
registration,
|
|
624
|
+
callStatus,
|
|
625
|
+
durationSec,
|
|
626
|
+
formattedDuration,
|
|
627
|
+
incoming,
|
|
628
|
+
incomingInfo,
|
|
629
|
+
muted,
|
|
630
|
+
speakerOn,
|
|
631
|
+
held,
|
|
632
|
+
dial,
|
|
633
|
+
answer,
|
|
634
|
+
hangup,
|
|
635
|
+
decline,
|
|
636
|
+
toggleMute,
|
|
637
|
+
toggleSpeaker,
|
|
638
|
+
toggleHold,
|
|
639
|
+
sendDtmf,
|
|
640
|
+
playKeyTone,
|
|
641
|
+
register,
|
|
642
|
+
refreshRegistration,
|
|
643
|
+
unregister,
|
|
644
|
+
getCallLogs,
|
|
645
|
+
getRegistrationStatus,
|
|
646
|
+
startServices,
|
|
647
|
+
stopServices,
|
|
648
|
+
]
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
return <CallContext.Provider value={value}>{children}</CallContext.Provider>;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Access the call API from any descendant of `<CallProvider>`.
|
|
656
|
+
*
|
|
657
|
+
* Throws if used outside the provider.
|
|
658
|
+
*/
|
|
659
|
+
export function useCall(): CallApi {
|
|
660
|
+
const ctx = useContext(CallContext);
|
|
661
|
+
if (!ctx) {
|
|
662
|
+
throw new Error(
|
|
663
|
+
'`useCall()` must be used inside `<CallProvider>`. Mount the provider near the root of your app tree.'
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
return ctx;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Internal — exported for power users who want to read the context without throwing. */
|
|
670
|
+
export function useCallSafe(): CallApi | null {
|
|
671
|
+
return useContext(CallContext);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** Re-export to allow `formatDuration`, `callStatusLabel`, etc. to be imported from the main entry too. */
|
|
675
|
+
export { callStatusLabel, formatDuration };
|