@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
package/dist/client.js ADDED
@@ -0,0 +1,801 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UPLOAD_TIMEOUT_MS = exports.ChatClient = exports.PONG_TIMEOUT_MS = exports.PING_INTERVAL_MS = exports.OPEN_TIMEOUT_MS = exports.PUSH_TOKEN_PATH = exports.CLIENT_HEADER = exports.SDK_VERSION = exports.DEFAULT_BASE_URL = void 0;
4
+ exports.normalizeBaseUrl = normalizeBaseUrl;
5
+ exports.fetchWidgetConfig = fetchWidgetConfig;
6
+ exports.createConversation = createConversation;
7
+ exports.fetchConversationHistory = fetchConversationHistory;
8
+ exports.reconnectDelay = reconnectDelay;
9
+ exports.unregisterPushToken = unregisterPushToken;
10
+ const errors_1 = require("./errors");
11
+ const protocol_1 = require("./protocol");
12
+ const identity_1 = require("./identity");
13
+ const i18n_1 = require("./i18n");
14
+ const outbox_1 = require("./outbox");
15
+ const attachments_1 = require("./attachments");
16
+ exports.DEFAULT_BASE_URL = "https://api.giovchat.app";
17
+ /** Keep in step with package.json; sent so the dashboard can show where the chat is installed. */
18
+ exports.SDK_VERSION = "0.2.0";
19
+ exports.CLIENT_HEADER = { "X-Giov-Client": `react-native/${exports.SDK_VERSION}` };
20
+ function normalizeBaseUrl(value) {
21
+ const url = new URL(value);
22
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
23
+ throw new errors_1.GiovChatError("invalid_response", "Use an HTTP(S) API URL without credentials, query parameters or fragments.");
24
+ }
25
+ return url.toString().replace(/\/$/, "");
26
+ }
27
+ async function request(baseUrl, path, init = {}) {
28
+ const controller = new AbortController();
29
+ let timedOut = false;
30
+ const abort = () => controller.abort();
31
+ if (init.signal?.aborted)
32
+ controller.abort();
33
+ init.signal?.addEventListener("abort", abort);
34
+ const timeout = setTimeout(() => { timedOut = true; controller.abort(); }, 15000);
35
+ try {
36
+ const response = await fetch(normalizeBaseUrl(baseUrl) + path, { ...init, signal: controller.signal });
37
+ const data = (0, protocol_1.object)(await response.json().catch(() => null));
38
+ if (!response.ok) {
39
+ // Protocol v1: 401/403 the session is gone; 409 (or the code) the conversation is closed;
40
+ // 503 workspace_busy the workspace is at its plan's limit of open conversations; 401
41
+ // invalid_user_hash the workspace requires a signed identity and userHash is wrong or missing.
42
+ const code = data.error === "invalid_user_hash" ? "identity_rejected"
43
+ : response.status === 429 ? "rate_limited"
44
+ : response.status === 401 || response.status === 403 ? "invalid_session"
45
+ : response.status === 409 || data.error === "conversation_closed" ? "conversation_closed"
46
+ : response.status === 503 && data.error === "workspace_busy" ? "workspace_busy" : "server";
47
+ throw new errors_1.GiovChatError(code, code === "identity_rejected" ? "This workspace requires a signed visitor identity (userId and userHash)."
48
+ : code === "invalid_session" ? "Chat session expired. Please start a new chat."
49
+ : code === "conversation_closed" ? "This conversation has ended."
50
+ : code === "workspace_busy" ? "The team is busy right now. Please try again later."
51
+ : code === "rate_limited" ? "Too many requests. Please wait a moment."
52
+ : "Chat is unavailable. Please try again.", response.status, (0, errors_1.retryAfterSeconds)(response.headers?.get?.("Retry-After")));
53
+ }
54
+ return data;
55
+ }
56
+ catch (error) {
57
+ if (init.signal?.aborted)
58
+ throw new errors_1.GiovChatError("disconnected", "Chat closed.");
59
+ if (timedOut)
60
+ throw new errors_1.GiovChatError("timeout", "Chat request timed out. Please try again.");
61
+ throw (0, errors_1.chatError)(error);
62
+ }
63
+ finally {
64
+ clearTimeout(timeout);
65
+ init.signal?.removeEventListener("abort", abort);
66
+ }
67
+ }
68
+ async function fetchWidgetConfig(baseUrl, tenantKey, signal) {
69
+ const data = await request(baseUrl, "/widget/config?tenantId=" + encodeURIComponent(tenantKey), { signal, headers: exports.CLIENT_HEADER });
70
+ if (typeof data.title !== "string" || typeof data.primaryColor !== "string" ||
71
+ !["light", "dark", "auto"].includes(String(data.theme))) {
72
+ throw new errors_1.GiovChatError("invalid_response", "Invalid widget configuration.");
73
+ }
74
+ const fields = (0, protocol_1.object)(data.requestUserInfo);
75
+ const attachments = (0, attachments_1.normalizeAttachmentPolicy)(data.attachments);
76
+ return {
77
+ title: data.title, primaryColor: data.primaryColor, theme: data.theme,
78
+ greetingText: typeof data.greetingText === "string" ? data.greetingText : "Hi! How can we help?",
79
+ textArea: typeof data.textArea === "string" ? data.textArea : undefined,
80
+ botEnabled: data.botEnabled === true,
81
+ botName: typeof data.botName === "string" ? data.botName : undefined,
82
+ botAvatarUrl: (0, identity_1.safeImageUrl)(data.botAvatarUrl) ?? undefined,
83
+ avatarUrl: typeof data.avatarUrl === "string" ? data.avatarUrl : undefined,
84
+ showBranding: data.showBranding !== false,
85
+ requestUserInfo: { email: fields.email === true, phone: fields.phone === true,
86
+ firstName: fields.firstName === true, lastName: fields.lastName === true },
87
+ ...(attachments ? { attachments } : {}),
88
+ ...(privacyPolicyUrl(data.privacyPolicyUrl) ? { privacyPolicyUrl: privacyPolicyUrl(data.privacyPolicyUrl) } : {}),
89
+ };
90
+ }
91
+ /** As the web widget reads it: an https URL without credentials, else nothing. */
92
+ function privacyPolicyUrl(value) {
93
+ if (typeof value !== "string")
94
+ return undefined;
95
+ try {
96
+ const url = new URL(value);
97
+ return url.protocol === "https:" && !url.username && !url.password ? url.toString() : undefined;
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ /** Creates a credential-bearing visitor session. Resuming requires the entire session. */
104
+ async function createConversation(baseUrl, tenantKey, contact, existingSession, signal) {
105
+ const data = await request(baseUrl, "/conversations", {
106
+ method: "POST", signal,
107
+ headers: { "Content-Type": "application/json",
108
+ ...(existingSession ? { Authorization: "Bearer " + existingSession.sessionToken } : {}) },
109
+ body: JSON.stringify({ tenantId: tenantKey, ...contact, conversationId: existingSession?.conversationId }),
110
+ });
111
+ if (typeof data.conversationId !== "string" || !data.conversationId ||
112
+ typeof data.sessionToken !== "string" || !/^[a-f0-9]{64}$/.test(data.sessionToken)) {
113
+ throw new errors_1.GiovChatError("invalid_response", "Invalid chat session response.");
114
+ }
115
+ return { conversationId: data.conversationId, sessionToken: data.sessionToken };
116
+ }
117
+ async function fetchWidgetToken(baseUrl, tenantKey, session, signal) {
118
+ const data = await request(baseUrl, "/auth/widget-token", {
119
+ method: "POST", signal,
120
+ headers: { "Content-Type": "application/json", Authorization: "Bearer " + session.sessionToken },
121
+ body: JSON.stringify({ tenantId: tenantKey, conversationId: session.conversationId }),
122
+ });
123
+ if (typeof data.token !== "string" || !data.token)
124
+ throw new errors_1.GiovChatError("invalid_response", "Invalid chat token response.");
125
+ // The API reports the expiry in seconds; assume a little under 15 minutes when it does not.
126
+ const expiresAt = typeof data.expiresAt === "number" && Number.isFinite(data.expiresAt) ? data.expiresAt * 1000 : Date.now() + 14 * 60000;
127
+ return { token: data.token, expiresAt, attachmentAccess: (0, attachments_1.parseAttachmentAccess)(data.attachmentAccess) };
128
+ }
129
+ async function fetchConversationHistory(baseUrl, tenantKey, session, signal) {
130
+ const load = async () => {
131
+ const { token } = await fetchWidgetToken(baseUrl, tenantKey, session, signal);
132
+ return request(baseUrl, "/conversations/" + encodeURIComponent(session.conversationId) + "/history", {
133
+ signal, headers: { Authorization: "Bearer " + token },
134
+ }).catch((error) => { throw Object.assign((0, errors_1.chatError)(error), { fromHistory: true }); });
135
+ };
136
+ // Protocol "Tokens": a 401 from history renews the visitor token once; a second one (or a 401
137
+ // from the token route) means the session is gone.
138
+ const data = await load().catch((error) => {
139
+ if (error.fromHistory && error.status === 401)
140
+ return load();
141
+ throw error;
142
+ });
143
+ if (!Array.isArray(data.messages))
144
+ throw new errors_1.GiovChatError("invalid_response", "Invalid conversation history.");
145
+ const assignedAgentName = typeof data.assignedAgentName === "string" ? data.assignedAgentName.trim() || null : null;
146
+ // History rows carry no sender name; a human reply is shown under the assigned teammate, as in the widget.
147
+ const messages = data.messages.map(protocol_1.parseMessage).filter((m) => m !== null).map((message) => message.role === "agent" && !message.isBot && !message.isAutomated && !message.senderName && assignedAgentName
148
+ ? { ...message, senderName: assignedAgentName } : message);
149
+ return {
150
+ messages, closed: data.status === "closed",
151
+ assignedAgentName,
152
+ assignedAgentAvatar: assignedAgentName ? (0, identity_1.safeImageUrl)(data.assignedAgentAvatar) : null,
153
+ attachmentAccess: (0, attachments_1.parseAttachmentAccess)(data.attachmentAccess),
154
+ };
155
+ }
156
+ /**
157
+ * Visitor push registration (docs/protocol/README.md, "Push notifications"). Feature-detected: a
158
+ * server without it answers 404/405/501 and registration degrades to a no-op.
159
+ */
160
+ exports.PUSH_TOKEN_PATH = "/push-token";
161
+ /** BCP 47 tags the server stores with a device ("pt-BR", "fr"); anything else is left out. */
162
+ const PUSH_LOCALE = /^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{1,8}){0,3}$/;
163
+ const SEND_FAILED = "Message could not be sent. Please try again.";
164
+ /** Connection timings from the visitor protocol (docs/protocol/README.md). */
165
+ exports.OPEN_TIMEOUT_MS = 10000;
166
+ exports.PING_INTERVAL_MS = 25000;
167
+ exports.PONG_TIMEOUT_MS = 10000;
168
+ /** Retry waits: min(1500 × 2^attempt, 15000) ms plus a little jitter; reset after a successful connect. */
169
+ function reconnectDelay(attempt, jitter = Math.random()) {
170
+ return Math.min(1500 * 2 ** attempt, 15000) + Math.floor(jitter * 400);
171
+ }
172
+ /** Transport only. Session credentials are never derived from contact information. */
173
+ class ChatClient {
174
+ constructor(options) {
175
+ this.options = options;
176
+ this.ws = null;
177
+ this.messageCbs = new Set();
178
+ this.statusCbs = new Set();
179
+ this.typingCbs = new Set();
180
+ this.errorCbs = new Set();
181
+ this.ackCbs = new Set();
182
+ this.streamCbs = new Set();
183
+ this.accessCbs = new Set();
184
+ this.pending = new Map();
185
+ this.timer = null;
186
+ this.heartbeat = null;
187
+ this.watchdog = null;
188
+ this.resendTimers = new Set();
189
+ this.controller = null;
190
+ this.cancelHandshake = null;
191
+ this.generation = 0;
192
+ this.desired = false;
193
+ this.attempt = 0;
194
+ this.session = null;
195
+ this.widgetToken = null;
196
+ this.tokenRequest = null;
197
+ this.pushUnsupported = false;
198
+ this.baseUrl = normalizeBaseUrl(options.baseUrl ?? exports.DEFAULT_BASE_URL);
199
+ this.outbox = options.outbox ?? new outbox_1.Outbox();
200
+ }
201
+ onMessage(cb) { this.messageCbs.add(cb); return () => { this.messageCbs.delete(cb); }; }
202
+ onStatusChange(cb) { this.statusCbs.add(cb); return () => { this.statusCbs.delete(cb); }; }
203
+ /** `isBot` is server-reported identity; undefined means an older/unknown sender. */
204
+ onTyping(cb) { this.typingCbs.add(cb); return () => { this.typingCbs.delete(cb); }; }
205
+ onError(cb) { this.errorCbs.add(cb); return () => { this.errorCbs.delete(cb); }; }
206
+ /** The server saved a visitor message: the stored copy replaces the one shown while sending. */
207
+ onAck(cb) { this.ackCbs.add(cb); return () => { this.ackCbs.delete(cb); }; }
208
+ onStream(cb) { this.streamCbs.add(cb); return () => { this.streamCbs.delete(cb); }; }
209
+ /** A newer attachment download token arrived (with a visitor token). */
210
+ onAttachmentAccess(cb) { this.accessCbs.add(cb); return () => { this.accessCbs.delete(cb); }; }
211
+ emit(callbacks, value) {
212
+ for (const cb of callbacks) {
213
+ try {
214
+ cb(value);
215
+ }
216
+ catch { /* Host callbacks cannot break transport cleanup. */ }
217
+ }
218
+ }
219
+ async connect(session) {
220
+ // Messages already waiting (sendMessage before connect) are kept: they go out on this socket.
221
+ this.teardown();
222
+ this.desired = true;
223
+ this.session = session;
224
+ this.attempt = 0;
225
+ await this.open(this.generation);
226
+ }
227
+ async token(force = false) {
228
+ const session = this.session;
229
+ if (!session)
230
+ throw new errors_1.GiovChatError("disconnected", "Chat closed.");
231
+ if (!force && this.widgetToken && this.widgetToken.expiresAt - Date.now() > 60000)
232
+ return this.widgetToken;
233
+ // One request at a time. Teardown forgets it, so the next connection never waits on an aborted one.
234
+ const generation = this.generation;
235
+ const pending = this.tokenRequest ?? (this.tokenRequest = fetchWidgetToken(this.baseUrl, this.options.tenantKey, session, this.controller?.signal)
236
+ .then((value) => {
237
+ if (this.session === session && this.generation === generation) {
238
+ this.widgetToken = value;
239
+ if (value.attachmentAccess)
240
+ this.emit(this.accessCbs, value.attachmentAccess);
241
+ }
242
+ return value;
243
+ })
244
+ .finally(() => { if (this.tokenRequest === pending)
245
+ this.tokenRequest = null; }));
246
+ return pending;
247
+ }
248
+ /** The current visitor token, for attachment downloads when no access token is usable. */
249
+ get visitorToken() { return this.widgetToken?.token ?? null; }
250
+ /** When the current visitor token expires (milliseconds); 0 without one. */
251
+ get visitorTokenExpiresAt() { return this.widgetToken?.expiresAt ?? 0; }
252
+ /**
253
+ * Fetches a new visitor token (and with it, usually, a newer attachment access token), e.g. before
254
+ * attachment links that use the visitor token expire. Resolves false when it could not; never throws.
255
+ */
256
+ async renewVisitorToken() {
257
+ if (!this.session)
258
+ return false;
259
+ try {
260
+ await this.token(true);
261
+ return true;
262
+ }
263
+ catch {
264
+ return false;
265
+ }
266
+ }
267
+ async open(generation) {
268
+ if (!this.desired || !this.session || generation !== this.generation)
269
+ return;
270
+ this.controller = new AbortController();
271
+ const { token } = await this.token(true);
272
+ if (!this.desired || generation !== this.generation)
273
+ throw new errors_1.GiovChatError("disconnected", "Chat closed.");
274
+ const url = new URL(this.baseUrl + "/conversations/" + encodeURIComponent(this.session.conversationId) + "/ws");
275
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
276
+ url.searchParams.set("token", token);
277
+ url.searchParams.set("role", "user");
278
+ await new Promise((resolve, reject) => {
279
+ const ws = new WebSocket(url.toString());
280
+ this.ws = ws;
281
+ let opened = false;
282
+ let settled = false;
283
+ let pongDeadline = null;
284
+ const finish = (error) => {
285
+ if (settled)
286
+ return;
287
+ settled = true;
288
+ clearTimeout(timeout);
289
+ this.cancelHandshake = null;
290
+ if (error)
291
+ reject(error);
292
+ else
293
+ resolve();
294
+ };
295
+ const timeout = setTimeout(() => {
296
+ finish(new errors_1.GiovChatError("timeout", "Chat connection timed out."));
297
+ drop();
298
+ }, exports.OPEN_TIMEOUT_MS);
299
+ this.cancelHandshake = () => finish(new errors_1.GiovChatError("disconnected", "Chat closed."));
300
+ ws.onopen = () => {
301
+ if (generation !== this.generation || !this.desired || settled) {
302
+ ws.close();
303
+ return;
304
+ }
305
+ opened = true;
306
+ this.attempt = 0;
307
+ this.emit(this.statusCbs, true);
308
+ // A ping every 25 s; no pong within 10 s means the socket is dead.
309
+ this.heartbeat = setInterval(() => {
310
+ try {
311
+ ws.send(JSON.stringify({ type: "ping" }));
312
+ }
313
+ catch {
314
+ drop();
315
+ return;
316
+ }
317
+ if (pongDeadline)
318
+ clearTimeout(pongDeadline);
319
+ pongDeadline = setTimeout(drop, exports.PONG_TIMEOUT_MS);
320
+ }, exports.PING_INTERVAL_MS);
321
+ // A message on a socket that never answered means the socket is dead: drop it so the
322
+ // reconnect resends everything still waiting.
323
+ this.watchdog = setInterval(() => { if (this.outbox.stale())
324
+ drop(); }, 3000);
325
+ finish();
326
+ if (this.options.flushOnOpen !== false)
327
+ this.flush();
328
+ };
329
+ ws.onerror = () => {
330
+ finish(new errors_1.GiovChatError("connection_failed", "Unable to connect to chat."));
331
+ ws.close();
332
+ };
333
+ // A dead socket can take a minute to report its close (Android's OkHttp waits out the close
334
+ // handshake), so it is treated as closed at once; its late onclose is then ignored.
335
+ const drop = () => {
336
+ try {
337
+ ws.close();
338
+ }
339
+ catch { /* Already closing. */ }
340
+ closed();
341
+ };
342
+ const closed = (event) => {
343
+ if (pongDeadline)
344
+ clearTimeout(pongDeadline);
345
+ pongDeadline = null;
346
+ if (generation !== this.generation || this.ws !== ws)
347
+ return;
348
+ this.ws = null;
349
+ this.outbox.socketClosed();
350
+ if (this.heartbeat)
351
+ clearInterval(this.heartbeat);
352
+ if (this.watchdog)
353
+ clearInterval(this.watchdog);
354
+ this.heartbeat = null;
355
+ this.watchdog = null;
356
+ this.emit(this.statusCbs, false);
357
+ finish(new errors_1.GiovChatError("connection_failed", "Chat connection closed."));
358
+ if (event?.code === 1000 && event.reason === "Conversation deleted") {
359
+ // The conversation is gone: no reconnect; the chat forgets the stored session.
360
+ this.desired = false;
361
+ this.emit(this.errorCbs, new errors_1.GiovChatError("conversation_closed", "This conversation has ended."));
362
+ return;
363
+ }
364
+ if (opened)
365
+ this.reconnect(generation);
366
+ };
367
+ ws.onclose = closed;
368
+ ws.onmessage = (event) => {
369
+ if (generation !== this.generation || this.ws !== ws)
370
+ return;
371
+ let data;
372
+ try {
373
+ data = (0, protocol_1.object)(JSON.parse(String(event.data)));
374
+ }
375
+ catch {
376
+ return;
377
+ }
378
+ this.receive(data, () => { if (pongDeadline)
379
+ clearTimeout(pongDeadline); pongDeadline = null; }, ws);
380
+ };
381
+ });
382
+ }
383
+ receive(data, pong, ws) {
384
+ if (data.type === "pong") {
385
+ pong();
386
+ return;
387
+ }
388
+ if (data.type === "typing" || data.type === "typing_end") {
389
+ if (data.role === "agent") {
390
+ const typing = data.type === "typing";
391
+ // Kept on typing_end too: a stop from the other sender (person or GiovBot) must not hide the indicator.
392
+ const isBot = typeof data.isBot === "boolean" ? data.isBot : undefined;
393
+ for (const cb of this.typingCbs) {
394
+ try {
395
+ cb(typing, isBot);
396
+ }
397
+ catch { /* Host callbacks cannot break transport cleanup. */ }
398
+ }
399
+ }
400
+ return;
401
+ }
402
+ if (data.type === "stream" && typeof data.streamId === "string") {
403
+ this.emit(this.streamCbs, { streamId: data.streamId, content: typeof data.content === "string" ? data.content : "" });
404
+ return;
405
+ }
406
+ if (data.type === "stream_end" && typeof data.streamId === "string") {
407
+ this.emit(this.streamCbs, { streamId: data.streamId, end: true });
408
+ return;
409
+ }
410
+ if (data.type === "error") {
411
+ this.serverError(data, ws);
412
+ return;
413
+ }
414
+ if (data.type === "message_ack" && typeof data.clientMessageId === "string") {
415
+ const message = (0, protocol_1.parseMessage)({ ...(0, protocol_1.object)(data.message), role: "visitor" });
416
+ const entry = this.outbox.acknowledge(data.clientMessageId);
417
+ if (message) {
418
+ for (const cb of this.ackCbs) {
419
+ try {
420
+ cb(data.clientMessageId, message);
421
+ }
422
+ catch { /* Host callbacks cannot break transport cleanup. */ }
423
+ }
424
+ this.settle(data.clientMessageId, message);
425
+ }
426
+ else if (entry) {
427
+ this.settle(data.clientMessageId, null);
428
+ }
429
+ return;
430
+ }
431
+ if (data.type && data.type !== "message")
432
+ return;
433
+ const message = (0, protocol_1.parseMessage)(data);
434
+ // The saved reply takes the place of its streamed draft, under the draft's key so it is not redrawn.
435
+ if (message && typeof data.streamId === "string")
436
+ message.clientMessageId = `stream:${data.streamId}`;
437
+ if (message)
438
+ this.emit(this.messageCbs, message);
439
+ if (message && typeof data.streamId === "string")
440
+ this.emit(this.streamCbs, { streamId: data.streamId, end: true });
441
+ }
442
+ /** Error frames: a save failure names its message; refusals and rate limits apply to what is on the socket. */
443
+ serverError(data, ws) {
444
+ if (data.error === "message_failed" && typeof data.clientMessageId === "string") {
445
+ const outcome = this.outbox.rejected(data.clientMessageId);
446
+ if (!outcome)
447
+ return;
448
+ if (outcome.failed) {
449
+ this.fail(data.clientMessageId);
450
+ this.emit(this.errorCbs, new errors_1.GiovChatError("send_failed", SEND_FAILED));
451
+ }
452
+ else {
453
+ this.later(outcome.retryAfter, () => { if (this.ws === ws)
454
+ this.flush(); });
455
+ }
456
+ return;
457
+ }
458
+ if (data.error === "rate_limited") {
459
+ // Too fast: everything on the socket goes again a little later, with the same ids.
460
+ this.outbox.socketClosed();
461
+ this.later(3000, () => { if (this.ws === ws)
462
+ this.flush(); });
463
+ return;
464
+ }
465
+ if (data.error === "message_failed")
466
+ return;
467
+ // invalid_message, invalid_attachment and any code this client does not know (the AsyncAPI says
468
+ // to treat an unknown one as invalid_message). Frames are checked in order, so the refusal is
469
+ // for the oldest message still waiting.
470
+ const refused = this.outbox.refuseOldestSending();
471
+ if (refused)
472
+ this.fail(refused.clientMessageId);
473
+ this.emit(this.errorCbs, new errors_1.GiovChatError("send_failed", SEND_FAILED));
474
+ }
475
+ later(delay, run) {
476
+ const timer = setTimeout(() => { this.resendTimers.delete(timer); run(); }, delay);
477
+ this.resendTimers.add(timer);
478
+ }
479
+ settle(clientMessageId, message) {
480
+ const waiters = this.pending.get(clientMessageId);
481
+ this.pending.delete(clientMessageId);
482
+ for (const waiter of waiters ?? []) {
483
+ if (message)
484
+ waiter.resolve(message);
485
+ else
486
+ waiter.reject(new errors_1.GiovChatError("send_failed", SEND_FAILED));
487
+ }
488
+ }
489
+ fail(clientMessageId) {
490
+ const waiters = this.pending.get(clientMessageId);
491
+ this.pending.delete(clientMessageId);
492
+ for (const waiter of waiters ?? [])
493
+ waiter.reject(new errors_1.GiovChatError("send_failed", SEND_FAILED));
494
+ }
495
+ reconnect(generation) {
496
+ if (!this.desired || generation !== this.generation || this.timer)
497
+ return;
498
+ const delay = reconnectDelay(this.attempt++);
499
+ this.timer = setTimeout(() => {
500
+ this.timer = null;
501
+ void this.open(generation).catch((error) => {
502
+ if (!this.desired || generation !== this.generation)
503
+ return;
504
+ const failure = (0, errors_1.chatError)(error);
505
+ if (failure.code === "invalid_session")
506
+ this.disconnect();
507
+ else
508
+ this.reconnect(generation);
509
+ this.emit(this.errorCbs, failure);
510
+ });
511
+ }, delay);
512
+ }
513
+ /** Reconnect now instead of waiting for the backoff, e.g. when the app returns to the foreground. */
514
+ reconnectNow() {
515
+ if (!this.desired || this.isConnected || !this.timer)
516
+ return;
517
+ clearTimeout(this.timer);
518
+ this.timer = null;
519
+ this.attempt = 0;
520
+ const generation = this.generation;
521
+ void this.open(generation).catch((error) => {
522
+ if (!this.desired || generation !== this.generation)
523
+ return;
524
+ const failure = (0, errors_1.chatError)(error);
525
+ if (failure.code === "invalid_session")
526
+ this.disconnect();
527
+ else
528
+ this.reconnect(generation);
529
+ this.emit(this.errorCbs, failure);
530
+ });
531
+ }
532
+ /** Puts everything waiting in the outbox on the socket, oldest first. */
533
+ flush() {
534
+ const ws = this.ws;
535
+ if (!ws || ws.readyState !== WebSocket.OPEN)
536
+ return;
537
+ for (const { frame } of this.outbox.takeQueued()) {
538
+ try {
539
+ ws.send(frame);
540
+ }
541
+ catch {
542
+ ws.close();
543
+ return;
544
+ }
545
+ }
546
+ }
547
+ /**
548
+ * Queues a visitor message and sends it as soon as a socket is open; returns its client message id
549
+ * at once. Delivery is tracked in `outbox` and confirmed through `onAck`.
550
+ */
551
+ send(text, attachments = []) {
552
+ const entry = this.outbox.enqueue(text, attachments);
553
+ this.flush();
554
+ return entry.clientMessageId;
555
+ }
556
+ /**
557
+ * Sends one message and resolves with the stored copy once the server confirms it. While the
558
+ * connection is being restored it waits in the outbox instead of failing. Rejects only when the
559
+ * server refuses it or `disconnect()` is called first.
560
+ */
561
+ async sendMessage(text) {
562
+ if (!text.trim() || text.length > 2000)
563
+ throw new errors_1.GiovChatError("send_failed", "Messages must contain 1–2000 characters.");
564
+ const clientMessageId = this.outbox.enqueue(text).clientMessageId;
565
+ const result = new Promise((resolve, reject) => {
566
+ const waiters = this.pending.get(clientMessageId) ?? [];
567
+ waiters.push({ resolve, reject });
568
+ this.pending.set(clientMessageId, waiters);
569
+ });
570
+ this.flush();
571
+ return result;
572
+ }
573
+ /** Sends a message that failed again, in its original place in the queue. */
574
+ retryMessage(clientMessageId) {
575
+ const retried = this.outbox.retry(clientMessageId);
576
+ if (retried)
577
+ this.flush();
578
+ return retried;
579
+ }
580
+ /** The visitor started typing (send once per burst) or stopped. */
581
+ sendTyping() { this.sendFrame({ type: "typing" }); }
582
+ sendTypingEnd() { this.sendFrame({ type: "typing_end" }); }
583
+ sendFrame(frame) {
584
+ if (this.ws?.readyState !== WebSocket.OPEN)
585
+ return;
586
+ try {
587
+ this.ws.send(JSON.stringify(frame));
588
+ }
589
+ catch { /* The next reconnect recovers. */ }
590
+ }
591
+ /**
592
+ * Uploads one file to this conversation and returns its URL for `send`. Reports progress (0–1)
593
+ * where XMLHttpRequest is available (React Native has it). Retries once with a new token on 401.
594
+ */
595
+ async uploadAttachment(file, onProgress) {
596
+ const session = this.session;
597
+ if (!session)
598
+ throw new errors_1.GiovChatError("disconnected", "Chat closed.");
599
+ const path = `${this.baseUrl}/conversations/${encodeURIComponent(session.conversationId)}/upload-visitor`;
600
+ // One bound for the whole upload, the 401 retry included, so a stalled network cannot hold the composer.
601
+ const deadline = Date.now() + exports.UPLOAD_TIMEOUT_MS;
602
+ let { token } = await this.token();
603
+ let response = await upload(path, token, file, deadline, onProgress);
604
+ if (response.status === 401 && Date.now() < deadline) {
605
+ token = (await this.token(true)).token;
606
+ response = await upload(path, token, file, deadline, onProgress);
607
+ }
608
+ const data = (0, protocol_1.object)(response.body);
609
+ if (response.status < 200 || response.status >= 300 || typeof data.url !== "string")
610
+ throw uploadError(response, data);
611
+ const access = (0, attachments_1.parseAttachmentAccess)(data.attachmentAccess);
612
+ if (access)
613
+ this.emit(this.accessCbs, access);
614
+ const preview = (0, attachments_1.sanitizePreview)(data.preview);
615
+ return { url: data.url, filename: typeof data.filename === "string" ? data.filename : file.name, ...(preview ? { preview } : {}) };
616
+ }
617
+ /**
618
+ * Registers this device for visitor push notifications about this conversation. Until the server
619
+ * supports visitor push it resolves `{ registered: false, reason: "unsupported" }` and never throws.
620
+ */
621
+ async registerPushToken(token, platform, options = {}) {
622
+ if (this.pushUnsupported)
623
+ return { registered: false, reason: "unsupported" };
624
+ const session = this.session;
625
+ if (!session)
626
+ return { registered: false, reason: "no_conversation" };
627
+ if (!token.trim() || token.length > 4096 || (platform !== "ios" && platform !== "android"))
628
+ return { registered: false, reason: "failed" };
629
+ const locale = options.locale ?? (0, i18n_1.deviceLocale)();
630
+ try {
631
+ const response = await this.pushRequest(session, "POST", { token, platform,
632
+ ...(options.environment ? { environment: options.environment } : {}), ...(options.appId ? { appId: options.appId } : {}),
633
+ ...(PUSH_LOCALE.test(locale) ? { locale } : {}) });
634
+ if (response.status === 404 || response.status === 405 || response.status === 501) {
635
+ this.pushUnsupported = true;
636
+ return { registered: false, reason: "unsupported" };
637
+ }
638
+ return response.ok ? { registered: true } : { registered: false, reason: "failed" };
639
+ }
640
+ catch {
641
+ return { registered: false, reason: "failed" };
642
+ }
643
+ }
644
+ /**
645
+ * Stops push notifications about this conversation on this device (sign-out, notifications turned
646
+ * off). Resolves true when the server confirmed; never throws. Without a live connection, pass the
647
+ * stored `session`: a fresh visitor token is fetched for it.
648
+ */
649
+ async unregisterPushToken(token, session) {
650
+ if (this.pushUnsupported || !token.trim() || token.length > 4096)
651
+ return false;
652
+ if (this.session) {
653
+ try {
654
+ return (await this.pushRequest(this.session, "DELETE", { token })).ok;
655
+ }
656
+ catch {
657
+ return false;
658
+ }
659
+ }
660
+ return session ? unregisterPushToken(this.baseUrl, this.options.tenantKey, session, token) : false;
661
+ }
662
+ /** One push-token request with the visitor token, renewed once on 401 (protocol "Tokens"). */
663
+ async pushRequest(session, method, body) {
664
+ const send = async (visitorToken) => pushFetch(this.baseUrl, session, method, visitorToken, body);
665
+ const response = await send((await this.token()).token);
666
+ return response.status === 401 ? send((await this.token(true)).token) : response;
667
+ }
668
+ rejectPending() {
669
+ for (const waiters of this.pending.values()) {
670
+ for (const waiter of waiters)
671
+ waiter.reject(new errors_1.GiovChatError("send_failed", "Delivery could not be confirmed. Check the conversation before sending again."));
672
+ }
673
+ this.pending.clear();
674
+ }
675
+ /** Closes the connection. Unsent messages stay in the outbox; promises from sendMessage are rejected. */
676
+ disconnect() {
677
+ this.rejectPending();
678
+ this.teardown();
679
+ }
680
+ teardown() {
681
+ this.desired = false;
682
+ this.generation++;
683
+ this.controller?.abort();
684
+ this.controller = null;
685
+ this.cancelHandshake?.();
686
+ this.cancelHandshake = null;
687
+ if (this.timer)
688
+ clearTimeout(this.timer);
689
+ if (this.heartbeat)
690
+ clearInterval(this.heartbeat);
691
+ if (this.watchdog)
692
+ clearInterval(this.watchdog);
693
+ for (const timer of this.resendTimers)
694
+ clearTimeout(timer);
695
+ this.resendTimers.clear();
696
+ this.timer = null;
697
+ this.heartbeat = null;
698
+ this.watchdog = null;
699
+ const ws = this.ws;
700
+ this.ws = null;
701
+ if (ws) {
702
+ ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null;
703
+ ws.close();
704
+ }
705
+ // Unsent messages stay in the outbox; a later connection sends them.
706
+ this.outbox.socketClosed();
707
+ this.session = null;
708
+ this.widgetToken = null;
709
+ this.tokenRequest = null;
710
+ this.emit(this.statusCbs, false);
711
+ }
712
+ get isConnected() { return this.ws !== null && this.ws.readyState === WebSocket.OPEN; }
713
+ }
714
+ exports.ChatClient = ChatClient;
715
+ /** A push-token request; it names the SDK so the dashboard can show which app versions receive pushes. */
716
+ function pushFetch(baseUrl, session, method, visitorToken, body) {
717
+ return fetch(`${baseUrl}/conversations/${encodeURIComponent(session.conversationId)}${exports.PUSH_TOKEN_PATH}`, {
718
+ method, headers: { "Content-Type": "application/json", Authorization: `Bearer ${visitorToken}`, ...exports.CLIENT_HEADER }, body: JSON.stringify(body),
719
+ });
720
+ }
721
+ /**
722
+ * Stops push for a stored conversation without a connection (the chat is closed or hidden): a fresh
723
+ * visitor token from the session secret, then `DELETE /push-token`. Never throws.
724
+ */
725
+ async function unregisterPushToken(baseUrl, tenantKey, session, token) {
726
+ if (!token.trim() || token.length > 4096)
727
+ return false;
728
+ try {
729
+ const { token: visitorToken } = await fetchWidgetToken(baseUrl, tenantKey, session);
730
+ return (await pushFetch(normalizeBaseUrl(baseUrl), session, "DELETE", visitorToken, { token })).ok;
731
+ }
732
+ catch {
733
+ return false;
734
+ }
735
+ }
736
+ /** The whole upload, retry included, gives up after this long. */
737
+ exports.UPLOAD_TIMEOUT_MS = 120000;
738
+ /**
739
+ * An upload refusal by status: 404 the conversation is gone, 401/403 the session is gone, 429 rate
740
+ * limited (with Retry-After), 5xx the server, any other 4xx the file itself (type, size, policy).
741
+ */
742
+ function uploadError(response, data) {
743
+ const { status } = response;
744
+ const message = typeof data.message === "string" ? data.message : "File upload failed.";
745
+ if (status === 0)
746
+ return response.timedOut ? new errors_1.GiovChatError("timeout", "File upload timed out.") : new errors_1.GiovChatError("network", "Unable to reach chat. Please try again.");
747
+ if (status === 404 || status === 409)
748
+ return new errors_1.GiovChatError("conversation_closed", "This conversation has ended.", status);
749
+ if (status === 401 || status === 403)
750
+ return new errors_1.GiovChatError("invalid_session", "Chat session expired. Please start a new chat.", status);
751
+ if (status === 429)
752
+ return new errors_1.GiovChatError("rate_limited", "Too many requests. Please wait a moment.", status, response.retryAfter);
753
+ if (status >= 500)
754
+ return new errors_1.GiovChatError("server", message, status, response.retryAfter);
755
+ return new errors_1.GiovChatError("upload_failed", message, status);
756
+ }
757
+ /** One multipart upload, through XMLHttpRequest for progress when it exists, else fetch; abandoned at `deadline`. */
758
+ function upload(url, token, file, deadline, onProgress) {
759
+ const form = (0, attachments_1.buildUploadForm)(file);
760
+ const headers = { Authorization: `Bearer ${token}` };
761
+ const remaining = Math.max(0, deadline - Date.now());
762
+ if (typeof XMLHttpRequest === "undefined") {
763
+ const controller = new AbortController();
764
+ let timedOut = false;
765
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, remaining);
766
+ return fetch(url, { method: "POST", headers, body: form, signal: controller.signal })
767
+ .then(async (response) => ({ status: response.status, body: await response.json().catch(() => null),
768
+ retryAfter: (0, errors_1.retryAfterSeconds)(response.headers?.get?.("Retry-After")) }))
769
+ .catch(() => ({ status: 0, body: null, timedOut }))
770
+ .finally(() => clearTimeout(timer));
771
+ }
772
+ return new Promise((resolve) => {
773
+ const xhr = new XMLHttpRequest();
774
+ let timedOut = false;
775
+ // xhr.timeout alone is not enough: some stacks never fire it while bytes trickle out.
776
+ const timer = setTimeout(() => { timedOut = true; xhr.abort(); }, remaining);
777
+ const done = (value) => { clearTimeout(timer); resolve(value); };
778
+ xhr.open("POST", url);
779
+ for (const [name, value] of Object.entries(headers))
780
+ xhr.setRequestHeader(name, value);
781
+ xhr.timeout = remaining;
782
+ if (onProgress && xhr.upload) {
783
+ xhr.upload.onprogress = (event) => { if (event.lengthComputable && event.total > 0)
784
+ onProgress(Math.min(1, event.loaded / event.total)); };
785
+ }
786
+ xhr.onload = () => {
787
+ let body = null;
788
+ try {
789
+ body = JSON.parse(xhr.responseText);
790
+ }
791
+ catch { /* Not JSON. */ }
792
+ if (xhr.status >= 200 && xhr.status < 300)
793
+ onProgress?.(1);
794
+ done({ status: xhr.status, body, retryAfter: (0, errors_1.retryAfterSeconds)(xhr.getResponseHeader?.("Retry-After")) });
795
+ };
796
+ xhr.ontimeout = () => { timedOut = true; done({ status: 0, body: null, timedOut }); };
797
+ xhr.onerror = xhr.onabort = () => done({ status: 0, body: null, timedOut });
798
+ xhr.send(form);
799
+ });
800
+ }
801
+ //# sourceMappingURL=client.js.map