@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 +300 -0
- package/app.plugin.js +73 -0
- package/dist/index.d.mts +657 -1
- package/dist/index.d.ts +657 -1
- package/dist/index.js +5018 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4945 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +26 -5
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;
|