opendesk-react-native 0.1.2 → 0.1.6

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
@@ -1,133 +1,157 @@
1
- # opendesk-react-native
2
-
3
- React Native SDK for [openDesk](https://opendesk.sh) live chat. It uses the same visitor API and realtime gateway as openDesk's web widget (`/widget/v1.js`), with no WebView:
4
-
5
- - `OpenDesk`: a headless client for the session, conversations, messages, attachments, AI streaming, handoff and unread counts
6
- - `OpenDeskProvider` and hooks: `useConversations`, `useConversation`, `useUnreadCount` and `useOpenDeskState`
7
- - `OpenDeskChat` and `OpenDeskLauncher`: drop-in UI that mirrors the web widget. It reads the brand colour, radius, theme, bot, team and copy from your widget's settings, and is built only from React Native core components.
8
-
9
- ## Install
10
-
11
- ```bash
12
- npm install opendesk-react-native expo-secure-store
13
- ```
14
-
15
- ## Set up
16
-
17
- ```tsx
18
- import * as SecureStore from 'expo-secure-store';
19
- import { OpenDesk, OpenDeskProvider, secureStoreStorage } from 'opendesk-react-native';
20
-
21
- export const support = new OpenDesk({
22
- baseUrl: 'https://desk.example.com', // your openDesk origin (https://app.opendesk.sh on cloud)
23
- widgetKey: 'wk_…', // Settings → Brands and widgets → Chat widgets
24
- storage: secureStoreStorage(SecureStore), // keeps the visitor's conversations across launches
25
- });
26
-
27
- export function App() {
28
- const identity = useSupportIdentity(); // see below
29
- return (
30
- <OpenDeskProvider client={support} identity={identity}>
31
- <Navigation />
32
- </OpenDeskProvider>
33
- );
34
- }
35
- ```
36
-
37
- ## Where the visitor token is kept
38
-
39
- The SDK stores one value: the visitor token (`od_vis_…`). It is a bearer credential, so anyone holding it can read that visitor's conversations and post as them. Keep it in the iOS Keychain or Android Keystore with one of the two adapters. Each adapter takes the native module as an argument, so the SDK itself depends on neither:
40
-
41
- | Your app uses | Install | Pass as `storage` |
42
- | --- | --- | --- |
43
- | Expo | `npx expo install expo-secure-store` | `secureStoreStorage(SecureStore)` (`import * as SecureStore from 'expo-secure-store'`) |
44
- | Bare React Native | `npm install react-native-keychain`, then `pod install` | `keychainStorage(Keychain)` (`import * as Keychain from 'react-native-keychain'`) |
45
-
46
- Both adapters store the token as *after first unlock, this device only*. It's readable in the background after the first unlock, and it's left out of iCloud and device-transfer backups.
47
-
48
- Leave `storage` out and the token lives in memory only, so every launch starts a new visitor. Any `{ getItem, setItem, removeItem }` object also works. AsyncStorage fits that shape, but it writes plain text, so don't use it for this.
49
-
50
- ## Identify the signed-in user
51
-
52
- To attach chats to a known contact, pass `{ user_id, user_hash, email, name }` as `identity`. `user_hash` is `hex(HMAC-SHA256(widget_secret, user_id))`, as described in [Identity verification](https://docs.opendesk.sh/guides/live-chat/identity-verification/). Compute it on your server, never in the app, and serve it from an authenticated endpoint of your own:
53
-
54
- ```tsx
55
- function useSupportIdentity() {
56
- const { user } = useAuth();
57
- const [identity, setIdentity] = useState<Identity | null>(null);
58
-
59
- useEffect(() => {
60
- if (!user) return setIdentity(null);
61
- // Your backend returns { user_id, user_hash, email, name } for the signed-in user.
62
- fetchMySupportIdentity().then(setIdentity).catch(() => {});
63
- }, [user?.id]);
64
-
65
- return identity;
66
- }
67
- ```
68
-
69
- When the user signs out, pass `null` (or call `support.shutdown()`) so the next person on the device never sees their conversations. If a different `user_id` signs in, the SDK drops the previous visitor by itself. Leave `identity` undefined for anonymous chat.
70
-
71
- ## Show the chat
72
-
73
- ```tsx
74
- import * as DocumentPicker from 'expo-document-picker';
75
- import { OpenDeskChat, OpenDeskLauncher } from 'opendesk-react-native';
76
-
77
- <OpenDeskChat
78
- onClose={() => navigation.goBack()}
79
- keyboardVerticalOffset={headerHeight}
80
- pickAttachment={async () => {
81
- const result = await DocumentPicker.getDocumentAsync({ type: ['image/*', 'application/pdf'] });
82
- const file = result.assets?.[0];
83
- return file ? { uri: file.uri, name: file.name, type: file.mimeType ?? 'application/octet-stream', size: file.size } : null;
84
- }}
85
- />
86
-
87
- <OpenDeskLauncher onPress={() => navigation.navigate('Support')} style={{ position: 'absolute', right: 20, bottom: 20 }} />
88
- ```
89
-
90
- `OpenDeskChat` also takes these props:
91
-
92
- - `theme`: `'light' | 'dark' | 'auto'`, which overrides the widget setting
93
- - `accentColor`
94
- - `strings`: replaces any of the built-in English copy (`defaultStrings` lists every key)
95
- - `initialConversationId`
96
-
97
- For an in-app banner while the chat is closed, use `support.onMessage(({ conversationId, from, message }) => …)`.
98
-
99
- ## Build your own UI
100
-
101
- ```tsx
102
- const { conversations } = useConversations();
103
- const { messages, aiReplies, typing } = useConversation(conversationId); // marks it read while mounted
104
- const client = useOpenDesk();
105
-
106
- await client.startConversation({ body, topic_id });
107
- await client.sendMessage(conversationId, { body, attachment_ids });
108
- const attachment = await client.upload({ uri, name, type }); // or a File/Blob on the web
109
- client.typing(conversationId);
110
- await client.requestHuman(conversationId); // after an AI answer
111
- await client.decideAction(conversationId, actionId, 'confirm');
112
- await client.rateMessage(conversationId, messageId, 'up');
113
- ```
114
-
115
- Render messages with `message.client_id ?? message.id` as the key. That keeps a bubble mounted when its optimistic copy or streamed AI answer is replaced by the stored message.
116
-
117
- ## Preview
118
-
119
- ```bash
120
- npm run example
121
- ```
122
-
123
- This runs `OpenDeskChat` in the browser on port 8774, through react-native-web, against a mock openDesk (`example/mockDesk.ts`). The mock covers AI streaming, tool checks, an AI action to confirm, agent typing, handoff, attachments and unread badges, and needs no server.
124
-
125
- ## Notes
126
-
127
- - Realtime uses Phoenix Channels (the `phoenix` package) on `<gateway_url>` and the topic `contact:<wid>:<contact_id>`. The 5-minute `ws_token` is refreshed before every reconnect, and missed messages are re-fetched over REST after a drop.
128
- - If **Allowed sites** is set on the widget, openDesk checks the `Origin` header. Native requests send none, so check that your app can still reach the API.
129
- - The widget API has no push notifications for replies sent while the app is closed. Use openDesk webhooks (`message.created`) to your backend for those.
130
-
131
- ## License
132
-
133
- MIT
1
+ # opendesk-react-native
2
+
3
+ React Native SDK for [openDesk](https://opendesk.sh) live chat. It uses the same visitor API and realtime gateway as openDesk's web widget (`/widget/v1.js`), with no WebView:
4
+
5
+ - `OpenDesk`: a headless client for the session, conversations, messages, attachments, AI streaming, handoff and unread counts
6
+ - `OpenDeskProvider` and hooks: `useConversations`, `useConversation`, `useUnreadCount` and `useOpenDeskState`
7
+ - `OpenDeskChat` and `OpenDeskLauncher`: drop-in UI that mirrors the web widget. It reads the brand colour, radius, theme, bot, team and copy from your widget's settings, and is built only from React Native core components.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install opendesk-react-native expo-secure-store
13
+ ```
14
+
15
+ ## Set up
16
+
17
+ ```tsx
18
+ import * as SecureStore from 'expo-secure-store';
19
+ import { OpenDesk, OpenDeskProvider, secureStoreStorage } from 'opendesk-react-native';
20
+
21
+ export const support = new OpenDesk({
22
+ baseUrl: 'https://desk.example.com', // your openDesk origin (https://app.opendesk.sh on cloud)
23
+ widgetKey: 'wk_…', // Settings → Brands and widgets → Chat widgets
24
+ storage: secureStoreStorage(SecureStore), // keeps the visitor's conversations across launches
25
+ });
26
+
27
+ export function App() {
28
+ const identity = useSupportIdentity(); // see below
29
+ return (
30
+ <OpenDeskProvider client={support} identity={identity}>
31
+ <Navigation />
32
+ </OpenDeskProvider>
33
+ );
34
+ }
35
+ ```
36
+
37
+ ## Where the visitor token is kept
38
+
39
+ The SDK stores one value: the visitor token (`od_vis_…`). It is a bearer credential, so anyone holding it can read that visitor's conversations and post as them. Keep it in the iOS Keychain or Android Keystore with one of the two adapters. Each adapter takes the native module as an argument, so the SDK itself depends on neither:
40
+
41
+ | Your app uses | Install | Pass as `storage` |
42
+ | --- | --- | --- |
43
+ | Expo | `npx expo install expo-secure-store` | `secureStoreStorage(SecureStore)` (`import * as SecureStore from 'expo-secure-store'`) |
44
+ | Bare React Native | `npm install react-native-keychain`, then `pod install` | `keychainStorage(Keychain)` (`import * as Keychain from 'react-native-keychain'`) |
45
+
46
+ Both adapters store the token as *after first unlock, this device only*. It's readable in the background after the first unlock, and it's left out of iCloud and device-transfer backups.
47
+
48
+ Leave `storage` out and the token lives in memory only, so every launch starts a new visitor. Any `{ getItem, setItem, removeItem }` object also works. AsyncStorage fits that shape, but it writes plain text, so don't use it for this.
49
+
50
+ ## Reply sound
51
+
52
+ The SDK can play the web widget's reply chime when an agent or AI replies in a conversation the visitor doesn't have open. It follows the widget's **Sound** setting in openDesk, the same as on the web. Install `expo-audio` and pass it in:
53
+
54
+ ```tsx
55
+ // npx expo install expo-audio
56
+ import * as ExpoAudio from 'expo-audio';
57
+ import { expoAudioSound } from 'opendesk-react-native';
58
+
59
+ export const support = new OpenDesk({
60
+ // …
61
+ sound: expoAudioSound(ExpoAudio), // or expoAudioSound(ExpoAudio, { volume: 0.5 })
62
+ });
63
+ ```
64
+
65
+ The chime ships with the package (`assets/chime.wav`). The SDK leaves your app's audio mode alone; under expo-audio's default the chime mixes with other audio rather than pausing it. To play something else, pass any function as `sound`. Leave `sound` out for no sound.
66
+
67
+ ## Identify the signed-in user
68
+
69
+ To attach chats to a known contact, pass `{ user_id, user_hash, email, name }` as `identity`. `user_hash` is `hex(HMAC-SHA256(widget_secret, user_id))`, as described in [Identity verification](https://docs.opendesk.sh/guides/live-chat/identity-verification/). Compute it on your server, never in the app, and serve it from an authenticated endpoint of your own:
70
+
71
+ ```tsx
72
+ function useSupportIdentity() {
73
+ const { user } = useAuth();
74
+ const [identity, setIdentity] = useState<Identity | null>(null);
75
+
76
+ useEffect(() => {
77
+ if (!user) return setIdentity(null);
78
+ // Your backend returns { user_id, user_hash, email, name } for the signed-in user.
79
+ fetchMySupportIdentity().then(setIdentity).catch(() => {});
80
+ }, [user?.id]);
81
+
82
+ return identity;
83
+ }
84
+ ```
85
+
86
+ When the user signs out, pass `null` (or call `support.shutdown()`) so the next person on the device never sees their conversations. If a different `user_id` signs in, the SDK drops the previous visitor by itself. Leave `identity` undefined for anonymous chat.
87
+
88
+ ## Show the chat
89
+
90
+ ```tsx
91
+ import * as DocumentPicker from 'expo-document-picker';
92
+ import { OpenDeskChat, OpenDeskLauncher } from 'opendesk-react-native';
93
+
94
+ <OpenDeskChat
95
+ onClose={() => navigation.goBack()}
96
+ pickAttachment={async () => {
97
+ const result = await DocumentPicker.getDocumentAsync({ type: ['image/*', 'application/pdf'] });
98
+ const file = result.assets?.[0];
99
+ return file ? { uri: file.uri, name: file.name, type: file.mimeType ?? 'application/octet-stream', size: file.size } : null;
100
+ }}
101
+ />
102
+
103
+ <OpenDeskLauncher onPress={() => navigation.navigate('Support')} style={{ position: 'absolute', right: 20, bottom: 20 }} />
104
+ ```
105
+
106
+ `OpenDeskChat` also takes these props:
107
+
108
+ - `theme`: `'light' | 'dark' | 'auto'`, which overrides the widget setting
109
+ - `accentColor`
110
+ - `strings`: replaces any of the built-in English copy (`defaultStrings` lists every key)
111
+ - `initialConversationId`
112
+ - `keyboardVerticalOffset`: extra space between the composer and the keyboard (see below)
113
+
114
+ The chat keeps its composer above the keyboard by measuring where it sits on screen, so it needs no offset under a navigation header. Two cases it can't measure past:
115
+
116
+ - **React Native's `<Modal>` on Android** is a separate window that gets no keyboard events, so the composer stays under the keyboard. Show the chat as a screen, or as a full-screen view over your app, instead.
117
+ - **An iOS page sheet** (`presentationStyle="pageSheet"`, or a sheet-style navigation modal) starts below the top of the screen. Pass the sheet's distance from the top as `keyboardVerticalOffset`, or present it full screen.
118
+
119
+ Tapping an attached image opens it inside the chat. On Android, the back button closes it.
120
+
121
+ For an in-app banner while the chat is closed, use `support.onMessage(({ conversationId, from, message }) => …)`.
122
+
123
+ ## Build your own UI
124
+
125
+ ```tsx
126
+ const { conversations } = useConversations();
127
+ const { messages, aiReplies, typing } = useConversation(conversationId); // marks it read while mounted
128
+ const client = useOpenDesk();
129
+
130
+ await client.startConversation({ body, topic_id });
131
+ await client.sendMessage(conversationId, { body, attachment_ids });
132
+ const attachment = await client.upload({ uri, name, type }); // or a File/Blob on the web
133
+ client.typing(conversationId);
134
+ await client.requestHuman(conversationId); // after an AI answer
135
+ await client.decideAction(conversationId, actionId, 'confirm');
136
+ await client.rateMessage(conversationId, messageId, 'up');
137
+ ```
138
+
139
+ Render messages with `message.client_id ?? message.id` as the key. That keeps a bubble mounted when its optimistic copy or streamed AI answer is replaced by the stored message.
140
+
141
+ ## Preview
142
+
143
+ ```bash
144
+ npm run example
145
+ ```
146
+
147
+ This runs `OpenDeskChat` in the browser on port 8774, through react-native-web, against a mock openDesk (`example/mockDesk.ts`). The mock covers AI streaming, tool checks, an AI action to confirm, agent typing, handoff, attachments and unread badges, and needs no server.
148
+
149
+ ## Notes
150
+
151
+ - Realtime uses Phoenix Channels (the `phoenix` package, 1.8+) on `<gateway_url>` and the topic `contact:<wid>:<contact_id>`. The 5-minute `ws_token` goes in Phoenix's `authToken` (the `Sec-WebSocket-Protocol` header; the gateway refuses it in the URL), is refreshed before every reconnect, and missed messages are re-fetched over REST after a drop.
152
+ - If **Allowed sites** is set on the widget, openDesk checks the `Origin` header. Native requests send none, so check that your app can still reach the API.
153
+ - The widget API has no push notifications for replies sent while the app is closed. Use openDesk webhooks (`message.created`) to your backend for those.
154
+
155
+ ## License
156
+
157
+ MIT
Binary file
package/lib/api.js CHANGED
@@ -63,13 +63,14 @@ class OpenDeskApi {
63
63
  const form = new FormData();
64
64
  if (isBlob(file)) {
65
65
  form.append('file', file, 'name' in file && typeof file.name === 'string' ? file.name : 'upload');
66
+ return this.request('POST', '/v1/widget/attachments', form);
66
67
  }
67
- else {
68
- form.append('file', { uri: file.uri, name: file.name, type: file.type || 'application/octet-stream' });
69
- }
70
- return this.request('POST', '/v1/widget/attachments', form);
68
+ form.append('file', { uri: file.uri, name: file.name, type: file.type || 'application/octet-stream' });
69
+ // Expo replaces `fetch` with expo/fetch, which can't send `{ uri }` parts; React Native's
70
+ // XMLHttpRequest reads the file from disk itself.
71
+ return this.request('POST', '/v1/widget/attachments', form, true, typeof XMLHttpRequest === 'undefined' ? this.fetcher : xhrFetch);
71
72
  }
72
- async request(method, path, body, authenticated = true) {
73
+ async request(method, path, body, authenticated = true, fetcher = this.fetcher) {
73
74
  const headers = { Accept: 'application/json' };
74
75
  let payload;
75
76
  if (body instanceof FormData) {
@@ -84,10 +85,10 @@ class OpenDeskApi {
84
85
  }
85
86
  let response;
86
87
  try {
87
- response = await this.fetcher(this.baseUrl + path, { method, headers, body: payload });
88
+ response = await fetcher(this.baseUrl + path, { method, headers, body: payload });
88
89
  }
89
- catch {
90
- throw new errors_1.OpenDeskError(0, 'network_error', "Can't reach the server");
90
+ catch (cause) {
91
+ throw new errors_1.OpenDeskError(0, 'network_error', "Can't reach the server", cause);
91
92
  }
92
93
  if (!response.ok) {
93
94
  let code = 'error';
@@ -112,3 +113,19 @@ exports.OpenDeskApi = OpenDeskApi;
112
113
  function isBlob(file) {
113
114
  return typeof Blob !== 'undefined' && file instanceof Blob;
114
115
  }
116
+ /** `fetch` over XMLHttpRequest, for the multipart uploads expo/fetch can't send. */
117
+ const xhrFetch = (input, init) => new Promise((resolve, reject) => {
118
+ const xhr = new XMLHttpRequest();
119
+ xhr.open(init?.method ?? 'GET', String(input));
120
+ Object.entries((init?.headers ?? {})).forEach(([name, value]) => xhr.setRequestHeader(name, value));
121
+ xhr.onload = () => {
122
+ const empty = xhr.status === 204 || xhr.status === 205 || xhr.status === 304;
123
+ resolve(new Response(empty ? null : xhr.responseText, {
124
+ status: xhr.status,
125
+ headers: { 'content-type': xhr.getResponseHeader('content-type') ?? '' },
126
+ }));
127
+ };
128
+ xhr.onerror = () => reject(new TypeError('Network request failed'));
129
+ xhr.ontimeout = xhr.onerror;
130
+ xhr.send((init?.body ?? null));
131
+ });
package/lib/client.d.ts CHANGED
@@ -9,8 +9,14 @@ export interface OpenDeskOptions {
9
9
  widgetKey: string;
10
10
  /** Persists the visitor token so conversations survive restarts. Defaults to memory. */
11
11
  storage?: TokenStorage;
12
+ /** Replaces `fetch`. Native `{ uri }` uploads still go over XMLHttpRequest, which can read the file. */
12
13
  fetcher?: Fetcher;
13
14
  socketFactory?: SocketFactory;
15
+ /**
16
+ * Played when an agent or AI reply arrives in a conversation that isn't open, if the widget
17
+ * has sound on. `expoAudioSound(ExpoAudio)` plays the web widget's chime.
18
+ */
19
+ sound?: () => void;
14
20
  }
15
21
  /** An AI answer being streamed before it lands as a message. */
16
22
  export interface AIReply {
@@ -61,6 +67,7 @@ export declare class OpenDesk {
61
67
  private readonly storage;
62
68
  private readonly storageKey;
63
69
  private readonly socketFactory;
70
+ private readonly sound;
64
71
  private readonly subscribers;
65
72
  private readonly messageListeners;
66
73
  private readonly typingTimers;
@@ -103,6 +110,8 @@ export declare class OpenDesk {
103
110
  private authorized;
104
111
  private catchUp;
105
112
  private onRealtimeEvent;
113
+ /** The widget's sound setting decides, as on the web; the app decides what plays. */
114
+ private playSound;
106
115
  private onLiveEvent;
107
116
  private onAgentTyping;
108
117
  private clearTyping;
package/lib/client.js CHANGED
@@ -45,6 +45,7 @@ class OpenDesk {
45
45
  this.storage = options.storage ?? (0, storage_1.memoryStorage)();
46
46
  this.storageKey = `opendesk.visitor.${options.widgetKey}`;
47
47
  this.socketFactory = options.socketFactory ?? realtime_1.phoenixSocketFactory;
48
+ this.sound = options.sound ?? null;
48
49
  }
49
50
  /** Called for every agent or AI reply, e.g. to show an in-app banner. */
50
51
  onMessage(listener) {
@@ -290,6 +291,7 @@ class OpenDesk {
290
291
  this.set((state) => ({
291
292
  unread: { ...state.unread, [conversationId]: (state.unread[conversationId] ?? 0) + 1 },
292
293
  }));
294
+ this.playSound();
293
295
  }
294
296
  const from = message.author.user?.name || message.author.bot?.name || this.state.config?.workspace_name || 'Support';
295
297
  this.messageListeners.forEach((listener) => {
@@ -301,6 +303,18 @@ class OpenDesk {
301
303
  }
302
304
  });
303
305
  }
306
+ /** The widget's sound setting decides, as on the web; the app decides what plays. */
307
+ playSound() {
308
+ if (!this.sound || this.state.config?.sound !== true) {
309
+ return;
310
+ }
311
+ try {
312
+ this.sound();
313
+ }
314
+ catch {
315
+ // A sound that fails must not break the stream.
316
+ }
317
+ }
304
318
  onLiveEvent(event) {
305
319
  const { reply_id: replyId, ticket_id: conversationId } = event.data;
306
320
  if (typeof replyId !== 'string' || typeof conversationId !== 'string') {
package/lib/errors.d.ts CHANGED
@@ -1,8 +1,12 @@
1
- /** A failed openDesk request; `status` is 0 when the server could not be reached. */
1
+ /**
2
+ * A failed openDesk request; `status` is 0 when the server could not be reached, and then
3
+ * `cause` holds what `fetch` threw.
4
+ */
2
5
  export declare class OpenDeskError extends Error {
3
6
  readonly status: number;
4
7
  readonly code: string;
5
- constructor(status: number, code: string, message: string);
8
+ readonly cause?: unknown | undefined;
9
+ constructor(status: number, code: string, message: string, cause?: unknown | undefined);
6
10
  /** The visitor token is unknown or revoked; a fresh session is needed. */
7
11
  get isUnauthorized(): boolean;
8
12
  }
package/lib/errors.js CHANGED
@@ -1,12 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OpenDeskError = void 0;
4
- /** A failed openDesk request; `status` is 0 when the server could not be reached. */
4
+ /**
5
+ * A failed openDesk request; `status` is 0 when the server could not be reached, and then
6
+ * `cause` holds what `fetch` threw.
7
+ */
5
8
  class OpenDeskError extends Error {
6
- constructor(status, code, message) {
9
+ constructor(status, code, message, cause) {
7
10
  super(message);
8
11
  this.status = status;
9
12
  this.code = code;
13
+ this.cause = cause;
10
14
  this.name = 'OpenDeskError';
11
15
  }
12
16
  /** The visitor token is unknown or revoked; a fresh session is needed. */
package/lib/index.d.ts CHANGED
@@ -7,6 +7,8 @@ export { OpenDeskRealtime, phoenixSocketFactory } from './realtime';
7
7
  export type { RealtimeChannel, RealtimeSocket, SocketFactory } from './realtime';
8
8
  export { keychainStorage, memoryStorage, secureStoreStorage } from './storage';
9
9
  export type { ExpoSecureStoreModule, KeychainModule, TokenStorage } from './storage';
10
+ export { expoAudioSound } from './sound';
11
+ export type { ExpoAudioModule, ExpoAudioPlayer } from './sound';
10
12
  export { OpenDeskProvider, useConversation, useConversations, useOpenDesk, useOpenDeskState, useUnreadCount, } from './react';
11
13
  export type { ConversationView, OpenDeskProviderProps } from './react';
12
14
  export { OpenDeskChat } from './ui/OpenDeskChat';
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.defaultStrings = exports.OpenDeskLauncher = exports.OpenDeskChat = exports.useUnreadCount = exports.useOpenDeskState = exports.useOpenDesk = exports.useConversations = exports.useConversation = exports.OpenDeskProvider = exports.secureStoreStorage = exports.memoryStorage = exports.keychainStorage = exports.phoenixSocketFactory = exports.OpenDeskRealtime = exports.OpenDeskError = exports.OpenDeskApi = exports.OpenDesk = void 0;
3
+ exports.defaultStrings = exports.OpenDeskLauncher = exports.OpenDeskChat = exports.useUnreadCount = exports.useOpenDeskState = exports.useOpenDesk = exports.useConversations = exports.useConversation = exports.OpenDeskProvider = exports.expoAudioSound = exports.secureStoreStorage = exports.memoryStorage = exports.keychainStorage = exports.phoenixSocketFactory = exports.OpenDeskRealtime = exports.OpenDeskError = exports.OpenDeskApi = exports.OpenDesk = void 0;
4
4
  var client_1 = require("./client");
5
5
  Object.defineProperty(exports, "OpenDesk", { enumerable: true, get: function () { return client_1.OpenDesk; } });
6
6
  var api_1 = require("./api");
@@ -14,6 +14,8 @@ var storage_1 = require("./storage");
14
14
  Object.defineProperty(exports, "keychainStorage", { enumerable: true, get: function () { return storage_1.keychainStorage; } });
15
15
  Object.defineProperty(exports, "memoryStorage", { enumerable: true, get: function () { return storage_1.memoryStorage; } });
16
16
  Object.defineProperty(exports, "secureStoreStorage", { enumerable: true, get: function () { return storage_1.secureStoreStorage; } });
17
+ var sound_1 = require("./sound");
18
+ Object.defineProperty(exports, "expoAudioSound", { enumerable: true, get: function () { return sound_1.expoAudioSound; } });
17
19
  var react_1 = require("./react");
18
20
  Object.defineProperty(exports, "OpenDeskProvider", { enumerable: true, get: function () { return react_1.OpenDeskProvider; } });
19
21
  Object.defineProperty(exports, "useConversation", { enumerable: true, get: function () { return react_1.useConversation; } });
package/lib/realtime.d.ts CHANGED
@@ -17,6 +17,11 @@ export interface RealtimeSocket {
17
17
  export type SocketFactory = (url: string, params: () => {
18
18
  token: string;
19
19
  }) => RealtimeSocket;
20
+ /**
21
+ * The gateway reads the token from Phoenix's `authToken` (sent in the `Sec-WebSocket-Protocol`
22
+ * header) and refuses a connect that carries it in the query string. Phoenix calls the function
23
+ * on every connect, so a refreshed token is used on the next attempt.
24
+ */
20
25
  export declare const phoenixSocketFactory: SocketFactory;
21
26
  export interface RealtimeHandlers {
22
27
  onEvent(event: RealtimeEvent): void;
package/lib/realtime.js CHANGED
@@ -3,7 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OpenDeskRealtime = exports.phoenixSocketFactory = void 0;
4
4
  exports.jwtClaim = jwtClaim;
5
5
  const phoenix_1 = require("phoenix");
6
- const phoenixSocketFactory = (url, params) => new phoenix_1.Socket(url, { params, reconnectAfterMs: (tries) => [1000, 2000, 5000, 10000][tries - 1] ?? 15000 });
6
+ /**
7
+ * The gateway reads the token from Phoenix's `authToken` (sent in the `Sec-WebSocket-Protocol`
8
+ * header) and refuses a connect that carries it in the query string. Phoenix calls the function
9
+ * on every connect, so a refreshed token is used on the next attempt.
10
+ */
11
+ const phoenixSocketFactory = (url, params) => new phoenix_1.Socket(url, {
12
+ authToken: () => params().token,
13
+ reconnectAfterMs: (tries) => [1000, 2000, 5000, 10000][tries - 1] ?? 15000,
14
+ });
7
15
  exports.phoenixSocketFactory = phoenixSocketFactory;
8
16
  const TYPING_THROTTLE_MS = 3000;
9
17
  /**
package/lib/sound.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ /** The parts of `expo-audio` the adapter uses; pass the module itself. */
2
+ export interface ExpoAudioModule {
3
+ createAudioPlayer(source: number): ExpoAudioPlayer;
4
+ }
5
+ export interface ExpoAudioPlayer {
6
+ volume: number;
7
+ play(): void;
8
+ seekTo(seconds: number): Promise<void>;
9
+ }
10
+ /**
11
+ * Plays the web widget's reply chime with `expo-audio`. Takes the module as an argument, so the
12
+ * SDK has no dependency on Expo:
13
+ *
14
+ * import * as ExpoAudio from 'expo-audio';
15
+ * new OpenDesk({ …, sound: expoAudioSound(ExpoAudio) });
16
+ *
17
+ * The player is made on the first reply and kept for the next. The app's audio mode is left
18
+ * alone; under expo-audio's default the chime mixes with other audio instead of pausing it.
19
+ */
20
+ export declare function expoAudioSound(ExpoAudio: ExpoAudioModule, options?: {
21
+ volume?: number;
22
+ }): () => void;
package/lib/sound.js ADDED
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.expoAudioSound = expoAudioSound;
7
+ const chime_wav_1 = __importDefault(require("../assets/chime.wav"));
8
+ /**
9
+ * Plays the web widget's reply chime with `expo-audio`. Takes the module as an argument, so the
10
+ * SDK has no dependency on Expo:
11
+ *
12
+ * import * as ExpoAudio from 'expo-audio';
13
+ * new OpenDesk({ …, sound: expoAudioSound(ExpoAudio) });
14
+ *
15
+ * The player is made on the first reply and kept for the next. The app's audio mode is left
16
+ * alone; under expo-audio's default the chime mixes with other audio instead of pausing it.
17
+ */
18
+ function expoAudioSound(ExpoAudio, options = {}) {
19
+ let player = null;
20
+ return () => {
21
+ if (!player) {
22
+ player = ExpoAudio.createAudioPlayer(chime_wav_1.default);
23
+ if (options.volume !== undefined) {
24
+ player.volume = options.volume;
25
+ }
26
+ }
27
+ const current = player;
28
+ void current
29
+ .seekTo(0)
30
+ .catch(() => undefined)
31
+ .then(() => current.play());
32
+ };
33
+ }
package/lib/types.d.ts CHANGED
@@ -20,6 +20,8 @@ export interface WidgetConfig {
20
20
  locale: string;
21
21
  language: string;
22
22
  open: boolean;
23
+ /** Whether replies play a sound while the visitor isn't looking at them. */
24
+ sound: boolean;
23
25
  require_email: boolean;
24
26
  chat_welcome: boolean;
25
27
  help_center_url: string;
@@ -17,7 +17,11 @@ export interface OpenDeskChatProps {
17
17
  theme?: 'light' | 'dark' | 'auto';
18
18
  /** Replaces any of the built-in English strings. */
19
19
  strings?: Partial<ChatStrings>;
20
- /** Height of any navigation header above the chat, so the keyboard never covers the composer on iOS. */
20
+ /**
21
+ * Extra space to keep between the composer and the keyboard. The chat measures its own position,
22
+ * so navigation headers need nothing here; inside an iOS page sheet, which it can't see past,
23
+ * pass the sheet's distance from the top of the screen.
24
+ */
21
25
  keyboardVerticalOffset?: number;
22
26
  }
23
27
  /**