@giovchat/react-native 0.2.0 → 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.
Files changed (73) hide show
  1. package/README.md +66 -9
  2. package/dist/GiovChat.d.ts +35 -0
  3. package/dist/GiovChat.d.ts.map +1 -1
  4. package/dist/GiovChat.js +45 -8
  5. package/dist/GiovChat.js.map +1 -1
  6. package/dist/brandingStyle.d.ts +38 -0
  7. package/dist/brandingStyle.d.ts.map +1 -0
  8. package/dist/brandingStyle.js +71 -0
  9. package/dist/brandingStyle.js.map +1 -0
  10. package/dist/client.d.ts +38 -2
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +91 -1
  13. package/dist/client.js.map +1 -1
  14. package/dist/components/Branding.d.ts +24 -1
  15. package/dist/components/Branding.d.ts.map +1 -1
  16. package/dist/components/Branding.js +49 -6
  17. package/dist/components/Branding.js.map +1 -1
  18. package/dist/components/MessageBubble.d.ts +8 -1
  19. package/dist/components/MessageBubble.d.ts.map +1 -1
  20. package/dist/components/MessageBubble.js +19 -17
  21. package/dist/components/MessageBubble.js.map +1 -1
  22. package/dist/components/RatingCard.d.ts +23 -0
  23. package/dist/components/RatingCard.d.ts.map +1 -0
  24. package/dist/components/RatingCard.js +107 -0
  25. package/dist/components/RatingCard.js.map +1 -0
  26. package/dist/errors.d.ts +3 -1
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js.map +1 -1
  29. package/dist/i18n.d.ts +23 -0
  30. package/dist/i18n.d.ts.map +1 -1
  31. package/dist/i18n.js +100 -0
  32. package/dist/i18n.js.map +1 -1
  33. package/dist/index.d.ts +12 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +19 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/protocol.d.ts.map +1 -1
  38. package/dist/protocol.js +5 -0
  39. package/dist/protocol.js.map +1 -1
  40. package/dist/rating.d.ts +41 -0
  41. package/dist/rating.d.ts.map +1 -0
  42. package/dist/rating.js +165 -0
  43. package/dist/rating.js.map +1 -0
  44. package/dist/ratingStore.d.ts +36 -0
  45. package/dist/ratingStore.d.ts.map +1 -0
  46. package/dist/ratingStore.js +55 -0
  47. package/dist/ratingStore.js.map +1 -0
  48. package/dist/styleOverrides.d.ts +10 -2
  49. package/dist/styleOverrides.d.ts.map +1 -1
  50. package/dist/styleOverrides.js +9 -0
  51. package/dist/styleOverrides.js.map +1 -1
  52. package/dist/types.d.ts +45 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/useChatSession.d.ts +18 -1
  55. package/dist/useChatSession.d.ts.map +1 -1
  56. package/dist/useChatSession.js +178 -9
  57. package/dist/useChatSession.js.map +1 -1
  58. package/package.json +3 -2
  59. package/src/GiovChat.tsx +121 -11
  60. package/src/brandingStyle.ts +87 -0
  61. package/src/client.ts +92 -2
  62. package/src/components/Branding.tsx +66 -7
  63. package/src/components/MessageBubble.tsx +17 -1
  64. package/src/components/RatingCard.tsx +219 -0
  65. package/src/errors.ts +4 -1
  66. package/src/i18n.ts +123 -0
  67. package/src/index.ts +17 -0
  68. package/src/protocol.ts +5 -0
  69. package/src/rating.ts +169 -0
  70. package/src/ratingStore.ts +65 -0
  71. package/src/styleOverrides.ts +15 -2
  72. package/src/types.ts +51 -0
  73. package/src/useChatSession.ts +152 -6
package/src/GiovChat.tsx CHANGED
@@ -42,6 +42,9 @@ import {
42
42
  } from "./attachments";
43
43
  import type { GiovPushOptions, GiovPushPlatform, GiovPushRegistration } from "./client";
44
44
  import { forgetUnreadCount, publishUnreadCount } from "./unread";
45
+ import { RatingCard } from "./components/RatingCard";
46
+ import { forgetRating, publishRating } from "./ratingStore";
47
+ import type { GiovAnswerFeedback, GiovFeedbackValue, GiovRatingRequest, GiovRatingResult } from "./types";
45
48
 
46
49
  // ─── Public prop types ─────────────────────────────────────────────────────
47
50
 
@@ -125,9 +128,39 @@ export interface GiovChatProps {
125
128
  sessionStorage?: GiovChatSessionStorage;
126
129
  onError?: (error: GiovChatError) => void;
127
130
  onConnectionStatusChange?: (status: GiovChatConnectionStatus) => void;
131
+
132
+ /**
133
+ * Draw the conversation rating card ("How did we do?") in the chat. Turn it off to build your own with
134
+ * `useGiovRating()` or the handle (`submitRating`, `showRating`).
135
+ * @default true
136
+ */
137
+ ratingCard?: boolean;
138
+ /**
139
+ * Thumbs under GiovBot answers (hidden on servers without them).
140
+ * @default true
141
+ */
142
+ answerFeedback?: boolean;
143
+ /** The team or GiovBot asked the visitor to rate the conversation (once per card, live or from history). */
144
+ onRatingRequested?: (request: GiovRatingRequest) => void;
145
+ /** The visitor's rating was stored. */
146
+ onRatingSubmitted?: (result: GiovRatingResult) => void;
147
+ /** The visitor's vote on a GiovBot answer was stored. */
148
+ onAnswerFeedback?: (feedback: GiovAnswerFeedback) => void;
128
149
  }
