@giovchat/react-native 0.0.0-stage → 0.2.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 +333 -2
- package/dist/GiovChat.d.ts +100 -0
- package/dist/GiovChat.d.ts.map +1 -0
- package/dist/GiovChat.js +257 -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/client.d.ts +190 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +801 -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 +6 -0
- package/dist/components/Branding.d.ts.map +1 -0
- package/dist/components/Branding.js +28 -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 +16 -0
- package/dist/components/MessageBubble.d.ts.map +1 -0
- package/dist/components/MessageBubble.js +49 -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/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 +18 -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 +73 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +292 -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 +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -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 +34 -0
- package/dist/protocol.js.map +1 -0
- package/dist/styleOverrides.d.ts +37 -0
- package/dist/styleOverrides.d.ts.map +1 -0
- package/dist/styleOverrides.js +54 -0
- package/dist/styleOverrides.js.map +1 -0
- package/dist/types.d.ts +111 -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 +78 -0
- package/dist/useChatSession.d.ts.map +1 -0
- package/dist/useChatSession.js +796 -0
- package/dist/useChatSession.js.map +1 -0
- package/package.json +59 -4
- package/src/GiovChat.tsx +530 -0
- package/src/attachments.ts +218 -0
- package/src/brand-assets.ts +5 -0
- package/src/client.ts +758 -0
- package/src/color.ts +74 -0
- package/src/components/Attachments.tsx +75 -0
- package/src/components/Branding.tsx +35 -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 +121 -0
- package/src/components/PreChatForm.tsx +183 -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 +30 -0
- package/src/i18n.ts +351 -0
- package/src/identity.ts +54 -0
- package/src/index.ts +68 -0
- package/src/markdown.ts +227 -0
- package/src/outbox.ts +175 -0
- package/src/protocol.ts +31 -0
- package/src/styleOverrides.ts +85 -0
- package/src/types.ts +119 -0
- package/src/unread.ts +39 -0
- package/src/useChatSession.ts +695 -0
package/src/identity.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { GiovWidgetConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Who the visitor is talking to, as the web widget shows it (apps/widget-web/src/components/
|
|
5
|
+
* header-identity.ts): a person once one has taken over (their name and photo), otherwise the AI
|
|
6
|
+
* assistant while it answers (its own name and avatar), and the team title when neither is set up.
|
|
7
|
+
*/
|
|
8
|
+
export type HeaderIdentityKind = "person" | "bot" | "team";
|
|
9
|
+
export interface HeaderIdentity {
|
|
10
|
+
kind: HeaderIdentityKind;
|
|
11
|
+
title: string;
|
|
12
|
+
avatar: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function headerIdentity(
|
|
16
|
+
config: Pick<GiovWidgetConfig, "title" | "botEnabled" | "botName" | "botAvatarUrl" | "avatarUrl">,
|
|
17
|
+
currentAgentName?: string | null,
|
|
18
|
+
currentAgentAvatar?: string | null,
|
|
19
|
+
): HeaderIdentity {
|
|
20
|
+
if (currentAgentName) return { kind: "person", title: currentAgentName, avatar: safeImageUrl(currentAgentAvatar) };
|
|
21
|
+
if (config.botEnabled && config.botName) return { kind: "bot", title: config.botName, avatar: safeImageUrl(config.botAvatarUrl) };
|
|
22
|
+
return { kind: "team", title: config.title, avatar: safeImageUrl(config.avatarUrl) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Only http(s) images are loaded; anything else falls back to the initial. */
|
|
26
|
+
export function safeImageUrl(value: unknown): string | null {
|
|
27
|
+
return typeof value === "string" && /^https?:\/\//i.test(value.trim()) ? value.trim() : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type IdentityInput = { role: string; isBot?: boolean; isAutomated?: boolean; senderName?: string | null };
|
|
31
|
+
|
|
32
|
+
/** The label above a bubble. AI delivery is also automated internally; the AI identity takes precedence. */
|
|
33
|
+
export function messageSenderLabel(
|
|
34
|
+
message: IdentityInput,
|
|
35
|
+
labels: { botName?: string; agentName?: string | null; you: string; automated: string; agent: string },
|
|
36
|
+
): string {
|
|
37
|
+
if (message.role !== "agent") return labels.you;
|
|
38
|
+
if (message.isBot === true) return labels.botName?.trim() || "GiovBot";
|
|
39
|
+
if (message.isAutomated === true) return labels.automated;
|
|
40
|
+
return message.senderName?.trim() || labels.agentName?.trim() || labels.agent;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type AgentTypingState = { visible: boolean; isBot?: boolean };
|
|
44
|
+
|
|
45
|
+
/** Only explicit server AI metadata may brand a typing indicator as the AI (as in the widget). */
|
|
46
|
+
export function reduceAgentTyping(current: AgentTypingState, event: { type?: unknown; role?: unknown; isBot?: unknown }): AgentTypingState {
|
|
47
|
+
if (event.role !== "agent") return current;
|
|
48
|
+
if (event.type === "typing") return { visible: true, isBot: typeof event.isBot === "boolean" ? event.isBot : undefined };
|
|
49
|
+
if (event.type === "typing_end") {
|
|
50
|
+
if (typeof event.isBot === "boolean" && typeof current.isBot === "boolean" && event.isBot !== current.isBot) return current;
|
|
51
|
+
return { visible: false };
|
|
52
|
+
}
|
|
53
|
+
return current;
|
|
54
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Public types
|
|
2
|
+
export type {
|
|
3
|
+
GiovChatContact,
|
|
4
|
+
GiovChatSession,
|
|
5
|
+
GiovChatSessionStorage,
|
|
6
|
+
GiovChatConnectionStatus,
|
|
7
|
+
GiovMessage,
|
|
8
|
+
GiovWidgetConfig,
|
|
9
|
+
MessageHandler,
|
|
10
|
+
RequestUserInfo,
|
|
11
|
+
StatusHandler,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
// Advanced / headless usage
|
|
15
|
+
export {
|
|
16
|
+
ChatClient,
|
|
17
|
+
createConversation,
|
|
18
|
+
DEFAULT_BASE_URL,
|
|
19
|
+
fetchWidgetConfig,
|
|
20
|
+
fetchConversationHistory,
|
|
21
|
+
PUSH_TOKEN_PATH,
|
|
22
|
+
SDK_VERSION,
|
|
23
|
+
UPLOAD_TIMEOUT_MS,
|
|
24
|
+
unregisterPushToken,
|
|
25
|
+
} from "./client";
|
|
26
|
+
export type {
|
|
27
|
+
ChatClientOptions,
|
|
28
|
+
GiovConversationHistory,
|
|
29
|
+
GiovPushOptions,
|
|
30
|
+
GiovPushPlatform,
|
|
31
|
+
GiovPushRegistration,
|
|
32
|
+
GiovStreamEvent,
|
|
33
|
+
GiovUploadResult,
|
|
34
|
+
} from "./client";
|
|
35
|
+
export { Outbox, ACK_TIMEOUT_MS, MAX_SEND_FAILURES, MAX_MESSAGE_CHARS, clampMessage } from "./outbox";
|
|
36
|
+
export type { OutboxEntry, OutboxState } from "./outbox";
|
|
37
|
+
export { parseChatMarkdown, parseInline, plainText } from "./markdown";
|
|
38
|
+
export type { Block as ChatMarkdownBlock, InlineNode as ChatMarkdownInline } from "./markdown";
|
|
39
|
+
export { headerIdentity, messageSenderLabel, reduceAgentTyping } from "./identity";
|
|
40
|
+
export type { HeaderIdentity, HeaderIdentityKind, AgentTypingState } from "./identity";
|
|
41
|
+
export {
|
|
42
|
+
DEFAULT_ATTACHMENT_POLICY,
|
|
43
|
+
MAX_ATTACHED_FILES,
|
|
44
|
+
SUPPORTED_ATTACHMENT_TYPES,
|
|
45
|
+
normalizeAttachmentPolicy,
|
|
46
|
+
validateAttachments,
|
|
47
|
+
} from "./attachments";
|
|
48
|
+
export type {
|
|
49
|
+
GiovAttachment,
|
|
50
|
+
GiovAttachmentPolicy,
|
|
51
|
+
GiovAttachmentPreview,
|
|
52
|
+
GiovChatAttachmentPicker,
|
|
53
|
+
GiovChatPickedFile,
|
|
54
|
+
} from "./attachments";
|
|
55
|
+
|
|
56
|
+
// Main component
|
|
57
|
+
export { GiovChat } from "./GiovChat";
|
|
58
|
+
export type { GiovChatProps, GiovChatHandle } from "./GiovChat";
|
|
59
|
+
export { useGiovChatUnreadCount } from "./unread";
|
|
60
|
+
export { GIOVCHAT_STRINGS, resolveLocale, resolveStrings } from "./i18n";
|
|
61
|
+
export type { GiovChatLocale, GiovChatStrings } from "./i18n";
|
|
62
|
+
export { GiovChatError } from "./errors";
|
|
63
|
+
export type { GiovChatErrorCode } from "./errors";
|
|
64
|
+
export type {
|
|
65
|
+
GiovChatStyleOverrides,
|
|
66
|
+
GiovChatTextStyle,
|
|
67
|
+
GiovChatBackgroundStyle,
|
|
68
|
+
} from "./styleOverrides";
|
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,31 @@
|
|
|
1
|
+
import type { GiovMessage } from "./types";
|
|
2
|
+
import { parseAttachments } from "./attachments";
|
|
3
|
+
import { safeImageUrl } from "./identity";
|
|
4
|
+
|
|
5
|
+
export function object(value: unknown): Record<string, unknown> {
|
|
6
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A message from a socket frame (camelCase) or a history row (snake_case). The server stores the
|
|
11
|
+
* visitor's role as "user". Sender identity is kept for agents only, and the AI identity wins over
|
|
12
|
+
* "automated" (AI replies carry both flags).
|
|
13
|
+
*/
|
|
14
|
+
export function parseMessage(value: unknown): GiovMessage | null {
|
|
15
|
+
const m = object(value);
|
|
16
|
+
const role = m.role === "user" ? "visitor" : m.role;
|
|
17
|
+
const createdAt = m.createdAt ?? m.created_at;
|
|
18
|
+
if (typeof m.id !== "string" || !m.id || (role !== "agent" && role !== "visitor") ||
|
|
19
|
+
typeof m.content !== "string" || typeof createdAt !== "number" || !Number.isFinite(createdAt)) return null;
|
|
20
|
+
// camelCase first; the snake_case history fields are deprecated aliases (protocol v1).
|
|
21
|
+
const isBot = role === "agent" && (typeof m.isBot === "boolean" ? m.isBot : m.is_bot === 1);
|
|
22
|
+
const isAutomated = role === "agent" && (typeof m.isAutomated === "boolean" ? m.isAutomated : m.is_automated === 1);
|
|
23
|
+
const human = role === "agent" && !isBot && !isAutomated;
|
|
24
|
+
const attachments = parseAttachments(m.attachmentId ?? m.attachment_id, m.attachmentPreviews ?? m.attachment_previews);
|
|
25
|
+
return {
|
|
26
|
+
id: m.id, role, content: m.content, createdAt, isBot, isAutomated,
|
|
27
|
+
senderName: role === "agent" && typeof m.senderName === "string" ? m.senderName : undefined,
|
|
28
|
+
...(human && safeImageUrl(m.senderAvatar) ? { senderAvatar: safeImageUrl(m.senderAvatar) } : {}),
|
|
29
|
+
...(attachments.length ? { attachments } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { TextStyle, ViewStyle } from "react-native";
|
|
2
|
+
|
|
3
|
+
/** Typography and colors only; text sizing and layout remain SDK-controlled. */
|
|
4
|
+
export type GiovChatTextStyle = Pick<TextStyle, "fontFamily" | "fontWeight" | "fontStyle"> & {
|
|
5
|
+
color?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type GiovChatBackgroundStyle = { backgroundColor?: string };
|
|
9
|
+
|
|
10
|
+
/** Plain objects only. No arbitrary React Native styles or branding overrides. */
|
|
11
|
+
export interface GiovChatStyleOverrides {
|
|
12
|
+
/** Default font family for all customizable text. Load the font in your app. */
|
|
13
|
+
fontFamily?: string;
|
|
14
|
+
container?: GiovChatBackgroundStyle;
|
|
15
|
+
header?: GiovChatBackgroundStyle;
|
|
16
|
+
headerTitle?: GiovChatTextStyle;
|
|
17
|
+
headerSubtitle?: GiovChatTextStyle;
|
|
18
|
+
agentBubble?: GiovChatBackgroundStyle;
|
|
19
|
+
visitorBubble?: GiovChatBackgroundStyle;
|
|
20
|
+
agentText?: GiovChatTextStyle;
|
|
21
|
+
visitorText?: GiovChatTextStyle;
|
|
22
|
+
composer?: GiovChatBackgroundStyle;
|
|
23
|
+
input?: GiovChatTextStyle & GiovChatBackgroundStyle;
|
|
24
|
+
button?: GiovChatBackgroundStyle;
|
|
25
|
+
buttonText?: GiovChatTextStyle;
|
|
26
|
+
form?: GiovChatBackgroundStyle;
|
|
27
|
+
formTitle?: GiovChatTextStyle;
|
|
28
|
+
label?: GiovChatTextStyle;
|
|
29
|
+
hint?: GiovChatTextStyle;
|
|
30
|
+
errorText?: GiovChatTextStyle;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const backgroundSlots = [
|
|
34
|
+
"container", "header", "agentBubble", "visitorBubble", "composer", "button", "form",
|
|
35
|
+
] as const;
|
|
36
|
+
const textSlots = [
|
|
37
|
+
"headerTitle", "headerSubtitle", "agentText", "visitorText", "input", "buttonText",
|
|
38
|
+
"formTitle", "label", "hint", "errorText",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export type ResolvedWidgetStyles = Record<(typeof backgroundSlots)[number], ViewStyle> &
|
|
42
|
+
Record<(typeof textSlots)[number], TextStyle>;
|
|
43
|
+
|
|
44
|
+
function record(value: unknown): Record<string, unknown> {
|
|
45
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
46
|
+
? value as Record<string, unknown>
|
|
47
|
+
: {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function nonEmptyString(value: unknown): value is string {
|
|
51
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Rebuild every slot from an allowlist, including for untyped JavaScript callers. */
|
|
55
|
+
export function resolveStyleOverrides(value: unknown): ResolvedWidgetStyles {
|
|
56
|
+
const input = record(value);
|
|
57
|
+
const result = {} as ResolvedWidgetStyles;
|
|
58
|
+
for (const slot of backgroundSlots) {
|
|
59
|
+
const source = record(input[slot]);
|
|
60
|
+
result[slot] = nonEmptyString(source.backgroundColor)
|
|
61
|
+
? { backgroundColor: source.backgroundColor }
|
|
62
|
+
: {};
|
|
63
|
+
}
|
|
64
|
+
for (const slot of textSlots) {
|
|
65
|
+
const source = record(input[slot]);
|
|
66
|
+
const style: TextStyle = {};
|
|
67
|
+
if (nonEmptyString(input.fontFamily)) style.fontFamily = input.fontFamily;
|
|
68
|
+
if (nonEmptyString(source.fontFamily)) style.fontFamily = source.fontFamily;
|
|
69
|
+
if (nonEmptyString(source.color)) style.color = source.color;
|
|
70
|
+
if (source.fontStyle === "normal" || source.fontStyle === "italic") {
|
|
71
|
+
style.fontStyle = source.fontStyle;
|
|
72
|
+
}
|
|
73
|
+
const weight = source.fontWeight;
|
|
74
|
+
if (weight === "normal" || weight === "bold" ||
|
|
75
|
+
(typeof weight === "string" && /^[1-9]00$/.test(weight)) ||
|
|
76
|
+
(typeof weight === "number" && weight >= 100 && weight <= 900 && weight % 100 === 0)) {
|
|
77
|
+
style.fontWeight = weight as TextStyle["fontWeight"];
|
|
78
|
+
}
|
|
79
|
+
if (slot === "input" && nonEmptyString(source.backgroundColor)) {
|
|
80
|
+
style.backgroundColor = source.backgroundColor;
|
|
81
|
+
}
|
|
82
|
+
result[slot] = style;
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|