@ringg/react-native 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,13 +20,13 @@ views are native.
20
20
  npm install @ringg/react-native
21
21
  ```
22
22
 
23
- Then the peer dependencies, which must be installed in **your** app so there is
24
- exactly one autolinked copy of each native module:
23
+ Then the three native modules. React Native autolinking only wires native code
24
+ that resolves from **your** app's `node_modules`, so these cannot be ours:
25
25
 
26
26
  ```bash
27
- npx expo install @livekit/react-native @livekit/react-native-webrtc livekit-client react-native-svg
27
+ npx expo install @livekit/react-native @livekit/react-native-webrtc react-native-svg
28
28
  # or, without Expo:
29
- npm install @livekit/react-native @livekit/react-native-webrtc livekit-client react-native-svg
29
+ npm install @livekit/react-native @livekit/react-native-webrtc react-native-svg
30
30
  ```
31
31
 
32
32
  Requires React ≥ 18 · React Native ≥ 0.73.
@@ -36,109 +36,120 @@ Requires React ≥ 18 · React Native ≥ 0.73.
36
36
 
37
37
  ## Platform setup (required for voice)
38
38
 
39
- **Expo** add the LiveKit plugin and the permissions to `app.json`:
39
+ Voice calls need a microphone, background audio and LiveKit's manifest
40
+ metadata. Text chat needs none of it.
41
+
42
+ **Expo** — add the plugin to `app.json` and rebuild. It applies all of the
43
+ above, including the native transport's own configuration:
44
+
45
+ ```json
46
+ {
47
+ "expo": {
48
+ "plugins": ["@ringg/react-native"]
49
+ }
50
+ }
51
+ ```
52
+
53
+ To change the microphone prompt your users see:
40
54
 
41
55
  ```json
42
56
  {
43
57
  "expo": {
44
- "plugins": ["@livekit/react-native-expo-plugin"],
45
- "ios": {
46
- "infoPlist": {
47
- "NSMicrophoneUsageDescription": "Voice calls use the microphone.",
48
- "UIBackgroundModes": ["audio"]
49
- }
50
- },
51
- "android": {
52
- "permissions": [
53
- "android.permission.RECORD_AUDIO",
54
- "android.permission.MODIFY_AUDIO_SETTINGS",
55
- "android.permission.ACCESS_NETWORK_STATE",
56
- "android.permission.BLUETOOTH_CONNECT"
57
- ]
58
- }
58
+ "plugins": [["@ringg/react-native", { "microphonePermissionText": "Talk to support." }]]
59
59
  }
60
60
  }
61
61
  ```
62
62
 
63
- **Bare React Native** — the same keys, by hand: `NSMicrophoneUsageDescription`
64
- and `UIBackgroundModes: [audio]` in `ios/<App>/Info.plist`, and the four
65
- permissions above in `android/app/src/main/AndroidManifest.xml`.
63
+ **Bare React Native** — no config plugins, so the same values by hand.
66
64
 
67
- ## Integrate in 3 steps
65
+ `ios/<App>/Info.plist`:
68
66
 
69
- ### 1 · Register the WebRTC globals
67
+ ```xml
68
+ <key>NSMicrophoneUsageDescription</key>
69
+ <string>Voice calls use the microphone.</string>
70
+ <key>UIBackgroundModes</key>
71
+ <array><string>audio</string></array>
72
+ ```
70
73
 
71
- Once, at your app entry, **before** anything imports LiveKit:
74
+ `android/app/src/main/AndroidManifest.xml`:
72
75
 
73
- ```ts
74
- // index.js
75
- import { registerGlobals } from "@livekit/react-native";
76
- registerGlobals();
76
+ ```xml
77
+ <uses-permission android:name="android.permission.INTERNET" />
78
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
79
+ <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
80
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
81
+ <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
77
82
  ```
78
83
 
79
- ### 2 · Build a transport and a controller
84
+ ## Integrate
80
85
 
