@lorikeetai/react-native-sdk 2.1.0 → 2.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -397,6 +397,165 @@ WebView component that handles the widget content.
397
397
  - `webviewProps` (optional): Passed through to the webview component. The SDK
398
398
  adds external-link handling on `onNavigationStateChange` and then calls yours
399
399
 
400
+ ## Voice calls (beta)
401
+
402
+ `@lorikeetai/react-native-sdk/voice` runs a Lorikeet voice call inside your
403
+ app with native audio, so the customer can keep talking while the app is in
404
+ the background. It is a beta. Install it from the `beta` tag, and expect its
405
+ API to change before it is stable.
406
+
407
+ Nothing here touches the chat widget. Only the `voice` entry point loads
408
+ LiveKit, so an app that never imports it installs nothing new and needs no
409
+ native rebuild.
410
+
411
+ ### Install
412
+
413
+ The LiveKit packages are optional peer dependencies, so install them yourself:
414
+
415
+ ```bash
416
+ pnpm add @lorikeetai/react-native-sdk@beta \
417
+ @livekit/react-native@^3.1.0 \
418
+ @livekit/react-native-webrtc@^150.1.0 \
419
+ livekit-client@^2.20.0
420
+ ```
421
+
422
+ `@livekit/react-native` depends on `@livekit/components-react`, which needs
423
+ `react-dom`. Install the `react-dom` version that matches your `react`, or the
424
+ package manager may pick a newer one that your `react` does not satisfy.
425
+
426
+ Do not install `@livekit/krisp-noise-filter`. The call does not use it, and
427
+ its license is not open source.
428
+
429
+ Voice requires React Native 0.79 or later, though the chat widget's peer range
430
+ is unchanged. The `voice` subpath is in the package's `exports` map, and 0.79
431
+ ships the first Metro (0.82) that reads that map by default. TypeScript reads
432
+ it with `moduleResolution` set to `bundler` or `node16`, and Expo's base
433
+ `tsconfig` uses `bundler`.
434
+
435
+ ### Native setup
436
+
437
+ These are native modules. Expo Go does not include them, so use a
438
+ [development build](https://docs.expo.dev/develop/development-builds/introduction/)
439
+ on Expo, and rebuild native code after installing.
440
+
441
+ With Expo SDK 56 or later, add the two config plugins, the iOS microphone
442
+ string and audio background mode, and the Android audio permissions to
443
+ `app.json`, then run `expo prebuild`:
444
+
445
+ ```bash
446
+ npx expo install @livekit/react-native-expo-plugin @config-plugins/react-native-webrtc
447
+ ```
448
+
449
+ ```json
450
+ {
451
+ "expo": {
452
+ "platforms": ["ios", "android"],
453
+ "plugins": [
454
+ "@livekit/react-native-expo-plugin",
455
+ "@config-plugins/react-native-webrtc"
456
+ ],
457
+ "ios": {
458
+ "infoPlist": {
459
+ "NSMicrophoneUsageDescription": "Talk to our support team.",
460
+ "UIBackgroundModes": ["audio"]
461
+ }
462
+ },
463
+ "android": {
464
+ "permissions": [
465
+ "android.permission.RECORD_AUDIO",
466
+ "android.permission.MODIFY_AUDIO_SETTINGS"
467
+ ]
468
+ }
469
+ }
470
+ }
471
+ ```
472
+
473
+ Set `platforms` to iOS and Android. Expo treats an app with `react-dom`
474
+ installed as web-capable, so `expo export` would also try a web bundle, which
475
+ LiveKit's native modules cannot build.
476
+
477
+ Use version 1.1.0 or later of `@livekit/react-native-expo-plugin`. It adds the
478
+ `livekit/podspecs` CocoaPods source, which LiveKit's iOS pods need once
479
+ CocoaPods trunk stops accepting new pods on 2 December 2026.
480
+
481
+ Without Expo, follow LiveKit's
482
+ [installation steps](https://github.com/livekit/client-sdk-react-native#installation).
483
+ Add the `livekit/podspecs` source to your `Podfile`, call
484
+ `LiveKitReactNative.setup` in `MainApplication` and `LivekitReactNative.setup`
485
+ in `AppDelegate`, and add the same permissions and background mode as above.
486
+
487
+ On Android, the microphone keeps working in the background only while your
488
+ app runs a foreground service of type `microphone`, which the SDK does not
489
+ start for you. Without one, the agent can still be heard, but the customer's
490
+ microphone goes silent when the app leaves the foreground.
491
+
492
+ ### Usage
493
+
494
+ Your backend creates the session with
495
+ `POST /v1/conversation/voice/web/create`, using your API credentials, and
496
+ returns its `{ conversationId, wsUrl, token }` to the app. Never put your API
497
+ credentials in the app.
498
+
499
+ ```tsx
500
+ import { Button, Text, View } from 'react-native'
501
+
502
+ import { useLorikeetCall } from '@lorikeetai/react-native-sdk/voice'
503
+
504
+ export function SupportCall() {
505
+ const call = useLorikeetCall({
506
+ getSession: async () => {
507
+ const response = await fetch('https://api.example.com/support/call', {
508
+ method: 'POST',
509
+ })
510
+ if (response.status === 404) return null
511
+ if (!response.ok) throw new Error(`Call session: ${response.status}`)
512
+ return response.json()
513
+ },
514
+ onError: (cause) => console.warn('Support call failed', cause),
515
+ })
516
+
517
+ return (
518
+ <View>
519
+ <Text>{call.state}</Text>
520
+ {call.state === 'idle' || call.state === 'error' ? (
521
+ <Button title="Start call" onPress={() => void call.start()} />
522
+ ) : (
523
+ <Button title="End call" onPress={call.end} />
524
+ )}
525
+ </View>
526
+ )
527
+ }
528
+ ```
529
+
530
+ `useLorikeetCall({ getSession, onError })` returns:
531
+
532
+ - `state`: `idle`, `starting`, `connecting`, `active` or `error`, as in the web
533
+ Voice Widget. `starting` covers asking for the microphone and fetching the
534
+ session, and `connecting` covers joining the call.
535
+ - `error`: why the call is in `error`. `microphone_denied` means the customer
536
+ refused the microphone, or it is off for the app in the device's settings.
537
+ `not_available` means `getSession` returned `null`. `failed` is anything
538
+ else, and `onError` receives its cause. `microphone_system_denied` is the web
539
+ Voice Widget's code for a browser blocked by the operating system, and a
540
+ mobile device never reports it.
541
+ - `start()`: start a call from `idle` or `error`. The hook asks for microphone
542
+ access before it calls `getSession`, so a refusal starts no conversation.
543
+ - `end()`: hang up, or abandon a call still starting. The call also ends, and
544
+ returns to `idle`, when the agent leaves.
545
+ - `ended`: whether the last call ended after it began connecting.
546
+ - `microphoneMuted` and `setMicrophoneMuted(muted)`: mute the customer.
547
+ - `speakerMuted` and `setSpeakerMuted(muted)`: mute the agent.
548
+
549
+ The call has no live transcript. The conversation is in Lorikeet under its
550
+ `conversationId`.
551
+
552
+ If your app already calls LiveKit's `registerGlobals()`, the hook leaves your
553
+ configuration alone. Otherwise the hook calls it when the first call starts.
554
+
555
+ The hook owns the native audio session for the length of a call and stops it
556
+ when the call ends. An app that runs its own LiveKit call at the same time
557
+ should not use the hook.
558
+
400
559
  ## Development
401
560
 
402
561
  This package is outside the root pnpm workspace, on its own lockfile, so run these from `packages/react-native-widget` after `pnpm install` there.
package/dist/voice.cjs ADDED
@@ -0,0 +1,175 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ let react = require("react");
3
+ let _livekit_react_native = require("@livekit/react-native");
4
+ let livekit_client = require("livekit-client");
5
+
6
+ // src/voice/use-lorikeet-call.ts
7
+ /** The server's `LORIKEET_AGENT_IDENTITY`. The call ends when it leaves. */
8
+ const LORIKEET_AGENT_IDENTITY = "lorikeet-agent";
9
+ const errorFor = (error) => {
10
+ if (typeof error !== "object" || error === null || !("name" in error)) return "failed";
11
+ return error.name === "SecurityError" || error.name === "NotAllowedError" ? "microphone_denied" : "failed";
12
+ };
13
+ const isSession = (value) => typeof value === "object" && value !== null && "conversationId" in value && typeof value.conversationId === "string" && "token" in value && typeof value.token === "string" && "wsUrl" in value && typeof value.wsUrl === "string";
14
+ /**
15
+ * Runs a Lorikeet voice call over LiveKit's native audio, with the web Voice
16
+ * Widget's states and errors.
17
+ */
18
+ function useLorikeetCall({ getSession, onError }) {
19
+ const [state, setState] = (0, react.useState)("idle");
20
+ const [error, setError] = (0, react.useState)();
21
+ const [ended, setEnded] = (0, react.useState)(false);
22
+ const [microphoneMuted, setMicrophoneMutedState] = (0, react.useState)(false);
23
+ const [speakerMuted, setSpeakerMutedState] = (0, react.useState)(false);
24
+ const stateRef = (0, react.useRef)(state);
25
+ const generationRef = (0, react.useRef)(0);
26
+ const localTrackRef = (0, react.useRef)(null);
27
+ const roomRef = (0, react.useRef)(null);
28
+ const speakerMutedRef = (0, react.useRef)(false);
29
+ const audioSessionActiveRef = (0, react.useRef)(false);
30
+ const audioSessionStoppedRef = (0, react.useRef)(Promise.resolve());
31
+ const optionsRef = (0, react.useRef)({
32
+ getSession,
33
+ onError
34
+ });
35
+ optionsRef.current = {
36
+ getSession,
37
+ onError
38
+ };
39
+ const transitionTo = (0, react.useCallback)((next) => {
40
+ stateRef.current = next;
41
+ setState(next);
42
+ }, []);
43
+ const cleanup = (0, react.useCallback)(() => {
44
+ var _localTrackRef$curren;
45
+ (_localTrackRef$curren = localTrackRef.current) === null || _localTrackRef$curren === void 0 || _localTrackRef$curren.stop();
46
+ localTrackRef.current = null;
47
+ const room = roomRef.current;
48
+ roomRef.current = null;
49
+ const disconnected = (room ? room.disconnect() : Promise.resolve()).catch(() => void 0);
50
+ if (audioSessionActiveRef.current) {
51
+ audioSessionActiveRef.current = false;
52
+ audioSessionStoppedRef.current = disconnected.then(() => _livekit_react_native.AudioSession.stopAudioSession()).catch(() => void 0);
53
+ }
54
+ }, []);
55
+ const resetMutes = (0, react.useCallback)(() => {
56
+ setMicrophoneMutedState(false);
57
+ setSpeakerMutedState(false);
58
+ speakerMutedRef.current = false;
59
+ }, []);
60
+ const end = (0, react.useCallback)(() => {
61
+ generationRef.current += 1;
62
+ cleanup();
63
+ setEnded(stateRef.current === "connecting" || stateRef.current === "active");
64
+ setError(void 0);
65
+ resetMutes();
66
+ transitionTo("idle");
67
+ }, [
68
+ cleanup,
69
+ resetMutes,
70
+ transitionTo
71
+ ]);
72
+ const fail = (0, react.useCallback)((callError) => {
73
+ generationRef.current += 1;
74
+ cleanup();
75
+ setEnded(false);
76
+ setError(callError);
77
+ transitionTo("error");
78
+ }, [cleanup, transitionTo]);
79
+ (0, react.useEffect)(() => {
80
+ return () => {
81
+ generationRef.current += 1;
82
+ cleanup();
83
+ };
84
+ }, [cleanup]);
85
+ const start = (0, react.useCallback)(async () => {
86
+ if (stateRef.current !== "idle" && stateRef.current !== "error") return;
87
+ setError(void 0);
88
+ setEnded(false);
89
+ resetMutes();
90
+ transitionTo("starting");
91
+ const generation = ++generationRef.current;
92
+ const isStale = () => generationRef.current !== generation;
93
+ try {
94
+ if (!("LiveKitReactNativeGlobal" in globalThis)) (0, _livekit_react_native.registerGlobals)();
95
+ await audioSessionStoppedRef.current;
96
+ if (isStale()) return;
97
+ audioSessionActiveRef.current = true;
98
+ await _livekit_react_native.AudioSession.startAudioSession();
99
+ if (isStale()) return;
100
+ const localTrack = await (0, livekit_client.createLocalAudioTrack)();
101
+ if (isStale()) {
102
+ localTrack.stop();
103
+ return;
104
+ }
105
+ localTrackRef.current = localTrack;
106
+ const session = await optionsRef.current.getSession();
107
+ if (isStale()) return;
108
+ if (session === null) {
109
+ fail("not_available");
110
+ return;
111
+ }
112
+ if (!isSession(session)) throw new TypeError("getSession must resolve to { conversationId, wsUrl, token } or null");
113
+ transitionTo("connecting");
114
+ const room = new livekit_client.Room();
115
+ roomRef.current = room;
116
+ const isCurrentRoom = () => roomRef.current === room;
117
+ room.on(livekit_client.RoomEvent.ParticipantDisconnected, (participant) => {
118
+ if (isCurrentRoom() && participant.identity === "lorikeet-agent") end();
119
+ });
120
+ room.on(livekit_client.RoomEvent.TrackSubscribed, (track) => {
121
+ if (isCurrentRoom() && track instanceof livekit_client.RemoteAudioTrack) track.setVolume(speakerMutedRef.current ? 0 : 1);
122
+ });
123
+ let joined = false;
124
+ room.on(livekit_client.RoomEvent.Disconnected, () => {
125
+ if (isCurrentRoom() && joined) end();
126
+ });
127
+ await room.connect(session.wsUrl, session.token, { autoSubscribe: true });
128
+ if (isStale()) return;
129
+ joined = true;
130
+ await room.localParticipant.publishTrack(localTrack, { source: livekit_client.Track.Source.Microphone });
131
+ if (isStale()) return;
132
+ transitionTo("active");
133
+ } catch (caught) {
134
+ if (isStale()) return;
135
+ const callError = errorFor(caught);
136
+ fail(callError);
137
+ if (callError === "failed") try {
138
+ var _optionsRef$current$o, _optionsRef$current;
139
+ (_optionsRef$current$o = (_optionsRef$current = optionsRef.current).onError) === null || _optionsRef$current$o === void 0 || _optionsRef$current$o.call(_optionsRef$current, caught);
140
+ } catch {}
141
+ }
142
+ }, [
143
+ end,
144
+ fail,
145
+ resetMutes,
146
+ transitionTo
147
+ ]);
148
+ return {
149
+ end,
150
+ ended,
151
+ error,
152
+ microphoneMuted,
153
+ setMicrophoneMuted: (0, react.useCallback)((muted) => {
154
+ const track = localTrackRef.current;
155
+ if (!track) return;
156
+ const reconcile = () => {
157
+ if (localTrackRef.current === track) setMicrophoneMutedState(track.isMuted);
158
+ };
159
+ (muted ? track.mute() : track.unmute()).then(reconcile, reconcile);
160
+ }, []),
161
+ setSpeakerMuted: (0, react.useCallback)((muted) => {
162
+ var _roomRef$current$remo, _roomRef$current;
163
+ speakerMutedRef.current = muted;
164
+ const participants = (_roomRef$current$remo = (_roomRef$current = roomRef.current) === null || _roomRef$current === void 0 ? void 0 : _roomRef$current.remoteParticipants.values()) !== null && _roomRef$current$remo !== void 0 ? _roomRef$current$remo : [];
165
+ for (const participant of participants) for (const { track } of participant.audioTrackPublications.values()) if (track instanceof livekit_client.RemoteAudioTrack) track.setVolume(muted ? 0 : 1);
166
+ setSpeakerMutedState(muted);
167
+ }, []),
168
+ speakerMuted,
169
+ start,
170
+ state
171
+ };
172
+ }
173
+
174
+ exports.useLorikeetCall = useLorikeetCall;
175
+ //# sourceMappingURL=voice.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice.cjs","names":["useState","useRef","useCallback","AudioSession","createLocalAudioTrack","Room","RoomEvent","RemoteAudioTrack","Track"],"sources":["../src/voice/use-lorikeet-call.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react'\n\nimport { AudioSession, registerGlobals } from '@livekit/react-native'\nimport {\n createLocalAudioTrack,\n type LocalAudioTrack,\n RemoteAudioTrack,\n Room,\n RoomEvent,\n Track,\n} from 'livekit-client'\n\nimport type {\n LorikeetCall,\n LorikeetCallError,\n LorikeetCallSession,\n LorikeetCallState,\n UseLorikeetCallOptions,\n} from './types'\n\n/** The server's `LORIKEET_AGENT_IDENTITY`. The call ends when it leaves. */\nexport const LORIKEET_AGENT_IDENTITY = 'lorikeet-agent'\n\n// react-native-webrtc rejects a refused permission with a plain object named\n// `SecurityError`, not a DOMException, so match on the name alone.\nconst errorFor = (error: unknown): LorikeetCallError => {\n if (typeof error !== 'object' || error === null || !('name' in error)) {\n return 'failed'\n }\n return error.name === 'SecurityError' || error.name === 'NotAllowedError'\n ? 'microphone_denied'\n : 'failed'\n}\n\nconst isSession = (value: unknown): value is LorikeetCallSession =>\n typeof value === 'object' &&\n value !== null &&\n 'conversationId' in value &&\n typeof value.conversationId === 'string' &&\n 'token' in value &&\n typeof value.token === 'string' &&\n 'wsUrl' in value &&\n typeof value.wsUrl === 'string'\n\n/**\n * Runs a Lorikeet voice call over LiveKit's native audio, with the web Voice\n * Widget's states and errors.\n */\nexport function useLorikeetCall({\n getSession,\n onError,\n}: UseLorikeetCallOptions): LorikeetCall {\n const [state, setState] = useState<LorikeetCallState>('idle')\n const [error, setError] = useState<LorikeetCallError | undefined>()\n const [ended, setEnded] = useState(false)\n const [microphoneMuted, setMicrophoneMutedState] = useState(false)\n const [speakerMuted, setSpeakerMutedState] = useState(false)\n\n const stateRef = useRef(state)\n const generationRef = useRef(0)\n const localTrackRef = useRef<LocalAudioTrack | null>(null)\n const roomRef = useRef<Room | null>(null)\n const speakerMutedRef = useRef(false)\n const audioSessionActiveRef = useRef(false)\n // The previous call's teardown, so the next call cannot start its audio\n // session only to have that teardown stop it.\n const audioSessionStoppedRef = useRef<Promise<void>>(Promise.resolve())\n // Read at call time, so a host passing inline functions keeps `start` stable.\n const optionsRef = useRef({ getSession, onError })\n optionsRef.current = { getSession, onError }\n\n const transitionTo = useCallback((next: LorikeetCallState) => {\n stateRef.current = next\n setState(next)\n }, [])\n\n const cleanup = useCallback(() => {\n localTrackRef.current?.stop()\n localTrackRef.current = null\n const room = roomRef.current\n roomRef.current = null\n // Nothing is left to report a teardown failure to, and a failed disconnect\n // must not leave the native audio session running.\n const disconnected = (room ? room.disconnect() : Promise.resolve()).catch(\n () => undefined,\n )\n if (audioSessionActiveRef.current) {\n audioSessionActiveRef.current = false\n audioSessionStoppedRef.current = disconnected\n .then(() => AudioSession.stopAudioSession())\n .catch(() => undefined)\n }\n }, [])\n\n const resetMutes = useCallback(() => {\n setMicrophoneMutedState(false)\n setSpeakerMutedState(false)\n speakerMutedRef.current = false\n }, [])\n\n const end = useCallback(() => {\n generationRef.current += 1\n cleanup()\n setEnded(stateRef.current === 'connecting' || stateRef.current === 'active')\n setError(undefined)\n resetMutes()\n transitionTo('idle')\n }, [cleanup, resetMutes, transitionTo])\n\n const fail = useCallback(\n (callError: LorikeetCallError) => {\n generationRef.current += 1\n cleanup()\n setEnded(false)\n setError(callError)\n transitionTo('error')\n },\n [cleanup, transitionTo],\n )\n\n useEffect(() => {\n return () => {\n generationRef.current += 1\n cleanup()\n }\n }, [cleanup])\n\n const start = useCallback(async () => {\n if (stateRef.current !== 'idle' && stateRef.current !== 'error') {\n return\n }\n\n setError(undefined)\n setEnded(false)\n resetMutes()\n transitionTo('starting')\n const generation = ++generationRef.current\n const isStale = () => generationRef.current !== generation\n\n try {\n // `registerGlobals` sets this. Calling it again would replace any iOS\n // audio setup a host that already uses LiveKit configured itself.\n if (!('LiveKitReactNativeGlobal' in globalThis)) {\n registerGlobals()\n }\n await audioSessionStoppedRef.current\n if (isStale()) {\n return\n }\n audioSessionActiveRef.current = true\n await AudioSession.startAudioSession()\n if (isStale()) {\n return\n }\n\n const localTrack = await createLocalAudioTrack()\n if (isStale()) {\n localTrack.stop()\n return\n }\n localTrackRef.current = localTrack\n\n const session = await optionsRef.current.getSession()\n if (isStale()) {\n return\n }\n if (session === null) {\n fail('not_available')\n return\n }\n if (!isSession(session)) {\n throw new TypeError(\n 'getSession must resolve to { conversationId, wsUrl, token } or null',\n )\n }\n transitionTo('connecting')\n\n const room = new Room()\n roomRef.current = room\n const isCurrentRoom = () => roomRef.current === room\n room.on(RoomEvent.ParticipantDisconnected, (participant) => {\n if (\n isCurrentRoom() &&\n participant.identity === LORIKEET_AGENT_IDENTITY\n ) {\n end()\n }\n })\n room.on(RoomEvent.TrackSubscribed, (track) => {\n if (isCurrentRoom() && track instanceof RemoteAudioTrack) {\n track.setVolume(speakerMutedRef.current ? 0 : 1)\n }\n })\n // A failed join also emits Disconnected, just before `connect` rejects,\n // and has to surface as `failed` rather than as a hang-up.\n let joined = false\n room.on(RoomEvent.Disconnected, () => {\n if (isCurrentRoom() && joined) {\n end()\n }\n })\n\n await room.connect(session.wsUrl, session.token, { autoSubscribe: true })\n if (isStale()) {\n return\n }\n joined = true\n await room.localParticipant.publishTrack(localTrack, {\n source: Track.Source.Microphone,\n })\n if (isStale()) {\n return\n }\n transitionTo('active')\n } catch (caught) {\n if (isStale()) {\n return\n }\n const callError = errorFor(caught)\n fail(callError)\n if (callError === 'failed') {\n try {\n optionsRef.current.onError?.(caught)\n } catch {\n // A host's failing error handler must not turn into an unhandled\n // rejection from a `start()` that callers fire and forget.\n }\n }\n }\n }, [end, fail, resetMutes, transitionTo])\n\n const setMicrophoneMuted = useCallback((muted: boolean) => {\n const track = localTrackRef.current\n if (!track) {\n return\n }\n // Report what the track did rather than what was asked, so a mute that\n // fails is not shown as muted while the customer is still heard.\n const reconcile = () => {\n if (localTrackRef.current === track) {\n setMicrophoneMutedState(track.isMuted)\n }\n }\n void (muted ? track.mute() : track.unmute()).then(reconcile, reconcile)\n }, [])\n\n const setSpeakerMuted = useCallback((muted: boolean) => {\n speakerMutedRef.current = muted\n const participants = roomRef.current?.remoteParticipants.values() ?? []\n for (const participant of participants) {\n for (const { track } of participant.audioTrackPublications.values()) {\n if (track instanceof RemoteAudioTrack) {\n track.setVolume(muted ? 0 : 1)\n }\n }\n }\n setSpeakerMutedState(muted)\n }, [])\n\n return {\n end,\n ended,\n error,\n microphoneMuted,\n setMicrophoneMuted,\n setSpeakerMuted,\n speakerMuted,\n start,\n state,\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * What `POST /v1/conversation/voice/web/create` returns. Your backend calls it
3
+ * with your API credentials and hands the result to the app, which must never
4
+ * hold those credentials itself.
5
+ */
6
+ interface LorikeetCallSession {
7
+ /** The conversation the call belongs to. */
8
+ conversationId: string;
9
+ /** The token that admits this customer to the call's room. */
10
+ token: string;
11
+ /** The voice server to connect to. */
12
+ wsUrl: string;
13
+ }
14
+ /** The web Voice Widget's call states. */
15
+ type LorikeetCallState = 'idle' | 'starting' | 'connecting' | 'active' | 'error';
16
+ /**
17
+ * Why a call is in `error`, from the web Voice Widget's set.
18
+ *
19
+ * - `not_available`: `getSession` returned `null`.
20
+ * - `microphone_denied`: the customer refused microphone access, or it is off
21
+ * for the app in the device's settings.
22
+ * - `microphone_system_denied`: the web Voice Widget's code for a browser
23
+ * blocked by the operating system. Kept so one handler covers both
24
+ * platforms. A mobile device reports `microphone_denied` instead.
25
+ * - `failed`: anything else. `onError` receives the cause.
26
+ */
27
+ type LorikeetCallError = 'not_available' | 'microphone_denied' | 'microphone_system_denied' | 'failed';
28
+ interface UseLorikeetCallOptions {
29
+ /**
30
+ * Fetches a session from your backend for a new call. Called each time the
31
+ * customer starts one, after microphone access is granted, so a refused
32
+ * microphone starts no conversation.
33
+ *
34
+ * Return `null` when calling is not available to this customer, which puts
35
+ * the call in `error` with `not_available`. Throw, or return anything that
36
+ * is not a session, for `failed`.
37
+ */
38
+ getSession: () => Promise<LorikeetCallSession | null>;
39
+ /**
40
+ * Receives the cause whenever a call fails with `failed`. Anything it throws
41
+ * is ignored.
42
+ */
43
+ onError?: (cause: unknown) => void;
44
+ }
45
+ interface LorikeetCall {
46
+ /** End the call, or abandon one still starting. The state returns to `idle`. */
47
+ end: () => void;
48
+ /**
49
+ * Whether the last call ended, by either side, after it began connecting.
50
+ * Reset when the next call starts.
51
+ */
52
+ ended: boolean;
53
+ /** Set while `state` is `error`. */
54
+ error: LorikeetCallError | undefined;
55
+ microphoneMuted: boolean;
56
+ /** Mute or unmute the customer's microphone. Only applies to a call in progress. */
57
+ setMicrophoneMuted: (muted: boolean) => void;
58
+ /** Mute or unmute the agent's audio. */
59
+ setSpeakerMuted: (muted: boolean) => void;
60
+ speakerMuted: boolean;
61
+ /**
62
+ * Start a call from `idle` or `error`. Does nothing in any other state.
63
+ * Never rejects: a failure is reported through `state` and `error`.
64
+ */
65
+ start: () => Promise<void>;
66
+ state: LorikeetCallState;
67
+ }
68
+ /**
69
+ * Runs a Lorikeet voice call over LiveKit's native audio, with the web Voice
70
+ * Widget's states and errors.
71
+ */
72
+ declare function useLorikeetCall({ getSession, onError }: UseLorikeetCallOptions): LorikeetCall;
73
+ export { type LorikeetCall, type LorikeetCallError, type LorikeetCallSession, type LorikeetCallState, type UseLorikeetCallOptions, useLorikeetCall };
74
+ //# sourceMappingURL=voice.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice.d.cts","names":[],"sources":["../src/voice/types.ts","../src/voice/use-lorikeet-call.ts"],"mappings":";;;;;UAKiB;;EAEf;;EAEA;;EAEA;;;KAIU;;;;;;;;;;;;KAkBA;UAMK;;;;;;;;;;EAUf,kBAAkB,QAAQ;;;;;EAK1B,WAAW;;UAGI;;EAEf;;;;;EAKA;;EAEA,OAAO;EACP;;EAEA,qBAAqB;;EAErB,kBAAkB;EAClB;;;;;EAKA,aAAa;EACb,OAAO;;;;;;iBC9BO,kBACd,YACA,WACC,yBAAyB"}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * What `POST /v1/conversation/voice/web/create` returns. Your backend calls it
3
+ * with your API credentials and hands the result to the app, which must never
4
+ * hold those credentials itself.
5
+ */
6
+ interface LorikeetCallSession {
7
+ /** The conversation the call belongs to. */
8
+ conversationId: string;
9
+ /** The token that admits this customer to the call's room. */
10
+ token: string;
11
+ /** The voice server to connect to. */
12
+ wsUrl: string;
13
+ }
14
+ /** The web Voice Widget's call states. */
15
+ type LorikeetCallState = 'idle' | 'starting' | 'connecting' | 'active' | 'error';
16
+ /**
17
+ * Why a call is in `error`, from the web Voice Widget's set.
18
+ *
19
+ * - `not_available`: `getSession` returned `null`.
20
+ * - `microphone_denied`: the customer refused microphone access, or it is off
21
+ * for the app in the device's settings.
22
+ * - `microphone_system_denied`: the web Voice Widget's code for a browser
23
+ * blocked by the operating system. Kept so one handler covers both
24
+ * platforms. A mobile device reports `microphone_denied` instead.
25
+ * - `failed`: anything else. `onError` receives the cause.
26
+ */
27
+ type LorikeetCallError = 'not_available' | 'microphone_denied' | 'microphone_system_denied' | 'failed';
28
+ interface UseLorikeetCallOptions {
29
+ /**
30
+ * Fetches a session from your backend for a new call. Called each time the
31
+ * customer starts one, after microphone access is granted, so a refused
32
+ * microphone starts no conversation.
33
+ *
34
+ * Return `null` when calling is not available to this customer, which puts
35
+ * the call in `error` with `not_available`. Throw, or return anything that
36
+ * is not a session, for `failed`.
37
+ */
38
+ getSession: () => Promise<LorikeetCallSession | null>;
39
+ /**
40
+ * Receives the cause whenever a call fails with `failed`. Anything it throws
41
+ * is ignored.
42
+ */
43
+ onError?: (cause: unknown) => void;
44
+ }
45
+ interface LorikeetCall {
46
+ /** End the call, or abandon one still starting. The state returns to `idle`. */
47
+ end: () => void;
48
+ /**
49
+ * Whether the last call ended, by either side, after it began connecting.
50
+ * Reset when the next call starts.
51
+ */
52
+ ended: boolean;
53
+ /** Set while `state` is `error`. */
54
+ error: LorikeetCallError | undefined;
55
+ microphoneMuted: boolean;
56
+ /** Mute or unmute the customer's microphone. Only applies to a call in progress. */
57
+ setMicrophoneMuted: (muted: boolean) => void;
58
+ /** Mute or unmute the agent's audio. */
59
+ setSpeakerMuted: (muted: boolean) => void;
60
+ speakerMuted: boolean;
61
+ /**
62
+ * Start a call from `idle` or `error`. Does nothing in any other state.
63
+ * Never rejects: a failure is reported through `state` and `error`.
64
+ */
65
+ start: () => Promise<void>;
66
+ state: LorikeetCallState;
67
+ }
68
+ /**
69
+ * Runs a Lorikeet voice call over LiveKit's native audio, with the web Voice
70
+ * Widget's states and errors.
71
+ */
72
+ declare function useLorikeetCall({ getSession, onError }: UseLorikeetCallOptions): LorikeetCall;
73
+ export { type LorikeetCall, type LorikeetCallError, type LorikeetCallSession, type LorikeetCallState, type UseLorikeetCallOptions, useLorikeetCall };
74
+ //# sourceMappingURL=voice.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice.d.ts","names":[],"sources":["../src/voice/types.ts","../src/voice/use-lorikeet-call.ts"],"mappings":";;;;;UAKiB;;EAEf;;EAEA;;EAEA;;;KAIU;;;;;;;;;;;;KAkBA;UAMK;;;;;;;;;;EAUf,kBAAkB,QAAQ;;;;;EAK1B,WAAW;;UAGI;;EAEf;;;;;EAKA;;EAEA,OAAO;EACP;;EAEA,qBAAqB;;EAErB,kBAAkB;EAClB;;;;;EAKA,aAAa;EACb,OAAO;;;;;;iBC9BO,kBACd,YACA,WACC,yBAAyB"}
package/dist/voice.js ADDED
@@ -0,0 +1,174 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { AudioSession, registerGlobals } from "@livekit/react-native";
3
+ import { RemoteAudioTrack, Room, RoomEvent, Track, createLocalAudioTrack } from "livekit-client";
4
+
5
+ // src/voice/use-lorikeet-call.ts
6
+ /** The server's `LORIKEET_AGENT_IDENTITY`. The call ends when it leaves. */
7
+ const LORIKEET_AGENT_IDENTITY = "lorikeet-agent";
8
+ const errorFor = (error) => {
9
+ if (typeof error !== "object" || error === null || !("name" in error)) return "failed";
10
+ return error.name === "SecurityError" || error.name === "NotAllowedError" ? "microphone_denied" : "failed";
11
+ };
12
+ const isSession = (value) => typeof value === "object" && value !== null && "conversationId" in value && typeof value.conversationId === "string" && "token" in value && typeof value.token === "string" && "wsUrl" in value && typeof value.wsUrl === "string";
13
+ /**
14
+ * Runs a Lorikeet voice call over LiveKit's native audio, with the web Voice
15
+ * Widget's states and errors.
16
+ */
17
+ function useLorikeetCall({ getSession, onError }) {
18
+ const [state, setState] = useState("idle");
19
+ const [error, setError] = useState();
20
+ const [ended, setEnded] = useState(false);
21
+ const [microphoneMuted, setMicrophoneMutedState] = useState(false);
22
+ const [speakerMuted, setSpeakerMutedState] = useState(false);
23
+ const stateRef = useRef(state);
24
+ const generationRef = useRef(0);
25
+ const localTrackRef = useRef(null);
26
+ const roomRef = useRef(null);
27
+ const speakerMutedRef = useRef(false);
28
+ const audioSessionActiveRef = useRef(false);
29
+ const audioSessionStoppedRef = useRef(Promise.resolve());
30
+ const optionsRef = useRef({
31
+ getSession,
32
+ onError
33
+ });
34
+ optionsRef.current = {
35
+ getSession,
36
+ onError
37
+ };
38
+ const transitionTo = useCallback((next) => {
39
+ stateRef.current = next;
40
+ setState(next);
41
+ }, []);
42
+ const cleanup = useCallback(() => {
43
+ var _localTrackRef$curren;
44
+ (_localTrackRef$curren = localTrackRef.current) === null || _localTrackRef$curren === void 0 || _localTrackRef$curren.stop();
45
+ localTrackRef.current = null;
46
+ const room = roomRef.current;
47
+ roomRef.current = null;
48
+ const disconnected = (room ? room.disconnect() : Promise.resolve()).catch(() => void 0);
49
+ if (audioSessionActiveRef.current) {
50
+ audioSessionActiveRef.current = false;
51
+ audioSessionStoppedRef.current = disconnected.then(() => AudioSession.stopAudioSession()).catch(() => void 0);
52
+ }
53
+ }, []);
54
+ const resetMutes = useCallback(() => {
55
+ setMicrophoneMutedState(false);
56
+ setSpeakerMutedState(false);
57
+ speakerMutedRef.current = false;
58
+ }, []);
59
+ const end = useCallback(() => {
60
+ generationRef.current += 1;
61
+ cleanup();
62
+ setEnded(stateRef.current === "connecting" || stateRef.current === "active");
63
+ setError(void 0);
64
+ resetMutes();
65
+ transitionTo("idle");
66
+ }, [
67
+ cleanup,
68
+ resetMutes,
69
+ transitionTo
70
+ ]);
71
+ const fail = useCallback((callError) => {
72
+ generationRef.current += 1;
73
+ cleanup();
74
+ setEnded(false);
75
+ setError(callError);
76
+ transitionTo("error");
77
+ }, [cleanup, transitionTo]);
78
+ useEffect(() => {
79
+ return () => {
80
+ generationRef.current += 1;
81
+ cleanup();
82
+ };
83
+ }, [cleanup]);
84
+ const start = useCallback(async () => {
85
+ if (stateRef.current !== "idle" && stateRef.current !== "error") return;
86
+ setError(void 0);
87
+ setEnded(false);
88
+ resetMutes();
89
+ transitionTo("starting");
90
+ const generation = ++generationRef.current;
91
+ const isStale = () => generationRef.current !== generation;
92
+ try {
93
+ if (!("LiveKitReactNativeGlobal" in globalThis)) registerGlobals();
94
+ await audioSessionStoppedRef.current;
95
+ if (isStale()) return;
96
+ audioSessionActiveRef.current = true;
97
+ await AudioSession.startAudioSession();
98
+ if (isStale()) return;
99
+ const localTrack = await createLocalAudioTrack();
100
+ if (isStale()) {
101
+ localTrack.stop();
102
+ return;
103
+ }
104
+ localTrackRef.current = localTrack;
105
+ const session = await optionsRef.current.getSession();
106
+ if (isStale()) return;
107
+ if (session === null) {
108
+ fail("not_available");
109
+ return;
110
+ }
111
+ if (!isSession(session)) throw new TypeError("getSession must resolve to { conversationId, wsUrl, token } or null");
112
+ transitionTo("connecting");
113
+ const room = new Room();
114
+ roomRef.current = room;
115
+ const isCurrentRoom = () => roomRef.current === room;
116
+ room.on(RoomEvent.ParticipantDisconnected, (participant) => {
117
+ if (isCurrentRoom() && participant.identity === "lorikeet-agent") end();
118
+ });
119
+ room.on(RoomEvent.TrackSubscribed, (track) => {
120
+ if (isCurrentRoom() && track instanceof RemoteAudioTrack) track.setVolume(speakerMutedRef.current ? 0 : 1);
121
+ });
122
+ let joined = false;
123
+ room.on(RoomEvent.Disconnected, () => {
124
+ if (isCurrentRoom() && joined) end();
125
+ });
126
+ await room.connect(session.wsUrl, session.token, { autoSubscribe: true });
127
+ if (isStale()) return;
128
+ joined = true;
129
+ await room.localParticipant.publishTrack(localTrack, { source: Track.Source.Microphone });
130
+ if (isStale()) return;
131
+ transitionTo("active");
132
+ } catch (caught) {
133
+ if (isStale()) return;
134
+ const callError = errorFor(caught);
135
+ fail(callError);
136
+ if (callError === "failed") try {
137
+ var _optionsRef$current$o, _optionsRef$current;
138
+ (_optionsRef$current$o = (_optionsRef$current = optionsRef.current).onError) === null || _optionsRef$current$o === void 0 || _optionsRef$current$o.call(_optionsRef$current, caught);
139
+ } catch {}
140
+ }
141
+ }, [
142
+ end,
143
+ fail,
144
+ resetMutes,
145
+ transitionTo
146
+ ]);
147
+ return {
148
+ end,
149
+ ended,
150
+ error,
151
+ microphoneMuted,
152
+ setMicrophoneMuted: useCallback((muted) => {
153
+ const track = localTrackRef.current;
154
+ if (!track) return;
155
+ const reconcile = () => {
156
+ if (localTrackRef.current === track) setMicrophoneMutedState(track.isMuted);
157
+ };
158
+ (muted ? track.mute() : track.unmute()).then(reconcile, reconcile);
159
+ }, []),
160
+ setSpeakerMuted: useCallback((muted) => {
161
+ var _roomRef$current$remo, _roomRef$current;
162
+ speakerMutedRef.current = muted;
163
+ const participants = (_roomRef$current$remo = (_roomRef$current = roomRef.current) === null || _roomRef$current === void 0 ? void 0 : _roomRef$current.remoteParticipants.values()) !== null && _roomRef$current$remo !== void 0 ? _roomRef$current$remo : [];
164
+ for (const participant of participants) for (const { track } of participant.audioTrackPublications.values()) if (track instanceof RemoteAudioTrack) track.setVolume(muted ? 0 : 1);
165
+ setSpeakerMutedState(muted);
166
+ }, []),
167
+ speakerMuted,
168
+ start,
169
+ state
170
+ };
171
+ }
172
+
173
+ export { useLorikeetCall };
174
+ //# sourceMappingURL=voice.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voice.js","names":[],"sources":["../src/voice/use-lorikeet-call.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react'\n\nimport { AudioSession, registerGlobals } from '@livekit/react-native'\nimport {\n createLocalAudioTrack,\n type LocalAudioTrack,\n RemoteAudioTrack,\n Room,\n RoomEvent,\n Track,\n} from 'livekit-client'\n\nimport type {\n LorikeetCall,\n LorikeetCallError,\n LorikeetCallSession,\n LorikeetCallState,\n UseLorikeetCallOptions,\n} from './types'\n\n/** The server's `LORIKEET_AGENT_IDENTITY`. The call ends when it leaves. */\nexport const LORIKEET_AGENT_IDENTITY = 'lorikeet-agent'\n\n// react-native-webrtc rejects a refused permission with a plain object named\n// `SecurityError`, not a DOMException, so match on the name alone.\nconst errorFor = (error: unknown): LorikeetCallError => {\n if (typeof error !== 'object' || error === null || !('name' in error)) {\n return 'failed'\n }\n return error.name === 'SecurityError' || error.name === 'NotAllowedError'\n ? 'microphone_denied'\n : 'failed'\n}\n\nconst isSession = (value: unknown): value is LorikeetCallSession =>\n typeof value === 'object' &&\n value !== null &&\n 'conversationId' in value &&\n typeof value.conversationId === 'string' &&\n 'token' in value &&\n typeof value.token === 'string' &&\n 'wsUrl' in value &&\n typeof value.wsUrl === 'string'\n\n/**\n * Runs a Lorikeet voice call over LiveKit's native audio, with the web Voice\n * Widget's states and errors.\n */\nexport function useLorikeetCall({\n getSession,\n onError,\n}: UseLorikeetCallOptions): LorikeetCall {\n const [state, setState] = useState<LorikeetCallState>('idle')\n const [error, setError] = useState<LorikeetCallError | undefined>()\n const [ended, setEnded] = useState(false)\n const [microphoneMuted, setMicrophoneMutedState] = useState(false)\n const [speakerMuted, setSpeakerMutedState] = useState(false)\n\n const stateRef = useRef(state)\n const generationRef = useRef(0)\n const localTrackRef = useRef<LocalAudioTrack | null>(null)\n const roomRef = useRef<Room | null>(null)\n const speakerMutedRef = useRef(false)\n const audioSessionActiveRef = useRef(false)\n // The previous call's teardown, so the next call cannot start its audio\n // session only to have that teardown stop it.\n const audioSessionStoppedRef = useRef<Promise<void>>(Promise.resolve())\n // Read at call time, so a host passing inline functions keeps `start` stable.\n const optionsRef = useRef({ getSession, onError })\n optionsRef.current = { getSession, onError }\n\n const transitionTo = useCallback((next: LorikeetCallState) => {\n stateRef.current = next\n setState(next)\n }, [])\n\n const cleanup = useCallback(() => {\n localTrackRef.current?.stop()\n localTrackRef.current = null\n const room = roomRef.current\n roomRef.current = null\n // Nothing is left to report a teardown failure to, and a failed disconnect\n // must not leave the native audio session running.\n const disconnected = (room ? room.disconnect() : Promise.resolve()).catch(\n () => undefined,\n )\n if (audioSessionActiveRef.current) {\n audioSessionActiveRef.current = false\n audioSessionStoppedRef.current = disconnected\n .then(() => AudioSession.stopAudioSession())\n .catch(() => undefined)\n }\n }, [])\n\n const resetMutes = useCallback(() => {\n setMicrophoneMutedState(false)\n setSpeakerMutedState(false)\n speakerMutedRef.current = false\n }, [])\n\n const end = useCallback(() => {\n generationRef.current += 1\n cleanup()\n setEnded(stateRef.current === 'connecting' || stateRef.current === 'active')\n setError(undefined)\n resetMutes()\n transitionTo('idle')\n }, [cleanup, resetMutes, transitionTo])\n\n const fail = useCallback(\n (callError: LorikeetCallError) => {\n generationRef.current += 1\n cleanup()\n setEnded(false)\n setError(callError)\n transitionTo('error')\n },\n [cleanup, transitionTo],\n )\n\n useEffect(() => {\n return () => {\n generationRef.current += 1\n cleanup()\n }\n }, [cleanup])\n\n const start = useCallback(async () => {\n if (stateRef.current !== 'idle' && stateRef.current !== 'error') {\n return\n }\n\n setError(undefined)\n setEnded(false)\n resetMutes()\n transitionTo('starting')\n const generation = ++generationRef.current\n const isStale = () => generationRef.current !== generation\n\n try {\n // `registerGlobals` sets this. Calling it again would replace any iOS\n // audio setup a host that already uses LiveKit configured itself.\n if (!('LiveKitReactNativeGlobal' in globalThis)) {\n registerGlobals()\n }\n await audioSessionStoppedRef.current\n if (isStale()) {\n return\n }\n audioSessionActiveRef.current = true\n await AudioSession.startAudioSession()\n if (isStale()) {\n return\n }\n\n const localTrack = await createLocalAudioTrack()\n if (isStale()) {\n localTrack.stop()\n return\n }\n localTrackRef.current = localTrack\n\n const session = await optionsRef.current.getSession()\n if (isStale()) {\n return\n }\n if (session === null) {\n fail('not_available')\n return\n }\n if (!isSession(session)) {\n throw new TypeError(\n 'getSession must resolve to { conversationId, wsUrl, token } or null',\n )\n }\n transitionTo('connecting')\n\n const room = new Room()\n roomRef.current = room\n const isCurrentRoom = () => roomRef.current === room\n room.on(RoomEvent.ParticipantDisconnected, (participant) => {\n if (\n isCurrentRoom() &&\n participant.identity === LORIKEET_AGENT_IDENTITY\n ) {\n end()\n }\n })\n room.on(RoomEvent.TrackSubscribed, (track) => {\n if (isCurrentRoom() && track instanceof RemoteAudioTrack) {\n track.setVolume(speakerMutedRef.current ? 0 : 1)\n }\n })\n // A failed join also emits Disconnected, just before `connect` rejects,\n // and has to surface as `failed` rather than as a hang-up.\n let joined = false\n room.on(RoomEvent.Disconnected, () => {\n if (isCurrentRoom() && joined) {\n end()\n }\n })\n\n await room.connect(session.wsUrl, session.token, { autoSubscribe: true })\n if (isStale()) {\n return\n }\n joined = true\n await room.localParticipant.publishTrack(localTrack, {\n source: Track.Source.Microphone,\n })\n if (isStale()) {\n return\n }\n transitionTo('active')\n } catch (caught) {\n if (isStale()) {\n return\n }\n const callError = errorFor(caught)\n fail(callError)\n if (callError === 'failed') {\n try {\n optionsRef.current.onError?.(caught)\n } catch {\n // A host's failing error handler must not turn into an unhandled\n // rejection from a `start()` that callers fire and forget.\n }\n }\n }\n }, [end, fail, resetMutes, transitionTo])\n\n const setMicrophoneMuted = useCallback((muted: boolean) => {\n const track = localTrackRef.current\n if (!track) {\n return\n }\n // Report what the track did rather than what was asked, so a mute that\n // fails is not shown as muted while the customer is still heard.\n const reconcile = () => {\n if (localTrackRef.current === track) {\n setMicrophoneMutedState(track.isMuted)\n }\n }\n void (muted ? track.mute() : track.unmute()).then(reconcile, reconcile)\n }, [])\n\n const setSpeakerMuted = useCallback((muted: boolean) => {\n speakerMutedRef.current = muted\n const participants = roomRef.current?.remoteParticipants.values() ?? []\n for (const participant of participants) {\n for (const { track } of participant.audioTrackPublications.values()) {\n if (track instanceof RemoteAudioTrack) {\n track.setVolume(muted ? 0 : 1)\n }\n }\n }\n setSpeakerMutedState(muted)\n }, [])\n\n return {\n end,\n ended,\n error,\n microphoneMuted,\n setMicrophoneMuted,\n setSpeakerMuted,\n speakerMuted,\n start,\n state,\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@lorikeetai/react-native-sdk",
3
- "version": "2.1.0",
3
+ "version": "2.2.0-beta.1",
4
4
  "type": "module",
5
5
  "private": false,
6
- "description": "LorikeetCX React Native widget for embedding the chat widget via WebView",
6
+ "description": "LorikeetCX React Native SDK: the chat widget in a WebView, and in-app voice calls",
7
7
  "keywords": [
8
8
  "lorikeet",
9
9
  "react-native",
10
10
  "webview",
11
- "widget"
11
+ "widget",
12
+ "voice"
12
13
  ],
13
14
  "author": "LorikeetCX.ai",
14
15
  "license": "MIT",
@@ -27,20 +28,48 @@
27
28
  "react-native": "./dist/index.js",
28
29
  "import": "./dist/index.js",
29
30
  "require": "./dist/index.cjs"
31
+ },
32
+ "./voice": {
33
+ "react-native": "./dist/voice.js",
34
+ "import": {
35
+ "types": "./dist/voice.d.ts",
36
+ "default": "./dist/voice.js"
37
+ },
38
+ "require": {
39
+ "types": "./dist/voice.d.cts",
40
+ "default": "./dist/voice.cjs"
41
+ }
30
42
  }
31
43
  },
