@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/color.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readable text on the workspace's colours (WCAG 2.x relative luminance and contrast ratio). The
|
|
3
|
+
* brand colour comes from the dashboard, so text on it is black or white, whichever reads better.
|
|
4
|
+
* Pure: no React or native modules.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
type Rgb = [number, number, number];
|
|
8
|
+
|
|
9
|
+
/** `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()` and `rgba()`; anything else (named colours) is null. Alpha is ignored. */
|
|
10
|
+
export function parseColor(value: unknown): Rgb | null {
|
|
11
|
+
if (typeof value !== "string") return null;
|
|
12
|
+
const text = value.trim().toLowerCase();
|
|
13
|
+
const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(text)?.[1];
|
|
14
|
+
if (hex) {
|
|
15
|
+
const full = hex.length <= 4 ? [...hex.slice(0, 3)].map((digit) => digit + digit).join("") : hex.slice(0, 6);
|
|
16
|
+
return [0, 2, 4].map((at) => parseInt(full.slice(at, at + 2), 16)) as Rgb;
|
|
17
|
+
}
|
|
18
|
+
const rgb = /^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*(?:[,/]\s*[\d.]+%?\s*)?\)$/.exec(text);
|
|
19
|
+
if (rgb) {
|
|
20
|
+
const channels = rgb.slice(1, 4).map(Number);
|
|
21
|
+
return channels.every((channel) => channel <= 255) ? channels as Rgb : null;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** WCAG relative luminance, 0 (black) to 1 (white). */
|
|
27
|
+
export function luminance([r, g, b]: Rgb): number {
|
|
28
|
+
const linear = (channel: number) => {
|
|
29
|
+
const c = channel / 255;
|
|
30
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
31
|
+
};
|
|
32
|
+
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** WCAG contrast ratio, 1 to 21. */
|
|
36
|
+
export function contrastRatio(a: Rgb, b: Rgb): number {
|
|
37
|
+
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
|
|
38
|
+
return (light + 0.05) / (dark + 0.05);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const WHITE: Rgb = [255, 255, 255];
|
|
42
|
+
const INK: Rgb = [17, 17, 17];
|
|
43
|
+
export const WHITE_TEXT = "#fff";
|
|
44
|
+
export const DARK_TEXT = "#111";
|
|
45
|
+
|
|
46
|
+
/** White or near-black text, whichever contrasts more with `background`; white when the colour cannot be read. */
|
|
47
|
+
export function textOn(background: unknown): string {
|
|
48
|
+
const rgb = parseColor(background);
|
|
49
|
+
if (!rgb) return WHITE_TEXT;
|
|
50
|
+
return contrastRatio(INK, rgb) > contrastRatio(WHITE, rgb) ? DARK_TEXT : WHITE_TEXT;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when the text chosen for `background` is the dark one. */
|
|
54
|
+
export function isLightBackground(background: unknown): boolean {
|
|
55
|
+
return textOn(background) === DARK_TEXT;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const hex = ([r, g, b]: Rgb) => "#" + [r, g, b].map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("");
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Quieter text for labels and times on `background`: the readable text colour softened as far as
|
|
62
|
+
* WCAG AA allows (4.5:1), and the full colour when even that is not enough.
|
|
63
|
+
*/
|
|
64
|
+
export function secondaryTextOn(background: unknown): string {
|
|
65
|
+
const rgb = parseColor(background);
|
|
66
|
+
const text = textOn(background);
|
|
67
|
+
if (!rgb) return text;
|
|
68
|
+
const target = text === DARK_TEXT ? INK : WHITE;
|
|
69
|
+
for (const alpha of [0.6, 0.7, 0.8, 0.9]) {
|
|
70
|
+
const blended = rgb.map((channel, index) => Math.round(channel + (target[index]! - channel) * alpha)) as Rgb;
|
|
71
|
+
if (contrastRatio(blended, rgb) >= 4.5) return hex(blended);
|
|
72
|
+
}
|
|
73
|
+
return text;
|
|
74
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Image, Linking, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
|
3
|
+
import { attachmentName, isImageAttachment, previewBoxSize, type GiovAttachment } from "../attachments";
|
|
4
|
+
import { useGiovChatStrings } from "../i18n";
|
|
5
|
+
|
|
6
|
+
export interface AttachmentListProps {
|
|
7
|
+
attachments: GiovAttachment[];
|
|
8
|
+
/** Adds the download credential to a stored attachment URL. */
|
|
9
|
+
resolveUrl: (url: string) => string;
|
|
10
|
+
dark: boolean;
|
|
11
|
+
/** The bubble's text colour, for file rows. */
|
|
12
|
+
textColor: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function open(url: string) {
|
|
16
|
+
if (/^https?:\/\//i.test(url)) void Linking.openURL(url).catch(() => undefined);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Images inline in a box of their final shape (the placeholder shows until they load); other files as rows. */
|
|
20
|
+
export function AttachmentList({ attachments, resolveUrl, dark, textColor }: AttachmentListProps) {
|
|
21
|
+
const strings = useGiovChatStrings();
|
|
22
|
+
return (
|
|
23
|
+
<View style={styles.list}>
|
|
24
|
+
{attachments.map((attachment, index) => {
|
|
25
|
+
const url = resolveUrl(attachment.url);
|
|
26
|
+
const name = attachmentName(attachment) || (isImageAttachment(attachment) ? strings.image : strings.attachment);
|
|
27
|
+
return isImageAttachment(attachment)
|
|
28
|
+
? <AttachmentImage key={`${attachment.url}-${index}`} attachment={attachment} url={url} name={name} dark={dark} />
|
|
29
|
+
: (
|
|
30
|
+
<TouchableOpacity
|
|
31
|
+
key={`${attachment.url}-${index}`}
|
|
32
|
+
style={[styles.file, { borderColor: dark ? "rgba(255,255,255,0.18)" : "rgba(0,0,0,0.12)" }]}
|
|
33
|
+
onPress={() => open(url)}
|
|
34
|
+
accessibilityRole="link"
|
|
35
|
+
accessibilityLabel={strings.openAttachment(name)}
|
|
36
|
+
>
|
|
37
|
+
<Text style={[styles.fileIcon, { color: textColor }]}>⎙</Text>
|
|
38
|
+
<Text style={[styles.fileName, { color: textColor }]} numberOfLines={1}>{name}</Text>
|
|
39
|
+
</TouchableOpacity>
|
|
40
|
+
);
|
|
41
|
+
})}
|
|
42
|
+
</View>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function AttachmentImage({ attachment, url, name, dark }: { attachment: GiovAttachment; url: string; name: string; dark: boolean }) {
|
|
47
|
+
const strings = useGiovChatStrings();
|
|
48
|
+
const [loaded, setLoaded] = useState(false);
|
|
49
|
+
// A failed load is tied to its URL: a renewed credential gives a new URL, and the image loads again.
|
|
50
|
+
const [failedUrl, setFailedUrl] = useState<string | null>(null);
|
|
51
|
+
const failed = failedUrl === url;
|
|
52
|
+
const box = previewBoxSize(attachment.preview) ?? { width: 200, height: 150 };
|
|
53
|
+
return (
|
|
54
|
+
<TouchableOpacity onPress={() => open(url)} accessibilityRole="imagebutton" accessibilityLabel={strings.openAttachment(name)}>
|
|
55
|
+
<View style={[styles.imageBox, box, { backgroundColor: dark ? "#1f1f1f" : "#ececec" }]}>
|
|
56
|
+
{attachment.preview?.thumb && !loaded && (
|
|
57
|
+
<Image source={{ uri: attachment.preview.thumb }} style={styles.fill} blurRadius={6} accessible={false} />
|
|
58
|
+
)}
|
|
59
|
+
<Image key={url} source={{ uri: url }} style={styles.fill} resizeMode="cover" accessible={false}
|
|
60
|
+
onLoad={() => { setLoaded(true); setFailedUrl(null); }} onError={() => setFailedUrl(url)} />
|
|
61
|
+
{failed && <Text style={[styles.unavailable, { color: dark ? "#d4d4d4" : "#404040" }]}>{strings.imageUnavailable}</Text>}
|
|
62
|
+
</View>
|
|
63
|
+
</TouchableOpacity>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const styles = StyleSheet.create({
|
|
68
|
+
list: { gap: 6, marginBottom: 4 },
|
|
69
|
+
imageBox: { borderRadius: 10, overflow: "hidden" },
|
|
70
|
+
fill: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 },
|
|
71
|
+
unavailable: { position: "absolute", left: 8, right: 8, bottom: 8, fontSize: 12, textAlign: "center" },
|
|
72
|
+
file: { flexDirection: "row", alignItems: "center", gap: 8, borderWidth: 1, borderRadius: 10, paddingHorizontal: 10, paddingVertical: 8, maxWidth: 240 },
|
|
73
|
+
fileIcon: { fontSize: 16 },
|
|
74
|
+
fileName: { fontSize: 13, flexShrink: 1, textDecorationLine: "underline" },
|
|
75
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Linking, StyleSheet, Text, TouchableOpacity } from "react-native";
|
|
3
|
+
|
|
4
|
+
/** Deliberately isolated from customizable styles, including the global font. */
|
|
5
|
+
export function Branding({ showBranding }: { showBranding?: boolean }) {
|
|
6
|
+
if (showBranding === false) return null;
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<TouchableOpacity
|
|
10
|
+
style={styles.container}
|
|
11
|
+
accessibilityRole="link"
|
|
12
|
+
accessibilityLabel="Powered by GiovChat"
|
|
13
|
+
onPress={() => {
|
|
14
|
+
void Linking.openURL("https://giovchat.app/?utm_source=react_native_widget&utm_medium=powered_by")
|
|
15
|
+
.catch(() => {});
|
|
16
|
+
}}
|
|
17
|
+
>
|
|
18
|
+
<Text style={styles.text}>Powered by <Text style={styles.brand}>GiovChat</Text></Text>
|
|
19
|
+
</TouchableOpacity>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const styles = StyleSheet.create({
|
|
24
|
+
container: {
|
|
25
|
+
flexShrink: 0,
|
|
26
|
+
alignItems: "center",
|
|
27
|
+
paddingVertical: 12,
|
|
28
|
+
paddingHorizontal: 16,
|
|
29
|
+
backgroundColor: "#fff",
|
|
30
|
+
borderTopWidth: StyleSheet.hairlineWidth,
|
|
31
|
+
borderTopColor: "#ebebeb",
|
|
32
|
+
},
|
|
33
|
+
text: { color: "#52525b", fontSize: 12 },
|
|
34
|
+
brand: { color: "#7c3aed", fontWeight: "700" },
|
|
35
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import React, { memo, useMemo, type ReactNode } from "react";
|
|
2
|
+
import { Linking, Platform, StyleSheet, Text, View, type StyleProp, type TextStyle } from "react-native";
|
|
3
|
+
import { isOpenableHref, parseChatMarkdown, type Block, type InlineLine, type InlineNode, type ListBlock } from "../markdown";
|
|
4
|
+
|
|
5
|
+
export interface ChatMarkdownProps {
|
|
6
|
+
content: string;
|
|
7
|
+
/** The bubble's text style (colour, font); every run carries it so overrides reach each word. */
|
|
8
|
+
textStyle: StyleProp<TextStyle>;
|
|
9
|
+
/** Quote bars and rules: a translucent tone of the text colour reads on any bubble. */
|
|
10
|
+
accentColor: string;
|
|
11
|
+
codeBackground: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MONOSPACE = Platform.OS === "ios" ? "Menlo" : "monospace";
|
|
15
|
+
|
|
16
|
+
function openLink(href: string | undefined) {
|
|
17
|
+
if (isOpenableHref(href)) void Linking.openURL(href).catch(() => undefined);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function inlineStyle(node: InlineNode, codeBackground: string): TextStyle {
|
|
21
|
+
const style: TextStyle = {};
|
|
22
|
+
if (node.marks.bold) style.fontWeight = "700";
|
|
23
|
+
if (node.marks.italic) style.fontStyle = "italic";
|
|
24
|
+
const lines = [node.marks.underline || node.href ? "underline" : null, node.marks.strike ? "line-through" : null].filter(Boolean);
|
|
25
|
+
if (lines.length) style.textDecorationLine = lines.join(" ") as TextStyle["textDecorationLine"];
|
|
26
|
+
if (node.marks.code) {
|
|
27
|
+
style.fontFamily = MONOSPACE;
|
|
28
|
+
style.backgroundColor = codeBackground;
|
|
29
|
+
style.fontSize = 13;
|
|
30
|
+
}
|
|
31
|
+
return style;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function renderLines(lines: InlineLine[], textStyle: StyleProp<TextStyle>, codeBackground: string, keyPrefix: string): ReactNode[] {
|
|
35
|
+
return lines.flatMap((line, lineIndex) => [
|
|
36
|
+
lineIndex > 0 ? "\n" : null,
|
|
37
|
+
...line.map((node, index) => (
|
|
38
|
+
<Text
|
|
39
|
+
accessibilityRole={node.href ? "link" : undefined}
|
|
40
|
+
key={`${keyPrefix}-${lineIndex}-${index}`}
|
|
41
|
+
onPress={node.href ? () => openLink(node.href) : undefined}
|
|
42
|
+
style={[textStyle, inlineStyle(node, codeBackground)]}
|
|
43
|
+
>
|
|
44
|
+
{node.text}
|
|
45
|
+
</Text>
|
|
46
|
+
)),
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ChatMarkdownImpl({ content, textStyle, accentColor, codeBackground }: ChatMarkdownProps) {
|
|
51
|
+
const blocks = useMemo(() => parseChatMarkdown(content), [content]);
|
|
52
|
+
|
|
53
|
+
const renderList = (list: ListBlock, key: string): ReactNode => (
|
|
54
|
+
<View key={key} style={styles.list}>
|
|
55
|
+
{list.items.map((item, index) => (
|
|
56
|
+
<View key={`${key}-${index}`} style={styles.listItem}>
|
|
57
|
+
<Text style={[textStyle, styles.marker]}>{list.ordered ? `${list.start + index}.` : "•"}</Text>
|
|
58
|
+
<View style={styles.listBody}>
|
|
59
|
+
<Text style={textStyle}>{renderLines(item.lines, textStyle, codeBackground, `${key}-${index}`)}</Text>
|
|
60
|
+
{item.children.map((child, childIndex) => renderList(child, `${key}-${index}-${childIndex}`))}
|
|
61
|
+
</View>
|
|
62
|
+
</View>
|
|
63
|
+
))}
|
|
64
|
+
</View>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const renderBlock = (block: Block, index: number): ReactNode => {
|
|
68
|
+
const key = `b${index}`;
|
|
69
|
+
switch (block.type) {
|
|
70
|
+
case "paragraph":
|
|
71
|
+
return <Text key={key} style={textStyle}>{renderLines(block.lines, textStyle, codeBackground, key)}</Text>;
|
|
72
|
+
case "heading":
|
|
73
|
+
return <Text key={key} style={[textStyle, styles.heading]} accessibilityRole="header">{renderLines([block.content], [textStyle, styles.heading], codeBackground, key)}</Text>;
|
|
74
|
+
case "code":
|
|
75
|
+
return (
|
|
76
|
+
<View key={key} style={[styles.codeBlock, { backgroundColor: codeBackground }]}>
|
|
77
|
+
<Text style={[textStyle, styles.codeText]}>{block.text}</Text>
|
|
78
|
+
</View>
|
|
79
|
+
);
|
|
80
|
+
case "rule":
|
|
81
|
+
return <View key={key} style={[styles.rule, { backgroundColor: accentColor }]} />;
|
|
82
|
+
case "quote":
|
|
83
|
+
return (
|
|
84
|
+
<View key={key} style={[styles.quote, { borderLeftColor: accentColor }]}>
|
|
85
|
+
<Text style={[textStyle, styles.quoteText]}>{renderLines(block.lines, textStyle, codeBackground, key)}</Text>
|
|
86
|
+
</View>
|
|
87
|
+
);
|
|
88
|
+
case "list":
|
|
89
|
+
return renderList(block, key);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return <View style={styles.root}>{blocks.map(renderBlock)}</View>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Chat Markdown (the widget and web inbox grammar) as native text; links open through Linking. */
|
|
97
|
+
export const ChatMarkdown = memo(ChatMarkdownImpl);
|
|
98
|
+
|
|
99
|
+
const styles = StyleSheet.create({
|
|
100
|
+
root: { gap: 4 },
|
|
101
|
+
heading: { fontWeight: "700" },
|
|
102
|
+
list: { gap: 2 },
|
|
103
|
+
listItem: { flexDirection: "row", gap: 6 },
|
|
104
|
+
marker: { minWidth: 14 },
|
|
105
|
+
listBody: { flex: 1, gap: 2 },
|
|
106
|
+
codeBlock: { borderRadius: 8, paddingHorizontal: 8, paddingVertical: 6 },
|
|
107
|
+
codeText: { fontFamily: MONOSPACE, fontSize: 13, lineHeight: 18 },
|
|
108
|
+
rule: { height: StyleSheet.hairlineWidth, marginVertical: 4, opacity: 0.6 },
|
|
109
|
+
quote: { borderLeftWidth: 3, paddingLeft: 8 },
|
|
110
|
+
quoteText: { opacity: 0.85 },
|
|
111
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Image } from "react-native";
|
|
3
|
+
import { giovBotMarkDataUri } from "../brand-assets";
|
|
4
|
+
|
|
5
|
+
/** Compact native fallback rasterized from the canonical GiovBot vector artwork. */
|
|
6
|
+
export function GiovBotMark({ size = 28 }: { size?: number }) {
|
|
7
|
+
return (
|
|
8
|
+
<Image
|
|
9
|
+
source={{ uri: giovBotMarkDataUri, width: 64, height: 64 }}
|
|
10
|
+
style={{ width: size, height: size, flexShrink: 0, marginTop: 2 }}
|
|
11
|
+
resizeMode="contain"
|
|
12
|
+
accessible={false}
|
|
13
|
+
accessibilityElementsHidden
|
|
14
|
+
importantForAccessibility="no"
|
|
15
|
+
accessibilityIgnoresInvertColors
|
|
16
|
+
testID="giovbot-mark"
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text, View } from "react-native";
|
|
3
|
+
import { bubbleStyles as s } from "./bubbleStyles";
|
|
4
|
+
import { useWidgetStyles } from "./WidgetStyles";
|
|
5
|
+
|
|
6
|
+
export interface GreetingBubbleProps {
|
|
7
|
+
text: string;
|
|
8
|
+
dark: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function GreetingBubble({ text, dark }: GreetingBubbleProps) {
|
|
12
|
+
const custom = useWidgetStyles();
|
|
13
|
+
return (
|
|
14
|
+
<View style={[s.wrapper, s.left, { marginTop: 16 }]}>
|
|
15
|
+
<View style={[s.bubble, s.agentBubble, dark && s.agentBubbleDark, custom.agentBubble]}>
|
|
16
|
+
<Text style={[s.content, { color: dark ? "#f0f0f0" : "#111" }, custom.agentText]}>{text}</Text>
|
|
17
|
+
</View>
|
|
18
|
+
</View>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Image, Linking, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
|
3
|
+
import { useWidgetStyles } from "./WidgetStyles";
|
|
4
|
+
import { GiovBotMark } from "./GiovBotMark";
|
|
5
|
+
import { useGiovChatStrings } from "../i18n";
|
|
6
|
+
import type { HeaderIdentityKind } from "../identity";
|
|
7
|
+
import { isLightBackground, secondaryTextOn, textOn } from "../color";
|
|
8
|
+
|
|
9
|
+
export interface HeaderProps {
|
|
10
|
+
title: string;
|
|
11
|
+
subtitle?: string | null;
|
|
12
|
+
primaryColor: string;
|
|
13
|
+
onClose?: () => void;
|
|
14
|
+
/** Photo for the person or bot answering (http(s) only). */
|
|
15
|
+
avatarUrl?: string | null;
|
|
16
|
+
/** Who is answering; a bot without its own avatar shows the GiovBot mark. */
|
|
17
|
+
kind?: HeaderIdentityKind;
|
|
18
|
+
/** The workspace's privacy policy (https); the menu links to Giov's when there is none, as the web widget does. */
|
|
19
|
+
privacyPolicyUrl?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The web widget's fallback (apps/widget-web/src/components/ChatMenu.tsx). */
|
|
23
|
+
export const DEFAULT_PRIVACY_POLICY_URL = "https://giovchat.app/privacy";
|
|
24
|
+
|
|
25
|
+
export function Header({ title, subtitle, primaryColor, onClose, avatarUrl, kind, privacyPolicyUrl }: HeaderProps) {
|
|
26
|
+
const custom = useWidgetStyles();
|
|
27
|
+
const strings = useGiovChatStrings();
|
|
28
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
29
|
+
// Black or white on the brand colour by WCAG contrast; the host's text colours still win.
|
|
30
|
+
const background = custom.header.backgroundColor ?? primaryColor;
|
|
31
|
+
const ink = textOn(background);
|
|
32
|
+
return (
|
|
33
|
+
<View style={[styles.container, { backgroundColor: primaryColor }, custom.header]}>
|
|
34
|
+
<View style={[styles.avatar, isLightBackground(background) && styles.avatarOnLight]}>
|
|
35
|
+
{avatarUrl ? (
|
|
36
|
+
<Image source={{ uri: avatarUrl }} style={styles.avatarImage} accessible={false} />
|
|
37
|
+
) : kind === "bot" ? (
|
|
38
|
+
<GiovBotMark size={26} />
|
|
39
|
+
) : (
|
|
40
|
+
<Text style={[styles.avatarLetter, { color: ink }, custom.headerTitle]}>{title.charAt(0).toUpperCase()}</Text>
|
|
41
|
+
)}
|
|
42
|
+
</View>
|
|
43
|
+
<View style={styles.info}>
|
|
44
|
+
<Text style={[styles.title, { color: ink }, custom.headerTitle]} numberOfLines={1} accessibilityRole="header">
|
|
45
|
+
{title}
|
|
46
|
+
</Text>
|
|
47
|
+
{subtitle ? (
|
|
48
|
+
<Text style={[styles.subtitle, { color: secondaryTextOn(background) }, custom.headerSubtitle]} numberOfLines={1}>
|
|
49
|
+
{subtitle}
|
|
50
|
+
</Text>
|
|
51
|
+
) : null}
|
|
52
|
+
</View>
|
|
53
|
+
<TouchableOpacity
|
|
54
|
+
onPress={() => setMenuOpen((open) => !open)}
|
|
55
|
+
hitSlop={{ top: 12, bottom: 12, left: 8, right: 8 }}
|
|
56
|
+
accessibilityLabel={strings.menu}
|
|
57
|
+
accessibilityRole="button"
|
|
58
|
+
accessibilityState={{ expanded: menuOpen }}
|
|
59
|
+
>
|
|
60
|
+
<Text style={[styles.closeIcon, { color: custom.headerTitle.color ?? ink }]}>⋯</Text>
|
|
61
|
+
</TouchableOpacity>
|
|
62
|
+
{menuOpen && (
|
|
63
|
+
<View style={styles.menu}>
|
|
64
|
+
<TouchableOpacity
|
|
65
|
+
style={styles.menuItem}
|
|
66
|
+
accessibilityRole="link"
|
|
67
|
+
accessibilityLabel={strings.privacyPolicy}
|
|
68
|
+
onPress={() => {
|
|
69
|
+
setMenuOpen(false);
|
|
70
|
+
void Linking.openURL(privacyPolicyUrl ?? DEFAULT_PRIVACY_POLICY_URL).catch(() => undefined);
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<Text style={styles.menuText}>{strings.privacyPolicy}</Text>
|
|
74
|
+
</TouchableOpacity>
|
|
75
|
+
</View>
|
|
76
|
+
)}
|
|
77
|
+
{onClose && (
|
|
78
|
+
<TouchableOpacity
|
|
79
|
+
onPress={onClose}
|
|
80
|
+
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
|
81
|
+
accessibilityLabel={strings.closeChat}
|
|
82
|
+
accessibilityRole="button"
|
|
83
|
+
>
|
|
84
|
+
<Text style={[styles.closeIcon, { color: custom.headerTitle.color ?? ink }]}>✕</Text>
|
|
85
|
+
</TouchableOpacity>
|
|
86
|
+
)}
|
|
87
|
+
</View>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const styles = StyleSheet.create({
|
|
92
|
+
container: {
|
|
93
|
+
flexDirection: "row",
|
|
94
|
+
alignItems: "center",
|
|
95
|
+
paddingHorizontal: 16,
|
|
96
|
+
paddingVertical: 14,
|
|
97
|
+
gap: 12,
|
|
98
|
+
zIndex: 2,
|
|
99
|
+
},
|
|
100
|
+
menu: {
|
|
101
|
+
position: "absolute",
|
|
102
|
+
top: "100%",
|
|
103
|
+
right: 12,
|
|
104
|
+
backgroundColor: "#fff",
|
|
105
|
+
borderRadius: 10,
|
|
106
|
+
paddingVertical: 4,
|
|
107
|
+
shadowColor: "#000",
|
|
108
|
+
shadowOpacity: 0.15,
|
|
109
|
+
shadowRadius: 12,
|
|
110
|
+
shadowOffset: { width: 0, height: 4 },
|
|
111
|
+
elevation: 6,
|
|
112
|
+
},
|
|
113
|
+
menuItem: { paddingHorizontal: 14, paddingVertical: 10 },
|
|
114
|
+
menuText: { color: "#111", fontSize: 14 },
|
|
115
|
+
avatar: {
|
|
116
|
+
width: 38,
|
|
117
|
+
height: 38,
|
|
118
|
+
borderRadius: 19,
|
|
119
|
+
backgroundColor: "rgba(255,255,255,0.22)",
|
|
120
|
+
alignItems: "center",
|
|
121
|
+
justifyContent: "center",
|
|
122
|
+
overflow: "hidden",
|
|
123
|
+
},
|
|
124
|
+
avatarOnLight: { backgroundColor: "rgba(0,0,0,0.1)" },
|
|
125
|
+
avatarImage: { width: 38, height: 38 },
|
|
126
|
+
avatarLetter: { color: "#fff", fontWeight: "700", fontSize: 17 },
|
|
127
|
+
info: { flex: 1 },
|
|
128
|
+
title: { color: "#fff", fontWeight: "700", fontSize: 16 },
|
|
129
|
+
subtitle: { color: "rgba(255,255,255,0.78)", fontSize: 12, marginTop: 2 },
|
|
130
|
+
closeIcon: { color: "#fff", fontSize: 18, fontWeight: "300", lineHeight: 22 },
|
|
131
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text, TouchableOpacity, View } from "react-native";
|
|
3
|
+
import type { GiovMessage } from "../types";
|
|
4
|
+
import { bubbleStyles as s } from "./bubbleStyles";
|
|
5
|
+
import { useWidgetStyles } from "./WidgetStyles";
|
|
6
|
+
import { GiovBotMark } from "./GiovBotMark";
|
|
7
|
+
import { ChatMarkdown } from "./ChatMarkdown";
|
|
8
|
+
import { AttachmentList } from "./Attachments";
|
|
9
|
+
import { messageSenderLabel } from "../identity";
|
|
10
|
+
import { useGiovChatStrings } from "../i18n";
|
|
11
|
+
import { isLightBackground, secondaryTextOn, textOn } from "../color";
|
|
12
|
+
|
|
13
|
+
export interface MessageBubbleProps {
|
|
14
|
+
message: GiovMessage;
|
|
15
|
+
primaryColor: string;
|
|
16
|
+
dark: boolean;
|
|
17
|
+
botName?: string;
|
|
18
|
+
/** Adds the download credential to attachment URLs. Without it, attachments load as stored. */
|
|
19
|
+
resolveAttachmentUrl?: (url: string) => string;
|
|
20
|
+
/** Offered on a visitor message that failed to send. */
|
|
21
|
+
onRetry?: (message: GiovMessage) => void;
|
|
22
|
+
/** Drops a visitor message that failed to send (a refused one can never succeed on retry). */
|
|
23
|
+
onDiscard?: (message: GiovMessage) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const identity = (url: string) => url;
|
|
27
|
+
|
|
28
|
+
export function MessageBubble({ message, primaryColor, dark, botName, resolveAttachmentUrl = identity, onRetry, onDiscard }: MessageBubbleProps) {
|
|
29
|
+
const custom = useWidgetStyles();
|
|
30
|
+
const strings = useGiovChatStrings();
|
|
31
|
+
const isVisitor = message.role === "visitor";
|
|
32
|
+
const isBot = message.role === "agent" && message.isBot === true;
|
|
33
|
+
const textStyle = isVisitor ? custom.visitorText : custom.agentText;
|
|
34
|
+
// Text on the brand colour is black or white by WCAG contrast; labels and times keep 4.5:1 on their bubble.
|
|
35
|
+
const bubbleColor = isVisitor ? custom.visitorBubble.backgroundColor ?? primaryColor : custom.agentBubble.backgroundColor ?? (dark ? "#242424" : "#f0f0f0");
|
|
36
|
+
const lightVisitor = isVisitor && isLightBackground(bubbleColor);
|
|
37
|
+
const textColor = isVisitor ? textOn(bubbleColor) : dark ? "#f0f0f0" : "#111";
|
|
38
|
+
const quietColor = secondaryTextOn(bubbleColor);
|
|
39
|
+
const failed = isVisitor && message.status === "failed";
|
|
40
|
+
|
|
41
|
+
const senderLabel = messageSenderLabel(message, { botName, you: strings.you, automated: strings.automated, agent: strings.agent });
|
|
42
|
+
|
|
43
|
+
const time = new Date(message.createdAt).toLocaleTimeString([], {
|
|
44
|
+
hour: "2-digit",
|
|
45
|
+
minute: "2-digit",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<View style={[s.wrapper, isVisitor ? s.right : s.left, isBot && s.assistantRow]}>
|
|
50
|
+
{isBot && <GiovBotMark />}
|
|
51
|
+
<View
|
|
52
|
+
style={[
|
|
53
|
+
s.bubble,
|
|
54
|
+
isVisitor
|
|
55
|
+
? [s.visitorBubble, { backgroundColor: primaryColor }]
|
|
56
|
+
: [s.agentBubble, dark && s.agentBubbleDark],
|
|
57
|
+
isVisitor ? custom.visitorBubble : custom.agentBubble,
|
|
58
|
+
message.status === "sending" && { opacity: 0.75 },
|
|
59
|
+
]}
|
|
60
|
+
accessibilityState={message.isStreaming ? { busy: true } : undefined}
|
|
61
|
+
>
|
|
62
|
+
<Text
|
|
63
|
+
style={[
|
|
64
|
+
s.sender,
|
|
65
|
+
{ color: quietColor },
|
|
66
|
+
textStyle,
|
|
67
|
+
]}
|
|
68
|
+
>
|
|
69
|
+
{senderLabel}
|
|
70
|
+
</Text>
|
|
71
|
+
{message.attachments?.length ? (
|
|
72
|
+
<AttachmentList attachments={message.attachments} resolveUrl={resolveAttachmentUrl} dark={isVisitor ? !lightVisitor : dark} textColor={typeof textStyle.color === "string" ? textStyle.color : textColor} />
|
|
73
|
+
) : null}
|
|
74
|
+
{message.content ? (
|
|
75
|
+
<ChatMarkdown
|
|
76
|
+
content={message.content}
|
|
77
|
+
textStyle={[s.content, { color: textColor }, textStyle]}
|
|
78
|
+
accentColor={isVisitor ? (lightVisitor ? "rgba(0,0,0,0.35)" : "rgba(255,255,255,0.5)") : dark ? "rgba(255,255,255,0.3)" : "rgba(0,0,0,0.2)"}
|
|
79
|
+
codeBackground={isVisitor ? (lightVisitor ? "rgba(0,0,0,0.08)" : "rgba(255,255,255,0.18)") : dark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.06)"}
|
|
80
|
+
/>
|
|
81
|
+
) : null}
|
|
82
|
+
{!message.isStreaming && (
|
|
83
|
+
<Text
|
|
84
|
+
style={[
|
|
85
|
+
s.timestamp,
|
|
86
|
+
{ color: quietColor },
|
|
87
|
+
textStyle,
|
|
88
|
+
]}
|
|
89
|
+
>
|
|
90
|
+
{message.status === "sending" ? strings.sending : time}
|
|
91
|
+
</Text>
|
|
92
|
+
)}
|
|
93
|
+
</View>
|
|
94
|
+
{failed && (
|
|
95
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, marginTop: 4 }}>
|
|
96
|
+
<TouchableOpacity
|
|
97
|
+
onPress={onRetry ? () => onRetry(message) : undefined}
|
|
98
|
+
disabled={!onRetry}
|
|
99
|
+
accessibilityRole="button"
|
|
100
|
+
accessibilityLabel={`${strings.notSent}. ${strings.tapToRetry}`}
|
|
101
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
102
|
+
>
|
|
103
|
+
<Text style={{ color: "#dc2626", fontSize: 12 }}>
|
|
104
|
+
{strings.notSent}{onRetry ? ` · ${strings.tapToRetry}` : ""}
|
|
105
|
+
</Text>
|
|
106
|
+
</TouchableOpacity>
|
|
107
|
+
{onDiscard && (
|
|
108
|
+
<TouchableOpacity
|
|
109
|
+
onPress={() => onDiscard(message)}
|
|
110
|
+
accessibilityRole="button"
|
|
111
|
+
accessibilityLabel={strings.discard}
|
|
112
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
113
|
+
>
|
|
114
|
+
<Text style={{ color: dark ? "#999" : "#666", fontSize: 12 }}>{strings.discard}</Text>
|
|
115
|
+
</TouchableOpacity>
|
|
116
|
+
)}
|
|
117
|
+
</View>
|
|
118
|
+
)}
|
|
119
|
+
</View>
|
|
120
|
+
);
|
|
121
|
+
}
|