@experiaapp/webchat-react-native 2.0.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 +254 -0
- package/app.plugin.js +6 -0
- package/lib/adapters/audio.d.ts +74 -0
- package/lib/adapters/audio.js +39 -0
- package/lib/adapters/audioRoute.d.ts +57 -0
- package/lib/adapters/audioRoute.js +77 -0
- package/lib/adapters/expoDefaults.d.ts +77 -0
- package/lib/adapters/expoDefaults.js +539 -0
- package/lib/adapters/picker.d.ts +67 -0
- package/lib/adapters/picker.js +37 -0
- package/lib/adapters/webrtc.d.ts +131 -0
- package/lib/adapters/webrtc.js +70 -0
- package/lib/core/VideoCallClient.d.ts +106 -0
- package/lib/core/VideoCallClient.js +302 -0
- package/lib/core/WebchatClient.d.ts +34 -0
- package/lib/core/WebchatClient.js +132 -0
- package/lib/core/configClient.d.ts +42 -0
- package/lib/core/configClient.js +302 -0
- package/lib/core/greet.d.ts +11 -0
- package/lib/core/greet.js +17 -0
- package/lib/core/ice.d.ts +31 -0
- package/lib/core/ice.js +48 -0
- package/lib/core/linkify.d.ts +11 -0
- package/lib/core/linkify.js +25 -0
- package/lib/core/logger.d.ts +17 -0
- package/lib/core/logger.js +53 -0
- package/lib/core/media.d.ts +52 -0
- package/lib/core/media.js +115 -0
- package/lib/core/mediaType.d.ts +21 -0
- package/lib/core/mediaType.js +66 -0
- package/lib/core/messagesReducer.d.ts +36 -0
- package/lib/core/messagesReducer.js +58 -0
- package/lib/core/persistence.d.ts +45 -0
- package/lib/core/persistence.js +63 -0
- package/lib/core/socketFactory.d.ts +16 -0
- package/lib/core/socketFactory.js +82 -0
- package/lib/core/types.d.ts +320 -0
- package/lib/core/types.js +30 -0
- package/lib/core/unread.d.ts +2 -0
- package/lib/core/unread.js +5 -0
- package/lib/i18n/ar.json +1 -0
- package/lib/i18n/en.json +1 -0
- package/lib/i18n/index.d.ts +7 -0
- package/lib/i18n/index.js +43 -0
- package/lib/index.d.ts +59 -0
- package/lib/index.js +142 -0
- package/lib/plugin/withWebchat.d.ts +53 -0
- package/lib/plugin/withWebchat.js +164 -0
- package/lib/state/WebchatProvider.d.ts +132 -0
- package/lib/state/WebchatProvider.js +906 -0
- package/lib/state/useWebchat.d.ts +1 -0
- package/lib/state/useWebchat.js +12 -0
- package/lib/theme/dir.d.ts +14 -0
- package/lib/theme/dir.js +20 -0
- package/lib/theme/themeFactory.d.ts +219 -0
- package/lib/theme/themeFactory.js +182 -0
- package/lib/ui/AttachButton.d.ts +35 -0
- package/lib/ui/AttachButton.js +26 -0
- package/lib/ui/AudioRecorder.d.ts +25 -0
- package/lib/ui/AudioRecorder.js +228 -0
- package/lib/ui/Bubble.d.ts +1 -0
- package/lib/ui/Bubble.js +265 -0
- package/lib/ui/CallControls.d.ts +27 -0
- package/lib/ui/CallControls.js +92 -0
- package/lib/ui/CallPlaceholder.d.ts +16 -0
- package/lib/ui/CallPlaceholder.js +73 -0
- package/lib/ui/Composer.d.ts +5 -0
- package/lib/ui/Composer.js +272 -0
- package/lib/ui/FileTile.d.ts +9 -0
- package/lib/ui/FileTile.js +31 -0
- package/lib/ui/Header.d.ts +52 -0
- package/lib/ui/Header.js +236 -0
- package/lib/ui/Icon.d.ts +21 -0
- package/lib/ui/Icon.js +110 -0
- package/lib/ui/ImageBubble.d.ts +11 -0
- package/lib/ui/ImageBubble.js +16 -0
- package/lib/ui/MediaUploadMenu.d.ts +23 -0
- package/lib/ui/MediaUploadMenu.js +68 -0
- package/lib/ui/MessageList.d.ts +1 -0
- package/lib/ui/MessageList.js +46 -0
- package/lib/ui/PoweredBy.d.ts +8 -0
- package/lib/ui/PoweredBy.js +14 -0
- package/lib/ui/PrechatForm.d.ts +1 -0
- package/lib/ui/PrechatForm.js +230 -0
- package/lib/ui/QuickReplies.d.ts +1 -0
- package/lib/ui/QuickReplies.js +24 -0
- package/lib/ui/TypingIndicator.d.ts +9 -0
- package/lib/ui/TypingIndicator.js +88 -0
- package/lib/ui/VideoBubble.d.ts +10 -0
- package/lib/ui/VideoBubble.js +130 -0
- package/lib/ui/VideoCall.d.ts +34 -0
- package/lib/ui/VideoCall.js +191 -0
- package/lib/ui/VideoTile.d.ts +25 -0
- package/lib/ui/VideoTile.js +13 -0
- package/lib/ui/VoiceMessage.d.ts +19 -0
- package/lib/ui/VoiceMessage.js +127 -0
- package/lib/ui/WebChat.d.ts +10 -0
- package/lib/ui/WebChat.js +386 -0
- package/lib/ui/openLink.d.ts +1 -0
- package/lib/ui/openLink.js +16 -0
- package/package.json +94 -0
package/README.md
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# @experiaapp/webchat-react-native
|
|
2
|
+
|
|
3
|
+
A drop-in **React Native webchat SDK** — real-time chat, media attachments (images, documents, voice notes), and 1:1 video calls — that you embed in your app with a single component. Theming, language (English/Arabic), and behavior are driven by your channel configuration.
|
|
4
|
+
|
|
5
|
+
- 💬 Real-time messaging (text, quick replies, message status, unread tracking)
|
|
6
|
+
- 📎 Attachments — photos, PDF documents, and recorded voice notes
|
|
7
|
+
- 🎥 In-app video calls (optional)
|
|
8
|
+
- 🎨 Server-driven theming + a customizable launcher
|
|
9
|
+
- 🌍 English / Arabic with full RTL
|
|
10
|
+
- ⚙️ Works in **Expo** and **bare React Native**
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- React Native **>= 0.74**, React **>= 18**
|
|
17
|
+
- An Expo-managed / dev-client app, **or** a bare RN app with Expo Modules (see [Bare React Native](#bare-react-native))
|
|
18
|
+
- A **channel** and connection details from your Experia account
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# the SDK
|
|
26
|
+
npm install @experiaapp/webchat-react-native
|
|
27
|
+
|
|
28
|
+
# required peers
|
|
29
|
+
npx expo install \
|
|
30
|
+
react-native-safe-area-context \
|
|
31
|
+
react-native-localize \
|
|
32
|
+
@react-native-async-storage/async-storage
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Optional peers (each enables a feature when installed)
|
|
36
|
+
|
|
37
|
+
Install only what you need — the SDK auto-detects them and degrades gracefully when absent:
|
|
38
|
+
|
|
39
|
+
| Feature | Install |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| Photo / document attachments | `npx expo install expo-image-picker expo-document-picker expo-file-system` |
|
|
42
|
+
| Voice-note recording & playback | `npx expo install expo-audio` |
|
|
43
|
+
| Crisp vector icons | `npx expo install react-native-svg` |
|
|
44
|
+
| Connectivity-aware reconnect | `npx expo install @react-native-community/netinfo` |
|
|
45
|
+
| Video calls | `npx expo install react-native-webrtc react-native-incall-manager` |
|
|
46
|
+
|
|
47
|
+
### Expo config plugin
|
|
48
|
+
|
|
49
|
+
Add the plugin to your `app.json` / `app.config.js` so the required iOS/Android permissions (camera, microphone, photo library) are injected at build time:
|
|
50
|
+
|
|
51
|
+
```jsonc
|
|
52
|
+
{
|
|
53
|
+
"expo": {
|
|
54
|
+
"plugins": [
|
|
55
|
+
"@experiaapp/webchat-react-native",
|
|
56
|
+
"@config-plugins/react-native-webrtc" // only if you use video calls
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Then create a development build (`eas build`, or `expo prebuild && expo run:ios|run:android`) — the chat, media, and video features rely on native modules and **won't work in Expo Go**.
|
|
63
|
+
|
|
64
|
+
### Bare React Native
|
|
65
|
+
|
|
66
|
+
The optional Expo peers above need the Expo Modules runtime. In a bare app, run once:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npx install-expo-modules@latest
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Then `cd ios && pod install` and rebuild.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Quick start
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
import React from "react";
|
|
80
|
+
import { SafeAreaProvider } from "react-native-safe-area-context";
|
|
81
|
+
import { WebChat } from "@experiaapp/webchat-react-native";
|
|
82
|
+
|
|
83
|
+
const CONFIG = {
|
|
84
|
+
channelId: "YOUR_CHANNEL_ID",
|
|
85
|
+
connectionUrl: "YOUR_SOCKET_HOST",
|
|
86
|
+
configUrl: "YOUR_CONFIG_HOST",
|
|
87
|
+
language: "en", // "en" | "ar"
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export default function App() {
|
|
91
|
+
return (
|
|
92
|
+
<SafeAreaProvider>
|
|
93
|
+
<WebChat config={CONFIG} />
|
|
94
|
+
</SafeAreaProvider>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
That's the whole integration: a floating launcher button appears, and tapping it opens the chat. **Branding, title, avatar, and theme come from your channel configuration** (fetched via `configUrl`), so you usually only provide the connection details above.
|
|
100
|
+
|
|
101
|
+
> `channelId`, `connectionUrl`, and `configUrl` identify and connect your channel — you receive these with your Experia account.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Configuration
|
|
106
|
+
|
|
107
|
+
The `config` prop carries your **connection details**. Everything else — video calls, attachments, the pre-chat form, branding, title, avatar, and colors — is configured in your **Experia channel** and fetched at runtime via `configUrl`. In most apps you only pass the connection details (plus, optionally, the language):
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
<WebChat
|
|
111
|
+
config={{
|
|
112
|
+
channelId: "YOUR_CHANNEL_ID",
|
|
113
|
+
connectionUrl: "YOUR_SOCKET_HOST",
|
|
114
|
+
configUrl: "YOUR_CONFIG_HOST",
|
|
115
|
+
language: "en", // "en" | "ar" — drives RTL (optional)
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Managed in your Experia channel (no app changes)
|
|
121
|
+
|
|
122
|
+
Controlled from your channel configuration and applied automatically — you don't set these in code:
|
|
123
|
+
|
|
124
|
+
- **Features:** video calls, image / document / voice attachments, emoji picker, typing indicator, "powered by" badge
|
|
125
|
+
- **Launcher & chrome:** default launcher visibility, close button, online/offline status, connecting text
|
|
126
|
+
- **Branding:** theme & colors, header title/subtitle, agent avatar, launcher image
|
|
127
|
+
- **Pre-chat form:** enabled, fields (text / email / phone / dropdown), labels, headline, submit label
|
|
128
|
+
- **Behavior:** history-persistence window, welcome message
|
|
129
|
+
|
|
130
|
+
### Advanced — overriding the channel config
|
|
131
|
+
|
|
132
|
+
Precedence is **prop → channel → default**, so you *can* override any setting per build by passing it on `config`. This is an escape hatch, not the usual path — e.g. force a pre-chat form or turn video off regardless of the channel:
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
<WebChat
|
|
136
|
+
config={{
|
|
137
|
+
channelId: "YOUR_CHANNEL_ID",
|
|
138
|
+
connectionUrl: "YOUR_SOCKET_HOST",
|
|
139
|
+
configUrl: "YOUR_CONFIG_HOST",
|
|
140
|
+
makeVideoCall: false, // override the channel
|
|
141
|
+
prechatForm: {
|
|
142
|
+
enabled: true,
|
|
143
|
+
fields: [{ key: "name", type: "text", label: { en: "Name", ar: "الاسم" }, required: true }],
|
|
144
|
+
},
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Host-side props
|
|
150
|
+
|
|
151
|
+
These live on the component, not the channel config:
|
|
152
|
+
|
|
153
|
+
| Prop | Type | Description |
|
|
154
|
+
| --- | --- | --- |
|
|
155
|
+
| `config` | object | Connection details (+ optional overrides). **Required.** |
|
|
156
|
+
| `open` | `boolean` | Open the surface on mount. |
|
|
157
|
+
| `title` | `string` | Override the channel's header title. |
|
|
158
|
+
| `renderLauncher` | `({ open }) => ReactNode` | Render your own launcher (see below). |
|
|
159
|
+
| `prechatValues` | `Record<string, string>` | Pre-fill the greeting with known user data. |
|
|
160
|
+
| `disabledInput` | `boolean` | Lock the composer. |
|
|
161
|
+
| `secureScreen` | `boolean` | Block screen capture while the chat is open (best-effort). |
|
|
162
|
+
|
|
163
|
+
Callbacks: `onOpen`, `onClose`, `onMessageReceived(message)`, `onConnected`, `onDisconnected`, `onError(error)`, `onUnreadChange(count)`.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Controlling the chat (imperative API)
|
|
168
|
+
|
|
169
|
+
Attach a `ref` to open/close the chat or manage the session from anywhere in your app:
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
import { useRef } from "react";
|
|
173
|
+
import { WebChat, type WebChatHandle } from "@experiaapp/webchat-react-native";
|
|
174
|
+
|
|
175
|
+
const chat = useRef<WebChatHandle>(null);
|
|
176
|
+
|
|
177
|
+
<WebChat ref={chat} config={CONFIG} />;
|
|
178
|
+
|
|
179
|
+
// elsewhere:
|
|
180
|
+
chat.current?.open();
|
|
181
|
+
chat.current?.close();
|
|
182
|
+
chat.current?.sendMessage("Hello");
|
|
183
|
+
chat.current?.getUnreadCount(); // number
|
|
184
|
+
chat.current?.clearHistory(); // drop messages, keep the session
|
|
185
|
+
chat.current?.resetSession(); // start a new session + clear history
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
> `clearHistory()` / `resetSession()` erase the locally stored conversation — call one on user logout.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Customizing the launcher
|
|
193
|
+
|
|
194
|
+
The default is a round floating button. You can change it four ways:
|
|
195
|
+
|
|
196
|
+
**1. Your own launcher (any shape):**
|
|
197
|
+
|
|
198
|
+
```tsx
|
|
199
|
+
<WebChat
|
|
200
|
+
config={CONFIG}
|
|
201
|
+
renderLauncher={({ open }) => (
|
|
202
|
+
<Pressable onPress={open} style={{ /* a pill, bar, avatar, anything */ }}>
|
|
203
|
+
<Text>Chat with us</Text>
|
|
204
|
+
</Pressable>
|
|
205
|
+
)}
|
|
206
|
+
/>
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**2. Trigger from your own UI** — hide the built-in button and open via the ref:
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
<WebChat ref={chat} config={{ ...CONFIG, showButtonChat: false }} />
|
|
213
|
+
// <YourButton onPress={() => chat.current?.open()} />
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**3. Reshape the default** via channel theme tokens (size, color, corner radius).
|
|
217
|
+
|
|
218
|
+
**4. Use a branded image** via the `openLauncherImage` channel config.
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Media & video
|
|
223
|
+
|
|
224
|
+
- **Attachments:** images (PNG/JPEG), PDF documents, and voice notes. Picking is gated by the `uploadMedia` / `upload*` flags; selected files are validated against the channel's allow-list before sending.
|
|
225
|
+
- **Video calls:** set `makeVideoCall: true` to show the call button (a call can also start automatically when your channel triggers it). Video requires the `react-native-webrtc` + `react-native-incall-manager` peers, the `@config-plugins/react-native-webrtc` Expo plugin (managed apps), and a development build.
|
|
226
|
+
|
|
227
|
+
Notifications: the SDK does **not** integrate push. Use the `onMessageReceived` callback to fire your own local notification.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## TypeScript
|
|
232
|
+
|
|
233
|
+
The package ships full type declarations. Import types alongside values:
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
import { WebChat, type WebChatHandle } from "@experiaapp/webchat-react-native";
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Lower-level building blocks (the theme helpers, media utilities, individual UI pieces, and the video-call primitives) are also exported if you need to compose a custom surface.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Troubleshooting
|
|
244
|
+
|
|
245
|
+
- **Native feature does nothing / picker won't open in Expo Go** — Expo Go can't load the native modules; use a development build (`eas build` / `expo prebuild`).
|
|
246
|
+
- **Icons render as plain glyphs** — install `react-native-svg`.
|
|
247
|
+
- **Video-call button missing** — set `makeVideoCall: true`, install the WebRTC peers + plugin, and ensure your channel enables it.
|
|
248
|
+
- **The same conversation keeps resuming** — that's `storage: "localStorage"` (the default). Use `"sessionStorage"` to start fresh each launch, or call `resetSession()`.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
Proprietary. © Experia. All rights reserved. Use is governed by your agreement with Experia.
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Expo loads this file (by convention) when the package name appears in `expo.plugins`.
|
|
2
|
+
// It re-exports the compiled config plugin from `lib/` (built via `tsc -p tsconfig.build.json`).
|
|
3
|
+
// CommonJS interop: the plugin is the default export, so unwrap `.default`.
|
|
4
|
+
const mod = require('./lib/plugin/withWebchat');
|
|
5
|
+
|
|
6
|
+
module.exports = mod.default || mod;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical voice-note codec, RESOLVED for web parity (plan B6 / Task 6).
|
|
3
|
+
*
|
|
4
|
+
* The backend expects exactly what the web widget sends: Ogg/Opus. A real
|
|
5
|
+
* Opus-capable recorder MUST be injected — note that iOS cannot natively
|
|
6
|
+
* record Opus, so the bare/Expo impls have to wrap an Opus-capable library
|
|
7
|
+
* (validated in the Task 6 pre-step). Tests inject {@link NoopAudioAdapter}
|
|
8
|
+
* and assert against this constant rather than calling a native module.
|
|
9
|
+
*/
|
|
10
|
+
export declare const CANONICAL_AUDIO_MIME: "audio/ogg;codecs=opus";
|
|
11
|
+
/**
|
|
12
|
+
* Result of a completed recording. `mimeType` is expected to equal
|
|
13
|
+
* {@link CANONICAL_AUDIO_MIME}; `durationMs` is the recorded length.
|
|
14
|
+
*/
|
|
15
|
+
export interface RecordingResult {
|
|
16
|
+
uri: string;
|
|
17
|
+
mimeType: string;
|
|
18
|
+
durationMs: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Live playback progress (Task 6 UI). Reported by an engine that emits position
|
|
22
|
+
* updates so a player can show "current / total" and reset when the note ends.
|
|
23
|
+
* Times are in ms; `durationMs` is 0 until the engine knows the media length.
|
|
24
|
+
*/
|
|
25
|
+
export interface PlaybackStatus {
|
|
26
|
+
positionMs: number;
|
|
27
|
+
durationMs: number;
|
|
28
|
+
isPlaying: boolean;
|
|
29
|
+
didJustFinish: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The injectable audio surface. `startRecording`/`stopRecording` capture a
|
|
33
|
+
* note; `play`/`stop` drive inline playback. Real impls request mic permission
|
|
34
|
+
* and coordinate mic mutual-exclusion with video calls at the call site.
|
|
35
|
+
*/
|
|
36
|
+
export interface AudioAdapter {
|
|
37
|
+
startRecording(): Promise<void>;
|
|
38
|
+
stopRecording(): Promise<RecordingResult>;
|
|
39
|
+
/**
|
|
40
|
+
* Start inline playback of `uri`. When `opts.onStatus` is provided AND the
|
|
41
|
+
* engine reports progress, it is called with position/duration so the player UI
|
|
42
|
+
* can show "current / total" and reset on finish. Engines without progress
|
|
43
|
+
* events simply never call it (the UI keeps showing the recorded total).
|
|
44
|
+
*/
|
|
45
|
+
play(uri: string, opts?: {
|
|
46
|
+
onStatus?: (status: PlaybackStatus) => void;
|
|
47
|
+
}): Promise<void>;
|
|
48
|
+
stop(): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* Marks an adapter whose `startRecording`/`stopRecording` are INERT stubs (e.g.
|
|
51
|
+
* the default {@link createExpoAudioAdapter}, where recording is hook-only and
|
|
52
|
+
* cannot live on an object method). When `true`, the {@link AudioRecorder}
|
|
53
|
+
* component MUST NOT call this adapter to capture a note — it drives the
|
|
54
|
+
* expo-audio `useAudioRecorder` hook directly instead (and falls back to a
|
|
55
|
+
* hidden/disabled recorder when expo-audio is absent). When absent/false the
|
|
56
|
+
* adapter's imperative `startRecording`/`stopRecording` are the real capture
|
|
57
|
+
* surface (a host-injected recorder, or the test fakes) and are used as-is.
|
|
58
|
+
*/
|
|
59
|
+
recordingDelegated?: boolean;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Inert adapter for unit tests and SSR/no-mic environments. It records no audio
|
|
63
|
+
* and returns a zero-length result tagged with the canonical codec, so the
|
|
64
|
+
* surrounding state machine and attach() path can be exercised without any
|
|
65
|
+
* native module.
|
|
66
|
+
*/
|
|
67
|
+
export declare class NoopAudioAdapter implements AudioAdapter {
|
|
68
|
+
startRecording(): Promise<void>;
|
|
69
|
+
stopRecording(): Promise<RecordingResult>;
|
|
70
|
+
play(_uri: string, _opts?: {
|
|
71
|
+
onStatus?: (status: PlaybackStatus) => void;
|
|
72
|
+
}): Promise<void>;
|
|
73
|
+
stop(): Promise<void>;
|
|
74
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/adapters/audio.ts
|
|
3
|
+
//
|
|
4
|
+
// Voice record/playback behind an injectable interface so the core never
|
|
5
|
+
// touches a native audio module directly. The recorder MUST emit the canonical
|
|
6
|
+
// codec below (web parity); the player accepts a local/remote URI.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.NoopAudioAdapter = exports.CANONICAL_AUDIO_MIME = void 0;
|
|
9
|
+
/**
|
|
10
|
+
* Canonical voice-note codec, RESOLVED for web parity (plan B6 / Task 6).
|
|
11
|
+
*
|
|
12
|
+
* The backend expects exactly what the web widget sends: Ogg/Opus. A real
|
|
13
|
+
* Opus-capable recorder MUST be injected — note that iOS cannot natively
|
|
14
|
+
* record Opus, so the bare/Expo impls have to wrap an Opus-capable library
|
|
15
|
+
* (validated in the Task 6 pre-step). Tests inject {@link NoopAudioAdapter}
|
|
16
|
+
* and assert against this constant rather than calling a native module.
|
|
17
|
+
*/
|
|
18
|
+
exports.CANONICAL_AUDIO_MIME = "audio/ogg;codecs=opus";
|
|
19
|
+
/**
|
|
20
|
+
* Inert adapter for unit tests and SSR/no-mic environments. It records no audio
|
|
21
|
+
* and returns a zero-length result tagged with the canonical codec, so the
|
|
22
|
+
* surrounding state machine and attach() path can be exercised without any
|
|
23
|
+
* native module.
|
|
24
|
+
*/
|
|
25
|
+
class NoopAudioAdapter {
|
|
26
|
+
async startRecording() {
|
|
27
|
+
/* no-op */
|
|
28
|
+
}
|
|
29
|
+
async stopRecording() {
|
|
30
|
+
return { uri: "", mimeType: exports.CANONICAL_AUDIO_MIME, durationMs: 0 };
|
|
31
|
+
}
|
|
32
|
+
async play(_uri, _opts) {
|
|
33
|
+
/* no-op */
|
|
34
|
+
}
|
|
35
|
+
async stop() {
|
|
36
|
+
/* no-op */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
exports.NoopAudioAdapter = NoopAudioAdapter;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subset of `react-native-incall-manager` this wrapper drives. The library
|
|
3
|
+
* exports a singleton instance as its default export; only these four methods
|
|
4
|
+
* are used here. Structural so a `{ start: jest.fn(), ... }` spy satisfies it.
|
|
5
|
+
*/
|
|
6
|
+
export interface InCallManagerLike {
|
|
7
|
+
start(setup?: {
|
|
8
|
+
auto?: boolean;
|
|
9
|
+
media?: "video" | "audio";
|
|
10
|
+
ringback?: string;
|
|
11
|
+
}): void;
|
|
12
|
+
stop(setup?: {
|
|
13
|
+
busytone?: string;
|
|
14
|
+
}): void;
|
|
15
|
+
setForceSpeakerphoneOn(flag: boolean): void;
|
|
16
|
+
setSpeakerphoneOn?(enable: boolean): void;
|
|
17
|
+
}
|
|
18
|
+
export interface AudioRouteStartOptions {
|
|
19
|
+
/** `"video"` (default) configures the session for a video call. */
|
|
20
|
+
media?: "video" | "audio";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Owns the audio route for the lifetime of a call. Construct once per call with
|
|
24
|
+
* the injected manager, `start()` on connect, toggle the speaker mid-call, and
|
|
25
|
+
* `stop()` on teardown to hand the audio session back to the OS.
|
|
26
|
+
*/
|
|
27
|
+
export declare class AudioRoute {
|
|
28
|
+
private readonly manager;
|
|
29
|
+
private active;
|
|
30
|
+
constructor(manager: InCallManagerLike);
|
|
31
|
+
/**
|
|
32
|
+
* Begin the in-call audio session and route to the loudspeaker by default
|
|
33
|
+
* (B14 — a video call should be audible without holding the phone to the ear).
|
|
34
|
+
* Idempotent: calling `start` twice is a no-op until `stop` runs.
|
|
35
|
+
*/
|
|
36
|
+
start(opts?: AudioRouteStartOptions): void;
|
|
37
|
+
/**
|
|
38
|
+
* Toggle the output route mid-call. `true` forces speakerphone; `false`
|
|
39
|
+
* releases the force so audio returns to the earpiece (or a connected
|
|
40
|
+
* Bluetooth/wired route the OS prefers).
|
|
41
|
+
*/
|
|
42
|
+
setSpeaker(on: boolean): void;
|
|
43
|
+
/** Whether an in-call audio session is currently active. */
|
|
44
|
+
isActive(): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* End the in-call audio session and restore the previous audio mode. Releases
|
|
47
|
+
* the speakerphone force first so the OS route is clean for the next call /
|
|
48
|
+
* for Phase-2 voice playback (shared-mic mutual exclusion, §4.4). Idempotent.
|
|
49
|
+
*/
|
|
50
|
+
stop(): void;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Real {@link AudioRoute} backed by the `react-native-incall-manager` singleton.
|
|
54
|
+
* The module is lazy-required so importing this file for the type alone does not
|
|
55
|
+
* load native bindings (chat-only consumers / the unit-test core never touch it).
|
|
56
|
+
*/
|
|
57
|
+
export declare function reactNativeAudioRoute(): AudioRoute;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/adapters/audioRoute.ts
|
|
3
|
+
//
|
|
4
|
+
// Audio-route control for video calls, a thin wrapper over
|
|
5
|
+
// `react-native-incall-manager`. The signaling core / call lifecycle calls
|
|
6
|
+
// {@link AudioRoute.start} when a call begins (default speakerphone, B14),
|
|
7
|
+
// {@link AudioRoute.setSpeaker} for the in-call route toggle, and
|
|
8
|
+
// {@link AudioRoute.stop} on teardown to restore the audio session (Plan Task 4).
|
|
9
|
+
//
|
|
10
|
+
// The native manager is injected so this is unit-testable with a spy and never
|
|
11
|
+
// pulls native bindings into the test/core import graph. Real impls construct
|
|
12
|
+
// {@link reactNativeAudioRoute}, which lazy-requires the singleton default
|
|
13
|
+
// export of `react-native-incall-manager`.
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.AudioRoute = void 0;
|
|
16
|
+
exports.reactNativeAudioRoute = reactNativeAudioRoute;
|
|
17
|
+
/**
|
|
18
|
+
* Owns the audio route for the lifetime of a call. Construct once per call with
|
|
19
|
+
* the injected manager, `start()` on connect, toggle the speaker mid-call, and
|
|
20
|
+
* `stop()` on teardown to hand the audio session back to the OS.
|
|
21
|
+
*/
|
|
22
|
+
class AudioRoute {
|
|
23
|
+
constructor(manager) {
|
|
24
|
+
this.active = false;
|
|
25
|
+
this.manager = manager;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Begin the in-call audio session and route to the loudspeaker by default
|
|
29
|
+
* (B14 — a video call should be audible without holding the phone to the ear).
|
|
30
|
+
* Idempotent: calling `start` twice is a no-op until `stop` runs.
|
|
31
|
+
*/
|
|
32
|
+
start(opts = {}) {
|
|
33
|
+
var _a;
|
|
34
|
+
if (this.active)
|
|
35
|
+
return;
|
|
36
|
+
this.manager.start({ media: (_a = opts.media) !== null && _a !== void 0 ? _a : "video", auto: false });
|
|
37
|
+
// Force-route to speakerphone so the remote party is audible by default.
|
|
38
|
+
this.manager.setForceSpeakerphoneOn(true);
|
|
39
|
+
this.active = true;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Toggle the output route mid-call. `true` forces speakerphone; `false`
|
|
43
|
+
* releases the force so audio returns to the earpiece (or a connected
|
|
44
|
+
* Bluetooth/wired route the OS prefers).
|
|
45
|
+
*/
|
|
46
|
+
setSpeaker(on) {
|
|
47
|
+
this.manager.setForceSpeakerphoneOn(on);
|
|
48
|
+
}
|
|
49
|
+
/** Whether an in-call audio session is currently active. */
|
|
50
|
+
isActive() {
|
|
51
|
+
return this.active;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* End the in-call audio session and restore the previous audio mode. Releases
|
|
55
|
+
* the speakerphone force first so the OS route is clean for the next call /
|
|
56
|
+
* for Phase-2 voice playback (shared-mic mutual exclusion, §4.4). Idempotent.
|
|
57
|
+
*/
|
|
58
|
+
stop() {
|
|
59
|
+
if (!this.active)
|
|
60
|
+
return;
|
|
61
|
+
this.manager.setForceSpeakerphoneOn(false);
|
|
62
|
+
this.manager.stop();
|
|
63
|
+
this.active = false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.AudioRoute = AudioRoute;
|
|
67
|
+
/**
|
|
68
|
+
* Real {@link AudioRoute} backed by the `react-native-incall-manager` singleton.
|
|
69
|
+
* The module is lazy-required so importing this file for the type alone does not
|
|
70
|
+
* load native bindings (chat-only consumers / the unit-test core never touch it).
|
|
71
|
+
*/
|
|
72
|
+
function reactNativeAudioRoute() {
|
|
73
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
74
|
+
const InCallManager = require("react-native-incall-manager")
|
|
75
|
+
.default;
|
|
76
|
+
return new AudioRoute(InCallManager);
|
|
77
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { PickerAdapter } from "./picker";
|
|
2
|
+
import type { AudioAdapter } from "./audio";
|
|
3
|
+
/**
|
|
4
|
+
* Default {@link PickerAdapter} backed by Expo. Returns `undefined` when NEITHER
|
|
5
|
+
* expo-image-picker nor expo-document-picker is installed — so the attach button
|
|
6
|
+
* stays hidden (feature-detect parity with the absent-prop case). When only one
|
|
7
|
+
* lib is present the adapter exposes only that method (a partial picker is valid;
|
|
8
|
+
* the Surface omits the missing row).
|
|
9
|
+
*/
|
|
10
|
+
export declare function createExpoPicker(): PickerAdapter | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Default file->base64 reader backed by `expo-file-system`. Returns RAW base64
|
|
13
|
+
* (no `data:` prefix) — `toDataUrl` adds the prefix downstream. Only invoked for
|
|
14
|
+
* assets WITHOUT eager base64 (documents, recorded audio); image-picker assets
|
|
15
|
+
* carry `base64` and short-circuit it.
|
|
16
|
+
*
|
|
17
|
+
* SDK drift: SDK 54 promoted the OO `File`/`Directory` API to the package root and
|
|
18
|
+
* DEPRECATED the classic functions. On SDK 56 the root `readAsStringAsync` is a STUB
|
|
19
|
+
* that THROWS at runtime ("This method will throw in runtime"), so we MUST prefer the
|
|
20
|
+
* OO `new File(uri).base64()` and only fall back to `readAsStringAsync` on OLDER SDKs
|
|
21
|
+
* (<=53) where the `File` class doesn't exist. Returns `undefined` when the lib is
|
|
22
|
+
* absent (provider's throwing reader still only fires if an attach without eager
|
|
23
|
+
* base64 is actually attempted).
|
|
24
|
+
*/
|
|
25
|
+
export declare function createExpoReadBase64(): ((uri: string) => Promise<string>) | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Shape of `publicStyle.defaultFontFamily` (web parity). One remote TTF per
|
|
28
|
+
* weight (ExtraLight..Black); `name` is the family-level label. All optional —
|
|
29
|
+
* an absent/empty `fonts` array means "no custom font" and the loader resolves
|
|
30
|
+
* to an empty map. Kept local (not imported from core) so this adapter file has
|
|
31
|
+
* no cross-layer dependency, mirroring the picker/audio adapters.
|
|
32
|
+
*/
|
|
33
|
+
export interface BrandFontFamily {
|
|
34
|
+
name?: string;
|
|
35
|
+
fonts?: Array<{
|
|
36
|
+
url: string;
|
|
37
|
+
fontWeight?: string;
|
|
38
|
+
fontName?: string;
|
|
39
|
+
}>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Default brand-font loader backed by `expo-font`. Returns `undefined` (no-op)
|
|
43
|
+
* when expo-font is absent — so Metro never pulls the native module into the
|
|
44
|
+
* import graph and the jest suite (where it is not installed) keeps working,
|
|
45
|
+
* exactly like the other expo defaults.
|
|
46
|
+
*
|
|
47
|
+
* When present it returns `loadBrandFonts(fontFamily)`, an async loader that:
|
|
48
|
+
* - maps each provided font entry to a stable RN family name (`webchat-{…}`),
|
|
49
|
+
* - calls `Font.loadAsync({ [familyName]: url })` for each (in parallel via
|
|
50
|
+
* Promise.all), TOLERATING individual failures (a bad url drops just that
|
|
51
|
+
* weight, never rejects the whole batch),
|
|
52
|
+
* - RESOLVES to a `weight -> familyName` map for the apply phase. The map is
|
|
53
|
+
* keyed by each entry's `fontWeight` (so `resolveFontFamily(map, weight)`
|
|
54
|
+
* can look a weight up); entries without a `fontWeight` are still loaded but
|
|
55
|
+
* are only reachable via the family-level `name` key (added when present).
|
|
56
|
+
*
|
|
57
|
+
* An empty/undefined `fonts` array resolves to `{}` (no fonts loaded).
|
|
58
|
+
*/
|
|
59
|
+
export declare function createExpoFontLoader(): ((fontFamily?: BrandFontFamily) => Promise<Record<string, string>>) | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* Default PLAYBACK-CAPABLE {@link AudioAdapter} backed by `expo-audio`. Returns
|
|
62
|
+
* `undefined` when expo-audio is absent (received-voice falls back to a download
|
|
63
|
+
* tile — parity with the absent-prop case).
|
|
64
|
+
*
|
|
65
|
+
* PLAYBACK (imperative, fully implemented here):
|
|
66
|
+
* play(uri) -> createAudioPlayer(uri); player.play(); retain the player.
|
|
67
|
+
* stop() -> retained player.pause() + player.remove() (MUST remove() to free
|
|
68
|
+
* native resources); null-safe; a single active player is retained,
|
|
69
|
+
* so a new play() removes any prior one first.
|
|
70
|
+
*
|
|
71
|
+
* RECORDING (delegated to Phase 2 component): startRecording()/stopRecording() are
|
|
72
|
+
* inert stubs that keep the AudioAdapter type satisfied. expo-audio's recorder is
|
|
73
|
+
* HOOK-ONLY (useAudioRecorder), so the real capture lives in the <AudioRecorder>
|
|
74
|
+
* component (Phase 2) which drives the hook and yields a RecordingResult tagged with
|
|
75
|
+
* the m4a container mime above. The default object adapter cannot call a hook.
|
|
76
|
+
*/
|
|
77
|
+
export declare function createExpoAudioAdapter(): AudioAdapter | undefined;
|