32
44
  "peerDependencies": {
45
+ "@livekit/react-native": "^3.1.0",
46
+ "@livekit/react-native-webrtc": "^150.1.0",
47
+ "livekit-client": "^2.20.0",
33
48
  "react": ">=18.0.0",
34
49
  "react-native": ">=0.70.0",
35
50
  "react-native-safe-area-context": ">=4.0.0 <6.0.0",
36
51
  "react-native-svg": ">=13.0.0 <16.0.0",
37
52
  "react-native-webview": ">=13.0.0 <14.0.0"
38
53
  },
54
+ "peerDependenciesMeta": {
55
+ "@livekit/react-native": {
56
+ "optional": true
57
+ },
58
+ "@livekit/react-native-webrtc": {
59
+ "optional": true
60
+ },
61
+ "livekit-client": {
62
+ "optional": true
63
+ }
64
+ },
39
65
  "devDependencies": {
66
+ "@livekit/react-native": "3.1.0",
67
+ "@livekit/react-native-webrtc": "150.1.1",
40
68
  "@testing-library/react": "^16.1.0",
41
69
  "@types/react": "^19.0.0",
42
70
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
43
71
  "happy-dom": "^21.0.4",
72
+ "livekit-client": "^2.23.0",
44
73
  "react": "^19.1.0",
45
74
  "react-dom": "^19.1.0",
46
75
  "react-native": "0.81.4",