@giovchat/react-native 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +333 -2
  3. package/dist/GiovChat.d.ts +100 -0
  4. package/dist/GiovChat.d.ts.map +1 -0
  5. package/dist/GiovChat.js +257 -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/client.d.ts +190 -0
  16. package/dist/client.d.ts.map +1 -0
  17. package/dist/client.js +801 -0
  18. package/dist/client.js.map +1 -0
  19. package/dist/color.d.ts +25 -0
  20. package/dist/color.d.ts.map +1 -0
  21. package/dist/color.js +78 -0
  22. package/dist/color.js.map +1 -0
  23. package/dist/components/Attachments.d.ts +13 -0
  24. package/dist/components/Attachments.d.ts.map +1 -0
  25. package/dist/components/Attachments.js +42 -0
  26. package/dist/components/Attachments.js.map +1 -0
  27. package/dist/components/Branding.d.ts +6 -0
  28. package/dist/components/Branding.d.ts.map +1 -0
  29. package/dist/components/Branding.js +28 -0
  30. package/dist/components/Branding.js.map +1 -0
  31. package/dist/components/ChatMarkdown.d.ts +15 -0
  32. package/dist/components/ChatMarkdown.d.ts.map +1 -0
  33. package/dist/components/ChatMarkdown.js +72 -0
  34. package/dist/components/ChatMarkdown.js.map +1 -0
  35. package/dist/components/GiovBotMark.d.ts +6 -0
  36. package/dist/components/GiovBotMark.d.ts.map +1 -0
  37. package/dist/components/GiovBotMark.js +11 -0
  38. package/dist/components/GiovBotMark.js.map +1 -0
  39. package/dist/components/GreetingBubble.d.ts +7 -0
  40. package/dist/components/GreetingBubble.d.ts.map +1 -0
  41. package/dist/components/GreetingBubble.js +12 -0
  42. package/dist/components/GreetingBubble.js.map +1 -0
  43. package/dist/components/Header.d.ts +18 -0
  44. package/dist/components/Header.d.ts.map +1 -0
  45. package/dist/components/Header.js +67 -0
  46. package/dist/components/Header.js.map +1 -0
  47. package/dist/components/MessageBubble.d.ts +16 -0
  48. package/dist/components/MessageBubble.d.ts.map +1 -0
  49. package/dist/components/MessageBubble.js +49 -0
  50. package/dist/components/MessageBubble.js.map +1 -0
  51. package/dist/components/PreChatForm.d.ts +11 -0
  52. package/dist/components/PreChatForm.d.ts.map +1 -0
  53. package/dist/components/PreChatForm.js +72 -0
  54. package/dist/components/PreChatForm.js.map +1 -0
  55. package/dist/components/TypingIndicator.d.ts +9 -0
  56. package/dist/components/TypingIndicator.d.ts.map +1 -0
  57. package/dist/components/TypingIndicator.js +18 -0
  58. package/dist/components/TypingIndicator.js.map +1 -0
  59. package/dist/components/WidgetStyles.d.ts +3 -0
  60. package/dist/components/WidgetStyles.d.ts.map +1 -0
  61. package/dist/components/WidgetStyles.js +9 -0
  62. package/dist/components/WidgetStyles.js.map +1 -0
  63. package/dist/components/bubbleStyles.d.ts +47 -0
  64. package/dist/components/bubbleStyles.d.ts.map +1 -0
  65. package/dist/components/bubbleStyles.js +19 -0
  66. package/dist/components/bubbleStyles.js.map +1 -0
  67. package/dist/components/index.d.ts +11 -0
  68. package/dist/components/index.d.ts.map +1 -0
  69. package/dist/components/index.js +14 -0
  70. package/dist/components/index.js.map +1 -0
  71. package/dist/errors.d.ts +18 -0
  72. package/dist/errors.d.ts.map +1 -0
  73. package/dist/errors.js +26 -0
  74. package/dist/errors.js.map +1 -0
  75. package/dist/i18n.d.ts +73 -0
  76. package/dist/i18n.d.ts.map +1 -0
  77. package/dist/i18n.js +292 -0
  78. package/dist/i18n.js.map +1 -0
  79. package/dist/identity.d.ts +41 -0
  80. package/dist/identity.d.ts.map +1 -0
  81. package/dist/identity.js +41 -0
  82. package/dist/identity.js.map +1 -0
  83. package/dist/index.d.ts +20 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +46 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/markdown.d.ts +56 -0
  88. package/dist/markdown.d.ts.map +1 -0
  89. package/dist/markdown.js +219 -0
  90. package/dist/markdown.js.map +1 -0
  91. package/dist/outbox.d.ts +68 -0
  92. package/dist/outbox.d.ts.map +1 -0
  93. package/dist/outbox.js +168 -0
  94. package/dist/outbox.js.map +1 -0
  95. package/dist/protocol.d.ts +9 -0
  96. package/dist/protocol.d.ts.map +1 -0
  97. package/dist/protocol.js +34 -0
  98. package/dist/protocol.js.map +1 -0
  99. package/dist/styleOverrides.d.ts +37 -0
  100. package/dist/styleOverrides.d.ts.map +1 -0
  101. package/dist/styleOverrides.js +54 -0
  102. package/dist/styleOverrides.js.map +1 -0
  103. package/dist/types.d.ts +111 -0
  104. package/dist/types.d.ts.map +1 -0
  105. package/dist/types.js +3 -0
  106. package/dist/types.js.map +1 -0
  107. package/dist/unread.d.ts +9 -0
  108. package/dist/unread.d.ts.map +1 -0
  109. package/dist/unread.js +49 -0
  110. package/dist/unread.js.map +1 -0
  111. package/dist/useChatSession.d.ts +78 -0
  112. package/dist/useChatSession.d.ts.map +1 -0
  113. package/dist/useChatSession.js +796 -0
  114. package/dist/useChatSession.js.map +1 -0
  115. package/package.json +59 -4
  116. package/src/GiovChat.tsx +530 -0
  117. package/src/attachments.ts +218 -0
  118. package/src/brand-assets.ts +5 -0
  119. package/src/client.ts +758 -0
  120. package/src/color.ts +74 -0
  121. package/src/components/Attachments.tsx +75 -0
  122. package/src/components/Branding.tsx +35 -0
  123. package/src/components/ChatMarkdown.tsx +111 -0
  124. package/src/components/GiovBotMark.tsx +19 -0
  125. package/src/components/GreetingBubble.tsx +20 -0
  126. package/src/components/Header.tsx +131 -0
  127. package/src/components/MessageBubble.tsx +121 -0
  128. package/src/components/PreChatForm.tsx +183 -0
  129. package/src/components/TypingIndicator.tsx +34 -0
  130. package/src/components/WidgetStyles.tsx +5 -0
  131. package/src/components/bubbleStyles.ts +16 -0
  132. package/src/components/index.ts +14 -0
  133. package/src/errors.ts +30 -0
  134. package/src/i18n.ts +351 -0
  135. package/src/identity.ts +54 -0
  136. package/src/index.ts +68 -0
  137. package/src/markdown.ts +227 -0
  138. package/src/outbox.ts +175 -0
  139. package/src/protocol.ts +31 -0
  140. package/src/styleOverrides.ts +85 -0
  141. package/src/types.ts +119 -0
  142. package/src/unread.ts +39 -0
  143. package/src/useChatSession.ts +695 -0
