opendesk-react-native 0.1.6 → 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.
@@ -8,6 +8,8 @@ const react_2 = require("../react");
8
8
  const BotFace_1 = require("./BotFace");
9
9
  const icons_1 = require("./icons");
10
10
  const keyboard_1 = require("./keyboard");
11
+ const rich_1 = require("./rich");
12
+ const RichText_1 = require("./RichText");
11
13
  const strings_1 = require("./strings");
12
14
  const theme_1 = require("./theme");
13
15
  const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
@@ -204,6 +206,7 @@ function Bubble({ ui, message, reply, first, last, gap, showHandoff, onDecide, o
204
206
  const avatar = message ? (message.author.user?.avatar_url ?? bot?.avatar_url) : bot?.avatar_url;
205
207
  const botLook = bot?.look ?? fallbackBot?.look;
206
208
  const text = message ? (message.body_text ?? '') : (reply?.text ?? '');
209
+ const body = (0, react_1.useMemo)(() => bodyOf(message, reply, mine, ai), [message, reply, mine, ai]);
207
210
  const local = message?.id.startsWith('local-') === true;
208
211
  const action = message?.ai_action;
209
212
  const tools = reply?.tools ?? [];
@@ -214,12 +217,29 @@ function Bubble({ ui, message, reply, first, last, gap, showHandoff, onDecide, o
214
217
  const files = (message?.attachments ?? []).filter((a) => !a.content_type.startsWith('image/'));
215
218
  return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.row, mine ? styles.rowMe : styles.rowThem, gap && styles.rowGap], children: [!mine && first && author ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.who, children: [ai ? ((0, jsx_runtime_1.jsx)(BotFace_1.BotFace, { size: 18, look: botLook, avatarUrl: avatar })) : avatar ? ((0, jsx_runtime_1.jsx)(Avatar, { ui: ui, size: 18, name: author, url: avatar, tint: theme.bg2 })) : null, (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.whoName, children: author }), ai ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.aiPill, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.aiPillText, children: strings.ai }) })) : null] })) : null, tools.length > 0 ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.tools, children: tools.map((tool, index) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.tool, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.toolIc, tool.status === 'error' && { backgroundColor: theme.bg2 }], children: (0, jsx_runtime_1.jsx)(icons_1.SparkIcon, { color: tool.status === 'error' ? theme.fg3 : theme.ai, size: 8 }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.toolText, tool.status === 'running' && { color: theme.fg2 }], children: (0, strings_1.format)(tool.status === 'running' ? strings.checking : tool.status === 'error' ? strings.toolFailed : strings.checked, {
216
219
  tool: tool.name.replace(/_/g, ' '),
217
- }) })] }, `${tool.name}-${index}`))) })) : null, text || reply ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
220
+ }) })] }, `${tool.name}-${index}`))) })) : null, text || body.blocks.length > 0 || reply ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
218
221
  styles.msg,
219
222
  mine ? styles.msgMe : ai ? styles.msgAi : styles.msgThem,
220
223
  corners,
221
224
  local && styles.sendingMsg,
