@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,161 @@
1
+ /**
2
+ * Strips `http(s)://` and trailing `/` from a tenant domain.
3
+ *
4
+ * Multi-tenant backends often hand the SDK an HTTP URL ("https://t1.example.com/")
5
+ * when what we need is a bare SIP host ("t1.example.com"). Doing this once
6
+ * here is friendlier than failing the registration with a cryptic error.
7
+ */
8
+ export function formatTenantDomain(domain) {
9
+ if (!domain)
10
+ return '';
11
+ return domain.replace(/^https?:\/\//, '').replace(/\/$/, '');
12
+ }
13
+ /**
14
+ * Extract the user-part of `sip:user@domain` (or returns the input unchanged).
15
+ *
16
+ * The regex stops at `@`, `;` (URI params like `;transport=tcp`), and `>`
17
+ * (display-name angle brackets) so it handles all three common SIP URI
18
+ * formats without separate parsers.
19
+ */
20
+ export function parseSipUser(sipUri = '') {
21
+ const m = /sip:([^@;>]+)/i.exec(sipUri);
22
+ return m?.[1] ?? sipUri.replace(/^sip:/i, '');
23
+ }
24
+ // Allowlist anything dialable: digits plus the three special SIP keys.
25
+ // Notably DROPS letters — PSTN gateways reject them, and the dial-pad
26
+ // converts letters via the standard ABC/DEF mapping before getting here.
27
+ export function sanitizeDial(input = '') {
28
+ return input.replace(/[^\d+#*]/g, '');
29
+ }
30
+ /** Format seconds as `M:SS` or `H:MM:SS`. */
31
+ export function formatDuration(totalSeconds) {
32
+ const s = Math.max(0, Math.floor(totalSeconds));
33
+ const h = Math.floor(s / 3600);
34
+ const m = Math.floor((s % 3600) / 60);
35
+ const sec = s % 60;
36
+ const pad = (n) => String(n).padStart(2, '0');
37
+ return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
38
+ }
39
+ /** Convert two-character initials for avatar placeholders. */
40
+ export function initialsFrom(name) {
41
+ return (name || '??').slice(0, 2).toUpperCase();
42
+ }
43
+ /**
44
+ * Linphone numeric-state → string-state mapping.
45
+ *
46
+ * The SDK's current native modules emit states as strings ("Connected"),
47
+ * but older Linphone bindings — and any third party that wires up the
48
+ * native module directly — may still send the raw enum ordinals. Keeping
49
+ * this map means a Linphone upgrade or a different binding doesn't break
50
+ * existing apps.
51
+ */
52
+ const CALL_STATE_BY_INT = {
53
+ 0: 'Idle',
54
+ 1: 'IncomingReceived',
55
+ 2: 'PushIncomingReceived',
56
+ 3: 'OutgoingInit',
57
+ 4: 'OutgoingProgress',
58
+ 5: 'OutgoingRinging',
59
+ 6: 'OutgoingEarlyMedia',
60
+ 7: 'Connected',
61
+ 8: 'StreamsRunning',
62
+ 9: 'Pausing',
63
+ 10: 'Paused',
64
+ 11: 'Resuming',
65
+ 12: 'Referred',
66
+ 13: 'Error',
67
+ 14: 'End',
68
+ 15: 'PausedByRemote',
69
+ 16: 'UpdatedByRemote',
70
+ 17: 'IncomingEarlyMedia',
71
+ 18: 'Updating',
72
+ 19: 'Released',
73
+ 20: 'EarlyUpdatedByRemote',
74
+ 21: 'EarlyUpdating',
75
+ };
76
+ const REG_STATE_BY_INT = {
77
+ 0: 'none',
78
+ 1: 'progress',
79
+ 2: 'ok',
80
+ 3: 'cleared',
81
+ 4: 'failed',
82
+ };
83
+ /** Normalise a raw call-state value (string or int) to its canonical string form. */
84
+ export function callStateName(raw) {
85
+ if (typeof raw === 'number')
86
+ return CALL_STATE_BY_INT[raw] ?? String(raw);
87
+ return String(raw ?? '');
88
+ }
89
+ /** Normalise a raw registration-state value (string or int) to canonical lowercase string. */
90
+ export function regStateName(raw) {
91
+ if (typeof raw === 'number') {
92
+ return REG_STATE_BY_INT[raw] ?? 'unknown';
93
+ }
94
+ const s = String(raw ?? '').toLowerCase();
95
+ if (s === 'none' ||
96
+ s === 'progress' ||
97
+ s === 'ok' ||
98
+ s === 'cleared' ||
99
+ s === 'failed') {
100
+ return s;
101
+ }
102
+ return 'unknown';
103
+ }
104
+ /**
105
+ * Map a raw call status to a user-friendly UI label.
106
+ *
107
+ * You typically don't need to call this directly — `useCall().callStatus`
108
+ * already returns the canonical state name, and the bundled
109
+ * `<OutgoingCallView>` does its own mapping. Use this when building a custom UI.
110
+ */
111
+ export function callStatusLabel(status) {
112
+ switch (status) {
113
+ case 'OutgoingInit':
114
+ case 'OutgoingProgress':
115
+ case 'OutgoingEarlyMedia':
116
+ return 'Calling…';
117
+ case 'OutgoingRinging':
118
+ return 'Ringing…';
119
+ case 'Connected':
120
+ case 'StreamsRunning':
121
+ return 'In progress';
122
+ case 'Pausing':
123
+ return 'Pausing…';
124
+ case 'Paused':
125
+ case 'PausedByRemote':
126
+ return 'On hold';
127
+ case 'Resuming':
128
+ return 'Resuming…';
129
+ case 'End':
130
+ case 'Released':
131
+ return 'Call ended';
132
+ case 'Error':
133
+ return 'Call failed';
134
+ case 'IncomingReceived':
135
+ case 'PushIncomingReceived':
136
+ case 'IncomingEarlyMedia':
137
+ return 'Incoming call';
138
+ default:
139
+ return status || 'Idle';
140
+ }
141
+ }
142
+ /**
143
+ * Resolve a destination string to a SIP URI.
144
+ *
145
+ * Three-case dispatch in order of "most explicit wins":
146
+ * 1. `sip:100@gw.com` → already a URI, pass through unchanged.
147
+ * 2. `100@gw.com` → caller specified the gateway, just prefix `sip:`.
148
+ * 3. `100` (bare) → fill in the configured domain.
149
+ *
150
+ * This lets apps mix internal-extension dialling with PSTN gateway URIs
151
+ * without needing a separate API.
152
+ */
153
+ export function destinationToSipUri(destination, domain) {
154
+ if (!destination)
155
+ return '';
156
+ if (destination.startsWith('sip:'))
157
+ return destination;
158
+ if (destination.includes('@'))
159
+ return `sip:${destination}`;
160
+ return `sip:${destination}@${formatTenantDomain(domain)}`;
161
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Public entry point for `@nativetalk/react-native-call-sdk`.
3
+ *
4
+ * Most apps only need:
5
+ *
6
+ * ```tsx
7
+ * import { CallProvider, useCall } from '@nativetalk/react-native-call-sdk';
8
+ * ```
9
+ *
10
+ * Optional UI components are exported from the `/ui` sub-path:
11
+ *
12
+ * ```tsx
13
+ * import { Dialer, IncomingCallView, OutgoingCallView } from '@nativetalk/react-native-call-sdk/ui';
14
+ * ```
15
+ *
16
+ * Power-users can reach the raw native bridge:
17
+ *
18
+ * ```ts
19
+ * import { CallEngine } from '@nativetalk/react-native-call-sdk';
20
+ * CallEngine.call('sip:100@sip.example.com');
21
+ * ```
22
+ */
23
+ export { CallProvider, useCall, useCallSafe } from './CallProvider';
24
+ export { callStateName, callStatusLabel, destinationToSipUri, formatDuration, formatTenantDomain, initialsFrom, parseSipUser, regStateName, sanitizeDial, } from './helpers';
25
+ import * as Native from './native';
26
+ /**
27
+ * Direct access to the native bridge.
28
+ *
29
+ * This is escape-hatch territory — prefer `useCall()` whenever possible. Use
30
+ * `CallEngine` only when you need to drive the SDK from outside React (e.g.
31
+ * a headless task) or to wire iOS VoIP push tokens.
32
+ */
33
+ export const CallEngine = {
34
+ init: Native.init,
35
+ register: Native.register,
36
+ call: Native.call,
37
+ answer: Native.answer,
38
+ end: Native.end,
39
+ hangup: Native.end,
40
+ decline: Native.decline,
41
+ mute: Native.mute,
42
+ speaker: Native.speaker,
43
+ hold: Native.hold,
44
+ resume: Native.resume,
45
+ sendDtmf: Native.sendDtmf,
46
+ playKeyTone: Native.playKeyTone,
47
+ refreshRegisters: Native.refreshRegisters,
48
+ setRegisterEnabled: Native.setRegisterEnabled,
49
+ getRegistrationStatus: Native.getRegistrationStatus,
50
+ getCallLogs: Native.getCallLogs,
51
+ startNativeServices: Native.startNativeServices,
52
+ stopNativeServices: Native.stopNativeServices,
53
+ registerVoipToken: Native.registerVoipToken,
54
+ ensureMicPermission: Native.ensureMicPermission,
55
+ on: Native.on,
56
+ };
57
+ export { callEvents } from './native';
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Thin bridge to the native `NativetalkCallSdk` module.
3
+ *
4
+ * This file is the only place that talks to `NativeModules` and the event
5
+ * emitter. Everything else in the SDK uses these exports so the JS layer
6
+ * stays testable.
7
+ *
8
+ * If you see "TurboModuleRegistry … was not found" at runtime, the native
9
+ * library isn't linked. See `docs/installation.md`.
10
+ */
11
+ import { NativeEventEmitter, NativeModules, PermissionsAndroid, Platform, } from 'react-native';
12
+ const LINKING_ERROR = `The package '@nativetalk/react-native-call-sdk' doesn't seem to be linked. Make sure:\n\n` +
13
+ `- you rebuilt the app after installing the package\n` +
14
+ `- you are not using Expo Go (use a dev client or bare workflow)\n` +
15
+ `- (iOS) you ran 'pod install' inside the ios/ directory\n`;
16
+ // Proxy fallback: if autolinking failed, every method access throws a helpful
17
+ // error instead of a cryptic "undefined is not a function". This means a
18
+ // misconfigured app crashes EARLY with a clear message, not deep inside JS.
19
+ const NativetalkCallSdk = NativeModules.NativetalkCallSdk ??
20
+ new Proxy({}, {
21
+ get() {
22
+ throw new Error(LINKING_ERROR);
23
+ },
24
+ });
25
+ // Single shared emitter — re-creating it per subscription wastes native
26
+ // handlers and can drop events during the swap.
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ export const callEvents = new NativeEventEmitter(NativetalkCallSdk);
29
+ /** Request microphone permission on Android. Returns true if granted (or platform is iOS). */
30
+ export async function ensureMicPermission() {
31
+ if (Platform.OS !== 'android')
32
+ return true;
33
+ const granted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO, {
34
+ title: 'Microphone Permission',
35
+ message: 'Microphone access is required to make and receive calls.',
36
+ buttonPositive: 'OK',
37
+ });
38
+ return granted === PermissionsAndroid.RESULTS.GRANTED;
39
+ }
40
+ // --- Core lifecycle ---
41
+ // Idempotent on the native side, safe to call multiple times.
42
+ export function init(cfg) {
43
+ return NativetalkCallSdk.init(cfg ?? {});
44
+ }
45
+ // Android-only: iOS uses CallKit + VoIP push instead of a background
46
+ // service. Guarding here keeps the JS API symmetric across platforms.
47
+ export function startNativeServices() {
48
+ if (Platform.OS !== 'android')
49
+ return;
50
+ NativetalkCallSdk.startNativeServices();
51
+ }
52
+ export function stopNativeServices(logout = false) {
53
+ if (Platform.OS !== 'android')
54
+ return;
55
+ NativetalkCallSdk.stopNativeServices(logout);
56
+ }
57
+ export function register(account) {
58
+ NativetalkCallSdk.register(account);
59
+ }
60
+ // Optional-chained: older native builds may not export this method, and
61
+ // we'd rather no-op than crash on a non-critical refresh.
62
+ export function refreshRegisters() {
63
+ NativetalkCallSdk.refreshRegisters?.();
64
+ }
65
+ export function setRegisterEnabled(enabled) {
66
+ NativetalkCallSdk.setRegisterEnabled(enabled);
67
+ }
68
+ export function getRegistrationStatus() {
69
+ return NativetalkCallSdk.getRegistrationStatus();
70
+ }
71
+ // --- Call control ---
72
+ export function call(sipUri) {
73
+ NativetalkCallSdk.call(sipUri);
74
+ }
75
+ export function answer() {
76
+ NativetalkCallSdk.answer();
77
+ }
78
+ export function end() {
79
+ NativetalkCallSdk.end();
80
+ }
81
+ export function decline(reason = 'declined') {
82
+ NativetalkCallSdk.decline?.(reason);
83
+ }
84
+ export function mute(on) {
85
+ NativetalkCallSdk.mute(on);
86
+ }
87
+ export function speaker(on) {
88
+ NativetalkCallSdk.speaker(on);
89
+ }
90
+ export function hold() {
91
+ NativetalkCallSdk.hold();
92
+ }
93
+ export function resume() {
94
+ NativetalkCallSdk.resume();
95
+ }
96
+ export function sendDtmf(digit) {
97
+ NativetalkCallSdk.sendDtmf(digit);
98
+ }
99
+ export function playKeyTone(digit) {
100
+ NativetalkCallSdk.playKeyTone(digit);
101
+ }
102
+ // --- Call logs ---
103
+ export function getCallLogs() {
104
+ return NativetalkCallSdk.getCallLogs();
105
+ }
106
+ // --- iOS push token (advanced; only used if you wire VoIP push manually) ---
107
+ // Call from the host AppDelegate's PushKit `didUpdate` handler so the token
108
+ // gets attached to the SIP account params. Android FCM uses a different flow.
109
+ export function registerVoipToken(hex) {
110
+ if (Platform.OS !== 'ios')
111
+ return;
112
+ NativetalkCallSdk.registerVoipToken?.(hex);
113
+ }
114
+ export const on = {
115
+ RegistrationChanged: (cb) => callEvents.addListener('RegistrationChanged', cb),
116
+ CallIncoming: (cb) => callEvents.addListener('CallIncoming', cb),
117
+ CallState: (cb) => callEvents.addListener('CallState', cb),
118
+ CallEnded: (cb) => callEvents.addListener('CallEnded', cb),
119
+ // The TM* events are Android-only (telephony observer for native GSM
120
+ // calls). They never fire on iOS but the subscription is harmless.
121
+ TMPhoneCallState: (cb) => callEvents.addListener('TMPhoneCallState', cb),
122
+ TMPhoneCallInfo: (cb) => callEvents.addListener('TMPhoneCallInfo', cb),
123
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Public types for the Nativetalk Call SDK.
3
+ *
4
+ * These describe the SIP account configuration, registration state, call
5
+ * lifecycle events, and the shape of `useCall()` and `<CallProvider>`.
6
+ */
7
+ export {};
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import { Text, View, StyleSheet } from 'react-native';
3
+ /** Tiny initials-circle used by the bundled call screens. */
4
+ export function Avatar({ initials, size = 80, background = '#EEF2FF', color = '#2D6BFF', style, textStyle, }) {
5
+ return (<View style={[
6
+ styles.base,
7
+ { width: size, height: size, borderRadius: size / 2, backgroundColor: background },
8
+ style,
9
+ ]}>
10
+ <Text style={[
11
+ styles.text,
12
+ { color, fontSize: Math.floor(size * 0.4) },
13
+ textStyle,
14
+ ]}>
15
+ {(initials || '??').slice(0, 2).toUpperCase()}
16
+ </Text>
17
+ </View>);
18
+ }
19
+ const styles = StyleSheet.create({
20
+ base: { alignItems: 'center', justifyContent: 'center' },
21
+ text: { fontWeight: '700' },
22
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Bundled dial-pad component.
3
+ *
4
+ * Drop it anywhere inside a `<CallProvider>` and it'll dial through the SDK.
5
+ * Override almost everything via props if you want to keep the layout but
6
+ * change the look.
7
+ */
8
+ import React, { useState } from 'react';
9
+ import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View, useWindowDimensions, } from 'react-native';
10
+ import { useCall } from '../CallProvider';
11
+ import { sanitizeDial } from '../helpers';
12
+ import { mergeTheme } from './theme';
13
+ const dialPad = [
14
+ [
15
+ { number: '1', letters: '' },
16
+ { number: '2', letters: 'ABC' },
17
+ { number: '3', letters: 'DEF' },
18
+ ],
19
+ [
20
+ { number: '4', letters: 'GHI' },
21
+ { number: '5', letters: 'JKL' },
22
+ { number: '6', letters: 'MNO' },
23
+ ],
24
+ [
25
+ { number: '7', letters: 'PQRS' },
26
+ { number: '8', letters: 'TUV' },
27
+ { number: '9', letters: 'WXYZ' },
28
+ ],
29
+ [
30
+ { number: '*', letters: '' },
31
+ { number: '0', letters: '+' },
32
+ { number: '#', letters: '' },
33
+ ],
34
+ ];
35
+ export function Dialer({ initialValue = '', onDialed, header, renderCallButton, theme, playKeyTones = true, }) {
36
+ const [input, setInput] = useState(initialValue);
37
+ const { dial, playKeyTone } = useCall();
38
+ const { width, height } = useWindowDimensions();
39
+ const isCompact = height < 760;
40
+ const buttonSize = Math.min(width / 4.3, isCompact ? 68 : 84);
41
+ const t = mergeTheme(theme);
42
+ const handlePress = (value) => {
43
+ if (playKeyTones)
44
+ playKeyTone(value);
45
+ setInput((prev) => prev + value);
46
+ };
47
+ const handleBackspace = () => setInput((prev) => prev.slice(0, -1));
48
+ const handleCall = async () => {
49
+ if (!input)
50
+ return;
51
+ try {
52
+ await dial(input);
53
+ onDialed?.(input);
54
+ }
55
+ catch (err) {
56
+ // Errors surface via <CallProvider onError>. Swallow here so the dialer
57
+ // doesn't crash the host app.
58
+ }
59
+ };
60
+ const callDisabled = input.length === 0;
61
+ return (<View style={[styles.container, { backgroundColor: t.background }]}>
62
+ {header}
63
+ <View style={styles.inputContainer}>
64
+ <TextInput value={input} placeholder="Enter Number" placeholderTextColor="#ccc" onChangeText={(s) => setInput(sanitizeDial(s))} keyboardType="phone-pad" inputMode="tel" autoCorrect={false} autoCapitalize="none" maxLength={64} style={[styles.inputText, { color: t.text }]} returnKeyType="done" onSubmitEditing={handleCall}/>
65
+ {input.length > 0 && (<TouchableOpacity style={styles.clearButton} onPress={handleBackspace}>
66
+ <Text style={{ fontSize: 22, color: '#999' }}>⌫</Text>
67
+ </TouchableOpacity>)}
68
+ </View>
69
+
70
+ <ScrollView style={styles.padContainer} contentContainerStyle={[
71
+ styles.padContent,
72
+ { paddingTop: isCompact ? 16 : 30, paddingBottom: 24 },
73
+ ]} bounces={false} showsVerticalScrollIndicator={false}>
74
+ {dialPad.map((row, i) => (<View style={[styles.padRow, { marginBottom: isCompact ? 8 : 10 }]} key={i}>
75
+ {row.map((item) => (<TouchableOpacity key={item.number} style={[
76
+ styles.padButton,
77
+ {
78
+ width: buttonSize,
79
+ height: buttonSize,
80
+ borderRadius: buttonSize / 2,
81
+ marginHorizontal: isCompact ? 6 : 8,
82
+ },
83
+ ]} onPress={() => handlePress(item.number)} activeOpacity={0.8}>
84
+ <Text style={[
85
+ styles.padNumber,
86
+ { fontSize: isCompact ? 28 : 30, color: t.text },
87
+ ]}>
88
+ {item.number}
89
+ </Text>
90
+ {!!item.letters && (<Text style={[styles.padLetters, { color: t.subtext }]}>
91
+ {item.letters}
92
+ </Text>)}
93
+ </TouchableOpacity>))}
94
+ </View>))}
95
+
96
+ <View style={{ marginTop: isCompact ? 10 : 15 }}>
97
+ {renderCallButton ? (renderCallButton({
98
+ onPress: handleCall,
99
+ disabled: callDisabled,
100
+ number: input,
101
+ })) : (<TouchableOpacity onPress={handleCall} disabled={callDisabled} style={[
102
+ styles.callButton,
103
+ {
104
+ backgroundColor: callDisabled ? '#ccc' : t.answer,
105
+ width: isCompact ? 62 : 70,
106
+ height: isCompact ? 62 : 70,
107
+ borderRadius: (isCompact ? 62 : 70) / 2,
108
+ },
109
+ ]}>
110
+ <Text style={{ color: '#fff', fontSize: 28 }}>📞</Text>
111
+ </TouchableOpacity>)}
112
+ </View>
113
+ </ScrollView>
114
+ </View>);
115
+ }
116
+ const styles = StyleSheet.create({
117
+ container: { flex: 1 },
118
+ inputContainer: {
119
+ flexDirection: 'row',
120
+ alignItems: 'center',
121
+ justifyContent: 'center',
122
+ minHeight: 100,
123
+ marginTop: 10,
124
+ marginBottom: 10,
125
+ paddingHorizontal: 30,
126
+ position: 'relative',
127
+ },
128
+ inputText: {
129
+ flex: 1,
130
+ fontSize: 26,
131
+ textAlign: 'center',
132
+ fontWeight: '600',
133
+ letterSpacing: 2,
134
+ paddingVertical: 12,
135
+ },
136
+ clearButton: {
137
+ position: 'absolute',
138
+ right: 35,
139
+ padding: 6,
140
+ zIndex: 10,
141
+ },
142
+ padContainer: {
143
+ flex: 1,
144
+ borderTopLeftRadius: 22,
145
+ borderTopRightRadius: 22,
146
+ },
147
+ padContent: { alignItems: 'center' },
148
+ padRow: { flexDirection: 'row', justifyContent: 'center' },
149
+ padButton: {
150
+ backgroundColor: '#fff',
151
+ justifyContent: 'center',
152
+ alignItems: 'center',
153
+ elevation: 1,
154
+ shadowColor: '#000',
155
+ shadowOffset: { width: 0, height: 1 },
156
+ shadowOpacity: 0.07,
157
+ shadowRadius: 1.5,
158
+ },
159
+ padNumber: { fontWeight: '600', textAlign: 'center' },
160
+ padLetters: { fontSize: 11, letterSpacing: 2, textAlign: 'center', marginTop: 2 },
161
+ callButton: { alignItems: 'center', justifyContent: 'center' },
162
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Drop-in screen rendered when an incoming call is ringing.
3
+ *
4
+ * Wire-up:
5
+ *
6
+ * ```tsx
7
+ * <CallProvider
8
+ * onIncomingCall={() => navigation.navigate('IncomingCall')}
9
+ * config={cfg}
10
+ * >
11
+ * …
12
+ * </CallProvider>
13
+ *
14
+ * // your IncomingCall screen:
15
+ * <IncomingCallView onAnswered={() => navigation.replace('InCall')} />
16
+ * ```
17
+ */
18
+ import React, { useEffect } from 'react';
19
+ import { StyleSheet, Text, TouchableOpacity, View, } from 'react-native';
20
+ import { useCall } from '../CallProvider';
21
+ import { parseSipUser } from '../helpers';
22
+ import { Avatar } from './Avatar';
23
+ import { mergeTheme } from './theme';
24
+ export function IncomingCallView({ onAnswered, onDeclined, onDismissed, location, title = 'Incoming call', theme, header, style, }) {
25
+ const { incoming, incomingInfo, answer, decline } = useCall();
26
+ const t = mergeTheme(theme);
27
+ // Auto-dismiss if the call ends while this screen is mounted.
28
+ useEffect(() => {
29
+ if (!incoming)
30
+ onDismissed?.();
31
+ }, [incoming, onDismissed]);
32
+ const pretty = (s = '') => s.includes('@') ? parseSipUser(s) : s;
33
+ const name = incomingInfo?.name ?? 'Unknown';
34
+ const phone = incomingInfo?.phone ?? '';
35
+ const initials = incomingInfo?.initials ?? '??';
36
+ const onAnswer = async () => {
37
+ await answer();
38
+ onAnswered?.();
39
+ };
40
+ const onDecline = async () => {
41
+ await decline('busy');
42
+ onDeclined?.();
43
+ };
44
+ return (<View style={[styles.container, { backgroundColor: t.background }, style]}>
45
+ {header}
46
+ <Text style={[styles.status, { color: t.text }]}>{title}</Text>
47
+
48
+ <View style={styles.avatarWrap}>
49
+ <Avatar initials={initials} size={80} color={t.primary} background="#EEF2FF"/>
50
+ </View>
51
+
52
+ <Text style={[styles.name, { color: t.text }]}>{pretty(name)}</Text>
53
+ <Text style={[styles.phone, { color: t.text }]}>{pretty(phone)}</Text>
54
+ {!!location && (<Text style={[styles.location, { color: t.subtext }]}>{location}</Text>)}
55
+
56
+ <View style={styles.bottomRow}>
57
+ <TouchableOpacity onPress={onDecline} activeOpacity={0.85} style={[styles.circleBtn, { backgroundColor: t.decline }]}>
58
+ <Text style={styles.circleIcon}>✕</Text>
59
+ </TouchableOpacity>
60
+
61
+ <TouchableOpacity onPress={onAnswer} activeOpacity={0.85} style={[styles.circleBtn, { backgroundColor: t.answer }]}>
62
+ <Text style={styles.circleIcon}>📞</Text>
63
+ </TouchableOpacity>
64
+ </View>
65
+ </View>);
66
+ }
67
+ const styles = StyleSheet.create({
68
+ container: {
69
+ flex: 1,
70
+ alignItems: 'center',
71
+ justifyContent: 'flex-start',
72
+ paddingVertical: 60,
73
+ },
74
+ status: { fontSize: 18, marginBottom: 16, marginTop: 8 },
75
+ avatarWrap: { marginBottom: 22, marginTop: 6 },
76
+ name: { fontSize: 28, fontWeight: '800', textAlign: 'center' },
77
+ phone: { fontSize: 18, marginTop: 8 },
78
+ location: { fontSize: 16, marginTop: 8 },
79
+ bottomRow: {
80
+ position: 'absolute',
81
+ bottom: 60,
82
+ left: 0,
83
+ right: 0,
84
+ flexDirection: 'row',
85
+ justifyContent: 'space-between',
86
+ paddingHorizontal: 40,
87
+ },
88
+ circleBtn: {
89
+ width: 80,
90
+ height: 80,
91
+ borderRadius: 40,
92
+ alignItems: 'center',
93
+ justifyContent: 'center',
94
+ shadowColor: '#000',
95
+ shadowOpacity: 0.15,
96
+ shadowOffset: { width: 0, height: 4 },
97
+ shadowRadius: 8,
98
+ elevation: 4,
99
+ },
100
+ circleIcon: { fontSize: 32, color: '#fff' },
101
+ });