@poolse/react-native-calls 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -2
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +12 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallMessagingService.kt +18 -2
- package/android/src/main/java/dev/poolse/callcore/android/MessageNotifications.kt +340 -0
- package/android/src/main/java/dev/poolse/callcore/android/MissedCallNotification.kt +103 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +43 -1
- package/dist/index.cjs +28 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +47 -1
- package/dist/index.d.ts +47 -1
- package/dist/index.js +24 -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 +7 -7
- package/src/index.ts +80 -0
package/dist/index.cjs
CHANGED
|
@@ -39,6 +39,29 @@ var onRingEnded = (callId, reason) => native?.onRingEnded(callId, reason);
|
|
|
39
39
|
var onCallFinished = (callId, reason) => native?.onCallFinished(callId, reason ?? null);
|
|
40
40
|
var canUseFullScreenIntent = () => native?.canUseFullScreenIntent() ?? true;
|
|
41
41
|
var openFullScreenIntentSettings = () => native?.openFullScreenIntentSettings();
|
|
42
|
+
function takeCallAction() {
|
|
43
|
+
return native?.takeCallAction?.() ?? null;
|
|
44
|
+
}
|
|
45
|
+
function onCallAction(cb) {
|
|
46
|
+
if (!native || !native.takeCallAction) return () => void 0;
|
|
47
|
+
const sub = native.addListener("onCallAction", cb);
|
|
48
|
+
return () => sub.remove();
|
|
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
|
+
}
|
|
42
65
|
function getVoipToken() {
|
|
43
66
|
return native?.getVoipToken?.() ?? null;
|
|
44
67
|
}
|
|
@@ -73,22 +96,27 @@ exports.CallVideoView = CallVideoView;
|
|
|
73
96
|
exports.answer = answer;
|
|
74
97
|
exports.canUseFullScreenIntent = canUseFullScreenIntent;
|
|
75
98
|
exports.configure = configure;
|
|
99
|
+
exports.conversationOfNotification = conversationOfNotification;
|
|
76
100
|
exports.decline = decline;
|
|
77
101
|
exports.getCallState = getCallState;
|
|
78
102
|
exports.getVoipToken = getVoipToken;
|
|
79
103
|
exports.hangUp = hangUp;
|
|
80
104
|
exports.join = join;
|
|
81
105
|
exports.nativeCallsAvailable = nativeCallsAvailable;
|
|
106
|
+
exports.onCallAction = onCallAction;
|
|
82
107
|
exports.onCallFinished = onCallFinished;
|
|
83
108
|
exports.onCallState = onCallState;
|
|
84
109
|
exports.onRingEnded = onRingEnded;
|
|
85
110
|
exports.onVoipToken = onVoipToken;
|
|
86
111
|
exports.openFullScreenIntentSettings = openFullScreenIntentSettings;
|
|
87
112
|
exports.place = place;
|
|
113
|
+
exports.setActiveConversation = setActiveConversation;
|
|
88
114
|
exports.setAudioOutput = setAudioOutput;
|
|
89
115
|
exports.setCamera = setCamera;
|
|
90
116
|
exports.setMuted = setMuted;
|
|
91
117
|
exports.setSpeaker = setSpeaker;
|
|
118
|
+
exports.shouldPresentMessage = shouldPresentMessage;
|
|
92
119
|
exports.switchCamera = switchCamera;
|
|
120
|
+
exports.takeCallAction = takeCallAction;
|
|
93
121
|
//# sourceMappingURL=index.cjs.map
|
|
94
122
|
//# 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\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 addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => 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// ── 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
|
@@ -77,6 +77,17 @@ interface PlaceOptions {
|
|
|
77
77
|
/** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */
|
|
78
78
|
sessionToken: string;
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* What the user tapped on a missed-call notification: the notification
|
|
82
|
+
* itself (`open` the conversation) or its Call back button (`call_back`
|
|
83
|
+
* — place a call on the conversation with `media`).
|
|
84
|
+
*/
|
|
85
|
+
interface CallAction {
|
|
86
|
+
type: 'open' | 'call_back';
|
|
87
|
+
conversationId: string;
|
|
88
|
+
callId: string | null;
|
|
89
|
+
media: 'audio' | 'video';
|
|
90
|
+
}
|
|
80
91
|
/** True when the native call core is present in this build. */
|
|
81
92
|
declare const nativeCallsAvailable: boolean;
|
|
82
93
|
/** Configure once, before any call can arrive. No secrets — just the API origin. */
|
|
@@ -123,6 +134,41 @@ declare const onCallFinished: (callId: string, reason?: string) => void;
|
|
|
123
134
|
declare const canUseFullScreenIntent: () => boolean;
|
|
124
135
|
/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */
|
|
125
136
|
declare const openFullScreenIntentSettings: () => void;
|
|
137
|
+
/**
|
|
138
|
+
* Android: the missed-call notification action the app was LAUNCHED
|
|
139
|
+
* with, once — read it on mount. `null` when the app opened any other
|
|
140
|
+
* way. The native core posts that notification itself from the push,
|
|
141
|
+
* so it works with the app closed.
|
|
142
|
+
*
|
|
143
|
+
* iOS shows missed calls as an ordinary alert push (to the token you
|
|
144
|
+
* register with `kind: 'alert'`); read its taps with your notification
|
|
145
|
+
* library — its `type` is `call_missed`, the category
|
|
146
|
+
* `poolse_missed_call`, the Call back action `call_back`.
|
|
147
|
+
*/
|
|
148
|
+
declare function takeCallAction(): CallAction | null;
|
|
149
|
+
/** Android: a missed-call notification tapped while the app is running. */
|
|
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;
|
|
126
172
|
/**
|
|
127
173
|
* iOS: the current PushKit VoIP token, registered natively at launch.
|
|
128
174
|
* `null` until iOS issues one, and always on Android (FCM carries rings).
|
|
@@ -147,4 +193,4 @@ interface CallVideoViewProps extends ViewProps {
|
|
|
147
193
|
*/
|
|
148
194
|
declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
|
|
149
195
|
|
|
150
|
-
export { type AudioOutput, type AudioOutputKind, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setAudioOutput, setCamera, setMuted, setSpeaker, switchCamera };
|
|
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
|
@@ -77,6 +77,17 @@ interface PlaceOptions {
|
|
|
77
77
|
/** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */
|
|
78
78
|
sessionToken: string;
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* What the user tapped on a missed-call notification: the notification
|
|
82
|
+
* itself (`open` the conversation) or its Call back button (`call_back`
|
|
83
|
+
* — place a call on the conversation with `media`).
|
|
84
|
+
*/
|
|
85
|
+
interface CallAction {
|
|
86
|
+
type: 'open' | 'call_back';
|
|
87
|
+
conversationId: string;
|
|
88
|
+
callId: string | null;
|
|
89
|
+
media: 'audio' | 'video';
|
|
90
|
+
}
|
|
80
91
|
/** True when the native call core is present in this build. */
|
|
81
92
|
declare const nativeCallsAvailable: boolean;
|
|
82
93
|
/** Configure once, before any call can arrive. No secrets — just the API origin. */
|
|
@@ -123,6 +134,41 @@ declare const onCallFinished: (callId: string, reason?: string) => void;
|
|
|
123
134
|
declare const canUseFullScreenIntent: () => boolean;
|
|
124
135
|
/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */
|
|
125
136
|
declare const openFullScreenIntentSettings: () => void;
|
|
137
|
+
/**
|
|
138
|
+
* Android: the missed-call notification action the app was LAUNCHED
|
|
139
|
+
* with, once — read it on mount. `null` when the app opened any other
|
|
140
|
+
* way. The native core posts that notification itself from the push,
|
|
141
|
+
* so it works with the app closed.
|
|
142
|
+
*
|
|
143
|
+
* iOS shows missed calls as an ordinary alert push (to the token you
|
|
144
|
+
* register with `kind: 'alert'`); read its taps with your notification
|
|
145
|
+
* library — its `type` is `call_missed`, the category
|
|
146
|
+
* `poolse_missed_call`, the Call back action `call_back`.
|
|
147
|
+
*/
|
|
148
|
+
declare function takeCallAction(): CallAction | null;
|
|
149
|
+
/** Android: a missed-call notification tapped while the app is running. */
|
|
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;
|
|
126
172
|
/**
|
|
127
173
|
* iOS: the current PushKit VoIP token, registered natively at launch.
|
|
128
174
|
* `null` until iOS issues one, and always on Android (FCM carries rings).
|
|
@@ -147,4 +193,4 @@ interface CallVideoViewProps extends ViewProps {
|
|
|
147
193
|
*/
|
|
148
194
|
declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
|
|
149
195
|
|
|
150
|
-
export { type AudioOutput, type AudioOutputKind, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setAudioOutput, setCamera, setMuted, setSpeaker, switchCamera };
|
|
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
|
@@ -37,6 +37,29 @@ var onRingEnded = (callId, reason) => native?.onRingEnded(callId, reason);
|
|
|
37
37
|
var onCallFinished = (callId, reason) => native?.onCallFinished(callId, reason ?? null);
|
|
38
38
|
var canUseFullScreenIntent = () => native?.canUseFullScreenIntent() ?? true;
|
|
39
39
|
var openFullScreenIntentSettings = () => native?.openFullScreenIntentSettings();
|
|
40
|
+
function takeCallAction() {
|
|
41
|
+
return native?.takeCallAction?.() ?? null;
|
|
42
|
+
}
|
|
43
|
+
function onCallAction(cb) {
|
|
44
|
+
if (!native || !native.takeCallAction) return () => void 0;
|
|
45
|
+
const sub = native.addListener("onCallAction", cb);
|
|
46
|
+
return () => sub.remove();
|
|
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
|
+
}
|
|
40
63
|
function getVoipToken() {
|
|
41
64
|
return native?.getVoipToken?.() ?? null;
|
|
42
65
|
}
|
|
@@ -67,6 +90,6 @@ function CallVideoView(props) {
|
|
|
67
90
|
return createElement(Native, props);
|
|
68
91
|
}
|
|
69
92
|
|
|
70
|
-
export { CallVideoView, answer, canUseFullScreenIntent, configure, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setAudioOutput, setCamera, setMuted, setSpeaker, switchCamera };
|
|
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 };
|
|
71
94
|
//# sourceMappingURL=index.js.map
|
|
72
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\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 addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => 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// ── 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.0",
|
|
4
4
|
"description": "Native call core for poolse — Kotlin/Swift calling engine with a thin React Native bridge. Ring, answer and carry audio with the app killed and the phone locked.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,18 +31,18 @@
|
|
|
31
31
|
"expo-module.config.json",
|
|
32
32
|
"README.md"
|
|
33
33
|
],
|
|
34
|
-
"scripts": {
|
|
35
|
-
"build": "tsup",
|
|
36
|
-
"prepublishOnly": "tsup",
|
|
37
|
-
"typecheck": "tsc --noEmit"
|
|
38
|
-
},
|
|
39
34
|
"peerDependencies": {
|
|
40
35
|
"expo": "*",
|
|
36
|
+
"expo-notifications": "*",
|
|
41
37
|
"react-native": "*"
|
|
42
38
|
},
|
|
43
39
|
"devDependencies": {
|
|
44
40
|
"expo-modules-core": "*",
|
|
45
41
|
"tsup": "^8.3.5",
|
|
46
42
|
"typescript": "^5.6.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsup",
|
|
46
|
+
"typecheck": "tsc --noEmit"
|
|
47
47
|
}
|
|
48
|
-
}
|
|
48
|
+
}
|