opendesk-react-native 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +132 -2
- package/lib/api.d.ts +43 -0
- package/lib/api.js +114 -0
- package/lib/client.d.ts +120 -0
- package/lib/client.js +505 -0
- package/lib/errors.d.ts +8 -0
- package/lib/errors.js +17 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +29 -0
- package/lib/react.d.ts +33 -0
- package/lib/react.js +72 -0
- package/lib/realtime.d.ts +51 -0
- package/lib/realtime.js +124 -0
- package/lib/storage.d.ts +48 -0
- package/lib/storage.js +63 -0
- package/lib/types.d.ts +203 -0
- package/lib/types.js +6 -0
- package/lib/ui/BotFace.d.ts +13 -0
- package/lib/ui/BotFace.js +103 -0
- package/lib/ui/OpenDeskChat.d.ts +27 -0
- package/lib/ui/OpenDeskChat.js +636 -0
- package/lib/ui/OpenDeskLauncher.d.ts +12 -0
- package/lib/ui/OpenDeskLauncher.js +46 -0
- package/lib/ui/icons.d.ts +23 -0
- package/lib/ui/icons.js +95 -0
- package/lib/ui/strings.d.ts +53 -0
- package/lib/ui/strings.js +58 -0
- package/lib/ui/theme.d.ts +51 -0
- package/lib/ui/theme.js +89 -0
- package/package.json +43 -4
- package/src/api.ts +172 -0
- package/src/client.ts +659 -0
- package/src/errors.ts +16 -0
- package/src/index.ts +25 -0
- package/src/react.tsx +109 -0
- package/src/realtime.ts +166 -0
- package/src/storage.ts +93 -0
- package/src/types.ts +200 -0
- package/src/ui/BotFace.tsx +163 -0
- package/src/ui/OpenDeskChat.tsx +1318 -0
- package/src/ui/OpenDeskLauncher.tsx +69 -0
- package/src/ui/icons.tsx +162 -0
- package/src/ui/strings.ts +57 -0
- package/src/ui/theme.ts +127 -0
package/lib/react.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OpenDeskProvider = OpenDeskProvider;
|
|
4
|
+
exports.useOpenDesk = useOpenDesk;
|
|
5
|
+
exports.useOpenDeskState = useOpenDeskState;
|
|
6
|
+
exports.useUnreadCount = useUnreadCount;
|
|
7
|
+
exports.useConversations = useConversations;
|
|
8
|
+
exports.useConversation = useConversation;
|
|
9
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
10
|
+
const react_1 = require("react");
|
|
11
|
+
const OpenDeskContext = (0, react_1.createContext)(null);
|
|
12
|
+
function OpenDeskProvider({ client, identity, autoBoot = true, children }) {
|
|
13
|
+
const identityKey = identity ? JSON.stringify(identity) : identity;
|
|
14
|
+
const previousKey = (0, react_1.useRef)(undefined);
|
|
15
|
+
(0, react_1.useEffect)(() => {
|
|
16
|
+
if (identityKey === previousKey.current) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const hadIdentity = typeof previousKey.current === 'string';
|
|
20
|
+
previousKey.current = identityKey;
|
|
21
|
+
if (identity === null && hadIdentity) {
|
|
22
|
+
void client.shutdown();
|
|
23
|
+
}
|
|
24
|
+
else if (identity) {
|
|
25
|
+
void client.identify(identity).catch(() => undefined);
|
|
26
|
+
}
|
|
27
|
+
}, [client, identity, identityKey]);
|
|
28
|
+
(0, react_1.useEffect)(() => {
|
|
29
|
+
if (autoBoot) {
|
|
30
|
+
client.boot().catch(() => undefined);
|
|
31
|
+
}
|
|
32
|
+
}, [client, autoBoot]);
|
|
33
|
+
return (0, jsx_runtime_1.jsx)(OpenDeskContext.Provider, { value: client, children: children });
|
|
34
|
+
}
|
|
35
|
+
function useOpenDesk() {
|
|
36
|
+
const client = (0, react_1.useContext)(OpenDeskContext);
|
|
37
|
+
if (!client) {
|
|
38
|
+
throw new Error('useOpenDesk must be used inside <OpenDeskProvider>');
|
|
39
|
+
}
|
|
40
|
+
return client;
|
|
41
|
+
}
|
|
42
|
+
function useOpenDeskState(selector) {
|
|
43
|
+
const client = useOpenDesk();
|
|
44
|
+
return (0, react_1.useSyncExternalStore)(client.subscribe, () => selector(client.getState()));
|
|
45
|
+
}
|
|
46
|
+
function useUnreadCount() {
|
|
47
|
+
return useOpenDeskState((state) => Object.values(state.unread).reduce((total, count) => total + count, 0));
|
|
48
|
+
}
|
|
49
|
+
function useConversations() {
|
|
50
|
+
const conversations = useOpenDeskState((state) => state.conversations);
|
|
51
|
+
const status = useOpenDeskState((state) => state.status);
|
|
52
|
+
const error = useOpenDeskState((state) => state.error);
|
|
53
|
+
return { conversations, ready: status === 'ready', error };
|
|
54
|
+
}
|
|
55
|
+
const noMessages = [];
|
|
56
|
+
/** Opens a conversation while mounted: loads its messages, keeps it read, and closes it on unmount. */
|
|
57
|
+
function useConversation(conversationId) {
|
|
58
|
+
const client = useOpenDesk();
|
|
59
|
+
const messages = useOpenDeskState((state) => (conversationId ? state.messages[conversationId] : undefined) ?? noMessages);
|
|
60
|
+
const replies = useOpenDeskState((state) => state.aiReplies);
|
|
61
|
+
const typing = useOpenDeskState((state) => (conversationId ? state.typing[conversationId] : undefined) ?? null);
|
|
62
|
+
const conversation = useOpenDeskState((state) => state.conversations.find((c) => c.id === conversationId) ?? null);
|
|
63
|
+
(0, react_1.useEffect)(() => {
|
|
64
|
+
if (!conversationId) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
client.openConversation(conversationId).catch(() => undefined);
|
|
68
|
+
return () => client.closeConversation();
|
|
69
|
+
}, [client, conversationId]);
|
|
70
|
+
const aiReplies = (0, react_1.useMemo)(() => Object.values(replies).filter((reply) => reply.conversationId === conversationId), [replies, conversationId]);
|
|
71
|
+
return { messages, aiReplies, typing, conversation };
|
|
72
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { LiveEvent, RealtimeEvent, TypingEvent } from './types';
|
|
2
|
+
/** The parts of a Phoenix socket and channel the SDK uses, so tests can swap them. */
|
|
3
|
+
export interface RealtimeChannel {
|
|
4
|
+
on(event: string, callback: (payload: any) => void): unknown;
|
|
5
|
+
join(): unknown;
|
|
6
|
+
leave(): unknown;
|
|
7
|
+
push(event: string, payload: object): unknown;
|
|
8
|
+
}
|
|
9
|
+
export interface RealtimeSocket {
|
|
10
|
+
onOpen(callback: () => void): unknown;
|
|
11
|
+
onClose(callback: () => void): unknown;
|
|
12
|
+
onError(callback: () => void): unknown;
|
|
13
|
+
connect(): void;
|
|
14
|
+
disconnect(): void;
|
|
15
|
+
channel(topic: string, params?: object): RealtimeChannel;
|
|
16
|
+
}
|
|
17
|
+
export type SocketFactory = (url: string, params: () => {
|
|
18
|
+
token: string;
|
|
19
|
+
}) => RealtimeSocket;
|
|
20
|
+
export declare const phoenixSocketFactory: SocketFactory;
|
|
21
|
+
export interface RealtimeHandlers {
|
|
22
|
+
onEvent(event: RealtimeEvent): void;
|
|
23
|
+
onLive(event: LiveEvent): void;
|
|
24
|
+
onTyping(event: TypingEvent): void;
|
|
25
|
+
onStatus(connected: boolean): void;
|
|
26
|
+
/** Fired when the socket comes back after a drop, so missed events can be fetched over REST. */
|
|
27
|
+
onReconnect(): void;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Joins `contact:<wid>:<contact_id>` on the widget gateway. The `ws_token` lasts five minutes
|
|
31
|
+
* and is checked only on connect, so every drop fetches a fresh one before the next attempt.
|
|
32
|
+
*/
|
|
33
|
+
export declare class OpenDeskRealtime {
|
|
34
|
+
private readonly factory;
|
|
35
|
+
private readonly gatewayUrl;
|
|
36
|
+
private token;
|
|
37
|
+
private readonly contactId;
|
|
38
|
+
private readonly refreshToken;
|
|
39
|
+
private readonly handlers;
|
|
40
|
+
private socket;
|
|
41
|
+
private channel;
|
|
42
|
+
private refreshing;
|
|
43
|
+
private lastTypingAt;
|
|
44
|
+
constructor(factory: SocketFactory, gatewayUrl: string, token: string, contactId: string, refreshToken: () => Promise<string>, handlers: RealtimeHandlers);
|
|
45
|
+
start(): void;
|
|
46
|
+
/** Tells agents the visitor is typing in a conversation; throttled to one push per 3s. */
|
|
47
|
+
typing(conversationId: string): void;
|
|
48
|
+
typingDone(): void;
|
|
49
|
+
stop(): void;
|
|
50
|
+
}
|
|
51
|
+
export declare function jwtClaim(token: string, claim: string): string | null;
|
package/lib/realtime.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OpenDeskRealtime = exports.phoenixSocketFactory = void 0;
|
|
4
|
+
exports.jwtClaim = jwtClaim;
|
|
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 });
|
|
7
|
+
exports.phoenixSocketFactory = phoenixSocketFactory;
|
|
8
|
+
const TYPING_THROTTLE_MS = 3000;
|
|
9
|
+
/**
|
|
10
|
+
* Joins `contact:<wid>:<contact_id>` on the widget gateway. The `ws_token` lasts five minutes
|
|
11
|
+
* and is checked only on connect, so every drop fetches a fresh one before the next attempt.
|
|
12
|
+
*/
|
|
13
|
+
class OpenDeskRealtime {
|
|
14
|
+
constructor(factory, gatewayUrl, token, contactId, refreshToken, handlers) {
|
|
15
|
+
this.factory = factory;
|
|
16
|
+
this.gatewayUrl = gatewayUrl;
|
|
17
|
+
this.token = token;
|
|
18
|
+
this.contactId = contactId;
|
|
19
|
+
this.refreshToken = refreshToken;
|
|
20
|
+
this.handlers = handlers;
|
|
21
|
+
this.socket = null;
|
|
22
|
+
this.channel = null;
|
|
23
|
+
this.refreshing = false;
|
|
24
|
+
this.lastTypingAt = 0;
|
|
25
|
+
}
|
|
26
|
+
start() {
|
|
27
|
+
const workspaceId = jwtClaim(this.token, 'wid');
|
|
28
|
+
if (!workspaceId || !this.gatewayUrl) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const socket = this.factory(this.gatewayUrl, () => ({ token: this.token }));
|
|
32
|
+
let dropped = false;
|
|
33
|
+
socket.onOpen(() => {
|
|
34
|
+
this.handlers.onStatus(true);
|
|
35
|
+
if (dropped) {
|
|
36
|
+
this.handlers.onReconnect();
|
|
37
|
+
}
|
|
38
|
+
dropped = false;
|
|
39
|
+
});
|
|
40
|
+
const onDrop = () => {
|
|
41
|
+
dropped = true;
|
|
42
|
+
this.handlers.onStatus(false);
|
|
43
|
+
if (this.refreshing) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
this.refreshing = true;
|
|
47
|
+
this.refreshToken()
|
|
48
|
+
.then((token) => {
|
|
49
|
+
this.token = token;
|
|
50
|
+
})
|
|
51
|
+
.catch(() => undefined)
|
|
52
|
+
.finally(() => {
|
|
53
|
+
this.refreshing = false;
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
socket.onError(onDrop);
|
|
57
|
+
socket.onClose(onDrop);
|
|
58
|
+
socket.connect();
|
|
59
|
+
const channel = socket.channel(`contact:${workspaceId}:${this.contactId}`, {});
|
|
60
|
+
channel.on('event', (payload) => this.handlers.onEvent(payload));
|
|
61
|
+
channel.on('typing', (payload) => this.handlers.onTyping(payload));
|
|
62
|
+
channel.on('live', (payload) => {
|
|
63
|
+
if (payload && typeof payload.type === 'string' && payload.data) {
|
|
64
|
+
this.handlers.onLive(payload);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
channel.join();
|
|
68
|
+
this.socket = socket;
|
|
69
|
+
this.channel = channel;
|
|
70
|
+
}
|
|
71
|
+
/** Tells agents the visitor is typing in a conversation; throttled to one push per 3s. */
|
|
72
|
+
typing(conversationId) {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
if (!this.channel || now - this.lastTypingAt < TYPING_THROTTLE_MS) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
this.lastTypingAt = now;
|
|
78
|
+
this.channel.push('typing', { ticket_id: conversationId });
|
|
79
|
+
}
|
|
80
|
+
typingDone() {
|
|
81
|
+
this.lastTypingAt = 0;
|
|
82
|
+
}
|
|
83
|
+
stop() {
|
|
84
|
+
this.channel?.leave();
|
|
85
|
+
this.socket?.disconnect();
|
|
86
|
+
this.channel = null;
|
|
87
|
+
this.socket = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
exports.OpenDeskRealtime = OpenDeskRealtime;
|
|
91
|
+
function jwtClaim(token, claim) {
|
|
92
|
+
try {
|
|
93
|
+
const segment = token.split('.')[1];
|
|
94
|
+
if (!segment) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const value = JSON.parse(decodeBase64Url(segment))[claim];
|
|
98
|
+
return typeof value === 'string' ? value : null;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
105
|
+
/** Decodes base64url to a UTF-8 string without relying on `atob`, which older Hermes lacks. */
|
|
106
|
+
function decodeBase64Url(input) {
|
|
107
|
+
const clean = input.replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '');
|
|
108
|
+
const bytes = [];
|
|
109
|
+
let buffer = 0;
|
|
110
|
+
let bits = 0;
|
|
111
|
+
for (const char of clean) {
|
|
112
|
+
const index = BASE64.indexOf(char);
|
|
113
|
+
if (index < 0) {
|
|
114
|
+
throw new Error('Invalid base64');
|
|
115
|
+
}
|
|
116
|
+
buffer = (buffer << 6) | index;
|
|
117
|
+
bits += 6;
|
|
118
|
+
if (bits >= 8) {
|
|
119
|
+
bits -= 8;
|
|
120
|
+
bytes.push((buffer >> bits) & 0xff);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return decodeURIComponent(bytes.map((byte) => `%${byte.toString(16).padStart(2, '0')}`).join(''));
|
|
124
|
+
}
|
package/lib/storage.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the visitor token is kept between launches. The token is a bearer credential for the
|
|
3
|
+
* visitor's conversations, so prefer `secureStoreStorage` or `keychainStorage`, which keep it in
|
|
4
|
+
* the iOS Keychain / Android Keystore. Without a storage the session lasts until the app quits.
|
|
5
|
+
*/
|
|
6
|
+
export interface TokenStorage {
|
|
7
|
+
getItem(key: string): Promise<string | null>;
|
|
8
|
+
setItem(key: string, value: string): Promise<void>;
|
|
9
|
+
removeItem(key: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare function memoryStorage(): TokenStorage;
|
|
12
|
+
/** The parts of `expo-secure-store` the adapter uses; pass the module itself. */
|
|
13
|
+
export interface ExpoSecureStoreModule {
|
|
14
|
+
getItemAsync(key: string, options?: object): Promise<string | null>;
|
|
15
|
+
setItemAsync(key: string, value: string, options?: object): Promise<void>;
|
|
16
|
+
deleteItemAsync(key: string, options?: object): Promise<void>;
|
|
17
|
+
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY?: unknown;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Keeps the token in `expo-secure-store`. Takes the module as an argument, so the SDK has no
|
|
21
|
+
* dependency on Expo:
|
|
22
|
+
*
|
|
23
|
+
* import * as SecureStore from 'expo-secure-store';
|
|
24
|
+
* new OpenDesk({ …, storage: secureStoreStorage(SecureStore) });
|
|
25
|
+
*
|
|
26
|
+
* On iOS the item is readable after the first unlock and never leaves the device in backups.
|
|
27
|
+
*/
|
|
28
|
+
export declare function secureStoreStorage(SecureStore: ExpoSecureStoreModule): TokenStorage;
|
|
29
|
+
/** The parts of `react-native-keychain` the adapter uses; pass the module itself. */
|
|
30
|
+
export interface KeychainModule {
|
|
31
|
+
getGenericPassword(options?: object): Promise<false | {
|
|
32
|
+
username: string;
|
|
33
|
+
password: string;
|
|
34
|
+
}>;
|
|
35
|
+
setGenericPassword(username: string, password: string, options?: object): Promise<unknown>;
|
|
36
|
+
resetGenericPassword(options?: object): Promise<unknown>;
|
|
37
|
+
ACCESSIBLE?: {
|
|
38
|
+
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY?: unknown;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Keeps the token in `react-native-keychain`, one Keychain/Keystore entry per key (the key is
|
|
43
|
+
* the entry's `service`). Takes the module as an argument, so the SDK has no native dependency:
|
|
44
|
+
*
|
|
45
|
+
* import * as Keychain from 'react-native-keychain';
|
|
46
|
+
* new OpenDesk({ …, storage: keychainStorage(Keychain) });
|
|
47
|
+
*/
|
|
48
|
+
export declare function keychainStorage(Keychain: KeychainModule): TokenStorage;
|
package/lib/storage.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.memoryStorage = memoryStorage;
|
|
4
|
+
exports.secureStoreStorage = secureStoreStorage;
|
|
5
|
+
exports.keychainStorage = keychainStorage;
|
|
6
|
+
function memoryStorage() {
|
|
7
|
+
const items = new Map();
|
|
8
|
+
return {
|
|
9
|
+
getItem: async (key) => items.get(key) ?? null,
|
|
10
|
+
setItem: async (key, value) => {
|
|
11
|
+
items.set(key, value);
|
|
12
|
+
},
|
|
13
|
+
removeItem: async (key) => {
|
|
14
|
+
items.delete(key);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Keeps the token in `expo-secure-store`. Takes the module as an argument, so the SDK has no
|
|
20
|
+
* dependency on Expo:
|
|
21
|
+
*
|
|
22
|
+
* import * as SecureStore from 'expo-secure-store';
|
|
23
|
+
* new OpenDesk({ …, storage: secureStoreStorage(SecureStore) });
|
|
24
|
+
*
|
|
25
|
+
* On iOS the item is readable after the first unlock and never leaves the device in backups.
|
|
26
|
+
*/
|
|
27
|
+
function secureStoreStorage(SecureStore) {
|
|
28
|
+
const options = SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY === undefined
|
|
29
|
+
? undefined
|
|
30
|
+
: { keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY };
|
|
31
|
+
return {
|
|
32
|
+
getItem: (key) => SecureStore.getItemAsync(secureStoreKey(key), options),
|
|
33
|
+
setItem: (key, value) => SecureStore.setItemAsync(secureStoreKey(key), value, options),
|
|
34
|
+
removeItem: (key) => SecureStore.deleteItemAsync(secureStoreKey(key), options),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Keeps the token in `react-native-keychain`, one Keychain/Keystore entry per key (the key is
|
|
39
|
+
* the entry's `service`). Takes the module as an argument, so the SDK has no native dependency:
|
|
40
|
+
*
|
|
41
|
+
* import * as Keychain from 'react-native-keychain';
|
|
42
|
+
* new OpenDesk({ …, storage: keychainStorage(Keychain) });
|
|
43
|
+
*/
|
|
44
|
+
function keychainStorage(Keychain) {
|
|
45
|
+
const accessible = Keychain.ACCESSIBLE?.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY;
|
|
46
|
+
const options = (key) => ({ service: key, ...(accessible === undefined ? {} : { accessible }) });
|
|
47
|
+
return {
|
|
48
|
+
getItem: async (key) => {
|
|
49
|
+
const entry = await Keychain.getGenericPassword({ service: key });
|
|
50
|
+
return entry ? entry.password : null;
|
|
51
|
+
},
|
|
52
|
+
setItem: async (key, value) => {
|
|
53
|
+
await Keychain.setGenericPassword('opendesk', value, options(key));
|
|
54
|
+
},
|
|
55
|
+
removeItem: async (key) => {
|
|
56
|
+
await Keychain.resetGenericPassword({ service: key });
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** SecureStore keys may only hold letters, digits, `.`, `-` and `_`. */
|
|
61
|
+
function secureStoreKey(key) {
|
|
62
|
+
return key.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
63
|
+
}
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types for openDesk's visitor-facing widget API (`/v1/widget/*`), taken from
|
|
3
|
+
* openDesk's OpenAPI document. Only the fields a chat client needs are listed.
|
|
4
|
+
*/
|
|
5
|
+
export type ConversationStatus = 'open' | 'pending' | 'on_hold' | 'solved' | 'closed';
|
|
6
|
+
export interface WidgetConfig {
|
|
7
|
+
workspace_name: string;
|
|
8
|
+
title: string;
|
|
9
|
+
subtitle: string;
|
|
10
|
+
greeting: string;
|
|
11
|
+
intro: string;
|
|
12
|
+
offline_message: string;
|
|
13
|
+
brand_color: string;
|
|
14
|
+
brand: {
|
|
15
|
+
name: string;
|
|
16
|
+
color: string;
|
|
17
|
+
};
|
|
18
|
+
theme: 'auto' | 'light' | 'dark';
|
|
19
|
+
radius: 'square' | 'soft' | 'round';
|
|
20
|
+
locale: string;
|
|
21
|
+
language: string;
|
|
22
|
+
open: boolean;
|
|
23
|
+
require_email: boolean;
|
|
24
|
+
chat_welcome: boolean;
|
|
25
|
+
help_center_url: string;
|
|
26
|
+
avatar_url: string | null;
|
|
27
|
+
attachments: boolean;
|
|
28
|
+
attachment_extensions: string[] | null;
|
|
29
|
+
voice_messages: boolean;
|
|
30
|
+
voice_max_seconds: number;
|
|
31
|
+
ai_answers: boolean;
|
|
32
|
+
suggestions: string[] | null;
|
|
33
|
+
topics: {
|
|
34
|
+
id: string;
|
|
35
|
+
label: string;
|
|
36
|
+
}[] | null;
|
|
37
|
+
team: {
|
|
38
|
+
name: string;
|
|
39
|
+
avatar_url: string | null;
|
|
40
|
+
}[] | null;
|
|
41
|
+
bot: {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
role: string;
|
|
45
|
+
avatar_url: string;
|
|
46
|
+
greeting: string;
|
|
47
|
+
look?: BotLook | null;
|
|
48
|
+
} | null;
|
|
49
|
+
}
|
|
50
|
+
export interface ContactRef {
|
|
51
|
+
id: string;
|
|
52
|
+
name: string;
|
|
53
|
+
email: string | null;
|
|
54
|
+
avatar_url: string | null;
|
|
55
|
+
}
|
|
56
|
+
export interface UserRef {
|
|
57
|
+
id: string;
|
|
58
|
+
name: string;
|
|
59
|
+
email: string;
|
|
60
|
+
avatar_url: string | null;
|
|
61
|
+
}
|
|
62
|
+
export interface BotRef {
|
|
63
|
+
id: string;
|
|
64
|
+
name: string;
|
|
65
|
+
avatar_url: string;
|
|
66
|
+
look?: BotLook | null;
|
|
67
|
+
}
|
|
68
|
+
/** How the widget draws a bot that has no uploaded image. */
|
|
69
|
+
export interface BotLook {
|
|
70
|
+
kind?: 'built' | 'emoji' | 'image';
|
|
71
|
+
shape?: 'blob' | 'circle' | 'rounded' | 'squircle' | 'hexagon' | 'cloud';
|
|
72
|
+
fill?: {
|
|
73
|
+
type?: 'solid' | 'gradient';
|
|
74
|
+
color?: string;
|
|
75
|
+
to?: string;
|
|
76
|
+
};
|
|
77
|
+
eyes?: string;
|
|
78
|
+
mouth?: string;
|
|
79
|
+
emoji?: string;
|
|
80
|
+
}
|
|
81
|
+
export interface WidgetSession {
|
|
82
|
+
visitor_token: string;
|
|
83
|
+
contact: ContactRef;
|
|
84
|
+
ws_token: string;
|
|
85
|
+
gateway_url: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A verified identity: `user_hash` is hex(HMAC-SHA256(widget_secret, user_id)) and must be
|
|
89
|
+
* computed on your server, never in the app.
|
|
90
|
+
*/
|
|
91
|
+
export interface Identity {
|
|
92
|
+
user_id?: string;
|
|
93
|
+
user_hash?: string;
|
|
94
|
+
email?: string;
|
|
95
|
+
name?: string;
|
|
96
|
+
avatar_url?: string;
|
|
97
|
+
}
|
|
98
|
+
export interface Conversation {
|
|
99
|
+
id: string;
|
|
100
|
+
subject: string;
|
|
101
|
+
status: ConversationStatus;
|
|
102
|
+
last_message_at: string;
|
|
103
|
+
last_message_preview: string;
|
|
104
|
+
ai_handoff: boolean;
|
|
105
|
+
}
|
|
106
|
+
export interface Attachment {
|
|
107
|
+
id: string;
|
|
108
|
+
filename: string;
|
|
109
|
+
content_type: string;
|
|
110
|
+
size_bytes: number;
|
|
111
|
+
inline: boolean;
|
|
112
|
+
url: string;
|
|
113
|
+
}
|
|
114
|
+
export interface AIAction {
|
|
115
|
+
id: string;
|
|
116
|
+
status: 'pending' | 'confirmed' | 'declined' | 'expired' | 'failed';
|
|
117
|
+
title: string;
|
|
118
|
+
summary: string;
|
|
119
|
+
tool: string;
|
|
120
|
+
}
|
|
121
|
+
export interface Message {
|
|
122
|
+
id: string;
|
|
123
|
+
ticket_id: string;
|
|
124
|
+
kind: 'reply' | 'note';
|
|
125
|
+
direction: 'inbound' | 'outbound';
|
|
126
|
+
author: {
|
|
127
|
+
type: 'contact' | 'user' | 'system' | 'api';
|
|
128
|
+
contact?: ContactRef | null;
|
|
129
|
+
user?: UserRef | null;
|
|
130
|
+
bot?: BotRef | null;
|
|
131
|
+
};
|
|
132
|
+
body_text: string;
|
|
133
|
+
body_html: string;
|
|
134
|
+
attachments: Attachment[] | null;
|
|
135
|
+
created_at: string;
|
|
136
|
+
edited_at?: string | null;
|
|
137
|
+
deleted_at?: string | null;
|
|
138
|
+
ai?: boolean;
|
|
139
|
+
ai_action?: AIAction | null;
|
|
140
|
+
ai_rating?: 'up' | 'down' | '';
|
|
141
|
+
/**
|
|
142
|
+
* Set by the SDK, not openDesk: a render key that stays the same when the optimistic copy
|
|
143
|
+
* or the streaming AI answer is replaced by the stored message. Use `client_id ?? id` as a key.
|
|
144
|
+
*/
|
|
145
|
+
client_id?: string;
|
|
146
|
+
}
|
|
147
|
+
/** A file picked on the device, in the shape React Native's FormData expects. */
|
|
148
|
+
export interface LocalFile {
|
|
149
|
+
uri: string;
|
|
150
|
+
name: string;
|
|
151
|
+
type: string;
|
|
152
|
+
/** Bytes, when the picker reports it; shown before the upload finishes. */
|
|
153
|
+
size?: number;
|
|
154
|
+
}
|
|
155
|
+
/** What `upload` accepts: a picked file on iOS/Android, or a `File`/`Blob` on the web. */
|
|
156
|
+
export type UploadFile = LocalFile | Blob;
|
|
157
|
+
export interface StartConversationInput {
|
|
158
|
+
body?: string;
|
|
159
|
+
attachment_ids?: string[];
|
|
160
|
+
topic_id?: string;
|
|
161
|
+
page_url?: string;
|
|
162
|
+
}
|
|
163
|
+
export interface SendMessageInput {
|
|
164
|
+
body?: string;
|
|
165
|
+
attachment_ids?: string[];
|
|
166
|
+
page_url?: string;
|
|
167
|
+
}
|
|
168
|
+
/** The envelope the gateway pushes as `event` on `contact:<wid>:<contact_id>`. */
|
|
169
|
+
export interface RealtimeEvent {
|
|
170
|
+
id: string;
|
|
171
|
+
seq: number;
|
|
172
|
+
type: string;
|
|
173
|
+
visibility?: string;
|
|
174
|
+
occurred_at: string;
|
|
175
|
+
data: {
|
|
176
|
+
ticket_id?: string;
|
|
177
|
+
message_id?: string;
|
|
178
|
+
message?: Message;
|
|
179
|
+
[key: string]: unknown;
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** Ephemeral AI streaming frames pushed as `live`. */
|
|
183
|
+
export interface LiveEvent {
|
|
184
|
+
type: 'ai.reply.started' | 'ai.reply.delta' | 'ai.reply.tool' | 'ai.reply.ended' | string;
|
|
185
|
+
data: {
|
|
186
|
+
reply_id?: string;
|
|
187
|
+
ticket_id?: string;
|
|
188
|
+
text?: string;
|
|
189
|
+
tool?: string;
|
|
190
|
+
status?: string;
|
|
191
|
+
message_id?: string;
|
|
192
|
+
handoff?: boolean;
|
|
193
|
+
action_id?: string;
|
|
194
|
+
bot?: BotRef;
|
|
195
|
+
[key: string]: unknown;
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
export interface TypingEvent {
|
|
199
|
+
ticket_id: string;
|
|
200
|
+
user?: {
|
|
201
|
+
name?: string;
|
|
202
|
+
} | null;
|
|
203
|
+
}
|
package/lib/types.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { BotLook } from '../types';
|
|
2
|
+
/** The widget's default bot when none is configured: a violet blob with pill eyes. */
|
|
3
|
+
export declare const defaultBotLook: BotLook;
|
|
4
|
+
/**
|
|
5
|
+
* The bot avatar the web widget draws from its `look` settings: an uploaded image, an emoji,
|
|
6
|
+
* or a built face. The face is laid out on the widget's 64-unit grid (body from 7 to 57) with
|
|
7
|
+
* plain Views, so it needs no SVG library.
|
|
8
|
+
*/
|
|
9
|
+
export declare function BotFace({ size, look, avatarUrl }: {
|
|
10
|
+
size: number;
|
|
11
|
+
look?: BotLook | null;
|
|
12
|
+
avatarUrl?: string | null;
|
|
13
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.defaultBotLook = void 0;
|
|
4
|
+
exports.BotFace = BotFace;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_native_1 = require("react-native");
|
|
7
|
+
/** The widget's default bot when none is configured: a violet blob with pill eyes. */
|
|
8
|
+
exports.defaultBotLook = {
|
|
9
|
+
kind: 'built',
|
|
10
|
+
shape: 'blob',
|
|
11
|
+
fill: { type: 'gradient', color: '#c9bcff', to: '#4a2bc4' },
|
|
12
|
+
eyes: 'pills',
|
|
13
|
+
mouth: 'none',
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The bot avatar the web widget draws from its `look` settings: an uploaded image, an emoji,
|
|
17
|
+
* or a built face. The face is laid out on the widget's 64-unit grid (body from 7 to 57) with
|
|
18
|
+
* plain Views, so it needs no SVG library.
|
|
19
|
+
*/
|
|
20
|
+
function BotFace({ size, look, avatarUrl }) {
|
|
21
|
+
const resolved = look ?? exports.defaultBotLook;
|
|
22
|
+
const u = size / 64;
|
|
23
|
+
if (avatarUrl && (resolved.kind === 'image' || !look)) {
|
|
24
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: avatarUrl }, style: { width: size, height: size, borderRadius: size / 2 } });
|
|
25
|
+
}
|
|
26
|
+
const body = 50 * u;
|
|
27
|
+
const radius = { circle: body / 2, blob: body / 2, cloud: body * 0.42, squircle: body * 0.38, rounded: body * 0.28, hexagon: body * 0.3 }[resolved.shape ?? 'blob'] ?? body / 2;
|
|
28
|
+
const top = resolved.fill?.color ?? '#c9bcff';
|
|
29
|
+
const bottom = resolved.fill?.type === 'gradient' ? (resolved.fill.to ?? top) : top;
|
|
30
|
+
if (resolved.kind === 'emoji' && resolved.emoji) {
|
|
31
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: { width: size, height: size, alignItems: 'center', justifyContent: 'center' }, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: { fontSize: size * 0.72, lineHeight: size }, children: resolved.emoji }) }));
|
|
32
|
+
}
|
|
33
|
+
const at = (x, y, w, h) => ({
|
|
34
|
+
position: 'absolute',
|
|
35
|
+
left: x * u,
|
|
36
|
+
top: y * u,
|
|
37
|
+
width: w * u,
|
|
38
|
+
height: h * u,
|
|
39
|
+
});
|
|
40
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: { width: size, height: size }, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [at(7, 7, 50, 50), { borderRadius: radius, overflow: 'hidden', backgroundColor: bottom }], children: top === bottom
|
|
41
|
+
? null
|
|
42
|
+
: BANDS.map((index) => ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: {
|
|
43
|
+
position: 'absolute',
|
|
44
|
+
left: 0,
|
|
45
|
+
right: 0,
|
|
46
|
+
top: `${(index * 100) / BANDS.length}%`,
|
|
47
|
+
height: `${100 / BANDS.length + 1}%`,
|
|
48
|
+
backgroundColor: mixHex(top, bottom, index / (BANDS.length - 1)),
|
|
49
|
+
} }, index))) }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [at(15.5, 12.8, 13, 7), { borderRadius: 4 * u, backgroundColor: '#ffffff', opacity: 0.38, transform: [{ rotate: '-24deg' }] }] }), (0, jsx_runtime_1.jsx)(Eyes, { kind: resolved.eyes ?? 'pills', at: at, u: u }), (0, jsx_runtime_1.jsx)(Mouth, { kind: resolved.mouth ?? 'none', at: at, u: u })] }));
|
|
50
|
+
}
|
|
51
|
+
/** Enough horizontal bands that a two-stop gradient reads smooth at avatar sizes, without an SVG library. */
|
|
52
|
+
const BANDS = Array.from({ length: 24 }, (_, index) => index);
|
|
53
|
+
/** Interpolates two hex colours; `amount` 0 is `from`, 1 is `to`. */
|
|
54
|
+
function mixHex(from, to, amount) {
|
|
55
|
+
const parse = (hex) => {
|
|
56
|
+
const h = hex.replace('#', '');
|
|
57
|
+
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
|
|
58
|
+
const n = parseInt(full, 16);
|
|
59
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
60
|
+
};
|
|
61
|
+
const a = parse(from);
|
|
62
|
+
const b = parse(to);
|
|
63
|
+
return `#${a.map((channel, i) => Math.round(channel + (b[i] - channel) * amount).toString(16).padStart(2, '0')).join('')}`;
|
|
64
|
+
}
|
|
65
|
+
function Eyes({ kind, at, u }) {
|
|
66
|
+
const eye = (style) => (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [style, { backgroundColor: '#ffffff' }] });
|
|
67
|
+
switch (kind) {
|
|
68
|
+
case 'dots':
|
|
69
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [eye({ ...at(20.6, 26.6, 6.8, 6.8), borderRadius: 3.4 * u }), eye({ ...at(36.6, 26.6, 6.8, 6.8), borderRadius: 3.4 * u })] }));
|
|
70
|
+
case 'ovals':
|
|
71
|
+
case 'glasses':
|
|
72
|
+
case 'sparkle':
|
|
73
|
+
case 'star':
|
|
74
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [eye({ ...at(21.3, 25.9, 5.4, 8.2), borderRadius: 2.7 * u }), eye({ ...at(37.3, 25.9, 5.4, 8.2), borderRadius: 2.7 * u })] }));
|
|
75
|
+
case 'happy':
|
|
76
|
+
case 'sleepy':
|
|
77
|
+
case 'wink':
|
|
78
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [eye({ ...at(19.5, 27, 9, 5), borderTopLeftRadius: 4.5 * u, borderTopRightRadius: 4.5 * u }), eye({ ...at(35.5, 27, 9, 5), borderTopLeftRadius: 4.5 * u, borderTopRightRadius: 4.5 * u })] }));
|
|
79
|
+
default:
|
|
80
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [eye({ ...at(24.4, 28.35, 4.4, 7.3), borderRadius: 2.2 * u }), eye({ ...at(35.2, 28.35, 4.4, 7.3), borderRadius: 2.2 * u })] }));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function Mouth({ kind, at, u }) {
|
|
84
|
+
switch (kind) {
|
|
85
|
+
case 'smile':
|
|
86
|
+
case 'cat':
|
|
87
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
|
|
88
|
+
at(26.4, 37.6, 11.2, 6.2),
|
|
89
|
+
{ borderBottomWidth: 1.6 * u, borderColor: '#ffffff', borderBottomLeftRadius: 6 * u, borderBottomRightRadius: 6 * u },
|
|
90
|
+
] }));
|
|
91
|
+
case 'grin':
|
|
92
|
+
case 'tongue':
|
|
93
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [at(25.4, 38.4, 13.2, 8.2), { backgroundColor: '#ffffff', borderBottomLeftRadius: 6.6 * u, borderBottomRightRadius: 6.6 * u }] }));
|
|
94
|
+
case 'open':
|
|
95
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [at(28.7, 38.1, 6.6, 7.8), { backgroundColor: '#ffffff', borderRadius: 3.3 * u }] });
|
|
96
|
+
case 'flat':
|
|
97
|
+
case 'tiny':
|
|
98
|
+
case 'smirk':
|
|
99
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [at(28, 41, 8, 1.6), { backgroundColor: '#ffffff', borderRadius: 0.8 * u }] });
|
|
100
|
+
default:
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|