@poolse/react-native-calls 0.7.1 → 0.9.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/README.md +25 -0
- package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +3 -0
- package/android/src/main/java/dev/poolse/callcore/Ports.kt +2 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +9 -2
- package/android/src/main/java/dev/poolse/callcore/android/CallHttpClient.kt +1 -0
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +2 -0
- package/android/src/main/livekit/java/dev/poolse/callcore/livekit/LiveKitMediaPort.kt +7 -3
- package/dist/index.cjs +25 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -1
- package/dist/index.d.ts +26 -1
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/ios/Core/CallCoreRuntime.swift +3 -0
- package/ios/Core/CallHttpClient.swift +2 -1
- package/ios/Core/Ports.swift +5 -1
- package/ios/Core/SnapshotEncoding.swift +2 -0
- package/ios/Platform/CallCore.swift +6 -1
- package/ios/Platform/LiveKitMediaPort.swift +6 -2
- package/package.json +7 -6
- package/src/index.ts +61 -0
- package/LICENSE +0 -21
package/README.md
CHANGED
|
@@ -26,6 +26,31 @@ calls.setAudioOutput(s.audioOutputs[0].id);
|
|
|
26
26
|
calls.setSpeaker(true); // the one-tap toggle, still there
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
### Rooms — voice or video, nobody rings
|
|
30
|
+
|
|
31
|
+
A room is the conversation's drop-in call: members join when they like
|
|
32
|
+
while the chat carries on, and no phone rings. It runs on the same
|
|
33
|
+
native core as a call — CallKit on iOS, the call service on Android — so
|
|
34
|
+
it keeps going with the phone locked and routes audio like a call.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// Start the room (or join whatever is live on the conversation).
|
|
38
|
+
await calls.startRoom(conversationId, { media: 'video', sessionToken });
|
|
39
|
+
|
|
40
|
+
// Everyone else: useLiveCall(conversationId) from @poolse/react shows it
|
|
41
|
+
// (call.kind === 'room'); LiveCallBanner's Join calls
|
|
42
|
+
await calls.join(call.id, conversationId, sessionToken);
|
|
43
|
+
|
|
44
|
+
calls.hangUp(); // leaves — the room ends once it has been empty a moment
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`snapshot.kind` is `'room'` for a room (`'call'` otherwise), so a host
|
|
48
|
+
can show it differently from a call — Loop shows a bar over the chat
|
|
49
|
+
with mute, camera, audio output and Leave rather than the call screen.
|
|
50
|
+
A room plays on the speaker unless a headset or Bluetooth device is in,
|
|
51
|
+
and turning the camera on (`setCamera(true)`) is all it takes to go from
|
|
52
|
+
voice to video.
|
|
53
|
+
|
|
29
54
|
### Chat notifications
|
|
30
55
|
|
|
31
56
|
poolse sends message notifications itself, with the push credentials you
|
|
@@ -119,6 +119,9 @@ class CallCoreRuntime(
|
|
|
119
119
|
/** Best name for the current call: the server's bundle, else the ring's. Runtime thread only. */
|
|
120
120
|
val conversationName: String? get() = bundle?.conversationName ?: ring?.conversationName
|
|
121
121
|
|
|
122
|
+
/** `room` or `call`, once the server's bundle says; null before. Runtime thread only. */
|
|
123
|
+
val callKind: String? get() = bundle?.kind
|
|
124
|
+
|
|
122
125
|
/** True if [callId] was already resolved here — its ring must never surface again. */
|
|
123
126
|
fun isResolved(callId: String): Boolean = synchronized(resolved) { resolved.containsKey(callId) }
|
|
124
127
|
|
|
@@ -53,6 +53,8 @@ data class CallSessionBundle(
|
|
|
53
53
|
val livekitUrl: String,
|
|
54
54
|
val livekitToken: String,
|
|
55
55
|
val controlToken: String,
|
|
56
|
+
/** `room` (rings nobody, CONTRACT.md §5.6) or `call`. */
|
|
57
|
+
val kind: String = "call",
|
|
56
58
|
)
|
|
57
59
|
|
|
58
60
|
/** LiveKit (or any SFU) — media only, no lifecycle opinions. */
|
|
@@ -65,6 +65,10 @@ class CallCore private constructor(context: Context) {
|
|
|
65
65
|
private var extrasMedia: String? = null
|
|
66
66
|
private var roster: List<MediaParticipant> = emptyList()
|
|
67
67
|
private var conversationName: String? = null
|
|
68
|
+
private var callKind: String = "call"
|
|
69
|
+
|
|
70
|
+
// Video calls and rooms default to the speaker (the media port agrees).
|
|
71
|
+
private fun loud(state: MachineState) = state.media == "video" || callKind == "room"
|
|
68
72
|
private var mutedSelf = false
|
|
69
73
|
private var cameraOn = false
|
|
70
74
|
private var cameraFacing: String? = null
|
|
@@ -211,9 +215,11 @@ class CallCore private constructor(context: Context) {
|
|
|
211
215
|
extrasMedia = state.media
|
|
212
216
|
}
|
|
213
217
|
runtime.conversationName?.let { conversationName = it }
|
|
218
|
+
runtime.callKind?.let { callKind = it }
|
|
214
219
|
|
|
215
220
|
val snap = state.toSnapshot(
|
|
216
221
|
conversationName = conversationName,
|
|
222
|
+
kind = callKind,
|
|
217
223
|
participants = roster.map {
|
|
218
224
|
mapOf(
|
|
219
225
|
"userId" to it.identity,
|
|
@@ -223,7 +229,7 @@ class CallCore private constructor(context: Context) {
|
|
|
223
229
|
"cameraOn" to it.cameraOn,
|
|
224
230
|
)
|
|
225
231
|
},
|
|
226
|
-
audioRoute = audioRoute ?: if (state
|
|
232
|
+
audioRoute = audioRoute ?: if (loud(state)) "speaker" else "earpiece",
|
|
227
233
|
audioOutputs = audioOutputs.map { mapOf("id" to it.id, "kind" to it.kind, "name" to it.name) },
|
|
228
234
|
audioOutputId = audioOutputId,
|
|
229
235
|
mutedSelf = mutedSelf,
|
|
@@ -240,6 +246,7 @@ class CallCore private constructor(context: Context) {
|
|
|
240
246
|
extrasMedia = media
|
|
241
247
|
roster = emptyList()
|
|
242
248
|
conversationName = null
|
|
249
|
+
callKind = "call"
|
|
243
250
|
mutedSelf = false
|
|
244
251
|
// Reported by the media adapter once the camera is really published.
|
|
245
252
|
cameraOn = false
|
|
@@ -268,7 +275,7 @@ class CallCore private constructor(context: Context) {
|
|
|
268
275
|
// Screen off at the ear: only for an audio call on the earpiece.
|
|
269
276
|
val inCall = state.phase == Phase.ACTIVE || state.phase == Phase.RECONNECTING ||
|
|
270
277
|
state.phase == Phase.RINGING_OUT || state.phase == Phase.CONNECTING
|
|
271
|
-
val earpiece = (audioRoute ?: if (state
|
|
278
|
+
val earpiece = (audioRoute ?: if (loud(state)) "speaker" else "earpiece") == "earpiece"
|
|
272
279
|
setProximity(inCall && state.media == "audio" && earpiece)
|
|
273
280
|
}
|
|
274
281
|
|
|
@@ -185,6 +185,7 @@ internal class CallHttpClient(
|
|
|
185
185
|
livekitToken = livekit.getString("token"),
|
|
186
186
|
controlToken = root.optStringOrNull("control_token")
|
|
187
187
|
?: requireNotNull(current?.controlToken) { "bundle without control_token" },
|
|
188
|
+
kind = call.optStringOrNull("kind") ?: current?.kind ?: "call",
|
|
188
189
|
)
|
|
189
190
|
}
|
|
190
191
|
|
|
@@ -16,6 +16,7 @@ import dev.poolse.callcore.Phase
|
|
|
16
16
|
*/
|
|
17
17
|
internal fun MachineState.toSnapshot(
|
|
18
18
|
conversationName: String? = null,
|
|
19
|
+
kind: String = "call",
|
|
19
20
|
participants: List<Map<String, Any>> = emptyList(),
|
|
20
21
|
audioRoute: String = "earpiece",
|
|
21
22
|
audioOutputs: List<Map<String, Any>> = emptyList(),
|
|
@@ -27,6 +28,7 @@ internal fun MachineState.toSnapshot(
|
|
|
27
28
|
if (phase == Phase.IDLE) return null
|
|
28
29
|
return buildMap {
|
|
29
30
|
putAll(snapshotCore(conversationName, participants, audioRoute, mutedSelf, cameraOn))
|
|
31
|
+
put("kind", kind)
|
|
30
32
|
put("audioOutputs", audioOutputs)
|
|
31
33
|
put("audioOutputId", audioOutputId)
|
|
32
34
|
// Optional in the contract: present only while a camera is published.
|
|
@@ -61,6 +61,9 @@ class LiveKitMediaPort(
|
|
|
61
61
|
@Volatile private var room: Room? = null
|
|
62
62
|
private var eventJob: Job? = null
|
|
63
63
|
private var media: String = "audio"
|
|
64
|
+
// Speaker unless a headset is in: a video call, or a room (a drop-in
|
|
65
|
+
// room is held like a meeting, not to the ear).
|
|
66
|
+
private var loud = false
|
|
64
67
|
|
|
65
68
|
/**
|
|
66
69
|
* The user's output choice for the CURRENT call: an output id
|
|
@@ -94,8 +97,9 @@ class LiveKitMediaPort(
|
|
|
94
97
|
detach()
|
|
95
98
|
media = bundle.media
|
|
96
99
|
val wantsVideo = bundle.media == "video"
|
|
100
|
+
loud = wantsVideo || bundle.kind == "room"
|
|
97
101
|
val newRoom = LiveKit.create(appContext = context.applicationContext)
|
|
98
|
-
configureAudio(newRoom,
|
|
102
|
+
configureAudio(newRoom, loud)
|
|
99
103
|
// A 1:1/small call: never let the SFU pause a subscribed camera for
|
|
100
104
|
// "not visible" — a track whose view mounts late would stay black.
|
|
101
105
|
// (Renderers are still registered with visibility via addRenderer.)
|
|
@@ -340,7 +344,7 @@ class LiveKitMediaPort(
|
|
|
340
344
|
return when {
|
|
341
345
|
external != null -> external
|
|
342
346
|
outputWanted == PRIVATE -> earpiece ?: speaker
|
|
343
|
-
|
|
347
|
+
loud -> speaker ?: earpiece
|
|
344
348
|
else -> earpiece ?: speaker
|
|
345
349
|
}
|
|
346
350
|
}
|
|
@@ -378,7 +382,7 @@ class LiveKitMediaPort(
|
|
|
378
382
|
is AudioDevice.BluetoothHeadset -> "bluetooth"
|
|
379
383
|
is AudioDevice.WiredHeadset -> "headset"
|
|
380
384
|
is AudioDevice.Earpiece -> "earpiece"
|
|
381
|
-
null -> if (
|
|
385
|
+
null -> if (loud) "speaker" else "earpiece"
|
|
382
386
|
}
|
|
383
387
|
|
|
384
388
|
// ── room events ─────────────────────────────────────────────────────
|
package/dist/index.cjs
CHANGED
|
@@ -7,7 +7,9 @@ var expoModulesCore = require('expo-modules-core');
|
|
|
7
7
|
// src/index.ts
|
|
8
8
|
var native = expoModulesCore.requireOptionalNativeModule("PoolseCalls");
|
|
9
9
|
var nativeCallsAvailable = native != null;
|
|
10
|
+
var apiOrigin = null;
|
|
10
11
|
async function configure(apiBaseUrl, options) {
|
|
12
|
+
apiOrigin = apiBaseUrl.replace(/\/+$/, "");
|
|
11
13
|
await native?.configure(apiBaseUrl, options ?? null);
|
|
12
14
|
}
|
|
13
15
|
function getCallState() {
|
|
@@ -24,6 +26,28 @@ function place(conversationId, options) {
|
|
|
24
26
|
function join(callId, conversationId, sessionToken) {
|
|
25
27
|
return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();
|
|
26
28
|
}
|
|
29
|
+
async function startRoom(conversationId, options) {
|
|
30
|
+
if (!native) throw new Error("The native call core is not in this build.");
|
|
31
|
+
if (!apiOrigin) throw new Error("Call configure() before startRoom().");
|
|
32
|
+
const res = await fetch(
|
|
33
|
+
`${apiOrigin}/v1/conversations/${encodeURIComponent(conversationId)}/room`,
|
|
34
|
+
{
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: {
|
|
37
|
+
authorization: `Bearer ${options.sessionToken}`,
|
|
38
|
+
"content-type": "application/json"
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify({ media: options.media })
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
const body = await res.json().catch(() => null);
|
|
44
|
+
const callId = body?.call?.id;
|
|
45
|
+
if (!res.ok || !callId) {
|
|
46
|
+
throw new Error(body?.error?.message ?? `Couldn't start the room (${res.status}).`);
|
|
47
|
+
}
|
|
48
|
+
await native.join(callId, conversationId, options.sessionToken);
|
|
49
|
+
return { callId, created: body?.created === true };
|
|
50
|
+
}
|
|
27
51
|
var answer = () => native?.answer();
|
|
28
52
|
var decline = () => native?.decline();
|
|
29
53
|
var hangUp = () => native?.hangUp();
|
|
@@ -116,6 +140,7 @@ exports.setCamera = setCamera;
|
|
|
116
140
|
exports.setMuted = setMuted;
|
|
117
141
|
exports.setSpeaker = setSpeaker;
|
|
118
142
|
exports.shouldPresentMessage = shouldPresentMessage;
|
|
143
|
+
exports.startRoom = startRoom;
|
|
119
144
|
exports.switchCamera = switchCamera;
|
|
120
145
|
exports.takeCallAction = takeCallAction;
|
|
121
146
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":["requireOptionalNativeModule","requireNativeViewManager","createElement","View"],"mappings":"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","file":"index.cjs","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n /** The kind of output playing now — the short answer to \"where is the audio\". */\n audioRoute: AudioOutputKind;\n /**\n * Every output the call can play through right now: always the phone\n * (earpiece, or wired headphones in its place) and the speaker, plus\n * each connected Bluetooth / USB device by its own name. Empty until\n * the call's audio is up. Pass an `id` to `setAudioOutput`.\n */\n audioOutputs: AudioOutput[];\n /** The `audioOutputs` entry playing now; null before audio is up. */\n audioOutputId: string | null;\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n\n/** One place call audio can play. */\nexport interface AudioOutput {\n /** Opaque; stable while the device stays connected. */\n id: string;\n kind: AudioOutputKind;\n /**\n * The device's own name for Bluetooth/USB (\"AirPods Pro\"); a plain\n * English default for built-ins (\"iPhone\"/\"Phone\", \"Speaker\",\n * \"Headphones\") — relabel those by `kind` to localise.\n */\n name: string;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\n/**\n * What the user tapped on a missed-call notification: the notification\n * itself (`open` the conversation) or its Call back button (`call_back`\n * — place a call on the conversation with `media`).\n */\nexport interface CallAction {\n type: 'open' | 'call_back';\n conversationId: string;\n callId: string | null;\n media: 'audio' | 'video';\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n setAudioOutput?(id: string): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n takeCallAction?(): CallAction | null;\n setActiveConversation?(conversationId: string | null): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n addListener(event: 'onCallAction', cb: (payload: CallAction) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Play call audio through one of `snapshot.audioOutputs`. The choice\n * holds for the rest of the call, until that device disconnects or a new\n * headset / Bluetooth device connects (which takes over, as in the\n * phone's own dialer). Without a choice a connected headset wins, then\n * the earpiece for voice calls or the speaker for video.\n */\nexport function setAudioOutput(id: string): void {\n if (native?.setAudioOutput) native.setAudioOutput(id);\n else native?.setSpeaker(id === 'speaker'); // an older native build\n}\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── missed-call notifications (Android) ─────────────────────────────────\n\n/**\n * Android: the missed-call notification action the app was LAUNCHED\n * with, once — read it on mount. `null` when the app opened any other\n * way. The native core posts that notification itself from the push,\n * so it works with the app closed.\n *\n * iOS shows missed calls as an ordinary alert push (to the token you\n * register with `kind: 'alert'`); read its taps with your notification\n * library — its `type` is `call_missed`, the category\n * `poolse_missed_call`, the Call back action `call_back`.\n */\nexport function takeCallAction(): CallAction | null {\n return native?.takeCallAction?.() ?? null;\n}\n\n/** Android: a missed-call notification tapped while the app is running. */\nexport function onCallAction(cb: (action: CallAction) => void): Unsubscribe {\n if (!native || !native.takeCallAction) return () => undefined;\n const sub = native.addListener('onCallAction', cb);\n return () => sub.remove();\n}\n\n// ── chat notifications ─────────────────────────────────────────────────\n\nlet activeConversation: string | null = null;\n\n/**\n * Tell the core which conversation is on screen (`null` when none, and\n * when the app goes to the background). Its messages are not notified —\n * the user is reading them — and opening it clears the notifications\n * already shown for it.\n */\nexport function setActiveConversation(conversationId: string | null): void {\n activeConversation = conversationId;\n native?.setActiveConversation?.(conversationId);\n}\n\n/**\n * For the app's notification handler (expo-notifications'\n * `setNotificationHandler`): `false` for a chat notification of the\n * conversation on screen, which must not pop over the chat itself.\n * Android never asks — the core skips those natively.\n */\nexport function shouldPresentMessage(data: Record<string, unknown> | undefined | null): boolean {\n if (!data || data['type'] !== 'message') return true;\n return data['conversation_id'] !== activeConversation;\n}\n\n/**\n * The conversation a tapped notification belongs to — a chat message\n * (`type: 'message'`) or a missed call (`call_missed`) — from its data.\n * iOS hands the tap to your notification library; Android delivers it\n * through `takeCallAction` / `onCallAction` (type `open`).\n */\nexport function conversationOfNotification(\n data: Record<string, unknown> | undefined | null,\n): string | null {\n if (!data) return null;\n const type = data['type'];\n if (type !== 'message' && type !== 'call_missed') return null;\n return typeof data['conversation_id'] === 'string' ? data['conversation_id'] : null;\n}\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["requireOptionalNativeModule","requireNativeViewManager","createElement","View"],"mappings":"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","file":"index.cjs","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n /**\n * `room` — rings nobody, joined and left at will (`startRoom`); `call`\n * otherwise. Known once the server answered: a room being joined reads\n * `call` while `connecting`. Absent before 0.9.0: treat as `call`.\n */\n kind?: 'call' | 'room';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n /** The kind of output playing now — the short answer to \"where is the audio\". */\n audioRoute: AudioOutputKind;\n /**\n * Every output the call can play through right now: always the phone\n * (earpiece, or wired headphones in its place) and the speaker, plus\n * each connected Bluetooth / USB device by its own name. Empty until\n * the call's audio is up. Pass an `id` to `setAudioOutput`.\n */\n audioOutputs: AudioOutput[];\n /** The `audioOutputs` entry playing now; null before audio is up. */\n audioOutputId: string | null;\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n\n/** One place call audio can play. */\nexport interface AudioOutput {\n /** Opaque; stable while the device stays connected. */\n id: string;\n kind: AudioOutputKind;\n /**\n * The device's own name for Bluetooth/USB (\"AirPods Pro\"); a plain\n * English default for built-ins (\"iPhone\"/\"Phone\", \"Speaker\",\n * \"Headphones\") — relabel those by `kind` to localise.\n */\n name: string;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\n/**\n * What the user tapped on a missed-call notification: the notification\n * itself (`open` the conversation) or its Call back button (`call_back`\n * — place a call on the conversation with `media`).\n */\nexport interface CallAction {\n type: 'open' | 'call_back';\n conversationId: string;\n callId: string | null;\n media: 'audio' | 'video';\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n setAudioOutput?(id: string): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n takeCallAction?(): CallAction | null;\n setActiveConversation?(conversationId: string | null): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n addListener(event: 'onCallAction', cb: (payload: CallAction) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n// Kept for startRoom, the one command that talks to the server from JS.\nlet apiOrigin: string | null = null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n apiOrigin = apiBaseUrl.replace(/\\/+$/, '');\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport interface StartRoomResult {\n /** The room's call id — what the Join banner and `call:*` events carry. */\n callId: string;\n /** False when something was live already (a room, or a ringing call) and this joined it. */\n created: boolean;\n}\n\n/**\n * Start the conversation's room — a call that rings nobody (CONTRACT.md\n * §5.6) — or join whatever is live there, then carry it on the native\n * core like any joined call: CallKit / the call service, so it keeps\n * going with the phone locked, and the same audio routing as a call.\n *\n * Others in the conversation see it through the Join banner and join it\n * with `join`. Leaving (`hangUp`) never ends it for them; it ends once\n * it has been empty for a short grace. `media: 'video'` makes it a video\n * room. Rejects when the server refuses (not a member, no SFU) or\n * before `configure`.\n */\nexport async function startRoom(\n conversationId: string,\n options: PlaceOptions,\n): Promise<StartRoomResult> {\n if (!native) throw new Error('The native call core is not in this build.');\n if (!apiOrigin) throw new Error('Call configure() before startRoom().');\n const res = await fetch(\n `${apiOrigin}/v1/conversations/${encodeURIComponent(conversationId)}/room`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${options.sessionToken}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({ media: options.media }),\n },\n );\n const body = (await res.json().catch(() => null)) as {\n created?: boolean;\n call?: { id?: string };\n error?: { message?: string };\n } | null;\n const callId = body?.call?.id;\n if (!res.ok || !callId) {\n throw new Error(body?.error?.message ?? `Couldn't start the room (${res.status}).`);\n }\n // The core joins with its own request: it owns the session from here,\n // exactly as when the room is joined from the banner.\n await native.join(callId, conversationId, options.sessionToken);\n return { callId, created: body?.created === true };\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Play call audio through one of `snapshot.audioOutputs`. The choice\n * holds for the rest of the call, until that device disconnects or a new\n * headset / Bluetooth device connects (which takes over, as in the\n * phone's own dialer). Without a choice a connected headset wins, then\n * the earpiece for voice calls or the speaker for video.\n */\nexport function setAudioOutput(id: string): void {\n if (native?.setAudioOutput) native.setAudioOutput(id);\n else native?.setSpeaker(id === 'speaker'); // an older native build\n}\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── missed-call notifications (Android) ─────────────────────────────────\n\n/**\n * Android: the missed-call notification action the app was LAUNCHED\n * with, once — read it on mount. `null` when the app opened any other\n * way. The native core posts that notification itself from the push,\n * so it works with the app closed.\n *\n * iOS shows missed calls as an ordinary alert push (to the token you\n * register with `kind: 'alert'`); read its taps with your notification\n * library — its `type` is `call_missed`, the category\n * `poolse_missed_call`, the Call back action `call_back`.\n */\nexport function takeCallAction(): CallAction | null {\n return native?.takeCallAction?.() ?? null;\n}\n\n/** Android: a missed-call notification tapped while the app is running. */\nexport function onCallAction(cb: (action: CallAction) => void): Unsubscribe {\n if (!native || !native.takeCallAction) return () => undefined;\n const sub = native.addListener('onCallAction', cb);\n return () => sub.remove();\n}\n\n// ── chat notifications ─────────────────────────────────────────────────\n\nlet activeConversation: string | null = null;\n\n/**\n * Tell the core which conversation is on screen (`null` when none, and\n * when the app goes to the background). Its messages are not notified —\n * the user is reading them — and opening it clears the notifications\n * already shown for it.\n */\nexport function setActiveConversation(conversationId: string | null): void {\n activeConversation = conversationId;\n native?.setActiveConversation?.(conversationId);\n}\n\n/**\n * For the app's notification handler (expo-notifications'\n * `setNotificationHandler`): `false` for a chat notification of the\n * conversation on screen, which must not pop over the chat itself.\n * Android never asks — the core skips those natively.\n */\nexport function shouldPresentMessage(data: Record<string, unknown> | undefined | null): boolean {\n if (!data || data['type'] !== 'message') return true;\n return data['conversation_id'] !== activeConversation;\n}\n\n/**\n * The conversation a tapped notification belongs to — a chat message\n * (`type: 'message'`) or a missed call (`call_missed`) — from its data.\n * iOS hands the tap to your notification library; Android delivers it\n * through `takeCallAction` / `onCallAction` (type `open`).\n */\nexport function conversationOfNotification(\n data: Record<string, unknown> | undefined | null,\n): string | null {\n if (!data) return null;\n const type = data['type'];\n if (type !== 'message' && type !== 'call_missed') return null;\n return typeof data['conversation_id'] === 'string' ? data['conversation_id'] : null;\n}\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -12,6 +12,12 @@ interface Snapshot {
|
|
|
12
12
|
conversationId: string;
|
|
13
13
|
conversationName: string | null;
|
|
14
14
|
direction: 'incoming' | 'outgoing' | 'joined';
|
|
15
|
+
/**
|
|
16
|
+
* `room` — rings nobody, joined and left at will (`startRoom`); `call`
|
|
17
|
+
* otherwise. Known once the server answered: a room being joined reads
|
|
18
|
+
* `call` while `connecting`. Absent before 0.9.0: treat as `call`.
|
|
19
|
+
*/
|
|
20
|
+
kind?: 'call' | 'room';
|
|
15
21
|
media: 'audio' | 'video';
|
|
16
22
|
phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';
|
|
17
23
|
reason?: 'left' | 'finished' | 'declined_all' | 'busy_all' | 'no_answer' | 'cancelled' | 'connection_lost' | 'failed';
|
|
@@ -99,6 +105,25 @@ type Unsubscribe = () => void;
|
|
|
99
105
|
declare function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe;
|
|
100
106
|
declare function place(conversationId: string, options: PlaceOptions): Promise<void>;
|
|
101
107
|
declare function join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;
|
|
108
|
+
interface StartRoomResult {
|
|
109
|
+
/** The room's call id — what the Join banner and `call:*` events carry. */
|
|
110
|
+
callId: string;
|
|
111
|
+
/** False when something was live already (a room, or a ringing call) and this joined it. */
|
|
112
|
+
created: boolean;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Start the conversation's room — a call that rings nobody (CONTRACT.md
|
|
116
|
+
* §5.6) — or join whatever is live there, then carry it on the native
|
|
117
|
+
* core like any joined call: CallKit / the call service, so it keeps
|
|
118
|
+
* going with the phone locked, and the same audio routing as a call.
|
|
119
|
+
*
|
|
120
|
+
* Others in the conversation see it through the Join banner and join it
|
|
121
|
+
* with `join`. Leaving (`hangUp`) never ends it for them; it ends once
|
|
122
|
+
* it has been empty for a short grace. `media: 'video'` makes it a video
|
|
123
|
+
* room. Rejects when the server refuses (not a member, no SFU) or
|
|
124
|
+
* before `configure`.
|
|
125
|
+
*/
|
|
126
|
+
declare function startRoom(conversationId: string, options: PlaceOptions): Promise<StartRoomResult>;
|
|
102
127
|
declare const answer: () => void;
|
|
103
128
|
declare const decline: () => void;
|
|
104
129
|
declare const hangUp: () => void;
|
|
@@ -193,4 +218,4 @@ interface CallVideoViewProps extends ViewProps {
|
|
|
193
218
|
*/
|
|
194
219
|
declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
|
|
195
220
|
|
|
196
|
-
export { type AudioOutput, type AudioOutputKind, type CallAction, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, conversationOfNotification, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setActiveConversation, setAudioOutput, setCamera, setMuted, setSpeaker, shouldPresentMessage, switchCamera, takeCallAction };
|
|
221
|
+
export { type AudioOutput, type AudioOutputKind, type CallAction, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type StartRoomResult, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, conversationOfNotification, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setActiveConversation, setAudioOutput, setCamera, setMuted, setSpeaker, shouldPresentMessage, startRoom, switchCamera, takeCallAction };
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,12 @@ interface Snapshot {
|
|
|
12
12
|
conversationId: string;
|
|
13
13
|
conversationName: string | null;
|
|
14
14
|
direction: 'incoming' | 'outgoing' | 'joined';
|
|
15
|
+
/**
|
|
16
|
+
* `room` — rings nobody, joined and left at will (`startRoom`); `call`
|
|
17
|
+
* otherwise. Known once the server answered: a room being joined reads
|
|
18
|
+
* `call` while `connecting`. Absent before 0.9.0: treat as `call`.
|
|
19
|
+
*/
|
|
20
|
+
kind?: 'call' | 'room';
|
|
15
21
|
media: 'audio' | 'video';
|
|
16
22
|
phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';
|
|
17
23
|
reason?: 'left' | 'finished' | 'declined_all' | 'busy_all' | 'no_answer' | 'cancelled' | 'connection_lost' | 'failed';
|
|
@@ -99,6 +105,25 @@ type Unsubscribe = () => void;
|
|
|
99
105
|
declare function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe;
|
|
100
106
|
declare function place(conversationId: string, options: PlaceOptions): Promise<void>;
|
|
101
107
|
declare function join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;
|
|
108
|
+
interface StartRoomResult {
|
|
109
|
+
/** The room's call id — what the Join banner and `call:*` events carry. */
|
|
110
|
+
callId: string;
|
|
111
|
+
/** False when something was live already (a room, or a ringing call) and this joined it. */
|
|
112
|
+
created: boolean;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Start the conversation's room — a call that rings nobody (CONTRACT.md
|
|
116
|
+
* §5.6) — or join whatever is live there, then carry it on the native
|
|
117
|
+
* core like any joined call: CallKit / the call service, so it keeps
|
|
118
|
+
* going with the phone locked, and the same audio routing as a call.
|
|
119
|
+
*
|
|
120
|
+
* Others in the conversation see it through the Join banner and join it
|
|
121
|
+
* with `join`. Leaving (`hangUp`) never ends it for them; it ends once
|
|
122
|
+
* it has been empty for a short grace. `media: 'video'` makes it a video
|
|
123
|
+
* room. Rejects when the server refuses (not a member, no SFU) or
|
|
124
|
+
* before `configure`.
|
|
125
|
+
*/
|
|
126
|
+
declare function startRoom(conversationId: string, options: PlaceOptions): Promise<StartRoomResult>;
|
|
102
127
|
declare const answer: () => void;
|
|
103
128
|
declare const decline: () => void;
|
|
104
129
|
declare const hangUp: () => void;
|
|
@@ -193,4 +218,4 @@ interface CallVideoViewProps extends ViewProps {
|
|
|
193
218
|
*/
|
|
194
219
|
declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
|
|
195
220
|
|
|
196
|
-
export { type AudioOutput, type AudioOutputKind, type CallAction, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, conversationOfNotification, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setActiveConversation, setAudioOutput, setCamera, setMuted, setSpeaker, shouldPresentMessage, switchCamera, takeCallAction };
|
|
221
|
+
export { type AudioOutput, type AudioOutputKind, type CallAction, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type StartRoomResult, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, conversationOfNotification, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setActiveConversation, setAudioOutput, setCamera, setMuted, setSpeaker, shouldPresentMessage, startRoom, switchCamera, takeCallAction };
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,9 @@ import { requireOptionalNativeModule, requireNativeViewManager } from 'expo-modu
|
|
|
5
5
|
// src/index.ts
|
|
6
6
|
var native = requireOptionalNativeModule("PoolseCalls");
|
|
7
7
|
var nativeCallsAvailable = native != null;
|
|
8
|
+
var apiOrigin = null;
|
|
8
9
|
async function configure(apiBaseUrl, options) {
|
|
10
|
+
apiOrigin = apiBaseUrl.replace(/\/+$/, "");
|
|
9
11
|
await native?.configure(apiBaseUrl, options ?? null);
|
|
10
12
|
}
|
|
11
13
|
function getCallState() {
|
|
@@ -22,6 +24,28 @@ function place(conversationId, options) {
|
|
|
22
24
|
function join(callId, conversationId, sessionToken) {
|
|
23
25
|
return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();
|
|
24
26
|
}
|
|
27
|
+
async function startRoom(conversationId, options) {
|
|
28
|
+
if (!native) throw new Error("The native call core is not in this build.");
|
|
29
|
+
if (!apiOrigin) throw new Error("Call configure() before startRoom().");
|
|
30
|
+
const res = await fetch(
|
|
31
|
+
`${apiOrigin}/v1/conversations/${encodeURIComponent(conversationId)}/room`,
|
|
32
|
+
{
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: {
|
|
35
|
+
authorization: `Bearer ${options.sessionToken}`,
|
|
36
|
+
"content-type": "application/json"
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify({ media: options.media })
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
const body = await res.json().catch(() => null);
|
|
42
|
+
const callId = body?.call?.id;
|
|
43
|
+
if (!res.ok || !callId) {
|
|
44
|
+
throw new Error(body?.error?.message ?? `Couldn't start the room (${res.status}).`);
|
|
45
|
+
}
|
|
46
|
+
await native.join(callId, conversationId, options.sessionToken);
|
|
47
|
+
return { callId, created: body?.created === true };
|
|
48
|
+
}
|
|
25
49
|
var answer = () => native?.answer();
|
|
26
50
|
var decline = () => native?.decline();
|
|
27
51
|
var hangUp = () => native?.hangUp();
|
|
@@ -90,6 +114,6 @@ function CallVideoView(props) {
|
|
|
90
114
|
return createElement(Native, props);
|
|
91
115
|
}
|
|
92
116
|
|
|
93
|
-
export { CallVideoView, answer, canUseFullScreenIntent, configure, conversationOfNotification, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setActiveConversation, setAudioOutput, setCamera, setMuted, setSpeaker, shouldPresentMessage, switchCamera, takeCallAction };
|
|
117
|
+
export { CallVideoView, answer, canUseFullScreenIntent, configure, conversationOfNotification, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setActiveConversation, setAudioOutput, setCamera, setMuted, setSpeaker, shouldPresentMessage, startRoom, switchCamera, takeCallAction };
|
|
94
118
|
//# sourceMappingURL=index.js.map
|
|
95
119
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n /** The kind of output playing now — the short answer to \"where is the audio\". */\n audioRoute: AudioOutputKind;\n /**\n * Every output the call can play through right now: always the phone\n * (earpiece, or wired headphones in its place) and the speaker, plus\n * each connected Bluetooth / USB device by its own name. Empty until\n * the call's audio is up. Pass an `id` to `setAudioOutput`.\n */\n audioOutputs: AudioOutput[];\n /** The `audioOutputs` entry playing now; null before audio is up. */\n audioOutputId: string | null;\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n\n/** One place call audio can play. */\nexport interface AudioOutput {\n /** Opaque; stable while the device stays connected. */\n id: string;\n kind: AudioOutputKind;\n /**\n * The device's own name for Bluetooth/USB (\"AirPods Pro\"); a plain\n * English default for built-ins (\"iPhone\"/\"Phone\", \"Speaker\",\n * \"Headphones\") — relabel those by `kind` to localise.\n */\n name: string;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\n/**\n * What the user tapped on a missed-call notification: the notification\n * itself (`open` the conversation) or its Call back button (`call_back`\n * — place a call on the conversation with `media`).\n */\nexport interface CallAction {\n type: 'open' | 'call_back';\n conversationId: string;\n callId: string | null;\n media: 'audio' | 'video';\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n setAudioOutput?(id: string): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n takeCallAction?(): CallAction | null;\n setActiveConversation?(conversationId: string | null): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n addListener(event: 'onCallAction', cb: (payload: CallAction) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Play call audio through one of `snapshot.audioOutputs`. The choice\n * holds for the rest of the call, until that device disconnects or a new\n * headset / Bluetooth device connects (which takes over, as in the\n * phone's own dialer). Without a choice a connected headset wins, then\n * the earpiece for voice calls or the speaker for video.\n */\nexport function setAudioOutput(id: string): void {\n if (native?.setAudioOutput) native.setAudioOutput(id);\n else native?.setSpeaker(id === 'speaker'); // an older native build\n}\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── missed-call notifications (Android) ─────────────────────────────────\n\n/**\n * Android: the missed-call notification action the app was LAUNCHED\n * with, once — read it on mount. `null` when the app opened any other\n * way. The native core posts that notification itself from the push,\n * so it works with the app closed.\n *\n * iOS shows missed calls as an ordinary alert push (to the token you\n * register with `kind: 'alert'`); read its taps with your notification\n * library — its `type` is `call_missed`, the category\n * `poolse_missed_call`, the Call back action `call_back`.\n */\nexport function takeCallAction(): CallAction | null {\n return native?.takeCallAction?.() ?? null;\n}\n\n/** Android: a missed-call notification tapped while the app is running. */\nexport function onCallAction(cb: (action: CallAction) => void): Unsubscribe {\n if (!native || !native.takeCallAction) return () => undefined;\n const sub = native.addListener('onCallAction', cb);\n return () => sub.remove();\n}\n\n// ── chat notifications ─────────────────────────────────────────────────\n\nlet activeConversation: string | null = null;\n\n/**\n * Tell the core which conversation is on screen (`null` when none, and\n * when the app goes to the background). Its messages are not notified —\n * the user is reading them — and opening it clears the notifications\n * already shown for it.\n */\nexport function setActiveConversation(conversationId: string | null): void {\n activeConversation = conversationId;\n native?.setActiveConversation?.(conversationId);\n}\n\n/**\n * For the app's notification handler (expo-notifications'\n * `setNotificationHandler`): `false` for a chat notification of the\n * conversation on screen, which must not pop over the chat itself.\n * Android never asks — the core skips those natively.\n */\nexport function shouldPresentMessage(data: Record<string, unknown> | undefined | null): boolean {\n if (!data || data['type'] !== 'message') return true;\n return data['conversation_id'] !== activeConversation;\n}\n\n/**\n * The conversation a tapped notification belongs to — a chat message\n * (`type: 'message'`) or a missed call (`call_missed`) — from its data.\n * iOS hands the tap to your notification library; Android delivers it\n * through `takeCallAction` / `onCallAction` (type `open`).\n */\nexport function conversationOfNotification(\n data: Record<string, unknown> | undefined | null,\n): string | null {\n if (!data) return null;\n const type = data['type'];\n if (type !== 'message' && type !== 'call_missed') return null;\n return typeof data['conversation_id'] === 'string' ? data['conversation_id'] : null;\n}\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n /**\n * `room` — rings nobody, joined and left at will (`startRoom`); `call`\n * otherwise. Known once the server answered: a room being joined reads\n * `call` while `connecting`. Absent before 0.9.0: treat as `call`.\n */\n kind?: 'call' | 'room';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n /** The kind of output playing now — the short answer to \"where is the audio\". */\n audioRoute: AudioOutputKind;\n /**\n * Every output the call can play through right now: always the phone\n * (earpiece, or wired headphones in its place) and the speaker, plus\n * each connected Bluetooth / USB device by its own name. Empty until\n * the call's audio is up. Pass an `id` to `setAudioOutput`.\n */\n audioOutputs: AudioOutput[];\n /** The `audioOutputs` entry playing now; null before audio is up. */\n audioOutputId: string | null;\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n\n/** One place call audio can play. */\nexport interface AudioOutput {\n /** Opaque; stable while the device stays connected. */\n id: string;\n kind: AudioOutputKind;\n /**\n * The device's own name for Bluetooth/USB (\"AirPods Pro\"); a plain\n * English default for built-ins (\"iPhone\"/\"Phone\", \"Speaker\",\n * \"Headphones\") — relabel those by `kind` to localise.\n */\n name: string;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\n/**\n * What the user tapped on a missed-call notification: the notification\n * itself (`open` the conversation) or its Call back button (`call_back`\n * — place a call on the conversation with `media`).\n */\nexport interface CallAction {\n type: 'open' | 'call_back';\n conversationId: string;\n callId: string | null;\n media: 'audio' | 'video';\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n setAudioOutput?(id: string): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n takeCallAction?(): CallAction | null;\n setActiveConversation?(conversationId: string | null): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n addListener(event: 'onCallAction', cb: (payload: CallAction) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n// Kept for startRoom, the one command that talks to the server from JS.\nlet apiOrigin: string | null = null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n apiOrigin = apiBaseUrl.replace(/\\/+$/, '');\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport interface StartRoomResult {\n /** The room's call id — what the Join banner and `call:*` events carry. */\n callId: string;\n /** False when something was live already (a room, or a ringing call) and this joined it. */\n created: boolean;\n}\n\n/**\n * Start the conversation's room — a call that rings nobody (CONTRACT.md\n * §5.6) — or join whatever is live there, then carry it on the native\n * core like any joined call: CallKit / the call service, so it keeps\n * going with the phone locked, and the same audio routing as a call.\n *\n * Others in the conversation see it through the Join banner and join it\n * with `join`. Leaving (`hangUp`) never ends it for them; it ends once\n * it has been empty for a short grace. `media: 'video'` makes it a video\n * room. Rejects when the server refuses (not a member, no SFU) or\n * before `configure`.\n */\nexport async function startRoom(\n conversationId: string,\n options: PlaceOptions,\n): Promise<StartRoomResult> {\n if (!native) throw new Error('The native call core is not in this build.');\n if (!apiOrigin) throw new Error('Call configure() before startRoom().');\n const res = await fetch(\n `${apiOrigin}/v1/conversations/${encodeURIComponent(conversationId)}/room`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${options.sessionToken}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({ media: options.media }),\n },\n );\n const body = (await res.json().catch(() => null)) as {\n created?: boolean;\n call?: { id?: string };\n error?: { message?: string };\n } | null;\n const callId = body?.call?.id;\n if (!res.ok || !callId) {\n throw new Error(body?.error?.message ?? `Couldn't start the room (${res.status}).`);\n }\n // The core joins with its own request: it owns the session from here,\n // exactly as when the room is joined from the banner.\n await native.join(callId, conversationId, options.sessionToken);\n return { callId, created: body?.created === true };\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Play call audio through one of `snapshot.audioOutputs`. The choice\n * holds for the rest of the call, until that device disconnects or a new\n * headset / Bluetooth device connects (which takes over, as in the\n * phone's own dialer). Without a choice a connected headset wins, then\n * the earpiece for voice calls or the speaker for video.\n */\nexport function setAudioOutput(id: string): void {\n if (native?.setAudioOutput) native.setAudioOutput(id);\n else native?.setSpeaker(id === 'speaker'); // an older native build\n}\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── missed-call notifications (Android) ─────────────────────────────────\n\n/**\n * Android: the missed-call notification action the app was LAUNCHED\n * with, once — read it on mount. `null` when the app opened any other\n * way. The native core posts that notification itself from the push,\n * so it works with the app closed.\n *\n * iOS shows missed calls as an ordinary alert push (to the token you\n * register with `kind: 'alert'`); read its taps with your notification\n * library — its `type` is `call_missed`, the category\n * `poolse_missed_call`, the Call back action `call_back`.\n */\nexport function takeCallAction(): CallAction | null {\n return native?.takeCallAction?.() ?? null;\n}\n\n/** Android: a missed-call notification tapped while the app is running. */\nexport function onCallAction(cb: (action: CallAction) => void): Unsubscribe {\n if (!native || !native.takeCallAction) return () => undefined;\n const sub = native.addListener('onCallAction', cb);\n return () => sub.remove();\n}\n\n// ── chat notifications ─────────────────────────────────────────────────\n\nlet activeConversation: string | null = null;\n\n/**\n * Tell the core which conversation is on screen (`null` when none, and\n * when the app goes to the background). Its messages are not notified —\n * the user is reading them — and opening it clears the notifications\n * already shown for it.\n */\nexport function setActiveConversation(conversationId: string | null): void {\n activeConversation = conversationId;\n native?.setActiveConversation?.(conversationId);\n}\n\n/**\n * For the app's notification handler (expo-notifications'\n * `setNotificationHandler`): `false` for a chat notification of the\n * conversation on screen, which must not pop over the chat itself.\n * Android never asks — the core skips those natively.\n */\nexport function shouldPresentMessage(data: Record<string, unknown> | undefined | null): boolean {\n if (!data || data['type'] !== 'message') return true;\n return data['conversation_id'] !== activeConversation;\n}\n\n/**\n * The conversation a tapped notification belongs to — a chat message\n * (`type: 'message'`) or a missed call (`call_missed`) — from its data.\n * iOS hands the tap to your notification library; Android delivers it\n * through `takeCallAction` / `onCallAction` (type `open`).\n */\nexport function conversationOfNotification(\n data: Record<string, unknown> | undefined | null,\n): string | null {\n if (!data) return null;\n const type = data['type'];\n if (type !== 'message' && type !== 'call_missed') return null;\n return typeof data['conversation_id'] === 'string' ? data['conversation_id'] : null;\n}\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
|
|
@@ -98,6 +98,9 @@ public final class CallCoreRuntime {
|
|
|
98
98
|
/// Best name for the current call: the server's bundle, else the ring's. Runtime thread only.
|
|
99
99
|
public var conversationName: String? { bundle?.conversationName ?? ring?.conversationName }
|
|
100
100
|
|
|
101
|
+
/// `room` or `call`, once the server's bundle says; nil before. Runtime thread only.
|
|
102
|
+
public var callKind: String? { bundle?.kind }
|
|
103
|
+
|
|
101
104
|
/// True if `callId` was already resolved here — its ring must never surface again.
|
|
102
105
|
public func isResolved(_ callId: String) -> Bool { resolved.contains(callId) }
|
|
103
106
|
|
|
@@ -171,7 +171,8 @@ public final class CallHttpClient: HttpPort {
|
|
|
171
171
|
created: optBool(root, "created", fallback: true),
|
|
172
172
|
livekitUrl: try getString(livekit, "url"),
|
|
173
173
|
livekitToken: try getString(livekit, "token"),
|
|
174
|
-
controlToken: controlToken
|
|
174
|
+
controlToken: controlToken,
|
|
175
|
+
kind: optString(call, "kind") ?? current?.kind ?? "call"
|
|
175
176
|
)
|
|
176
177
|
}
|
|
177
178
|
|
package/ios/Core/Ports.swift
CHANGED
|
@@ -46,9 +46,12 @@ public struct CallSessionBundle: Equatable {
|
|
|
46
46
|
public var livekitUrl: String
|
|
47
47
|
public var livekitToken: String
|
|
48
48
|
public var controlToken: String
|
|
49
|
+
/// `room` (rings nobody, CONTRACT.md §5.6) or `call`.
|
|
50
|
+
public var kind: String
|
|
49
51
|
|
|
50
52
|
public init(callId: String, conversationId: String, conversationName: String?, media: String,
|
|
51
|
-
created: Bool, livekitUrl: String, livekitToken: String, controlToken: String
|
|
53
|
+
created: Bool, livekitUrl: String, livekitToken: String, controlToken: String,
|
|
54
|
+
kind: String = "call") {
|
|
52
55
|
self.callId = callId
|
|
53
56
|
self.conversationId = conversationId
|
|
54
57
|
self.conversationName = conversationName
|
|
@@ -57,6 +60,7 @@ public struct CallSessionBundle: Equatable {
|
|
|
57
60
|
self.livekitUrl = livekitUrl
|
|
58
61
|
self.livekitToken = livekitToken
|
|
59
62
|
self.controlToken = controlToken
|
|
63
|
+
self.kind = kind
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
|
|
@@ -66,6 +66,7 @@ public extension MachineState {
|
|
|
66
66
|
/// fields are merged in by the bridge from the media adapter.
|
|
67
67
|
/// `conversationName` nil → NSNull; `reason`/`cameraFacing` omitted when nil.
|
|
68
68
|
func toSnapshot(conversationName: String? = nil,
|
|
69
|
+
kind: String = "call",
|
|
69
70
|
participants: [SnapshotParticipant] = [],
|
|
70
71
|
audioRoute: String = "earpiece",
|
|
71
72
|
audioOutputs: [AudioOutput] = [],
|
|
@@ -79,6 +80,7 @@ public extension MachineState {
|
|
|
79
80
|
"callId": callId.map { $0 as Any } ?? NSNull(),
|
|
80
81
|
"conversationId": conversationId.map { $0 as Any } ?? NSNull(),
|
|
81
82
|
"conversationName": conversationName.map { $0 as Any } ?? NSNull(),
|
|
83
|
+
"kind": kind,
|
|
82
84
|
"direction": (direction ?? .incoming).wire,
|
|
83
85
|
"media": media,
|
|
84
86
|
"phase": phase.wire,
|
|
@@ -139,6 +139,7 @@ final class CallCore {
|
|
|
139
139
|
private var extrasMedia: String?
|
|
140
140
|
private var roster: [SnapshotParticipant] = []
|
|
141
141
|
private var conversationName: String?
|
|
142
|
+
private var callKind = "call"
|
|
142
143
|
private var mutedSelf = false
|
|
143
144
|
private var cameraOn = false
|
|
144
145
|
private var cameraFacing: String?
|
|
@@ -307,11 +308,14 @@ final class CallCore {
|
|
|
307
308
|
extrasMedia = state.media
|
|
308
309
|
}
|
|
309
310
|
if let name = runtime.conversationName { conversationName = name }
|
|
311
|
+
if let kind = runtime.callKind { callKind = kind }
|
|
310
312
|
|
|
311
313
|
let snap = state.toSnapshot(
|
|
312
314
|
conversationName: conversationName,
|
|
315
|
+
kind: callKind,
|
|
313
316
|
participants: roster,
|
|
314
|
-
|
|
317
|
+
// Video calls and rooms default to the speaker (the media port agrees).
|
|
318
|
+
audioRoute: audioRoute ?? (state.media == "video" || callKind == "room" ? "speaker" : "earpiece"),
|
|
315
319
|
audioOutputs: audioOutputs,
|
|
316
320
|
audioOutputId: audioOutputId,
|
|
317
321
|
mutedSelf: mutedSelf,
|
|
@@ -329,6 +333,7 @@ final class CallCore {
|
|
|
329
333
|
extrasMedia = media
|
|
330
334
|
roster = []
|
|
331
335
|
conversationName = nil
|
|
336
|
+
callKind = "call"
|
|
332
337
|
mutedSelf = false
|
|
333
338
|
cameraOn = false
|
|
334
339
|
cameraFacing = nil
|
|
@@ -66,6 +66,9 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
66
66
|
private(set) var room: Room?
|
|
67
67
|
private var observer: RoomObserver?
|
|
68
68
|
private var media = "audio"
|
|
69
|
+
/// Speaker unless a headset is in: a video call, or a room (a drop-in
|
|
70
|
+
/// room is held like a meeting, not to the ear).
|
|
71
|
+
private var loud = false
|
|
69
72
|
private var connectedRoom: ObjectIdentifier?
|
|
70
73
|
|
|
71
74
|
/// Where the user asked the audio to go, for the CURRENT call.
|
|
@@ -209,6 +212,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
209
212
|
detach()
|
|
210
213
|
media = bundle.media
|
|
211
214
|
let wantsVideo = bundle.media == "video"
|
|
215
|
+
loud = wantsVideo || bundle.kind == "room"
|
|
212
216
|
|
|
213
217
|
// A 1:1/small call: never let the SFU pause a subscribed camera for
|
|
214
218
|
// "not visible" — a view that mounts late would stay black.
|
|
@@ -398,7 +402,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
398
402
|
/// the same; forcing the speaker over AirPods was a bug).
|
|
399
403
|
private func resolvedChoice(_ s: AVAudioSession = .sharedInstance()) -> OutputChoice {
|
|
400
404
|
if let wanted = outputWanted { return wanted }
|
|
401
|
-
return
|
|
405
|
+
return loud && !externalConnected(s) ? .speaker : .privateRoute
|
|
402
406
|
}
|
|
403
407
|
|
|
404
408
|
private func applyRoute() {
|
|
@@ -444,7 +448,7 @@ final class LiveKitMediaPort: NSObject, MediaPort {
|
|
|
444
448
|
|
|
445
449
|
private func currentRoute() -> String {
|
|
446
450
|
guard let port = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType else {
|
|
447
|
-
return
|
|
451
|
+
return loud ? "speaker" : "earpiece"
|
|
448
452
|
}
|
|
449
453
|
return kind(of: port)
|
|
450
454
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poolse/react-native-calls",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Native call core for poolse — Kotlin/Swift calling engine with a thin React Native bridge. Ring, answer and carry audio with the app killed and the phone locked.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
"expo-module.config.json",
|
|
32
32
|
"README.md"
|
|
33
33
|
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsup",
|
|
36
|
+
"prepublishOnly": "tsup",
|
|
37
|
+
"typecheck": "tsc --noEmit"
|
|
38
|
+
},
|
|
34
39
|
"peerDependencies": {
|
|
35
40
|
"expo": "*",
|
|
36
41
|
"expo-notifications": "*",
|
|
@@ -40,9 +45,5 @@
|
|
|
40
45
|
"expo-modules-core": "*",
|
|
41
46
|
"tsup": "^8.3.5",
|
|
42
47
|
"typescript": "^5.6.0"
|
|
43
|
-
},
|
|
44
|
-
"scripts": {
|
|
45
|
-
"build": "tsup",
|
|
46
|
-
"typecheck": "tsc --noEmit"
|
|
47
48
|
}
|
|
48
|
-
}
|
|
49
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,12 @@ export interface Snapshot {
|
|
|
21
21
|
conversationId: string;
|
|
22
22
|
conversationName: string | null;
|
|
23
23
|
direction: 'incoming' | 'outgoing' | 'joined';
|
|
24
|
+
/**
|
|
25
|
+
* `room` — rings nobody, joined and left at will (`startRoom`); `call`
|
|
26
|
+
* otherwise. Known once the server answered: a room being joined reads
|
|
27
|
+
* `call` while `connecting`. Absent before 0.9.0: treat as `call`.
|
|
28
|
+
*/
|
|
29
|
+
kind?: 'call' | 'room';
|
|
24
30
|
media: 'audio' | 'video';
|
|
25
31
|
phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';
|
|
26
32
|
reason?:
|
|
@@ -144,8 +150,12 @@ const native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');
|
|
|
144
150
|
/** True when the native call core is present in this build. */
|
|
145
151
|
export const nativeCallsAvailable = native != null;
|
|
146
152
|
|
|
153
|
+
// Kept for startRoom, the one command that talks to the server from JS.
|
|
154
|
+
let apiOrigin: string | null = null;
|
|
155
|
+
|
|
147
156
|
/** Configure once, before any call can arrive. No secrets — just the API origin. */
|
|
148
157
|
export async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {
|
|
158
|
+
apiOrigin = apiBaseUrl.replace(/\/+$/, '');
|
|
149
159
|
await native?.configure(apiBaseUrl, options ?? null);
|
|
150
160
|
}
|
|
151
161
|
|
|
@@ -175,6 +185,57 @@ export function join(
|
|
|
175
185
|
return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();
|
|
176
186
|
}
|
|
177
187
|
|
|
188
|
+
export interface StartRoomResult {
|
|
189
|
+
/** The room's call id — what the Join banner and `call:*` events carry. */
|
|
190
|
+
callId: string;
|
|
191
|
+
/** False when something was live already (a room, or a ringing call) and this joined it. */
|
|
192
|
+
created: boolean;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Start the conversation's room — a call that rings nobody (CONTRACT.md
|
|
197
|
+
* §5.6) — or join whatever is live there, then carry it on the native
|
|
198
|
+
* core like any joined call: CallKit / the call service, so it keeps
|
|
199
|
+
* going with the phone locked, and the same audio routing as a call.
|
|
200
|
+
*
|
|
201
|
+
* Others in the conversation see it through the Join banner and join it
|
|
202
|
+
* with `join`. Leaving (`hangUp`) never ends it for them; it ends once
|
|
203
|
+
* it has been empty for a short grace. `media: 'video'` makes it a video
|
|
204
|
+
* room. Rejects when the server refuses (not a member, no SFU) or
|
|
205
|
+
* before `configure`.
|
|
206
|
+
*/
|
|
207
|
+
export async function startRoom(
|
|
208
|
+
conversationId: string,
|
|
209
|
+
options: PlaceOptions,
|
|
210
|
+
): Promise<StartRoomResult> {
|
|
211
|
+
if (!native) throw new Error('The native call core is not in this build.');
|
|
212
|
+
if (!apiOrigin) throw new Error('Call configure() before startRoom().');
|
|
213
|
+
const res = await fetch(
|
|
214
|
+
`${apiOrigin}/v1/conversations/${encodeURIComponent(conversationId)}/room`,
|
|
215
|
+
{
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: {
|
|
218
|
+
authorization: `Bearer ${options.sessionToken}`,
|
|
219
|
+
'content-type': 'application/json',
|
|
220
|
+
},
|
|
221
|
+
body: JSON.stringify({ media: options.media }),
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
const body = (await res.json().catch(() => null)) as {
|
|
225
|
+
created?: boolean;
|
|
226
|
+
call?: { id?: string };
|
|
227
|
+
error?: { message?: string };
|
|
228
|
+
} | null;
|
|
229
|
+
const callId = body?.call?.id;
|
|
230
|
+
if (!res.ok || !callId) {
|
|
231
|
+
throw new Error(body?.error?.message ?? `Couldn't start the room (${res.status}).`);
|
|
232
|
+
}
|
|
233
|
+
// The core joins with its own request: it owns the session from here,
|
|
234
|
+
// exactly as when the room is joined from the banner.
|
|
235
|
+
await native.join(callId, conversationId, options.sessionToken);
|
|
236
|
+
return { callId, created: body?.created === true };
|
|
237
|
+
}
|
|
238
|
+
|
|
178
239
|
export const answer = (): void => native?.answer();
|
|
179
240
|
export const decline = (): void => native?.decline();
|
|
180
241
|
export const hangUp = (): void => native?.hangUp();
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Poolse
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|