@poolse/react-native-calls 0.6.0 → 0.7.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 CHANGED
@@ -14,9 +14,11 @@ can render against it unconditionally.
14
14
  import * as calls from '@poolse/react-native-calls';
15
15
 
16
16
  await calls.configure('https://api.poolse.dev', { smallIcon: 'ic_call', ringback: 'ringback' });
17
- const stop = calls.onCallState((s) => render(s)); // s: Snapshot | null
17
+ const stop = calls.onCallState((s) => render(s)); // s: Snapshot | null
18
18
  await calls.place(conversationId, { media: 'audio', sessionToken });
19
- calls.answer(); calls.decline(); calls.hangUp();
19
+ calls.answer();
20
+ calls.decline();
21
+ calls.hangUp();
20
22
 
21
23
  // Audio output: s.audioOutputs lists the phone, the speaker and every
22
24
  // connected headset/Bluetooth device by name; s.audioOutputId is the live one.
@@ -24,7 +26,38 @@ calls.setAudioOutput(s.audioOutputs[0].id);
24
26
  calls.setSpeaker(true); // the one-tap toggle, still there
25
27
  ```
26
28
 
29
+ ### Chat notifications
30
+
31
+ poolse sends message notifications itself, with the push credentials you
32
+ set up for calls. Register the devices (Android: the FCM token; iOS: the
33
+ regular APNs token with `kind: 'alert'`, beside the VoIP one), and the
34
+ core does the rest — one notification per conversation with every
35
+ message a line in it, Reply and Mark as read straight from the
36
+ notification (no app, no session), cleared when the chat is read on
37
+ another device. Requires `expo-notifications` (a peer dependency).
38
+
39
+ ```ts
40
+ calls.setActiveConversation(conversationId); // on screen: don't notify it
41
+ calls.setActiveConversation(null); // left it, or app backgrounded
42
+
43
+ // iOS, in your expo-notifications handler:
44
+ Notifications.setNotificationHandler({
45
+ handleNotification: async (n) => {
46
+ const show = calls.shouldPresentMessage(n.request.content.data);
47
+ return {
48
+ shouldShowBanner: show,
49
+ shouldShowList: show,
50
+ shouldPlaySound: show,
51
+ shouldSetBadge: true,
52
+ };
53
+ },
54
+ });
55
+ // A tap opens the app: calls.conversationOfNotification(data) (iOS), or
56
+ // takeCallAction()/onCallAction() with type 'open' (Android).
57
+ ```
58
+
27
59
  ## Layout
60
+
28
61
  - `src/` — the TypeScript API (this is what apps import).
29
62
  - `android/src/main/java/dev/poolse/callcore/` — the pure state machine
30
63
  and ports (contract cv 1).
@@ -26,6 +26,10 @@
26
26
  android:name="dev.poolse.callcore.android.CallActionReceiver"
27
27
  android:exported="false" />
28
28
 
29
+ <receiver
30
+ android:name="dev.poolse.callcore.android.MessageActionReceiver"
31
+ android:exported="false" />
32
+
29
33
  <service
30
34
  android:name="dev.poolse.callcore.android.CallForegroundService"
31
35
  android:exported="false"
@@ -384,6 +384,12 @@ class CallCore private constructor(context: Context) {
384
384
  /** The live core if one exists — never creates (cheap reads, e.g. getCallState). */
385
385
  fun peek(): CallCore? = instance
386
386
 
387
+ /** The configured API origin (restored from disk on a cold start), or null. */
388
+ fun apiBaseUrl(context: Context): String? {
389
+ if (config == null) restoreConfig(context.applicationContext)
390
+ return config?.apiBaseUrl?.ifEmpty { null }
391
+ }
392
+
387
393
  /** The configured notification icon (restored from disk on a cold start). */
388
394
  fun smallIcon(context: Context): Int {
389
395
  if (config == null) restoreConfig(context.applicationContext)
@@ -35,6 +35,17 @@ class CallMessagingService : FirebaseMessagingService() {
35
35
  // type=call_event so old JS Android builds — which ring on ANY
36
36
  // type=call — ignore them. Everything else belongs to the host.
37
37
  val type = data["type"]
38
+ if (type == "message") {
39
+ // Chat notifications (§5.5): posted here so they appear with the
40
+ // app killed, grouped per conversation.
41
+ if (CallCore.getOrNull(this) == null) return
42
+ val conversationId = data["conversation_id"] ?: return
43
+ when (data["event"]) {
44
+ "new" -> MessageNotifications.onNew(this, data, CallCore.smallIcon(this))
45
+ "read" -> MessageNotifications.clear(this, conversationId)
46
+ }
47
+ return
48
+ }
38
49
  if (type != "call" && type != "call_event") {
39
50
  forwardToHost { it.onMessageReceived(message) }
40
51
  return
@@ -0,0 +1,340 @@
1
+ package dev.poolse.callcore.android
2
+
3
+ import android.app.NotificationChannel
4
+ import android.app.NotificationManager
5
+ import android.app.PendingIntent
6
+ import android.content.BroadcastReceiver
7
+ import android.content.Context
8
+ import android.content.Intent
9
+ import android.os.Build
10
+ import android.util.Log
11
+ import androidx.core.app.NotificationCompat
12
+ import androidx.core.app.NotificationManagerCompat
13
+ import androidx.core.app.Person
14
+ import androidx.core.app.RemoteInput
15
+ import okhttp3.MediaType.Companion.toMediaType
16
+ import okhttp3.Request
17
+ import okhttp3.RequestBody.Companion.toRequestBody
18
+ import org.json.JSONArray
19
+ import org.json.JSONObject
20
+ import java.util.UUID
21
+
22
+ /**
23
+ * Chat notifications on Android (CONTRACT.md §5.5), WhatsApp-style.
24
+ *
25
+ * One notification per conversation, in MessagingStyle: each new message
26
+ * becomes a line in it ("Ana: on my way") rather than a new card, and a
27
+ * group summary collects every chat. Reply (inline text) and Mark as
28
+ * read run natively, authorized by the reply grant the push carried, so
29
+ * they work with the app killed and the phone locked.
30
+ *
31
+ * Android cannot read back the lines of a posted notification, so the
32
+ * last few lines per conversation are kept in a small preferences file
33
+ * and the notification is rebuilt from them on every message. They are
34
+ * dropped the moment the conversation is read, here or elsewhere.
35
+ */
36
+ object MessageNotifications {
37
+ const val CHANNEL_ID = "poolse_messages"
38
+ private const val GROUP_KEY = "dev.poolse.messages"
39
+ private const val SUMMARY_ID = 0x504F4F4C // "POOL"
40
+ private const val PREFS = "dev.poolse.messages"
41
+ private const val MAX_LINES = 7
42
+ internal const val KEY_REPLY = "poolse_reply_text"
43
+ internal const val ACTION_REPLY = "dev.poolse.callcore.message.REPLY"
44
+ internal const val ACTION_READ = "dev.poolse.callcore.message.READ"
45
+ internal const val EXTRA_CONVERSATION = "poolse_message_conversation"
46
+ private const val TAG = "PoolseMessages"
47
+
48
+ // A message notification that waited this long for the phone to come
49
+ // online is history.
50
+ private const val STALE_MS = 24 * 60 * 60 * 1000L
51
+
52
+ /**
53
+ * The conversation on screen in the app, set by the host. Its messages
54
+ * are not notified — the user is reading them.
55
+ */
56
+ @Volatile var activeConversationId: String? = null
57
+
58
+ /** A `type: "message", event: "new"` push. */
59
+ fun onNew(context: Context, data: Map<String, String>, smallIcon: Int) {
60
+ val conversationId = data["conversation_id"] ?: return
61
+ if (conversationId == activeConversationId) return
62
+ val sentAt = data["sent_at"]?.toLongOrNull() ?: System.currentTimeMillis()
63
+ if (System.currentTimeMillis() - sentAt > STALE_MS) return
64
+
65
+ val state = load(context, conversationId) ?: Conversation(conversationId)
66
+ state.title = data["conversation_name"]?.ifBlank { null } ?: state.title
67
+ state.group = data["group"] == "1"
68
+ data["reply_grant"]?.ifBlank { null }?.let { state.grant = it }
69
+ val messageId = data["message_id"]
70
+ if (messageId == null || state.lines.none { it.id == messageId }) {
71
+ state.lines += Line(
72
+ id = messageId,
73
+ sender = data["sender_name"]?.ifBlank { null } ?: "Someone",
74
+ senderKey = data["sender_user_id"],
75
+ text = data["text"] ?: "",
76
+ at = sentAt,
77
+ self = false,
78
+ )
79
+ while (state.lines.size > MAX_LINES) state.lines.removeAt(0)
80
+ }
81
+ save(context, state)
82
+ post(context, state, smallIcon, alert = true)
83
+ }
84
+
85
+ /** The conversation was read (here, or on another device): clear it. */
86
+ fun clear(context: Context, conversationId: String) {
87
+ prefs(context).edit().remove(key(conversationId)).apply()
88
+ runCatching { NotificationManagerCompat.from(context).cancel(notificationId(conversationId)) }
89
+ refreshSummary(context, CallCore.smallIcon(context))
90
+ }
91
+
92
+ /** The reply went through: show it as the user's line and stop the spinner. */
93
+ internal fun onReplied(context: Context, conversationId: String, text: String, ok: Boolean) {
94
+ val state = load(context, conversationId) ?: return
95
+ state.lines += Line(
96
+ id = null,
97
+ sender = "You",
98
+ senderKey = null,
99
+ text = if (ok) text else "Not sent: $text",
100
+ at = System.currentTimeMillis(),
101
+ self = true,
102
+ )
103
+ while (state.lines.size > MAX_LINES) state.lines.removeAt(0)
104
+ save(context, state)
105
+ post(context, state, CallCore.smallIcon(context), alert = false)
106
+ }
107
+
108
+ internal fun grantFor(context: Context, conversationId: String): String? =
109
+ load(context, conversationId)?.grant
110
+
111
+ // ── building ────────────────────────────────────────────────────────
112
+
113
+ private fun post(context: Context, state: Conversation, smallIcon: Int, alert: Boolean) {
114
+ ensureChannel(context)
115
+ val me = Person.Builder().setName("You").build()
116
+ val style = NotificationCompat.MessagingStyle(me)
117
+ .setGroupConversation(state.group)
118
+ if (state.group) style.conversationTitle = state.title
119
+ for (line in state.lines) {
120
+ val person = if (line.self) null else Person.Builder().setName(line.sender).setKey(line.senderKey).build()
121
+ style.addMessage(NotificationCompat.MessagingStyle.Message(line.text, line.at, person))
122
+ }
123
+
124
+ val id = notificationId(state.id)
125
+ val builder = NotificationCompat.Builder(context, CHANNEL_ID)
126
+ .setSmallIcon(smallIcon)
127
+ .setStyle(style)
128
+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
129
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
130
+ .setGroup(GROUP_KEY)
131
+ .setAutoCancel(true)
132
+ .setOnlyAlertOnce(!alert)
133
+ .setWhen(state.lines.lastOrNull()?.at ?: System.currentTimeMillis())
134
+ .setNumber(state.lines.count { !it.self })
135
+ openIntent(context, state.id, id)?.let(builder::setContentIntent)
136
+
137
+ if (state.grant != null) {
138
+ val remoteInput = RemoteInput.Builder(KEY_REPLY).setLabel("Message").build()
139
+ val reply = NotificationCompat.Action.Builder(0, "Reply", actionIntent(context, ACTION_REPLY, state.id, id + 1, mutable = true))
140
+ .addRemoteInput(remoteInput)
141
+ .setAllowGeneratedReplies(true)
142
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
143
+ .setShowsUserInterface(false)
144
+ .build()
145
+ val read = NotificationCompat.Action.Builder(0, "Mark as read", actionIntent(context, ACTION_READ, state.id, id + 2, mutable = false))
146
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
147
+ .setShowsUserInterface(false)
148
+ .build()
149
+ builder.addAction(reply).addAction(read)
150
+ }
151
+
152
+ try {
153
+ NotificationManagerCompat.from(context).notify(id, builder.build())
154
+ } catch (e: SecurityException) {
155
+ Log.w(TAG, "message notification refused (POST_NOTIFICATIONS?)", e)
156
+ return
157
+ }
158
+ refreshSummary(context, smallIcon)
159
+ }
160
+
161
+ /** The group's summary: "5 messages from 2 chats". Gone with the last chat. */
162
+ private fun refreshSummary(context: Context, smallIcon: Int) {
163
+ val all = prefs(context).all.keys.filter { it.startsWith("conv:") }
164
+ .mapNotNull { load(context, it.removePrefix("conv:")) }
165
+ val manager = NotificationManagerCompat.from(context)
166
+ if (all.isEmpty()) {
167
+ runCatching { manager.cancel(SUMMARY_ID) }
168
+ return
169
+ }
170
+ val messages = all.sumOf { c -> c.lines.count { !it.self } }
171
+ val text = if (all.size == 1) "$messages new messages" else "$messages messages from ${all.size} chats"
172
+ val summary = NotificationCompat.Builder(context, CHANNEL_ID)
173
+ .setSmallIcon(smallIcon)
174
+ .setContentTitle(text)
175
+ .setStyle(NotificationCompat.InboxStyle().setSummaryText(text))
176
+ .setGroup(GROUP_KEY)
177
+ .setGroupSummary(true)
178
+ .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
179
+ .setAutoCancel(true)
180
+ .build()
181
+ runCatching { manager.notify(SUMMARY_ID, summary) }
182
+ }
183
+
184
+ private fun openIntent(context: Context, conversationId: String, requestCode: Int): PendingIntent? {
185
+ val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null
186
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
187
+ .putExtra(MissedCallNotification.EXTRA_ACTION, MissedCallNotification.ACTION_OPEN)
188
+ .putExtra(MissedCallNotification.EXTRA_CONVERSATION_ID, conversationId)
189
+ return PendingIntent.getActivity(
190
+ context,
191
+ requestCode,
192
+ intent,
193
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
194
+ )
195
+ }
196
+
197
+ private fun actionIntent(context: Context, action: String, conversationId: String, requestCode: Int, mutable: Boolean): PendingIntent {
198
+ val intent = Intent(context, MessageActionReceiver::class.java)
199
+ .setAction(action)
200
+ .putExtra(EXTRA_CONVERSATION, conversationId)
201
+ // RemoteInput writes the typed text into the intent: it must be mutable.
202
+ val mutability = if (mutable && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
203
+ PendingIntent.FLAG_MUTABLE
204
+ } else {
205
+ PendingIntent.FLAG_IMMUTABLE
206
+ }
207
+ return PendingIntent.getBroadcast(context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT or mutability)
208
+ }
209
+
210
+ private fun ensureChannel(context: Context) {
211
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
212
+ val manager = context.getSystemService(NotificationManager::class.java)
213
+ if (manager.getNotificationChannel(CHANNEL_ID) != null) return
214
+ manager.createNotificationChannel(
215
+ NotificationChannel(CHANNEL_ID, "Messages", NotificationManager.IMPORTANCE_HIGH).apply {
216
+ lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
217
+ },
218
+ )
219
+ }
220
+
221
+ private fun notificationId(conversationId: String) = ("msg:$conversationId").hashCode()
222
+
223
+ // ── storage ─────────────────────────────────────────────────────────
224
+
225
+ internal data class Line(
226
+ val id: String?,
227
+ val sender: String,
228
+ val senderKey: String?,
229
+ val text: String,
230
+ val at: Long,
231
+ val self: Boolean,
232
+ )
233
+
234
+ internal class Conversation(
235
+ val id: String,
236
+ var title: String? = null,
237
+ var group: Boolean = false,
238
+ var grant: String? = null,
239
+ val lines: MutableList<Line> = mutableListOf(),
240
+ )
241
+
242
+ private fun prefs(context: Context) =
243
+ context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
244
+
245
+ private fun key(conversationId: String) = "conv:$conversationId"
246
+
247
+ private fun load(context: Context, conversationId: String): Conversation? {
248
+ val raw = prefs(context).getString(key(conversationId), null) ?: return null
249
+ return runCatching {
250
+ val o = JSONObject(raw)
251
+ val lines = o.optJSONArray("lines") ?: JSONArray()
252
+ Conversation(
253
+ id = conversationId,
254
+ title = o.optString("title").ifBlank { null },
255
+ group = o.optBoolean("group"),
256
+ grant = o.optString("grant").ifBlank { null },
257
+ lines = MutableList(lines.length()) { i ->
258
+ val l = lines.getJSONObject(i)
259
+ Line(
260
+ id = l.optString("id").ifBlank { null },
261
+ sender = l.optString("sender"),
262
+ senderKey = l.optString("key").ifBlank { null },
263
+ text = l.optString("text"),
264
+ at = l.optLong("at"),
265
+ self = l.optBoolean("self"),
266
+ )
267
+ },
268
+ )
269
+ }.getOrNull()
270
+ }
271
+
272
+ private fun save(context: Context, c: Conversation) {
273
+ val lines = JSONArray()
274
+ for (l in c.lines) {
275
+ lines.put(
276
+ JSONObject()
277
+ .put("id", l.id ?: "")
278
+ .put("sender", l.sender)
279
+ .put("key", l.senderKey ?: "")
280
+ .put("text", l.text)
281
+ .put("at", l.at)
282
+ .put("self", l.self),
283
+ )
284
+ }
285
+ val o = JSONObject()
286
+ .put("title", c.title ?: "")
287
+ .put("group", c.group)
288
+ .put("grant", c.grant ?: "")
289
+ .put("lines", lines)
290
+ prefs(context).edit().putString(key(c.id), o.toString()).apply()
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Reply / Mark as read, tapped on a message notification. Posts to the
296
+ * notification-action endpoints with the conversation's reply grant —
297
+ * no app, no session — on a background thread kept alive by goAsync().
298
+ */
299
+ class MessageActionReceiver : BroadcastReceiver() {
300
+ override fun onReceive(context: Context, intent: Intent) {
301
+ val conversationId = intent.getStringExtra(MessageNotifications.EXTRA_CONVERSATION) ?: return
302
+ val app = context.applicationContext
303
+ val grant = MessageNotifications.grantFor(app, conversationId)
304
+ val base = CallCore.apiBaseUrl(app)
305
+
306
+ when (intent.action) {
307
+ MessageNotifications.ACTION_REPLY -> {
308
+ val text = RemoteInput.getResultsFromIntent(intent)
309
+ ?.getCharSequence(MessageNotifications.KEY_REPLY)?.toString()?.trim().orEmpty()
310
+ if (text.isEmpty() || grant == null || base == null) return
311
+ val pending = goAsync()
312
+ Thread {
313
+ val body = JSONObject().put("body", text).put("id", UUID.randomUUID().toString())
314
+ val ok = post("$base/v1/notifications/reply", grant, body)
315
+ MessageNotifications.onReplied(app, conversationId, text, ok)
316
+ pending.finish()
317
+ }.start()
318
+ }
319
+ MessageNotifications.ACTION_READ -> {
320
+ // Optimistic: the notification goes now; the server catches up.
321
+ MessageNotifications.clear(app, conversationId)
322
+ if (grant == null || base == null) return
323
+ val pending = goAsync()
324
+ Thread {
325
+ post("$base/v1/notifications/read", grant, JSONObject())
326
+ pending.finish()
327
+ }.start()
328
+ }
329
+ }
330
+ }
331
+
332
+ private fun post(url: String, grant: String, body: JSONObject): Boolean = runCatching {
333
+ val request = Request.Builder()
334
+ .url(url)
335
+ .header("Authorization", "Bearer $grant")
336
+ .post(body.toString().toRequestBody("application/json".toMediaType()))
337
+ .build()
338
+ CallHttpClient.defaultClient().newCall(request).execute().use { it.isSuccessful }
339
+ }.onFailure { Log.w("PoolseMessages", "notification action failed", it) }.getOrDefault(false)
340
+ }
@@ -8,6 +8,7 @@ import android.os.Build
8
8
  import android.provider.Settings
9
9
  import android.util.Log
10
10
  import dev.poolse.callcore.android.CallCore
11
+ import dev.poolse.callcore.android.MessageNotifications
11
12
  import dev.poolse.callcore.android.MissedCallNotification
12
13
  import expo.modules.kotlin.modules.Module
13
14
  import expo.modules.kotlin.modules.ModuleDefinition
@@ -102,6 +103,13 @@ class PoolseCallsModule : Module() {
102
103
  consumeCallAction(intent)?.let { sendEvent("onCallAction", it) }
103
104
  }
104
105
 
106
+ // The chat on screen: its messages are not notified, and opening it
107
+ // clears what was already shown for it.
108
+ Function("setActiveConversation") { conversationId: String? ->
109
+ MessageNotifications.activeConversationId = conversationId
110
+ if (conversationId != null) context?.let { MessageNotifications.clear(it, conversationId) }
111
+ }
112
+
105
113
  // Android 14+: full-screen intents need a user grant for non-dialer apps.
106
114
  Function("canUseFullScreenIntent") {
107
115
  val ctx = context ?: return@Function true
package/dist/index.cjs CHANGED
@@ -47,6 +47,21 @@ function onCallAction(cb) {
47
47
  const sub = native.addListener("onCallAction", cb);
48
48
  return () => sub.remove();
49
49
  }
50
+ var activeConversation = null;
51
+ function setActiveConversation(conversationId) {
52
+ activeConversation = conversationId;
53
+ native?.setActiveConversation?.(conversationId);
54
+ }
55
+ function shouldPresentMessage(data) {
56
+ if (!data || data["type"] !== "message") return true;
57
+ return data["conversation_id"] !== activeConversation;
58
+ }
59
+ function conversationOfNotification(data) {
60
+ if (!data) return null;
61
+ const type = data["type"];
62
+ if (type !== "message" && type !== "call_missed") return null;
63
+ return typeof data["conversation_id"] === "string" ? data["conversation_id"] : null;
64
+ }
50
65
  function getVoipToken() {
51
66
  return native?.getVoipToken?.() ?? null;
52
67
  }
@@ -81,6 +96,7 @@ exports.CallVideoView = CallVideoView;
81
96
  exports.answer = answer;
82
97
  exports.canUseFullScreenIntent = canUseFullScreenIntent;
83
98
  exports.configure = configure;
99
+ exports.conversationOfNotification = conversationOfNotification;
84
100
  exports.decline = decline;
85
101
  exports.getCallState = getCallState;
86
102
  exports.getVoipToken = getVoipToken;
@@ -94,10 +110,12 @@ exports.onRingEnded = onRingEnded;
94
110
  exports.onVoipToken = onVoipToken;
95
111
  exports.openFullScreenIntentSettings = openFullScreenIntentSettings;
96
112
  exports.place = place;
113
+ exports.setActiveConversation = setActiveConversation;
97
114
  exports.setAudioOutput = setAudioOutput;
98
115
  exports.setCamera = setCamera;
99
116
  exports.setMuted = setMuted;
100
117
  exports.setSpeaker = setSpeaker;
118
+ exports.shouldPresentMessage = shouldPresentMessage;
101
119
  exports.switchCamera = switchCamera;
102
120
  exports.takeCallAction = takeCallAction;
103
121
  //# sourceMappingURL=index.cjs.map
@@ -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 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// ── 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 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"]}
package/dist/index.d.cts CHANGED
@@ -148,6 +148,27 @@ declare const openFullScreenIntentSettings: () => void;
148
148
  declare function takeCallAction(): CallAction | null;
149
149
  /** Android: a missed-call notification tapped while the app is running. */
150
150
  declare function onCallAction(cb: (action: CallAction) => void): Unsubscribe;
151
+ /**
152
+ * Tell the core which conversation is on screen (`null` when none, and
153
+ * when the app goes to the background). Its messages are not notified —
154
+ * the user is reading them — and opening it clears the notifications
155
+ * already shown for it.
156
+ */
157
+ declare function setActiveConversation(conversationId: string | null): void;
158
+ /**
159
+ * For the app's notification handler (expo-notifications'
160
+ * `setNotificationHandler`): `false` for a chat notification of the
161
+ * conversation on screen, which must not pop over the chat itself.
162
+ * Android never asks — the core skips those natively.
163
+ */
164
+ declare function shouldPresentMessage(data: Record<string, unknown> | undefined | null): boolean;
165
+ /**
166
+ * The conversation a tapped notification belongs to — a chat message
167
+ * (`type: 'message'`) or a missed call (`call_missed`) — from its data.
168
+ * iOS hands the tap to your notification library; Android delivers it
169
+ * through `takeCallAction` / `onCallAction` (type `open`).
170
+ */
171
+ declare function conversationOfNotification(data: Record<string, unknown> | undefined | null): string | null;
151
172
  /**
152
173
  * iOS: the current PushKit VoIP token, registered natively at launch.
153
174
  * `null` until iOS issues one, and always on Android (FCM carries rings).
@@ -172,4 +193,4 @@ interface CallVideoViewProps extends ViewProps {
172
193
  */
173
194
  declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
174
195
 
175
- export { type AudioOutput, type AudioOutputKind, type CallAction, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setAudioOutput, setCamera, setMuted, setSpeaker, switchCamera, takeCallAction };
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 };
package/dist/index.d.ts CHANGED
@@ -148,6 +148,27 @@ declare const openFullScreenIntentSettings: () => void;
148
148
  declare function takeCallAction(): CallAction | null;
149
149
  /** Android: a missed-call notification tapped while the app is running. */
150
150
  declare function onCallAction(cb: (action: CallAction) => void): Unsubscribe;
151
+ /**
152
+ * Tell the core which conversation is on screen (`null` when none, and
153
+ * when the app goes to the background). Its messages are not notified —
154
+ * the user is reading them — and opening it clears the notifications
155
+ * already shown for it.
156
+ */
157
+ declare function setActiveConversation(conversationId: string | null): void;
158
+ /**
159
+ * For the app's notification handler (expo-notifications'
160
+ * `setNotificationHandler`): `false` for a chat notification of the
161
+ * conversation on screen, which must not pop over the chat itself.
162
+ * Android never asks — the core skips those natively.
163
+ */
164
+ declare function shouldPresentMessage(data: Record<string, unknown> | undefined | null): boolean;
165
+ /**
166
+ * The conversation a tapped notification belongs to — a chat message
167
+ * (`type: 'message'`) or a missed call (`call_missed`) — from its data.
168
+ * iOS hands the tap to your notification library; Android delivers it
169
+ * through `takeCallAction` / `onCallAction` (type `open`).
170
+ */
171
+ declare function conversationOfNotification(data: Record<string, unknown> | undefined | null): string | null;
151
172
  /**
152
173
  * iOS: the current PushKit VoIP token, registered natively at launch.
153
174
  * `null` until iOS issues one, and always on Android (FCM carries rings).
@@ -172,4 +193,4 @@ interface CallVideoViewProps extends ViewProps {
172
193
  */
173
194
  declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
174
195
 
175
- export { type AudioOutput, type AudioOutputKind, type CallAction, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setAudioOutput, setCamera, setMuted, setSpeaker, switchCamera, takeCallAction };
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 };
package/dist/index.js CHANGED
@@ -45,6 +45,21 @@ function onCallAction(cb) {
45
45
  const sub = native.addListener("onCallAction", cb);
46
46
  return () => sub.remove();
47
47
  }
48
+ var activeConversation = null;
49
+ function setActiveConversation(conversationId) {
50
+ activeConversation = conversationId;
51
+ native?.setActiveConversation?.(conversationId);
52
+ }
53
+ function shouldPresentMessage(data) {
54
+ if (!data || data["type"] !== "message") return true;
55
+ return data["conversation_id"] !== activeConversation;
56
+ }
57
+ function conversationOfNotification(data) {
58
+ if (!data) return null;
59
+ const type = data["type"];
60
+ if (type !== "message" && type !== "call_missed") return null;
61
+ return typeof data["conversation_id"] === "string" ? data["conversation_id"] : null;
62
+ }
48
63
  function getVoipToken() {
49
64
  return native?.getVoipToken?.() ?? null;
50
65
  }
@@ -75,6 +90,6 @@ function CallVideoView(props) {
75
90
  return createElement(Native, props);
76
91
  }
77
92
 
78
- export { CallVideoView, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setAudioOutput, setCamera, setMuted, setSpeaker, switchCamera, takeCallAction };
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 };
79
94
  //# sourceMappingURL=index.js.map
80
95
  //# 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 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// ── 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 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"]}
@@ -72,6 +72,13 @@ public class PoolseCallsModule: Module {
72
72
  Function("setSpeaker") { (on: Bool) in onCore { $0.setSpeaker(on) } }
73
73
  Function("setAudioOutput") { (id: String) in onCore { $0.setAudioOutput(id) } }
74
74
 
75
+ // The chat on screen: opening it clears what was already shown for it.
76
+ // (Suppressing new ones while it is open happens in the app's
77
+ // notification handler — see shouldPresentMessage in src/index.ts.)
78
+ Function("setActiveConversation") { (conversationId: String?) in
79
+ if let conversationId = conversationId { MessageNotifications.clear(conversationId: conversationId) }
80
+ }
81
+
75
82
  // Socket forwarding (open app): idempotent with the push path.
76
83
  Function("onRingEnded") { (callId: String, reason: String) in
77
84
  onCore { $0.onRingEnded(callId: callId, reason: reason) }
@@ -531,5 +531,6 @@ public final class PoolseCallsSetup: NSObject {
531
531
  _ = CallKitController.shared
532
532
  _ = CallCore.getOrNull()
533
533
  VoipPush.shared.register()
534
+ MessageNotifications.shared.install()
534
535
  }
535
536
  }
@@ -0,0 +1,163 @@
1
+ import EXNotifications
2
+ import Foundation
3
+ import UIKit
4
+ import UserNotifications
5
+
6
+ /// Chat notifications on iOS (CONTRACT.md §5.5) — the parts that must work
7
+ /// with the app not running: Reply and Mark as read from the notification,
8
+ /// and clearing a conversation's notifications once it was read elsewhere.
9
+ ///
10
+ /// iOS itself shows the message (an alert push with `thread-id` =
11
+ /// conversation, so a chat's messages stack together). This class only
12
+ /// acts on them. It plugs into expo-notifications rather than taking over
13
+ /// the notification-center delegate: expo-notifications owns that delegate
14
+ /// in an Expo app, and fanning out to registered delegates is its
15
+ /// supported extension point — taking it would break every other
16
+ /// notification feature in the app.
17
+ ///
18
+ /// Reply and Mark as read run natively, authorized by the reply grant the
19
+ /// push carried, so they work from the lock screen with the app killed.
20
+ final class MessageNotifications: NSObject, NotificationDelegate {
21
+ static let shared = MessageNotifications()
22
+
23
+ static let category = "poolse_message"
24
+ static let replyAction = "reply"
25
+ static let markReadAction = "mark_read"
26
+
27
+ private var installed = false
28
+
29
+ /// Main thread, at launch (PoolseCallsSetup.registerVoipEarly).
30
+ func install() {
31
+ guard !installed else { return }
32
+ installed = true
33
+ registerCategory()
34
+ NotificationCenterManager.shared.addDelegate(self)
35
+ }
36
+
37
+ /// Add the message category beside whatever the app (or expo) declared:
38
+ /// setNotificationCategories REPLACES the whole set.
39
+ private func registerCategory() {
40
+ let reply = UNTextInputNotificationAction(
41
+ identifier: Self.replyAction,
42
+ title: "Reply",
43
+ options: [],
44
+ textInputButtonTitle: "Send",
45
+ textInputPlaceholder: "Message"
46
+ )
47
+ let markRead = UNNotificationAction(identifier: Self.markReadAction, title: "Mark as read", options: [])
48
+ let category = UNNotificationCategory(
49
+ identifier: Self.category,
50
+ actions: [reply, markRead],
51
+ intentIdentifiers: [],
52
+ hiddenPreviewsBodyPlaceholder: "New message",
53
+ categorySummaryFormat: "%u more messages from %@",
54
+ options: []
55
+ )
56
+ let center = UNUserNotificationCenter.current()
57
+ center.getNotificationCategories { existing in
58
+ var all = existing.filter { $0.identifier != Self.category }
59
+ all.insert(category)
60
+ center.setNotificationCategories(all)
61
+ }
62
+ }
63
+
64
+ // MARK: - NotificationDelegate (expo-notifications)
65
+
66
+ func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool {
67
+ let content = response.notification.request.content
68
+ guard content.categoryIdentifier == Self.category,
69
+ let grant = content.userInfo["reply_grant"] as? String, !grant.isEmpty else { return false }
70
+ let conversationId = content.userInfo["conversation_id"] as? String
71
+
72
+ switch response.actionIdentifier {
73
+ case Self.replyAction:
74
+ let text = (response as? UNTextInputNotificationResponse)?.userText
75
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
76
+ guard !text.isEmpty else { return true }
77
+ post(path: "/v1/notifications/reply", grant: grant,
78
+ body: ["body": text, "id": UUID().uuidString.lowercased()]) { ok in
79
+ if ok, let conversationId = conversationId { Self.clear(conversationId: conversationId) }
80
+ }
81
+ return true
82
+ case Self.markReadAction:
83
+ if let conversationId = conversationId { Self.clear(conversationId: conversationId) }
84
+ post(path: "/v1/notifications/read", grant: grant, body: [:]) { _ in }
85
+ return true
86
+ default:
87
+ // A plain tap opens the app; the host routes it to the conversation.
88
+ return false
89
+ }
90
+ }
91
+
92
+ /// A background push: the conversation was read on another device.
93
+ func didReceive(_ userInfo: [AnyHashable: Any],
94
+ completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool {
95
+ guard userInfo["type"] as? String == "message_read",
96
+ let conversationId = userInfo["conversation_id"] as? String else { return false }
97
+ Self.clear(conversationId: conversationId)
98
+ if let unread = (userInfo["unread_total"] as? NSNumber)?.intValue ?? Int(userInfo["unread_total"] as? String ?? "") {
99
+ Self.setBadge(unread)
100
+ }
101
+ completionHandler(.newData)
102
+ return true
103
+ }
104
+
105
+ // MARK: - helpers
106
+
107
+ /// Remove every delivered notification of one conversation.
108
+ static func clear(conversationId: String) {
109
+ let center = UNUserNotificationCenter.current()
110
+ center.getDeliveredNotifications { delivered in
111
+ let ids = delivered
112
+ .filter { $0.request.content.threadIdentifier == conversationId ||
113
+ ($0.request.content.userInfo["conversation_id"] as? String) == conversationId }
114
+ .map { $0.request.identifier }
115
+ if !ids.isEmpty { center.removeDeliveredNotifications(withIdentifiers: ids) }
116
+ }
117
+ }
118
+
119
+ static func setBadge(_ count: Int) {
120
+ if #available(iOS 16.0, *) {
121
+ UNUserNotificationCenter.current().setBadgeCount(max(count, 0))
122
+ } else {
123
+ DispatchQueue.main.async { UIApplication.shared.applicationIconBadgeNumber = max(count, 0) }
124
+ }
125
+ }
126
+
127
+ /// A grant-authorized POST that survives the app being suspended: the
128
+ /// action may have launched the app in the background just for this.
129
+ private func post(path: String, grant: String, body: [String: Any], done: @escaping (Bool) -> Void) {
130
+ guard let base = CallCore.config?.apiBaseUrl, let url = URL(string: base + path) else {
131
+ done(false)
132
+ return
133
+ }
134
+ var request = URLRequest(url: url)
135
+ request.httpMethod = "POST"
136
+ request.setValue("Bearer \(grant)", forHTTPHeaderField: "Authorization")
137
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
138
+ request.httpBody = try? JSONSerialization.data(withJSONObject: body)
139
+ request.timeoutInterval = 20
140
+
141
+ var task: UIBackgroundTaskIdentifier = .invalid
142
+ let finish: (Bool) -> Void = { ok in
143
+ done(ok)
144
+ DispatchQueue.main.async {
145
+ if task != .invalid { UIApplication.shared.endBackgroundTask(task) }
146
+ task = .invalid
147
+ }
148
+ }
149
+ DispatchQueue.main.async {
150
+ task = UIApplication.shared.beginBackgroundTask(withName: "poolse-notification-action") {
151
+ if task != .invalid { UIApplication.shared.endBackgroundTask(task) }
152
+ task = .invalid
153
+ }
154
+ URLSession.shared.dataTask(with: request) { _, response, error in
155
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
156
+ if error != nil || !(200..<300).contains(status) {
157
+ pcTrace("notification action \(path) failed status=\(status) error=\(String(describing: error))")
158
+ }
159
+ finish(error == nil && (200..<300).contains(status))
160
+ }.resume()
161
+ }
162
+ }
163
+ }
@@ -53,12 +53,16 @@ Pod::Spec.new do |s|
53
53
  # nanopb headers use <pb.h> angled includes that do not resolve when
54
54
  # CocoaPods builds the pod as a static library (Expo's default).
55
55
  s.dependency 'LiveKitClient', '= 2.15.1'
56
+ # Chat notifications plug into expo-notifications' notification-center
57
+ # delegate fan-out (MessageNotifications.swift) instead of replacing the
58
+ # delegate, which would break the app's own notifications.
59
+ s.dependency 'EXNotifications'
56
60
 
57
61
  s.pod_target_xcconfig = {
58
62
  'DEFINES_MODULE' => 'YES',
59
63
  'SWIFT_COMPILATION_MODE' => 'wholemodule',
60
64
  }
61
65
 
62
- s.frameworks = 'CallKit', 'PushKit', 'AVFoundation', 'UIKit'
66
+ s.frameworks = 'CallKit', 'PushKit', 'AVFoundation', 'UIKit', 'UserNotifications'
63
67
  s.source_files = 'Core/**/*.swift', 'Platform/**/*.swift', 'Module/**/*.swift'
64
68
  end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poolse/react-native-calls",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -33,6 +33,7 @@
33
33
  ],
34
34
  "peerDependencies": {
35
35
  "expo": "*",
36
+ "expo-notifications": "*",
36
37
  "react-native": "*"
37
38
  },
38
39
  "devDependencies": {
package/src/index.ts CHANGED
@@ -133,6 +133,7 @@ interface PoolseCallsNative {
133
133
  openFullScreenIntentSettings(): void;
134
134
  getVoipToken?(): VoipToken | null;
135
135
  takeCallAction?(): CallAction | null;
136
+ setActiveConversation?(conversationId: string | null): void;
136
137
  addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };
137
138
  addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };
138
139
  addListener(event: 'onCallAction', cb: (payload: CallAction) => void): { remove(): void };
@@ -244,6 +245,47 @@ export function onCallAction(cb: (action: CallAction) => void): Unsubscribe {
244
245
  return () => sub.remove();
245
246
  }
246
247
 
248
+ // ── chat notifications ─────────────────────────────────────────────────
249
+
250
+ let activeConversation: string | null = null;
251
+
252
+ /**
253
+ * Tell the core which conversation is on screen (`null` when none, and
254
+ * when the app goes to the background). Its messages are not notified —
255
+ * the user is reading them — and opening it clears the notifications
256
+ * already shown for it.
257
+ */
258
+ export function setActiveConversation(conversationId: string | null): void {
259
+ activeConversation = conversationId;
260
+ native?.setActiveConversation?.(conversationId);
261
+ }
262
+
263
+ /**
264
+ * For the app's notification handler (expo-notifications'
265
+ * `setNotificationHandler`): `false` for a chat notification of the
266
+ * conversation on screen, which must not pop over the chat itself.
267
+ * Android never asks — the core skips those natively.
268
+ */
269
+ export function shouldPresentMessage(data: Record<string, unknown> | undefined | null): boolean {
270
+ if (!data || data['type'] !== 'message') return true;
271
+ return data['conversation_id'] !== activeConversation;
272
+ }
273
+
274
+ /**
275
+ * The conversation a tapped notification belongs to — a chat message
276
+ * (`type: 'message'`) or a missed call (`call_missed`) — from its data.
277
+ * iOS hands the tap to your notification library; Android delivers it
278
+ * through `takeCallAction` / `onCallAction` (type `open`).
279
+ */
280
+ export function conversationOfNotification(
281
+ data: Record<string, unknown> | undefined | null,
282
+ ): string | null {
283
+ if (!data) return null;
284
+ const type = data['type'];
285
+ if (type !== 'message' && type !== 'call_missed') return null;
286
+ return typeof data['conversation_id'] === 'string' ? data['conversation_id'] : null;
287
+ }
288
+
247
289
  // ── VoIP push (iOS) ───────────────────────────────────────────────────
248
290
 
249
291
  /**