@@ -0,0 +1,695 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { AppState } from "react-native";
3
+ import {
4
+ ChatClient, createConversation, fetchConversationHistory, fetchWidgetConfig, reconnectDelay, unregisterPushToken as unregisterStoredPushToken,
5
+ type GiovPushOptions, type GiovPushPlatform, type GiovPushRegistration,
6
+ } from "./client";
7
+ import { reduceAgentTyping, type AgentTypingState } from "./identity";
8
+ import { chatError, GiovChatError } from "./errors";
9
+ import { Outbox, type OutboxEntry } from "./outbox";
10
+ import {
11
+ attachmentDisplayUrl,
12
+ newerAttachmentAccess,
13
+ usableAttachmentToken,
14
+ type AttachmentAccess,
15
+ type GiovAttachment,
16
+ type GiovAttachmentPreview,
17
+ type GiovChatPickedFile,
18
+ } from "./attachments";
19
+ import type { GiovChatConnectionStatus, GiovChatContact, GiovChatSession, GiovChatSessionStorage, GiovMessage, GiovWidgetConfig, RequestUserInfo } from "./types";
20
+
21
+ export function missingContactFields(fields: RequestUserInfo = {}, contact: GiovChatContact = {}): RequestUserInfo {
22
+ const missing: RequestUserInfo = {};
23
+ for (const field of ["email", "phone", "firstName", "lastName"] as const) {
24
+ const value = contact[field]?.trim() ?? "";
25
+ if (fields[field] && (!value || (field === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)))) missing[field] = true;
26
+ }
27
+ return missing;
28
+ }
29
+
30
+ /**
31
+ * The storage key of a visitor's session: the API, the workspace, the host's `sessionKey` and the
32
+ * signed-in `userId`. Contact details are not part of it, so a profile edit keeps the conversation.
33
+ */
34
+ export function sessionScope(baseUrl: string, tenantKey: string, contact?: GiovChatContact, sessionKey?: string | number): string {
35
+ return JSON.stringify([baseUrl.replace(/\/$/, ""), tenantKey, sessionKey ?? null, contact?.userId ?? null]);
36
+ }
37
+
38
+ /** The 0.2.0 key, which also held the contact details; read once so an upgrade keeps the conversation. */
39
+ export function legacySessionScope(baseUrl: string, tenantKey: string, contact?: GiovChatContact, sessionKey?: string | number): string {
40
+ return JSON.stringify([baseUrl.replace(/\/$/, ""), tenantKey, sessionKey ?? null,
41
+ contact?.userId ?? null, contact?.email ?? null, contact?.phone ?? null,
42
+ contact?.firstName ?? null, contact?.lastName ?? null]);
43
+ }
44
+
45
+ /** By server id; a copy without a local key keeps the one it was shown under, so the bubble is not redrawn. */
46
+ export function mergeMessages(current: GiovMessage[], incoming: GiovMessage[]): GiovMessage[] {
47
+ const messages = new Map(current.map((message) => [message.id, message]));
48
+ for (const message of incoming) {
49
+ const existing = messages.get(message.id);
50
+ messages.set(message.id, existing?.clientMessageId && !message.clientMessageId
51
+ ? { ...message, clientMessageId: existing.clientMessageId } : message);
52
+ }
53
+ return [...messages.values()].sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
54
+ }
55
+
56
+ /** What the visitor sees: saved messages, then what is still sending, then replies being written. */
57
+ export function displayMessages(
58
+ saved: GiovMessage[], pending: readonly OutboxEntry[], drafts: Readonly<Record<string, { content: string; createdAt: number }>>,
59
+ describe: (url: string) => Omit<GiovAttachment, "url"> = () => ({ filename: "" }),
60
+ ): GiovMessage[] {
61
+ const shown = new Set(saved.map((message) => message.clientMessageId).filter(Boolean));
62
+ const list = saved.map((message) => message.attachments?.length
63
+ ? { ...message, attachments: message.attachments.map((attachment) => ({ ...describe(attachment.url), ...attachment, filename: attachment.filename || describe(attachment.url).filename })) }
64
+ : message);
65
+ for (const entry of pending) {
66
+ if (shown.has(entry.clientMessageId)) continue;
67
+ list.push({
68
+ id: entry.clientMessageId, clientMessageId: entry.clientMessageId, role: "visitor", content: entry.content,
69
+ createdAt: entry.createdAt, status: entry.state === "failed" ? "failed" : "sending",
70
+ ...(entry.attachments.length ? { attachments: entry.attachments.map((url) => ({ url, ...describe(url) })) } : {}),
71
+ });
72
+ }
73
+ for (const [streamId, draft] of Object.entries(drafts)) {
74
+ const key = `stream:${streamId}`;
75
+ if (shown.has(key) || !draft.content) continue;
76
+ list.push({ id: key, clientMessageId: key, role: "agent", content: draft.content, createdAt: draft.createdAt, isBot: true, isAutomated: true, isStreaming: true });
77
+ }
78
+ return list.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
79
+ }
80
+
81
+ type Options = {
82
+ tenantKey: string; baseUrl: string; visible: boolean; contact?: GiovChatContact;
83
+ scope: string; sessionStorage?: GiovChatSessionStorage;
84
+ /** The host's `sessionKey`, for reading a session stored under the 0.2.0 key. */
85
+ sessionKey?: string | number;
86
+ /** Stay connected while hidden (app in the foreground) to count unread replies. Never starts a conversation. */
87
+ keepConnected?: boolean;
88
+ /** How long the app may be in the background before the socket closes (so the server pushes replies). */
89
+ backgroundGraceMs?: number;
90
+ onError?: (error: GiovChatError) => void;
91
+ onConnectionStatusChange?: (status: GiovChatConnectionStatus) => void;
92
+ onConversationStart?: (conversationId: string) => void;
93
+ onUnreadCountChange?: (count: number) => void;
94
+ };
95
+
96
+ /**
97
+ * `ready`: the chat can be used but no conversation exists yet. Per the visitor protocol a native
98
+ * client starts one when the visitor sends the first message (or submits the pre-chat form), so
99
+ * opening the screen never creates an empty conversation. `idle`: hidden with nothing to resume.
100
+ */
101
+ export type ChatPhase = "idle" | "loading" | "pre-chat" | "ready" | "connecting" | "active" | "error";
102
+ export type UploadProgress = { name: string; progress: number };
103
+
104
+ /** How long after the last keystroke the agent stops seeing "typing" (the widget's value). */
105
+ const TYPING_IDLE_MS = 2500;
106
+ /** A short trip to the background (a file picker, the app switcher) keeps the socket, as in the Android SDK. */
107
+ export const BACKGROUND_GRACE_MS = 5000;
108
+ const NOT_TYPING: AgentTypingState = { visible: false };
109
+ /** A failed start that cannot succeed by itself waits for the visitor instead of retrying. */
110
+ const NO_AUTO_RETRY = new Set(["identity_rejected", "invalid_session"]);
111
+
112
+ export function useChatSession(options: Options) {
113
+ const { tenantKey, baseUrl, visible, scope, sessionStorage } = options;
114
+ const callbacks = useRef(options);
115
+ callbacks.current = options;
116
+ const contact = useRef(options.contact);
117
+ const session = useRef<GiovChatSession | null>(null);
118
+ const loadedStorage = useRef(false);
119
+ // Set by the first message or the pre-chat form: the next start creates the conversation.
120
+ const startNow = useRef(false);
121
+ const client = useRef<ChatClient | null>(null);
122
+ const generation = useRef(0);
123
+ const outboxRef = useRef<Outbox | null>(null);
124
+ outboxRef.current ??= new Outbox();
125
+ const outbox = outboxRef.current;
126
+ const visibleRef = useRef(visible);
127
+ visibleRef.current = visible;
128
+ // Files sent from this device: names and types the server's copy does not carry.
129
+ const localFiles = useRef(new Map<string, Omit<GiovAttachment, "url">>());
130
+ const pushRequest = useRef<{ token: string; platform: GiovPushPlatform; options?: GiovPushOptions; resolve: (value: GiovPushRegistration) => void } | null>(null);
131
+ // The last token the host registered: a conversation started after the team closed the previous
132
+ // one gets it again. Forgotten by reset() (sign-out) and unregisterPushToken().
133
+ const lastPush = useRef<{ token: string; platform: GiovPushPlatform; options?: GiovPushOptions } | null>(null);
134
+ // Failed starts retried with the protocol's backoff; `auto` while such a retry runs.
135
+ const autoRetry = useRef<{ failures: number; timer: ReturnType<typeof setTimeout> | null; auto: boolean }>({ failures: 0, timer: null, auto: false });
136
+ const lastTokenRenewal = useRef(0);
137
+ const sessionEnded = useRef<{ forget: () => Promise<void>; end: (failure: GiovChatError) => Promise<void> } | null>(null);
138
+ const typing = useRef<{ active: boolean; timer: ReturnType<typeof setTimeout> | null }>({ active: false, timer: null });
139
+ const configCache = useRef<{ key: string; config: GiovWidgetConfig } | null>(null);
140
+ // The current connection's history catch-up, for a quick return from the background.
141
+ const catchUp = useRef<(() => Promise<void>) | null>(null);
142
+ const [attempt, setAttempt] = useState(0);
143
+ const [foreground, setForeground] = useState(AppState.currentState !== "background" && AppState.currentState !== "inactive");
144
+ const [phase, setPhase] = useState<ChatPhase>("loading");
145
+ const phaseRef = useRef(phase);
146
+ phaseRef.current = phase;
147
+ const [config, setConfig] = useState<GiovWidgetConfig | null>(null);
148
+ const [messages, setMessages] = useState<GiovMessage[]>([]);
149
+ const [pending, setPending] = useState<OutboxEntry[]>(() => outbox.list());
150
+ const [drafts, setDrafts] = useState<Record<string, { content: string; createdAt: number }>>({});
151
+ const [typingState, setTypingState] = useState<AgentTypingState>(NOT_TYPING);
152
+ const [tokenVersion, setTokenVersion] = useState(0);
153
+ // The last reply that arrived live on the socket (not from history), for screen-reader announcements.
154
+ const [lastIncoming, setLastIncoming] = useState<GiovMessage | null>(null);
155
+ const [currentAgent, setCurrentAgent] = useState<{ name: string | null; avatar: string | null }>({ name: null, avatar: null });
156
+ const [access, setAccess] = useState<AttachmentAccess | null>(null);
157
+ const [unreadCount, setUnreadCount] = useState(0);
158
+ const [error, setError] = useState<GiovChatError | null>(null);
159
+ const [sending, setSending] = useState(false);
160
+ const [uploads, setUploads] = useState<UploadProgress[]>([]);
161
+ const [requiredFields, setRequiredFields] = useState<RequestUserInfo>({});
162
+ const [hasConversation, setHasConversation] = useState(false);
163
+ const keepConnected = options.keepConnected === true;
164
+ const wantConnection = foreground && (visible || keepConnected);
165
+
166
+ const report = useCallback((failure: GiovChatError) => {
167
+ try { callbacks.current.onError?.(failure); } catch { /* Host callbacks are isolated. */ }
168
+ }, []);
169
+ const status = useCallback((value: GiovChatConnectionStatus) => {
170
+ try { callbacks.current.onConnectionStatusChange?.(value); } catch { /* Host callbacks are isolated. */ }
171
+ }, []);
172
+ const captureAccess = useCallback((incoming: AttachmentAccess | null | undefined) => {
173
+ if (incoming) setAccess((current) => newerAttachmentAccess(current, incoming));
174
+ }, []);
175
+ const stopTyping = useCallback(() => {
176
+ const state = typing.current;
177
+ if (state.timer) clearTimeout(state.timer);
178
+ state.timer = null;
179
+ if (state.active) client.current?.sendTypingEnd();
180
+ state.active = false;
181
+ }, []);
182
+
183
+ useEffect(() => outbox.subscribe(() => setPending(outbox.list())), [outbox]);
184
+
185
+ // A profile edit (name, email, phone) keeps the conversation; the new details go with the next start.
186
+ const contactKey = JSON.stringify(options.contact ?? null);
187
+ const firstContact = useRef(contactKey);
188
+ useEffect(() => {
189
+ if (contactKey === firstContact.current) return;
190
+ firstContact.current = contactKey;
191
+ contact.current = { ...contact.current, ...callbacks.current.contact };
192
+ }, [contactKey]);
193
+
194
+ // The socket closes only after a while in the background, so a picker or a glance at the app
195
+ // switcher does not reload the chat; iOS "inactive" (Control Centre, a system prompt) is ignored.
196
+ useEffect(() => {
197
+ const grace = callbacks.current.backgroundGraceMs ?? BACKGROUND_GRACE_MS;
198
+ let timer: ReturnType<typeof setTimeout> | null = null;
199
+ let since: number | null = null;
200
+ const subscription = AppState.addEventListener("change", (state) => {
201
+ if (state === "background") {
202
+ if (since !== null) return;
203
+ since = Date.now();
204
+ timer = setTimeout(() => { timer = null; setForeground(false); }, grace);
205
+ return;
206
+ }
207
+ if (state !== "active") return;
208
+ // Timers stop while the OS suspends the app, so the time away decides, not whether the timer ran.
209
+ const away = since === null ? 0 : Date.now() - since;
210
+ if (timer) clearTimeout(timer);
211
+ timer = null;
212
+ since = null;
213
+ setForeground(true);
214
+ if (away >= grace) { setAttempt((value) => value + 1); return; }
215
+ // Back quickly: the socket was kept. Catch up on anything missed, or reconnect now if it dropped.
216
+ const activeClient = client.current;
217
+ if (activeClient?.isConnected) void catchUp.current?.().catch(() => { /* The next reconnect catches up. */ });
218
+ else activeClient?.reconnectNow();
219
+ });
220
+ return () => { subscription.remove(); if (timer) clearTimeout(timer); };
221
+ }, []);
222
+
223
+ // Opening a chat that stayed connected while hidden shows what arrived; one with no session starts it.
224
+ useEffect(() => {
225
+ if (!visible) return;
226
+ setUnreadCount(0);
227
+ if (keepConnected && phaseRef.current === "idle") setAttempt((value) => value + 1);
228
+ }, [visible, keepConnected]);
229
+
230
+ useEffect(() => {
231
+ try { callbacks.current.onUnreadCountChange?.(unreadCount); } catch { /* Host callbacks are isolated. */ }
232
+ }, [unreadCount]);
233
+
234
+ useEffect(() => {
235
+ const currentGeneration = ++generation.current;
236
+ if (!wantConnection) {
237
+ setTypingState(NOT_TYPING);
238
+ setDrafts({});
239
+ status("disconnected");
240
+ return;
241
+ }
242
+ let cancelled = false;
243
+ const controller = new AbortController();
244
+ let connection: ChatClient | null = null;
245
+ const alive = () => !cancelled && currentGeneration === generation.current;
246
+ const abort = () => {
247
+ cancelled = true;
248
+ if (generation.current === currentGeneration) generation.current++;
249
+ controller.abort();
250
+ if (typing.current.active) connection?.sendTypingEnd();
251
+ if (typing.current.timer) clearTimeout(typing.current.timer);
252
+ typing.current = { active: false, timer: null };
253
+ connection?.disconnect();
254
+ if (client.current === connection) client.current = null;
255
+ catchUp.current = null;
256
+ };
257
+ const storageFailure = () => report(new GiovChatError("storage", "Chat session could not be saved or restored."));
258
+ const clearSession = async () => {
259
+ session.current = null;
260
+ setHasConversation(false);
261
+ setMessages([]);
262
+ try { await sessionStorage?.remove(scope); } catch { storageFailure(); }
263
+ };
264
+ // The session is gone (401/403 after one token renewal): end the chat and forget the stored pair;
265
+ // "Try again" then waits for the visitor's next message.
266
+ const endSession = async (failure: GiovChatError) => {
267
+ if (!alive()) return;
268
+ connection?.disconnect();
269
+ if (client.current === connection) client.current = null;
270
+ connection = null;
271
+ catchUp.current = null;
272
+ await clearSession();
273
+ if (!alive()) return;
274
+ setCurrentAgent({ name: null, avatar: null });
275
+ setDrafts({});
276
+ setTypingState(NOT_TYPING);
277
+ setError(failure);
278
+ setPhase("error");
279
+ status("disconnected");
280
+ };
281
+ // The conversation was closed or deleted: forget it; the visitor's next message starts a new one.
282
+ const forgetConversation = async () => {
283
+ if (!alive()) return;
284
+ connection?.disconnect();
285
+ if (client.current === connection) client.current = null;
286
+ connection = null;
287
+ catchUp.current = null;
288
+ await clearSession();
289
+ if (!alive()) return;
290
+ setCurrentAgent({ name: null, avatar: null });
291
+ setDrafts({});
292
+ setTypingState(NOT_TYPING);
293
+ setPhase("ready");
294
+ status("disconnected");
295
+ setError(new GiovChatError("conversation_closed", "This conversation has ended."));
296
+ };
297
+ sessionEnded.current = { forget: forgetConversation, end: endSession };
298
+ async function start() {
299
+ // The configuration is loaded once: a restart (the first message, the pre-chat form, coming
300
+ // back from the background) keeps the conversation on screen instead of a loading spinner.
301
+ const cacheKey = `${baseUrl} ${tenantKey}`;
302
+ const cached = configCache.current?.key === cacheKey ? configCache.current.config : null;
303
+ // An automatic retry after a failed start keeps the error on screen until it succeeds.
304
+ const quiet = autoRetry.current.auto && phaseRef.current === "error";
305
+ if (!cached) { if (!quiet) setPhase("loading"); setConfig(null); }
306
+ else if (quiet) { /* Unchanged. */ }
307
+ else if (session.current) setPhase("connecting");
308
+ else if (phaseRef.current === "error" || phaseRef.current === "idle") setPhase("loading");
309
+ if (!quiet) setError(null);
310
+ setTypingState(NOT_TYPING);
311
+ setSending(false);
312
+ status("connecting");
313
+ const cfg = cached ?? await fetchWidgetConfig(baseUrl, tenantKey, controller.signal);
314
+ if (!alive()) return;
315
+ configCache.current = { key: cacheKey, config: cfg };
316
+ setConfig(cfg);
317
+ if (!loadedStorage.current) {
318
+ loadedStorage.current = true;
319
+ try {
320
+ let stored = await sessionStorage?.load(scope);
321
+ // Stored by 0.2.0 under a key that held the contact details: move it to the current key.
322
+ const legacy = legacySessionScope(baseUrl, tenantKey, contact.current, callbacks.current.sessionKey);
323
+ if (!stored && sessionStorage && legacy !== scope) {
324
+ stored = await sessionStorage.load(legacy);
325
+ if (stored) { await sessionStorage.save(scope, stored); await sessionStorage.remove(legacy); }
326
+ }
327
+ if (!alive()) return;
328
+ if (stored && typeof stored.conversationId === "string" && typeof stored.sessionToken === "string" &&
329
+ /^[a-f0-9]{64}$/.test(stored.sessionToken)) session.current = stored;
330
+ } catch { storageFailure(); }
331
+ }
332
+ if (!alive()) return;
333
+ if (session.current) {
334
+ try { session.current = await createConversation(baseUrl, tenantKey, contact.current, session.current, controller.signal); }
335
+ catch (failure) {
336
+ if (!alive()) return;
337
+ const error = chatError(failure);
338
+ if (error.code !== "invalid_session" && error.code !== "conversation_closed" && error.status !== 404) throw error;
339
+ await clearSession();
340
+ }
341
+ }
342
+ if (!alive()) return;
343
+ // Hidden and nothing to resume: wait until the chat is opened instead of starting a conversation.
344
+ if (!visibleRef.current && !session.current) {
345
+ settled();
346
+ setPhase("idle");
347
+ status("disconnected");
348
+ return;
349
+ }
350
+ const missing = missingContactFields(cfg.requestUserInfo, contact.current);
351
+ setRequiredFields(missing);
352
+ if (!session.current && Object.keys(missing).length) {
353
+ settled();
354
+ setPhase("pre-chat");
355
+ status("disconnected");
356
+ return;
357
+ }
358
+ if (!session.current && !startNow.current) {
359
+ settled();
360
+ setPhase("ready");
361
+ status("disconnected");
362
+ return;
363
+ }
364
+ if (!autoRetry.current.auto || phaseRef.current !== "error") setPhase("connecting");
365
+ if (!session.current) {
366
+ const created = await createConversation(baseUrl, tenantKey, contact.current, undefined, controller.signal);
367
+ if (!alive()) return;
368
+ startNow.current = false;
369
+ session.current = created;
370
+ try { await sessionStorage?.save(scope, created); } catch { storageFailure(); }
371
+ if (!alive()) return;
372
+ try { callbacks.current.onConversationStart?.(created.conversationId); } catch { /* Host callback. */ }
373
+ // A new conversation for the same visitor (the team closed the last one): the device is registered again.
374
+ if (lastPush.current && !pushRequest.current) pushRequest.current = { ...lastPush.current, resolve: () => {} };
375
+ }
376
+ if (!alive() || !session.current) return;
377
+ const activeSession = session.current;
378
+ setHasConversation(true);
379
+ // The outbox goes out after the history catch-up, in the protocol's order.
380
+ connection = new ChatClient({ baseUrl, tenantKey, outbox, flushOnOpen: false });
381
+ client.current = connection;
382
+ let openedOnce = false;
383
+ connection.onMessage((message) => {
384
+ if (!alive()) return;
385
+ setMessages((previous) => mergeMessages(previous, [message]));
386
+ if (message.role === "agent") {
387
+ setTypingState(NOT_TYPING);
388
+ if (!message.isBot && !message.isAutomated && message.senderName?.trim()) {
389
+ setCurrentAgent({ name: message.senderName.trim(), avatar: message.senderAvatar ?? null });
390
+ }
391
+ if (!visibleRef.current) setUnreadCount((count) => count + 1);
392
+ setLastIncoming(message);
393
+ }
394
+ });
395
+ connection.onAck((clientMessageId, message) => {
396
+ if (!alive()) return;
397
+ setMessages((previous) => mergeMessages(previous, [{ ...message, clientMessageId }]));
398
+ });
399
+ connection.onStream((event) => {
400
+ if (!alive()) return;
401
+ setDrafts((current) => {
402
+ if (event.end) {
403
+ if (!(event.streamId in current)) return current;
404
+ const next = { ...current };
405
+ delete next[event.streamId];
406
+ return next;
407
+ }
408
+ return { ...current, [event.streamId]: { content: event.content, createdAt: current[event.streamId]?.createdAt ?? Date.now() } };
409
+ });
410
+ if (!event.end && event.content) setTypingState(NOT_TYPING);
411
+ });
412
+ connection.onAttachmentAccess((value) => { if (alive()) captureAccess(value); });
413
+ // A stop from the other sender (person or GiovBot) keeps the indicator that is showing.
414
+ connection.onTyping((active, isBot) => {
415
+ if (alive()) setTypingState((current) => reduceAgentTyping(current, { type: active ? "typing" : "typing_end", role: "agent", isBot }));
416
+ });
417
+ connection.onError((failure) => {
418
+ if (!alive()) return;
419
+ report(failure);
420
+ if (failure.code === "conversation_closed") { void forgetConversation(); return; }
421
+ if (failure.code === "invalid_session") { void endSession(failure); return; }
422
+ setError(failure);
423
+ });
424
+ // Catch-up: history merged by id with what arrived meanwhile, then the outbox is resent.
425
+ const refreshHistory = async () => {
426
+ let history;
427
+ try { history = await fetchConversationHistory(baseUrl, tenantKey, activeSession, controller.signal); }
428
+ catch (failure) {
429
+ const error = chatError(failure);
430
+ if (error.status === 404) { await forgetConversation(); return; }
431
+ if (error.code === "invalid_session") { await endSession(error); return; }
432
+ throw failure;
433
+ }
434
+ if (!alive()) return;
435
+ if (history.closed) { await forgetConversation(); return; }
436
+ captureAccess(history.attachmentAccess);
437
+ setCurrentAgent({ name: history.assignedAgentName ?? null, avatar: history.assignedAgentAvatar ?? null });
438
+ setMessages((previous) => mergeMessages(previous, history.messages));
439
+ connection?.flush();
440
+ };
441
+ catchUp.current = refreshHistory;
442
+ connection.onStatusChange((connected) => {
443
+ if (!alive()) return;
444
+ if (!connected) {
445
+ setTypingState(NOT_TYPING);
446
+ // A reply being written is lost with the socket; the saved copy arrives with the catch-up.
447
+ setDrafts({});
448
+ }
449
+ status(connected ? "connected" : "disconnected");
450
+ if (connected) settled();
451
+ if (connected || phaseRef.current !== "error") setPhase(connected ? "active" : "connecting");
452
+ if (connected) {
453
+ setError(null);
454
+ // Catch up on whatever arrived while the socket was down.
455
+ if (openedOnce) void refreshHistory().catch((failure) => {
456
+ if (!alive()) return;
457
+ report(chatError(failure));
458
+ setError(chatError(failure));
459
+ // Without the catch-up the outbox still goes out on this socket, rather than waiting for the next one.
460
+ connection?.flush();
461
+ });
462
+ openedOnce = true;
463
+ const push = pushRequest.current;
464
+ if (push && connection) {
465
+ pushRequest.current = null;
466
+ void connection.registerPushToken(push.token, push.platform, push.options).then(push.resolve);
467
+ }
468
+ }
469
+ });
470
+ await connection.connect(activeSession);
471
+ if (!alive()) return;
472
+ await refreshHistory();
473
+ }
474
+ // Started for good: later failures retry from the first backoff step.
475
+ function settled() { autoRetry.current.failures = 0; autoRetry.current.auto = false; }
476
+ void start().catch((failure) => {
477
+ if (!alive()) return;
478
+ abort();
479
+ const error = chatError(failure);
480
+ if (error.code === "invalid_session") void clearSession();
481
+ if (error.code === "identity_rejected") {
482
+ // No conversation can start for this identity: a registration waiting for one ends here.
483
+ pushRequest.current?.resolve({ registered: false, reason: "no_conversation" });
484
+ pushRequest.current = null;
485
+ }
486
+ setError(error);
487
+ setPhase("error");
488
+ status("disconnected");
489
+ report(error);
490
+ // A first connection that fails retries with the protocol's backoff while the error shows, as the web widget does.
491
+ if (!NO_AUTO_RETRY.has(error.code)) {
492
+ const retrying = autoRetry.current;
493
+ if (retrying.timer) clearTimeout(retrying.timer);
494
+ retrying.timer = setTimeout(() => {
495
+ retrying.timer = null;
496
+ retrying.auto = true;
497
+ setAttempt((value) => value + 1);
498
+ }, reconnectDelay(retrying.failures++));
499
+ }
500
+ });
501
+ return () => {
502
+ abort();
503
+ status("disconnected");
504
+ if (autoRetry.current.timer) clearTimeout(autoRetry.current.timer);
505
+ autoRetry.current.timer = null;
506
+ };
507
+ }, [tenantKey, baseUrl, wantConnection, attempt, scope, sessionStorage, outbox, report, status, captureAccess]);
508
+
509
+ const retry = useCallback(() => {
510
+ autoRetry.current = { ...autoRetry.current, failures: 0, auto: false };
511
+ setAttempt((value) => value + 1);
512
+ }, []);
513
+ const submitContact = useCallback((info: GiovChatContact) => {
514
+ contact.current = { ...contact.current, ...info };
515
+ startNow.current = true;
516
+ retry();
517
+ }, [retry]);
518
+ const reset = useCallback(async () => {
519
+ generation.current++;
520
+ stopTyping();
521
+ client.current?.disconnect();
522
+ client.current = null;
523
+ catchUp.current = null;
524
+ session.current = null;
525
+ contact.current = callbacks.current.contact;
526
+ loadedStorage.current = true;
527
+ outbox.clear();
528
+ localFiles.current.clear();
529
+ startNow.current = false;
530
+ // Sign-out: the next visitor's conversation must not get this device's notifications.
531
+ lastPush.current = null;
532
+ pushRequest.current?.resolve({ registered: false, reason: "no_conversation" });
533
+ pushRequest.current = null;
534
+ setHasConversation(false);
535
+ setMessages([]);
536
+ setDrafts({});
537
+ setTypingState(NOT_TYPING);
538
+ setCurrentAgent({ name: null, avatar: null });
539
+ setAccess(null);
540
+ setUnreadCount(0);
541
+ setError(null);
542
+ try { await sessionStorage?.remove(scope); }
543
+ catch { report(new GiovChatError("storage", "Saved chat session could not be cleared.")); }
544
+ retry();
545
+ }, [sessionStorage, scope, report, retry, outbox, stopTyping]);
546
+
547
+ /**
548
+ * Sends text and files. Files upload first (they need the conversation); the message then waits in
549
+ * the outbox until a socket takes it, so text typed while reconnecting is never lost. Several
550
+ * files go as one message each after the text, as in the widget. Resolves false when nothing was
551
+ * queued (empty, or an upload failed).
552
+ */
553
+ const send = useCallback(async (text: string, files: readonly GiovChatPickedFile[] = []): Promise<boolean> => {
554
+ const trimmed = text.trim();
555
+ if (!trimmed && !files.length) return false;
556
+ const current = phaseRef.current;
557
+ if (current !== "active" && current !== "connecting" && current !== "ready") return false;
558
+ // Uploads need a live conversation; the attach button only appears once one exists.
559
+ if (files.length && !session.current) return false;
560
+ const currentGeneration = generation.current;
561
+ stopTyping();
562
+ setError(null);
563
+ const urls: string[] = [];
564
+ if (files.length) {
565
+ const activeClient = client.current;
566
+ if (!activeClient) {
567
+ setError(new GiovChatError("disconnected", "Chat is reconnecting. Please try again."));
568
+ return false;
569
+ }
570
+ setSending(true);
571
+ setUploads(files.map((file) => ({ name: file.name, progress: 0 })));
572
+ try {
573
+ for (const [index, file] of files.entries()) {
574
+ const result = await activeClient.uploadAttachment(file, (progress) => {
575
+ if (generation.current === currentGeneration) setUploads((current) => current.map((item, i) => i === index ? { ...item, progress } : item));
576
+ });
577
+ const preview: GiovAttachmentPreview | undefined = result.preview ?? (file.width && file.height ? { width: file.width, height: file.height } : undefined);
578
+ localFiles.current.set(result.url, { filename: result.filename || file.name, contentType: file.type, ...(preview ? { preview } : {}) });
579
+ urls.push(result.url);
580
+ }
581
+ } catch (failure) {
582
+ if (generation.current === currentGeneration) {
583
+ const cause = chatError(failure);
584
+ // The conversation or the session is gone: the same handling as on the socket.
585
+ if (cause.code === "conversation_closed") { report(cause); void sessionEnded.current?.forget(); return false; }
586
+ if (cause.code === "invalid_session") { report(cause); void sessionEnded.current?.end(cause); return false; }
587
+ // Rate limits, server and network failures keep their code (and Retry-After) for the host;
588
+ // the visitor reads that the upload failed.
589
+ report(cause.code === "upload_failed" ? cause : new GiovChatError(cause.code, "File upload failed.", cause.status, cause.retryAfterSeconds));
590
+ setError(new GiovChatError("upload_failed", "File upload failed.", cause.status, cause.retryAfterSeconds));
591
+ }
592
+ return false;
593
+ } finally {
594
+ if (generation.current === currentGeneration) { setSending(false); setUploads([]); }
595
+ }
596
+ if (generation.current !== currentGeneration) return false;
597
+ }
598
+ const parts = urls.length > 1
599
+ ? [...(trimmed ? [{ content: trimmed, attachments: [] as string[] }] : []), ...urls.map((url) => ({ content: "", attachments: [url] }))]
600
+ : [{ content: trimmed, attachments: urls }];
601
+ try {
602
+ for (const part of parts) outbox.enqueue(part.content, part.attachments);
603
+ } catch (failure) {
604
+ const failed = chatError(failure);
605
+ report(failed);
606
+ setError(failed);
607
+ return false;
608
+ }
609
+ if (session.current) client.current?.flush();
610
+ else if (!startNow.current) {
611
+ // The first message starts the conversation; it goes out once the socket is open.
612
+ startNow.current = true;
613
+ setAttempt((value) => value + 1);
614
+ }
615
+ return true;
616
+ }, [outbox, report, stopTyping]);
617
+
618
+ /** Tries a failed message again; it is sent in its original place in the queue. */
619
+ const retryMessage = useCallback((clientMessageId: string) => {
620
+ if (outbox.retry(clientMessageId)) client.current?.flush();
621
+ }, [outbox]);
622
+ const discardMessage = useCallback((clientMessageId: string) => { outbox.remove(clientMessageId); }, [outbox]);
623
+
624
+ /** Call on every keystroke: the agent sees "typing" once per burst and it ends after a pause. */
625
+ const notifyTyping = useCallback(() => {
626
+ const activeClient = client.current;
627
+ if (!activeClient?.isConnected) return;
628
+ const state = typing.current;
629
+ if (!state.active) { activeClient.sendTyping(); state.active = true; }
630
+ if (state.timer) clearTimeout(state.timer);
631
+ state.timer = setTimeout(() => { state.timer = null; state.active = false; client.current?.sendTypingEnd(); }, TYPING_IDLE_MS);
632
+ }, []);
633
+
634
+ /** Registers now when connected, or once the conversation connects. Never throws. */
635
+ const registerPushToken = useCallback((token: string, platform: GiovPushPlatform, options?: GiovPushOptions): Promise<GiovPushRegistration> => {
636
+ lastPush.current = { token, platform, options };
637
+ const activeClient = client.current;
638
+ if (activeClient?.isConnected) return activeClient.registerPushToken(token, platform, options);
639
+ return new Promise((resolve) => {
640
+ pushRequest.current?.resolve({ registered: false, reason: "failed" });
641
+ pushRequest.current = { token, platform, options, resolve };
642
+ });
643
+ }, []);
644
+
645
+ /** Stops push for this device; a registration still waiting for the connection is dropped. Never throws. */
646
+ const unregisterPushToken = useCallback(async (token: string): Promise<boolean> => {
647
+ if (pushRequest.current?.token === token) {
648
+ pushRequest.current.resolve({ registered: false, reason: "failed" });
649
+ pushRequest.current = null;
650
+ }
651
+ if (lastPush.current?.token === token) lastPush.current = null;
652
+ // While the chat is closed or hidden there is no connection: the stored session is enough.
653
+ let stored = session.current;
654
+ if (!stored && !loadedStorage.current) {
655
+ try {
656
+ const loaded = await sessionStorage?.load(scope);
657
+ if (loaded && typeof loaded.conversationId === "string" && /^[a-f0-9]{64}$/.test(String(loaded.sessionToken))) stored = loaded;
658
+ } catch { /* Nothing stored that can be used. */ }
659
+ }
660
+ if (client.current) return client.current.unregisterPushToken(token, stored);
661
+ return stored ? unregisterStoredPushToken(baseUrl, tenantKey, stored, token) : false;
662
+ }, [baseUrl, tenantKey, scope, sessionStorage]);
663
+
664
+ useEffect(() => () => {
665
+ if (typing.current.timer) clearTimeout(typing.current.timer);
666
+ pushRequest.current?.resolve({ registered: false, reason: "no_conversation" });
667
+ pushRequest.current = null;
668
+ }, []);
669
+
670
+ const describe = useCallback((url: string) => localFiles.current.get(url) ?? { filename: "" }, []);
671
+ const shown = useMemo(() => displayMessages(messages, pending, drafts, describe), [messages, pending, drafts, describe]);
672
+ // A conversation open for a long time: without a usable access token, images fall back to the
673
+ // 15-minute visitor token. Renew it when messages arrive and it is close to expiry, at most once a
674
+ // minute (the web widget's rule); the new URLs make failed images load again.
675
+ useEffect(() => {
676
+ const activeClient = client.current;
677
+ const now = Date.now();
678
+ if (!activeClient || usableAttachmentToken(access, now)) return;
679
+ if (activeClient.visitorTokenExpiresAt - now > 2 * 60_000 || now - lastTokenRenewal.current < 60_000) return;
680
+ lastTokenRenewal.current = now;
681
+ void activeClient.renewVisitorToken().then((renewed) => { if (renewed) setTokenVersion((value) => value + 1); });
682
+ }, [messages, access]);
683
+ const attachmentUrl = useCallback(
684
+ (url: string) => attachmentDisplayUrl(url, usableAttachmentToken(access, Date.now()), client.current?.visitorToken),
685
+ // tokenVersion: a renewed visitor token changes the URLs.
686
+ [access, tokenVersion], // eslint-disable-line react-hooks/exhaustive-deps
687
+ );
688
+
689
+ return {
690
+ phase, config, messages: shown, agentTyping: typingState.visible, agentTypingIsBot: typingState.visible ? typingState.isBot : undefined, error, sending, uploads, requiredFields, hasConversation,
691
+ currentAgentName: currentAgent.name, currentAgentAvatar: currentAgent.avatar, unreadCount: visible ? 0 : unreadCount,
692
+ lastIncoming,
693
+ retry, reset, submitContact, send, retryMessage, discardMessage, notifyTyping, attachmentUrl, registerPushToken, unregisterPushToken,
694
+ };
695
+ }