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