129
150
 
130
151
  export interface GiovChatHandle {
152
+ /**
153
+ * Rates the conversation (`requestId` defaults to the open card). Rejects with a `GiovChatError`
154
+ * (`rating_closed`, `request_replaced`, `too_many_changes`, `invalid_rating`, `rate_limited` with
155
+ * `retryAfterSeconds`, `unsupported` on an older server).
156
+ */
157
+ submitRating(score: number, options?: { comment?: string; requestId?: string }): Promise<GiovRatingResult>;
158
+ /** Thumbs up or down (with an optional reason for down) on a GiovBot answer; `none` takes the vote back. */
159
+ submitAnswerFeedback(messageId: string, value: GiovFeedbackValue, options?: { reason?: string }): Promise<void>;
160
+ /** The rating card the visitor can answer or change now, or null. */
161
+ getRatingRequest(): GiovRatingRequest | null;
162
+ /** Opens the built-in rating sheet for the open card, over the chat or on its own. False when there is no card. */
163
+ showRating(): boolean;
131
164
  /** Clears this identity's stored session and starts fresh if the chat is visible. */
132
165
  reset(): Promise<void>;
133
166
  /**
@@ -198,6 +231,11 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
198
231
  sessionStorage,
199
232
  onError,
200
233
  onConnectionStatusChange,
234
+ ratingCard = true,
235
+ answerFeedback = true,
236
+ onRatingRequested,
237
+ onRatingSubmitted,
238
+ onAnswerFeedback,
201
239
  }: GiovChatProps, ref) {
202
240
  const systemScheme = useColorScheme();
203
241
  const custom = useMemo(() => resolveStyleOverrides(styleOverrides), [styleOverrides]);
@@ -207,18 +245,37 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
207
245
  const chat = useChatSession({
208
246
  tenantKey, baseUrl, visible, contact, scope, sessionKey, sessionStorage, keepConnected,
209
247
  onError, onConnectionStatusChange, onConversationStart, onUnreadCountChange,
248
+ onRatingRequested, onRatingSubmitted, onAnswerFeedback,
210
249
  });
211
250
  const { phase, config, messages, agentTyping, agentTypingIsBot, error, sending, uploads, requiredFields, retry, reset, submitContact, send } = chat;
212
251
  const [input, setInput] = useState("");
213
252
  const [files, setFiles] = useState<GiovChatPickedFile[]>([]);
214
253
  const [fileProblem, setFileProblem] = useState<string | null>(null);
254
+ const [sheetOpen, setSheetOpen] = useState(false);
215
255
  const listRef = useRef<FlatList<GiovMessage>>(null);
216
256
  const unreadOwner = useRef(Symbol("giovchat-unread"));
257
+ // The calls below read the latest state through a ref, so the handle and the hook entry stay stable.
258
+ const live = useRef(chat);
259
+ live.current = chat;
260
+ const ratingCalls = useMemo(() => ({
261
+ submit: (score: number, options?: { comment?: string; requestId?: string }) => live.current.submitRating(score, options),
262
+ submitFeedback: (messageId: string, value: GiovFeedbackValue, options?: { reason?: string }) => live.current.submitAnswerFeedback(messageId, value, options),
263
+ show: () => { if (!live.current.ratingRequest) return false; setSheetOpen(true); return true; },
264
+ }), []);
265
+ const ratingOwner = useRef(Symbol("giovchat-rating"));
266
+ useEffect(() => {
267
+ publishRating(ratingOwner.current, tenantKey, { request: chat.ratingRequest, supported: chat.ratingsSupported, ...ratingCalls });
268
+ }, [tenantKey, chat.ratingRequest, chat.ratingsSupported, ratingCalls]);
269
+ useEffect(() => () => forgetRating(ratingOwner.current), []);
217
270
  useImperativeHandle(ref, () => ({
271
+ submitRating: ratingCalls.submit,
272
+ submitAnswerFeedback: ratingCalls.submitFeedback,
273
+ getRatingRequest: () => live.current.ratingRequest,
274
+ showRating: ratingCalls.show,
218
275
  reset: async () => { setInput(""); setFiles([]); await reset(); },
219
276
  registerPushToken: (token, platform, options) => chat.registerPushToken(token, platform, { ...(locale ? { locale } : {}), ...options }),
220
277
  unregisterPushToken: chat.unregisterPushToken,
221
- }), [reset, chat.registerPushToken, chat.unregisterPushToken, locale]);
278
+ }), [reset, chat.registerPushToken, chat.unregisterPushToken, locale, ratingCalls]);
222
279
  useEffect(() => {
223
280
  const timer = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 80);
224
281
  return () => clearTimeout(timer);
@@ -242,7 +299,7 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
242
299
  const handleClose = useCallback(() => onClose?.(), [onClose]);
243
300
  const policy = config?.attachments ?? DEFAULT_ATTACHMENT_POLICY;
244
301
  // Uploads need a conversation: the first message starts it, then files can follow.
245
- const canAttach = !!attachmentPicker && chat.hasConversation && policy.enabled && policy.allowedTypes.length > 0;
302
+ const canAttach = !!attachmentPicker && chat.hasConversation && !chat.closed && policy.enabled && policy.allowedTypes.length > 0;
246
303
  const composing = phase === "ready" || phase === "connecting" || phase === "active";
247
304
  const canSend = composing && !sending && (!!input.trim() || files.length > 0);
248
305
  const handleSend = async () => {
@@ -265,6 +322,14 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
265
322
  if (problems.length) setFileProblem(problems.map((problem) => problemText(problem, strings)).join(" "));
266
323
  } catch { /* The picker was dismissed or failed; nothing changes. */ }
