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
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type StyleProp, type ViewStyle } from 'react-native';
|
|
2
|
+
export interface OpenDeskLauncherProps {
|
|
3
|
+
onPress: () => void;
|
|
4
|
+
/** Shows the close icon instead of the chat bubble. */
|
|
5
|
+
open?: boolean;
|
|
6
|
+
accentColor?: string;
|
|
7
|
+
accessibilityLabel?: string;
|
|
8
|
+
/** Position it yourself, e.g. `{ position: 'absolute', right: 20, bottom: 20 }`. */
|
|
9
|
+
style?: StyleProp<ViewStyle>;
|
|
10
|
+
}
|
|
11
|
+
/** The web widget's launcher button: brand colour, rounded square, unread badge. */
|
|
12
|
+
export declare function OpenDeskLauncher({ onPress, open, accentColor, accessibilityLabel, style }: OpenDeskLauncherProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OpenDeskLauncher = OpenDeskLauncher;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const react_native_1 = require("react-native");
|
|
7
|
+
const react_2 = require("../react");
|
|
8
|
+
const icons_1 = require("./icons");
|
|
9
|
+
const theme_1 = require("./theme");
|
|
10
|
+
/** The web widget's launcher button: brand colour, rounded square, unread badge. */
|
|
11
|
+
function OpenDeskLauncher({ onPress, open = false, accentColor, accessibilityLabel, style }) {
|
|
12
|
+
const config = (0, react_2.useOpenDeskState)((state) => state.config);
|
|
13
|
+
const unread = (0, react_2.useUnreadCount)();
|
|
14
|
+
const scheme = (0, react_native_1.useColorScheme)() === 'dark' ? 'dark' : 'light';
|
|
15
|
+
const theme = (0, react_1.useMemo)(() => (0, theme_1.makeTheme)(config, config?.theme === 'light' || config?.theme === 'dark' ? config.theme : scheme, accentColor), [accentColor, config, scheme]);
|
|
16
|
+
const radius = { square: 10, soft: 16, round: 27 }[config?.radius ?? 'soft'] ?? 16;
|
|
17
|
+
const label = accessibilityLabel ?? (open ? 'Close chat' : unread > 0 ? `Open chat, ${unread} new` : 'Open chat');
|
|
18
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { accessibilityRole: "button", accessibilityLabel: label, onPress: onPress, style: ({ pressed }) => [styles.launcher, { backgroundColor: theme.accent, borderRadius: radius }, pressed && styles.pressed, style], children: [open ? (0, jsx_runtime_1.jsx)(icons_1.CloseIcon, { color: theme.accentFg, size: 22 }) : (0, jsx_runtime_1.jsx)(icons_1.ChatIcon, { color: theme.accentFg, size: 24 }), !open && unread > 0 ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.badge, { backgroundColor: theme.danger, borderColor: theme.bg }], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.badgeText, children: unread > 99 ? '99+' : unread }) })) : null] }));
|
|
19
|
+
}
|
|
20
|
+
const styles = react_native_1.StyleSheet.create({
|
|
21
|
+
launcher: {
|
|
22
|
+
width: 54,
|
|
23
|
+
height: 54,
|
|
24
|
+
alignItems: 'center',
|
|
25
|
+
justifyContent: 'center',
|
|
26
|
+
shadowColor: '#000000',
|
|
27
|
+
shadowOpacity: 0.3,
|
|
28
|
+
shadowRadius: 10,
|
|
29
|
+
shadowOffset: { width: 0, height: 8 },
|
|
30
|
+
elevation: 6,
|
|
31
|
+
},
|
|
32
|
+
pressed: { transform: [{ scale: 0.96 }] },
|
|
33
|
+
badge: {
|
|
34
|
+
position: 'absolute',
|
|
35
|
+
top: -5,
|
|
36
|
+
right: -5,
|
|
37
|
+
minWidth: 20,
|
|
38
|
+
height: 20,
|
|
39
|
+
paddingHorizontal: 6,
|
|
40
|
+
borderRadius: 10,
|
|
41
|
+
borderWidth: 2,
|
|
42
|
+
alignItems: 'center',
|
|
43
|
+
justifyContent: 'center',
|
|
44
|
+
},
|
|
45
|
+
badgeText: { color: '#ffffff', fontSize: 11, fontWeight: '600' },
|
|
46
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The widget's line icons drawn with plain Views, so the SDK needs no react-native-svg.
|
|
3
|
+
* Each fits a square of `size` with 1.6px strokes, like the web widget's 1.6 stroke SVGs.
|
|
4
|
+
*/
|
|
5
|
+
interface IconProps {
|
|
6
|
+
color: string;
|
|
7
|
+
size?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function CloseIcon({ color, size }: IconProps): import("react").JSX.Element;
|
|
10
|
+
/** A chevron; `direction` is where it points. */
|
|
11
|
+
export declare function ChevronIcon({ color, size, direction }: IconProps & {
|
|
12
|
+
direction?: 'left' | 'right';
|
|
13
|
+
}): import("react").JSX.Element;
|
|
14
|
+
/** An arrow; `angle` is the clockwise rotation from pointing right. */
|
|
15
|
+
export declare function ArrowIcon({ color, size, angle }: IconProps & {
|
|
16
|
+
angle?: number;
|
|
17
|
+
}): import("react").JSX.Element;
|
|
18
|
+
export declare function PaperclipIcon({ color, size }: IconProps): import("react").JSX.Element;
|
|
19
|
+
/** A speech bubble, used on the launcher. */
|
|
20
|
+
export declare function ChatIcon({ color, size }: IconProps): import("react").JSX.Element;
|
|
21
|
+
export declare function PersonIcon({ color, size }: IconProps): import("react").JSX.Element;
|
|
22
|
+
export declare function SparkIcon({ color, size }: IconProps): import("react").JSX.Element;
|
|
23
|
+
export {};
|
package/lib/ui/icons.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CloseIcon = CloseIcon;
|
|
4
|
+
exports.ChevronIcon = ChevronIcon;
|
|
5
|
+
exports.ArrowIcon = ArrowIcon;
|
|
6
|
+
exports.PaperclipIcon = PaperclipIcon;
|
|
7
|
+
exports.ChatIcon = ChatIcon;
|
|
8
|
+
exports.PersonIcon = PersonIcon;
|
|
9
|
+
exports.SparkIcon = SparkIcon;
|
|
10
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
11
|
+
const react_native_1 = require("react-native");
|
|
12
|
+
const stroke = 1.6;
|
|
13
|
+
function Box({ size, children }) {
|
|
14
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { style: { width: size, height: size, alignItems: 'center', justifyContent: 'center' }, children: children });
|
|
15
|
+
}
|
|
16
|
+
function Line({ length, color, style }) {
|
|
17
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [{ position: 'absolute', width: length, height: stroke, borderRadius: stroke, backgroundColor: color }, style] }));
|
|
18
|
+
}
|
|
19
|
+
function CloseIcon({ color, size = 16 }) {
|
|
20
|
+
const length = size * 0.78;
|
|
21
|
+
return ((0, jsx_runtime_1.jsxs)(Box, { size: size, children: [(0, jsx_runtime_1.jsx)(Line, { length: length, color: color, style: { transform: [{ rotate: '45deg' }] } }), (0, jsx_runtime_1.jsx)(Line, { length: length, color: color, style: { transform: [{ rotate: '-45deg' }] } })] }));
|
|
22
|
+
}
|
|
23
|
+
/** A chevron; `direction` is where it points. */
|
|
24
|
+
function ChevronIcon({ color, size = 16, direction = 'left' }) {
|
|
25
|
+
const arm = size * 0.42;
|
|
26
|
+
return ((0, jsx_runtime_1.jsx)(Box, { size: size, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: {
|
|
27
|
+
width: arm,
|
|
28
|
+
height: arm,
|
|
29
|
+
borderLeftWidth: stroke,
|
|
30
|
+
borderBottomWidth: stroke,
|
|
31
|
+
borderColor: color,
|
|
32
|
+
borderBottomLeftRadius: 1,
|
|
33
|
+
transform: [{ translateX: direction === 'left' ? arm * 0.2 : -arm * 0.2 }, { rotate: direction === 'left' ? '45deg' : '-135deg' }],
|
|
34
|
+
} }) }));
|
|
35
|
+
}
|
|
36
|
+
/** An arrow; `angle` is the clockwise rotation from pointing right. */
|
|
37
|
+
function ArrowIcon({ color, size = 16, angle = 0 }) {
|
|
38
|
+
const shaft = size * 0.7;
|
|
39
|
+
const head = size * 0.36;
|
|
40
|
+
return ((0, jsx_runtime_1.jsx)(Box, { size: size, children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: { width: shaft, height: shaft, alignItems: 'center', justifyContent: 'center', transform: [{ rotate: `${angle}deg` }] }, children: [(0, jsx_runtime_1.jsx)(Line, { length: shaft, color: color }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: {
|
|
41
|
+
position: 'absolute',
|
|
42
|
+
right: stroke / 2,
|
|
43
|
+
width: head,
|
|
44
|
+
height: head,
|
|
45
|
+
borderTopWidth: stroke,
|
|
46
|
+
borderRightWidth: stroke,
|
|
47
|
+
borderColor: color,
|
|
48
|
+
borderTopRightRadius: 1,
|
|
49
|
+
transform: [{ rotate: '45deg' }],
|
|
50
|
+
} })] }) }));
|
|
51
|
+
}
|
|
52
|
+
function PaperclipIcon({ color, size = 16 }) {
|
|
53
|
+
const width = size * 0.42;
|
|
54
|
+
const height = size * 0.86;
|
|
55
|
+
return ((0, jsx_runtime_1.jsx)(Box, { size: size, children: (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: { transform: [{ rotate: '40deg' }] }, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: { width, height, borderWidth: stroke, borderColor: color, borderRadius: width / 2, borderBottomColor: color } }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: {
|
|
56
|
+
position: 'absolute',
|
|
57
|
+
left: width / 2 - stroke / 2,
|
|
58
|
+
top: height * 0.22,
|
|
59
|
+
width: stroke,
|
|
60
|
+
height: height * 0.5,
|
|
61
|
+
borderRadius: stroke,
|
|
62
|
+
backgroundColor: color,
|
|
63
|
+
} })] }) }));
|
|
64
|
+
}
|
|
65
|
+
/** A speech bubble, used on the launcher. */
|
|
66
|
+
function ChatIcon({ color, size = 22 }) {
|
|
67
|
+
const width = size * 0.86;
|
|
68
|
+
const height = size * 0.66;
|
|
69
|
+
return ((0, jsx_runtime_1.jsxs)(Box, { size: size, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: { width, height, borderWidth: 2, borderColor: color, borderRadius: height * 0.38 } }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: {
|
|
70
|
+
position: 'absolute',
|
|
71
|
+
left: size * 0.2,
|
|
72
|
+
bottom: size * 0.06,
|
|
73
|
+
width: size * 0.22,
|
|
74
|
+
height: size * 0.22,
|
|
75
|
+
borderLeftWidth: 2,
|
|
76
|
+
borderBottomWidth: 2,
|
|
77
|
+
borderColor: color,
|
|
78
|
+
borderBottomLeftRadius: 2,
|
|
79
|
+
transform: [{ skewY: '-20deg' }],
|
|
80
|
+
} })] }));
|
|
81
|
+
}
|
|
82
|
+
function PersonIcon({ color, size = 16 }) {
|
|
83
|
+
return ((0, jsx_runtime_1.jsxs)(Box, { size: size, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: { width: size * 0.38, height: size * 0.38, borderRadius: size, borderWidth: stroke, borderColor: color, marginBottom: 1 } }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: {
|
|
84
|
+
width: size * 0.66,
|
|
85
|
+
height: size * 0.3,
|
|
86
|
+
borderTopLeftRadius: size,
|
|
87
|
+
borderTopRightRadius: size,
|
|
88
|
+
borderWidth: stroke,
|
|
89
|
+
borderBottomWidth: 0,
|
|
90
|
+
borderColor: color,
|
|
91
|
+
} })] }));
|
|
92
|
+
}
|
|
93
|
+
function SparkIcon({ color, size = 10 }) {
|
|
94
|
+
return ((0, jsx_runtime_1.jsx)(Box, { size: size, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: { width: size * 0.5, height: size * 0.5, backgroundColor: color, borderRadius: 1, transform: [{ rotate: '45deg' }] } }) }));
|
|
95
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** The web widget's English copy, so both channels read the same. Override any key via `strings`. */
|
|
2
|
+
export declare const defaultStrings: {
|
|
3
|
+
support: string;
|
|
4
|
+
closeChat: string;
|
|
5
|
+
greeting: string;
|
|
6
|
+
replyTime: string;
|
|
7
|
+
replyTimeAi: string;
|
|
8
|
+
unavailable: string;
|
|
9
|
+
unavailableHint: string;
|
|
10
|
+
sendMessage: string;
|
|
11
|
+
yourConversations: string;
|
|
12
|
+
conversation: string;
|
|
13
|
+
typing: string;
|
|
14
|
+
botTyping: string;
|
|
15
|
+
searchHelp: string;
|
|
16
|
+
poweredBy: string;
|
|
17
|
+
back: string;
|
|
18
|
+
emptyHint: string;
|
|
19
|
+
notSent: string;
|
|
20
|
+
sending: string;
|
|
21
|
+
uploading: string;
|
|
22
|
+
removeFile: string;
|
|
23
|
+
fileTypeRefused: string;
|
|
24
|
+
fileTooLarge: string;
|
|
25
|
+
uploadFailed: string;
|
|
26
|
+
placeholder: string;
|
|
27
|
+
attach: string;
|
|
28
|
+
send: string;
|
|
29
|
+
ai: string;
|
|
30
|
+
talkToPerson: string;
|
|
31
|
+
handoffFailed: string;
|
|
32
|
+
handedOff: string;
|
|
33
|
+
handedOffHint: string;
|
|
34
|
+
checking: string;
|
|
35
|
+
checked: string;
|
|
36
|
+
toolFailed: string;
|
|
37
|
+
actionPending: string;
|
|
38
|
+
confirm: string;
|
|
39
|
+
cancel: string;
|
|
40
|
+
actionConfirmed: string;
|
|
41
|
+
actionDeclined: string;
|
|
42
|
+
actionExpired: string;
|
|
43
|
+
actionFailed: string;
|
|
44
|
+
topicsTitle: string;
|
|
45
|
+
topicsHint: string;
|
|
46
|
+
placeholderTopic: string;
|
|
47
|
+
changeTopic: string;
|
|
48
|
+
offlineDefault: string;
|
|
49
|
+
today: string;
|
|
50
|
+
yesterday: string;
|
|
51
|
+
};
|
|
52
|
+
export type ChatStrings = typeof defaultStrings;
|
|
53
|
+
export declare function format(template: string, values?: Record<string, string | number>): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.defaultStrings = void 0;
|
|
4
|
+
exports.format = format;
|
|
5
|
+
/** The web widget's English copy, so both channels read the same. Override any key via `strings`. */
|
|
6
|
+
exports.defaultStrings = {
|
|
7
|
+
support: 'Support',
|
|
8
|
+
closeChat: 'Close chat',
|
|
9
|
+
greeting: 'Hi there. How can we help?',
|
|
10
|
+
replyTime: 'We usually reply within a few hours.',
|
|
11
|
+
replyTimeAi: '{name} answers right away, the team within a few hours.',
|
|
12
|
+
unavailable: 'Chat is unavailable',
|
|
13
|
+
unavailableHint: 'Check your connection and try again later.',
|
|
14
|
+
sendMessage: 'Send us a message',
|
|
15
|
+
yourConversations: 'Your conversations',
|
|
16
|
+
conversation: 'Conversation',
|
|
17
|
+
typing: '{name} is typing…',
|
|
18
|
+
botTyping: '{name} is writing…',
|
|
19
|
+
searchHelp: 'Search the help center',
|
|
20
|
+
poweredBy: 'Powered by opendesk',
|
|
21
|
+
back: 'Back to conversations',
|
|
22
|
+
emptyHint: "Ask us anything. We'll reply here, and by email if you leave one.",
|
|
23
|
+
notSent: 'Not sent.',
|
|
24
|
+
sending: 'Sending…',
|
|
25
|
+
uploading: 'Uploading…',
|
|
26
|
+
removeFile: 'Remove {name}',
|
|
27
|
+
fileTypeRefused: "{name} can't be sent here. Try another file type.",
|
|
28
|
+
fileTooLarge: '{name} is larger than 25 MB.',
|
|
29
|
+
uploadFailed: "Couldn't upload {name}. Try again.",
|
|
30
|
+
placeholder: 'Write a message…',
|
|
31
|
+
attach: 'Attach files',
|
|
32
|
+
send: 'Send',
|
|
33
|
+
ai: 'AI',
|
|
34
|
+
talkToPerson: 'Talk to a person',
|
|
35
|
+
handoffFailed: "Couldn't reach the team. Try again.",
|
|
36
|
+
handedOff: 'A person will reply here',
|
|
37
|
+
handedOffHint: "You'll see their reply in this conversation, and by email if you left one.",
|
|
38
|
+
checking: 'Checking {tool}…',
|
|
39
|
+
checked: 'Checked {tool}',
|
|
40
|
+
toolFailed: "Couldn't check {tool}",
|
|
41
|
+
actionPending: 'Needs your confirmation',
|
|
42
|
+
confirm: 'Confirm',
|
|
43
|
+
cancel: 'Cancel',
|
|
44
|
+
actionConfirmed: 'Confirmed',
|
|
45
|
+
actionDeclined: 'Cancelled',
|
|
46
|
+
actionExpired: 'Expired',
|
|
47
|
+
actionFailed: "Didn't go through",
|
|
48
|
+
topicsTitle: 'What can we help with?',
|
|
49
|
+
topicsHint: 'Pick a topic so your message reaches the right people.',
|
|
50
|
+
placeholderTopic: 'Pick a topic to start',
|
|
51
|
+
changeTopic: 'Change topic',
|
|
52
|
+
offlineDefault: "We're away right now. Leave a message and we'll reply as soon as we're back.",
|
|
53
|
+
today: 'Today',
|
|
54
|
+
yesterday: 'Yesterday',
|
|
55
|
+
};
|
|
56
|
+
function format(template, values = {}) {
|
|
57
|
+
return template.replace(/\{(\w+)\}/g, (match, key) => (key in values ? String(values[key]) : match));
|
|
58
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { WidgetConfig } from '../types';
|
|
2
|
+
/** Colour tokens from openDesk's web widget (`.root` and `.root[data-theme="dark"]`). */
|
|
3
|
+
export interface Palette {
|
|
4
|
+
bg: string;
|
|
5
|
+
bg2: string;
|
|
6
|
+
bg3: string;
|
|
7
|
+
line: string;
|
|
8
|
+
line2: string;
|
|
9
|
+
fg: string;
|
|
10
|
+
fg2: string;
|
|
11
|
+
fg3: string;
|
|
12
|
+
fg4: string;
|
|
13
|
+
ai: string;
|
|
14
|
+
aiSoft: string;
|
|
15
|
+
aiLine: string;
|
|
16
|
+
danger: string;
|
|
17
|
+
dangerSoft: string;
|
|
18
|
+
}
|
|
19
|
+
/** The widget's `radius` setting, in the same steps as the web widget. */
|
|
20
|
+
declare const radii: {
|
|
21
|
+
readonly square: {
|
|
22
|
+
readonly bubble: 6;
|
|
23
|
+
readonly ctl: 4;
|
|
24
|
+
readonly card: 4;
|
|
25
|
+
readonly bar: 4;
|
|
26
|
+
readonly av: 4;
|
|
27
|
+
};
|
|
28
|
+
readonly soft: {
|
|
29
|
+
readonly bubble: 14;
|
|
30
|
+
readonly ctl: 8;
|
|
31
|
+
readonly card: 10;
|
|
32
|
+
readonly bar: 10;
|
|
33
|
+
readonly av: 6;
|
|
34
|
+
};
|
|
35
|
+
readonly round: {
|
|
36
|
+
readonly bubble: 20;
|
|
37
|
+
readonly ctl: 999;
|
|
38
|
+
readonly card: 16;
|
|
39
|
+
readonly bar: 22;
|
|
40
|
+
readonly av: 999;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
export interface ChatTheme extends Palette {
|
|
44
|
+
accent: string;
|
|
45
|
+
accentFg: string;
|
|
46
|
+
headBg: string;
|
|
47
|
+
radius: (typeof radii)[keyof typeof radii];
|
|
48
|
+
mono: string;
|
|
49
|
+
}
|
|
50
|
+
export declare function makeTheme(config: WidgetConfig | null, scheme: 'light' | 'dark', accentOverride?: string): ChatTheme;
|
|
51
|
+
export {};
|
package/lib/ui/theme.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.makeTheme = makeTheme;
|
|
4
|
+
const react_native_1 = require("react-native");
|
|
5
|
+
const light = {
|
|
6
|
+
bg: '#ffffff',
|
|
7
|
+
bg2: '#f4f4f5',
|
|
8
|
+
bg3: '#fafafa',
|
|
9
|
+
line: '#e7e7e9',
|
|
10
|
+
line2: '#d6d6d9',
|
|
11
|
+
fg: '#0a0a0a',
|
|
12
|
+
fg2: '#525252',
|
|
13
|
+
fg3: '#737373',
|
|
14
|
+
fg4: '#a3a3a3',
|
|
15
|
+
ai: '#6d4bd8',
|
|
16
|
+
aiSoft: '#f4f0ff',
|
|
17
|
+
aiLine: '#e4dcff',
|
|
18
|
+
danger: '#dc2626',
|
|
19
|
+
dangerSoft: '#fef2f2',
|
|
20
|
+
};
|
|
21
|
+
const dark = {
|
|
22
|
+
bg: '#161616',
|
|
23
|
+
bg2: '#242426',
|
|
24
|
+
bg3: '#1c1c1d',
|
|
25
|
+
line: '#2c2c2f',
|
|
26
|
+
line2: '#3a3a3e',
|
|
27
|
+
fg: '#fafafa',
|
|
28
|
+
fg2: '#c4c4c6',
|
|
29
|
+
fg3: '#8d8d92',
|
|
30
|
+
fg4: '#5e5e63',
|
|
31
|
+
ai: '#b9a3ff',
|
|
32
|
+
aiSoft: '#231d33',
|
|
33
|
+
aiLine: '#372d52',
|
|
34
|
+
danger: '#f87171',
|
|
35
|
+
dangerSoft: '#2a1515',
|
|
36
|
+
};
|
|
37
|
+
/** The widget's `radius` setting, in the same steps as the web widget. */
|
|
38
|
+
const radii = {
|
|
39
|
+
square: { bubble: 6, ctl: 4, card: 4, bar: 4, av: 4 },
|
|
40
|
+
soft: { bubble: 14, ctl: 8, card: 10, bar: 10, av: 6 },
|
|
41
|
+
round: { bubble: 20, ctl: 999, card: 16, bar: 22, av: 999 },
|
|
42
|
+
};
|
|
43
|
+
function makeTheme(config, scheme, accentOverride) {
|
|
44
|
+
const palette = scheme === 'dark' ? dark : light;
|
|
45
|
+
const brand = normalizeHex(accentOverride ?? config?.brand_color ?? config?.brand?.color);
|
|
46
|
+
const accent = brand ?? palette.fg;
|
|
47
|
+
return {
|
|
48
|
+
...palette,
|
|
49
|
+
accent,
|
|
50
|
+
accentFg: brand ? readableOn(brand) : palette.bg,
|
|
51
|
+
headBg: mix(accent, palette.bg3, 0.035),
|
|
52
|
+
radius: radii[config?.radius ?? 'soft'] ?? radii.soft,
|
|
53
|
+
mono: react_native_1.Platform.select({ ios: 'Menlo', android: 'monospace', default: 'ui-monospace, SFMono-Regular, Menlo, monospace' }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function normalizeHex(value) {
|
|
57
|
+
if (!value) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const hex = value.trim().replace(/^#/, '');
|
|
61
|
+
return /^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex) ? `#${hex}` : null;
|
|
62
|
+
}
|
|
63
|
+
function rgb(hex) {
|
|
64
|
+
let h = hex.replace('#', '');
|
|
65
|
+
if (h.length === 3) {
|
|
66
|
+
h = h
|
|
67
|
+
.split('')
|
|
68
|
+
.map((c) => c + c)
|
|
69
|
+
.join('');
|
|
70
|
+
}
|
|
71
|
+
const n = parseInt(h, 16);
|
|
72
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
73
|
+
}
|
|
74
|
+
/** `color-mix(in srgb, a weight, b)`. */
|
|
75
|
+
function mix(a, b, weight) {
|
|
76
|
+
const [ar, ag, ab] = rgb(a);
|
|
77
|
+
const [br, bg, bb] = rgb(b);
|
|
78
|
+
const channel = (x, y) => Math.round(x * weight + y * (1 - weight)).toString(16).padStart(2, '0');
|
|
79
|
+
return `#${channel(ar, br)}${channel(ag, bg)}${channel(ab, bb)}`;
|
|
80
|
+
}
|
|
81
|
+
/** White or near-black text, whichever reads better on the brand colour. */
|
|
82
|
+
function readableOn(hex) {
|
|
83
|
+
const [r, g, b] = rgb(hex).map((c) => {
|
|
84
|
+
const v = c / 255;
|
|
85
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
86
|
+
});
|
|
87
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
88
|
+
return luminance > 0.45 ? '#0a0a0a' : '#ffffff';
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opendesk-react-native",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "React Native SDK for openDesk live chat: headless client, realtime, hooks and a drop-in chat screen.",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"src",
|
|
10
|
+
"!src/**/__tests__"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc -p tsconfig.build.json",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"prepare": "npm run build",
|
|
17
|
+
"example": "vite --config example/vite.config.ts"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"opendesk",
|
|
21
|
+
"react-native",
|
|
22
|
+
"live-chat",
|
|
23
|
+
"support"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"phoenix": "^1.7.21"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": ">=18",
|
|
31
|
+
"react-native": ">=0.72"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/phoenix": "^1.6.6",
|
|
35
|
+
"@types/react": "^19.1.0",
|
|
36
|
+
"@vitejs/plugin-react": "^5.2.0",
|
|
37
|
+
"react": "^19.1.0",
|
|
38
|
+
"react-dom": "^19.3.1",
|
|
39
|
+
"react-native": "^0.81.0",
|
|
40
|
+
"react-native-web": "^0.21.4",
|
|
41
|
+
"typescript": "^5.9.2",
|
|
42
|
+
"vite": "^7.3.7",
|
|
43
|
+
"vitest": "^3.2.4"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { OpenDeskError } from './errors';
|
|
2
|
+
import type {
|
|
3
|
+
Attachment,
|
|
4
|
+
Conversation,
|
|
5
|
+
Identity,
|
|
6
|
+
Message,
|
|
7
|
+
SendMessageInput,
|
|
8
|
+
StartConversationInput,
|
|
9
|
+
UploadFile,
|
|
10
|
+
WidgetConfig,
|
|
11
|
+
WidgetSession,
|
|
12
|
+
} from './types';
|
|
13
|
+
|
|
14
|
+
export type Fetcher = typeof fetch;
|
|
15
|
+
|
|
16
|
+
export interface OpenDeskApiOptions {
|
|
17
|
+
/** The openDesk origin, e.g. `https://desk.example.com`. `/v1` is added. */
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
/** The chat widget's public key (`wk_…`). */
|
|
20
|
+
widgetKey: string;
|
|
21
|
+
fetcher?: Fetcher;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Thin REST client for openDesk's visitor endpoints. Holds the visitor token once a
|
|
26
|
+
* session exists and sends it as `Authorization: Visitor <token>`.
|
|
27
|
+
*/
|
|
28
|
+
export class OpenDeskApi {
|
|
29
|
+
readonly baseUrl: string;
|
|
30
|
+
readonly widgetKey: string;
|
|
31
|
+
visitorToken: string | null = null;
|
|
32
|
+
private readonly fetcher: Fetcher;
|
|
33
|
+
|
|
34
|
+
constructor(options: OpenDeskApiOptions) {
|
|
35
|
+
this.baseUrl = options.baseUrl.replace(/\/widget\/v1\.js$/, '').replace(/\/+$/, '').replace(/\/v1$/, '');
|
|
36
|
+
this.widgetKey = options.widgetKey;
|
|
37
|
+
this.fetcher = options.fetcher ?? ((input, init) => fetch(input, init));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
config(): Promise<WidgetConfig> {
|
|
41
|
+
return this.request('GET', `/v1/widget/config?key=${encodeURIComponent(this.widgetKey)}`, undefined, false);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Starts a session, or resumes the current one when a visitor token is held. */
|
|
45
|
+
async session(identity: Identity = {}): Promise<WidgetSession> {
|
|
46
|
+
const session = await this.request<WidgetSession>(
|
|
47
|
+
'POST',
|
|
48
|
+
'/v1/widget/session',
|
|
49
|
+
{ key: this.widgetKey, ...(this.visitorToken ? { visitor_token: this.visitorToken } : {}), ...identity },
|
|
50
|
+
false,
|
|
51
|
+
);
|
|
52
|
+
this.visitorToken = session.visitor_token;
|
|
53
|
+
|
|
54
|
+
return session;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async endSession(): Promise<void> {
|
|
58
|
+
if (this.visitorToken) {
|
|
59
|
+
await this.request('DELETE', '/v1/widget/session');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async conversations(): Promise<Conversation[]> {
|
|
64
|
+
const response = await this.request<{ data: Conversation[] | null }>('GET', '/v1/widget/conversations');
|
|
65
|
+
|
|
66
|
+
return response.data ?? [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
startConversation(input: StartConversationInput): Promise<{ conversation: Conversation; message: Message }> {
|
|
70
|
+
return this.request('POST', '/v1/widget/conversations', input);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async messages(conversationId: string): Promise<Message[]> {
|
|
74
|
+
const response = await this.request<{ data: Message[] | null }>(
|
|
75
|
+
'GET',
|
|
76
|
+
`/v1/widget/conversations/${encodeURIComponent(conversationId)}/messages`,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return response.data ?? [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
sendMessage(conversationId: string, input: SendMessageInput): Promise<Message> {
|
|
83
|
+
return this.request('POST', `/v1/widget/conversations/${encodeURIComponent(conversationId)}/messages`, input);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async markSeen(conversationId: string): Promise<void> {
|
|
87
|
+
await this.request('POST', `/v1/widget/conversations/${encodeURIComponent(conversationId)}/seen`, {});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Asks for a person after an AI answer. */
|
|
91
|
+
async handoff(conversationId: string): Promise<void> {
|
|
92
|
+
await this.request('POST', `/v1/widget/conversations/${encodeURIComponent(conversationId)}/handoff`, {});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Confirms or declines an action the AI proposed. */
|
|
96
|
+
async decideAction(conversationId: string, actionId: string, decision: 'confirm' | 'decline'): Promise<void> {
|
|
97
|
+
await this.request(
|
|
98
|
+
'POST',
|
|
99
|
+
`/v1/widget/conversations/${encodeURIComponent(conversationId)}/actions/${encodeURIComponent(actionId)}`,
|
|
100
|
+
{ decision },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async rateMessage(conversationId: string, messageId: string, rating: 'up' | 'down'): Promise<void> {
|
|
105
|
+
await this.request(
|
|
106
|
+
'POST',
|
|
107
|
+
`/v1/widget/conversations/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(messageId)}/rating`,
|
|
108
|
+
{ rating },
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Uploads one file as multipart `file`. React Native's FormData takes `{ uri, name, type }`;
|
|
114
|
+
* a browser needs a real Blob, so web Files are passed through as they are.
|
|
115
|
+
*/
|
|
116
|
+
upload(file: UploadFile): Promise<Attachment> {
|
|
117
|
+
const form = new FormData();
|
|
118
|
+
if (isBlob(file)) {
|
|
119
|
+
form.append('file', file, 'name' in file && typeof file.name === 'string' ? file.name : 'upload');
|
|
120
|
+
} else {
|
|
121
|
+
form.append('file', { uri: file.uri, name: file.name, type: file.type || 'application/octet-stream' } as unknown as Blob);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return this.request('POST', '/v1/widget/attachments', form);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async request<T>(method: string, path: string, body?: unknown, authenticated = true): Promise<T> {
|
|
128
|
+
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
129
|
+
let payload: BodyInit | undefined;
|
|
130
|
+
|
|
131
|
+
if (body instanceof FormData) {
|
|
132
|
+
payload = body;
|
|
133
|
+
} else if (body !== undefined) {
|
|
134
|
+
headers['Content-Type'] = 'application/json';
|
|
135
|
+
payload = JSON.stringify(body);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (authenticated && this.visitorToken) {
|
|
139
|
+
headers.Authorization = `Visitor ${this.visitorToken}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let response: Response;
|
|
143
|
+
try {
|
|
144
|
+
response = await this.fetcher(this.baseUrl + path, { method, headers, body: payload });
|
|
145
|
+
} catch {
|
|
146
|
+
throw new OpenDeskError(0, 'network_error', "Can't reach the server");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
let code = 'error';
|
|
151
|
+
let message = `Request failed (${response.status})`;
|
|
152
|
+
try {
|
|
153
|
+
const problem = (await response.json()) as { code?: string; detail?: string; title?: string };
|
|
154
|
+
code = problem.code ?? code;
|
|
155
|
+
message = problem.detail || problem.title || message;
|
|
156
|
+
} catch {
|
|
157
|
+
// Not a problem+json body.
|
|
158
|
+
}
|
|
159
|
+
throw new OpenDeskError(response.status, code, message);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (response.status === 204 || !(response.headers.get('content-type') ?? '').includes('json')) {
|
|
163
|
+
return undefined as T;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return (await response.json()) as T;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isBlob(file: UploadFile): file is Blob {
|
|
171
|
+
return typeof Blob !== 'undefined' && file instanceof Blob;
|
|
172
|
+
}
|