222
- ], children: text ? (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.msgText, mine && { color: theme.accentFg }], children: text }) : (0, jsx_runtime_1.jsx)(Dots, { color: theme.ai }) })) : null, images.map((attachment) => ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "imagebutton", accessibilityLabel: (0, strings_1.format)(strings.viewImage, { name: attachment.filename }), onPress: () => onOpenImage(attachment), style: ({ pressed }) => [styles.fileGap, pressed && styles.pressed], children: (0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: attachment.url }, style: styles.thumb, resizeMode: "cover" }) }, attachment.id))), files.map((attachment) => ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { onPress: () => react_native_1.Linking.openURL(attachment.url), style: [styles.chip, styles.fileGap], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.chipText, numberOfLines: 1, children: attachment.filename }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.chipSize, children: fileSize(attachment.size_bytes) })] }, attachment.id))), message && action ? (0, jsx_runtime_1.jsx)(ActionCard, { ui: ui, message: message, onDecide: onDecide }) : null, message && showHandoff ? ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { accessibilityRole: "button", onPress: () => onRequestHuman(message), style: ({ pressed }) => [styles.handoff, pressed && { backgroundColor: theme.bg2 }], children: [(0, jsx_runtime_1.jsx)(icons_1.PersonIcon, { color: theme.fg, size: 14 }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.handoffText, children: strings.talkToPerson })] })) : null, last && message ? (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.meta, children: local ? strings.sending : clockTime(message.created_at) }) : null] }));
225
+ ], children: mine ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.msgText, { color: theme.accentFg }], children: text })) : body.blocks.length > 0 ? ((0, jsx_runtime_1.jsx)(RichText_1.RichText, { blocks: body.blocks, textStyle: styles.msgText, codeBackground: ai ? theme.bg2 : theme.line, quoteColor: theme.line2, mono: theme.mono })) : ((0, jsx_runtime_1.jsx)(Dots, { color: theme.ai })) })) : null, body.sources.length > 0 ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.sources, children: body.sources.map((source) => ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { accessibilityRole: "link", accessibilityHint: (0, rich_1.hostOf)(source.href), onPress: () => {
226
+ react_native_1.Linking.openURL(source.href).catch(() => undefined);
227
+ }, style: ({ pressed }) => [styles.src, pressed && { borderColor: theme.aiLine, backgroundColor: theme.aiSoft }], children: [(0, jsx_runtime_1.jsx)(icons_1.ArrowIcon, { color: theme.ai, size: 11, angle: -45 }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.srcText, numberOfLines: 1, children: source.title })] }, source.href))) })) : null, images.map((attachment) => ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "imagebutton", accessibilityLabel: (0, strings_1.format)(strings.viewImage, { name: attachment.filename }), onPress: () => onOpenImage(attachment), style: ({ pressed }) => [styles.fileGap, pressed && styles.pressed], children: (0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: attachment.url }, style: styles.thumb, resizeMode: "cover" }) }, attachment.id))), files.map((attachment) => ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { onPress: () => react_native_1.Linking.openURL(attachment.url), style: [styles.chip, styles.fileGap], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.chipText, numberOfLines: 1, children: attachment.filename }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.chipSize, children: fileSize(attachment.size_bytes) })] }, attachment.id))), message && action ? (0, jsx_runtime_1.jsx)(ActionCard, { ui: ui, message: message, onDecide: onDecide }) : null, message && showHandoff ? ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { accessibilityRole: "button", onPress: () => onRequestHuman(message), style: ({ pressed }) => [styles.handoff, pressed && { backgroundColor: theme.bg2 }], children: [(0, jsx_runtime_1.jsx)(icons_1.PersonIcon, { color: theme.fg, size: 14 }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.handoffText, children: strings.talkToPerson })] })) : null, last && message ? (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.meta, children: local ? strings.sending : clockTime(message.created_at) }) : null] }));
228
+ }
229
+ /**
230
+ * What a bubble shows: the visitor's own text as typed; stored replies from their HTML (links
231
+ * included), falling back to markdown in the text; AI answers still streaming as markdown. An AI
232
+ * answer's trailing list of links becomes its sources.
233
+ */
234
+ function bodyOf(message, reply, mine, ai) {
235
+ if (mine) {
236
+ return { blocks: [], sources: [] };
237
+ }
238
+ if (reply && !message) {
239
+ return { blocks: (0, rich_1.parseMarkdown)(reply.text, true), sources: [] };
240
+ }
241
+ const blocks = message?.body_html?.trim() ? (0, rich_1.parseHtml)(message.body_html, ai) : (0, rich_1.parseMarkdown)(message?.body_text ?? '');
242
+ return ai ? (0, rich_1.extractSources)(blocks) : { blocks, sources: [] };
223
243
  }
