opendesk-react-native 0.1.3 → 0.1.7
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 +25 -1
- 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/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 +77 -12
- package/lib/ui/RichText.d.ts +15 -0
- package/lib/ui/RichText.js +59 -0
- package/lib/ui/keyboard.d.ts +15 -0
- package/lib/ui/keyboard.js +62 -0
- package/lib/ui/rich.d.ts +54 -0
- package/lib/ui/rich.js +444 -0
- package/lib/ui/strings.d.ts +2 -0
- package/lib/ui/strings.js +2 -0
- package/package.json +2 -1
- 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/sound.ts +40 -0
- package/src/types.ts +202 -200
- package/src/ui/OpenDeskChat.tsx +1476 -1318
- package/src/ui/RichText.tsx +129 -0
- package/src/ui/keyboard.ts +68 -0
- package/src/ui/rich.ts +507 -0
- package/src/ui/strings.ts +59 -57
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
import { type View } from 'react-native';
|
|
3
|
+
/**
|
|
4
|
+
* How far the keyboard covers the view in `ref`, for use as its bottom padding.
|
|
5
|
+
*
|
|
6
|
+
* KeyboardAvoidingView compares the keyboard with a layout relative to its parent, so it misses
|
|
7
|
+
* anything above that parent (a header, a sheet), and on Android it relies on the window
|
|
8
|
+
* shrinking, which edge-to-edge apps no longer do. This measures the view in window coordinates
|
|
9
|
+
* instead and works the same on both platforms. Call `onLayout` from the view's `onLayout` so a
|
|
10
|
+
* window that does shrink is measured again and the padding drops back.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useKeyboardInset(ref: RefObject<View | null>, extraOffset?: number): {
|
|
13
|
+
inset: number;
|
|
14
|
+
onLayout: () => void;
|
|
15
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useKeyboardInset = useKeyboardInset;
|
|
4
|
+
const react_1 = require("react");
|
|
5
|
+
const react_native_1 = require("react-native");
|
|
6
|
+
/**
|
|
7
|
+
* How far the keyboard covers the view in `ref`, for use as its bottom padding.
|
|
8
|
+
*
|
|
9
|
+
* KeyboardAvoidingView compares the keyboard with a layout relative to its parent, so it misses
|
|
10
|
+
* anything above that parent (a header, a sheet), and on Android it relies on the window
|
|
11
|
+
* shrinking, which edge-to-edge apps no longer do. This measures the view in window coordinates
|
|
12
|
+
* instead and works the same on both platforms. Call `onLayout` from the view's `onLayout` so a
|
|
13
|
+
* window that does shrink is measured again and the padding drops back.
|
|
14
|
+
*/
|
|
15
|
+
function useKeyboardInset(ref, extraOffset = 0) {
|
|
16
|
+
const [inset, setInset] = (0, react_1.useState)(0);
|
|
17
|
+
const keyboard = (0, react_1.useRef)(null);
|
|
18
|
+
const measure = (0, react_1.useCallback)(() => {
|
|
19
|
+
const event = keyboard.current;
|
|
20
|
+
const view = ref.current;
|
|
21
|
+
if (!event || !view) {
|
|
22
|
+
setInset(0);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
view.measureInWindow((_x, y, _width, height) => {
|
|
26
|
+
if (keyboard.current !== event) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const next = Math.max(0, Math.round(y + height - event.endCoordinates.screenY + extraOffset));
|
|
30
|
+
setInset((current) => {
|
|
31
|
+
if (current !== next) {
|
|
32
|
+
animateWithKeyboard(event);
|
|
33
|
+
}
|
|
34
|
+
return next;
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}, [ref, extraOffset]);
|
|
38
|
+
(0, react_1.useEffect)(() => {
|
|
39
|
+
const show = (event) => {
|
|
40
|
+
keyboard.current = event;
|
|
41
|
+
measure();
|
|
42
|
+
};
|
|
43
|
+
const hide = (event) => {
|
|
44
|
+
keyboard.current = null;
|
|
45
|
+
animateWithKeyboard(event);
|
|
46
|
+
setInset(0);
|
|
47
|
+
};
|
|
48
|
+
const subscriptions = react_native_1.Platform.OS === 'ios'
|
|
49
|
+
? [react_native_1.Keyboard.addListener('keyboardWillChangeFrame', show), react_native_1.Keyboard.addListener('keyboardWillHide', hide)]
|
|
50
|
+
: [react_native_1.Keyboard.addListener('keyboardDidShow', show), react_native_1.Keyboard.addListener('keyboardDidHide', hide)];
|
|
51
|
+
return () => subscriptions.forEach((subscription) => subscription.remove());
|
|
52
|
+
}, [measure]);
|
|
53
|
+
return { inset, onLayout: measure };
|
|
54
|
+
}
|
|
55
|
+
/** iOS reports the keyboard's own curve, so the padding can move in step with it. */
|
|
56
|
+
function animateWithKeyboard(event) {
|
|
57
|
+
if (react_native_1.Platform.OS !== 'ios' || !event?.duration) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const type = react_native_1.LayoutAnimation.Types[event.easing] ?? react_native_1.LayoutAnimation.Types.keyboard;
|
|
61
|
+
react_native_1.LayoutAnimation.configureNext({ duration: event.duration, update: { duration: event.duration, type } });
|
|
62
|
+
}
|
package/lib/ui/rich.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns message bodies into a small block/inline tree that React Native can render, the way
|
|
3
|
+
* openDesk's web widget does: stored messages from `body_html`, AI answers still streaming (and
|
|
4
|
+
* messages with only `body_text`) from the widget's markdown subset. A trailing list of links in
|
|
5
|
+
* an AI answer becomes its sources.
|
|
6
|
+
*/
|
|
7
|
+
export type Inline = {
|
|
8
|
+
kind: 'text';
|
|
9
|
+
text: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'bold' | 'italic';
|
|
12
|
+
children: Inline[];
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'code';
|
|
15
|
+
text: string;
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'link';
|
|
18
|
+
href: string;
|
|
19
|
+
children: Inline[];
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'break';
|
|
22
|
+
};
|
|
23
|
+
export type Block = {
|
|
24
|
+
kind: 'paragraph' | 'heading' | 'quote';
|
|
25
|
+
inlines: Inline[];
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'list';
|
|
28
|
+
ordered: boolean;
|
|
29
|
+
items: Inline[][];
|
|
30
|
+
} | {
|
|
31
|
+
kind: 'code';
|
|
32
|
+
text: string;
|
|
33
|
+
};
|
|
34
|
+
export interface Source {
|
|
35
|
+
href: string;
|
|
36
|
+
title: string;
|
|
37
|
+
}
|
|
38
|
+
/** Only web and mail links open; anything else (javascript:, intent:, …) renders as text. */
|
|
39
|
+
export declare function isSafeHref(href: string): boolean;
|
|
40
|
+
/** The domain shown with a source, without `www.`. */
|
|
41
|
+
export declare function hostOf(href: string): string;
|
|
42
|
+
/** Plain text of inlines, e.g. for accessibility labels. */
|
|
43
|
+
export declare function textOf(inlines: Inline[]): string;
|
|
44
|
+
export declare function parseMarkdown(source: string, streaming?: boolean): Block[];
|
|
45
|
+
/** Parses stored message HTML. `citations` drops AI `[1]` markers, as for markdown. */
|
|
46
|
+
export declare function parseHtml(html: string, citations?: boolean): Block[];
|
|
47
|
+
/**
|
|
48
|
+
* Splits off an AI answer's sources: a trailing list in which every item is just one web link.
|
|
49
|
+
* Like the web widget, it leaves the answer alone when the list is all there is.
|
|
50
|
+
*/
|
|
51
|
+
export declare function extractSources(blocks: Block[]): {
|
|
52
|
+
blocks: Block[];
|
|
53
|
+
sources: Source[];
|
|
54
|
+
};
|
package/lib/ui/rich.js
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Turns message bodies into a small block/inline tree that React Native can render, the way
|
|
4
|
+
* openDesk's web widget does: stored messages from `body_html`, AI answers still streaming (and
|
|
5
|
+
* messages with only `body_text`) from the widget's markdown subset. A trailing list of links in
|
|
6
|
+
* an AI answer becomes its sources.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.isSafeHref = isSafeHref;
|
|
10
|
+
exports.hostOf = hostOf;
|
|
11
|
+
exports.textOf = textOf;
|
|
12
|
+
exports.parseMarkdown = parseMarkdown;
|
|
13
|
+
exports.parseHtml = parseHtml;
|
|
14
|
+
exports.extractSources = extractSources;
|
|
15
|
+
/** Only web and mail links open; anything else (javascript:, intent:, …) renders as text. */
|
|
16
|
+
function isSafeHref(href) {
|
|
17
|
+
return /^(https?:\/\/|mailto:)/i.test(href.trim());
|
|
18
|
+
}
|
|
19
|
+
/** The domain shown with a source, without `www.`. */
|
|
20
|
+
function hostOf(href) {
|
|
21
|
+
const match = /^https?:\/\/([^/?#:]+)/i.exec(href);
|
|
22
|
+
return match ? match[1].replace(/^www\./i, '') : '';
|
|
23
|
+
}
|
|
24
|
+
/** Plain text of inlines, e.g. for accessibility labels. */
|
|
25
|
+
function textOf(inlines) {
|
|
26
|
+
return inlines
|
|
27
|
+
.map((inline) => {
|
|
28
|
+
switch (inline.kind) {
|
|
29
|
+
case 'text':
|
|
30
|
+
case 'code':
|
|
31
|
+
return inline.text;
|
|
32
|
+
case 'break':
|
|
33
|
+
return '\n';
|
|
34
|
+
default:
|
|
35
|
+
return textOf(inline.children);
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
.join('');
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------------------------
|
|
41
|
+
// Markdown: the web widget's subset (bold, italic, code, links, bare URLs, lists, headings,
|
|
42
|
+
// quotes, fences), including its clean-ups for text that is still streaming in.
|
|
43
|
+
// ---------------------------------------------------------------------------------------------
|
|
44
|
+
const CITATIONS = /[ \t]*\[\d{1,3}(?:\s*,\s*\d{1,3})*\](?!\()/g;
|
|
45
|
+
const PARTIAL_CITATION = /[ \t]*\[\d{0,3}(?:\s*,\s*\d{0,3})*$/;
|
|
46
|
+
const INLINE = /(\*\*([^*\n]+?)\*\*)|(`([^`\n]+?)`)|(\[([^\]\n]+)\]\(([^)\s]+)\))|((^|[^\w*])\*([^*\s][^*\n]*?)\*(?![\w*]))|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/g;
|
|
47
|
+
const BULLET = /^\s{0,3}[-*+]\s+(.*)$/;
|
|
48
|
+
const NUMBERED = /^\s{0,3}\d{1,3}[.)]\s+(.*)$/;
|
|
49
|
+
const HEADING = /^\s{0,3}#{1,6}\s+(.*)$/;
|
|
50
|
+
const QUOTE = /^\s{0,3}>\s?(.*)$/;
|
|
51
|
+
const FENCE = /^\s{0,3}```/;
|
|
52
|
+
/** Drops `[1]`-style citation markers the AI leaves in its text. */
|
|
53
|
+
function stripCitations(text, streaming) {
|
|
54
|
+
const stripped = text.replace(CITATIONS, '');
|
|
55
|
+
return streaming ? stripped.replace(PARTIAL_CITATION, '') : stripped;
|
|
56
|
+
}
|
|
57
|
+
/** Hides half-typed markup while an answer streams in, so `**bo` never shows its asterisks. */
|
|
58
|
+
function closeOpenMarkup(text) {
|
|
59
|
+
let result = text;
|
|
60
|
+
if ((result.match(/```/g) ?? []).length % 2 === 1) {
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
if ((result.match(/\*\*/g) ?? []).length % 2 === 1) {
|
|
64
|
+
const at = result.lastIndexOf('**');
|
|
65
|
+
result = result.slice(0, at) + result.slice(at + 2);
|
|
66
|
+
}
|
|
67
|
+
if ((result.replace(/```/g, '').match(/`/g) ?? []).length % 2 === 1) {
|
|
68
|
+
const at = result.lastIndexOf('`');
|
|
69
|
+
result = result.slice(0, at) + result.slice(at + 1);
|
|
70
|
+
}
|
|
71
|
+
return result.replace(/\[([^\]\n]*)\]\([^)\s]*$/, '$1').replace(/\[([^\]\n]*)$/, '$1');
|
|
72
|
+
}
|
|
73
|
+
function markdownInlines(text) {
|
|
74
|
+
const inlines = [];
|
|
75
|
+
const pattern = new RegExp(INLINE.source, 'g');
|
|
76
|
+
let last = 0;
|
|
77
|
+
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
|
|
78
|
+
if (match.index > last) {
|
|
79
|
+
inlines.push({ kind: 'text', text: text.slice(last, match.index) });
|
|
80
|
+
}
|
|
81
|
+
if (match[1]) {
|
|
82
|
+
inlines.push({ kind: 'bold', children: markdownInlines(match[2]) });
|
|
83
|
+
}
|
|
84
|
+
else if (match[3]) {
|
|
85
|
+
inlines.push({ kind: 'code', text: match[4] });
|
|
86
|
+
}
|
|
87
|
+
else if (match[5]) {
|
|
88
|
+
const href = match[7];
|
|
89
|
+
inlines.push(isSafeHref(href) ? { kind: 'link', href, children: markdownInlines(match[6]) } : { kind: 'text', text: match[6] });
|
|
90
|
+
}
|
|
91
|
+
else if (match[8]) {
|
|
92
|
+
if (match[9]) {
|
|
93
|
+
inlines.push({ kind: 'text', text: match[9] });
|
|
94
|
+
}
|
|
95
|
+
inlines.push({ kind: 'italic', children: markdownInlines(match[10]) });
|
|
96
|
+
}
|
|
97
|
+
else if (match[11]) {
|
|
98
|
+
inlines.push({ kind: 'link', href: match[11], children: [{ kind: 'text', text: match[11] }] });
|
|
99
|
+
}
|
|
100
|
+
last = match.index + match[0].length;
|
|
101
|
+
}
|
|
102
|
+
if (last < text.length) {
|
|
103
|
+
inlines.push({ kind: 'text', text: text.slice(last) });
|
|
104
|
+
}
|
|
105
|
+
return inlines;
|
|
106
|
+
}
|
|
107
|
+
function parseMarkdown(source, streaming = false) {
|
|
108
|
+
let text = stripCitations(source.replace(/\r\n?/g, '\n'), streaming);
|
|
109
|
+
if (streaming) {
|
|
110
|
+
text = closeOpenMarkup(text);
|
|
111
|
+
}
|
|
112
|
+
const lines = text.split('\n');
|
|
113
|
+
const blocks = [];
|
|
114
|
+
let paragraph = [];
|
|
115
|
+
const flush = () => {
|
|
116
|
+
if (paragraph.length === 0) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const inlines = [];
|
|
120
|
+
paragraph.forEach((line, index) => {
|
|
121
|
+
if (index > 0) {
|
|
122
|
+
inlines.push({ kind: 'break' });
|
|
123
|
+
}
|
|
124
|
+
inlines.push(...markdownInlines(line));
|
|
125
|
+
});
|
|
126
|
+
blocks.push({ kind: 'paragraph', inlines });
|
|
127
|
+
paragraph = [];
|
|
128
|
+
};
|
|
129
|
+
for (let i = 0; i < lines.length; i++) {
|
|
130
|
+
const line = lines[i];
|
|
131
|
+
if (FENCE.test(line)) {
|
|
132
|
+
flush();
|
|
133
|
+
const code = [];
|
|
134
|
+
for (i++; i < lines.length && !FENCE.test(lines[i]); i++) {
|
|
135
|
+
code.push(lines[i]);
|
|
136
|
+
}
|
|
137
|
+
blocks.push({ kind: 'code', text: code.join('\n') });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const listPattern = BULLET.test(line) ? BULLET : NUMBERED.test(line) ? NUMBERED : null;
|
|
141
|
+
if (listPattern) {
|
|
142
|
+
flush();
|
|
143
|
+
const items = [];
|
|
144
|
+
for (; i < lines.length && listPattern.test(lines[i]); i++) {
|
|
145
|
+
items.push(markdownInlines(listPattern.exec(lines[i])[1]));
|
|
146
|
+
}
|
|
147
|
+
i--;
|
|
148
|
+
blocks.push({ kind: 'list', ordered: listPattern === NUMBERED, items });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const heading = HEADING.exec(line);
|
|
152
|
+
if (heading) {
|
|
153
|
+
flush();
|
|
154
|
+
blocks.push({ kind: 'heading', inlines: markdownInlines(heading[1]) });
|
|
155
|
+
}
|
|
156
|
+
else if (QUOTE.test(line)) {
|
|
157
|
+
flush();
|
|
158
|
+
const quoted = [];
|
|
159
|
+
for (; i < lines.length && QUOTE.test(lines[i]); i++) {
|
|
160
|
+
quoted.push(QUOTE.exec(lines[i])[1]);
|
|
161
|
+
}
|
|
162
|
+
i--;
|
|
163
|
+
blocks.push({ kind: 'quote', inlines: markdownInlines(quoted.join(' ')) });
|
|
164
|
+
}
|
|
165
|
+
else if (line.trim()) {
|
|
166
|
+
paragraph.push(line.trim());
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
flush();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
flush();
|
|
173
|
+
return blocks;
|
|
174
|
+
}
|
|
175
|
+
const VOID = new Set(['br', 'img', 'hr', 'input', 'meta', 'link', 'wbr', 'source', 'col']);
|
|
176
|
+
const SKIP = new Set(['script', 'style', 'head', 'title', 'template', 'noscript', 'svg']);
|
|
177
|
+
const BLOCK = new Set([
|
|
178
|
+
'p',
|
|
179
|
+
'div',
|
|
180
|
+
'section',
|
|
181
|
+
'article',
|
|
182
|
+
'header',
|
|
183
|
+
'footer',
|
|
184
|
+
'main',
|
|
185
|
+
'aside',
|
|
186
|
+
'ul',
|
|
187
|
+
'ol',
|
|
188
|
+
'li',
|
|
189
|
+
'h1',
|
|
190
|
+
'h2',
|
|
191
|
+
'h3',
|
|
192
|
+
'h4',
|
|
193
|
+
'h5',
|
|
194
|
+
'h6',
|
|
195
|
+
'blockquote',
|
|
196
|
+
'pre',
|
|
197
|
+
'table',
|
|
198
|
+
'thead',
|
|
199
|
+
'tbody',
|
|
200
|
+
'tr',
|
|
201
|
+
'td',
|
|
202
|
+
'th',
|
|
203
|
+
'hr',
|
|
204
|
+
'figure',
|
|
205
|
+
'figcaption',
|
|
206
|
+
]);
|
|
207
|
+
const ENTITIES = {
|
|
208
|
+
amp: '&',
|
|
209
|
+
lt: '<',
|
|
210
|
+
gt: '>',
|
|
211
|
+
quot: '"',
|
|
212
|
+
apos: "'",
|
|
213
|
+
nbsp: ' ',
|
|
214
|
+
ndash: '–',
|
|
215
|
+
mdash: '—',
|
|
216
|
+
hellip: '…',
|
|
217
|
+
lsquo: '‘',
|
|
218
|
+
rsquo: '’',
|
|
219
|
+
ldquo: '“',
|
|
220
|
+
rdquo: '”',
|
|
221
|
+
bull: '•',
|
|
222
|
+
middot: '·',
|
|
223
|
+
copy: '©',
|
|
224
|
+
reg: '®',
|
|
225
|
+
trade: '™',
|
|
226
|
+
};
|
|
227
|
+
function decodeEntities(text) {
|
|
228
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity) => {
|
|
229
|
+
if (entity[0] === '#') {
|
|
230
|
+
const code = entity[1]?.toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
|
|
231
|
+
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
|
232
|
+
}
|
|
233
|
+
return ENTITIES[entity.toLowerCase()] ?? match;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
function parseAttributes(source) {
|
|
237
|
+
const attrs = {};
|
|
238
|
+
const pattern = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
239
|
+
for (let match = pattern.exec(source); match; match = pattern.exec(source)) {
|
|
240
|
+
attrs[match[1].toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? match[4] ?? '');
|
|
241
|
+
}
|
|
242
|
+
return attrs;
|
|
243
|
+
}
|
|
244
|
+
function parseTree(html) {
|
|
245
|
+
const root = { tag: '#root', attrs: {}, children: [] };
|
|
246
|
+
const stack = [root];
|
|
247
|
+
const pattern = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][a-zA-Z0-9]*)([^>]*)>|([^<]+|<)/g;
|
|
248
|
+
let skipping = null;
|
|
249
|
+
for (let match = pattern.exec(html); match; match = pattern.exec(html)) {
|
|
250
|
+
const [whole, closing, rawTag, rawAttrs, text] = match;
|
|
251
|
+
const current = stack[stack.length - 1];
|
|
252
|
+
if (whole.startsWith('<!--')) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (text !== undefined) {
|
|
256
|
+
if (!skipping) {
|
|
257
|
+
current.children.push(decodeEntities(text));
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const tag = rawTag.toLowerCase();
|
|
262
|
+
if (skipping) {
|
|
263
|
+
if (closing && tag === skipping) {
|
|
264
|
+
skipping = null;
|
|
265
|
+
}
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (closing) {
|
|
269
|
+
const at = stack.map((element) => element.tag).lastIndexOf(tag);
|
|
270
|
+
if (at > 0) {
|
|
271
|
+
stack.length = at;
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (SKIP.has(tag)) {
|
|
276
|
+
if (!rawAttrs.trim().endsWith('/')) {
|
|
277
|
+
skipping = tag;
|
|
278
|
+
}
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const element = { tag, attrs: parseAttributes(rawAttrs ?? ''), children: [] };
|
|
282
|
+
current.children.push(element);
|
|
283
|
+
if (!VOID.has(tag) && !rawAttrs.trim().endsWith('/')) {
|
|
284
|
+
stack.push(element);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return root.children;
|
|
288
|
+
}
|
|
289
|
+
function textContent(nodes) {
|
|
290
|
+
return nodes.map((node) => (typeof node === 'string' ? node : node.tag === 'br' ? '\n' : textContent(node.children))).join('');
|
|
291
|
+
}
|
|
292
|
+
function htmlInlines(nodes, citations) {
|
|
293
|
+
const inlines = [];
|
|
294
|
+
for (const node of nodes) {
|
|
295
|
+
if (typeof node === 'string') {
|
|
296
|
+
const text = node.replace(/[ \t\r\n\f]+/g, ' ');
|
|
297
|
+
inlines.push({ kind: 'text', text: citations ? text.replace(CITATIONS, '') : text });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
switch (node.tag) {
|
|
301
|
+
case 'br':
|
|
302
|
+
inlines.push({ kind: 'break' });
|
|
303
|
+
break;
|
|
304
|
+
case 'img':
|
|
305
|
+
if (node.attrs.alt) {
|
|
306
|
+
inlines.push({ kind: 'text', text: node.attrs.alt });
|
|
307
|
+
}
|
|
308
|
+
break;
|
|
309
|
+
case 'strong':
|
|
310
|
+
case 'b':
|
|
311
|
+
inlines.push({ kind: 'bold', children: htmlInlines(node.children, citations) });
|
|
312
|
+
break;
|
|
313
|
+
case 'em':
|
|
314
|
+
case 'i':
|
|
315
|
+
inlines.push({ kind: 'italic', children: htmlInlines(node.children, citations) });
|
|
316
|
+
break;
|
|
317
|
+
case 'code':
|
|
318
|
+
case 'kbd':
|
|
319
|
+
case 'samp':
|
|
320
|
+
inlines.push({ kind: 'code', text: textContent(node.children) });
|
|
321
|
+
break;
|
|
322
|
+
case 'a': {
|
|
323
|
+
const href = node.attrs.href ?? '';
|
|
324
|
+
const children = htmlInlines(node.children, citations);
|
|
325
|
+
if (isSafeHref(href)) {
|
|
326
|
+
inlines.push({ kind: 'link', href: href.trim(), children });
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
inlines.push(...children);
|
|
330
|
+
}
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
default:
|
|
334
|
+
if (BLOCK.has(node.tag) && inlines.length > 0) {
|
|
335
|
+
inlines.push({ kind: 'break' });
|
|
336
|
+
}
|
|
337
|
+
inlines.push(...htmlInlines(node.children, citations));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return inlines;
|
|
341
|
+
}
|
|
342
|
+
/** Trims whitespace and breaks at both ends of a run of inlines; returns [] when nothing is left. */
|
|
343
|
+
function tidy(inlines) {
|
|
344
|
+
const result = [...inlines];
|
|
345
|
+
const blank = (inline) => inline?.kind === 'break' || (inline?.kind === 'text' && !inline.text.trim());
|
|
346
|
+
while (blank(result[0])) {
|
|
347
|
+
result.shift();
|
|
348
|
+
}
|
|
349
|
+
while (blank(result[result.length - 1])) {
|
|
350
|
+
result.pop();
|
|
351
|
+
}
|
|
352
|
+
const first = result[0];
|
|
353
|
+
if (first?.kind === 'text') {
|
|
354
|
+
result[0] = { kind: 'text', text: first.text.replace(/^\s+/, '') };
|
|
355
|
+
}
|
|
356
|
+
const last = result[result.length - 1];
|
|
357
|
+
if (last?.kind === 'text') {
|
|
358
|
+
result[result.length - 1] = { kind: 'text', text: last.text.replace(/\s+$/, '') };
|
|
359
|
+
}
|
|
360
|
+
return textOf(result).trim() ? result : [];
|
|
361
|
+
}
|
|
362
|
+
function htmlBlocks(nodes, citations) {
|
|
363
|
+
const blocks = [];
|
|
364
|
+
let pending = [];
|
|
365
|
+
const flush = () => {
|
|
366
|
+
const inlines = tidy(htmlInlines(pending, citations));
|
|
367
|
+
if (inlines.length > 0) {
|
|
368
|
+
blocks.push({ kind: 'paragraph', inlines });
|
|
369
|
+
}
|
|
370
|
+
pending = [];
|
|
371
|
+
};
|
|
372
|
+
for (const node of nodes) {
|
|
373
|
+
if (typeof node === 'string' || !BLOCK.has(node.tag)) {
|
|
374
|
+
pending.push(node);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
flush();
|
|
378
|
+
switch (node.tag) {
|
|
379
|
+
case 'ul':
|
|
380
|
+
case 'ol': {
|
|
381
|
+
const items = node.children
|
|
382
|
+
.filter((child) => typeof child !== 'string' && child.tag === 'li')
|
|
383
|
+
.map((item) => tidy(htmlInlines(item.children, citations)))
|
|
384
|
+
.filter((item) => item.length > 0);
|
|
385
|
+
if (items.length > 0) {
|
|
386
|
+
blocks.push({ kind: 'list', ordered: node.tag === 'ol', items });
|
|
387
|
+
}
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
case 'pre':
|
|
391
|
+
blocks.push({ kind: 'code', text: textContent(node.children).replace(/^\n+|\s+$/g, '') });
|
|
392
|
+
break;
|
|
393
|
+
case 'blockquote': {
|
|
394
|
+
const inlines = tidy(htmlInlines(node.children, citations));
|
|
395
|
+
if (inlines.length > 0) {
|
|
396
|
+
blocks.push({ kind: 'quote', inlines });
|
|
397
|
+
}
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
case 'h1':
|
|
401
|
+
case 'h2':
|
|
402
|
+
case 'h3':
|
|
403
|
+
case 'h4':
|
|
404
|
+
case 'h5':
|
|
405
|
+
case 'h6': {
|
|
406
|
+
const inlines = tidy(htmlInlines(node.children, citations));
|
|
407
|
+
if (inlines.length > 0) {
|
|
408
|
+
blocks.push({ kind: 'heading', inlines });
|
|
409
|
+
}
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
case 'hr':
|
|
413
|
+
break;
|
|
414
|
+
default:
|
|
415
|
+
blocks.push(...htmlBlocks(node.children, citations));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
flush();
|
|
419
|
+
return blocks;
|
|
420
|
+
}
|
|
421
|
+
/** Parses stored message HTML. `citations` drops AI `[1]` markers, as for markdown. */
|
|
422
|
+
function parseHtml(html, citations = false) {
|
|
423
|
+
return htmlBlocks(parseTree(html), citations);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Splits off an AI answer's sources: a trailing list in which every item is just one web link.
|
|
427
|
+
* Like the web widget, it leaves the answer alone when the list is all there is.
|
|
428
|
+
*/
|
|
429
|
+
function extractSources(blocks) {
|
|
430
|
+
const last = blocks[blocks.length - 1];
|
|
431
|
+
if (!last || last.kind !== 'list' || blocks.length < 2) {
|
|
432
|
+
return { blocks, sources: [] };
|
|
433
|
+
}
|
|
434
|
+
const sources = [];
|
|
435
|
+
for (const item of last.items) {
|
|
436
|
+
const content = item.filter((inline) => !(inline.kind === 'text' && !inline.text.trim()));
|
|
437
|
+
const link = content[0];
|
|
438
|
+
if (content.length !== 1 || link?.kind !== 'link' || !/^https?:/i.test(link.href)) {
|
|
439
|
+
return { blocks, sources: [] };
|
|
440
|
+
}
|
|
441
|
+
sources.push({ href: link.href, title: textOf(link.children).trim() || link.href });
|
|
442
|
+
}
|
|
443
|
+
return { blocks: blocks.slice(0, -1), sources };
|
|
444
|
+
}
|
package/lib/ui/strings.d.ts
CHANGED
package/lib/ui/strings.js
CHANGED
|
@@ -24,6 +24,8 @@ exports.defaultStrings = {
|
|
|
24
24
|
sending: 'Sending…',
|
|
25
25
|
uploading: 'Uploading…',
|
|
26
26
|
removeFile: 'Remove {name}',
|
|
27
|
+
viewImage: 'View {name}',
|
|
28
|
+
closeImage: 'Close image',
|
|
27
29
|
fileTypeRefused: "{name} can't be sent here. Try another file type.",
|
|
28
30
|
fileTooLarge: '{name} is larger than 25 MB.',
|
|
29
31
|
uploadFailed: "Couldn't upload {name}. Try again.",
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opendesk-react-native",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "React Native SDK for openDesk live chat: headless client, realtime, hooks and a drop-in chat screen.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
7
7
|
"files": [
|
|
8
8
|
"lib",
|
|
9
9
|
"src",
|
|
10
|
+
"assets",
|
|
10
11
|
"!src/**/__tests__"
|
|
11
12
|
],
|
|
12
13
|
"scripts": {
|