@poolse/react-native-calls 0.6.0 → 0.7.1
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 +35 -2
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +6 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallMessagingService.kt +11 -0
- package/android/src/main/java/dev/poolse/callcore/android/MessageNotifications.kt +353 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +8 -0
- package/dist/index.cjs +18 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +22 -1
- package/dist/index.d.ts +22 -1
- package/dist/index.js +16 -1
- package/dist/index.js.map +1 -1
- package/ios/Module/PoolseCallsModule.swift +7 -0
- package/ios/Platform/CallCore.swift +1 -0
- package/ios/Platform/MessageNotifications.swift +163 -0
- package/ios/PoolseCalls.podspec +5 -1
- package/package.json +2 -1
- package/src/index.ts +42 -0
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));
|
|
17
|
+
const stop = calls.onCallState((s) => render(s)); // s: Snapshot | null
|
|
18
18
|
await calls.place(conversationId, { media: 'audio', sessionToken });
|
|
19
|
-
calls.answer();
|
|
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,353 @@
|
|
|
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
|
+
state.hidden = data["private"] == "1"
|
|
69
|
+
data["reply_grant"]?.ifBlank { null }?.let { state.grant = it }
|
|
70
|
+
val messageId = data["message_id"]
|
|
71
|
+
if (messageId == null || state.lines.none { it.id == messageId }) {
|
|
72
|
+
state.lines += Line(
|
|
73
|
+
id = messageId,
|
|
74
|
+
sender = data["sender_name"]?.ifBlank { null } ?: "Someone",
|
|
75
|
+
senderKey = data["sender_user_id"],
|
|
76
|
+
text = data["text"] ?: "",
|
|
77
|
+
at = sentAt,
|
|
78
|
+
self = false,
|
|
79
|
+
)
|
|
80
|
+
while (state.lines.size > MAX_LINES) state.lines.removeAt(0)
|
|
81
|
+
}
|
|
82
|
+
save(context, state)
|
|
83
|
+
post(context, state, smallIcon, alert = true)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The conversation was read (here, or on another device): clear it. */
|
|
87
|
+
fun clear(context: Context, conversationId: String) {
|
|
88
|
+
prefs(context).edit().remove(key(conversationId)).apply()
|
|
89
|
+
runCatching { NotificationManagerCompat.from(context).cancel(notificationId(conversationId)) }
|
|
90
|
+
refreshSummary(context, CallCore.smallIcon(context))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The reply went through: show it as the user's line and stop the spinner. */
|
|
94
|
+
internal fun onReplied(context: Context, conversationId: String, text: String, ok: Boolean) {
|
|
95
|
+
val state = load(context, conversationId) ?: return
|
|
96
|
+
state.lines += Line(
|
|
97
|
+
id = null,
|
|
98
|
+
sender = "You",
|
|
99
|
+
senderKey = null,
|
|
100
|
+
text = if (ok) text else "Not sent: $text",
|
|
101
|
+
at = System.currentTimeMillis(),
|
|
102
|
+
self = true,
|
|
103
|
+
)
|
|
104
|
+
while (state.lines.size > MAX_LINES) state.lines.removeAt(0)
|
|
105
|
+
save(context, state)
|
|
106
|
+
post(context, state, CallCore.smallIcon(context), alert = false)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
internal fun grantFor(context: Context, conversationId: String): String? =
|
|
110
|
+
load(context, conversationId)?.grant
|
|
111
|
+
|
|
112
|
+
// ── building ────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
private fun post(context: Context, state: Conversation, smallIcon: Int, alert: Boolean) {
|
|
115
|
+
ensureChannel(context)
|
|
116
|
+
val me = Person.Builder().setName("You").build()
|
|
117
|
+
val style = NotificationCompat.MessagingStyle(me)
|
|
118
|
+
.setGroupConversation(state.group)
|
|
119
|
+
if (state.group) style.conversationTitle = state.title
|
|
120
|
+
for (line in state.lines) {
|
|
121
|
+
val person = if (line.self) null else Person.Builder().setName(line.sender).setKey(line.senderKey).build()
|
|
122
|
+
style.addMessage(NotificationCompat.MessagingStyle.Message(line.text, line.at, person))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
val id = notificationId(state.id)
|
|
126
|
+
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
127
|
+
.setSmallIcon(smallIcon)
|
|
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
|
+
if (state.hidden) {
|
|
136
|
+
// The tenant hides previews: nothing about who or what — the app's
|
|
137
|
+
// name and a count. The server never sent the text anyway.
|
|
138
|
+
val unread = state.lines.count { !it.self }
|
|
139
|
+
builder
|
|
140
|
+
.setContentTitle(context.applicationInfo.loadLabel(context.packageManager))
|
|
141
|
+
.setContentText(if (unread <= 1) "New message" else "$unread new messages")
|
|
142
|
+
} else {
|
|
143
|
+
builder.setStyle(style)
|
|
144
|
+
}
|
|
145
|
+
openIntent(context, state.id, id)?.let(builder::setContentIntent)
|
|
146
|
+
|
|
147
|
+
if (state.grant != null) {
|
|
148
|
+
val remoteInput = RemoteInput.Builder(KEY_REPLY).setLabel("Message").build()
|
|
149
|
+
val reply = NotificationCompat.Action.Builder(0, "Reply", actionIntent(context, ACTION_REPLY, state.id, id + 1, mutable = true))
|
|
150
|
+
.addRemoteInput(remoteInput)
|
|
151
|
+
.setAllowGeneratedReplies(true)
|
|
152
|
+
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
|
153
|
+
.setShowsUserInterface(false)
|
|
154
|
+
.build()
|
|
155
|
+
val read = NotificationCompat.Action.Builder(0, "Mark as read", actionIntent(context, ACTION_READ, state.id, id + 2, mutable = false))
|
|
156
|
+
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
|
|
157
|
+
.setShowsUserInterface(false)
|
|
158
|
+
.build()
|
|
159
|
+
builder.addAction(reply).addAction(read)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
NotificationManagerCompat.from(context).notify(id, builder.build())
|
|
164
|
+
} catch (e: SecurityException) {
|
|
165
|
+
Log.w(TAG, "message notification refused (POST_NOTIFICATIONS?)", e)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
refreshSummary(context, smallIcon)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The group's summary: "5 messages from 2 chats". Gone with the last chat. */
|
|
172
|
+
private fun refreshSummary(context: Context, smallIcon: Int) {
|
|
173
|
+
val all = prefs(context).all.keys.filter { it.startsWith("conv:") }
|
|
174
|
+
.mapNotNull { load(context, it.removePrefix("conv:")) }
|
|
175
|
+
val manager = NotificationManagerCompat.from(context)
|
|
176
|
+
if (all.isEmpty()) {
|
|
177
|
+
runCatching { manager.cancel(SUMMARY_ID) }
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
val messages = all.sumOf { c -> c.lines.count { !it.self } }
|
|
181
|
+
val text = if (all.size == 1) "$messages new messages" else "$messages messages from ${all.size} chats"
|
|
182
|
+
val summary = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
183
|
+
.setSmallIcon(smallIcon)
|
|
184
|
+
.setContentTitle(text)
|
|
185
|
+
.setStyle(NotificationCompat.InboxStyle().setSummaryText(text))
|
|
186
|
+
.setGroup(GROUP_KEY)
|
|
187
|
+
.setGroupSummary(true)
|
|
188
|
+
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
|
189
|
+
.setAutoCancel(true)
|
|
190
|
+
.build()
|
|
191
|
+
runCatching { manager.notify(SUMMARY_ID, summary) }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private fun openIntent(context: Context, conversationId: String, requestCode: Int): PendingIntent? {
|
|
195
|
+
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null
|
|
196
|
+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
|
197
|
+
.putExtra(MissedCallNotification.EXTRA_ACTION, MissedCallNotification.ACTION_OPEN)
|
|
198
|
+
.putExtra(MissedCallNotification.EXTRA_CONVERSATION_ID, conversationId)
|
|
199
|
+
return PendingIntent.getActivity(
|
|
200
|
+
context,
|
|
201
|
+
requestCode,
|
|
202
|
+
intent,
|
|
203
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private fun actionIntent(context: Context, action: String, conversationId: String, requestCode: Int, mutable: Boolean): PendingIntent {
|
|
208
|
+
val intent = Intent(context, MessageActionReceiver::class.java)
|
|
209
|
+
.setAction(action)
|
|
210
|
+
.putExtra(EXTRA_CONVERSATION, conversationId)
|
|
211
|
+
// RemoteInput writes the typed text into the intent: it must be mutable.
|
|
212
|
+
val mutability = if (mutable && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
213
|
+
PendingIntent.FLAG_MUTABLE
|
|
214
|
+
} else {
|
|
215
|
+
PendingIntent.FLAG_IMMUTABLE
|
|
216
|
+
}
|
|
217
|
+
return PendingIntent.getBroadcast(context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT or mutability)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private fun ensureChannel(context: Context) {
|
|
221
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
222
|
+
val manager = context.getSystemService(NotificationManager::class.java)
|
|
223
|
+
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
|
224
|
+
manager.createNotificationChannel(
|
|
225
|
+
NotificationChannel(CHANNEL_ID, "Messages", NotificationManager.IMPORTANCE_HIGH).apply {
|
|
226
|
+
lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private fun notificationId(conversationId: String) = ("msg:$conversationId").hashCode()
|
|
232
|
+
|
|
233
|
+
// ── storage ─────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
internal data class Line(
|
|
236
|
+
val id: String?,
|
|
237
|
+
val sender: String,
|
|
238
|
+
val senderKey: String?,
|
|
239
|
+
val text: String,
|
|
240
|
+
val at: Long,
|
|
241
|
+
val self: Boolean,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
internal class Conversation(
|
|
245
|
+
val id: String,
|
|
246
|
+
var title: String? = null,
|
|
247
|
+
var group: Boolean = false,
|
|
248
|
+
var grant: String? = null,
|
|
249
|
+
val lines: MutableList<Line> = mutableListOf(),
|
|
250
|
+
var hidden: Boolean = false,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
private fun prefs(context: Context) =
|
|
254
|
+
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
255
|
+
|
|
256
|
+
private fun key(conversationId: String) = "conv:$conversationId"
|
|
257
|
+
|
|
258
|
+
private fun load(context: Context, conversationId: String): Conversation? {
|
|
259
|
+
val raw = prefs(context).getString(key(conversationId), null) ?: return null
|
|
260
|
+
return runCatching {
|
|
261
|
+
val o = JSONObject(raw)
|
|
262
|
+
val lines = o.optJSONArray("lines") ?: JSONArray()
|
|
263
|
+
Conversation(
|
|
264
|
+
id = conversationId,
|
|
265
|
+
title = o.optString("title").ifBlank { null },
|
|
266
|
+
group = o.optBoolean("group"),
|
|
267
|
+
grant = o.optString("grant").ifBlank { null },
|
|
268
|
+
hidden = o.optBoolean("hidden"),
|
|
269
|
+
lines = MutableList(lines.length()) { i ->
|
|
270
|
+
val l = lines.getJSONObject(i)
|
|
271
|
+
Line(
|
|
272
|
+
id = l.optString("id").ifBlank { null },
|
|
273
|
+
sender = l.optString("sender"),
|
|
274
|
+
senderKey = l.optString("key").ifBlank { null },
|
|
275
|
+
text = l.optString("text"),
|
|
276
|
+
at = l.optLong("at"),
|
|
277
|
+
self = l.optBoolean("self"),
|
|
278
|
+
)
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
}.getOrNull()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private fun save(context: Context, c: Conversation) {
|
|
285
|
+
val lines = JSONArray()
|
|
286
|
+
for (l in c.lines) {
|
|
287
|
+
lines.put(
|
|
288
|
+
JSONObject()
|
|
289
|
+
.put("id", l.id ?: "")
|
|
290
|
+
.put("sender", l.sender)
|
|
291
|
+
.put("key", l.senderKey ?: "")
|
|
292
|
+
.put("text", l.text)
|
|
293
|
+
.put("at", l.at)
|
|
294
|
+
.put("self", l.self),
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
val o = JSONObject()
|
|
298
|
+
.put("title", c.title ?: "")
|
|
299
|
+
.put("group", c.group)
|
|
300
|
+
.put("grant", c.grant ?: "")
|
|
301
|
+
.put("hidden", c.hidden)
|
|
302
|
+
.put("lines", lines)
|
|
303
|
+
prefs(context).edit().putString(key(c.id), o.toString()).apply()
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Reply / Mark as read, tapped on a message notification. Posts to the
|
|
309
|
+
* notification-action endpoints with the conversation's reply grant —
|
|
310
|
+
* no app, no session — on a background thread kept alive by goAsync().
|
|
311
|
+
*/
|
|
312
|
+
class MessageActionReceiver : BroadcastReceiver() {
|
|
313
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
314
|
+
val conversationId = intent.getStringExtra(MessageNotifications.EXTRA_CONVERSATION) ?: return
|
|
315
|
+
val app = context.applicationContext
|
|
316
|
+
val grant = MessageNotifications.grantFor(app, conversationId)
|
|
317
|
+
val base = CallCore.apiBaseUrl(app)
|
|
318
|
+
|
|
319
|
+
when (intent.action) {
|
|
320
|
+
MessageNotifications.ACTION_REPLY -> {
|
|
321
|
+
val text = RemoteInput.getResultsFromIntent(intent)
|
|
322
|
+
?.getCharSequence(MessageNotifications.KEY_REPLY)?.toString()?.trim().orEmpty()
|
|
323
|
+
if (text.isEmpty() || grant == null || base == null) return
|
|
324
|
+
val pending = goAsync()
|
|
325
|
+
Thread {
|
|
326
|
+
val body = JSONObject().put("body", text).put("id", UUID.randomUUID().toString())
|
|
327
|
+
val ok = post("$base/v1/notifications/reply", grant, body)
|
|
328
|
+
MessageNotifications.onReplied(app, conversationId, text, ok)
|
|
329
|
+
pending.finish()
|
|
330
|
+
}.start()
|
|
331
|
+
}
|
|
332
|
+
MessageNotifications.ACTION_READ -> {
|
|
333
|
+
// Optimistic: the notification goes now; the server catches up.
|
|
334
|
+
MessageNotifications.clear(app, conversationId)
|
|
335
|
+
if (grant == null || base == null) return
|
|
336
|
+
val pending = goAsync()
|
|
337
|
+
Thread {
|
|
338
|
+
post("$base/v1/notifications/read", grant, JSONObject())
|
|
339
|
+
pending.finish()
|
|
340
|
+
}.start()
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private fun post(url: String, grant: String, body: JSONObject): Boolean = runCatching {
|
|
346
|
+
val request = Request.Builder()
|
|
347
|
+
.url(url)
|
|
348
|
+
.header("Authorization", "Bearer $grant")
|
|
349
|
+
.post(body.toString().toRequestBody("application/json".toMediaType()))
|
|
350
|
+
.build()
|
|
351
|
+
CallHttpClient.defaultClient().newCall(request).execute().use { it.isSuccessful }
|
|
352
|
+
}.onFailure { Log.w("PoolseMessages", "notification action failed", it) }.getOrDefault(false)
|
|
353
|
+
}
|
|
@@ -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
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":["requireOptionalNativeModule","requireNativeViewManager","createElement","View"],"mappings":"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","file":"index.cjs","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n /** The kind of output playing now — the short answer to \"where is the audio\". */\n audioRoute: AudioOutputKind;\n /**\n * Every output the call can play through right now: always the phone\n * (earpiece, or wired headphones in its place) and the speaker, plus\n * each connected Bluetooth / USB device by its own name. Empty until\n * the call's audio is up. Pass an `id` to `setAudioOutput`.\n */\n audioOutputs: AudioOutput[];\n /** The `audioOutputs` entry playing now; null before audio is up. */\n audioOutputId: string | null;\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n\n/** One place call audio can play. */\nexport interface AudioOutput {\n /** Opaque; stable while the device stays connected. */\n id: string;\n kind: AudioOutputKind;\n /**\n * The device's own name for Bluetooth/USB (\"AirPods Pro\"); a plain\n * English default for built-ins (\"iPhone\"/\"Phone\", \"Speaker\",\n * \"Headphones\") — relabel those by `kind` to localise.\n */\n name: string;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\n/**\n * What the user tapped on a missed-call notification: the notification\n * itself (`open` the conversation) or its Call back button (`call_back`\n * — place a call on the conversation with `media`).\n */\nexport interface CallAction {\n type: 'open' | 'call_back';\n conversationId: string;\n callId: string | null;\n media: 'audio' | 'video';\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n setAudioOutput?(id: string): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n takeCallAction?(): CallAction | null;\n 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) }
|
|
@@ -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
|
+
}
|
package/ios/PoolseCalls.podspec
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.7.1",
|
|
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
|
/**
|