@ringg/react-native 0.2.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 ADDED
@@ -0,0 +1,300 @@
1
+ # @ringg/react-native
2
+
3
+ > ⚠️ **Alpha / pre-release.** Expect rough edges and breaking changes between
4
+ > versions. Pin an exact version if you need stability, and please report
5
+ > anything you hit. See **[Known issues](#known-issues)** below.
6
+
7
+ Embeddable chat + voice-call widget for [Ringg AI](https://ringg.ai) agents —
8
+ the React Native implementation. Drop `<RinggWidget />` over your app for text
9
+ chat, voice calls, interactive components (forms, calendars, quick replies,
10
+ Block Kit) and a post-call feedback screen.
11
+
12
+ It runs on the same headless brain as the web widget (`@ringg/core`, bundled
13
+ into this package), so conversation behaviour — reconnection, typing timing,
14
+ message ordering, optimistic sends — is identical across platforms. Only the
15
+ views are native.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install @ringg/react-native
21
+ ```
22
+
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
+
26
+ ```bash
27
+ npx expo install @livekit/react-native @livekit/react-native-webrtc react-native-svg
28
+ # or, without Expo:
29
+ npm install @livekit/react-native @livekit/react-native-webrtc react-native-svg
30
+ ```
31
+
32
+ Requires React ≥ 18 · React Native ≥ 0.73.
33
+
34
+ **Expo Go will not work** — LiveKit ships native code, so you need a
35
+ [development build](https://docs.expo.dev/develop/development-builds/introduction/).
36
+
37
+ ## Platform setup (required for voice)
38
+
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:
54
+
55
+ ```json
56
+ {
57
+ "expo": {
58
+ "plugins": [["@ringg/react-native", { "microphonePermissionText": "Talk to support." }]]
59
+ }
60
+ }
61
+ ```
62
+
63
+ **Bare React Native** — no config plugins, so the same values by hand.
64
+
65
+ `ios/<App>/Info.plist`:
66
+
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
+ ```
73
+
74
+ `android/app/src/main/AndroidManifest.xml`:
75
+
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" />
82
+ ```
83
+
84
+ ## Integrate
85
+
86
+ ```tsx
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";
91
+
92
+ export const App = () => (
93
+ <View style={{ flex: 1 }}>
94
+ <YourApp />
95
+ <RinggWidget
96
+ config={{
97
+ agentId: "<your-agent-id>",
98
+ authorization: "Bearer <your-token>",
99
+ clientOrigin: appOrigin(BUNDLE_ID),
100
+ title: "Support",
101
+ defaultTab: "text", // or "audio"
102
+ }}
103
+ />
104
+ </View>
105
+ );
106
+ ```
107
+
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
+ ```
127
+
128
+ ### Owning the lifecycle yourself
129
+
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
+
134
+ ```tsx
135
+ <RinggWidget config={config} ports={{ onDomAction: dispatcher }} />
136
+ ```
137
+
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
+ ```
153
+
154
+ ## Configuration
155
+
156
+ `RinggWidgetConfig` — only `agentId` is required:
157
+
158
+ | Field | Type | Purpose |
159
+ |---|---|---|
160
+ | `agentId` | `string` | your Ringg agent (**required**) |
161
+ | `authorization` | `string` | bearer token for your account |
162
+ | `title` / `description` | `string` | panel header text |
163
+ | `defaultTab` | `"audio" \| "text"` | which mode the panel opens in |
164
+ | `hideTabSelector` | `boolean` | pin the widget to one mode |
165
+ | `defaultExpanded` | `boolean` | open the panel on mount (no trigger) |
166
+ | `bypassStartScreen` | `boolean` | trigger tap starts the call directly |
167
+ | `bypassFeedbackScreen` | `boolean` | skip the post-call rating screen |
168
+ | `clientOrigin` | `string` | **required for real calls** — see below |
169
+ | `variables` | `Record<string, …>` | values for `{{placeholders}}` in agent prompts |
170
+ | `theme` | `WidgetTheme` | colours, radii, button style (gradients supported) |
171
+ | `logoUrl` / `logoStyles` | `string` / `PortableStyles` | branding in the header |
172
+ | `buttons` | `ButtonsConfig` | per-button copy, icons and styles |
173
+ | `legalDisclaimer` | `{ text, links }` | copy under the start buttons |
174
+ | `feedbackScreen` | `FeedbackScreenConfig` | rating screen copy and styling |
175
+ | `voiceCall` | `{ showAnimation, showTranscript }` | voice view options |
176
+ | `enabledSlashCommands` | `SlashCommand[]` | commands offered in the composer |
177
+ | `eventLogs` | `{ enabled, showIds }` | inline pills for agent-triggered actions |
178
+
179
+ `widgetPosition` and `innerWindowProps` are web-only and ignored here — the
180
+ panel sizes itself to the device.
181
+
182
+ ## Caller identity — required for real calls
183
+
184
+ The backend allow-lists an agent's callers by the `Origin` header. A browser
185
+ sends it automatically; a native app sends nothing, so the webcall request is
186
+ rejected **before authentication is even considered**:
187
+
188
+ | response | meaning |
189
+ |---|---|
190
+ | `400 Origin header is required` | no `clientOrigin` was set |
191
+ | `403 Client '…' is not allowed` | it was set, but is not on the agent's list |
192
+ | `401 Invalid credentials` | the token is wrong for that environment |
193
+
194
+ Pass your app's identity, and add the same string to the agent's allowed
195
+ clients in the dashboard:
196
+
197
+ ```ts
198
+ import { Platform } from "react-native";
199
+ import { appOrigin, createRinggWidgetController } from "@ringg/react-native";
200
+
201
+ const BUNDLE_ID = Platform.OS === "android" ? "com.acme.app" : "com.acme.App";
202
+
203
+ createRinggWidgetController(
204
+ { agentId: "…", authorization: "Bearer …", clientOrigin: appOrigin(BUNDLE_ID) },
205
+ ports,
206
+ );
207
+ ```
208
+
209
+ `appOrigin` returns `<platform>://<bundleId>` — e.g. `android://com.acme.app`.
210
+ The bundle id is a parameter because React Native cannot read it without a
211
+ native module, and this package will not add one for a single string. Your app
212
+ already declares it, so a constant (or a read of your own app config) is
213
+ enough. `expo-application` reports the same value if you would rather ask the
214
+ OS — but it is native code, so adding it requires rebuilding the app, not just
215
+ restarting the bundler.
216
+
217
+ ## Host events
218
+
219
+ The controller emits the same events as the web widget. On RN they are
220
+ in-memory rather than DOM events:
221
+
222
+ ```ts
223
+ const unsubscribe = controller.eventBus.on("ringg:conversation_status", ({ status, mode, callId }) => {
224
+ analytics.track(`call_${status}`, { mode, callId });
225
+ });
226
+ ```
227
+
228
+ Events: `ringg:widget_status`, `ringg:conversation_status`,
229
+ `ringg:feedback_status`, `ringg:calendar_booking`,
230
+ `ringg:component_acknowledgement`.
231
+
232
+ ## Agent-triggered app actions
233
+
234
+ Agents can fire host actions (`execute_dom_action` on the wire). On web these
235
+ become `CustomEvent`s; RN has no ambient event bus, so you supply the handler
236
+ and receive the same payload:
237
+
238
+ ```ts
239
+ import { createHostActionDispatcher } from "@ringg/react-native";
240
+
241
+ const ports = {
242
+ // ...
243
+ onDomAction: createHostActionDispatcher(({ name, payload }) => {
244
+ if (name === "open_checkout") navigation.navigate("Checkout", payload);
245
+ }),
246
+ };
247
+ ```
248
+
249
+ ## Notification sound
250
+
251
+ React Native has no audio playback of its own, and every option is a native
252
+ module — so the widget ships **silent** rather than forcing a dependency on
253
+ every integrator. Wire whichever player your app already has:
254
+
255
+ ```ts
256
+ import { createAudioPlayer } from "expo-audio";
257
+ import { createNotificationPlayer, DEFAULT_CONFIG } from "@ringg/react-native";
258
+
259
+ const notification = createNotificationPlayer(DEFAULT_CONFIG.notificationTuneUrl, (url) => createAudioPlayer(url).play());
260
+ ```
261
+
262
+ Pass it as `ports.notification`.
263
+
264
+ ## Testing hooks
265
+
266
+ Every meaningful node carries a `testID` mirroring the web widget's
267
+ `data-ringg` name, prefixed with `ringg-` — `ringg-trigger-button`,
268
+ `ringg-widget-root`, `ringg-header-title`, `ringg-message-input`,
269
+ `ringg-end-call-confirm`, and so on. These are a contract: they will not be
270
+ renamed without a major version.
271
+
272
+ Two web names have no RN counterpart, because they mark screen-reader-only
273
+ nodes and RN has no visually-hidden text: `ringg-header-status` and
274
+ `ringg-connecting-label`'s `sr-only` sibling. That copy lives in
275
+ `accessibilityLabel` on the surrounding node instead.
276
+
277
+ ## Example app
278
+
279
+ A runnable Expo harness lives in [`example/`](./example) — it is the reference
280
+ integration and runs fully offline (mock transport + in-process backend) when
281
+ no credentials are configured.
282
+
283
+ ## Known issues
284
+
285
+ - **Breaking changes between releases.** APIs may shift while pre-1.0.
286
+ - **No frosted-glass blur.** The web widget's header and composer blur what
287
+ scrolls under them; RN has no blur primitive without a native dependency, so
288
+ those surfaces are near-opaque instead.
289
+ - **No gradient-filled text.** The typing indicator's shimmer sweeps a gradient
290
+ through the glyphs on web. RN cannot fill text with a gradient without a
291
+ masking dependency, so the label takes the colour that sweep averages to and
292
+ the motion moves into the animated ellipsis beside it.
293
+ - **Markdown is a subset.** Agent replies render bold, italic, inline code,
294
+ links, lists, headings, code blocks, blockquotes and rules. Tables are not
295
+ supported and render as plain text.
296
+ - **Voice on emulators is unreliable.** Android emulator networking often
297
+ cannot establish the media connection, and iOS simulators expose no
298
+ microphone or playout device. Test voice on a real device.
299
+ - **The policy-finder pack is web-only.** That integrator-specific flow is not
300
+ part of this package.
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;