@giovchat/react-native 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +390 -2
  3. package/dist/GiovChat.d.ts +135 -0
  4. package/dist/GiovChat.d.ts.map +1 -0
  5. package/dist/GiovChat.js +294 -0
  6. package/dist/GiovChat.js.map +1 -0
  7. package/dist/attachments.d.ts +121 -0
  8. package/dist/attachments.d.ts.map +1 -0
  9. package/dist/attachments.js +185 -0
  10. package/dist/attachments.js.map +1 -0
  11. package/dist/brand-assets.d.ts +6 -0
  12. package/dist/brand-assets.d.ts.map +1 -0
  13. package/dist/brand-assets.js +9 -0
  14. package/dist/brand-assets.js.map +1 -0
  15. package/dist/brandingStyle.d.ts +38 -0
  16. package/dist/brandingStyle.d.ts.map +1 -0
  17. package/dist/brandingStyle.js +71 -0
  18. package/dist/brandingStyle.js.map +1 -0
  19. package/dist/client.d.ts +226 -0
  20. package/dist/client.d.ts.map +1 -0
  21. package/dist/client.js +891 -0
  22. package/dist/client.js.map +1 -0
  23. package/dist/color.d.ts +25 -0
  24. package/dist/color.d.ts.map +1 -0
  25. package/dist/color.js +78 -0
  26. package/dist/color.js.map +1 -0
  27. package/dist/components/Attachments.d.ts +13 -0
  28. package/dist/components/Attachments.d.ts.map +1 -0
  29. package/dist/components/Attachments.js +42 -0
  30. package/dist/components/Attachments.js.map +1 -0
  31. package/dist/components/Branding.d.ts +29 -0
  32. package/dist/components/Branding.d.ts.map +1 -0
  33. package/dist/components/Branding.js +71 -0
  34. package/dist/components/Branding.js.map +1 -0
  35. package/dist/components/ChatMarkdown.d.ts +15 -0
  36. package/dist/components/ChatMarkdown.d.ts.map +1 -0
  37. package/dist/components/ChatMarkdown.js +72 -0
  38. package/dist/components/ChatMarkdown.js.map +1 -0
  39. package/dist/components/GiovBotMark.d.ts +6 -0
  40. package/dist/components/GiovBotMark.d.ts.map +1 -0
  41. package/dist/components/GiovBotMark.js +11 -0
  42. package/dist/components/GiovBotMark.js.map +1 -0
  43. package/dist/components/GreetingBubble.d.ts +7 -0
  44. package/dist/components/GreetingBubble.d.ts.map +1 -0
  45. package/dist/components/GreetingBubble.js +12 -0
  46. package/dist/components/GreetingBubble.js.map +1 -0
  47. package/dist/components/Header.d.ts +18 -0
  48. package/dist/components/Header.d.ts.map +1 -0
  49. package/dist/components/Header.js +67 -0
  50. package/dist/components/Header.js.map +1 -0
  51. package/dist/components/MessageBubble.d.ts +23 -0
  52. package/dist/components/MessageBubble.d.ts.map +1 -0
  53. package/dist/components/MessageBubble.js +51 -0
  54. package/dist/components/MessageBubble.js.map +1 -0
  55. package/dist/components/PreChatForm.d.ts +11 -0
  56. package/dist/components/PreChatForm.d.ts.map +1 -0
  57. package/dist/components/PreChatForm.js +72 -0
  58. package/dist/components/PreChatForm.js.map +1 -0
  59. package/dist/components/RatingCard.d.ts +23 -0
  60. package/dist/components/RatingCard.d.ts.map +1 -0
  61. package/dist/components/RatingCard.js +107 -0
  62. package/dist/components/RatingCard.js.map +1 -0
  63. package/dist/components/TypingIndicator.d.ts +9 -0
  64. package/dist/components/TypingIndicator.d.ts.map +1 -0
  65. package/dist/components/TypingIndicator.js +18 -0
  66. package/dist/components/TypingIndicator.js.map +1 -0
  67. package/dist/components/WidgetStyles.d.ts +3 -0
  68. package/dist/components/WidgetStyles.d.ts.map +1 -0
  69. package/dist/components/WidgetStyles.js +9 -0
  70. package/dist/components/WidgetStyles.js.map +1 -0
  71. package/dist/components/bubbleStyles.d.ts +47 -0
  72. package/dist/components/bubbleStyles.d.ts.map +1 -0
  73. package/dist/components/bubbleStyles.js +19 -0
  74. package/dist/components/bubbleStyles.js.map +1 -0
  75. package/dist/components/index.d.ts +11 -0
  76. package/dist/components/index.d.ts.map +1 -0
  77. package/dist/components/index.js +14 -0
  78. package/dist/components/index.js.map +1 -0
  79. package/dist/errors.d.ts +20 -0
  80. package/dist/errors.d.ts.map +1 -0
  81. package/dist/errors.js +26 -0
  82. package/dist/errors.js.map +1 -0
  83. package/dist/i18n.d.ts +96 -0
  84. package/dist/i18n.d.ts.map +1 -0
  85. package/dist/i18n.js +392 -0
  86. package/dist/i18n.js.map +1 -0
  87. package/dist/identity.d.ts +41 -0
  88. package/dist/identity.d.ts.map +1 -0
  89. package/dist/identity.js +41 -0
  90. package/dist/identity.js.map +1 -0
  91. package/dist/index.d.ts +30 -0
  92. package/dist/index.d.ts.map +1 -0
  93. package/dist/index.js +64 -0
  94. package/dist/index.js.map +1 -0
  95. package/dist/markdown.d.ts +56 -0
  96. package/dist/markdown.d.ts.map +1 -0
  97. package/dist/markdown.js +219 -0
  98. package/dist/markdown.js.map +1 -0
  99. package/dist/outbox.d.ts +68 -0
  100. package/dist/outbox.d.ts.map +1 -0
  101. package/dist/outbox.js +168 -0
  102. package/dist/outbox.js.map +1 -0
  103. package/dist/protocol.d.ts +9 -0
  104. package/dist/protocol.d.ts.map +1 -0
  105. package/dist/protocol.js +39 -0
  106. package/dist/protocol.js.map +1 -0
  107. package/dist/rating.d.ts +41 -0
  108. package/dist/rating.d.ts.map +1 -0
  109. package/dist/rating.js +165 -0
  110. package/dist/rating.js.map +1 -0
  111. package/dist/ratingStore.d.ts +36 -0
  112. package/dist/ratingStore.d.ts.map +1 -0
  113. package/dist/ratingStore.js +55 -0
  114. package/dist/ratingStore.js.map +1 -0
  115. package/dist/styleOverrides.d.ts +45 -0
  116. package/dist/styleOverrides.d.ts.map +1 -0
  117. package/dist/styleOverrides.js +63 -0
  118. package/dist/styleOverrides.js.map +1 -0
  119. package/dist/types.d.ts +156 -0
  120. package/dist/types.d.ts.map +1 -0
  121. package/dist/types.js +3 -0
  122. package/dist/types.js.map +1 -0
  123. package/dist/unread.d.ts +9 -0
  124. package/dist/unread.d.ts.map +1 -0
  125. package/dist/unread.js +49 -0
  126. package/dist/unread.js.map +1 -0
  127. package/dist/useChatSession.d.ts +95 -0
  128. package/dist/useChatSession.d.ts.map +1 -0
  129. package/dist/useChatSession.js +965 -0
  130. package/dist/useChatSession.js.map +1 -0
  131. package/package.json +60 -4
  132. package/src/GiovChat.tsx +640 -0
  133. package/src/attachments.ts +218 -0
  134. package/src/brand-assets.ts +5 -0
  135. package/src/brandingStyle.ts +87 -0
  136. package/src/client.ts +848 -0
  137. package/src/color.ts +74 -0
  138. package/src/components/Attachments.tsx +75 -0
  139. package/src/components/Branding.tsx +94 -0
  140. package/src/components/ChatMarkdown.tsx +111 -0
  141. package/src/components/GiovBotMark.tsx +19 -0
  142. package/src/components/GreetingBubble.tsx +20 -0
  143. package/src/components/Header.tsx +131 -0
  144. package/src/components/MessageBubble.tsx +137 -0
  145. package/src/components/PreChatForm.tsx +183 -0
  146. package/src/components/RatingCard.tsx +219 -0
  147. package/src/components/TypingIndicator.tsx +34 -0
  148. package/src/components/WidgetStyles.tsx +5 -0
  149. package/src/components/bubbleStyles.ts +16 -0
  150. package/src/components/index.ts +14 -0
  151. package/src/errors.ts +33 -0
  152. package/src/i18n.ts +474 -0
  153. package/src/identity.ts +54 -0
  154. package/src/index.ts +85 -0
  155. package/src/markdown.ts +227 -0
  156. package/src/outbox.ts +175 -0
  157. package/src/protocol.ts +36 -0
  158. package/src/rating.ts +169 -0
  159. package/src/ratingStore.ts +65 -0
  160. package/src/styleOverrides.ts +98 -0
  161. package/src/types.ts +170 -0
  162. package/src/unread.ts +39 -0
  163. package/src/useChatSession.ts +841 -0
