@poolse/react-native-calls 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/android/src/main/java/dev/poolse/callcore/CallCoreRuntime.kt +66 -6
- package/android/src/main/java/dev/poolse/callcore/CallMachine.kt +108 -3
- package/android/src/main/java/dev/poolse/callcore/Ports.kt +12 -0
- package/android/src/main/java/dev/poolse/callcore/android/AndroidRingSurface.kt +121 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallActionReceiver.kt +16 -0
- package/android/src/main/java/dev/poolse/callcore/android/CallCore.kt +5 -0
- package/android/src/main/java/dev/poolse/callcore/android/SnapshotJson.kt +15 -0
- package/android/src/main/java/expo/modules/poolsecalls/PoolseCallsModule.kt +2 -0
- package/dist/index.cjs +4 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/ios/Core/CallCoreRuntime.swift +73 -6
- package/ios/Core/CallMachine.swift +91 -1
- package/ios/Core/Ports.swift +32 -1
- package/ios/Core/SnapshotEncoding.swift +14 -1
- package/ios/Module/PoolseCallsModule.swift +2 -0
- package/ios/Platform/CallCore.swift +63 -15
- package/ios/Platform/CallKitController.swift +7 -1
- package/package.json +1 -1
- package/src/index.ts +29 -0
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":["requireOptionalNativeModule","requireNativeViewManager","createElement","View"],"mappings":";;;;;;;AAmJA,IAAM,MAAA,GAASA,4CAA+C,aAAa,CAAA;AAGpE,IAAM,uBAAuB,MAAA,IAAU;AAG9C,IAAI,SAAA,GAA2B,IAAA;AAG/B,eAAsB,SAAA,CAAU,YAAoB,OAAA,EAA2C;AAC7F,EAAA,SAAA,GAAY,UAAA,CAAW,OAAA,CAAQ,MAAA,EAAQ,EAAE,CAAA;AACzC,EAAA,MAAM,MAAA,EAAQ,SAAA,CAAU,UAAA,EAAY,OAAA,IAAW,IAAI,CAAA;AACrD;AAGO,SAAS,YAAA,GAAgC;AAC9C,EAAA,OAAO,MAAA,EAAQ,YAAA,EAAa,CAAE,QAAA,IAAY,IAAA;AAC5C;AAKO,SAAS,YAAY,EAAA,EAAsD;AAChF,EAAA,IAAI,CAAC,MAAA,EAAQ,OAAO,MAAM,MAAA;AAC1B,EAAA,MAAM,GAAA,GAAM,OAAO,WAAA,CAAY,aAAA,EAAe,CAAC,OAAA,KAAY,EAAA,CAAG,OAAA,CAAQ,QAAQ,CAAC,CAAA;AAC/E,EAAA,OAAO,MAAM,IAAI,MAAA,EAAO;AAC1B;AAEO,SAAS,KAAA,CAAM,gBAAwB,OAAA,EAAsC;AAClF,EAAA,OAAO,MAAA,EAAQ,MAAM,cAAA,EAAgB,OAAA,CAAQ,OAAO,OAAA,CAAQ,YAAY,CAAA,IAAK,OAAA,CAAQ,OAAA,EAAQ;AAC/F;AAEO,SAAS,IAAA,CACd,MAAA,EACA,cAAA,EACA,YAAA,EACe;AACf,EAAA,OAAO,QAAQ,IAAA,CAAK,MAAA,EAAQ,gBAAgB,YAAY,CAAA,IAAK,QAAQ,OAAA,EAAQ;AAC/E;AAqBA,eAAsB,SAAA,CACpB,gBACA,OAAA,EAC0B;AAC1B,EAAA,IAAI,CAAC,MAAA,EAAQ,MAAM,IAAI,MAAM,4CAA4C,CAAA;AACzE,EAAA,IAAI,CAAC,SAAA,EAAW,MAAM,IAAI,MAAM,sCAAsC,CAAA;AACtE,EAAA,MAAM,MAAM,MAAM,KAAA;AAAA,IAChB,CAAA,EAAG,SAAS,CAAA,kBAAA,EAAqB,kBAAA,CAAmB,cAAc,CAAC,CAAA,KAAA,CAAA;AAAA,IACnE;AAAA,MACE,MAAA,EAAQ,MAAA;AAAA,MACR,OAAA,EAAS;AAAA,QACP,aAAA,EAAe,CAAA,OAAA,EAAU,OAAA,CAAQ,YAAY,CAAA,CAAA;AAAA,QAC7C,cAAA,EAAgB;AAAA,OAClB;AAAA,MACA,MAAM,IAAA,CAAK,SAAA,CAAU,EAAE,KAAA,EAAO,OAAA,CAAQ,OAAO;AAAA;AAC/C,GACF;AACA,EAAA,MAAM,OAAQ,MAAM,GAAA,CAAI,MAAK,CAAE,KAAA,CAAM,MAAM,IAAI,CAAA;AAK/C,EAAA,MAAM,MAAA,GAAS,MAAM,IAAA,EAAM,EAAA;AAC3B,EAAA,IAAI,CAAC,GAAA,CAAI,EAAA,IAAM,CAAC,MAAA,EAAQ;AACtB,IAAA,MAAM,IAAI,MAAM,IAAA,EAAM,KAAA,EAAO,WAAW,CAAA,yBAAA,EAA4B,GAAA,CAAI,MAAM,CAAA,EAAA,CAAI,CAAA;AAAA,EACpF;AAGA,EAAA,MAAM,MAAA,CAAO,IAAA,CAAK,MAAA,EAAQ,cAAA,EAAgB,QAAQ,YAAY,CAAA;AAC9D,EAAA,OAAO,EAAE,MAAA,EAAQ,OAAA,EAAS,IAAA,EAAM,YAAY,IAAA,EAAK;AACnD;AAEO,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,OAAA,GAAU,MAAY,MAAA,EAAQ,OAAA;AACpC,IAAM,MAAA,GAAS,MAAY,MAAA,EAAQ,MAAA;AACnC,IAAM,QAAA,GAAW,CAAC,KAAA,KAAyB,MAAA,EAAQ,SAAS,KAAK;AACjE,IAAM,SAAA,GAAY,CAAC,EAAA,KAAsB,MAAA,EAAQ,UAAU,EAAE;AAC7D,IAAM,YAAA,GAAe,MAAY,MAAA,EAAQ,YAAA;AAEzC,IAAM,UAAA,GAAa,CAAC,EAAA,KAAsB,MAAA,EAAQ,WAAW,EAAE;AAS/D,SAAS,eAAe,EAAA,EAAkB;AAC/C,EAAA,IAAI,MAAA,EAAQ,cAAA,EAAgB,MAAA,CAAO,cAAA,CAAe,EAAE,CAAA;AAAA,OAC/C,MAAA,EAAQ,UAAA,CAAW,EAAA,KAAO,SAAS,CAAA;AAC1C;AAMO,IAAM,cAAc,CAAC,MAAA,EAAgB,WAC1C,MAAA,EAAQ,WAAA,CAAY,QAAQ,MAAM;AAQ7B,IAAM,cAAA,GAAiB,CAAC,MAAA,EAAgB,MAAA,KAC7C,QAAQ,cAAA,CAAe,MAAA,EAAQ,UAAU,IAAI;AAMxC,IAAM,sBAAA,GAAyB,MAAe,MAAA,EAAQ,sBAAA,EAAuB,IAAK;AAGlF,IAAM,4BAAA,GAA+B,MAAY,MAAA,EAAQ,4BAAA;AAezD,SAAS,cAAA,GAAoC;AAClD,EAAA,OAAO,MAAA,EAAQ,kBAAiB,IAAK,IAAA;AACvC;AAGO,SAAS,aAAa,EAAA,EAA+C;AAC1E,EAAA,IAAI,CAAC,MAAA,IAAU,CAAC,MAAA,CAAO,cAAA,SAAuB,MAAM,MAAA;AACpD,EAAA,MAAM,GAAA,GAAM,MAAA,CAAO,WAAA,CAAY,cAAA,EAAgB,EAAE,CAAA;AACjD,EAAA,OAAO,MAAM,IAAI,MAAA,EAAO;AAC1B;AAIA,IAAI,kBAAA,GAAoC,IAAA;AAQjC,SAAS,sBAAsB,cAAA,EAAqC;AACzE,EAAA,kBAAA,GAAqB,cAAA;AACrB,EAAA,MAAA,EAAQ,wBAAwB,cAAc,CAAA;AAChD;AAQO,SAAS,qBAAqB,IAAA,EAA2D;AAC9F,EAAA,IAAI,CAAC,IAAA,IAAQ,IAAA,CAAK,MAAM,CAAA,KAAM,WAAW,OAAO,IAAA;AAChD,EAAA,OAAO,IAAA,CAAK,iBAAiB,CAAA,KAAM,kBAAA;AACrC;AAQO,SAAS,2BACd,IAAA,EACe;AACf,EAAA,IAAI,CAAC,MAAM,OAAO,IAAA;AAClB,EAAA,MAAM,IAAA,GAAO,KAAK,MAAM,CAAA;AACxB,EAAA,IAAI,IAAA,KAAS,SAAA,IAAa,IAAA,KAAS,aAAA,EAAe,OAAO,IAAA;AACzD,EAAA,OAAO,OAAO,IAAA,CAAK,iBAAiB,MAAM,QAAA,GAAW,IAAA,CAAK,iBAAiB,CAAA,GAAI,IAAA;AACjF;AAQO,SAAS,YAAA,GAAiC;AAC/C,EAAA,OAAO,MAAA,EAAQ,gBAAe,IAAK,IAAA;AACrC;AAGO,SAAS,YAAY,EAAA,EAA6C;AACvE,EAAA,IAAI,CAAC,MAAA,IAAU,CAAC,MAAA,CAAO,YAAA,SAAqB,MAAM,MAAA;AAClD,EAAA,MAAM,GAAA,GAAM,MAAA,CAAO,WAAA,CAAY,aAAA,EAAe,EAAE,CAAA;AAChD,EAAA,OAAO,MAAM,IAAI,MAAA,EAAO;AAC1B;AAgBA,IAAI,WAAA;AAGJ,SAAS,eAAA,GAA0D;AACjE,EAAA,IAAI,WAAA,KAAgB,QAAW,OAAO,WAAA;AACtC,EAAA,WAAA,GAAc,IAAA;AACd,EAAA,IAAI,MAAA,EAAQ;AACV,IAAA,IAAI;AACF,MAAA,WAAA,GAAcC,wCAAA,CAA2C,eAAe,iBAAiB,CAAA;AAAA,IAC3F,CAAA,CAAA,MAAQ;AACN,MAAA,WAAA,GAAc,IAAA;AAAA,IAChB;AAAA,EACF;AACA,EAAA,OAAO,WAAA;AACT;AAQO,SAAS,cAAc,KAAA,EAAgD;AAC5E,EAAA,MAAM,SAAS,eAAA,EAAgB;AAC/B,EAAA,IAAI,CAAC,MAAA,EAAQ;AACX,IAAA,MAAM,EAAE,aAAA,EAAe,EAAA,EAAI,KAAA,EAAO,EAAA,EAAI,MAAA,EAAQ,EAAA,EAAI,GAAA,EAAK,EAAA,EAAI,GAAG,IAAA,EAAK,GAAI,KAAA;AACvE,IAAA,OAAOC,mBAAA,CAAcC,kBAAM,IAAI,CAAA;AAAA,EACjC;AACA,EAAA,OAAOD,mBAAA,CAAc,QAAQ,KAAK,CAAA;AACpC","file":"index.cjs","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n /**\n * `room` — rings nobody, joined and left at will (`startRoom`); `call`\n * otherwise. Known once the server answered: a room being joined reads\n * `call` while `connecting`. Absent before 0.9.0: treat as `call`.\n */\n kind?: 'call' | 'room';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n /** The kind of output playing now — the short answer to \"where is the audio\". */\n audioRoute: AudioOutputKind;\n /**\n * Every output the call can play through right now: always the phone\n * (earpiece, or wired headphones in its place) and the speaker, plus\n * each connected Bluetooth / USB device by its own name. Empty until\n * the call's audio is up. Pass an `id` to `setAudioOutput`.\n */\n audioOutputs: AudioOutput[];\n /** The `audioOutputs` entry playing now; null before audio is up. */\n audioOutputId: string | null;\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n\n/** One place call audio can play. */\nexport interface AudioOutput {\n /** Opaque; stable while the device stays connected. */\n id: string;\n kind: AudioOutputKind;\n /**\n * The device's own name for Bluetooth/USB (\"AirPods Pro\"); a plain\n * English default for built-ins (\"iPhone\"/\"Phone\", \"Speaker\",\n * \"Headphones\") — relabel those by `kind` to localise.\n */\n name: string;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\n/**\n * What the user tapped on a missed-call notification: the notification\n * itself (`open` the conversation) or its Call back button (`call_back`\n * — place a call on the conversation with `media`).\n */\nexport interface CallAction {\n type: 'open' | 'call_back';\n conversationId: string;\n callId: string | null;\n media: 'audio' | 'video';\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n setAudioOutput?(id: string): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n takeCallAction?(): CallAction | null;\n setActiveConversation?(conversationId: string | null): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n addListener(event: 'onCallAction', cb: (payload: CallAction) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n// Kept for startRoom, the one command that talks to the server from JS.\nlet apiOrigin: string | null = null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n apiOrigin = apiBaseUrl.replace(/\\/+$/, '');\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport interface StartRoomResult {\n /** The room's call id — what the Join banner and `call:*` events carry. */\n callId: string;\n /** False when something was live already (a room, or a ringing call) and this joined it. */\n created: boolean;\n}\n\n/**\n * Start the conversation's room — a call that rings nobody (CONTRACT.md\n * §5.6) — or join whatever is live there, then carry it on the native\n * core like any joined call: CallKit / the call service, so it keeps\n * going with the phone locked, and the same audio routing as a call.\n *\n * Others in the conversation see it through the Join banner and join it\n * with `join`. Leaving (`hangUp`) never ends it for them; it ends once\n * it has been empty for a short grace. `media: 'video'` makes it a video\n * room. Rejects when the server refuses (not a member, no SFU) or\n * before `configure`.\n */\nexport async function startRoom(\n conversationId: string,\n options: PlaceOptions,\n): Promise<StartRoomResult> {\n if (!native) throw new Error('The native call core is not in this build.');\n if (!apiOrigin) throw new Error('Call configure() before startRoom().');\n const res = await fetch(\n `${apiOrigin}/v1/conversations/${encodeURIComponent(conversationId)}/room`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${options.sessionToken}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({ media: options.media }),\n },\n );\n const body = (await res.json().catch(() => null)) as {\n created?: boolean;\n call?: { id?: string };\n error?: { message?: string };\n } | null;\n const callId = body?.call?.id;\n if (!res.ok || !callId) {\n throw new Error(body?.error?.message ?? `Couldn't start the room (${res.status}).`);\n }\n // The core joins with its own request: it owns the session from here,\n // exactly as when the room is joined from the banner.\n await native.join(callId, conversationId, options.sessionToken);\n return { callId, created: body?.created === true };\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Play call audio through one of `snapshot.audioOutputs`. The choice\n * holds for the rest of the call, until that device disconnects or a new\n * headset / Bluetooth device connects (which takes over, as in the\n * phone's own dialer). Without a choice a connected headset wins, then\n * the earpiece for voice calls or the speaker for video.\n */\nexport function setAudioOutput(id: string): void {\n if (native?.setAudioOutput) native.setAudioOutput(id);\n else native?.setSpeaker(id === 'speaker'); // an older native build\n}\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── missed-call notifications (Android) ─────────────────────────────────\n\n/**\n * Android: the missed-call notification action the app was LAUNCHED\n * with, once — read it on mount. `null` when the app opened any other\n * way. The native core posts that notification itself from the push,\n * so it works with the app closed.\n *\n * iOS shows missed calls as an ordinary alert push (to the token you\n * register with `kind: 'alert'`); read its taps with your notification\n * library — its `type` is `call_missed`, the category\n * `poolse_missed_call`, the Call back action `call_back`.\n */\nexport function takeCallAction(): CallAction | null {\n return native?.takeCallAction?.() ?? null;\n}\n\n/** Android: a missed-call notification tapped while the app is running. */\nexport function onCallAction(cb: (action: CallAction) => void): Unsubscribe {\n if (!native || !native.takeCallAction) return () => undefined;\n const sub = native.addListener('onCallAction', cb);\n return () => sub.remove();\n}\n\n// ── chat notifications ─────────────────────────────────────────────────\n\nlet activeConversation: string | null = null;\n\n/**\n * Tell the core which conversation is on screen (`null` when none, and\n * when the app goes to the background). Its messages are not notified —\n * the user is reading them — and opening it clears the notifications\n * already shown for it.\n */\nexport function setActiveConversation(conversationId: string | null): void {\n activeConversation = conversationId;\n native?.setActiveConversation?.(conversationId);\n}\n\n/**\n * For the app's notification handler (expo-notifications'\n * `setNotificationHandler`): `false` for a chat notification of the\n * conversation on screen, which must not pop over the chat itself.\n * Android never asks — the core skips those natively.\n */\nexport function shouldPresentMessage(data: Record<string, unknown> | undefined | null): boolean {\n if (!data || data['type'] !== 'message') return true;\n return data['conversation_id'] !== activeConversation;\n}\n\n/**\n * The conversation a tapped notification belongs to — a chat message\n * (`type: 'message'`) or a missed call (`call_missed`) — from its data.\n * iOS hands the tap to your notification library; Android delivers it\n * through `takeCallAction` / `onCallAction` (type `open`).\n */\nexport function conversationOfNotification(\n data: Record<string, unknown> | undefined | null,\n): string | null {\n if (!data) return null;\n const type = data['type'];\n if (type !== 'message' && type !== 'call_missed') return null;\n return typeof data['conversation_id'] === 'string' ? data['conversation_id'] : null;\n}\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["requireOptionalNativeModule","requireNativeViewManager","createElement","View"],"mappings":"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","file":"index.cjs","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n /**\n * `room` — rings nobody, joined and left at will (`startRoom`); `call`\n * otherwise. Known once the server answered: a room being joined reads\n * `call` while `connecting`. Absent before 0.9.0: treat as `call`.\n */\n kind?: 'call' | 'room';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n /** The kind of output playing now — the short answer to \"where is the audio\". */\n audioRoute: AudioOutputKind;\n /**\n * Every output the call can play through right now: always the phone\n * (earpiece, or wired headphones in its place) and the speaker, plus\n * each connected Bluetooth / USB device by its own name. Empty until\n * the call's audio is up. Pass an `id` to `setAudioOutput`.\n */\n audioOutputs: AudioOutput[];\n /** The `audioOutputs` entry playing now; null before audio is up. */\n audioOutputId: string | null;\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /**\n * A second call ringing while this one is in progress (call waiting).\n * Offer `answerWaiting` (\"End & answer\" — this call ends) and\n * `declineWaiting` (its caller hears busy). If this call ends first,\n * the waiting one simply becomes the ringing call. null when none;\n * absent before 0.10.0.\n */\n waiting?: WaitingCall | null;\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/** The call waiting beside the current one. */\nexport interface WaitingCall {\n callId: string;\n conversationId: string | null;\n conversationName: string | null;\n callerName: string | null;\n media: 'audio' | 'video';\n}\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 answerWaiting?(callId: string | null): void;\n declineWaiting?(callId: string | null): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n setAudioOutput?(id: string): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n takeCallAction?(): CallAction | null;\n setActiveConversation?(conversationId: string | null): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n addListener(event: 'onCallAction', cb: (payload: CallAction) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n// Kept for startRoom, the one command that talks to the server from JS.\nlet apiOrigin: string | null = null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n apiOrigin = apiBaseUrl.replace(/\\/+$/, '');\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport interface StartRoomResult {\n /** The room's call id — what the Join banner and `call:*` events carry. */\n callId: string;\n /** False when something was live already (a room, or a ringing call) and this joined it. */\n created: boolean;\n}\n\n/**\n * Start the conversation's room — a call that rings nobody (CONTRACT.md\n * §5.6) — or join whatever is live there, then carry it on the native\n * core like any joined call: CallKit / the call service, so it keeps\n * going with the phone locked, and the same audio routing as a call.\n *\n * Others in the conversation see it through the Join banner and join it\n * with `join`. Leaving (`hangUp`) never ends it for them; it ends once\n * it has been empty for a short grace. `media: 'video'` makes it a video\n * room. Rejects when the server refuses (not a member, no SFU) or\n * before `configure`.\n */\nexport async function startRoom(\n conversationId: string,\n options: PlaceOptions,\n): Promise<StartRoomResult> {\n if (!native) throw new Error('The native call core is not in this build.');\n if (!apiOrigin) throw new Error('Call configure() before startRoom().');\n const res = await fetch(\n `${apiOrigin}/v1/conversations/${encodeURIComponent(conversationId)}/room`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${options.sessionToken}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({ media: options.media }),\n },\n );\n const body = (await res.json().catch(() => null)) as {\n created?: boolean;\n call?: { id?: string };\n error?: { message?: string };\n } | null;\n const callId = body?.call?.id;\n if (!res.ok || !callId) {\n throw new Error(body?.error?.message ?? `Couldn't start the room (${res.status}).`);\n }\n // The core joins with its own request: it owns the session from here,\n // exactly as when the room is joined from the banner.\n await native.join(callId, conversationId, options.sessionToken);\n return { callId, created: body?.created === true };\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\n\n/**\n * Call waiting: end the current call and answer `snapshot.waiting`.\n * Pass its `callId`: a tap on a card for a call that is no longer the\n * one waiting is then ignored instead of answering a different call.\n */\nexport const answerWaiting = (callId?: string): void => native?.answerWaiting?.(callId ?? null);\n\n/** Call waiting: turn `snapshot.waiting` away — its caller hears busy. */\nexport const declineWaiting = (callId?: string): void => native?.declineWaiting?.(callId ?? null);\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
|
@@ -44,10 +44,26 @@ interface Snapshot {
|
|
|
44
44
|
mutedSelf: boolean;
|
|
45
45
|
/** True only while the local camera track is actually published and enabled. */
|
|
46
46
|
cameraOn: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* A second call ringing while this one is in progress (call waiting).
|
|
49
|
+
* Offer `answerWaiting` ("End & answer" — this call ends) and
|
|
50
|
+
* `declineWaiting` (its caller hears busy). If this call ends first,
|
|
51
|
+
* the waiting one simply becomes the ringing call. null when none;
|
|
52
|
+
* absent before 0.10.0.
|
|
53
|
+
*/
|
|
54
|
+
waiting?: WaitingCall | null;
|
|
47
55
|
/** Facing of the published local camera; absent when the camera is off. */
|
|
48
56
|
cameraFacing?: 'front' | 'back';
|
|
49
57
|
}
|
|
50
58
|
type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
|
|
59
|
+
/** The call waiting beside the current one. */
|
|
60
|
+
interface WaitingCall {
|
|
61
|
+
callId: string;
|
|
62
|
+
conversationId: string | null;
|
|
63
|
+
conversationName: string | null;
|
|
64
|
+
callerName: string | null;
|
|
65
|
+
media: 'audio' | 'video';
|
|
66
|
+
}
|
|
51
67
|
/** One place call audio can play. */
|
|
52
68
|
interface AudioOutput {
|
|
53
69
|
/** Opaque; stable while the device stays connected. */
|
|
@@ -127,6 +143,14 @@ declare function startRoom(conversationId: string, options: PlaceOptions): Promi
|
|
|
127
143
|
declare const answer: () => void;
|
|
128
144
|
declare const decline: () => void;
|
|
129
145
|
declare const hangUp: () => void;
|
|
146
|
+
/**
|
|
147
|
+
* Call waiting: end the current call and answer `snapshot.waiting`.
|
|
148
|
+
* Pass its `callId`: a tap on a card for a call that is no longer the
|
|
149
|
+
* one waiting is then ignored instead of answering a different call.
|
|
150
|
+
*/
|
|
151
|
+
declare const answerWaiting: (callId?: string) => void;
|
|
152
|
+
/** Call waiting: turn `snapshot.waiting` away — its caller hears busy. */
|
|
153
|
+
declare const declineWaiting: (callId?: string) => void;
|
|
130
154
|
declare const setMuted: (muted: boolean) => void;
|
|
131
155
|
declare const setCamera: (on: boolean) => void;
|
|
132
156
|
declare const switchCamera: () => void;
|
|
@@ -218,4 +242,4 @@ interface CallVideoViewProps extends ViewProps {
|
|
|
218
242
|
*/
|
|
219
243
|
declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
|
|
220
244
|
|
|
221
|
-
export { type AudioOutput, type AudioOutputKind, type CallAction, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type StartRoomResult, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, conversationOfNotification, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setActiveConversation, setAudioOutput, setCamera, setMuted, setSpeaker, shouldPresentMessage, startRoom, switchCamera, takeCallAction };
|
|
245
|
+
export { type AudioOutput, type AudioOutputKind, type CallAction, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type StartRoomResult, type Unsubscribe, type VoipToken, type WaitingCall, answer, answerWaiting, canUseFullScreenIntent, configure, conversationOfNotification, decline, declineWaiting, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setActiveConversation, setAudioOutput, setCamera, setMuted, setSpeaker, shouldPresentMessage, startRoom, switchCamera, takeCallAction };
|
package/dist/index.d.ts
CHANGED
|
@@ -44,10 +44,26 @@ interface Snapshot {
|
|
|
44
44
|
mutedSelf: boolean;
|
|
45
45
|
/** True only while the local camera track is actually published and enabled. */
|
|
46
46
|
cameraOn: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* A second call ringing while this one is in progress (call waiting).
|
|
49
|
+
* Offer `answerWaiting` ("End & answer" — this call ends) and
|
|
50
|
+
* `declineWaiting` (its caller hears busy). If this call ends first,
|
|
51
|
+
* the waiting one simply becomes the ringing call. null when none;
|
|
52
|
+
* absent before 0.10.0.
|
|
53
|
+
*/
|
|
54
|
+
waiting?: WaitingCall | null;
|
|
47
55
|
/** Facing of the published local camera; absent when the camera is off. */
|
|
48
56
|
cameraFacing?: 'front' | 'back';
|
|
49
57
|
}
|
|
50
58
|
type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';
|
|
59
|
+
/** The call waiting beside the current one. */
|
|
60
|
+
interface WaitingCall {
|
|
61
|
+
callId: string;
|
|
62
|
+
conversationId: string | null;
|
|
63
|
+
conversationName: string | null;
|
|
64
|
+
callerName: string | null;
|
|
65
|
+
media: 'audio' | 'video';
|
|
66
|
+
}
|
|
51
67
|
/** One place call audio can play. */
|
|
52
68
|
interface AudioOutput {
|
|
53
69
|
/** Opaque; stable while the device stays connected. */
|
|
@@ -127,6 +143,14 @@ declare function startRoom(conversationId: string, options: PlaceOptions): Promi
|
|
|
127
143
|
declare const answer: () => void;
|
|
128
144
|
declare const decline: () => void;
|
|
129
145
|
declare const hangUp: () => void;
|
|
146
|
+
/**
|
|
147
|
+
* Call waiting: end the current call and answer `snapshot.waiting`.
|
|
148
|
+
* Pass its `callId`: a tap on a card for a call that is no longer the
|
|
149
|
+
* one waiting is then ignored instead of answering a different call.
|
|
150
|
+
*/
|
|
151
|
+
declare const answerWaiting: (callId?: string) => void;
|
|
152
|
+
/** Call waiting: turn `snapshot.waiting` away — its caller hears busy. */
|
|
153
|
+
declare const declineWaiting: (callId?: string) => void;
|
|
130
154
|
declare const setMuted: (muted: boolean) => void;
|
|
131
155
|
declare const setCamera: (on: boolean) => void;
|
|
132
156
|
declare const switchCamera: () => void;
|
|
@@ -218,4 +242,4 @@ interface CallVideoViewProps extends ViewProps {
|
|
|
218
242
|
*/
|
|
219
243
|
declare function CallVideoView(props: CallVideoViewProps): ReactElement | null;
|
|
220
244
|
|
|
221
|
-
export { type AudioOutput, type AudioOutputKind, type CallAction, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type StartRoomResult, type Unsubscribe, type VoipToken, answer, canUseFullScreenIntent, configure, conversationOfNotification, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setActiveConversation, setAudioOutput, setCamera, setMuted, setSpeaker, shouldPresentMessage, startRoom, switchCamera, takeCallAction };
|
|
245
|
+
export { type AudioOutput, type AudioOutputKind, type CallAction, CallVideoView, type CallVideoViewProps, type ConfigureOptions, type PlaceOptions, type Snapshot, type StartRoomResult, type Unsubscribe, type VoipToken, type WaitingCall, answer, answerWaiting, canUseFullScreenIntent, configure, conversationOfNotification, decline, declineWaiting, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setActiveConversation, setAudioOutput, setCamera, setMuted, setSpeaker, shouldPresentMessage, startRoom, switchCamera, takeCallAction };
|
package/dist/index.js
CHANGED
|
@@ -49,6 +49,8 @@ async function startRoom(conversationId, options) {
|
|
|
49
49
|
var answer = () => native?.answer();
|
|
50
50
|
var decline = () => native?.decline();
|
|
51
51
|
var hangUp = () => native?.hangUp();
|
|
52
|
+
var answerWaiting = (callId) => native?.answerWaiting?.(callId ?? null);
|
|
53
|
+
var declineWaiting = (callId) => native?.declineWaiting?.(callId ?? null);
|
|
52
54
|
var setMuted = (muted) => native?.setMuted(muted);
|
|
53
55
|
var setCamera = (on) => native?.setCamera(on);
|
|
54
56
|
var switchCamera = () => native?.switchCamera();
|
|
@@ -114,6 +116,6 @@ function CallVideoView(props) {
|
|
|
114
116
|
return createElement(Native, props);
|
|
115
117
|
}
|
|
116
118
|
|
|
117
|
-
export { CallVideoView, answer, canUseFullScreenIntent, configure, conversationOfNotification, decline, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setActiveConversation, setAudioOutput, setCamera, setMuted, setSpeaker, shouldPresentMessage, startRoom, switchCamera, takeCallAction };
|
|
119
|
+
export { CallVideoView, answer, answerWaiting, canUseFullScreenIntent, configure, conversationOfNotification, decline, declineWaiting, getCallState, getVoipToken, hangUp, join, nativeCallsAvailable, onCallAction, onCallFinished, onCallState, onRingEnded, onVoipToken, openFullScreenIntentSettings, place, setActiveConversation, setAudioOutput, setCamera, setMuted, setSpeaker, shouldPresentMessage, startRoom, switchCamera, takeCallAction };
|
|
118
120
|
//# sourceMappingURL=index.js.map
|
|
119
121
|
//# 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 /**\n * `room` — rings nobody, joined and left at will (`startRoom`); `call`\n * otherwise. Known once the server answered: a room being joined reads\n * `call` while `connecting`. Absent before 0.9.0: treat as `call`.\n */\n kind?: 'call' | 'room';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n /** The kind of output playing now — the short answer to \"where is the audio\". */\n audioRoute: AudioOutputKind;\n /**\n * Every output the call can play through right now: always the phone\n * (earpiece, or wired headphones in its place) and the speaker, plus\n * each connected Bluetooth / USB device by its own name. Empty until\n * the call's audio is up. Pass an `id` to `setAudioOutput`.\n */\n audioOutputs: AudioOutput[];\n /** The `audioOutputs` entry playing now; null before audio is up. */\n audioOutputId: string | null;\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /** Facing of the published local camera; absent when the camera is off. */\n cameraFacing?: 'front' | 'back';\n}\n\nexport type AudioOutputKind = 'earpiece' | 'speaker' | 'bluetooth' | 'headset';\n\n/** One place call audio can play. */\nexport interface AudioOutput {\n /** Opaque; stable while the device stays connected. */\n id: string;\n kind: AudioOutputKind;\n /**\n * The device's own name for Bluetooth/USB (\"AirPods Pro\"); a plain\n * English default for built-ins (\"iPhone\"/\"Phone\", \"Speaker\",\n * \"Headphones\") — relabel those by `kind` to localise.\n */\n name: string;\n}\n\nexport interface ConfigureOptions {\n /** Drawable resource NAME for the call notification's small icon. */\n smallIcon?: string;\n /** Ringback tone: raw resource NAME (Android); main-bundle sound name, mp3/caf/wav (iOS). */\n ringback?: string;\n /**\n * iOS: the APNs environment of this build's `aps-environment`\n * entitlement. Echoed back with the VoIP token so the server pushes to\n * the right gateway. Ignored on Android.\n */\n apnsEnvironment?: 'production' | 'sandbox';\n}\n\n/** The iOS PushKit (VoIP) token and the APNs environment it belongs to. */\nexport interface VoipToken {\n /** Hex-encoded PushKit token. */\n token: string;\n environment: 'production' | 'sandbox' | string;\n}\n\nexport interface PlaceOptions {\n media: 'audio' | 'video';\n /** The user's session token — authorizes placing/joining (CONTRACT.md §3.2). */\n sessionToken: string;\n}\n\n/**\n * What the user tapped on a missed-call notification: the notification\n * itself (`open` the conversation) or its Call back button (`call_back`\n * — place a call on the conversation with `media`).\n */\nexport interface CallAction {\n type: 'open' | 'call_back';\n conversationId: string;\n callId: string | null;\n media: 'audio' | 'video';\n}\n\ntype NativeSnapshot = { snapshot: Snapshot | null };\n\ninterface PoolseCallsNative {\n configure(apiBaseUrl: string, options: ConfigureOptions | null): Promise<void>;\n getCallState(): NativeSnapshot;\n place(conversationId: string, media: string, sessionToken: string): Promise<void>;\n join(callId: string, conversationId: string | null, sessionToken: string): Promise<void>;\n answer(): void;\n decline(): void;\n hangUp(): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n setAudioOutput?(id: string): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n takeCallAction?(): CallAction | null;\n setActiveConversation?(conversationId: string | null): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n addListener(event: 'onCallAction', cb: (payload: CallAction) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n// Kept for startRoom, the one command that talks to the server from JS.\nlet apiOrigin: string | null = null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n apiOrigin = apiBaseUrl.replace(/\\/+$/, '');\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport interface StartRoomResult {\n /** The room's call id — what the Join banner and `call:*` events carry. */\n callId: string;\n /** False when something was live already (a room, or a ringing call) and this joined it. */\n created: boolean;\n}\n\n/**\n * Start the conversation's room — a call that rings nobody (CONTRACT.md\n * §5.6) — or join whatever is live there, then carry it on the native\n * core like any joined call: CallKit / the call service, so it keeps\n * going with the phone locked, and the same audio routing as a call.\n *\n * Others in the conversation see it through the Join banner and join it\n * with `join`. Leaving (`hangUp`) never ends it for them; it ends once\n * it has been empty for a short grace. `media: 'video'` makes it a video\n * room. Rejects when the server refuses (not a member, no SFU) or\n * before `configure`.\n */\nexport async function startRoom(\n conversationId: string,\n options: PlaceOptions,\n): Promise<StartRoomResult> {\n if (!native) throw new Error('The native call core is not in this build.');\n if (!apiOrigin) throw new Error('Call configure() before startRoom().');\n const res = await fetch(\n `${apiOrigin}/v1/conversations/${encodeURIComponent(conversationId)}/room`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${options.sessionToken}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({ media: options.media }),\n },\n );\n const body = (await res.json().catch(() => null)) as {\n created?: boolean;\n call?: { id?: string };\n error?: { message?: string };\n } | null;\n const callId = body?.call?.id;\n if (!res.ok || !callId) {\n throw new Error(body?.error?.message ?? `Couldn't start the room (${res.status}).`);\n }\n // The core joins with its own request: it owns the session from here,\n // exactly as when the room is joined from the banner.\n await native.join(callId, conversationId, options.sessionToken);\n return { callId, created: body?.created === true };\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\nexport const setMuted = (muted: boolean): void => native?.setMuted(muted);\nexport const setCamera = (on: boolean): void => native?.setCamera(on);\nexport const switchCamera = (): void => native?.switchCamera();\n/** Route call audio to the loudspeaker (`true`) or back to earpiece / headset (`false`). */\nexport const setSpeaker = (on: boolean): void => native?.setSpeaker(on);\n\n/**\n * Play call audio through one of `snapshot.audioOutputs`. The choice\n * holds for the rest of the call, until that device disconnects or a new\n * headset / Bluetooth device connects (which takes over, as in the\n * phone's own dialer). Without a choice a connected headset wins, then\n * the earpiece for voice calls or the speaker for video.\n */\nexport function setAudioOutput(id: string): void {\n if (native?.setAudioOutput) native.setAudioOutput(id);\n else native?.setSpeaker(id === 'speaker'); // an older native build\n}\n\n/**\n * Socket forwarding for an open app: a `call:ring_ended` seen on the\n * socket. Idempotent with the push path — the core dedupes.\n */\nexport const onRingEnded = (callId: string, reason: string): void =>\n native?.onRingEnded(callId, reason);\n\n/**\n * Socket forwarding: `call:finished`. Pass the payload's `reason`\n * (`finished` | `declined_all` | `no_answer` | `cancelled`) so an\n * unanswered caller sees \"Declined\"/\"No answer\", not \"Call ended\".\n * Idempotent with the `finished` push.\n */\nexport const onCallFinished = (callId: string, reason?: string): void =>\n native?.onCallFinished(callId, reason ?? null);\n\n/**\n * Android 14+: whether the app may show the full-screen ring over the\n * lock screen. `true` on older Android and where the module is absent.\n */\nexport const canUseFullScreenIntent = (): boolean => native?.canUseFullScreenIntent() ?? true;\n\n/** Open the system screen where the user grants full-screen intents (falls back to notification settings). */\nexport const openFullScreenIntentSettings = (): void => native?.openFullScreenIntentSettings();\n\n// ── missed-call notifications (Android) ─────────────────────────────────\n\n/**\n * Android: the missed-call notification action the app was LAUNCHED\n * with, once — read it on mount. `null` when the app opened any other\n * way. The native core posts that notification itself from the push,\n * so it works with the app closed.\n *\n * iOS shows missed calls as an ordinary alert push (to the token you\n * register with `kind: 'alert'`); read its taps with your notification\n * library — its `type` is `call_missed`, the category\n * `poolse_missed_call`, the Call back action `call_back`.\n */\nexport function takeCallAction(): CallAction | null {\n return native?.takeCallAction?.() ?? null;\n}\n\n/** Android: a missed-call notification tapped while the app is running. */\nexport function onCallAction(cb: (action: CallAction) => void): Unsubscribe {\n if (!native || !native.takeCallAction) return () => undefined;\n const sub = native.addListener('onCallAction', cb);\n return () => sub.remove();\n}\n\n// ── chat notifications ─────────────────────────────────────────────────\n\nlet activeConversation: string | null = null;\n\n/**\n * Tell the core which conversation is on screen (`null` when none, and\n * when the app goes to the background). Its messages are not notified —\n * the user is reading them — and opening it clears the notifications\n * already shown for it.\n */\nexport function setActiveConversation(conversationId: string | null): void {\n activeConversation = conversationId;\n native?.setActiveConversation?.(conversationId);\n}\n\n/**\n * For the app's notification handler (expo-notifications'\n * `setNotificationHandler`): `false` for a chat notification of the\n * conversation on screen, which must not pop over the chat itself.\n * Android never asks — the core skips those natively.\n */\nexport function shouldPresentMessage(data: Record<string, unknown> | undefined | null): boolean {\n if (!data || data['type'] !== 'message') return true;\n return data['conversation_id'] !== activeConversation;\n}\n\n/**\n * The conversation a tapped notification belongs to — a chat message\n * (`type: 'message'`) or a missed call (`call_missed`) — from its data.\n * iOS hands the tap to your notification library; Android delivers it\n * through `takeCallAction` / `onCallAction` (type `open`).\n */\nexport function conversationOfNotification(\n data: Record<string, unknown> | undefined | null,\n): string | null {\n if (!data) return null;\n const type = data['type'];\n if (type !== 'message' && type !== 'call_missed') return null;\n return typeof data['conversation_id'] === 'string' ? data['conversation_id'] : null;\n}\n\n// ── VoIP push (iOS) ───────────────────────────────────────────────────\n\n/**\n * iOS: the current PushKit VoIP token, registered natively at launch.\n * `null` until iOS issues one, and always on Android (FCM carries rings).\n */\nexport function getVoipToken(): VoipToken | null {\n return native?.getVoipToken?.() ?? null;\n}\n\n/** iOS: fires whenever PushKit issues (or rotates) the VoIP token. No-op on Android. */\nexport function onVoipToken(cb: (token: VoipToken) => void): Unsubscribe {\n if (!native || !native.getVoipToken) return () => undefined;\n const sub = native.addListener('onVoipToken', cb);\n return () => sub.remove();\n}\n\n// ── video ─────────────────────────────────────────────────────────────\n\nexport interface CallVideoViewProps extends ViewProps {\n /** Remote participant's userId (LiveKit identity). Omit with local=true for self view. */\n participantId?: string;\n local?: boolean;\n /** default: true for local front camera, false otherwise */\n mirror?: boolean;\n /** default 'cover' */\n fit?: 'cover' | 'contain';\n}\n\ntype NativeVideoProps = CallVideoViewProps;\n\nlet nativeVideo: ComponentType<NativeVideoProps> | null | undefined;\n\n/** Resolved lazily (first render) and only when the native module exists. */\nfunction nativeVideoView(): ComponentType<NativeVideoProps> | null {\n if (nativeVideo !== undefined) return nativeVideo;\n nativeVideo = null;\n if (native) {\n try {\n nativeVideo = requireNativeViewManager<NativeVideoProps>('PoolseCalls', 'PoolseCallVideo');\n } catch {\n nativeVideo = null; // an older native build without the view\n }\n }\n return nativeVideo;\n}\n\n/**\n * A participant's camera (or, with `local`, your own), rendered natively\n * by the call core. Shows nothing (stays transparent) while that camera\n * is off — draw the avatar from the snapshot's `cameraOn` underneath.\n * Without the native module it renders an empty View.\n */\nexport function CallVideoView(props: CallVideoViewProps): ReactElement | null {\n const Native = nativeVideoView();\n if (!Native) {\n const { participantId: _p, local: _l, mirror: _m, fit: _f, ...rest } = props;\n return createElement(View, rest);\n }\n return createElement(Native, props);\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// @poolse/react-native-calls — the React Native face of the native\n// call core (CONTRACT.md §8). A thin, typed surface over the native\n// module: commands in, one snapshot event out. All call logic lives in\n// the native cores; nothing here decides anything about a call.\n//\n// On a platform or build without the native module (Expo Go, web,\n// unlinked), every command is a safe no-op and the state stays idle —\n// so a host can render against this unconditionally.\nimport { createElement, type ComponentType, type ReactElement } from 'react';\nimport { View, type ViewProps } from 'react-native';\nimport { requireNativeViewManager, requireOptionalNativeModule } from 'expo-modules-core';\n\n/**\n * A call's complete state. `null` means idle — no call. After a call\n * ends the snapshot stays `phase: 'ended'` (with `reason`) for ~2.5 s,\n * then becomes `null`.\n */\nexport interface Snapshot {\n cv: 1;\n callId: string;\n conversationId: string;\n conversationName: string | null;\n direction: 'incoming' | 'outgoing' | 'joined';\n /**\n * `room` — rings nobody, joined and left at will (`startRoom`); `call`\n * otherwise. Known once the server answered: a room being joined reads\n * `call` while `connecting`. Absent before 0.9.0: treat as `call`.\n */\n kind?: 'call' | 'room';\n media: 'audio' | 'video';\n phase: 'ringing_in' | 'ringing_out' | 'connecting' | 'active' | 'reconnecting' | 'ended';\n reason?:\n | 'left'\n | 'finished'\n | 'declined_all'\n | 'busy_all'\n | 'no_answer'\n | 'cancelled'\n | 'connection_lost'\n | 'failed';\n /** REMOTE participants only; `userId` is the LiveKit identity. */\n participants: Array<{\n userId: string;\n externalId: string;\n speaking: boolean;\n muted: boolean;\n /** True only with a published, subscribed, unmuted camera track (show video, not avatar). */\n cameraOn: boolean;\n }>;\n /** The kind of output playing now — the short answer to \"where is the audio\". */\n audioRoute: AudioOutputKind;\n /**\n * Every output the call can play through right now: always the phone\n * (earpiece, or wired headphones in its place) and the speaker, plus\n * each connected Bluetooth / USB device by its own name. Empty until\n * the call's audio is up. Pass an `id` to `setAudioOutput`.\n */\n audioOutputs: AudioOutput[];\n /** The `audioOutputs` entry playing now; null before audio is up. */\n audioOutputId: string | null;\n mutedSelf: boolean;\n /** True only while the local camera track is actually published and enabled. */\n cameraOn: boolean;\n /**\n * A second call ringing while this one is in progress (call waiting).\n * Offer `answerWaiting` (\"End & answer\" — this call ends) and\n * `declineWaiting` (its caller hears busy). If this call ends first,\n * the waiting one simply becomes the ringing call. null when none;\n * absent before 0.10.0.\n */\n waiting?: WaitingCall | null;\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/** The call waiting beside the current one. */\nexport interface WaitingCall {\n callId: string;\n conversationId: string | null;\n conversationName: string | null;\n callerName: string | null;\n media: 'audio' | 'video';\n}\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 answerWaiting?(callId: string | null): void;\n declineWaiting?(callId: string | null): void;\n setMuted(muted: boolean): void;\n setCamera(on: boolean): void;\n switchCamera(): void;\n setSpeaker(on: boolean): void;\n setAudioOutput?(id: string): void;\n onRingEnded(callId: string, reason: string): void;\n onCallFinished(callId: string, reason: string | null): void;\n canUseFullScreenIntent(): boolean;\n openFullScreenIntentSettings(): void;\n getVoipToken?(): VoipToken | null;\n takeCallAction?(): CallAction | null;\n setActiveConversation?(conversationId: string | null): void;\n addListener(event: 'onCallState', cb: (payload: NativeSnapshot) => void): { remove(): void };\n addListener(event: 'onVoipToken', cb: (payload: VoipToken) => void): { remove(): void };\n addListener(event: 'onCallAction', cb: (payload: CallAction) => void): { remove(): void };\n}\n\nconst native = requireOptionalNativeModule<PoolseCallsNative>('PoolseCalls');\n\n/** True when the native call core is present in this build. */\nexport const nativeCallsAvailable = native != null;\n\n// Kept for startRoom, the one command that talks to the server from JS.\nlet apiOrigin: string | null = null;\n\n/** Configure once, before any call can arrive. No secrets — just the API origin. */\nexport async function configure(apiBaseUrl: string, options?: ConfigureOptions): Promise<void> {\n apiOrigin = apiBaseUrl.replace(/\\/+$/, '');\n await native?.configure(apiBaseUrl, options ?? null);\n}\n\n/** The current snapshot — safe to read at any moment, including cold answer and before configure. */\nexport function getCallState(): Snapshot | null {\n return native?.getCallState().snapshot ?? null;\n}\n\nexport type Unsubscribe = () => void;\n\n/** Subscribe to call state. Fires a full snapshot on every change. */\nexport function onCallState(cb: (snapshot: Snapshot | null) => void): Unsubscribe {\n if (!native) return () => undefined;\n const sub = native.addListener('onCallState', (payload) => cb(payload.snapshot));\n return () => sub.remove();\n}\n\nexport function place(conversationId: string, options: PlaceOptions): Promise<void> {\n return native?.place(conversationId, options.media, options.sessionToken) ?? Promise.resolve();\n}\n\nexport function join(\n callId: string,\n conversationId: string | null,\n sessionToken: string,\n): Promise<void> {\n return native?.join(callId, conversationId, sessionToken) ?? Promise.resolve();\n}\n\nexport interface StartRoomResult {\n /** The room's call id — what the Join banner and `call:*` events carry. */\n callId: string;\n /** False when something was live already (a room, or a ringing call) and this joined it. */\n created: boolean;\n}\n\n/**\n * Start the conversation's room — a call that rings nobody (CONTRACT.md\n * §5.6) — or join whatever is live there, then carry it on the native\n * core like any joined call: CallKit / the call service, so it keeps\n * going with the phone locked, and the same audio routing as a call.\n *\n * Others in the conversation see it through the Join banner and join it\n * with `join`. Leaving (`hangUp`) never ends it for them; it ends once\n * it has been empty for a short grace. `media: 'video'` makes it a video\n * room. Rejects when the server refuses (not a member, no SFU) or\n * before `configure`.\n */\nexport async function startRoom(\n conversationId: string,\n options: PlaceOptions,\n): Promise<StartRoomResult> {\n if (!native) throw new Error('The native call core is not in this build.');\n if (!apiOrigin) throw new Error('Call configure() before startRoom().');\n const res = await fetch(\n `${apiOrigin}/v1/conversations/${encodeURIComponent(conversationId)}/room`,\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${options.sessionToken}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({ media: options.media }),\n },\n );\n const body = (await res.json().catch(() => null)) as {\n created?: boolean;\n call?: { id?: string };\n error?: { message?: string };\n } | null;\n const callId = body?.call?.id;\n if (!res.ok || !callId) {\n throw new Error(body?.error?.message ?? `Couldn't start the room (${res.status}).`);\n }\n // The core joins with its own request: it owns the session from here,\n // exactly as when the room is joined from the banner.\n await native.join(callId, conversationId, options.sessionToken);\n return { callId, created: body?.created === true };\n}\n\nexport const answer = (): void => native?.answer();\nexport const decline = (): void => native?.decline();\nexport const hangUp = (): void => native?.hangUp();\n\n/**\n * Call waiting: end the current call and answer `snapshot.waiting`.\n * Pass its `callId`: a tap on a card for a call that is no longer the\n * one waiting is then ignored instead of answering a different call.\n */\nexport const answerWaiting = (callId?: string): void => native?.answerWaiting?.(callId ?? null);\n\n/** Call waiting: turn `snapshot.waiting` away — its caller hears busy. */\nexport const declineWaiting = (callId?: string): void => native?.declineWaiting?.(callId ?? null);\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"]}
|
|
@@ -31,6 +31,7 @@ public final class CallCoreRuntime {
|
|
|
31
31
|
private static let T_RECONNECT = "reconnect_budget"
|
|
32
32
|
private static let T_CONNECT = "connect_watchdog"
|
|
33
33
|
private static let T_RING_OUT = "ring_out_backstop"
|
|
34
|
+
private static let T_WAITING = "waiting_deadline"
|
|
34
35
|
|
|
35
36
|
private let http: HttpPort
|
|
36
37
|
private let media: MediaPort
|
|
@@ -45,6 +46,9 @@ public final class CallCoreRuntime {
|
|
|
45
46
|
private var ring: RingContext?
|
|
46
47
|
/// Grants of rings auto-declined busy, kept until the decline fires.
|
|
47
48
|
private var busyRings: [String: RingContext] = [:]
|
|
49
|
+
/// The ring of the call waiting beside the current one (its grant
|
|
50
|
+
/// answers or busy-declines it).
|
|
51
|
+
private var waitingRingCtx: RingContext?
|
|
48
52
|
/// The session bundle of the current call, once the server accepted.
|
|
49
53
|
private var bundle: CallSessionBundle?
|
|
50
54
|
/// The session token place/join were called with, for one retry.
|
|
@@ -101,6 +105,9 @@ public final class CallCoreRuntime {
|
|
|
101
105
|
/// `room` or `call`, once the server's bundle says; nil before. Runtime thread only.
|
|
102
106
|
public var callKind: String? { bundle?.kind }
|
|
103
107
|
|
|
108
|
+
/// The call waiting beside the current one, with its caller's names. Runtime thread only.
|
|
109
|
+
public var waitingRing: RingContext? { state.waiting == nil ? nil : waitingRingCtx }
|
|
110
|
+
|
|
104
111
|
/// True if `callId` was already resolved here — its ring must never surface again.
|
|
105
112
|
public func isResolved(_ callId: String) -> Bool { resolved.contains(callId) }
|
|
106
113
|
|
|
@@ -144,6 +151,22 @@ public final class CallCoreRuntime {
|
|
|
144
151
|
public func answer() { post { [self] in dispatch(.userAnswer) } }
|
|
145
152
|
public func decline() { post { [self] in dispatch(.userDecline) } }
|
|
146
153
|
public func hangUp() { post { [self] in dispatch(.userHangup) } }
|
|
154
|
+
/// Call waiting: end this call and answer the one waiting. With
|
|
155
|
+
/// `callId`, only if that is still the call waiting (a stale tap on a
|
|
156
|
+
/// surface for an earlier one must not answer a different call).
|
|
157
|
+
public func answerWaiting(callId: String? = nil) {
|
|
158
|
+
post { [self] in
|
|
159
|
+
if let id = callId, state.waiting?.callId != id { return }
|
|
160
|
+
dispatch(.userAnswerWaiting)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/// Call waiting: turn the waiting call away (its caller hears busy).
|
|
164
|
+
public func declineWaiting(callId: String? = nil) {
|
|
165
|
+
post { [self] in
|
|
166
|
+
if let id = callId, state.waiting?.callId != id { return }
|
|
167
|
+
dispatch(.userDeclineWaiting)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
147
170
|
|
|
148
171
|
public func place(conversationId: String, media: String, sessionToken: String) {
|
|
149
172
|
post { [self] in
|
|
@@ -196,23 +219,30 @@ public final class CallCoreRuntime {
|
|
|
196
219
|
gen += 1
|
|
197
220
|
markResolved(before.callId)
|
|
198
221
|
}
|
|
199
|
-
for effect in t.effects { execute(effect, ringCtx: ringCtx) }
|
|
222
|
+
for effect in t.effects { execute(effect, ringCtx: ringCtx, before: before) }
|
|
200
223
|
// Retire the ring grant / bundle only after EVERY effect had its chance.
|
|
201
224
|
if isTerminal(next.phase) {
|
|
202
225
|
ring = nil
|
|
203
226
|
bundle = nil
|
|
204
227
|
sessionToken = nil
|
|
205
228
|
}
|
|
229
|
+
// A waiting call that went away without becoming the current one
|
|
230
|
+
// (declined, cancelled, timed out) is resolved: its ring never again.
|
|
231
|
+
if let w = before.waiting, next.waiting?.callId != w.callId, next.callId != w.callId {
|
|
232
|
+
markResolved(w.callId)
|
|
233
|
+
}
|
|
234
|
+
if next.waiting == nil { waitingRingCtx = nil }
|
|
206
235
|
runTimers(before, next)
|
|
207
236
|
if next != before || !t.effects.isEmpty { sink.emit(state: next) }
|
|
208
237
|
}
|
|
209
238
|
|
|
210
|
-
private func execute(_ effect: Effect, ringCtx: RingContext
|
|
239
|
+
private func execute(_ effect: Effect, ringCtx: RingContext?, before: MachineState) {
|
|
211
240
|
let g = gen
|
|
212
241
|
switch effect {
|
|
213
242
|
case .showRing:
|
|
214
|
-
ring
|
|
215
|
-
if let r = ringCtx {
|
|
243
|
+
// A promoted waiting call rings with the ring already on file.
|
|
244
|
+
if let r = ringCtx { ring = r }
|
|
245
|
+
if let r = ring { surface.show(ring: r) }
|
|
216
246
|
case .dismissRing:
|
|
217
247
|
// The grant is NOT retired here: HTTP_DECLINE may follow in this list.
|
|
218
248
|
if let r = ring ?? ringCtx { surface.dismiss(callId: r.callId) }
|
|
@@ -248,7 +278,8 @@ public final class CallCoreRuntime {
|
|
|
248
278
|
case .httpDecline:
|
|
249
279
|
if let r = ring { http.decline(callId: r.callId, auth: r.grant, busy: false) }
|
|
250
280
|
case let .httpDeclineBusy(callId):
|
|
251
|
-
|
|
281
|
+
let waitingMatch = waitingRingCtx?.callId == callId ? waitingRingCtx : nil
|
|
282
|
+
if let r = busyRings[callId] ?? waitingMatch { http.decline(callId: r.callId, auth: r.grant, busy: true) }
|
|
252
283
|
markResolved(callId)
|
|
253
284
|
case .httpHangup:
|
|
254
285
|
if let b = bundle {
|
|
@@ -281,6 +312,22 @@ public final class CallCoreRuntime {
|
|
|
281
312
|
bundle = nil
|
|
282
313
|
ring = nil
|
|
283
314
|
sessionToken = nil
|
|
315
|
+
|
|
316
|
+
case .showWaiting:
|
|
317
|
+
waitingRingCtx = ringCtx
|
|
318
|
+
if let r = ringCtx { surface.showWaiting(ring: r) }
|
|
319
|
+
case .dismissWaiting:
|
|
320
|
+
// The grant is NOT retired here: HTTP_DECLINE_BUSY may precede or follow.
|
|
321
|
+
if let id = before.waiting?.callId { surface.dismissWaiting(callId: id) }
|
|
322
|
+
case .promoteWaiting:
|
|
323
|
+
// The old call is over: nothing it started may land on this one.
|
|
324
|
+
markResolved(before.callId)
|
|
325
|
+
gen += 1
|
|
326
|
+
bundle = nil
|
|
327
|
+
sessionToken = nil
|
|
328
|
+
ring = waitingRingCtx
|
|
329
|
+
waitingRingCtx = nil
|
|
330
|
+
if let id = before.waiting?.callId { surface.promoteWaiting(callId: id) }
|
|
284
331
|
}
|
|
285
332
|
}
|
|
286
333
|
|
|
@@ -341,7 +388,10 @@ public final class CallCoreRuntime {
|
|
|
341
388
|
}
|
|
342
389
|
|
|
343
390
|
private func runTimers(_ before: MachineState, _ after: MachineState) {
|
|
344
|
-
|
|
391
|
+
runWaitingTimer(before, after)
|
|
392
|
+
// A different call in the same phase (end-and-answer while connecting)
|
|
393
|
+
// needs its own timers: the old ones belong to a retired generation.
|
|
394
|
+
if before.phase == after.phase && before.callId == after.callId { return }
|
|
345
395
|
switch before.phase {
|
|
346
396
|
case .ringingIn: timers.cancel(key: CallCoreRuntime.T_DEADLINE)
|
|
347
397
|
case .ended: timers.cancel(key: CallCoreRuntime.T_LINGER)
|
|
@@ -367,6 +417,23 @@ public final class CallCoreRuntime {
|
|
|
367
417
|
default: break
|
|
368
418
|
}
|
|
369
419
|
}
|
|
420
|
+
|
|
421
|
+
private func runWaitingTimer(_ before: MachineState, _ after: MachineState) {
|
|
422
|
+
let was = before.waiting?.callId
|
|
423
|
+
let now = after.waiting?.callId
|
|
424
|
+
if was == now { return }
|
|
425
|
+
if was != nil { timers.cancel(key: CallCoreRuntime.T_WAITING) }
|
|
426
|
+
guard let id = now, let deadline = waitingRingCtx?.ringDeadlineEpochMs else { return }
|
|
427
|
+
let delay = max(deadline - timers.nowMs(), CallCoreRuntime.CLOCK_SLACK_MS)
|
|
428
|
+
// Keyed to the waiting call, not the generation: the current call may
|
|
429
|
+
// move on (connect, reconnect) while this one keeps waiting.
|
|
430
|
+
timers.schedule(key: CallCoreRuntime.T_WAITING, delayMs: delay) { [weak self] in
|
|
431
|
+
self?.post {
|
|
432
|
+
guard let self = self, self.state.waiting?.callId == id else { return }
|
|
433
|
+
self.dispatch(.waitingDeadline)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
370
437
|
}
|
|
371
438
|
|
|
372
439
|
/// The real TimerPort: DispatchQueue.main work items keyed by token.
|
|
@@ -36,6 +36,12 @@ public enum Event: Equatable {
|
|
|
36
36
|
/// CONNECTING watchdog.
|
|
37
37
|
case connectTimeout
|
|
38
38
|
case lingerElapsed
|
|
39
|
+
/// Call waiting: leave the current call and answer the waiting one.
|
|
40
|
+
case userAnswerWaiting
|
|
41
|
+
/// Call waiting: turn the waiting call away — its caller hears busy.
|
|
42
|
+
case userDeclineWaiting
|
|
43
|
+
/// The waiting call's ring deadline passed.
|
|
44
|
+
case waitingDeadline
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
/// Abstract effects — each adapter maps them to its platform.
|
|
@@ -44,6 +50,10 @@ public enum Effect: Equatable {
|
|
|
44
50
|
case httpAnswer, httpJoin, httpPlace, httpDecline
|
|
45
51
|
case httpDeclineBusy(callId: String)
|
|
46
52
|
case httpHangup, httpReconnect, mediaJoin, mediaLeave, callSessionStart, callSessionStop
|
|
53
|
+
case showWaiting, dismissWaiting
|
|
54
|
+
/// The waiting call becomes the current one: retire the old call's
|
|
55
|
+
/// grant/bundle/generation and take the waiting ring's.
|
|
56
|
+
case promoteWaiting
|
|
47
57
|
|
|
48
58
|
public var wire: String {
|
|
49
59
|
switch self {
|
|
@@ -64,6 +74,9 @@ public enum Effect: Equatable {
|
|
|
64
74
|
case .mediaLeave: return "MEDIA_LEAVE"
|
|
65
75
|
case .callSessionStart: return "CALL_SESSION_START"
|
|
66
76
|
case .callSessionStop: return "CALL_SESSION_STOP"
|
|
77
|
+
case .showWaiting: return "SHOW_WAITING"
|
|
78
|
+
case .dismissWaiting: return "DISMISS_WAITING"
|
|
79
|
+
case .promoteWaiting: return "PROMOTE_WAITING"
|
|
67
80
|
}
|
|
68
81
|
}
|
|
69
82
|
}
|
|
@@ -91,7 +104,80 @@ private extension MachineState {
|
|
|
91
104
|
|
|
92
105
|
public enum CallMachine {
|
|
93
106
|
|
|
107
|
+
/// Phases that can hold a call waiting beside them: this device is on
|
|
108
|
+
/// (or placing, or joining) a call. A second ring while one is ringING
|
|
109
|
+
/// in is still busy — two rings at once is not a choice anyone can make.
|
|
110
|
+
private static func canHoldWaiting(_ s: MachineState) -> Bool {
|
|
111
|
+
switch s.phase {
|
|
112
|
+
case .ringingOut, .connecting, .active, .reconnecting: return true
|
|
113
|
+
default: return false
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// The ring surface effects the end-and-answer composite skips: the
|
|
118
|
+
/// waiting call goes straight from waiting to connecting, never ringing.
|
|
119
|
+
private static let ringSurfaceEffects: [Effect] = [.showRing, .ringtoneStart, .ringtoneStop, .dismissRing]
|
|
120
|
+
|
|
94
121
|
public static func transition(_ s: MachineState, _ e: Event) -> Transition {
|
|
122
|
+
guard let w = s.waiting else {
|
|
123
|
+
switch e {
|
|
124
|
+
case let .ring(callId, conversationId, media, stale)
|
|
125
|
+
where !stale && canHoldWaiting(s) && callId != s.callId:
|
|
126
|
+
var next = s
|
|
127
|
+
next.waiting = WaitingCall(callId: callId, conversationId: conversationId, media: media)
|
|
128
|
+
return Transition(next, [.showWaiting])
|
|
129
|
+
case .userAnswerWaiting, .userDeclineWaiting, .waitingDeadline:
|
|
130
|
+
return noop(s)
|
|
131
|
+
default:
|
|
132
|
+
return single(s, e)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
var cleared = s
|
|
137
|
+
cleared.waiting = nil
|
|
138
|
+
switch e {
|
|
139
|
+
case let .ring(callId, _, _, stale):
|
|
140
|
+
if callId == w.callId || stale { return noop(s) }
|
|
141
|
+
if callId == s.callId { return carry(single(cleared, e), w) }
|
|
142
|
+
return Transition(s, [.httpDeclineBusy(callId: callId)])
|
|
143
|
+
|
|
144
|
+
case let .ringEnded(callId, _) where callId == w.callId:
|
|
145
|
+
return Transition(cleared, [.dismissWaiting])
|
|
146
|
+
case let .callFinished(callId, _) where callId == w.callId:
|
|
147
|
+
return Transition(cleared, [.dismissWaiting])
|
|
148
|
+
case .waitingDeadline:
|
|
149
|
+
return Transition(cleared, [.dismissWaiting])
|
|
150
|
+
case .userDeclineWaiting:
|
|
151
|
+
return Transition(cleared, [.httpDeclineBusy(callId: w.callId), .dismissWaiting])
|
|
152
|
+
|
|
153
|
+
case .userAnswerWaiting:
|
|
154
|
+
// Leaving rings the waiting call (below); answering it at once
|
|
155
|
+
// makes that ring a no-op the surface never sees.
|
|
156
|
+
let left = transition(s, .userHangup)
|
|
157
|
+
guard left.state.phase == .ringingIn, left.state.callId == w.callId else { return noop(s) }
|
|
158
|
+
let answered = single(left.state, .userAnswer)
|
|
159
|
+
let effects = (left.effects + answered.effects).filter { !ringSurfaceEffects.contains($0) }
|
|
160
|
+
return Transition(answered.state, effects)
|
|
161
|
+
|
|
162
|
+
default:
|
|
163
|
+
let t = single(cleared, e)
|
|
164
|
+
guard t.state.phase == .ended || t.state.phase == .idle else { return carry(t, w) }
|
|
165
|
+
// The current call is over while another rings beside it: that
|
|
166
|
+
// ring is now simply THE ring (no ended screen in between).
|
|
167
|
+
let ringing = MachineState(phase: .ringingIn, callId: w.callId, conversationId: w.conversationId,
|
|
168
|
+
direction: .incoming, media: w.media)
|
|
169
|
+
return Transition(ringing, t.effects + [.promoteWaiting, .showRing, .ringtoneStart])
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private static func carry(_ t: Transition, _ w: WaitingCall) -> Transition {
|
|
174
|
+
var state = t.state
|
|
175
|
+
state.waiting = w
|
|
176
|
+
return Transition(state, t.effects)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// The one-call machine: everything that is not about a waiting call.
|
|
180
|
+
private static func single(_ s: MachineState, _ e: Event) -> Transition {
|
|
95
181
|
switch e {
|
|
96
182
|
|
|
97
183
|
case let .ring(callId, conversationId, media, stale):
|
|
@@ -271,6 +357,9 @@ public enum CallMachine {
|
|
|
271
357
|
|
|
272
358
|
case .lingerElapsed:
|
|
273
359
|
return s.phase == .ended ? Transition(.IDLE, []) : noop(s)
|
|
360
|
+
|
|
361
|
+
case .userAnswerWaiting, .userDeclineWaiting, .waitingDeadline:
|
|
362
|
+
return noop(s) // handled by `transition`, which owns the waiting slot
|
|
274
363
|
}
|
|
275
364
|
}
|
|
276
365
|
|
|
@@ -285,7 +374,8 @@ public enum CallMachine {
|
|
|
285
374
|
direction: .incoming, media: media),
|
|
286
375
|
[.showRing, .ringtoneStart])
|
|
287
376
|
}
|
|
288
|
-
// Busy
|
|
377
|
+
// Busy ringing another call (a call in progress holds it waiting
|
|
378
|
+
// instead — see `transition`): auto-decline the newcomer.
|
|
289
379
|
return Transition(s, [.httpDeclineBusy(callId: callId)])
|
|
290
380
|
}
|
|
291
381
|
|