@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,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 };