267
324
  };
325
+ const onRateCard = useCallback(async (requestId: string, score: number, comment: string) => {
326
+ await live.current.submitRating(score, { requestId, comment });
327
+ }, []);
328
+ const onVote = useCallback((messageId: string, value: GiovFeedbackValue, reason?: string) =>
329
+ live.current.submitAnswerFeedback(messageId, value, { reason }), []);
330
+ const sheetMessage = sheetOpen && chat.ratingRequest ? messages.find((message) => message.id === chat.ratingRequest?.messageId) : undefined;
331
+ // The sheet closes by itself once there is no card to show (answered cards stay until dismissed).
332
+ useEffect(() => { if (sheetOpen && !chat.ratingRequest) setSheetOpen(false); }, [sheetOpen, chat.ratingRequest]);
268
333
  const identity = config ? headerIdentity(config, chat.currentAgentName, chat.currentAgentAvatar) : null;
269
334
  const subtitle = phase === "active" || phase === "ready"
270
335
  ? identity?.kind === "person" ? strings.statusPerson : identity?.kind === "bot" ? strings.statusBot : strings.statusTeam
@@ -352,6 +417,9 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
352
417
  resolveAttachmentUrl={chat.attachmentUrl}
353
418
  onRetry={(message) => message.clientMessageId && chat.retryMessage(message.clientMessageId)}
354
419
  onDiscard={(message) => message.clientMessageId && chat.discardMessage(message.clientMessageId)}
420
+ ratingCard={ratingCard}
421
+ onRate={chat.ratingsSupported ? onRateCard : undefined}
422
+ onVote={answerFeedback && chat.ratingsSupported ? onVote : undefined}
355
423
  />
356
424
  )}
357
425
  />
@@ -384,6 +452,20 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
384
452
  </View>
385
453
  )}
386
454
 
455
+ {chat.closed ? (
456
+ <View style={[styles.closedBar, { borderTopColor: isDark ? "#1e1e1e" : "#ebebeb", backgroundColor: bgColor }, custom.composer]}>
457
+ <Text style={[styles.hint, { color: isDark ? "#aaa" : "#555" }, custom.hint]}>{strings.conversationEnded}</Text>
458
+ <TouchableOpacity
459
+ testID="giov-new-conversation"
460
+ style={[styles.retryBtn, { backgroundColor: primaryColor }, custom.button]}
461
+ onPress={() => { setInput(""); setFiles([]); void chat.startNew(); }}
462
+ accessibilityRole="button"
463
+ accessibilityLabel={strings.startNewConversation}
464
+ >
465
+ <Text style={[styles.retryBtnText, { color: buttonInk }, custom.buttonText]}>{strings.startNewConversation}</Text>
466
+ </TouchableOpacity>
467
+ </View>
468
+ ) : (
387
469
  <View
388
470
  style={[
389
471
  styles.composerRow,
@@ -440,6 +522,7 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
440
522
  <Text style={[styles.sendIcon, { color: buttonInk }, custom.buttonText]}>↑</Text>
441
523
  </TouchableOpacity>
442
524
  </View>
525
+ )}
443
526
  </KeyboardAvoidingView>
444
527
  )}
445
528
  <Branding showBranding={config?.showBranding} />
@@ -448,20 +531,42 @@ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(functio
448
531
  </GiovChatStringsContext.Provider>
449
532
  );
450
533
 