81
86
  ```tsx
82
- import { useEffect, useMemo } from "react";
83
- import {
84
- RinggWidget,
85
- createLiveKitTransport,
86
- createNativeMicPermission,
87
- createRinggWidgetController,
88
- } from "@ringg/react-native";
89
- import { createStaticUrlResolver } from "@ringg/core";
90
-
91
- const URLS = {
92
- dev: { backendUrl: "https://calling-dev.ringg.ai/ca/api/v0", livekitUrl: "wss://ringg-ai-dev-92tubwpz.livekit.cloud" },
93
- stage: { backendUrl: "https://stage-api.ringg.ai/ca/api/v0", livekitUrl: "wss://mercury.webrtc-stage.ringg.ai" },
94
- prod: { backendUrl: "https://prod-api.ringg.ai/ca/api/v0", livekitUrl: "wss://mercury.webrtc.ringg.ai" },
95
- };
87
+ import { RinggWidget, appOrigin } from "@ringg/react-native";
88
+ import { Platform } from "react-native";
89
+
90
+ const BUNDLE_ID = Platform.OS === "android" ? "com.acme.app" : "com.acme.App";
96
91
 
97
- export const RinggSupport = () => {
98
- const { controller, livekit } = useMemo(() => {
99
- const livekit = createLiveKitTransport();
100
- const controller = createRinggWidgetController(
101
- {
92
+ export const App = () => (
93
+ <View style={{ flex: 1 }}>
94
+ <YourApp />
95
+ <RinggWidget
96
+ config={{
102
97
  agentId: "<your-agent-id>",
103
98
  authorization: "Bearer <your-token>",
99
+ clientOrigin: appOrigin(BUNDLE_ID),
104
100
  title: "Support",
105
- description: "How can we help?",
106
101
  defaultTab: "text", // or "audio"
107
- },
108
- {
109
- transport: livekit.transport,
110
- urlResolver: createStaticUrlResolver(URLS),
111
- micPermission: createNativeMicPermission(),
112
- },
113
- );
114
- return { controller, livekit };
115
- }, []);
116
-
117
- // Releases the microphone and the audio session — do not skip this.
118
- useEffect(() => () => {
119
- controller.destroy();
120
- livekit.dispose();
121
- }, [controller, livekit]);
122
-
123
- return <RinggWidget controller={controller} room={livekit.room} />;
124
- };
102
+ }}
103
+ />
104
+ </View>
105
+ );
125
106
  ```
126
107
 
127
- Passing `room` is optional; it only enables the in-call audio visualizer.
108
+ That is the whole integration. The widget renders its own floating trigger and
109
+ panel over whatever is behind it, so make it the **last child** of your root
110
+ view. Tap the trigger and the chat or voice panel opens.
111
+
112
+ Endpoints, the media transport and the microphone port are the package's
113
+ business, not yours. `config.mode` (`dev` / `stage` / `prod`, default `prod`)
114
+ picks the environment.
115
+
116
+ ### Reading events, or driving the panel yourself
117
+
118
+ `onReady` hands back the controller the widget built:
119
+
120
+ ```tsx
121
+ <RinggWidget config={config} onReady={(controller) => {
122
+ controller.eventBus.on("ringg:conversation_status", ({ status, callId }) => {
123
+ analytics.track(`call_${status}`, { callId });
124
+ });
125
+ }} />
126
+ ```
128
127
 
129
- ### 3 · Mount it over your app
128
+ ### Owning the lifecycle yourself
130
129
 
131
- `RinggWidget` renders its own floating trigger and panel over whatever is
132
- behind it, so make it the **last child** of your root view:
130
+ Pass `ports` to override anything the widget would wire by default — a mock
131
+ transport in tests, your own `urlResolver`, a notification player, a handler for
132
+ agent-triggered app actions:
133
133
 
134
134
  ```tsx
135
- <View style={{ flex: 1 }}>
136
- <YourApp />
137
- <RinggSupport />
138
- </View>
135
+ <RinggWidget config={config} ports={{ onDomAction: dispatcher }} />
139
136
  ```
140
137
 
141
- Tap the trigger the chat/voice panel opens. That is the whole integration.
138
+ Or build the controller yourself and pass it instead of `config`, when the
139
+ widget cannot own the transport (a shared room, a custom adapter, several views
140
+ on one controller):
141
+
142
+ ```tsx
143
+ const livekit = createLiveKitTransport();
144
+ const controller = createRinggWidgetController(config, {
145
+ transport: livekit.transport,
146
+ urlResolver: defaultUrlResolver,
147
+ micPermission: createNativeMicPermission(),
148
+ });
149
+
150
+ <RinggWidget controller={controller} room={livekit.room} />;
151
+ // You built it, so you destroy it: controller.destroy(); livekit.dispose();
152
+ ```
142
153
 
143
154
  ## Configuration
144
155
 
@@ -243,8 +254,7 @@ every integrator. Wire whichever player your app already has:
243
254
 
