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