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 +157 -133
- package/assets/chime.wav +0 -0
- package/lib/api.js +25 -8
- package/lib/client.d.ts +9 -0
- package/lib/client.js +14 -0
- package/lib/errors.d.ts +6 -2
- package/lib/errors.js +6 -2
- package/lib/index.d.ts +2 -0
- package/lib/index.js +3 -1
- package/lib/realtime.d.ts +5 -0
- package/lib/realtime.js +9 -1
- package/lib/sound.d.ts +22 -0
- package/lib/sound.js +33 -0
- package/lib/types.d.ts +2 -0
- package/lib/ui/OpenDeskChat.d.ts +5 -1
- package/lib/ui/OpenDeskChat.js +41 -11
- package/lib/ui/keyboard.d.ts +15 -0
- package/lib/ui/keyboard.js +62 -0
- package/lib/ui/strings.d.ts +2 -0
- package/lib/ui/strings.js +2 -0
- package/package.json +46 -45
- package/src/api.ts +195 -172
- package/src/assets.d.ts +5 -0
- package/src/client.ts +680 -659
- package/src/errors.ts +20 -16
- package/src/index.ts +27 -25
- package/src/realtime.ts +174 -166
- package/src/sound.ts +40 -0
- package/src/types.ts +202 -200
- package/src/ui/OpenDeskChat.tsx +1408 -1318
- package/src/ui/keyboard.ts +68 -0
- package/src/ui/strings.ts +59 -57
package/src/errors.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
*/
|
|
5
|
+
export class OpenDeskError extends Error {
|
|
6
|
+
constructor(
|
|
7
|
+
public readonly status: number,
|
|
8
|
+
public readonly code: string,
|
|
9
|
+
message: string,
|
|
10
|
+
public readonly cause?: unknown,
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'OpenDeskError';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The visitor token is unknown or revoked; a fresh session is needed. */
|
|
17
|
+
get isUnauthorized(): boolean {
|
|
18
|
+
return this.status === 401;
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
|
-
export { OpenDesk } from './client';
|
|
2
|
-
export type { AIReply, IncomingMessage, OpenDeskOptions, OpenDeskState } from './client';
|
|
3
|
-
export { OpenDeskApi } from './api';
|
|
4
|
-
export type { Fetcher, OpenDeskApiOptions } from './api';
|
|
5
|
-
export { OpenDeskError } from './errors';
|
|
6
|
-
export { OpenDeskRealtime, phoenixSocketFactory } from './realtime';
|
|
7
|
-
export type { RealtimeChannel, RealtimeSocket, SocketFactory } from './realtime';
|
|
8
|
-
export { keychainStorage, memoryStorage, secureStoreStorage } from './storage';
|
|
9
|
-
export type { ExpoSecureStoreModule, KeychainModule, TokenStorage } from './storage';
|
|
10
|
-
export {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export type {
|
|
21
|
-
export {
|
|
22
|
-
export type {
|
|
23
|
-
export {
|
|
24
|
-
export type {
|
|
25
|
-
export
|
|
1
|
+
export { OpenDesk } from './client';
|
|
2
|
+
export type { AIReply, IncomingMessage, OpenDeskOptions, OpenDeskState } from './client';
|
|
3
|
+
export { OpenDeskApi } from './api';
|
|
4
|
+
export type { Fetcher, OpenDeskApiOptions } from './api';
|
|
5
|
+
export { OpenDeskError } from './errors';
|
|
6
|
+
export { OpenDeskRealtime, phoenixSocketFactory } from './realtime';
|
|
7
|
+
export type { RealtimeChannel, RealtimeSocket, SocketFactory } from './realtime';
|
|
8
|
+
export { keychainStorage, memoryStorage, secureStoreStorage } from './storage';
|
|
9
|
+
export type { ExpoSecureStoreModule, KeychainModule, TokenStorage } from './storage';
|
|
10
|
+
export { expoAudioSound } from './sound';
|
|
11
|
+
export type { ExpoAudioModule, ExpoAudioPlayer } from './sound';
|
|
12
|
+
export {
|
|
13
|
+
OpenDeskProvider,
|
|
14
|
+
useConversation,
|
|
15
|
+
useConversations,
|
|
16
|
+
useOpenDesk,
|
|
17
|
+
useOpenDeskState,
|
|
18
|
+
useUnreadCount,
|
|
19
|
+
} from './react';
|
|
20
|
+
export type { ConversationView, OpenDeskProviderProps } from './react';
|
|
21
|
+
export { OpenDeskChat } from './ui/OpenDeskChat';
|
|
22
|
+
export type { OpenDeskChatProps } from './ui/OpenDeskChat';
|
|
23
|
+
export { OpenDeskLauncher } from './ui/OpenDeskLauncher';
|
|
24
|
+
export type { OpenDeskLauncherProps } from './ui/OpenDeskLauncher';
|
|
25
|
+
export { defaultStrings } from './ui/strings';
|
|
26
|
+
export type { ChatStrings } from './ui/strings';
|
|
27
|
+
export type * from './types';
|
package/src/realtime.ts
CHANGED
|
@@ -1,166 +1,174 @@
|
|
|
1
|
-
import { Socket } from 'phoenix';
|
|
2
|
-
import type { LiveEvent, RealtimeEvent, TypingEvent } from './types';
|
|
3
|
-
|
|
4
|
-
/** The parts of a Phoenix socket and channel the SDK uses, so tests can swap them. */
|
|
5
|
-
export interface RealtimeChannel {
|
|
6
|
-
on(event: string, callback: (payload: any) => void): unknown;
|
|
7
|
-
join(): unknown;
|
|
8
|
-
leave(): unknown;
|
|
9
|
-
push(event: string, payload: object): unknown;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface RealtimeSocket {
|
|
13
|
-
onOpen(callback: () => void): unknown;
|
|
14
|
-
onClose(callback: () => void): unknown;
|
|
15
|
-
onError(callback: () => void): unknown;
|
|
16
|
-
connect(): void;
|
|
17
|
-
disconnect(): void;
|
|
18
|
-
channel(topic: string, params?: object): RealtimeChannel;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export type SocketFactory = (url: string, params: () => { token: string }) => RealtimeSocket;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
channel.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
this.lastTypingAt
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
this.channel
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
1
|
+
import { Socket } from 'phoenix';
|
|
2
|
+
import type { LiveEvent, RealtimeEvent, TypingEvent } from './types';
|
|
3
|
+
|
|
4
|
+
/** The parts of a Phoenix socket and channel the SDK uses, so tests can swap them. */
|
|
5
|
+
export interface RealtimeChannel {
|
|
6
|
+
on(event: string, callback: (payload: any) => void): unknown;
|
|
7
|
+
join(): unknown;
|
|
8
|
+
leave(): unknown;
|
|
9
|
+
push(event: string, payload: object): unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RealtimeSocket {
|
|
13
|
+
onOpen(callback: () => void): unknown;
|
|
14
|
+
onClose(callback: () => void): unknown;
|
|
15
|
+
onError(callback: () => void): unknown;
|
|
16
|
+
connect(): void;
|
|
17
|
+
disconnect(): void;
|
|
18
|
+
channel(topic: string, params?: object): RealtimeChannel;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SocketFactory = (url: string, params: () => { token: string }) => RealtimeSocket;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The gateway reads the token from Phoenix's `authToken` (sent in the `Sec-WebSocket-Protocol`
|
|
25
|
+
* header) and refuses a connect that carries it in the query string. Phoenix calls the function
|
|
26
|
+
* on every connect, so a refreshed token is used on the next attempt.
|
|
27
|
+
*/
|
|
28
|
+
export const phoenixSocketFactory: SocketFactory = (url, params) =>
|
|
29
|
+
new Socket(url, {
|
|
30
|
+
authToken: () => params().token,
|
|
31
|
+
reconnectAfterMs: (tries: number) => [1000, 2000, 5000, 10000][tries - 1] ?? 15000,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export interface RealtimeHandlers {
|
|
35
|
+
onEvent(event: RealtimeEvent): void;
|
|
36
|
+
onLive(event: LiveEvent): void;
|
|
37
|
+
onTyping(event: TypingEvent): void;
|
|
38
|
+
onStatus(connected: boolean): void;
|
|
39
|
+
/** Fired when the socket comes back after a drop, so missed events can be fetched over REST. */
|
|
40
|
+
onReconnect(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TYPING_THROTTLE_MS = 3000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Joins `contact:<wid>:<contact_id>` on the widget gateway. The `ws_token` lasts five minutes
|
|
47
|
+
* and is checked only on connect, so every drop fetches a fresh one before the next attempt.
|
|
48
|
+
*/
|
|
49
|
+
export class OpenDeskRealtime {
|
|
50
|
+
private socket: RealtimeSocket | null = null;
|
|
51
|
+
private channel: RealtimeChannel | null = null;
|
|
52
|
+
private refreshing = false;
|
|
53
|
+
private lastTypingAt = 0;
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
private readonly factory: SocketFactory,
|
|
57
|
+
private readonly gatewayUrl: string,
|
|
58
|
+
private token: string,
|
|
59
|
+
private readonly contactId: string,
|
|
60
|
+
private readonly refreshToken: () => Promise<string>,
|
|
61
|
+
private readonly handlers: RealtimeHandlers,
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
start(): void {
|
|
65
|
+
const workspaceId = jwtClaim(this.token, 'wid');
|
|
66
|
+
if (!workspaceId || !this.gatewayUrl) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const socket = this.factory(this.gatewayUrl, () => ({ token: this.token }));
|
|
71
|
+
let dropped = false;
|
|
72
|
+
|
|
73
|
+
socket.onOpen(() => {
|
|
74
|
+
this.handlers.onStatus(true);
|
|
75
|
+
if (dropped) {
|
|
76
|
+
this.handlers.onReconnect();
|
|
77
|
+
}
|
|
78
|
+
dropped = false;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const onDrop = (): void => {
|
|
82
|
+
dropped = true;
|
|
83
|
+
this.handlers.onStatus(false);
|
|
84
|
+
if (this.refreshing) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this.refreshing = true;
|
|
88
|
+
this.refreshToken()
|
|
89
|
+
.then((token) => {
|
|
90
|
+
this.token = token;
|
|
91
|
+
})
|
|
92
|
+
.catch(() => undefined)
|
|
93
|
+
.finally(() => {
|
|
94
|
+
this.refreshing = false;
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
socket.onError(onDrop);
|
|
98
|
+
socket.onClose(onDrop);
|
|
99
|
+
socket.connect();
|
|
100
|
+
|
|
101
|
+
const channel = socket.channel(`contact:${workspaceId}:${this.contactId}`, {});
|
|
102
|
+
channel.on('event', (payload: RealtimeEvent) => this.handlers.onEvent(payload));
|
|
103
|
+
channel.on('typing', (payload: TypingEvent) => this.handlers.onTyping(payload));
|
|
104
|
+
channel.on('live', (payload: LiveEvent) => {
|
|
105
|
+
if (payload && typeof payload.type === 'string' && payload.data) {
|
|
106
|
+
this.handlers.onLive(payload);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
channel.join();
|
|
110
|
+
|
|
111
|
+
this.socket = socket;
|
|
112
|
+
this.channel = channel;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Tells agents the visitor is typing in a conversation; throttled to one push per 3s. */
|
|
116
|
+
typing(conversationId: string): void {
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
if (!this.channel || now - this.lastTypingAt < TYPING_THROTTLE_MS) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
this.lastTypingAt = now;
|
|
122
|
+
this.channel.push('typing', { ticket_id: conversationId });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
typingDone(): void {
|
|
126
|
+
this.lastTypingAt = 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
stop(): void {
|
|
130
|
+
this.channel?.leave();
|
|
131
|
+
this.socket?.disconnect();
|
|
132
|
+
this.channel = null;
|
|
133
|
+
this.socket = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function jwtClaim(token: string, claim: string): string | null {
|
|
138
|
+
try {
|
|
139
|
+
const segment = token.split('.')[1];
|
|
140
|
+
if (!segment) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const value = JSON.parse(decodeBase64Url(segment))[claim];
|
|
144
|
+
|
|
145
|
+
return typeof value === 'string' ? value : null;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
152
|
+
|
|
153
|
+
/** Decodes base64url to a UTF-8 string without relying on `atob`, which older Hermes lacks. */
|
|
154
|
+
function decodeBase64Url(input: string): string {
|
|
155
|
+
const clean = input.replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '');
|
|
156
|
+
const bytes: number[] = [];
|
|
157
|
+
let buffer = 0;
|
|
158
|
+
let bits = 0;
|
|
159
|
+
|
|
160
|
+
for (const char of clean) {
|
|
161
|
+
const index = BASE64.indexOf(char);
|
|
162
|
+
if (index < 0) {
|
|
163
|
+
throw new Error('Invalid base64');
|
|
164
|
+
}
|
|
165
|
+
buffer = (buffer << 6) | index;
|
|
166
|
+
bits += 6;
|
|
167
|
+
if (bits >= 8) {
|
|
168
|
+
bits -= 8;
|
|
169
|
+
bytes.push((buffer >> bits) & 0xff);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return decodeURIComponent(bytes.map((byte) => `%${byte.toString(16).padStart(2, '0')}`).join(''));
|
|
174
|
+
}
|
package/src/sound.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import chime from '../assets/chime.wav';
|
|
2
|
+
|
|
3
|
+
/** The parts of `expo-audio` the adapter uses; pass the module itself. */
|
|
4
|
+
export interface ExpoAudioModule {
|
|
5
|
+
createAudioPlayer(source: number): ExpoAudioPlayer;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ExpoAudioPlayer {
|
|
9
|
+
volume: number;
|
|
10
|
+
play(): void;
|
|
11
|
+
seekTo(seconds: number): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Plays the web widget's reply chime with `expo-audio`. Takes the module as an argument, so the
|
|
16
|
+
* SDK has no dependency on Expo:
|
|
17
|
+
*
|
|
18
|
+
* import * as ExpoAudio from 'expo-audio';
|
|
19
|
+
* new OpenDesk({ …, sound: expoAudioSound(ExpoAudio) });
|
|
20
|
+
*
|
|
21
|
+
* The player is made on the first reply and kept for the next. The app's audio mode is left
|
|
22
|
+
* alone; under expo-audio's default the chime mixes with other audio instead of pausing it.
|
|
23
|
+
*/
|
|
24
|
+
export function expoAudioSound(ExpoAudio: ExpoAudioModule, options: { volume?: number } = {}): () => void {
|
|
25
|
+
let player: ExpoAudioPlayer | null = null;
|
|
26
|
+
|
|
27
|
+
return () => {
|
|
28
|
+
if (!player) {
|
|
29
|
+
player = ExpoAudio.createAudioPlayer(chime);
|
|
30
|
+
if (options.volume !== undefined) {
|
|
31
|
+
player.volume = options.volume;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const current = player;
|
|
35
|
+
void current
|
|
36
|
+
.seekTo(0)
|
|
37
|
+
.catch(() => undefined)
|
|
38
|
+
.then(() => current.play());
|
|
39
|
+
};
|
|
40
|
+
}
|