244
255
  ```ts
245
256
  import { createAudioPlayer } from "expo-audio";
246
- import { createNotificationPlayer } from "@ringg/react-native";
247
- import { DEFAULT_CONFIG } from "@ringg/core";
257
+ import { createNotificationPlayer, DEFAULT_CONFIG } from "@ringg/react-native";
248
258
 
249
259
  const notification = createNotificationPlayer(DEFAULT_CONFIG.notificationTuneUrl, (url) => createAudioPlayer(url).play());
250
260
  ```
package/app.plugin.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Expo config plugin — the whole native setup, in one entry.
3
+ *
4
+ * Voice calls need a microphone, background audio and the LiveKit native
5
+ * module's own manifest metadata. Making integrators assemble that themselves
6
+ * meant three separate things in their `app.json`: our transport's plugin, a
7
+ * list of Android permissions, and two `Info.plist` keys. Those are our
8
+ * requirements, so they are our job:
9
+ *
10
+ * { "expo": { "plugins": ["@ringg/react-native"] } }
11
+ *
12
+ * Bare React Native apps have no config plugins and still edit `Info.plist`
13
+ * and `AndroidManifest.xml` by hand; the values are in the README.
14
+ *
15
+ * Plain JS on purpose: Expo resolves `app.plugin.js` from the package root
16
+ * before any bundler runs, so it cannot be part of the tsup build.
17
+ */
18
+
19
+ const { AndroidConfig, withInfoPlist, withPlugins } = require("@expo/config-plugins");
20
+
21
+ // The subpath, not the package: `main` is deliberately empty there, and the
22
+ // plugin lives at the `app.plugin.js` entry Expo looks for by convention.
23
+ // Requiring it from here (rather than passing the package name for Expo to
24
+ // resolve) keeps it working under pnpm and Yarn PnP, where a dependency of a
25
+ // dependency is not resolvable from the app root.
26
+ const liveKitPlugin = require("@livekit/react-native-expo-plugin/app.plugin");
27
+ const withLiveKit = liveKitPlugin.default ?? liveKitPlugin;
28
+
29
+ /**
30
+ * Bluetooth is listed without `maxSdkVersion` filtering: Expo's permission
31
+ * helper writes plain `uses-permission` entries, and the legacy-only variants
32
+ * (`BLUETOOTH`, `BLUETOOTH_ADMIN`) are harmless on modern Android. Apps that
33
+ * care can strip them with their own `withAndroidManifest` plugin.
34
+ */
35
+ const ANDROID_PERMISSIONS = [
36
+ "android.permission.INTERNET",
37
+ "android.permission.RECORD_AUDIO",
38
+ "android.permission.MODIFY_AUDIO_SETTINGS",
39
+ "android.permission.ACCESS_NETWORK_STATE",
40
+ "android.permission.BLUETOOTH_CONNECT",
41
+ ];
42
+
43
+ const DEFAULT_MICROPHONE_TEXT = "Voice calls with the assistant use the microphone.";
44
+
45
+ /** iOS: the permission prompt copy, and the background mode a live call needs. */
46
+ const withRinggIos = (config, props) =>
47
+ withInfoPlist(config, (iosConfig) => {
48
+ // An app that already declares its own copy keeps it: the prompt is user
49
+ // facing, and ours is a fallback rather than a correction.
50
+ iosConfig.modResults.NSMicrophoneUsageDescription = props.microphonePermissionText ?? iosConfig.modResults.NSMicrophoneUsageDescription ?? DEFAULT_MICROPHONE_TEXT;
51
+
52
+ const backgroundModes = new Set(iosConfig.modResults.UIBackgroundModes ?? []);
53
+ backgroundModes.add("audio");
54
+ iosConfig.modResults.UIBackgroundModes = Array.from(backgroundModes);
55
+
56
+ return iosConfig;
57
+ });
58
+
59
+ /**
60
+ * @param {object} config Expo config
61
+ * @param {{ microphonePermissionText?: string }} [props]
62
+ */
63
+ const withRingg = (config, props = {}) =>
64
+ withPlugins(config, [
65
+ // `communication` routes audio to the earpiece/Bluetooth like a phone call
66
+ // rather than the media stream. A call played through the media channel is
67
+ // the wrong volume slider and the wrong speaker.
68
+ [withLiveKit, { android: { audioType: "communication" } }],
69
+ [AndroidConfig.Permissions.withPermissions, ANDROID_PERMISSIONS],
70
+ [withRinggIos, props],
71
+ ]);
72
+
73
+ module.exports = withRingg;