@@ -0,0 +1,640 @@
1
+ import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
2
+ import {
3
+ AccessibilityInfo,
4
+ ActivityIndicator,
5
+ FlatList,
6
+ KeyboardAvoidingView,
7
+ Modal,
8
+ Platform,
9
+ StyleSheet,
10
+ Text,
11
+ TextInput,
12
+ TouchableOpacity,
13
+ useColorScheme,
14
+ View,
15
+ } from "react-native";
16
+ import { useChatSession, sessionScope } from "./useChatSession";
17
+ import type { GiovChatError } from "./errors";
18
+ import {
19
+ GreetingBubble,
20
+ Header,
21
+ MessageBubble,
22
+ PreChatForm,
23
+ TypingIndicator,
24
+ } from "./components";
25
+ import type { GiovChatContact, GiovChatConnectionStatus, GiovChatSessionStorage, GiovMessage } from "./types";
26
+ import { SafeAreaView } from "react-native-safe-area-context";
27
+ import { resolveStyleOverrides, type GiovChatStyleOverrides } from "./styleOverrides";
28
+ import { WidgetStylesContext } from "./components/WidgetStyles";
29
+ import { Branding } from "./components/Branding";
30
+ import { GiovChatStringsContext, resolveStrings, type GiovChatStrings } from "./i18n";
31
+ import { headerIdentity, messageSenderLabel } from "./identity";
32
+ import { plainText } from "./markdown";
33
+ import { clampMessage } from "./outbox";
34
+ import { textOn } from "./color";
35
+ import {
36
+ DEFAULT_ATTACHMENT_POLICY,
37
+ MAX_ATTACHED_FILES,
38
+ validateAttachments,
39
+ type AttachmentProblem,
40
+ type GiovChatAttachmentPicker,
41
+ type GiovChatPickedFile,
42
+ } from "./attachments";
43
+ import type { GiovPushOptions, GiovPushPlatform, GiovPushRegistration } from "./client";
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";
48
+
49
+ // ─── Public prop types ─────────────────────────────────────────────────────
50
+
51
+ export interface GiovChatProps {
52
+ /**
53
+ * Tenant key from your agent dashboard.
54
+ * Widget settings (colors, greeting, pre-chat fields, bot name, etc.) are
55
+ * fetched from the server automatically using this key.
56
+ */
57
+ tenantKey: string;
58
+
59
+ /**
60
+ * Render mode:
61
+ * - `"modal"` (default) — wraps the chat in a native `Modal` sheet.
62
+ * Control visibility with the `visible` prop.
63
+ * - `"fullscreen"` — renders inline, filling the parent container.
64
+ * Use inside a dedicated screen or tab.
65
+ */
66
+ mode?: "modal" | "fullscreen";
67
+
68
+ /**
69
+ * Show or hide the chat.
70
+ * In `modal` mode this drives `Modal.visible`.
71
+ * In `fullscreen` mode the component renders `null` when `false`.
72
+ * @default true
73
+ */
74
+ visible?: boolean;
75
+
76
+ /** Called when the user taps the close (✕) button. */
77
+ onClose?: () => void;
78
+
79
+ /**
80
+ * Contact info for the currently logged-in user.
81
+ * Any field supplied here is forwarded to the server automatically
82
+ * and skips the corresponding pre-chat form field.
83
+ */
84
+ contact?: GiovChatContact;
85
+
86
+ /**
87
+ * Override the API base URL.
88
+ * @default "https://api.giovchat.app"
89
+ */
90
+ baseUrl?: string;
91
+
92
+ /**
93
+ * Override the primary brand color from the dashboard.
94
+ * Use a React Native color string.
95
+ */
96
+ primaryColor?: string;
97
+
98
+ /** Override the dashboard's theme. `"auto"` follows the device. */
99
+ theme?: "light" | "dark" | "auto";
100
+
101
+ /** Allowed font and color overrides. Layout and plan branding remain SDK-controlled. */
102
+ styleOverrides?: GiovChatStyleOverrides;
103
+
104
+ /** Interface language ("fr", "pt-BR", …). Defaults to the device language; unsupported ones use English. */
105
+ locale?: string;
106
+ /** Replace any of the SDK's interface strings. */
107
+ strings?: Partial<GiovChatStrings>;
108
+
109
+ /**
110
+ * Enables attachments: the host's own file or image picker. Visitors can attach only what the
111
+ * agent allows; without a picker the attach button is hidden.
112
+ */
113
+ attachmentPicker?: GiovChatAttachmentPicker;
114
+
115
+ /**
116
+ * Stay connected while the chat is hidden (and the app is in the foreground) so new replies are
117
+ * counted as unread. Only resumes an existing conversation; it never starts one.
118
+ */
119
+ keepConnected?: boolean;
120
+ /** Agent replies the visitor has not seen because the chat was hidden. */
121
+ onUnreadCountChange?: (count: number) => void;
122
+
123
+ /** Invoked once with the conversation ID after a session is created. */
124
+ onConversationStart?: (conversationId: string) => void;
125
+ /** Changes isolate/reset the session, for example on logout or account switching. */
126
+ sessionKey?: string | number;
127
+ /** Optional secure storage owned by the host application. No persistence by default. */
128
+ sessionStorage?: GiovChatSessionStorage;
129
+ onError?: (error: GiovChatError) => void;
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;
149
+ }
150
+
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;
164
+ /** Clears this identity's stored session and starts fresh if the chat is visible. */
165
+ reset(): Promise<void>;
166
+ /**
167
+ * Registers the device's push token (APNs or FCM) for replies in this conversation, now or once
168
+ * it connects. Pass `{ environment: "sandbox" }` from iOS development builds. Resolves
169
+ * `{ registered: false, reason: "unsupported" }` against a server without visitor push. The
170
+ * notification language defaults to the `locale` prop, else the device language.
171
+ *
172
+ * Before any conversation exists the promise stays pending until one connects (the visitor's
173
+ * first message starts it). It resolves `{ registered: false, reason: "no_conversation" }` when
174
+ * the chat unmounts (including a `sessionKey` change), on `reset()`, or when the workspace refuses
175
+ * the visitor's identity. The token is remembered: a new conversation after the team closed the
176
+ * last one is registered again automatically, until `reset()` or `unregisterPushToken()`.
177
+ */
178
+ registerPushToken(token: string, platform: GiovPushPlatform, options?: GiovPushOptions): Promise<GiovPushRegistration>;
179
+ /**
180
+ * Stops push notifications about this conversation on this device (sign-out, notifications turned
181
+ * off). Call it before `reset()`. Resolves true when the server confirmed; never throws.
182
+ */
183
+ unregisterPushToken(token: string): Promise<boolean>;
184
+ }
185
+
186
+ // ─── Component ───────────────────────────────────────────────────────────────
187
+
188
+ export const GiovChat = React.forwardRef<GiovChatHandle, GiovChatProps>(function GiovChat(props, ref) {
189
+ const scope = sessionScope(props.baseUrl ?? "https://api.giovchat.app", props.tenantKey, props.contact, props.sessionKey);
190
+ return <GiovChatInstance key={scope} {...props} ref={ref} />;
191
+ });
192
+
193
+ function errorText(error: GiovChatError, strings: GiovChatStrings): string {
194
+ switch (error.code) {
195
+ case "invalid_session": return strings.sessionExpired;
196
+ case "conversation_closed": return strings.conversationEnded;
197
+ case "upload_failed": return strings.uploadFailed;
198
+ case "send_failed": return strings.sendFailed;
199
+ case "disconnected": case "connection_failed": return strings.reconnecting;
200
+ case "workspace_busy": return strings.busy;
201
+ default: return strings.unavailable;
202
+ }
203
+ }
204
+
205
+ function problemText(problem: AttachmentProblem, strings: GiovChatStrings): string {
206
+ switch (problem.kind) {
207
+ case "disabled": return strings.attachmentsOff;
208
+ case "type": return strings.typeNotAllowed(problem.name);
209
+ case "size": return strings.fileTooLarge(problem.name, problem.maxSizeMb);
210
+ case "count": return strings.tooManyFiles(problem.max);
211
+ }
212
+ }
213
+
214
+ const GiovChatInstance = React.forwardRef<GiovChatHandle, GiovChatProps>(function GiovChatInstance({
215
+ tenantKey,
216
+ mode = "modal",
217
+ visible = true,
218
+ onClose,
219
+ contact,
220
+ baseUrl = "https://api.giovchat.app",
221
+ primaryColor: overridePrimaryColor,
222
+ theme: themeOverride,
223
+ styleOverrides,
224
+ locale,
225
+ strings: stringOverrides,
226
+ attachmentPicker,
227
+ keepConnected,
228
+ onUnreadCountChange,
229
+ onConversationStart,
230
+ sessionKey,
231
+ sessionStorage,
232
+ onError,
233
+ onConnectionStatusChange,
234
+ ratingCard = true,
235
+ answerFeedback = true,
236
+ onRatingRequested,
237
+ onRatingSubmitted,
238
+ onAnswerFeedback,
239
+ }: GiovChatProps, ref) {
240
+ const systemScheme = useColorScheme();
241
+ const custom = useMemo(() => resolveStyleOverrides(styleOverrides), [styleOverrides]);
242
+ const strings = useMemo(() => resolveStrings(locale, stringOverrides), [locale, stringOverrides]);
243
+
244
+ const scope = sessionScope(baseUrl, tenantKey, contact, sessionKey);
245
+ const chat = useChatSession({
246
+ tenantKey, baseUrl, visible, contact, scope, sessionKey, sessionStorage, keepConnected,
247
+ onError, onConnectionStatusChange, onConversationStart, onUnreadCountChange,
248
+ onRatingRequested, onRatingSubmitted, onAnswerFeedback,
249
+ });
250
+ const { phase, config, messages, agentTyping, agentTypingIsBot, error, sending, uploads, requiredFields, retry, reset, submitContact, send } = chat;
251
+ const [input, setInput] = useState("");
252
+ const [files, setFiles] = useState<GiovChatPickedFile[]>([]);
253
+ const [fileProblem, setFileProblem] = useState<string | null>(null);
254
+ const [sheetOpen, setSheetOpen] = useState(false);
255
+ const listRef = useRef<FlatList<GiovMessage>>(null);
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), []);
270
+ useImperativeHandle(ref, () => ({
271
+ submitRating: ratingCalls.submit,
272
+ submitAnswerFeedback: ratingCalls.submitFeedback,
273
+ getRatingRequest: () => live.current.ratingRequest,
274
+ showRating: ratingCalls.show,
275
+ reset: async () => { setInput(""); setFiles([]); await reset(); },
276
+ registerPushToken: (token, platform, options) => chat.registerPushToken(token, platform, { ...(locale ? { locale } : {}), ...options }),
277
+ unregisterPushToken: chat.unregisterPushToken,
278
+ }), [reset, chat.registerPushToken, chat.unregisterPushToken, locale, ratingCalls]);
279
+ useEffect(() => {
280
+ const timer = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 80);
281
+ return () => clearTimeout(timer);
282
+ }, [messages.length, messages[messages.length - 1]?.content]);
283
+ useEffect(() => { publishUnreadCount(unreadOwner.current, tenantKey, chat.unreadCount); }, [tenantKey, chat.unreadCount]);
284
+ // Screen readers hear each reply as it arrives (history is not read out again).
285
+ const incoming = chat.lastIncoming;
286
+ useEffect(() => {
287
+ if (!incoming || !visible) return;
288
+ const sender = messageSenderLabel(incoming, { botName: config?.botName, agentName: chat.currentAgentName, you: strings.you, automated: strings.automated, agent: strings.agent });
289
+ const text = plainText(incoming.content) || (incoming.attachments?.length ? strings.attachment : "");
290
+ if (text) AccessibilityInfo.announceForAccessibility?.(strings.newMessage(sender, text));
291
+ // Announced once per message: only the message itself triggers it.
292
+ // eslint-disable-next-line react-hooks/exhaustive-deps
293
+ }, [incoming]);
294
+ useEffect(() => () => forgetUnreadCount(unreadOwner.current), []);
295
+
296
+ const primaryColor = overridePrimaryColor ?? config?.primaryColor ?? "#7c3aed";
297
+ const themeSetting = themeOverride ?? config?.theme;
298
+ const isDark = themeSetting === "dark" || (themeSetting === "auto" && systemScheme === "dark");
299
+ const handleClose = useCallback(() => onClose?.(), [onClose]);
300
+ const policy = config?.attachments ?? DEFAULT_ATTACHMENT_POLICY;
301
+ // Uploads need a conversation: the first message starts it, then files can follow.
302
+ const canAttach = !!attachmentPicker && chat.hasConversation && !chat.closed && policy.enabled && policy.allowedTypes.length > 0;
303
+ const composing = phase === "ready" || phase === "connecting" || phase === "active";
304
+ const canSend = composing && !sending && (!!input.trim() || files.length > 0);
305
+ const handleSend = async () => {
306
+ const text = input.trim();
307
+ if (!canSend) return;
308
+ const sentFiles = files;
309
+ if (await send(text, sentFiles)) {
310
+ setInput((current) => current.trim() === text ? "" : current);
311
+ setFiles((current) => current === sentFiles ? [] : current);
312
+ setFileProblem(null);
313
+ }
314
+ };
315
+ const handleAttach = async () => {
316
+ if (!attachmentPicker) return;
317
+ setFileProblem(null);
318
+ try {
319
+ const picked = await attachmentPicker.pick({ allowedTypes: policy.allowedTypes, maxFiles: Math.max(1, MAX_ATTACHED_FILES - files.length), maxSizeMb: policy.maxSizeMb });
320
+ const { accepted, problems } = validateAttachments(picked, files.length, policy);
321
+ if (accepted.length) setFiles((current) => [...current, ...accepted]);
322
+ if (problems.length) setFileProblem(problems.map((problem) => problemText(problem, strings)).join(" "));
323
+ } catch { /* The picker was dismissed or failed; nothing changes. */ }
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]);
333
+ const identity = config ? headerIdentity(config, chat.currentAgentName, chat.currentAgentAvatar) : null;
334
+ const subtitle = phase === "active" || phase === "ready"
335
+ ? identity?.kind === "person" ? strings.statusPerson : identity?.kind === "bot" ? strings.statusBot : strings.statusTeam
336
+ : phase === "connecting" ? strings.connecting : null;
337
+ const showClose = mode === "modal" || !!onClose;
338
+ const bgColor = isDark ? "#0a0a0a" : "#fff";
339
+ // Black or white on the buttons' colour, by WCAG contrast.
340
+ const buttonInk = textOn(custom.button.backgroundColor ?? primaryColor);
341
+
342
+ // ── Render ───────────────────────────────────────────────────────────────────
343
+
344
+ const chatContent = (
345
+ <GiovChatStringsContext.Provider value={strings}>
346
+ <WidgetStylesContext.Provider value={custom}>
347
+ <SafeAreaView style={[styles.safeArea, { backgroundColor: bgColor }, custom.container]}>
348
+ <Header
349
+ privacyPolicyUrl={config?.privacyPolicyUrl}
350
+ title={identity?.title ?? config?.title ?? "Chat"}
351
+ subtitle={subtitle}
352
+ primaryColor={primaryColor}
353
+ avatarUrl={identity?.avatar}
354
+ kind={identity?.kind}
355
+ onClose={showClose ? handleClose : undefined}
356
+ />
357
+
358
+ {(phase === "loading" || phase === "idle") && (
359
+ <View style={styles.centered}>
360
+ <ActivityIndicator size="large" color={primaryColor} />
361
+ <Text style={[styles.hint, { color: isDark ? "#555" : "#aaa" }, custom.hint]}>{strings.loading}</Text>
362
+ </View>
363
+ )}
364
+
365
+ {phase === "error" && (
366
+ <View style={styles.centered}>
367
+ <Text style={[styles.errorText, custom.errorText]}>{error ? errorText(error, strings) : strings.unavailable}</Text>
368
+ <TouchableOpacity
369
+ style={[styles.retryBtn, { backgroundColor: primaryColor }, custom.button]}
370
+ onPress={retry}
371
+ accessibilityRole="button"
372
+ accessibilityLabel={strings.retry}
373
+ >
374
+ <Text style={[styles.retryBtnText, { color: buttonInk }, custom.buttonText]}>{strings.tryAgain}</Text>
375
+ </TouchableOpacity>
376
+ </View>
377
+ )}
378
+
379
+ {phase === "pre-chat" && config?.requestUserInfo && (
380
+ <PreChatForm
381
+ fields={requiredFields}
382
+ primaryColor={primaryColor}
383
+ dark={isDark}
384
+ initialValues={contact}
385
+ onSubmit={submitContact}
386
+ />
387
+ )}
388
+
389
+ {composing && (
390
+ <KeyboardAvoidingView
391
+ style={styles.flex}
392
+ behavior={Platform.OS === "ios" ? "padding" : "height"}
393
+ >
394
+ {error && (
395
+ <Text accessibilityRole="alert" style={[styles.errorText, { padding: 10 }, custom.errorText]}>{errorText(error, strings)}</Text>
396
+ )}
397
+ <FlatList
398
+ ref={listRef}
399
+ data={messages}
400
+ keyExtractor={(item) => item.clientMessageId ?? item.id}
401
+ contentContainerStyle={styles.listContent}
402
+ ListHeaderComponent={
403
+ config ? (
404
+ <GreetingBubble
405
+ text={config.greetingText}
406
+ dark={isDark}
407
+ />
408
+ ) : null
409
+ }
410
+ ListFooterComponent={agentTyping ? <TypingIndicator dark={isDark} isBot={agentTypingIsBot} botName={config?.botName} /> : null}
411
+ renderItem={({ item }: { item: GiovMessage }) => (
412
+ <MessageBubble
413
+ message={item}
414
+ primaryColor={primaryColor}
415
+ dark={isDark}
416
+ botName={config?.botName}
417
+ resolveAttachmentUrl={chat.attachmentUrl}
418
+ onRetry={(message) => message.clientMessageId && chat.retryMessage(message.clientMessageId)}
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}
423
+ />
424
+ )}
425
+ />
426
+
427
+ {(files.length > 0 || fileProblem) && (
428
+ <View style={[styles.files, { backgroundColor: bgColor, borderTopColor: isDark ? "#1e1e1e" : "#ebebeb" }]}>
429
+ {fileProblem && <Text accessibilityRole="alert" style={[styles.fileProblem, custom.errorText]}>{fileProblem}</Text>}
430
+ <View style={styles.fileChips}>
431
+ {files.map((file, index) => {
432
+ const upload = uploads[index];
433
+ return (
434
+ <View key={`${file.uri}-${index}`} style={[styles.fileChip, { backgroundColor: isDark ? "#1c1c1c" : "#f2f2f2" }]}>
435
+ <Text style={[styles.fileChipText, { color: isDark ? "#e5e5e5" : "#222" }, custom.hint]} numberOfLines={1}>
436
+ {upload ? strings.uploading(Math.round(upload.progress * 100)) : file.name}
437
+ </Text>
438
+ {!sending && (
439
+ <TouchableOpacity
440
+ onPress={() => setFiles((current) => current.filter((_, i) => i !== index))}
441
+ accessibilityRole="button"
442
+ accessibilityLabel={strings.removeAttachment(file.name)}
443
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
444
+ >
445
+ <Text style={{ color: isDark ? "#aaa" : "#666", fontSize: 14 }}>✕</Text>
446
+ </TouchableOpacity>
447
+ )}
448
+ </View>
449
+ );
450
+ })}
451
+ </View>
452
+ </View>
453
+ )}
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
+ ) : (
469
+ <View
470
+ style={[
471
+ styles.composerRow,
472
+ {
473
+ borderTopColor: isDark ? "#1e1e1e" : "#ebebeb",
474
+ backgroundColor: bgColor,
475
+ },
476
+ custom.composer,
477
+ ]}
478
+ >
479
+ {canAttach && (
480
+ <TouchableOpacity
481
+ style={[styles.attachBtn, { borderColor: isDark ? "#2e2e2e" : "#e5e5e5" }]}
482
+ onPress={handleAttach}
483
+ disabled={sending || files.length >= MAX_ATTACHED_FILES}
484
+ accessibilityRole="button"
485
+ accessibilityLabel={strings.attachFile}
486
+ >
487
+ <Text style={[styles.attachIcon, { color: isDark ? "#ccc" : "#555" }]}>+</Text>
488
+ </TouchableOpacity>
489
+ )}
490
+ <TextInput
491
+ style={[
492
+ styles.composerInput,
493
+ {
494
+ color: isDark ? "#f0f0f0" : "#111",
495
+ backgroundColor: isDark ? "#1c1c1c" : "#f5f5f5",
496
+ borderColor: isDark ? "#2e2e2e" : "#e5e5e5",
497
+ },
498
+ custom.input,
499
+ ]}
500
+ value={input}
501
+ // Over the limit (a long paste) the text is cut at 2000, never dropped, and never through an emoji.
502
+ onChangeText={(value) => { setInput(clampMessage(value)); if (value.trim()) chat.notifyTyping(); }}
503
+ placeholder={config?.textArea ?? strings.composerPlaceholder}
504
+ placeholderTextColor={custom.hint.color ?? (isDark ? "#484848" : "#b0b0b0")}
505
+ accessibilityLabel={strings.messageInput}
506
+ // Typing goes on while files upload; only Send waits.
507
+ editable={composing}
508
+ multiline
509
+ />
510
+ <TouchableOpacity
511
+ style={[
512
+ styles.sendBtn,
513
+ { backgroundColor: primaryColor },
514
+ custom.button,
515
+ !canSend && styles.sendBtnOff,
516
+ ]}
517
+ onPress={handleSend}
518
+ disabled={!canSend}
519
+ accessibilityRole="button"
520
+ accessibilityLabel={strings.sendMessage}
521
+ >
522
+ <Text style={[styles.sendIcon, { color: buttonInk }, custom.buttonText]}>↑</Text>
523
+ </TouchableOpacity>
524
+ </View>
525
+ )}
526
+ </KeyboardAvoidingView>
527
+ )}
528
+ <Branding showBranding={config?.showBranding} />
529
+ </SafeAreaView>
530
+ </WidgetStylesContext.Provider>
531
+ </GiovChatStringsContext.Provider>
532
+ );
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
+
553
+ if (mode === "modal") {
554
+ return (
555
+ <>
556
+ <Modal
557
+ visible={visible}
558
+ animationType="slide"
559
+ presentationStyle="pageSheet"
560
+ onRequestClose={handleClose}
561
+ >
562
+ {chatContent}
563
+ </Modal>
564
+ {sheet}
565
+ </>
566
+ );
567
+ }
568
+
569
+ return <>{visible ? chatContent : null}{sheet}</>;
570
+ });
571
+
572
+ const styles = StyleSheet.create({
573
+ flex: { flex: 1 },
574
+ safeArea: { flex: 1 },
575
+ centered: {
576
+ flex: 1,
577
+ alignItems: "center",
578
+ justifyContent: "center",
579
+ gap: 14,
580
+ padding: 24,
581
+ },
582
+ hint: { fontSize: 14, marginTop: 4 },
583
+ errorText: {
584
+ color: "#dc2626",
585
+ fontSize: 14,
586
+ textAlign: "center",
587
+ lineHeight: 20,
588
+ },
589
+ retryBtn: {
590
+ borderRadius: 8,
591
+ paddingHorizontal: 28,
592
+ paddingVertical: 11,
593
+ },
594
+ retryBtnText: { color: "#fff", fontWeight: "600", fontSize: 14 },
595
+ listContent: { paddingBottom: 12 },
596
+ files: { paddingHorizontal: 10, paddingTop: 8, borderTopWidth: StyleSheet.hairlineWidth, gap: 6 },
597
+ fileProblem: { color: "#dc2626", fontSize: 12 },
598
+ fileChips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
599
+ fileChip: { flexDirection: "row", alignItems: "center", gap: 6, borderRadius: 14, paddingHorizontal: 10, paddingVertical: 5, maxWidth: 220 },
600
+ fileChipText: { fontSize: 12, flexShrink: 1 },
601
+ composerRow: {
602
+ flexDirection: "row",
603
+ alignItems: "flex-end",
604
+ gap: 8,
605
+ padding: 10,
606
+ borderTopWidth: StyleSheet.hairlineWidth,
607
+ },
608
+ attachBtn: {
609
+ width: 40,
610
+ height: 40,
611
+ borderRadius: 20,
612
+ borderWidth: 1,
613
+ alignItems: "center",
614
+ justifyContent: "center",
615
+ },
616
+ attachIcon: { fontSize: 22, lineHeight: 24 },
617
+ composerInput: {
618
+ flex: 1,
619
+ borderWidth: 1,
620
+ borderRadius: 22,
621
+ paddingHorizontal: 14,
622
+ paddingVertical: Platform.OS === "ios" ? 10 : 8,
623
+ fontSize: 14,
624
+ maxHeight: 120,
625
+ },
626
+ sendBtn: {
627
+ width: 40,
628
+ height: 40,
629
+ borderRadius: 20,
630
+ alignItems: "center",
631
+ justifyContent: "center",
632
+ },
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" },
639
+ sendIcon: { color: "#fff", fontSize: 18, fontWeight: "700", lineHeight: 22 },
640
+ });