224
244
  function ActionCard({ ui, message, onDecide, }) {
225
245
  const { theme, strings, styles } = ui;
@@ -504,6 +524,21 @@ function createStyles(t) {
504
524
  msgMe: { backgroundColor: t.accent },
505
525
  msgThem: { backgroundColor: t.bg2 },
506
526
  msgAi: { backgroundColor: t.bg3, borderWidth: 1, borderColor: t.line },
527
+ sources: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 6 },
528
+ src: {
529
+ flexDirection: 'row',
530
+ alignItems: 'center',
531
+ gap: 6,
532
+ maxWidth: 240,
533
+ height: 26,
534
+ paddingLeft: 8,
535
+ paddingRight: 10,
536
+ borderRadius: r.ctl,
537
+ borderWidth: 1,
538
+ borderColor: t.line,
539
+ backgroundColor: t.bg,
540
+ },
541
+ srcText: { flexShrink: 1, fontSize: 12, color: t.fg2 },
507
542
  msgText: { fontSize: 13.5, lineHeight: 19.5, color: t.fg },
508
543
  sendingMsg: { opacity: 0.7 },
509
544
  meta: { fontFamily: t.mono, fontSize: 10, color: t.fg4, marginTop: 3, marginHorizontal: 4 },
@@ -0,0 +1,15 @@
1
+ import { type TextStyle } from 'react-native';
2
+ import type { Block } from './rich';
3
+ interface RichTextProps {
4
+ blocks: Block[];
5
+ /** The bubble's text style; colour and size carry into every block. */
6
+ textStyle: TextStyle;
7
+ /** Fill behind inline code and code blocks. */
8
+ codeBackground: string;
9
+ /** Rule beside quotes. */
10
+ quoteColor: string;
11
+ mono: string;
12
+ }
13
+ /** Renders parsed message blocks like the widget's `.msg`: paragraphs, lists, quotes, code and underlined links. */
14
+ export declare function RichText({ blocks, textStyle, codeBackground, quoteColor, mono }: RichTextProps): import("react").JSX.Element;
15
+ export {};
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RichText = RichText;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const react_1 = require("react");
6
+ const react_native_1 = require("react-native");
7
+ /** Renders parsed message blocks like the widget's `.msg`: paragraphs, lists, quotes, code and underlined links. */
8
+ function RichText({ blocks, textStyle, codeBackground, quoteColor, mono }) {
9
+ const color = textStyle.color;
10
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.blocks, children: blocks.map((block, index) => {
11
+ switch (block.kind) {
12
+ case 'paragraph':
13
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: textStyle, children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: block.inlines, codeStyle: { fontFamily: mono, backgroundColor: codeBackground } }) }, index));
14
+ case 'heading':
15
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.bold], children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: block.inlines, codeStyle: { fontFamily: mono, backgroundColor: codeBackground } }) }, index));
16
+ case 'quote':
17
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.quote, { borderLeftColor: quoteColor }], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.quoteText], children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: block.inlines, codeStyle: { fontFamily: mono, backgroundColor: codeBackground } }) }) }, index));
18
+ case 'code':
19
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.pre, { backgroundColor: codeBackground }], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.preText, { fontFamily: mono }], children: block.text }) }, index));
20
+ case 'list':
21
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.list, children: block.items.map((item, itemIndex) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.item, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.marker, { color }], children: block.ordered ? `${itemIndex + 1}.` : '•' }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.itemText], children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: item, codeStyle: { fontFamily: mono, backgroundColor: codeBackground } }) })] }, itemIndex))) }, index));
22
+ }
23
+ }) }));
24
+ }
25
+ function Inlines({ inlines, codeStyle }) {
26
+ return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: inlines.map((inline, index) => {
27
+ switch (inline.kind) {
28
+ case 'text':
29
+ return (0, jsx_runtime_1.jsx)(react_1.Fragment, { children: inline.text }, index);
30
+ case 'break':
31
+ return (0, jsx_runtime_1.jsx)(react_1.Fragment, { children: '\n' }, index);
32
+ case 'bold':
33
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.bold, children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: inline.children, codeStyle: codeStyle }) }, index));
34
+ case 'italic':
35
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.italic, children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: inline.children, codeStyle: codeStyle }) }, index));
36
+ case 'code':
37
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.code, codeStyle], children: inline.text }, index));
38
+ case 'link':
39
+ return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { accessibilityRole: "link", onPress: () => {
40
+ react_native_1.Linking.openURL(inline.href).catch(() => undefined);
41
+ }, style: styles.link, children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: inline.children, codeStyle: codeStyle }) }, index));
42
+ }
43
+ }) }));
44
+ }
45
+ const styles = react_native_1.StyleSheet.create({
46
+ blocks: { gap: 6 },
47
+ bold: { fontWeight: '600' },
48
+ italic: { fontStyle: 'italic' },
49
+ link: { textDecorationLine: 'underline', fontWeight: '500' },
50
+ code: { fontSize: 12 },
51
+ pre: { borderRadius: 6, paddingHorizontal: 8, paddingVertical: 6 },
52
+ preText: { fontSize: 12, lineHeight: 17 },
53
+ quote: { borderLeftWidth: 2, paddingLeft: 8 },
54
+ quoteText: { opacity: 0.8 },
55
+ list: { gap: 2 },
56
+ item: { flexDirection: 'row' },
57
+ marker: { minWidth: 16, paddingRight: 4 },
58
+ itemText: { flex: 1 },
59
+ });
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opendesk-react-native",
3
- "version": "0.1.6",
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",