534
+ // The rating sheet is its own Modal beside the chat, so it opens even while the chat is hidden.
535
+ const sheet = sheetMessage?.rating ? (
536
+ <Modal visible transparent animationType="slide" onRequestClose={() => setSheetOpen(false)}>
537
+ <GiovChatStringsContext.Provider value={strings}>
538
+ <View style={styles.sheetScrim}>
539
+ <View testID="giov-rating-sheet" accessibilityViewIsModal style={[styles.sheet, { backgroundColor: bgColor }]}>
540
+ <View style={styles.sheetHeader}>
541
+ <Text accessibilityRole="header" style={[styles.sheetTitle, { color: isDark ? "#f0f0f0" : "#111" }]}>{strings.rateTitle}</Text>
542
+ <TouchableOpacity testID="giov-rating-sheet-close" accessibilityRole="button" accessibilityLabel={strings.closeRating} onPress={() => setSheetOpen(false)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
543
+ <Text style={{ color: isDark ? "#aaa" : "#666", fontSize: 18 }}>✕</Text>
544
+ </TouchableOpacity>
545
+ </View>
546
+ <RatingCard block={sheetMessage.rating} primaryColor={primaryColor} dark={isDark} onRate={chat.ratingsSupported ? onRateCard : undefined} testIDPrefix="giov-sheet-rating" />
547
+ </View>
548
+ </View>
549
+ </GiovChatStringsContext.Provider>
550
+ </Modal>
551
+ ) : null;
552
+
451
553
  if (mode === "modal") {
452
554
  return (
453
- <Modal
454
- visible={visible}
455
- animationType="slide"
456
- presentationStyle="pageSheet"
457
- onRequestClose={handleClose}
458
- >
459
- {chatContent}
460
- </Modal>
555
+ <>
556
+ <Modal
557
+ visible={visible}
558
+ animationType="slide"
559
+ presentationStyle="pageSheet"
560
+ onRequestClose={handleClose}
561
+ >
562
+ {chatContent}
563
+ </Modal>
564
+ {sheet}
565
+ </>
461
566
  );
462
567
  }
463
568
 
464
- return visible ? chatContent : null;
569
+ return <>{visible ? chatContent : null}{sheet}</>;
465
570
  });
466
571
 
