@giovchat/react-native 0.0.0-stage → 0.3.0
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 +390 -2
- package/dist/GiovChat.d.ts +135 -0
- package/dist/GiovChat.d.ts.map +1 -0
- package/dist/GiovChat.js +294 -0
- package/dist/GiovChat.js.map +1 -0
- package/dist/attachments.d.ts +121 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +185 -0
- package/dist/attachments.js.map +1 -0
- package/dist/brand-assets.d.ts +6 -0
- package/dist/brand-assets.d.ts.map +1 -0
- package/dist/brand-assets.js +9 -0
- package/dist/brand-assets.js.map +1 -0
- package/dist/brandingStyle.d.ts +38 -0
- package/dist/brandingStyle.d.ts.map +1 -0
- package/dist/brandingStyle.js +71 -0
- package/dist/brandingStyle.js.map +1 -0
- package/dist/client.d.ts +226 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +891 -0
- package/dist/client.js.map +1 -0
- package/dist/color.d.ts +25 -0
- package/dist/color.d.ts.map +1 -0
- package/dist/color.js +78 -0
- package/dist/color.js.map +1 -0
- package/dist/components/Attachments.d.ts +13 -0
- package/dist/components/Attachments.d.ts.map +1 -0
- package/dist/components/Attachments.js +42 -0
- package/dist/components/Attachments.js.map +1 -0
- package/dist/components/Branding.d.ts +29 -0
- package/dist/components/Branding.d.ts.map +1 -0
- package/dist/components/Branding.js +71 -0
- package/dist/components/Branding.js.map +1 -0
- package/dist/components/ChatMarkdown.d.ts +15 -0
- package/dist/components/ChatMarkdown.d.ts.map +1 -0
- package/dist/components/ChatMarkdown.js +72 -0
- package/dist/components/ChatMarkdown.js.map +1 -0
- package/dist/components/GiovBotMark.d.ts +6 -0
- package/dist/components/GiovBotMark.d.ts.map +1 -0
- package/dist/components/GiovBotMark.js +11 -0
- package/dist/components/GiovBotMark.js.map +1 -0
- package/dist/components/GreetingBubble.d.ts +7 -0
- package/dist/components/GreetingBubble.d.ts.map +1 -0
- package/dist/components/GreetingBubble.js +12 -0
- package/dist/components/GreetingBubble.js.map +1 -0
- package/dist/components/Header.d.ts +18 -0
- package/dist/components/Header.d.ts.map +1 -0
- package/dist/components/Header.js +67 -0
- package/dist/components/Header.js.map +1 -0
- package/dist/components/MessageBubble.d.ts +23 -0
- package/dist/components/MessageBubble.d.ts.map +1 -0
- package/dist/components/MessageBubble.js +51 -0
- package/dist/components/MessageBubble.js.map +1 -0
- package/dist/components/PreChatForm.d.ts +11 -0
- package/dist/components/PreChatForm.d.ts.map +1 -0
- package/dist/components/PreChatForm.js +72 -0
- package/dist/components/PreChatForm.js.map +1 -0
- package/dist/components/RatingCard.d.ts +23 -0
- package/dist/components/RatingCard.d.ts.map +1 -0
- package/dist/components/RatingCard.js +107 -0
- package/dist/components/RatingCard.js.map +1 -0
- package/dist/components/TypingIndicator.d.ts +9 -0
- package/dist/components/TypingIndicator.d.ts.map +1 -0
- package/dist/components/TypingIndicator.js +18 -0
- package/dist/components/TypingIndicator.js.map +1 -0
- package/dist/components/WidgetStyles.d.ts +3 -0
- package/dist/components/WidgetStyles.d.ts.map +1 -0
- package/dist/components/WidgetStyles.js +9 -0
- package/dist/components/WidgetStyles.js.map +1 -0
- package/dist/components/bubbleStyles.d.ts +47 -0
- package/dist/components/bubbleStyles.d.ts.map +1 -0
- package/dist/components/bubbleStyles.js +19 -0
- package/dist/components/bubbleStyles.js.map +1 -0
- package/dist/components/index.d.ts +11 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +14 -0
- package/dist/components/index.js.map +1 -0
- package/dist/errors.d.ts +20 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/i18n.d.ts +96 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +392 -0
- package/dist/i18n.js.map +1 -0
- package/dist/identity.d.ts +41 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +41 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +56 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +219 -0
- package/dist/markdown.js.map +1 -0
- package/dist/outbox.d.ts +68 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +168 -0
- package/dist/outbox.js.map +1 -0
- package/dist/protocol.d.ts +9 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +39 -0
- package/dist/protocol.js.map +1 -0
- package/dist/rating.d.ts +41 -0
- package/dist/rating.d.ts.map +1 -0
- package/dist/rating.js +165 -0
- package/dist/rating.js.map +1 -0
- package/dist/ratingStore.d.ts +36 -0
- package/dist/ratingStore.d.ts.map +1 -0
- package/dist/ratingStore.js +55 -0
- package/dist/ratingStore.js.map +1 -0
- package/dist/styleOverrides.d.ts +45 -0
- package/dist/styleOverrides.d.ts.map +1 -0
- package/dist/styleOverrides.js +63 -0
- package/dist/styleOverrides.js.map +1 -0
- package/dist/types.d.ts +156 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/unread.d.ts +9 -0
- package/dist/unread.d.ts.map +1 -0
- package/dist/unread.js +49 -0
- package/dist/unread.js.map +1 -0
- package/dist/useChatSession.d.ts +95 -0
- package/dist/useChatSession.d.ts.map +1 -0
- package/dist/useChatSession.js +965 -0
- package/dist/useChatSession.js.map +1 -0
- package/package.json +60 -4
- package/src/GiovChat.tsx +640 -0
- package/src/attachments.ts +218 -0
- package/src/brand-assets.ts +5 -0
- package/src/brandingStyle.ts +87 -0
- package/src/client.ts +848 -0
- package/src/color.ts +74 -0
- package/src/components/Attachments.tsx +75 -0
- package/src/components/Branding.tsx +94 -0
- package/src/components/ChatMarkdown.tsx +111 -0
- package/src/components/GiovBotMark.tsx +19 -0
- package/src/components/GreetingBubble.tsx +20 -0
- package/src/components/Header.tsx +131 -0
- package/src/components/MessageBubble.tsx +137 -0
- package/src/components/PreChatForm.tsx +183 -0
- package/src/components/RatingCard.tsx +219 -0
- package/src/components/TypingIndicator.tsx +34 -0
- package/src/components/WidgetStyles.tsx +5 -0
- package/src/components/bubbleStyles.ts +16 -0
- package/src/components/index.ts +14 -0
- package/src/errors.ts +33 -0
- package/src/i18n.ts +474 -0
- package/src/identity.ts +54 -0
- package/src/index.ts +85 -0
- package/src/markdown.ts +227 -0
- package/src/outbox.ts +175 -0
- package/src/protocol.ts +36 -0
- package/src/rating.ts +169 -0
- package/src/ratingStore.ts +65 -0
- package/src/styleOverrides.ts +98 -0
- package/src/types.ts +170 -0
- package/src/unread.ts +39 -0
- package/src/useChatSession.ts +841 -0
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Markdown for the SDK: the grammar of the web widget's formatMessage (apps/widget-web/src/lib/
|
|
3
|
+
* formatMessage.ts, shared by the widget, Copilot and the web inbox), parsed into a tree that
|
|
4
|
+
* components/ChatMarkdown.tsx renders with native Text. The same parser as the Giov agent app.
|
|
5
|
+
* Paragraphs and line breaks, **bold**, *italic* or _italic_, ~~strike~~, <u>underline</u>, `code`,
|
|
6
|
+
* fenced code, # headings, - / * / + and 1. lists (one nested level), > quotes, --- rules,
|
|
7
|
+
* [links](https://…) and bare links. Only http(s)/mailto links get an href. Unfinished markup (while
|
|
8
|
+
* a reply streams in) stays as text until it is closed. Pure: no React.
|
|
9
|
+
*/
|
|
10
|
+
export type InlineMarks = { bold?: true; italic?: true; strike?: true; underline?: true; code?: true };
|
|
11
|
+
export type InlineNode = { text: string; marks: InlineMarks; href?: string };
|
|
12
|
+
/** One visual line; lines in a paragraph, quote or list item are separated by line breaks. */
|
|
13
|
+
export type InlineLine = InlineNode[];
|
|
14
|
+
|
|
15
|
+
export type ListItem = { lines: InlineLine[]; children: ListBlock[] };
|
|
16
|
+
export type ListBlock = { type: "list"; ordered: boolean; start: number; items: ListItem[] };
|
|
17
|
+
export type Block =
|
|
18
|
+
| { type: "paragraph"; lines: InlineLine[] }
|
|
19
|
+
| { type: "heading"; content: InlineLine }
|
|
20
|
+
| { type: "code"; text: string }
|
|
21
|
+
| { type: "rule" }
|
|
22
|
+
| { type: "quote"; lines: InlineLine[] }
|
|
23
|
+
| ListBlock;
|
|
24
|
+
|
|
25
|
+
type MarkName = "bold" | "italic" | "strike" | "underline";
|
|
26
|
+
|
|
27
|
+
// Placeholders and mark delimiters are control characters that never survive in chat text, and
|
|
28
|
+
// none is a word character, so the italic rules see the same neighbours as in the widget's HTML.
|
|
29
|
+
const CODE = "\u0000";
|
|
30
|
+
const LINK = "\u0001";
|
|
31
|
+
const MARKS: Record<string, readonly [MarkName, 1 | -1]> = {
|
|
32
|
+
"\u0010": ["bold", 1],
|
|
33
|
+
"\u0011": ["bold", -1],
|
|
34
|
+
"\u0012": ["italic", 1],
|
|
35
|
+
"\u0013": ["italic", -1],
|
|
36
|
+
"\u0014": ["strike", 1],
|
|
37
|
+
"\u0015": ["strike", -1],
|
|
38
|
+
"\u0016": ["underline", 1],
|
|
39
|
+
"\u0017": ["underline", -1]
|
|
40
|
+
};
|
|
41
|
+
const bold = (text: string) => `\u0010${text}\u0011`;
|
|
42
|
+
const italic = (text: string) => `\u0012${text}\u0013`;
|
|
43
|
+
const strike = (text: string) => `\u0014${text}\u0015`;
|
|
44
|
+
const underline = (text: string) => `\u0016${text}\u0017`;
|
|
45
|
+
|
|
46
|
+
function sameMarks(left: InlineMarks, right: InlineMarks): boolean {
|
|
47
|
+
return left.bold === right.bold && left.italic === right.italic && left.strike === right.strike
|
|
48
|
+
&& left.underline === right.underline && left.code === right.code;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Inline markup on one line, in the widget's order: code spans and links are cut out first. */
|
|
52
|
+
export function parseInline(text: string): InlineLine {
|
|
53
|
+
const source = text.replace(/[\u0000\u0001\u0010-\u0017]/g, "");
|
|
54
|
+
const codes: string[] = [];
|
|
55
|
+
let out = source.replace(/`([^`\n]+)`/g, (_, code: string) => `${CODE}${codes.push(code) - 1}${CODE}`);
|
|
56
|
+
const links: Array<{ label: string; href: string }> = [];
|
|
57
|
+
const keep = (label: string, href: string) => `${LINK}${links.push({ label, href }) - 1}${LINK}`;
|
|
58
|
+
out = out.replace(/\[([^\]\n]+)\]\(((?:https?:\/\/|mailto:)[^\s)]+)\)/gi, (_, label: string, href: string) => keep(label, href));
|
|
59
|
+
out = out.replace(/(^|[\s(])((?:https?:\/\/)[^\s<]+[^\s<.,;:!?)'"&])/gi, (_, lead: string, href: string) => lead + keep(href, href));
|
|
60
|
+
out = out
|
|
61
|
+
.replace(/\*\*(?=\S)([^*]+?\S)\*\*/g, (_, inner: string) => bold(inner))
|
|
62
|
+
.replace(/__(?=\S)([^_]+?\S)__/g, (_, inner: string) => bold(inner))
|
|
63
|
+
.replace(/(^|[^*\w])\*(?=\S)([^*\n]+?\S)\*(?![*\w])/g, (_, lead: string, inner: string) => lead + italic(inner))
|
|
64
|
+
.replace(/(^|[^_\w])_(?=\S)([^_\n]+?\S)_(?![_\w])/g, (_, lead: string, inner: string) => lead + italic(inner))
|
|
65
|
+
.replace(/~~(?=\S)([^~]+?\S)~~/g, (_, inner: string) => strike(inner))
|
|
66
|
+
.replace(/<u>([\s\S]+?)<\/u>/gi, (_, inner: string) => underline(inner));
|
|
67
|
+
|
|
68
|
+
const nodes: InlineNode[] = [];
|
|
69
|
+
const depth: Record<MarkName, number> = { bold: 0, italic: 0, strike: 0, underline: 0 };
|
|
70
|
+
const currentMarks = (): InlineMarks => {
|
|
71
|
+
const marks: InlineMarks = {};
|
|
72
|
+
for (const name of Object.keys(depth) as MarkName[]) if (depth[name] > 0) marks[name] = true;
|
|
73
|
+
return marks;
|
|
74
|
+
};
|
|
75
|
+
const push = (node: InlineNode) => {
|
|
76
|
+
if (!node.text) return;
|
|
77
|
+
const last = nodes[nodes.length - 1];
|
|
78
|
+
if (last && !last.href && !node.href && sameMarks(last.marks, node.marks)) last.text += node.text;
|
|
79
|
+
else nodes.push(node);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
let buffer = "";
|
|
83
|
+
const flush = () => {
|
|
84
|
+
push({ text: buffer, marks: currentMarks() });
|
|
85
|
+
buffer = "";
|
|
86
|
+
};
|
|
87
|
+
for (let index = 0; index < out.length; index++) {
|
|
88
|
+
const char = out[index];
|
|
89
|
+
const mark = MARKS[char];
|
|
90
|
+
if (mark) {
|
|
91
|
+
flush();
|
|
92
|
+
depth[mark[0]] += mark[1];
|
|
93
|
+
} else if (char === CODE || char === LINK) {
|
|
94
|
+
const end = out.indexOf(char, index + 1);
|
|
95
|
+
const slot = Number(out.slice(index + 1, end));
|
|
96
|
+
flush();
|
|
97
|
+
if (char === CODE) push({ text: codes[slot], marks: { ...currentMarks(), code: true } });
|
|
98
|
+
else push({ text: links[slot].label, marks: currentMarks(), href: links[slot].href });
|
|
99
|
+
index = end;
|
|
100
|
+
} else {
|
|
101
|
+
buffer += char;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
flush();
|
|
105
|
+
return nodes;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function parseChatMarkdown(content: string): Block[] {
|
|
109
|
+
if (!content || typeof content !== "string") return [];
|
|
110
|
+
const lines = content.replace(/\r\n?/g, "\n").split("\n");
|
|
111
|
+
const blocks: Block[] = [];
|
|
112
|
+
let paragraph: string[] = [];
|
|
113
|
+
let quote: string[] = [];
|
|
114
|
+
// Open lists, outermost first; one nested level, as in the widget.
|
|
115
|
+
const lists: Array<{ list: ListBlock; indent: number }> = [];
|
|
116
|
+
|
|
117
|
+
const flushParagraph = () => {
|
|
118
|
+
if (paragraph.length) blocks.push({ type: "paragraph", lines: paragraph.map(parseInline) });
|
|
119
|
+
paragraph = [];
|
|
120
|
+
};
|
|
121
|
+
const closeLists = (toIndent = -1) => {
|
|
122
|
+
while (lists.length && lists[lists.length - 1].indent > toIndent) lists.pop();
|
|
123
|
+
};
|
|
124
|
+
const flushQuote = () => {
|
|
125
|
+
if (quote.length) blocks.push({ type: "quote", lines: quote.map(parseInline) });
|
|
126
|
+
quote = [];
|
|
127
|
+
};
|
|
128
|
+
const flushAll = () => {
|
|
129
|
+
flushParagraph();
|
|
130
|
+
closeLists();
|
|
131
|
+
flushQuote();
|
|
132
|
+
};
|
|
133
|
+
const lastItem = (list: ListBlock) => list.items[list.items.length - 1];
|
|
134
|
+
|
|
135
|
+
for (let index = 0; index < lines.length; index++) {
|
|
136
|
+
const line = lines[index];
|
|
137
|
+
// Fenced code: everything up to the closing fence (or the end, while streaming) is literal.
|
|
138
|
+
if (/^\s*```/.test(line)) {
|
|
139
|
+
flushAll();
|
|
140
|
+
const body: string[] = [];
|
|
141
|
+
while (++index < lines.length && !/^\s*```/.test(lines[index])) body.push(lines[index]);
|
|
142
|
+
blocks.push({ type: "code", text: body.join("\n") });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!line.trim()) {
|
|
146
|
+
flushParagraph();
|
|
147
|
+
flushQuote();
|
|
148
|
+
// A blank line between list items keeps the list going.
|
|
149
|
+
if (lists.length && !/^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index + 1] ?? "")) closeLists();
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
153
|
+
if (heading) {
|
|
154
|
+
flushAll();
|
|
155
|
+
blocks.push({ type: "heading", content: parseInline(heading[2]) });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
|
|
159
|
+
flushAll();
|
|
160
|
+
blocks.push({ type: "rule" });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const quoted = /^>\s?(.*)$/.exec(line);
|
|
164
|
+
if (quoted) {
|
|
165
|
+
flushParagraph();
|
|
166
|
+
closeLists();
|
|
167
|
+
quote.push(quoted[1]);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
flushQuote();
|
|
171
|
+
const item = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
|
172
|
+
if (item) {
|
|
173
|
+
flushParagraph();
|
|
174
|
+
const indent = item[1].replace(/\t/g, " ").length;
|
|
175
|
+
const ordered = /\d/.test(item[2]);
|
|
176
|
+
const entry: ListItem = { lines: [parseInline(item[3])], children: [] };
|
|
177
|
+
closeLists(indent);
|
|
178
|
+
const top = lists[lists.length - 1];
|
|
179
|
+
if (top && top.indent === indent && top.list.ordered === ordered) {
|
|
180
|
+
top.list.items.push(entry);
|
|
181
|
+
} else {
|
|
182
|
+
if (top && top.indent === indent) closeLists(indent - 1);
|
|
183
|
+
const parent = lists[lists.length - 1];
|
|
184
|
+
// Only one nested level: deeper indentation joins the nested list.
|
|
185
|
+
const nested = Boolean(parent) && indent > parent.indent;
|
|
186
|
+
if (lists.length >= 2 && nested) {
|
|
187
|
+
parent.list.items.push(entry);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const list: ListBlock = { type: "list", ordered, start: ordered ? Number.parseInt(item[2], 10) || 1 : 1, items: [entry] };
|
|
191
|
+
if (nested) lastItem(parent.list).children.push(list);
|
|
192
|
+
else blocks.push(list);
|
|
193
|
+
lists.push({ list, indent });
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
// A line indented under a list item continues that item.
|
|
198
|
+
if (lists.length && /^\s+\S/.test(line)) {
|
|
199
|
+
lastItem(lists[lists.length - 1].list).lines.push(parseInline(line.trim()));
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
closeLists();
|
|
203
|
+
paragraph.push(line);
|
|
204
|
+
}
|
|
205
|
+
flushAll();
|
|
206
|
+
return blocks;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Links are opened only for these schemes, whatever the text claims. */
|
|
210
|
+
export function isOpenableHref(href: string | undefined): href is string {
|
|
211
|
+
return typeof href === "string" && /^(?:https?:\/\/|mailto:)/i.test(href);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The words of a message without its markup, for screen-reader announcements. */
|
|
215
|
+
export function plainText(content: string): string {
|
|
216
|
+
const line = (nodes: InlineLine) => nodes.map((node) => node.text).join("");
|
|
217
|
+
const list = (block: ListBlock): string[] => block.items.flatMap((item) => [item.lines.map(line).join(" "), ...item.children.flatMap(list)]);
|
|
218
|
+
return parseChatMarkdown(content).flatMap((block): string[] => {
|
|
219
|
+
switch (block.type) {
|
|
220
|
+
case "paragraph": case "quote": return block.lines.map(line);
|
|
221
|
+
case "heading": return [line(block.content)];
|
|
222
|
+
case "code": return [block.text];
|
|
223
|
+
case "list": return list(block);
|
|
224
|
+
default: return [];
|
|
225
|
+
}
|
|
226
|
+
}).filter((text) => text.trim()).join(" ").replace(/\s+/g, " ").trim();
|
|
227
|
+
}
|
package/src/outbox.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { GiovChatError } from "./errors";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Visitor messages the server has not acknowledged yet, in the order they were written. Every
|
|
5
|
+
* message keeps one client message id for its whole life, so resending after a dropped socket can
|
|
6
|
+
* never store it twice: the server answers a repeat with the original's ack. Survives reconnects
|
|
7
|
+
* and the app going to the background (the chat hook owns it, the transport only flushes it).
|
|
8
|
+
* Pure: no sockets or timers, so the rules are testable on their own.
|
|
9
|
+
*/
|
|
10
|
+
export type OutboxState = "queued" | "sending" | "failed";
|
|
11
|
+
export interface OutboxEntry {
|
|
12
|
+
clientMessageId: string;
|
|
13
|
+
content: string;
|
|
14
|
+
/** Uploaded attachment URLs this message carries. */
|
|
15
|
+
attachments: string[];
|
|
16
|
+
createdAt: number;
|
|
17
|
+
state: OutboxState;
|
|
18
|
+
/** When it last went out on a socket; 0 while it is waiting for one. */
|
|
19
|
+
sentAt: number;
|
|
20
|
+
/** Times the server said it could not save it. */
|
|
21
|
+
failures: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A sent message with no ack after this long means a dead socket: reconnect and resend. */
|
|
25
|
+
export const ACK_TIMEOUT_MS = 10_000;
|
|
26
|
+
/** Save failures retried before the visitor is told the message did not send. */
|
|
27
|
+
export const MAX_SEND_FAILURES = 3;
|
|
28
|
+
export const MAX_MESSAGE_CHARS = 2000;
|
|
29
|
+
/** Wait before resending after a save failure: 2 s, 4 s, … */
|
|
30
|
+
export const retryDelay = (failures: number) => 2000 * failures;
|
|
31
|
+
|
|
32
|
+
/** UUID v4 where crypto is available (Hermes ships getRandomValues with most setups), else a unique fallback. */
|
|
33
|
+
export function randomClientId(): string {
|
|
34
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string; getRandomValues?: (bytes: Uint8Array) => Uint8Array } }).crypto;
|
|
35
|
+
if (c && typeof c.randomUUID === "function") return c.randomUUID();
|
|
36
|
+
if (c && typeof c.getRandomValues === "function") {
|
|
37
|
+
const bytes = c.getRandomValues(new Uint8Array(16));
|
|
38
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
|
39
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
40
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
41
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
42
|
+
}
|
|
43
|
+
return `rn-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class Outbox {
|
|
47
|
+
private entries = new Map<string, OutboxEntry>();
|
|
48
|
+
private listeners = new Set<() => void>();
|
|
49
|
+
|
|
50
|
+
subscribe(listener: () => void): () => void {
|
|
51
|
+
this.listeners.add(listener);
|
|
52
|
+
return () => { this.listeners.delete(listener); };
|
|
53
|
+
}
|
|
54
|
+
private changed(): void {
|
|
55
|
+
for (const listener of this.listeners) { try { listener(); } catch { /* Listeners cannot break the queue. */ } }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Copies, oldest first. */
|
|
59
|
+
list(): OutboxEntry[] { return [...this.entries.values()].map((entry) => ({ ...entry, attachments: [...entry.attachments] })); }
|
|
60
|
+
get(clientMessageId: string): OutboxEntry | undefined {
|
|
61
|
+
const entry = this.entries.get(clientMessageId);
|
|
62
|
+
return entry && { ...entry, attachments: [...entry.attachments] };
|
|
63
|
+
}
|
|
64
|
+
get size(): number { return this.entries.size; }
|
|
65
|
+
|
|
66
|
+
/** Adds a message at the end. Throws when it could never be accepted (empty or too long). */
|
|
67
|
+
enqueue(content: string, attachments: readonly string[] = [], now = Date.now(), clientMessageId = randomClientId()): OutboxEntry {
|
|
68
|
+
if ((!content.trim() && attachments.length === 0) || content.length > MAX_MESSAGE_CHARS) {
|
|
69
|
+
throw new GiovChatError("send_failed", "Messages must contain 1–2000 characters.");
|
|
70
|
+
}
|
|
71
|
+
const entry: OutboxEntry = { clientMessageId, content, attachments: [...attachments], createdAt: now, state: "queued", sentAt: 0, failures: 0 };
|
|
72
|
+
this.entries.set(clientMessageId, entry);
|
|
73
|
+
this.changed();
|
|
74
|
+
return { ...entry, attachments: [...entry.attachments] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The wire frame; identical every time it is sent. */
|
|
78
|
+
static frame(entry: Pick<OutboxEntry, "clientMessageId" | "content" | "attachments">): string {
|
|
79
|
+
return JSON.stringify({ type: "message", content: entry.content, clientMessageId: entry.clientMessageId,
|
|
80
|
+
...(entry.attachments.length ? { attachments: entry.attachments } : {}) });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Everything waiting for a socket, in order, marked as sent now. Failed messages wait for `retry`. */
|
|
84
|
+
takeQueued(now = Date.now()): Array<{ clientMessageId: string; frame: string }> {
|
|
85
|
+
const out: Array<{ clientMessageId: string; frame: string }> = [];
|
|
86
|
+
for (const entry of this.entries.values()) {
|
|
87
|
+
if (entry.state !== "queued") continue;
|
|
88
|
+
entry.state = "sending";
|
|
89
|
+
entry.sentAt = now;
|
|
90
|
+
out.push({ clientMessageId: entry.clientMessageId, frame: Outbox.frame(entry) });
|
|
91
|
+
}
|
|
92
|
+
if (out.length) this.changed();
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The server saved it: the message leaves the queue. Unknown ids (already acknowledged) return null. */
|
|
97
|
+
acknowledge(clientMessageId: string): OutboxEntry | null {
|
|
98
|
+
const entry = this.entries.get(clientMessageId);
|
|
99
|
+
if (!entry) return null;
|
|
100
|
+
this.entries.delete(clientMessageId);
|
|
101
|
+
this.changed();
|
|
102
|
+
return entry;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The server could not save it: wait and resend, or give up after MAX_SEND_FAILURES. */
|
|
106
|
+
rejected(clientMessageId: string): { failed: boolean; retryAfter: number } | null {
|
|
107
|
+
const entry = this.entries.get(clientMessageId);
|
|
108
|
+
if (!entry || entry.state === "failed") return null;
|
|
109
|
+
entry.failures += 1;
|
|
110
|
+
entry.sentAt = 0;
|
|
111
|
+
entry.state = entry.failures >= MAX_SEND_FAILURES ? "failed" : "queued";
|
|
112
|
+
this.changed();
|
|
113
|
+
return { failed: entry.state === "failed", retryAfter: entry.state === "failed" ? 0 : retryDelay(entry.failures) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A message the server refused outright (invalid content or attachment): no retry can fix it. */
|
|
117
|
+
refuseOldestSending(): OutboxEntry | null {
|
|
118
|
+
for (const entry of this.entries.values()) {
|
|
119
|
+
if (entry.state !== "sending") continue;
|
|
120
|
+
entry.state = "failed";
|
|
121
|
+
entry.sentAt = 0;
|
|
122
|
+
this.changed();
|
|
123
|
+
return { ...entry, attachments: [...entry.attachments] };
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The socket went away: whatever was on it goes out again on the next one, with the same ids. */
|
|
129
|
+
socketClosed(): void {
|
|
130
|
+
let changed = false;
|
|
131
|
+
for (const entry of this.entries.values()) {
|
|
132
|
+
if (entry.state === "sending") { entry.state = "queued"; entry.sentAt = 0; changed = true; }
|
|
133
|
+
}
|
|
134
|
+
if (changed) this.changed();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** True when a message has waited longer than ACK_TIMEOUT_MS on an open socket. */
|
|
138
|
+
stale(now = Date.now()): boolean {
|
|
139
|
+
for (const entry of this.entries.values()) {
|
|
140
|
+
if (entry.state === "sending" && entry.sentAt > 0 && now - entry.sentAt > ACK_TIMEOUT_MS) return true;
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The visitor asked to try a failed message again: it rejoins the queue in its original place. */
|
|
146
|
+
retry(clientMessageId: string): boolean {
|
|
147
|
+
const entry = this.entries.get(clientMessageId);
|
|
148
|
+
if (!entry || entry.state !== "failed") return false;
|
|
149
|
+
entry.state = "queued";
|
|
150
|
+
entry.failures = 0;
|
|
151
|
+
entry.sentAt = 0;
|
|
152
|
+
this.changed();
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
remove(clientMessageId: string): boolean {
|
|
157
|
+
const removed = this.entries.delete(clientMessageId);
|
|
158
|
+
if (removed) this.changed();
|
|
159
|
+
return removed;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
clear(): void {
|
|
163
|
+
if (!this.entries.size) return;
|
|
164
|
+
this.entries.clear();
|
|
165
|
+
this.changed();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Composer text cut to MAX_MESSAGE_CHARS UTF-16 units (JavaScript string length), never through a surrogate pair. */
|
|
170
|
+
export function clampMessage(text: string, max = MAX_MESSAGE_CHARS): string {
|
|
171
|
+
if (text.length <= max) return text;
|
|
172
|
+
const cut = text.slice(0, max);
|
|
173
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
174
|
+
return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
|
|
175
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { GiovMessage } from "./types";
|
|
2
|
+
import { parseRatingBlock } from "./rating";
|
|
3
|
+
import { parseAttachments } from "./attachments";
|
|
4
|
+
import { safeImageUrl } from "./identity";
|
|
5
|
+
|
|
6
|
+
export function object(value: unknown): Record<string, unknown> {
|
|
7
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A message from a socket frame (camelCase) or a history row (snake_case). The server stores the
|
|
12
|
+
* visitor's role as "user". Sender identity is kept for agents only, and the AI identity wins over
|
|
13
|
+
* "automated" (AI replies carry both flags).
|
|
14
|
+
*/
|
|
15
|
+
export function parseMessage(value: unknown): GiovMessage | null {
|
|
16
|
+
const m = object(value);
|
|
17
|
+
const role = m.role === "user" ? "visitor" : m.role;
|
|
18
|
+
const createdAt = m.createdAt ?? m.created_at;
|
|
19
|
+
if (typeof m.id !== "string" || !m.id || (role !== "agent" && role !== "visitor") ||
|
|
20
|
+
typeof m.content !== "string" || typeof createdAt !== "number" || !Number.isFinite(createdAt)) return null;
|
|
21
|
+
// camelCase first; the snake_case history fields are deprecated aliases (protocol v1).
|
|
22
|
+
const isBot = role === "agent" && (typeof m.isBot === "boolean" ? m.isBot : m.is_bot === 1);
|
|
23
|
+
const isAutomated = role === "agent" && (typeof m.isAutomated === "boolean" ? m.isAutomated : m.is_automated === 1);
|
|
24
|
+
const human = role === "agent" && !isBot && !isAutomated;
|
|
25
|
+
const attachments = parseAttachments(m.attachmentId ?? m.attachment_id, m.attachmentPreviews ?? m.attachment_previews);
|
|
26
|
+
// Unknown block types are ignored (protocol versioning); only the rating card is drawn.
|
|
27
|
+
const rating = Array.isArray(m.blocks) ? m.blocks.map(parseRatingBlock).find((block) => block !== null) : undefined;
|
|
28
|
+
return {
|
|
29
|
+
id: m.id, role, content: m.content, createdAt, isBot, isAutomated,
|
|
30
|
+
senderName: role === "agent" && typeof m.senderName === "string" ? m.senderName : undefined,
|
|
31
|
+
...(human && safeImageUrl(m.senderAvatar) ? { senderAvatar: safeImageUrl(m.senderAvatar) } : {}),
|
|
32
|
+
...(attachments.length ? { attachments } : {}),
|
|
33
|
+
...(rating ? { rating } : {}),
|
|
34
|
+
...(isBot && (m.feedback === "up" || m.feedback === "down") ? { feedback: m.feedback } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/rating.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GiovFeedbackValue, GiovMessage, GiovRatingBlock, GiovRatingRequest, GiovRatingResult, GiovRatingScore,
|
|
3
|
+
} from "./types";
|
|
4
|
+
import { GiovChatError, retryAfterSeconds } from "./errors";
|
|
5
|
+
|
|
6
|
+
/** Pure rules for conversation ratings and the thumbs under GiovBot answers (docs/protocol, "Conversation ratings"). */
|
|
7
|
+
|
|
8
|
+
export const MAX_RATING_COMMENT = 1000;
|
|
9
|
+
export const MAX_FEEDBACK_REASON = 300;
|
|
10
|
+
|
|
11
|
+
const obj = (value: unknown): Record<string, unknown> =>
|
|
12
|
+
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
13
|
+
|
|
14
|
+
const isScore = (value: unknown): value is GiovRatingScore =>
|
|
15
|
+
typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 5;
|
|
16
|
+
|
|
17
|
+
/** A `rating` block from a message; null for any other (or malformed) block. */
|
|
18
|
+
export function parseRatingBlock(value: unknown): GiovRatingBlock | null {
|
|
19
|
+
const b = obj(value);
|
|
20
|
+
if (b.type !== "rating" || typeof b.requestId !== "string" || !b.requestId ||
|
|
21
|
+
typeof b.closesAt !== "number" || !Number.isFinite(b.closesAt)) return null;
|
|
22
|
+
const score = isScore(b.score) ? b.score : undefined;
|
|
23
|
+
return {
|
|
24
|
+
requestId: b.requestId, closesAt: b.closesAt,
|
|
25
|
+
...(typeof b.url === "string" && /^https:\/\//i.test(b.url) ? { url: b.url } : {}),
|
|
26
|
+
...(score ? { score } : {}),
|
|
27
|
+
...(score && typeof b.comment === "string" && b.comment ? { comment: b.comment } : {}),
|
|
28
|
+
...(b.state === "replaced" ? { state: "replaced" as const } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The `rating` socket frame: the card changed (answered, changed or replaced). */
|
|
33
|
+
export interface GiovRatingFrame {
|
|
34
|
+
requestId: string;
|
|
35
|
+
messageId?: string;
|
|
36
|
+
closesAt: number;
|
|
37
|
+
score?: GiovRatingScore;
|
|
38
|
+
comment?: string;
|
|
39
|
+
state?: "replaced";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseRatingFrame(value: unknown): GiovRatingFrame | null {
|
|
43
|
+
const f = obj(value);
|
|
44
|
+
if (f.type !== "rating" || typeof f.requestId !== "string" || !f.requestId ||
|
|
45
|
+
typeof f.closesAt !== "number" || !Number.isFinite(f.closesAt)) return null;
|
|
46
|
+
const score = isScore(f.score) ? f.score : undefined;
|
|
47
|
+
return {
|
|
48
|
+
requestId: f.requestId, closesAt: f.closesAt,
|
|
49
|
+
...(typeof f.messageId === "string" && f.messageId ? { messageId: f.messageId } : {}),
|
|
50
|
+
...(score ? { score } : {}),
|
|
51
|
+
...(score && typeof f.comment === "string" && f.comment ? { comment: f.comment } : {}),
|
|
52
|
+
...(f.state === "replaced" ? { state: "replaced" as const } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Sets the answer, deadline and state on the card a frame (or an answer) names; other messages stay as they are. */
|
|
57
|
+
export function patchRating(messages: GiovMessage[], frame: GiovRatingFrame): GiovMessage[] {
|
|
58
|
+
let changed = false;
|
|
59
|
+
const next = messages.map((message) => {
|
|
60
|
+
if ((frame.messageId && message.id !== frame.messageId) || message.rating?.requestId !== frame.requestId) return message;
|
|
61
|
+
changed = true;
|
|
62
|
+
const { score: _s, comment: _c, state: _t, ...rest } = message.rating;
|
|
63
|
+
return {
|
|
64
|
+
...message,
|
|
65
|
+
rating: {
|
|
66
|
+
...rest, closesAt: frame.closesAt,
|
|
67
|
+
...(frame.score ? { score: frame.score } : {}),
|
|
68
|
+
...(frame.score && frame.comment ? { comment: frame.comment } : {}),
|
|
69
|
+
...(frame.state ? { state: frame.state } : message.rating.state ? { state: message.rating.state } : {}),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
return changed ? next : messages;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One open card per conversation: an older card the visitor never answered is replaced by a newer
|
|
78
|
+
* one (also before the server's own `replaced` mark arrives). Answered cards keep their answer.
|
|
79
|
+
*/
|
|
80
|
+
export function supersedeRatings(messages: GiovMessage[]): GiovMessage[] {
|
|
81
|
+
let latest = -1;
|
|
82
|
+
messages.forEach((message, index) => { if (message.rating && message.rating.state !== "replaced") latest = index; });
|
|
83
|
+
let changed = false;
|
|
84
|
+
const next = messages.map((message, index) => {
|
|
85
|
+
const card = message.rating;
|
|
86
|
+
if (!card || card.state === "replaced" || index === latest || card.score) return message;
|
|
87
|
+
changed = true;
|
|
88
|
+
return { ...message, rating: { ...card, state: "replaced" as const } };
|
|
89
|
+
});
|
|
90
|
+
return changed ? next : messages;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** "hidden" (replaced), "open" (can be answered or changed) or "closed" (read-only: the clock reached `closesAt`; the server decides). */
|
|
94
|
+
export function ratingPhase(block: Pick<GiovRatingBlock, "closesAt" | "state">, now: number): "hidden" | "open" | "closed" {
|
|
95
|
+
return block.state === "replaced" ? "hidden" : now < block.closesAt ? "open" : "closed";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The card the visitor can answer or change now, or null. */
|
|
99
|
+
export function pendingRatingRequest(messages: GiovMessage[], now: number): GiovRatingRequest | null {
|
|
100
|
+
const cards = supersedeRatings(messages).filter((message) => message.rating && ratingPhase(message.rating, now) === "open");
|
|
101
|
+
const message = cards[cards.length - 1];
|
|
102
|
+
const card = message?.rating;
|
|
103
|
+
if (!message || !card) return null;
|
|
104
|
+
return {
|
|
105
|
+
requestId: card.requestId, messageId: message.id, closesAt: card.closesAt, answered: !!card.score,
|
|
106
|
+
...(card.url ? { url: card.url } : {}),
|
|
107
|
+
...(card.score ? { score: card.score } : {}),
|
|
108
|
+
...(card.comment ? { comment: card.comment } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A refusal for one card: `closed` makes it read-only now, `replaced` hides it. */
|
|
113
|
+
export function markRating(messages: GiovMessage[], requestId: string, kind: "closed" | "replaced", now = Date.now()): GiovMessage[] {
|
|
114
|
+
return messages.map((message) => {
|
|
115
|
+
const card = message.rating;
|
|
116
|
+
if (!card || card.requestId !== requestId) return message;
|
|
117
|
+
return { ...message, rating: kind === "replaced" ? { ...card, state: "replaced" as const } : { ...card, closesAt: Math.min(card.closesAt, now) } };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What a failed answer means for the card: read-only states, or a message to retry. */
|
|
122
|
+
export function ratingFailure(code: string): "closed" | "replaced" | "retry" {
|
|
123
|
+
return code === "rating_closed" ? "closed" : code === "request_replaced" ? "replaced" : "retry";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What tapping a thumb sends: the same thumb again takes the vote back. */
|
|
127
|
+
export const nextFeedback = (current: "up" | "down" | undefined, tapped: "up" | "down"): GiovFeedbackValue => (current === tapped ? "none" : tapped);
|
|
128
|
+
|
|
129
|
+
export function applyFeedback(messages: GiovMessage[], id: string, value: GiovFeedbackValue): GiovMessage[] {
|
|
130
|
+
return messages.map((message) => {
|
|
131
|
+
if (message.id !== id) return message;
|
|
132
|
+
const { feedback: _f, ...rest } = message;
|
|
133
|
+
return value === "none" ? rest : { ...rest, feedback: value };
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Thumbs go under finished GiovBot answers, never under the rating request itself. */
|
|
138
|
+
export const canVoteOn = (message: GiovMessage): boolean =>
|
|
139
|
+
message.role === "agent" && message.isBot === true && !message.isStreaming && !message.id.startsWith("stream:") && !message.rating;
|
|
140
|
+
|
|
141
|
+
const CODES: Record<string, GiovChatError["code"]> = {
|
|
142
|
+
invalid_rating: "invalid_rating", invalid_feedback: "invalid_feedback", request_not_found: "request_not_found",
|
|
143
|
+
message_not_found: "message_not_found", request_replaced: "request_replaced", rating_closed: "rating_closed",
|
|
144
|
+
too_many_changes: "too_many_changes",
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** The documented refusals of the rating and feedback routes, as typed errors. A 404 without a code is a server that lacks the routes. */
|
|
148
|
+
export function ratingError(status: number, body: unknown, retryAfter?: string | null): GiovChatError {
|
|
149
|
+
const data = obj(body);
|
|
150
|
+
const server = typeof data.error === "string" ? data.error : "";
|
|
151
|
+
const known = CODES[server];
|
|
152
|
+
const wait = retryAfterSeconds(retryAfter);
|
|
153
|
+
if (known) return new GiovChatError(known, "Rating was refused.", status, wait);
|
|
154
|
+
if (status === 404 || status === 405 || status === 501) return new GiovChatError("unsupported", "This chat does not take ratings.", status);
|
|
155
|
+
if (status === 401 || status === 403) return new GiovChatError("invalid_session", "Chat session expired. Please start a new chat.", status);
|
|
156
|
+
if (status === 429) return new GiovChatError("rate_limited", "Too many requests. Please wait a moment.", status, wait);
|
|
157
|
+
return new GiovChatError("server", "Chat is unavailable. Please try again.", status, wait);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function parseRatingResult(body: unknown, fallbackRequestId?: string): GiovRatingResult | null {
|
|
161
|
+
const b = obj(body);
|
|
162
|
+
const requestId = typeof b.requestId === "string" ? b.requestId : fallbackRequestId;
|
|
163
|
+
if (!requestId || !isScore(b.score) || typeof b.closesAt !== "number" || !Number.isFinite(b.closesAt)) return null;
|
|
164
|
+
return {
|
|
165
|
+
requestId, score: b.score, closesAt: b.closesAt,
|
|
166
|
+
...(typeof b.comment === "string" && b.comment ? { comment: b.comment } : {}),
|
|
167
|
+
...(typeof b.ratedAt === "number" ? { ratedAt: b.ratedAt } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { GiovChatError } from "./errors";
|
|
3
|
+
import type { GiovFeedbackValue, GiovRatingRequest, GiovRatingResult } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What `useGiovRating` returns: the rating card the visitor can answer now, and the calls to answer it
|
|
7
|
+
* or to open the built-in rating sheet. Each mounted `GiovChat` publishes its own entry under its tenant key.
|
|
8
|
+
*/
|
|
9
|
+
export interface GiovRatingController {
|
|
10
|
+
/** The open rating card (answered or not) or null: nothing to rate right now. */
|
|
11
|
+
request: GiovRatingRequest | null;
|
|
12
|
+
/** False once the server turned out not to take ratings (an older server): hide your own controls. */
|
|
13
|
+
supported: boolean;
|
|
14
|
+
/** Rates the conversation; see `GiovChatHandle.submitRating`. */
|
|
15
|
+
submit(score: number, options?: { comment?: string; requestId?: string }): Promise<GiovRatingResult>;
|
|
16
|
+
/** Thumbs on a GiovBot answer; see `GiovChatHandle.submitAnswerFeedback`. */
|
|
17
|
+
submitFeedback(messageId: string, value: GiovFeedbackValue, options?: { reason?: string }): Promise<void>;
|
|
18
|
+
/** Opens the built-in rating sheet for the open card; false when there is none. */
|
|
19
|
+
show(): boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const unavailable = () => Promise.reject(new GiovChatError("disconnected", "No chat is mounted."));
|
|
23
|
+
const NONE: GiovRatingController = { request: null, supported: true, submit: unavailable, submitFeedback: unavailable, show: () => false };
|
|
24
|
+
|
|
25
|
+
const entries = new Map<symbol, { tenantKey: string; value: GiovRatingController }>();
|
|
26
|
+
const listeners = new Set<() => void>();
|
|
27
|
+
const notify = () => { for (const listener of listeners) { try { listener(); } catch { /* Isolated. */ } } };
|
|
28
|
+
|
|
29
|
+
export function publishRating(owner: symbol, tenantKey: string, value: GiovRatingController): void {
|
|
30
|
+
entries.set(owner, { tenantKey, value });
|
|
31
|
+
notify();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function forgetRating(owner: symbol): void {
|
|
35
|
+
if (entries.delete(owner)) notify();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readRating(tenantKey?: string): GiovRatingController {
|
|
39
|
+
let found: GiovRatingController | null = null;
|
|
40
|
+
for (const entry of entries.values()) {
|
|
41
|
+
if (tenantKey && entry.tenantKey !== tenantKey) continue;
|
|
42
|
+
// The first mounted chat with a card wins; otherwise the first chat.
|
|
43
|
+
if (entry.value.request) return entry.value;
|
|
44
|
+
found ??= entry.value;
|
|
45
|
+
}
|
|
46
|
+
return found ?? NONE;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function subscribe(listener: () => void): () => void {
|
|
50
|
+
listeners.add(listener);
|
|
51
|
+
return () => { listeners.delete(listener); };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Conversation rating for a host that draws its own prompt (turn the built-in card off with
|
|
56
|
+
* `ratingCard={false}`), or wants a button that opens the built-in sheet. Needs a mounted `GiovChat`.
|
|
57
|
+
*
|
|
58
|
+
* ```tsx
|
|
59
|
+
* const rating = useGiovRating();
|
|
60
|
+
* if (rating.request && !rating.request.answered) return <Button title="Rate us" onPress={rating.show} />;
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function useGiovRating(tenantKey?: string): GiovRatingController {
|
|
64
|
+
return useSyncExternalStore(subscribe, () => readRating(tenantKey), () => NONE);
|
|
65
|
+
}
|