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.
@@ -24,6 +24,8 @@ import type { Attachment, Conversation, Message, UploadFile, WidgetConfig } from
24
24
  import { BotFace } from './BotFace';
25
25
  import { ArrowIcon, ChevronIcon, CloseIcon, PaperclipIcon, PersonIcon, SparkIcon } from './icons';
26
26
  import { useKeyboardInset } from './keyboard';
27
+ import { extractSources, hostOf, parseHtml, parseMarkdown, type Block, type Source } from './rich';
28
+ import { RichText } from './RichText';
27
29
  import { defaultStrings, format, type ChatStrings } from './strings';
28
30
  import { makeTheme, type ChatTheme } from './theme';
29
31
 
@@ -680,6 +682,7 @@ function Bubble({
680
682
  const avatar = message ? (message.author.user?.avatar_url ?? bot?.avatar_url) : bot?.avatar_url;
681
683
  const botLook = bot?.look ?? fallbackBot?.look;
682
684
  const text = message ? (message.body_text ?? '') : (reply?.text ?? '');
685
+ const body = useMemo(() => bodyOf(message, reply, mine, ai), [message, reply, mine, ai]);
683
686
  const local = message?.id.startsWith('local-') === true;
684
687
  const action = message?.ai_action;
685
688
  const tools = reply?.tools ?? [];
@@ -724,7 +727,7 @@ function Bubble({
724
727
  </View>
725
728
  ) : null}
726
729
 
727
- {text || reply ? (
730
+ {text || body.blocks.length > 0 || reply ? (
728
731
  <View
729
732
  style={[
730
733
  styles.msg,
@@ -733,7 +736,40 @@ function Bubble({
733
736
  local && styles.sendingMsg,
734
737
  ]}
735
738
  >
736
- {text ? <Text style={[styles.msgText, mine && { color: theme.accentFg }]}>{text}</Text> : <Dots color={theme.ai} />}
739
+ {mine ? (
740
+ <Text style={[styles.msgText, { color: theme.accentFg }]}>{text}</Text>
741
+ ) : body.blocks.length > 0 ? (
742
+ <RichText
743
+ blocks={body.blocks}
744
+ textStyle={styles.msgText}
745
+ codeBackground={ai ? theme.bg2 : theme.line}
746
+ quoteColor={theme.line2}
747
+ mono={theme.mono}
748
+ />
749
+ ) : (
750
+ <Dots color={theme.ai} />
751
+ )}
752
+ </View>
753
+ ) : null}
754
+
755
+ {body.sources.length > 0 ? (
756
+ <View style={styles.sources}>
757
+ {body.sources.map((source) => (
758
+ <Pressable
759
+ key={source.href}
760
+ accessibilityRole="link"
761
+ accessibilityHint={hostOf(source.href)}
762
+ onPress={() => {
763
+ Linking.openURL(source.href).catch(() => undefined);
764
+ }}
765
+ style={({ pressed }) => [styles.src, pressed && { borderColor: theme.aiLine, backgroundColor: theme.aiSoft }]}
766
+ >
767
+ <ArrowIcon color={theme.ai} size={11} angle={-45} />
768
+ <Text style={styles.srcText} numberOfLines={1}>
769
+ {source.title}
770
+ </Text>
771
+ </Pressable>
772
+ ))}
737
773
  </View>
738
774
  ) : null}
739
775
 
@@ -775,6 +811,23 @@ function Bubble({
775
811
  );
776
812
  }
777
813
 
814
+ /**
815
+ * What a bubble shows: the visitor's own text as typed; stored replies from their HTML (links
816
+ * included), falling back to markdown in the text; AI answers still streaming as markdown. An AI
817
+ * answer's trailing list of links becomes its sources.
818
+ */
819
+ function bodyOf(message: Message | undefined, reply: AIReply | undefined, mine: boolean, ai: boolean): { blocks: Block[]; sources: Source[] } {
820
+ if (mine) {
821
+ return { blocks: [], sources: [] };
822
+ }
823
+ if (reply && !message) {
824
+ return { blocks: parseMarkdown(reply.text, true), sources: [] };
825
+ }
826
+ const blocks = message?.body_html?.trim() ? parseHtml(message.body_html, ai) : parseMarkdown(message?.body_text ?? '');
827
+
828
+ return ai ? extractSources(blocks) : { blocks, sources: [] };
829
+ }
830
+
778
831
  function ActionCard({
779
832
  ui,
780
833
  message,
@@ -1245,6 +1298,21 @@ function createStyles(t: ChatTheme) {
1245
1298
  msgMe: { backgroundColor: t.accent },
1246
1299
  msgThem: { backgroundColor: t.bg2 },
1247
1300
  msgAi: { backgroundColor: t.bg3, borderWidth: 1, borderColor: t.line },
1301
+ sources: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 6 },
1302
+ src: {
1303
+ flexDirection: 'row',
1304
+ alignItems: 'center',
1305
+ gap: 6,
1306
+ maxWidth: 240,
1307
+ height: 26,
1308
+ paddingLeft: 8,
1309
+ paddingRight: 10,
1310
+ borderRadius: r.ctl,
1311
+ borderWidth: 1,
1312
+ borderColor: t.line,
1313
+ backgroundColor: t.bg,
1314
+ },
1315
+ srcText: { flexShrink: 1, fontSize: 12, color: t.fg2 },
1248
1316
  msgText: { fontSize: 13.5, lineHeight: 19.5, color: t.fg },
1249
1317
  sendingMsg: { opacity: 0.7 },
1250
1318
  meta: { fontFamily: t.mono, fontSize: 10, color: t.fg4, marginTop: 3, marginHorizontal: 4 },
@@ -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
+ });