467
572
  const styles = StyleSheet.create({
@@ -526,5 +631,10 @@ const styles = StyleSheet.create({
526
631
  justifyContent: "center",
527
632
  },
528
633
  sendBtnOff: { opacity: 0.35 },
634
+ closedBar: { alignItems: "center", gap: 10, padding: 14, borderTopWidth: StyleSheet.hairlineWidth },
635
+ sheetScrim: { flex: 1, justifyContent: "flex-end", backgroundColor: "rgba(0,0,0,0.4)" },
636
+ sheet: { borderTopLeftRadius: 18, borderTopRightRadius: 18, padding: 20, paddingBottom: 32, gap: 8 },
637
+ sheetHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
638
+ sheetTitle: { fontSize: 16, fontWeight: "700" },
529
639
  sendIcon: { color: "#fff", fontSize: 18, fontWeight: "700", lineHeight: 22 },
530
640
  });
@@ -0,0 +1,87 @@
1
+ import { contrastRatio, parseColor, textOn } from "./color";
2
+
3
+ /** The "Powered by GiovChat" footer's style slot. Layout, size and text stay SDK-controlled. */
4
+ export interface GiovChatBrandingStyle {
5
+ /** Font family of the footer. Defaults to the top-level `fontFamily`. */
6
+ fontFamily?: string;
7
+ /** Colour of "Powered by". Must reach 4.5:1 against the footer background. */
8
+ color?: string;
9
+ /** Colour of "GiovChat" (the link). Must reach 4.5:1 against the footer background. */
10
+ brandColor?: string;
11
+ /** Footer background. Opaque colours only. */
12
+ backgroundColor?: string;
13
+ }
14
+
15
+ /** WCAG AA for normal text. */
16
+ export const FOOTER_MIN_CONTRAST = 4.5;
17
+ export const FOOTER_DEFAULTS = { backgroundColor: "#ffffff", color: "#52525b", brandColor: "#7c3aed" } as const;
18
+ /** The footer text is never smaller than this, whatever the app's style. */
19
+ export const FOOTER_FONT_SIZE = 12;
20
+
21
+ export interface ResolvedFooterStyle {
22
+ fontFamily?: string;
23
+ color: string;
24
+ brandColor: string;
25
+ backgroundColor: string;
26
+ /** True when the app changed the background: the divider is then a neutral translucent grey. */
27
+ customBackground: boolean;
28
+ }
29
+
30
+ /** `parseColor`, but null for anything see-through: `transparent`, alpha below 1 (hex, rgba). */
31
+ export function parseOpaqueColor(value: unknown): [number, number, number] | null {
32
+ if (typeof value !== "string") return null;
33
+ const text = value.trim().toLowerCase();
34
+ const hex = /^#([0-9a-f]{4}|[0-9a-f]{8})$/.exec(text)?.[1];
35
+ if (hex) {
36
+ const alpha = hex.length === 4 ? hex[3]! + hex[3]! : hex.slice(6, 8);
37
+ if (parseInt(alpha, 16) !== 255) return null;
38
+ }
39
+ const functional = /^rgba?\(\s*\d{1,3}\s*[, ]\s*\d{1,3}\s*[, ]\s*\d{1,3}\s*(?:[,/]\s*([\d.]+)(%?)\s*)?\)$/.exec(text);
40
+ if (functional?.[1] !== undefined) {
41
+ const alpha = Number(functional[1]) / (functional[2] ? 100 : 1);
42
+ if (!(alpha >= 1)) return null;
43
+ }
44
+ return parseColor(text);
45
+ }
46
+
47
+ /**
48
+ * Applies the app's footer colours under the accessibility rules: an override that is see-through,
49
+ * unreadable (named colours), or below 4.5:1 against the effective background falls back to the
50
+ * SDK's default for that property (or, when the default fails too on the app's own background, to
51
+ * black or white). `warn` receives one message per rejected value.
52
+ */
53
+ export function resolveFooterStyle(
54
+ slot: GiovChatBrandingStyle | undefined,
55
+ warn: (message: string) => void = () => {},
56
+ ): ResolvedFooterStyle {
57
+ let background: string = FOOTER_DEFAULTS.backgroundColor;
58
+ let customBackground = false;
59
+ if (slot?.backgroundColor !== undefined) {
60
+ if (parseOpaqueColor(slot.backgroundColor)) {
61
+ background = slot.backgroundColor;
62
+ customBackground = true;
63
+ } else {
64
+ warn(`GiovChat: branding.backgroundColor "${slot.backgroundColor}" is ignored (use an opaque hex or rgb colour).`);
65
+ }
66
+ }
67
+ const backgroundRgb = parseColor(background)!;
68
+ const readable = (candidate: string) => {
69
+ const rgb = parseColor(candidate);
70
+ return rgb !== null && contrastRatio(rgb, backgroundRgb) >= FOOTER_MIN_CONTRAST;
71
+ };
72
+ const pick = (name: "color" | "brandColor"): string => {
73
+ const wanted = slot?.[name];
74
+ if (wanted !== undefined) {
75
+ if (parseOpaqueColor(wanted) && readable(wanted)) return wanted;
76
+ warn(`GiovChat: branding.${name} "${wanted}" is ignored (an opaque colour with at least ${FOOTER_MIN_CONTRAST}:1 contrast against the footer background is required).`);
77
+ }
78
+ return readable(FOOTER_DEFAULTS[name]) ? FOOTER_DEFAULTS[name] : textOn(background);
79
+ };
80
+ return {
81
+ ...(slot?.fontFamily ? { fontFamily: slot.fontFamily } : {}),
82
+ color: pick("color"),
83
+ brandColor: pick("brandColor"),
84
+ backgroundColor: background,
85
+ customBackground,
86
+ };
87
+ }
package/src/client.ts CHANGED
@@ -1,6 +1,9 @@
1
- import type { GiovChatContact, GiovChatSession, GiovMessage, GiovWidgetConfig, MessageHandler, StatusHandler } from "./types";
1
+ import type {
2
+ GiovChatContact, GiovChatSession, GiovFeedbackValue, GiovMessage, GiovRatingResult, GiovWidgetConfig, MessageHandler, StatusHandler,
3
+ } from "./types";
2
4
  import { GiovChatError, chatError, retryAfterSeconds } from "./errors";
3
5
  import { object, parseMessage } from "./protocol";
6
+ import { MAX_FEEDBACK_REASON, MAX_RATING_COMMENT, parseRatingFrame, parseRatingResult, ratingError, type GiovRatingFrame } from "./rating";
4
7
  import { safeImageUrl } from "./identity";
5
8
  import { deviceLocale } from "./i18n";
6
9
  import { Outbox } from "./outbox";
@@ -17,7 +20,7 @@ import {
17
20
  export const DEFAULT_BASE_URL = "https://api.giovchat.app";
18
21
 
19
22
  /** Keep in step with package.json; sent so the dashboard can show where the chat is installed. */
20
- export const SDK_VERSION = "0.2.0";
23
+ export const SDK_VERSION = "0.3.0";
21
24
  export const CLIENT_HEADER = { "X-Giov-Client": `react-native/${SDK_VERSION}` };
22
25
 
23
26
  export function normalizeBaseUrl(value: string): string {
@@ -186,6 +189,14 @@ export interface ChatClientOptions {
186
189
  flushOnOpen?: boolean;
187
190
  }
188
191
 
192
+ /** The `conversation_state` frame. */
193
+ export interface GiovConversationState {
194
+ status?: "open" | "closed";
195
+ handedOff?: boolean;
196
+ assignedAgentName?: string;
197
+ assignedAgentAvatar?: string;
198
+ }
199
+
189
200
  /** A GiovBot reply arriving word by word: the whole text so far, then `end` once it is saved or dropped. */
190
201
  export type GiovStreamEvent = { streamId: string; content: string; end?: false } | { streamId: string; end: true };
191
202
 
@@ -232,6 +243,8 @@ export class ChatClient {
232
243
  private ackCbs = new Set<(clientMessageId: string, message: GiovMessage) => void>();
233
244
  private streamCbs = new Set<(event: GiovStreamEvent) => void>();
234
245
  private accessCbs = new Set<(access: AttachmentAccess) => void>();
246
+ private ratingCbs = new Set<(frame: GiovRatingFrame) => void>();
247
+ private stateCbs = new Set<(state: GiovConversationState) => void>();
235
248
  private pending = new Map<string, Array<{ resolve: (message: GiovMessage) => void; reject: (error: GiovChatError) => void }>>();
236
249
  private timer: ReturnType<typeof setTimeout> | null = null;
237
250
  private heartbeat: ReturnType<typeof setInterval> | null = null;
@@ -246,6 +259,7 @@ export class ChatClient {
246
259
  private widgetToken: WidgetToken | null = null;
247
260
  private tokenRequest: Promise<WidgetToken> | null = null;
248
261
  private pushUnsupported = false;
262
+ private ratingsUnsupportedFlag = false;
249
263
  private readonly baseUrl: string;
250
264
  readonly outbox: Outbox;
251
265
 
@@ -264,6 +278,13 @@ export class ChatClient {
264
278
  onStream(cb: (event: GiovStreamEvent) => void): () => void { this.streamCbs.add(cb); return () => { this.streamCbs.delete(cb); }; }
265
279
  /** A newer attachment download token arrived (with a visitor token). */
266
280
  onAttachmentAccess(cb: (access: AttachmentAccess) => void): () => void { this.accessCbs.add(cb); return () => { this.accessCbs.delete(cb); }; }
281
+ /** A rating card was answered or changed (on any device), or replaced by a newer one. */
282
+ onRating(cb: (frame: GiovRatingFrame) => void): () => void { this.ratingCbs.add(cb); return () => { this.ratingCbs.delete(cb); }; }
283
+ /**
284
+ * The conversation was closed or reopened (`conversation_state` frame, sent on close and reopen) or handed
285
+ * to someone. `status` is absent on older servers: keep using the history's `status` there.
286
+ */
287
+ onConversationState(cb: (state: GiovConversationState) => void): () => void { this.stateCbs.add(cb); return () => { this.stateCbs.delete(cb); }; }
267
288
  private emit<T>(callbacks: Set<(value: T) => void>, value: T): void {
268
289
  for (const cb of callbacks) { try { cb(value); } catch { /* Host callbacks cannot break transport cleanup. */ } }
269
290
  }
@@ -426,6 +447,21 @@ export class ChatClient {
426
447
  }
427
448
  return;
428
449
  }
450
+ if (data.type === "conversation_state") {
451
+ const status = data.status === "closed" || data.status === "open" ? data.status : undefined;
452
+ this.emit(this.stateCbs, {
453
+ ...(status ? { status } : {}),
454
+ ...(typeof data.handedOff === "boolean" ? { handedOff: data.handedOff } : {}),
455
+ ...(typeof data.assignedAgentName === "string" ? { assignedAgentName: data.assignedAgentName } : {}),
456
+ ...(typeof data.assignedAgentAvatar === "string" ? { assignedAgentAvatar: data.assignedAgentAvatar } : {}),
457
+ });
458
+ return;
459
+ }
460
+ if (data.type === "rating") {
461
+ const frame = parseRatingFrame(data);
462
+ if (frame) this.emit(this.ratingCbs, frame);
463
+ return;
464
+ }
429
465
  if (data.type && data.type !== "message") return;
430
466
  const message = parseMessage(data);
431
467
  // The saved reply takes the place of its streamed draft, under the draft's key so it is not redrawn.
@@ -632,6 +668,60 @@ export class ChatClient {
632
668
  return response.status === 401 ? send((await this.token(true)).token) : response;
633
669
  }
634
670
 
671
+ /** True once the server answered a rating or feedback route with 404: an older server, so the controls are hidden. */
672
+ get ratingsUnsupported(): boolean { return this.ratingsUnsupportedFlag; }
673
+
674
+ /**
675
+ * Answers the conversation's rating card (`POST /conversations/{id}/rating`). Omit `requestId` to answer the
676
+ * open card. The same answer again is a no-op; a different one replaces it until the card closes. Rejects with a
677
+ * `GiovChatError` whose code is `invalid_rating`, `request_not_found`, `request_replaced`, `rating_closed`,
678
+ * `too_many_changes`, `rate_limited` (with `retryAfterSeconds`), `unsupported` (an older server) or a transport code.
679
+ */
680
+ async submitRating(score: number, options: { comment?: string; requestId?: string } = {}): Promise<GiovRatingResult> {
681
+ if (!Number.isInteger(score) || score < 1 || score > 5) throw new GiovChatError("invalid_rating", "The score must be a whole number from 1 to 5.");
682
+ const comment = options.comment?.trim();
683
+ if (comment && comment.length > MAX_RATING_COMMENT) throw new GiovChatError("invalid_rating", "The comment is too long.");
684
+ const body = await this.visitorPost("/rating", { score, ...(options.requestId ? { requestId: options.requestId } : {}), ...(comment ? { comment } : {}) });
685
+ const result = parseRatingResult(body, options.requestId);
686
+ if (!result) throw new GiovChatError("invalid_response", "Invalid rating response.");
687
+ return result;
688
+ }
689
+
690
+ /** Thumbs up or down on a GiovBot answer (`POST /conversations/{id}/messages/{messageId}/feedback`); `none` takes the vote back. */
691
+ async submitAnswerFeedback(messageId: string, value: GiovFeedbackValue, options: { reason?: string } = {}): Promise<void> {
692
+ if (!messageId || !["up", "down", "none"].includes(value)) throw new GiovChatError("invalid_feedback", "The vote must be up, down or none.");
693
+ const reason = value === "down" ? options.reason?.trim() : undefined;
694
+ if (reason && reason.length > MAX_FEEDBACK_REASON) throw new GiovChatError("invalid_feedback", "The reason is too long.");
695
+ await this.visitorPost(`/messages/${encodeURIComponent(messageId)}/feedback`, { value, ...(reason ? { reason } : {}) });
696
+ }
697
+
698
+ /** One visitor-token POST on this conversation, the token renewed once on 401 (protocol "Tokens"). */
699
+ private async visitorPost(path: string, payload: Record<string, unknown>): Promise<unknown> {
700
+ const session = this.session;
701
+ if (!session) throw new GiovChatError("disconnected", "Chat closed.");
702
+ const url = `${this.baseUrl}/conversations/${encodeURIComponent(session.conversationId)}${path}`;
703
+ const send = async (token: string) => {
704
+ const controller = new AbortController();
705
+ const timer = setTimeout(() => controller.abort(), 15000);
706
+ try {
707
+ const response = await fetch(url, {
708
+ method: "POST", signal: controller.signal,
709
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, ...CLIENT_HEADER },
710
+ body: JSON.stringify(payload),
711
+ });
712
+ return { status: response.status, body: await response.json().catch(() => null), retryAfter: response.headers?.get?.("Retry-After") ?? null };
713
+ } catch {
714
+ throw new GiovChatError(controller.signal.aborted ? "timeout" : "network", controller.signal.aborted ? "Chat request timed out. Please try again." : "Unable to reach chat. Please try again.");
715
+ } finally { clearTimeout(timer); }
716
+ };
717
+ let reply = await send((await this.token()).token);
718
+ if (reply.status === 401) reply = await send((await this.token(true)).token);
719
+ if (reply.status >= 200 && reply.status < 300) return reply.body;
720
+ const failure = ratingError(reply.status, reply.body, reply.retryAfter);
721
+ if (failure.code === "unsupported") this.ratingsUnsupportedFlag = true;
722
+ throw failure;
723
+ }
724
+
635
725
  private rejectPending(): void {
636
726
  for (const waiters of this.pending.values()) {
637
727
  for (const waiter of waiters) waiter.reject(new GiovChatError("send_failed", "Delivery could not be confirmed. Check the conversation before sending again."));
@@ -1,13 +1,32 @@
1
- import React from "react";
1
+ import React, { useEffect, useMemo, useState } from "react";
2
2
  import { Linking, StyleSheet, Text, TouchableOpacity } from "react-native";
3
+ import { DEFAULT_BASE_URL, fetchWidgetConfig } from "../client";
4
+ import { FOOTER_FONT_SIZE, resolveFooterStyle } from "../brandingStyle";
5
+ import { resolveStyleOverrides, type GiovChatStyleOverrides } from "../styleOverrides";
6
+ import { WidgetStylesContext, useWidgetStyles } from "./WidgetStyles";
3
7
 
4
- /** Deliberately isolated from customizable styles, including the global font. */
8
+ declare const __DEV__: boolean | undefined;
9
+ const devWarn = (message: string) => {
10
+ if (typeof __DEV__ !== "undefined" && __DEV__) console.warn(message);
11
+ };
12
+
13
+ /**
14
+ * The "Powered by GiovChat" footer. It follows the app's style (top-level `fontFamily` and the
15
+ * `branding` slot, read from the surrounding style context) under a contrast guard; layout, size and
16
+ * text are fixed, and only an explicit `showBranding={false}` from the server removes it.
17
+ */
5
18
  export function Branding({ showBranding }: { showBranding?: boolean }) {
19
+ const { branding } = useWidgetStyles();
20
+ const footer = useMemo(() => resolveFooterStyle(branding, devWarn), [branding]);
6
21
  if (showBranding === false) return null;
7
22
 
8
23
  return (
9
24
  <TouchableOpacity
10
- style={styles.container}
25
+ style={[
26
+ styles.container,
27
+ { backgroundColor: footer.backgroundColor },
28
+ footer.customBackground && styles.customDivider,
29
+ ]}
11
30
  accessibilityRole="link"
12
31
  accessibilityLabel="Powered by GiovChat"
13
32
  onPress={() => {
@@ -15,21 +34,61 @@ export function Branding({ showBranding }: { showBranding?: boolean }) {
15
34
  .catch(() => {});
16
35
  }}
17
36
  >
18
- <Text style={styles.text}>Powered by <Text style={styles.brand}>GiovChat</Text></Text>
37
+ <Text style={[styles.text, { color: footer.color }, footer.fontFamily ? { fontFamily: footer.fontFamily } : null]}>
38
+ Powered by <Text style={[styles.brand, { color: footer.brandColor }]}>GiovChat</Text>
39
+ </Text>
19
40
  </TouchableOpacity>
20
41
  );
21
42
  }
22
43
 
44
+ export interface GiovBrandingProps {
45
+ /**
46
+ * Show it or not. Pass the workspace's `showBranding` (`fetchWidgetConfig`) when you already have the
47
+ * config; leave it out and pass `tenantKey` to let the component read the config itself.
48
+ */
49
+ showBranding?: boolean;
50
+ /** Fetches `showBranding` from the workspace when the prop above is not set. */
51
+ tenantKey?: string;
52
+ baseUrl?: string;
53
+ /** The same style overrides you give `<GiovChat>`: `fontFamily` and `branding` apply. */
54
+ styleOverrides?: GiovChatStyleOverrides;
55
+ }
56
+
57
+ /**
58
+ * Drop-in footer for apps that build their own chat screen on the headless client. Renders nothing
59
+ * while the workspace setting loads and when the plan removed branding (`showBranding: false`); it
60
+ * shows when the setting is missing or cannot be loaded. Starter and Pro workspaces: please show it.
61
+ */
62
+ export function GiovBranding({ showBranding, tenantKey, baseUrl = DEFAULT_BASE_URL, styleOverrides }: GiovBrandingProps) {
63
+ const styles_ = useMemo(() => resolveStyleOverrides(styleOverrides), [styleOverrides]);
64
+ const [fetched, setFetched] = useState<boolean | undefined>(undefined);
65
+ useEffect(() => {
66
+ if (showBranding !== undefined || !tenantKey) return;
67
+ const controller = new AbortController();
68
+ fetchWidgetConfig(baseUrl, tenantKey, controller.signal)
69
+ .then((config) => setFetched(config.showBranding !== false))
70
+ .catch(() => { if (!controller.signal.aborted) setFetched(true); });
71
+ return () => controller.abort();
72
+ }, [showBranding, tenantKey, baseUrl]);
73
+ const known = showBranding ?? (tenantKey ? fetched : true);
74
+ if (known === undefined) return null;
75
+ return (
76
+ <WidgetStylesContext.Provider value={styles_}>
77
+ <Branding showBranding={known} />
78
+ </WidgetStylesContext.Provider>
79
+ );
80
+ }
81
+
23
82
  const styles = StyleSheet.create({
24
83
  container: {
25
84
  flexShrink: 0,
26
85
  alignItems: "center",
27
86
  paddingVertical: 12,
28
87
  paddingHorizontal: 16,
29
- backgroundColor: "#fff",
30
88
  borderTopWidth: StyleSheet.hairlineWidth,
31
89
  borderTopColor: "#ebebeb",
32
90
  },
33
- text: { color: "#52525b", fontSize: 12 },
34
- brand: { color: "#7c3aed", fontWeight: "700" },
91
+ customDivider: { borderTopColor: "rgba(128,128,128,0.25)" },
92
+ text: { fontSize: FOOTER_FONT_SIZE },
93
+ brand: { fontWeight: "700" },
35
94
  });
@@ -6,6 +6,8 @@ import { useWidgetStyles } from "./WidgetStyles";
6
6
  import { GiovBotMark } from "./GiovBotMark";
7
7
  import { ChatMarkdown } from "./ChatMarkdown";
8
8
  import { AttachmentList } from "./Attachments";
9
+ import { AnswerFeedback, RatingCard, type RateFn, type VoteFn } from "./RatingCard";
10
+ import { canVoteOn } from "../rating";
9
11
  import { messageSenderLabel } from "../identity";
10
12
  import { useGiovChatStrings } from "../i18n";
11
13
  import { isLightBackground, secondaryTextOn, textOn } from "../color";
@@ -21,11 +23,17 @@ export interface MessageBubbleProps {
21
23
  onRetry?: (message: GiovMessage) => void;
22
24
  /** Drops a visitor message that failed to send (a refused one can never succeed on retry). */
23
25
  onDiscard?: (message: GiovMessage) => void;
26
+ /** Draw the conversation rating card under a message that carries one (default true). */
27
+ ratingCard?: boolean;
28
+ /** Answers the card. Without it the card is read-only. */
29
+ onRate?: RateFn;
30
+ /** Thumbs under GiovBot answers. Without it no thumbs are drawn. */
31
+ onVote?: VoteFn;
24
32
  }
25
33
 
26
34
  const identity = (url: string) => url;
27
35
 
28
- export function MessageBubble({ message, primaryColor, dark, botName, resolveAttachmentUrl = identity, onRetry, onDiscard }: MessageBubbleProps) {
36
+ export function MessageBubble({ message, primaryColor, dark, botName, resolveAttachmentUrl = identity, onRetry, onDiscard, ratingCard = true, onRate, onVote }: MessageBubbleProps) {
29
37
  const custom = useWidgetStyles();
30
38
  const strings = useGiovChatStrings();
31
39
  const isVisitor = message.role === "visitor";
@@ -46,6 +54,7 @@ export function MessageBubble({ message, primaryColor, dark, botName, resolveAtt
46
54
  });
47
55
 
48
56
  return (
57
+ <>
49
58
  <View style={[s.wrapper, isVisitor ? s.right : s.left, isBot && s.assistantRow]}>
50
59
  {isBot && <GiovBotMark />}
51
60
  <View
@@ -79,6 +88,9 @@ export function MessageBubble({ message, primaryColor, dark, botName, resolveAtt
79
88
  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
89
  />
81
90
  ) : null}
91
+ {ratingCard && message.rating && !isVisitor ? (
92
+ <RatingCard block={message.rating} primaryColor={primaryColor} dark={dark} onRate={onRate} />
93
+ ) : null}
82
94
  {!message.isStreaming && (
83
95
  <Text
84
96
  style={[
@@ -117,5 +129,9 @@ export function MessageBubble({ message, primaryColor, dark, botName, resolveAtt
117
129
  </View>
118
130
  )}
119
131
  </View>
132
+ {onVote && canVoteOn(message) ? (
133
+ <AnswerFeedback messageId={message.id} value={message.feedback} dark={dark} primaryColor={primaryColor} onVote={onVote} />
134
+ ) : null}
135
+ </>
120
136
  );
121
137
  }