@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,965 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BACKGROUND_GRACE_MS = void 0;
4
+ exports.missingContactFields = missingContactFields;
5
+ exports.sessionScope = sessionScope;
6
+ exports.legacySessionScope = legacySessionScope;
7
+ exports.mergeMessages = mergeMessages;
8
+ exports.displayMessages = displayMessages;
9
+ exports.useChatSession = useChatSession;
10
+ const react_1 = require("react");
11
+ const react_native_1 = require("react-native");
12
+ const client_1 = require("./client");
13
+ const identity_1 = require("./identity");
14
+ const errors_1 = require("./errors");
15
+ const rating_1 = require("./rating");
16
+ const outbox_1 = require("./outbox");
17
+ const attachments_1 = require("./attachments");
18
+ function missingContactFields(fields = {}, contact = {}) {
19
+ const missing = {};
20
+ for (const field of ["email", "phone", "firstName", "lastName"]) {
21
+ const value = contact[field]?.trim() ?? "";
22
+ if (fields[field] && (!value || (field === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))))
23
+ missing[field] = true;
24
+ }
25
+ return missing;
26
+ }
27
+ /**
28
+ * The storage key of a visitor's session: the API, the workspace, the host's `sessionKey` and the
29
+ * signed-in `userId`. Contact details are not part of it, so a profile edit keeps the conversation.
30
+ */
31
+ function sessionScope(baseUrl, tenantKey, contact, sessionKey) {
32
+ return JSON.stringify([baseUrl.replace(/\/$/, ""), tenantKey, sessionKey ?? null, contact?.userId ?? null]);
33
+ }
34
+ /** The 0.2.0 key, which also held the contact details; read once so an upgrade keeps the conversation. */
35
+ function legacySessionScope(baseUrl, tenantKey, contact, sessionKey) {
36
+ return JSON.stringify([baseUrl.replace(/\/$/, ""), tenantKey, sessionKey ?? null,
37
+ contact?.userId ?? null, contact?.email ?? null, contact?.phone ?? null,
38
+ contact?.firstName ?? null, contact?.lastName ?? null]);
39
+ }
40
+ /** By server id; a copy without a local key keeps the one it was shown under, so the bubble is not redrawn. */
41
+ function mergeMessages(current, incoming) {
42
+ const messages = new Map(current.map((message) => [message.id, message]));
43
+ for (const message of incoming) {
44
+ const existing = messages.get(message.id);
45
+ messages.set(message.id, existing?.clientMessageId && !message.clientMessageId
46
+ ? { ...message, clientMessageId: existing.clientMessageId } : message);
47
+ }
48
+ return [...messages.values()].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
49
+ }
50
+ /** What the visitor sees: saved messages, then what is still sending, then replies being written. */
51
+ function displayMessages(saved, pending, drafts, describe = () => ({ filename: "" })) {
52
+ const shown = new Set(saved.map((message) => message.clientMessageId).filter(Boolean));
53
+ const list = saved.map((message) => message.attachments?.length
54
+ ? { ...message, attachments: message.attachments.map((attachment) => ({ ...describe(attachment.url), ...attachment, filename: attachment.filename || describe(attachment.url).filename })) }
55
+ : message);
56
+ for (const entry of pending) {
57
+ if (shown.has(entry.clientMessageId))
58
+ continue;
59
+ list.push({
60
+ id: entry.clientMessageId, clientMessageId: entry.clientMessageId, role: "visitor", content: entry.content,
61
+ createdAt: entry.createdAt, status: entry.state === "failed" ? "failed" : "sending",
62
+ ...(entry.attachments.length ? { attachments: entry.attachments.map((url) => ({ url, ...describe(url) })) } : {}),
63
+ });
64
+ }
65
+ for (const [streamId, draft] of Object.entries(drafts)) {
66
+ const key = `stream:${streamId}`;
67
+ if (shown.has(key) || !draft.content)
68
+ continue;
69
+ list.push({ id: key, clientMessageId: key, role: "agent", content: draft.content, createdAt: draft.createdAt, isBot: true, isAutomated: true, isStreaming: true });
70
+ }
71
+ return list.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
72
+ }
73
+ /** How long after the last keystroke the agent stops seeing "typing" (the widget's value). */
74
+ const TYPING_IDLE_MS = 2500;
75
+ /** A short trip to the background (a file picker, the app switcher) keeps the socket, as in the Android SDK. */
76
+ exports.BACKGROUND_GRACE_MS = 5000;
77
+ const NOT_TYPING = { visible: false };
78
+ /** A failed start that cannot succeed by itself waits for the visitor instead of retrying. */
79
+ const NO_AUTO_RETRY = new Set(["identity_rejected", "invalid_session"]);
80
+ function useChatSession(options) {
81
+ const { tenantKey, baseUrl, visible, scope, sessionStorage } = options;
82
+ const callbacks = (0, react_1.useRef)(options);
83
+ callbacks.current = options;
84
+ const contact = (0, react_1.useRef)(options.contact);
85
+ const session = (0, react_1.useRef)(null);
86
+ const loadedStorage = (0, react_1.useRef)(false);
87
+ // Set by the first message or the pre-chat form: the next start creates the conversation.
88
+ const startNow = (0, react_1.useRef)(false);
89
+ const client = (0, react_1.useRef)(null);
90
+ const generation = (0, react_1.useRef)(0);
91
+ const outboxRef = (0, react_1.useRef)(null);
92
+ outboxRef.current ?? (outboxRef.current = new outbox_1.Outbox());
93
+ const outbox = outboxRef.current;
94
+ const visibleRef = (0, react_1.useRef)(visible);
95
+ visibleRef.current = visible;
96
+ // Files sent from this device: names and types the server's copy does not carry.
97
+ const localFiles = (0, react_1.useRef)(new Map());
98
+ const pushRequest = (0, react_1.useRef)(null);
99
+ // The last token the host registered: a conversation started after the team closed the previous
100
+ // one gets it again. Forgotten by reset() (sign-out) and unregisterPushToken().
101
+ const lastPush = (0, react_1.useRef)(null);
102
+ // Failed starts retried with the protocol's backoff; `auto` while such a retry runs.
103
+ const autoRetry = (0, react_1.useRef)({ failures: 0, timer: null, auto: false });
104
+ const lastTokenRenewal = (0, react_1.useRef)(0);
105
+ const sessionEnded = (0, react_1.useRef)(null);
106
+ const typing = (0, react_1.useRef)({ active: false, timer: null });
107
+ const configCache = (0, react_1.useRef)(null);
108
+ // The current connection's history catch-up, for a quick return from the background.
109
+ const catchUp = (0, react_1.useRef)(null);
110
+ const [attempt, setAttempt] = (0, react_1.useState)(0);
111
+ const [foreground, setForeground] = (0, react_1.useState)(react_native_1.AppState.currentState !== "background" && react_native_1.AppState.currentState !== "inactive");
112
+ const [phase, setPhase] = (0, react_1.useState)("loading");
113
+ const phaseRef = (0, react_1.useRef)(phase);
114
+ phaseRef.current = phase;
115
+ const [config, setConfig] = (0, react_1.useState)(null);
116
+ const [messages, setMessages] = (0, react_1.useState)([]);
117
+ const [pending, setPending] = (0, react_1.useState)(() => outbox.list());
118
+ const [drafts, setDrafts] = (0, react_1.useState)({});
119
+ const [typingState, setTypingState] = (0, react_1.useState)(NOT_TYPING);
120
+ const [tokenVersion, setTokenVersion] = (0, react_1.useState)(0);
121
+ // The last reply that arrived live on the socket (not from history), for screen-reader announcements.
122
+ const [lastIncoming, setLastIncoming] = (0, react_1.useState)(null);
123
+ const [currentAgent, setCurrentAgent] = (0, react_1.useState)({ name: null, avatar: null });
124
+ const [access, setAccess] = (0, react_1.useState)(null);
125
+ const [unreadCount, setUnreadCount] = (0, react_1.useState)(0);
126
+ const [error, setError] = (0, react_1.useState)(null);
127
+ const [sending, setSending] = (0, react_1.useState)(false);
128
+ const [uploads, setUploads] = (0, react_1.useState)([]);
129
+ const [requiredFields, setRequiredFields] = (0, react_1.useState)({});
130
+ const [hasConversation, setHasConversation] = (0, react_1.useState)(false);
131
+ // False once the server answered a rating route with 404: an older server, so the controls are hidden.
132
+ const [ratingsSupported, setRatingsSupported] = (0, react_1.useState)(true);
133
+ const [clock, setClock] = (0, react_1.useState)(0);
134
+ // The team closed the conversation: it stays on screen, read-only, until the visitor starts a new one.
135
+ const [closed, setClosed] = (0, react_1.useState)(false);
136
+ const closedRef = (0, react_1.useRef)(false);
137
+ closedRef.current = closed;
138
+ const messagesRef = (0, react_1.useRef)(messages);
139
+ messagesRef.current = messages;
140
+ const announcedRatings = (0, react_1.useRef)(new Set());
141
+ const keepConnected = options.keepConnected === true;
142
+ const wantConnection = foreground && (visible || keepConnected);
143
+ const report = (0, react_1.useCallback)((failure) => {
144
+ try {
145
+ callbacks.current.onError?.(failure);
146
+ }
147
+ catch { /* Host callbacks are isolated. */ }
148
+ }, []);
149
+ const status = (0, react_1.useCallback)((value) => {
150
+ try {
151
+ callbacks.current.onConnectionStatusChange?.(value);
152
+ }
153
+ catch { /* Host callbacks are isolated. */ }
154
+ }, []);
155
+ const captureAccess = (0, react_1.useCallback)((incoming) => {
156
+ if (incoming)
157
+ setAccess((current) => (0, attachments_1.newerAttachmentAccess)(current, incoming));
158
+ }, []);
159
+ const stopTyping = (0, react_1.useCallback)(() => {
160
+ const state = typing.current;
161
+ if (state.timer)
162
+ clearTimeout(state.timer);
163
+ state.timer = null;
164
+ if (state.active)
165
+ client.current?.sendTypingEnd();
166
+ state.active = false;
167
+ }, []);
168
+ (0, react_1.useEffect)(() => outbox.subscribe(() => setPending(outbox.list())), [outbox]);
169
+ // A profile edit (name, email, phone) keeps the conversation; the new details go with the next start.
170
+ const contactKey = JSON.stringify(options.contact ?? null);
171
+ const firstContact = (0, react_1.useRef)(contactKey);
172
+ (0, react_1.useEffect)(() => {
173
+ if (contactKey === firstContact.current)
174
+ return;
175
+ firstContact.current = contactKey;
176
+ contact.current = { ...contact.current, ...callbacks.current.contact };
177
+ }, [contactKey]);
178
+ // The socket closes only after a while in the background, so a picker or a glance at the app
179
+ // switcher does not reload the chat; iOS "inactive" (Control Centre, a system prompt) is ignored.
180
+ (0, react_1.useEffect)(() => {
181
+ const grace = callbacks.current.backgroundGraceMs ?? exports.BACKGROUND_GRACE_MS;
182
+ let timer = null;
183
+ let since = null;
184
+ const subscription = react_native_1.AppState.addEventListener("change", (state) => {
185
+ if (state === "background") {
186
+ if (since !== null)
187
+ return;
188
+ since = Date.now();
189
+ timer = setTimeout(() => { timer = null; setForeground(false); }, grace);
190
+ return;
191
+ }
192
+ if (state !== "active")
193
+ return;
194
+ // Timers stop while the OS suspends the app, so the time away decides, not whether the timer ran.
195
+ const away = since === null ? 0 : Date.now() - since;
196
+ if (timer)
197
+ clearTimeout(timer);
198
+ timer = null;
199
+ since = null;
200
+ setForeground(true);
201
+ if (away >= grace) {
202
+ setAttempt((value) => value + 1);
203
+ return;
204
+ }
205
+ // Back quickly: the socket was kept. Catch up on anything missed, or reconnect now if it dropped.
206
+ const activeClient = client.current;
207
+ if (activeClient?.isConnected)
208
+ void catchUp.current?.().catch(() => { });
209
+ else
210
+ activeClient?.reconnectNow();
211
+ });
212
+ return () => { subscription.remove(); if (timer)
213
+ clearTimeout(timer); };
214
+ }, []);
215
+ // Opening a chat that stayed connected while hidden shows what arrived; one with no session starts it.
216
+ (0, react_1.useEffect)(() => {
217
+ if (!visible)
218
+ return;
219
+ setUnreadCount(0);
220
+ if (keepConnected && phaseRef.current === "idle")
221
+ setAttempt((value) => value + 1);
222
+ }, [visible, keepConnected]);
223
+ (0, react_1.useEffect)(() => {
224
+ try {
225
+ callbacks.current.onUnreadCountChange?.(unreadCount);
226
+ }
227
+ catch { /* Host callbacks are isolated. */ }
228
+ }, [unreadCount]);
229
+ (0, react_1.useEffect)(() => {
230
+ const currentGeneration = ++generation.current;
231
+ if (!wantConnection) {
232
+ setTypingState(NOT_TYPING);
233
+ setDrafts({});
234
+ status("disconnected");
235
+ return;
236
+ }
237
+ let cancelled = false;
238
+ const controller = new AbortController();
239
+ let connection = null;
240
+ const alive = () => !cancelled && currentGeneration === generation.current;
241
+ const abort = () => {
242
+ cancelled = true;
243
+ if (generation.current === currentGeneration)
244
+ generation.current++;
245
+ controller.abort();
246
+ if (typing.current.active)
247
+ connection?.sendTypingEnd();
248
+ if (typing.current.timer)
249
+ clearTimeout(typing.current.timer);
250
+ typing.current = { active: false, timer: null };
251
+ connection?.disconnect();
252
+ if (client.current === connection)
253
+ client.current = null;
254
+ catchUp.current = null;
255
+ };
256
+ const storageFailure = () => report(new errors_1.GiovChatError("storage", "Chat session could not be saved or restored."));
257
+ const clearSession = async () => {
258
+ session.current = null;
259
+ setClosed(false);
260
+ setHasConversation(false);
261
+ setMessages([]);
262
+ try {
263
+ await sessionStorage?.remove(scope);
264
+ }
265
+ catch {
266
+ storageFailure();
267
+ }
268
+ };
269
+ // The session is gone (401/403 after one token renewal): end the chat and forget the stored pair;
270
+ // "Try again" then waits for the visitor's next message.
271
+ const endSession = async (failure) => {
272
+ if (!alive())
273
+ return;
274
+ connection?.disconnect();
275
+ if (client.current === connection)
276
+ client.current = null;
277
+ connection = null;
278
+ catchUp.current = null;
279
+ await clearSession();
280
+ if (!alive())
281
+ return;
282
+ setCurrentAgent({ name: null, avatar: null });
283
+ setDrafts({});
284
+ setTypingState(NOT_TYPING);
285
+ setError(failure);
286
+ setPhase("error");
287
+ status("disconnected");
288
+ };
289
+ // The conversation was closed or deleted: forget it; the visitor's next message starts a new one.
290
+ const forgetConversation = async () => {
291
+ if (!alive())
292
+ return;
293
+ connection?.disconnect();
294
+ if (client.current === connection)
295
+ client.current = null;
296
+ connection = null;
297
+ catchUp.current = null;
298
+ await clearSession();
299
+ if (!alive())
300
+ return;
301
+ setCurrentAgent({ name: null, avatar: null });
302
+ setDrafts({});
303
+ setTypingState(NOT_TYPING);
304
+ setPhase("ready");
305
+ status("disconnected");
306
+ setError(new errors_1.GiovChatError("conversation_closed", "This conversation has ended."));
307
+ };
308
+ sessionEnded.current = { forget: forgetConversation, end: endSession };
309
+ async function start() {
310
+ // The configuration is loaded once: a restart (the first message, the pre-chat form, coming
311
+ // back from the background) keeps the conversation on screen instead of a loading spinner.
312
+ const cacheKey = `${baseUrl} ${tenantKey}`;
313
+ const cached = configCache.current?.key === cacheKey ? configCache.current.config : null;
314
+ // An automatic retry after a failed start keeps the error on screen until it succeeds.
315
+ const quiet = autoRetry.current.auto && phaseRef.current === "error";
316
+ if (!cached) {
317
+ if (!quiet)
318
+ setPhase("loading");
319
+ setConfig(null);
320
+ }
321
+ else if (quiet) { /* Unchanged. */ }
322
+ else if (session.current)
323
+ setPhase("connecting");
324
+ else if (phaseRef.current === "error" || phaseRef.current === "idle")
325
+ setPhase("loading");
326
+ if (!quiet)
327
+ setError(null);
328
+ setTypingState(NOT_TYPING);
329
+ setSending(false);
330
+ status("connecting");
331
+ const cfg = cached ?? await (0, client_1.fetchWidgetConfig)(baseUrl, tenantKey, controller.signal);
332
+ if (!alive())
333
+ return;
334
+ configCache.current = { key: cacheKey, config: cfg };
335
+ setConfig(cfg);
336
+ if (!loadedStorage.current) {
337
+ loadedStorage.current = true;
338
+ try {
339
+ let stored = await sessionStorage?.load(scope);
340
+ // Stored by 0.2.0 under a key that held the contact details: move it to the current key.
341
+ const legacy = legacySessionScope(baseUrl, tenantKey, contact.current, callbacks.current.sessionKey);
342
+ if (!stored && sessionStorage && legacy !== scope) {
343
+ stored = await sessionStorage.load(legacy);
344
+ if (stored) {
345
+ await sessionStorage.save(scope, stored);
346
+ await sessionStorage.remove(legacy);
347
+ }
348
+ }
349
+ if (!alive())
350
+ return;
351
+ if (stored && typeof stored.conversationId === "string" && typeof stored.sessionToken === "string" &&
352
+ /^[a-f0-9]{64}$/.test(stored.sessionToken))
353
+ session.current = stored;
354
+ }
355
+ catch {
356
+ storageFailure();
357
+ }
358
+ }
359
+ if (!alive())
360
+ return;
361
+ // A 409 on resume means the team closed it: its history is shown read-only when the server still
362
+ // lets this session read it (a rating card can arrive after the close); otherwise it is forgotten.
363
+ let resumedClosed = false;
364
+ if (session.current) {
365
+ try {
366
+ session.current = await (0, client_1.createConversation)(baseUrl, tenantKey, contact.current, session.current, controller.signal);
367
+ }
368
+ catch (failure) {
369
+ if (!alive())
370
+ return;
371
+ const error = (0, errors_1.chatError)(failure);
372
+ if (error.code === "conversation_closed" && error.status === 409)
373
+ resumedClosed = true;
374
+ else {
375
+ if (error.code !== "invalid_session" && error.code !== "conversation_closed" && error.status !== 404)
376
+ throw error;
377
+ await clearSession();
378
+ }
379
+ }
380
+ }
381
+ if (!alive())
382
+ return;
383
+ // Hidden and nothing to resume: wait until the chat is opened instead of starting a conversation.
384
+ if (!visibleRef.current && !session.current) {
385
+ settled();
386
+ setPhase("idle");
387
+ status("disconnected");
388
+ return;
389
+ }
390
+ const missing = missingContactFields(cfg.requestUserInfo, contact.current);
391
+ setRequiredFields(missing);
392
+ if (!session.current && Object.keys(missing).length) {
393
+ settled();
394
+ setPhase("pre-chat");
395
+ status("disconnected");
396
+ return;
397
+ }
398
+ if (!session.current && !startNow.current) {
399
+ settled();
400
+ setPhase("ready");
401
+ status("disconnected");
402
+ return;
403
+ }
404
+ if (!autoRetry.current.auto || phaseRef.current !== "error")
405
+ setPhase("connecting");
406
+ if (!session.current) {
407
+ const created = await (0, client_1.createConversation)(baseUrl, tenantKey, contact.current, undefined, controller.signal);
408
+ if (!alive())
409
+ return;
410
+ startNow.current = false;
411
+ session.current = created;
412
+ try {
413
+ await sessionStorage?.save(scope, created);
414
+ }
415
+ catch {
416
+ storageFailure();
417
+ }
418
+ if (!alive())
419
+ return;
420
+ try {
421
+ callbacks.current.onConversationStart?.(created.conversationId);
422
+ }
423
+ catch { /* Host callback. */ }
424
+ // A new conversation for the same visitor (the team closed the last one): the device is registered again.
425
+ if (lastPush.current && !pushRequest.current)
426
+ pushRequest.current = { ...lastPush.current, resolve: () => { } };
427
+ }
428
+ if (!alive() || !session.current)
429
+ return;
430
+ const activeSession = session.current;
431
+ setHasConversation(true);
432
+ // The outbox goes out after the history catch-up, in the protocol's order.
433
+ connection = new client_1.ChatClient({ baseUrl, tenantKey, outbox, flushOnOpen: false });
434
+ client.current = connection;
435
+ let openedOnce = false;
436
+ connection.onMessage((message) => {
437
+ if (!alive())
438
+ return;
439
+ setMessages((previous) => mergeMessages(previous, [message]));
440
+ if (message.role === "agent") {
441
+ setTypingState(NOT_TYPING);
442
+ if (!message.isBot && !message.isAutomated && message.senderName?.trim()) {
443
+ setCurrentAgent({ name: message.senderName.trim(), avatar: message.senderAvatar ?? null });
444
+ }
445
+ if (!visibleRef.current)
446
+ setUnreadCount((count) => count + 1);
447
+ setLastIncoming(message);
448
+ }
449
+ });
450
+ connection.onAck((clientMessageId, message) => {
451
+ if (!alive())
452
+ return;
453
+ setMessages((previous) => mergeMessages(previous, [{ ...message, clientMessageId }]));
454
+ });
455
+ connection.onStream((event) => {
456
+ if (!alive())
457
+ return;
458
+ setDrafts((current) => {
459
+ if (event.end) {
460
+ if (!(event.streamId in current))
461
+ return current;
462
+ const next = { ...current };
463
+ delete next[event.streamId];
464
+ return next;
465
+ }
466
+ return { ...current, [event.streamId]: { content: event.content, createdAt: current[event.streamId]?.createdAt ?? Date.now() } };
467
+ });
468
+ if (!event.end && event.content)
469
+ setTypingState(NOT_TYPING);
470
+ });
471
+ connection.onAttachmentAccess((value) => { if (alive())
472
+ captureAccess(value); });
473
+ // A card was answered or changed on another device, or replaced by a newer one.
474
+ connection.onRating((frame) => { if (alive())
475
+ setMessages((previous) => (0, rating_1.patchRating)(previous, frame)); });
476
+ // The team closed or reopened the conversation: the switch is immediate (older servers send no status).
477
+ connection.onConversationState((state) => {
478
+ if (!alive())
479
+ return;
480
+ if (state.status === "closed") {
481
+ setClosed(true);
482
+ setTypingState(NOT_TYPING);
483
+ setDrafts({});
484
+ setError(null);
485
+ setPhase("active");
486
+ }
487
+ else if (state.status === "open") {
488
+ if (closedRef.current)
489
+ setClosed(false);
490
+ connection?.flush();
491
+ }
492
+ });
493
+ // A stop from the other sender (person or GiovBot) keeps the indicator that is showing.
494
+ connection.onTyping((active, isBot) => {
495
+ if (alive())
496
+ setTypingState((current) => (0, identity_1.reduceAgentTyping)(current, { type: active ? "typing" : "typing_end", role: "agent", isBot }));
497
+ });
498
+ connection.onError((failure) => {
499
+ if (!alive())
500
+ return;
501
+ report(failure);
502
+ if (failure.code === "conversation_closed") {
503
+ void forgetConversation();
504
+ return;
505
+ }
506
+ if (failure.code === "invalid_session") {
507
+ void endSession(failure);
508
+ return;
509
+ }
510
+ setError(failure);
511
+ });
512
+ // Catch-up: history merged by id with what arrived meanwhile, then the outbox is resent.
513
+ const refreshHistory = async () => {
514
+ let history;
515
+ try {
516
+ history = await (0, client_1.fetchConversationHistory)(baseUrl, tenantKey, activeSession, controller.signal);
517
+ }
518
+ catch (failure) {
519
+ const error = (0, errors_1.chatError)(failure);
520
+ if (error.status === 404) {
521
+ await forgetConversation();
522
+ return;
523
+ }
524
+ // A closed conversation this server will not let us read any more: forget it, as before.
525
+ if (resumedClosed && (error.code === "invalid_session" || error.status === 403)) {
526
+ await forgetConversation();
527
+ return;
528
+ }
529
+ if (error.code === "invalid_session") {
530
+ await endSession(error);
531
+ return;
532
+ }
533
+ throw failure;
534
+ }
535
+ if (!alive())
536
+ return;
537
+ captureAccess(history.attachmentAccess);
538
+ setCurrentAgent({ name: history.assignedAgentName ?? null, avatar: history.assignedAgentAvatar ?? null });
539
+ setMessages((previous) => mergeMessages(previous, history.messages));
540
+ if (history.closed) {
541
+ // Read-only from here on: the history stays visible and a rating card stays answerable.
542
+ setClosed(true);
543
+ setTypingState(NOT_TYPING);
544
+ setDrafts({});
545
+ setError(null);
546
+ setPhase("active");
547
+ return;
548
+ }
549
+ if (closedRef.current)
550
+ setClosed(false);
551
+ connection?.flush();
552
+ };
553
+ catchUp.current = refreshHistory;
554
+ connection.onStatusChange((connected) => {
555
+ if (!alive())
556
+ return;
557
+ if (!connected) {
558
+ setTypingState(NOT_TYPING);
559
+ // A reply being written is lost with the socket; the saved copy arrives with the catch-up.
560
+ setDrafts({});
561
+ }
562
+ status(connected ? "connected" : "disconnected");
563
+ if (connected)
564
+ settled();
565
+ if (connected || phaseRef.current !== "error")
566
+ setPhase(connected ? "active" : "connecting");
567
+ if (connected) {
568
+ setError(null);
569
+ // Catch up on whatever arrived while the socket was down.
570
+ if (openedOnce)
571
+ void refreshHistory().catch((failure) => {
572
+ if (!alive())
573
+ return;
574
+ report((0, errors_1.chatError)(failure));
575
+ setError((0, errors_1.chatError)(failure));
576
+ // Without the catch-up the outbox still goes out on this socket, rather than waiting for the next one.
577
+ connection?.flush();
578
+ });
579
+ openedOnce = true;
580
+ const push = pushRequest.current;
581
+ if (push && connection) {
582
+ pushRequest.current = null;
583
+ void connection.registerPushToken(push.token, push.platform, push.options).then(push.resolve);
584
+ }
585
+ }
586
+ });
587
+ try {
588
+ await connection.connect(activeSession);
589
+ }
590
+ catch (failure) {
591
+ if (!resumedClosed)
592
+ throw failure; /* The history below decides whether it can be shown. */
593
+ }
594
+ if (!alive())
595
+ return;
596
+ await refreshHistory();
597
+ }
598
+ // Started for good: later failures retry from the first backoff step.
599
+ function settled() { autoRetry.current.failures = 0; autoRetry.current.auto = false; }
600
+ void start().catch((failure) => {
601
+ if (!alive())
602
+ return;
603
+ abort();
604
+ const error = (0, errors_1.chatError)(failure);
605
+ if (error.code === "invalid_session")
606
+ void clearSession();
607
+ if (error.code === "identity_rejected") {
608
+ // No conversation can start for this identity: a registration waiting for one ends here.
609
+ pushRequest.current?.resolve({ registered: false, reason: "no_conversation" });
610
+ pushRequest.current = null;
611
+ }
612
+ setError(error);
613
+ setPhase("error");
614
+ status("disconnected");
615
+ report(error);
616
+ // A first connection that fails retries with the protocol's backoff while the error shows, as the web widget does.
617
+ if (!NO_AUTO_RETRY.has(error.code)) {
618
+ const retrying = autoRetry.current;
619
+ if (retrying.timer)
620
+ clearTimeout(retrying.timer);
621
+ retrying.timer = setTimeout(() => {
622
+ retrying.timer = null;
623
+ retrying.auto = true;
624
+ setAttempt((value) => value + 1);
625
+ }, (0, client_1.reconnectDelay)(retrying.failures++));
626
+ }
627
+ });
628
+ return () => {
629
+ abort();
630
+ status("disconnected");
631
+ if (autoRetry.current.timer)
632
+ clearTimeout(autoRetry.current.timer);
633
+ autoRetry.current.timer = null;
634
+ };
635
+ }, [tenantKey, baseUrl, wantConnection, attempt, scope, sessionStorage, outbox, report, status, captureAccess]);
636
+ const retry = (0, react_1.useCallback)(() => {
637
+ autoRetry.current = { ...autoRetry.current, failures: 0, auto: false };
638
+ setAttempt((value) => value + 1);
639
+ }, []);
640
+ const submitContact = (0, react_1.useCallback)((info) => {
641
+ contact.current = { ...contact.current, ...info };
642
+ startNow.current = true;
643
+ retry();
644
+ }, [retry]);
645
+ const reset = (0, react_1.useCallback)(async () => {
646
+ generation.current++;
647
+ stopTyping();
648
+ client.current?.disconnect();
649
+ client.current = null;
650
+ catchUp.current = null;
651
+ session.current = null;
652
+ contact.current = callbacks.current.contact;
653
+ loadedStorage.current = true;
654
+ outbox.clear();
655
+ localFiles.current.clear();
656
+ announcedRatings.current.clear();
657
+ setClosed(false);
658
+ startNow.current = false;
659
+ // Sign-out: the next visitor's conversation must not get this device's notifications.
660
+ lastPush.current = null;
661
+ pushRequest.current?.resolve({ registered: false, reason: "no_conversation" });
662
+ pushRequest.current = null;
663
+ setHasConversation(false);
664
+ setMessages([]);
665
+ setDrafts({});
666
+ setTypingState(NOT_TYPING);
667
+ setCurrentAgent({ name: null, avatar: null });
668
+ setAccess(null);
669
+ setUnreadCount(0);
670
+ setError(null);
671
+ try {
672
+ await sessionStorage?.remove(scope);
673
+ }
674
+ catch {
675
+ report(new errors_1.GiovChatError("storage", "Saved chat session could not be cleared."));
676
+ }
677
+ retry();
678
+ }, [sessionStorage, scope, report, retry, outbox, stopTyping]);
679
+ /**
680
+ * Leaves the closed conversation: it is cleared (messages, stored session) and the chat is ready for
681
+ * a new one, which starts with the visitor's next message. Contact details, the outbox and the push
682
+ * token are kept. Does nothing while the conversation is open.
683
+ */
684
+ const startNew = (0, react_1.useCallback)(async () => {
685
+ if (!closedRef.current)
686
+ return;
687
+ generation.current++;
688
+ stopTyping();
689
+ client.current?.disconnect();
690
+ client.current = null;
691
+ catchUp.current = null;
692
+ session.current = null;
693
+ startNow.current = false;
694
+ announcedRatings.current.clear();
695
+ setClosed(false);
696
+ setHasConversation(false);
697
+ setMessages([]);
698
+ setDrafts({});
699
+ setTypingState(NOT_TYPING);
700
+ setCurrentAgent({ name: null, avatar: null });
701
+ setError(null);
702
+ try {
703
+ await sessionStorage?.remove(scope);
704
+ }
705
+ catch {
706
+ report(new errors_1.GiovChatError("storage", "Saved chat session could not be cleared."));
707
+ }
708
+ retry();
709
+ }, [sessionStorage, scope, report, retry, stopTyping]);
710
+ /**
711
+ * Sends text and files. Files upload first (they need the conversation); the message then waits in
712
+ * the outbox until a socket takes it, so text typed while reconnecting is never lost. Several
713
+ * files go as one message each after the text, as in the widget. Resolves false when nothing was
714
+ * queued (empty, or an upload failed).
715
+ */
716
+ const send = (0, react_1.useCallback)(async (text, files = []) => {
717
+ const trimmed = text.trim();
718
+ if (!trimmed && !files.length)
719
+ return false;
720
+ const current = phaseRef.current;
721
+ if (closedRef.current)
722
+ return false;
723
+ if (current !== "active" && current !== "connecting" && current !== "ready")
724
+ return false;
725
+ // Uploads need a live conversation; the attach button only appears once one exists.
726
+ if (files.length && !session.current)
727
+ return false;
728
+ const currentGeneration = generation.current;
729
+ stopTyping();
730
+ setError(null);
731
+ const urls = [];
732
+ if (files.length) {
733
+ const activeClient = client.current;
734
+ if (!activeClient) {
735
+ setError(new errors_1.GiovChatError("disconnected", "Chat is reconnecting. Please try again."));
736
+ return false;
737
+ }
738
+ setSending(true);
739
+ setUploads(files.map((file) => ({ name: file.name, progress: 0 })));
740
+ try {
741
+ for (const [index, file] of files.entries()) {
742
+ const result = await activeClient.uploadAttachment(file, (progress) => {
743
+ if (generation.current === currentGeneration)
744
+ setUploads((current) => current.map((item, i) => i === index ? { ...item, progress } : item));
745
+ });
746
+ const preview = result.preview ?? (file.width && file.height ? { width: file.width, height: file.height } : undefined);
747
+ localFiles.current.set(result.url, { filename: result.filename || file.name, contentType: file.type, ...(preview ? { preview } : {}) });
748
+ urls.push(result.url);
749
+ }
750
+ }
751
+ catch (failure) {
752
+ if (generation.current === currentGeneration) {
753
+ const cause = (0, errors_1.chatError)(failure);
754
+ // The conversation or the session is gone: the same handling as on the socket.
755
+ if (cause.code === "conversation_closed") {
756
+ report(cause);
757
+ void sessionEnded.current?.forget();
758
+ return false;
759
+ }
760
+ if (cause.code === "invalid_session") {
761
+ report(cause);
762
+ void sessionEnded.current?.end(cause);
763
+ return false;
764
+ }
765
+ // Rate limits, server and network failures keep their code (and Retry-After) for the host;
766
+ // the visitor reads that the upload failed.
767
+ report(cause.code === "upload_failed" ? cause : new errors_1.GiovChatError(cause.code, "File upload failed.", cause.status, cause.retryAfterSeconds));
768
+ setError(new errors_1.GiovChatError("upload_failed", "File upload failed.", cause.status, cause.retryAfterSeconds));
769
+ }
770
+ return false;
771
+ }
772
+ finally {
773
+ if (generation.current === currentGeneration) {
774
+ setSending(false);
775
+ setUploads([]);
776
+ }
777
+ }
778
+ if (generation.current !== currentGeneration)
779
+ return false;
780
+ }
781
+ const parts = urls.length > 1
782
+ ? [...(trimmed ? [{ content: trimmed, attachments: [] }] : []), ...urls.map((url) => ({ content: "", attachments: [url] }))]
783
+ : [{ content: trimmed, attachments: urls }];
784
+ try {
785
+ for (const part of parts)
786
+ outbox.enqueue(part.content, part.attachments);
787
+ }
788
+ catch (failure) {
789
+ const failed = (0, errors_1.chatError)(failure);
790
+ report(failed);
791
+ setError(failed);
792
+ return false;
793
+ }
794
+ if (session.current)
795
+ client.current?.flush();
796
+ else if (!startNow.current) {
797
+ // The first message starts the conversation; it goes out once the socket is open.
798
+ startNow.current = true;
799
+ setAttempt((value) => value + 1);
800
+ }
801
+ return true;
802
+ }, [outbox, report, stopTyping]);
803
+ /** Tries a failed message again; it is sent in its original place in the queue. */
804
+ const retryMessage = (0, react_1.useCallback)((clientMessageId) => {
805
+ if (outbox.retry(clientMessageId))
806
+ client.current?.flush();
807
+ }, [outbox]);
808
+ const discardMessage = (0, react_1.useCallback)((clientMessageId) => { outbox.remove(clientMessageId); }, [outbox]);
809
+ /** Call on every keystroke: the agent sees "typing" once per burst and it ends after a pause. */
810
+ const notifyTyping = (0, react_1.useCallback)(() => {
811
+ const activeClient = client.current;
812
+ if (!activeClient?.isConnected)
813
+ return;
814
+ const state = typing.current;
815
+ if (!state.active) {
816
+ activeClient.sendTyping();
817
+ state.active = true;
818
+ }
819
+ if (state.timer)
820
+ clearTimeout(state.timer);
821
+ state.timer = setTimeout(() => { state.timer = null; state.active = false; client.current?.sendTypingEnd(); }, TYPING_IDLE_MS);
822
+ }, []);
823
+ /** Registers now when connected, or once the conversation connects. Never throws. */
824
+ const registerPushToken = (0, react_1.useCallback)((token, platform, options) => {
825
+ lastPush.current = { token, platform, options };
826
+ const activeClient = client.current;
827
+ if (activeClient?.isConnected)
828
+ return activeClient.registerPushToken(token, platform, options);
829
+ return new Promise((resolve) => {
830
+ pushRequest.current?.resolve({ registered: false, reason: "failed" });
831
+ pushRequest.current = { token, platform, options, resolve };
832
+ });
833
+ }, []);
834
+ /** Stops push for this device; a registration still waiting for the connection is dropped. Never throws. */
835
+ const unregisterPushToken = (0, react_1.useCallback)(async (token) => {
836
+ if (pushRequest.current?.token === token) {
837
+ pushRequest.current.resolve({ registered: false, reason: "failed" });
838
+ pushRequest.current = null;
839
+ }
840
+ if (lastPush.current?.token === token)
841
+ lastPush.current = null;
842
+ // While the chat is closed or hidden there is no connection: the stored session is enough.
843
+ let stored = session.current;
844
+ if (!stored && !loadedStorage.current) {
845
+ try {
846
+ const loaded = await sessionStorage?.load(scope);
847
+ if (loaded && typeof loaded.conversationId === "string" && /^[a-f0-9]{64}$/.test(String(loaded.sessionToken)))
848
+ stored = loaded;
849
+ }
850
+ catch { /* Nothing stored that can be used. */ }
851
+ }
852
+ if (client.current)
853
+ return client.current.unregisterPushToken(token, stored);
854
+ return stored ? (0, client_1.unregisterPushToken)(baseUrl, tenantKey, stored, token) : false;
855
+ }, [baseUrl, tenantKey, scope, sessionStorage]);
856
+ (0, react_1.useEffect)(() => () => {
857
+ if (typing.current.timer)
858
+ clearTimeout(typing.current.timer);
859
+ pushRequest.current?.resolve({ registered: false, reason: "no_conversation" });
860
+ pushRequest.current = null;
861
+ }, []);
862
+ const noteUnsupported = (0, react_1.useCallback)((failure) => { if (failure.code === "unsupported")
863
+ setRatingsSupported(false); }, []);
864
+ /**
865
+ * Rates the conversation. `requestId` defaults to the open card (the server picks it when none is
866
+ * known). Rejects with a `GiovChatError` (`rating_closed`, `request_replaced`, `too_many_changes`,
867
+ * `invalid_rating`, `rate_limited`, `unsupported`, ...). The card on screen is patched on success and
868
+ * made read-only or hidden when the server says it is closed or replaced.
869
+ */
870
+ const submitRating = (0, react_1.useCallback)(async (score, options = {}) => {
871
+ const activeClient = client.current;
872
+ if (!activeClient)
873
+ throw new errors_1.GiovChatError("disconnected", "Chat is not connected.");
874
+ const requestId = options.requestId ?? (0, rating_1.pendingRatingRequest)(messagesRef.current, Date.now())?.requestId;
875
+ try {
876
+ const result = await activeClient.submitRating(score, { comment: options.comment, requestId });
877
+ setMessages((previous) => (0, rating_1.patchRating)(previous, { requestId: result.requestId, closesAt: result.closesAt, score: result.score, comment: result.comment }));
878
+ try {
879
+ callbacks.current.onRatingSubmitted?.(result);
880
+ }
881
+ catch { /* Host callbacks are isolated. */ }
882
+ return result;
883
+ }
884
+ catch (failure) {
885
+ const error = (0, errors_1.chatError)(failure);
886
+ noteUnsupported(error);
887
+ if (requestId && error.code === "rating_closed")
888
+ setMessages((previous) => (0, rating_1.markRating)(previous, requestId, "closed"));
889
+ if (requestId && error.code === "request_replaced")
890
+ setMessages((previous) => (0, rating_1.markRating)(previous, requestId, "replaced"));
891
+ throw error;
892
+ }
893
+ }, [noteUnsupported]);
894
+ /** Thumbs on a GiovBot answer, shown at once and taken back if the server refuses. `none` removes the vote. */
895
+ const submitAnswerFeedback = (0, react_1.useCallback)(async (messageId, value, options = {}) => {
896
+ const activeClient = client.current;
897
+ if (!activeClient)
898
+ throw new errors_1.GiovChatError("disconnected", "Chat is not connected.");
899
+ const before = messagesRef.current.find((message) => message.id === messageId)?.feedback ?? "none";
900
+ setMessages((previous) => (0, rating_1.applyFeedback)(previous, messageId, value));
901
+ try {
902
+ await activeClient.submitAnswerFeedback(messageId, value, options);
903
+ }
904
+ catch (failure) {
905
+ const error = (0, errors_1.chatError)(failure);
906
+ noteUnsupported(error);
907
+ setMessages((previous) => (0, rating_1.applyFeedback)(previous, messageId, before));
908
+ throw error;
909
+ }
910
+ try {
911
+ callbacks.current.onAnswerFeedback?.({ messageId, value, ...(value === "down" && options.reason?.trim() ? { reason: options.reason.trim() } : {}) });
912
+ }
913
+ catch { /* Host callbacks are isolated. */ }
914
+ }, [noteUnsupported]);
915
+ const describe = (0, react_1.useCallback)((url) => localFiles.current.get(url) ?? { filename: "" }, []);
916
+ // An older card the visitor never answered gives way to a newer one at once.
917
+ const shown = (0, react_1.useMemo)(() => (0, rating_1.supersedeRatings)(displayMessages(messages, pending, drafts, describe)), [messages, pending, drafts, describe]);
918
+ const ratingRequest = (0, react_1.useMemo)(() => (0, rating_1.pendingRatingRequest)(messages, Date.now()), [messages, clock]); // eslint-disable-line react-hooks/exhaustive-deps
919
+ // The card closes by the clock: look again at `closesAt`.
920
+ (0, react_1.useEffect)(() => {
921
+ if (!ratingRequest)
922
+ return;
923
+ const wait = ratingRequest.closesAt - Date.now();
924
+ if (wait <= 0)
925
+ return;
926
+ const timer = setTimeout(() => setClock((value) => value + 1), Math.min(wait + 50, 2 ** 31 - 1));
927
+ return () => clearTimeout(timer);
928
+ }, [ratingRequest]);
929
+ // "Rating requested" once per card the visitor has not answered.
930
+ (0, react_1.useEffect)(() => {
931
+ if (!ratingRequest || ratingRequest.answered || announcedRatings.current.has(ratingRequest.requestId))
932
+ return;
933
+ announcedRatings.current.add(ratingRequest.requestId);
934
+ try {
935
+ callbacks.current.onRatingRequested?.(ratingRequest);
936
+ }
937
+ catch { /* Host callbacks are isolated. */ }
938
+ }, [ratingRequest]);
939
+ // A conversation open for a long time: without a usable access token, images fall back to the
940
+ // 15-minute visitor token. Renew it when messages arrive and it is close to expiry, at most once a
941
+ // minute (the web widget's rule); the new URLs make failed images load again.
942
+ (0, react_1.useEffect)(() => {
943
+ const activeClient = client.current;
944
+ const now = Date.now();
945
+ if (!activeClient || (0, attachments_1.usableAttachmentToken)(access, now))
946
+ return;
947
+ if (activeClient.visitorTokenExpiresAt - now > 2 * 60000 || now - lastTokenRenewal.current < 60000)
948
+ return;
949
+ lastTokenRenewal.current = now;
950
+ void activeClient.renewVisitorToken().then((renewed) => { if (renewed)
951
+ setTokenVersion((value) => value + 1); });
952
+ }, [messages, access]);
953
+ const attachmentUrl = (0, react_1.useCallback)((url) => (0, attachments_1.attachmentDisplayUrl)(url, (0, attachments_1.usableAttachmentToken)(access, Date.now()), client.current?.visitorToken),
954
+ // tokenVersion: a renewed visitor token changes the URLs.
955
+ [access, tokenVersion]);
956
+ return {
957
+ phase, config, messages: shown, agentTyping: typingState.visible, agentTypingIsBot: typingState.visible ? typingState.isBot : undefined, error, sending, uploads, requiredFields, hasConversation,
958
+ currentAgentName: currentAgent.name, currentAgentAvatar: currentAgent.avatar, unreadCount: visible ? 0 : unreadCount,
959
+ lastIncoming,
960
+ retry, reset, submitContact, send, retryMessage, discardMessage, notifyTyping, attachmentUrl, registerPushToken, unregisterPushToken,
961
+ ratingRequest, ratingsSupported, submitRating, submitAnswerFeedback,
962
+ closed, startNew,
963
+ };
964
+ }
965
+ //# sourceMappingURL=useChatSession.js.map