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