@clivly/chat-widget 0.15.0 → 0.17.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.
- package/README.md +39 -0
- package/dist/browser-mount.cjs +394 -65
- package/dist/browser-mount.d.cts +51 -11
- package/dist/browser-mount.d.mts +51 -11
- package/dist/browser-mount.mjs +395 -66
- package/dist/browser.cjs +395 -67
- package/dist/browser.d.cts +51 -13
- package/dist/browser.d.mts +51 -13
- package/dist/browser.mjs +396 -68
- package/dist/index.cjs +395 -67
- package/dist/index.d.cts +52 -19
- package/dist/index.d.mts +52 -19
- package/dist/index.mjs +396 -68
- package/dist/widget.global.js +104 -12
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRoot } from "react-dom/client";
|
|
2
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
-
|
|
4
|
+
// src/theme.ts
|
|
5
5
|
const DARK_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
|
6
6
|
/** The attribute set by next-themes, daisyUI, and most `data-*` theme setups. */
|
|
7
7
|
const THEME_ATTRIBUTE = "data-theme";
|
|
@@ -109,8 +109,51 @@ function parseTheme(value) {
|
|
|
109
109
|
if (value === "auto" || value === "dark" || value === "light") return value;
|
|
110
110
|
return "auto";
|
|
111
111
|
}
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
// src/visitor-token-storage.ts
|
|
113
|
+
/** Normalised the way the server matches contacts: trimmed, lower-case. */
|
|
114
|
+
function normalizeVisitorEmail(email) {
|
|
115
|
+
return email.trim().toLowerCase();
|
|
116
|
+
}
|
|
117
|
+
const HASH_MODULUS = 4294967291;
|
|
118
|
+
/**
|
|
119
|
+
* A polynomial string hash, reduced mod a prime below 2^32: stable,
|
|
120
|
+
* synchronous, and exact in doubles (every intermediate stays under 2^53).
|
|
121
|
+
* Not a security primitive.
|
|
122
|
+
*/
|
|
123
|
+
function shortHash(value) {
|
|
124
|
+
let hash = 5381;
|
|
125
|
+
for (const char of value) hash = (hash * 33 + (char.codePointAt(0) ?? 0)) % HASH_MODULUS;
|
|
126
|
+
return hash.toString(16).padStart(8, "0");
|
|
127
|
+
}
|
|
128
|
+
function visitorTokenKey(widgetId, email) {
|
|
129
|
+
return `clivly-chat-widget:${widgetId}:visitor-token:${shortHash(normalizeVisitorEmail(email))}`;
|
|
130
|
+
}
|
|
131
|
+
/** The single per-widget key every widget before this change wrote. */
|
|
132
|
+
function legacyVisitorTokenKey(widgetId) {
|
|
133
|
+
return `clivly-chat-widget:${widgetId}:visitor-token`;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The token to offer for this person. Falls back to the legacy per-widget
|
|
137
|
+
* token once, so visitors who chatted before this change still resume — the
|
|
138
|
+
* server only honours it if they are the identity that thread belongs to.
|
|
139
|
+
*/
|
|
140
|
+
function readVisitorToken(storage, widgetId, email) {
|
|
141
|
+
if (!storage) return null;
|
|
142
|
+
try {
|
|
143
|
+
return storage.getItem(visitorTokenKey(widgetId, email)) ?? storage.getItem(legacyVisitorTokenKey(widgetId));
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Store this person's token and retire the legacy shared one. */
|
|
149
|
+
function writeVisitorToken(storage, widgetId, email, token) {
|
|
150
|
+
if (!storage) return;
|
|
151
|
+
try {
|
|
152
|
+
storage.setItem(visitorTokenKey(widgetId, email), token);
|
|
153
|
+
storage.removeItem?.(legacyVisitorTokenKey(widgetId));
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
// src/chat-widget.tsx
|
|
114
157
|
const DELIVERY_LABELS = {
|
|
115
158
|
failed: "Failed to send",
|
|
116
159
|
pending: "Sending…",
|
|
@@ -169,16 +212,95 @@ const WIDGET_STYLES = `
|
|
|
169
212
|
font-size: max(16px, 1em);
|
|
170
213
|
}
|
|
171
214
|
}
|
|
215
|
+
/* Floating placements. The insets keep the launcher off a phone's home
|
|
216
|
+
indicator and rounded corners; --clivly-chat-z-index lets a host slot it
|
|
217
|
+
beneath its own dialogs. */
|
|
218
|
+
.clivly-chat-widget[data-clivly-placement="bottom-right"],
|
|
219
|
+
.clivly-chat-widget[data-clivly-placement="bottom-left"] {
|
|
220
|
+
bottom: max(1.25rem, env(safe-area-inset-bottom));
|
|
221
|
+
position: fixed;
|
|
222
|
+
z-index: var(--clivly-chat-z-index, 2147483000);
|
|
223
|
+
}
|
|
224
|
+
.clivly-chat-widget[data-clivly-placement="bottom-right"] {
|
|
225
|
+
right: max(1.25rem, env(safe-area-inset-right));
|
|
226
|
+
}
|
|
227
|
+
.clivly-chat-widget[data-clivly-placement="bottom-left"] {
|
|
228
|
+
left: max(1.25rem, env(safe-area-inset-left));
|
|
229
|
+
}
|
|
172
230
|
.clivly-chat-widget__launcher {
|
|
173
231
|
align-items: center;
|
|
174
232
|
background: var(--clivly-chat-accent);
|
|
175
233
|
border: 0;
|
|
176
234
|
border-radius: 999px;
|
|
235
|
+
box-shadow:
|
|
236
|
+
0 2px 6px rgba(0, 0, 0, 0.18),
|
|
237
|
+
0 10px 28px -10px var(--clivly-chat-accent);
|
|
177
238
|
color: var(--clivly-chat-accent-contrast);
|
|
178
239
|
cursor: pointer;
|
|
179
240
|
display: inline-flex;
|
|
241
|
+
font-weight: 500;
|
|
180
242
|
gap: 0.5rem;
|
|
181
|
-
|
|
243
|
+
justify-content: center;
|
|
244
|
+
min-height: 3rem;
|
|
245
|
+
padding: 0 1.15rem;
|
|
246
|
+
transition: transform 150ms ease-out, filter 150ms ease-out;
|
|
247
|
+
}
|
|
248
|
+
.clivly-chat-widget__launcher:hover {
|
|
249
|
+
filter: brightness(1.06);
|
|
250
|
+
transform: translateY(-1px);
|
|
251
|
+
}
|
|
252
|
+
.clivly-chat-widget__launcher:active {
|
|
253
|
+
transform: scale(0.96);
|
|
254
|
+
}
|
|
255
|
+
.clivly-chat-widget__launcher:focus-visible {
|
|
256
|
+
outline: 2px solid var(--clivly-chat-accent);
|
|
257
|
+
outline-offset: 3px;
|
|
258
|
+
}
|
|
259
|
+
.clivly-chat-widget__launcher-icon {
|
|
260
|
+
flex-shrink: 0;
|
|
261
|
+
height: 1.25rem;
|
|
262
|
+
width: 1.25rem;
|
|
263
|
+
}
|
|
264
|
+
/* Icon-only: a 48px circle — a comfortable touch target that does not cover
|
|
265
|
+
the host's pagination, toolbars or form controls the way a labelled pill
|
|
266
|
+
does on a phone. The label is clipped, not removed, so it stays the
|
|
267
|
+
button's accessible name. */
|
|
268
|
+
.clivly-chat-widget[data-clivly-launcher="icon"] .clivly-chat-widget__launcher {
|
|
269
|
+
height: 3rem;
|
|
270
|
+
padding: 0;
|
|
271
|
+
width: 3rem;
|
|
272
|
+
}
|
|
273
|
+
.clivly-chat-widget[data-clivly-launcher="icon"] .clivly-chat-widget__launcher-label {
|
|
274
|
+
clip-path: inset(50%);
|
|
275
|
+
height: 1px;
|
|
276
|
+
overflow: hidden;
|
|
277
|
+
position: absolute;
|
|
278
|
+
white-space: nowrap;
|
|
279
|
+
width: 1px;
|
|
280
|
+
}
|
|
281
|
+
@media (width < 48rem) {
|
|
282
|
+
.clivly-chat-widget[data-clivly-launcher="auto"] .clivly-chat-widget__launcher {
|
|
283
|
+
height: 3rem;
|
|
284
|
+
padding: 0;
|
|
285
|
+
width: 3rem;
|
|
286
|
+
}
|
|
287
|
+
.clivly-chat-widget[data-clivly-launcher="auto"] .clivly-chat-widget__launcher-label {
|
|
288
|
+
clip-path: inset(50%);
|
|
289
|
+
height: 1px;
|
|
290
|
+
overflow: hidden;
|
|
291
|
+
position: absolute;
|
|
292
|
+
white-space: nowrap;
|
|
293
|
+
width: 1px;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
@media (prefers-reduced-motion: reduce) {
|
|
297
|
+
.clivly-chat-widget__launcher {
|
|
298
|
+
transition: none;
|
|
299
|
+
}
|
|
300
|
+
.clivly-chat-widget__launcher:hover,
|
|
301
|
+
.clivly-chat-widget__launcher:active {
|
|
302
|
+
transform: none;
|
|
303
|
+
}
|
|
182
304
|
}
|
|
183
305
|
.clivly-chat-widget__panel {
|
|
184
306
|
background: var(--clivly-chat-panel);
|
|
@@ -194,6 +316,19 @@ const WIDGET_STYLES = `
|
|
|
194
316
|
bottom: 4.5rem;
|
|
195
317
|
width: min(24rem, calc(100vw - 2rem));
|
|
196
318
|
}
|
|
319
|
+
/* Opens toward the page from whichever corner the launcher is in. */
|
|
320
|
+
.clivly-chat-widget[data-clivly-placement="bottom-left"] .clivly-chat-widget__panel {
|
|
321
|
+
left: 0;
|
|
322
|
+
right: auto;
|
|
323
|
+
}
|
|
324
|
+
/* dvh: on a phone vh includes the collapsed URL bar, so a 75vh panel's top
|
|
325
|
+
edge could slide under it. The 48px launcher sits lower than the pill. */
|
|
326
|
+
@media (width < 48rem) {
|
|
327
|
+
.clivly-chat-widget__panel {
|
|
328
|
+
bottom: 4rem;
|
|
329
|
+
height: min(44rem, calc(100dvh - 6.5rem));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
197
332
|
.clivly-chat-widget__header {
|
|
198
333
|
border-bottom: 1px solid var(--clivly-chat-border);
|
|
199
334
|
display: flex;
|
|
@@ -440,9 +575,6 @@ function createLocalMessage(message, delivery = "sent") {
|
|
|
440
575
|
delivery
|
|
441
576
|
};
|
|
442
577
|
}
|
|
443
|
-
function createStorageKey(widgetId) {
|
|
444
|
-
return `clivly-chat-widget:${widgetId}:visitor-token`;
|
|
445
|
-
}
|
|
446
578
|
function formatTimestamp(value) {
|
|
447
579
|
const date = new Date(value);
|
|
448
580
|
if (Number.isNaN(date.getTime())) return "";
|
|
@@ -561,6 +693,45 @@ function automationBadge(message) {
|
|
|
561
693
|
if (message.senderType !== "system" || !message.automation) return null;
|
|
562
694
|
return AUTOMATION_BADGES[message.automation] ?? "Automated";
|
|
563
695
|
}
|
|
696
|
+
/**
|
|
697
|
+
* The button that opens and closes the panel. Its size and shape come from the
|
|
698
|
+
* root's `data-clivly-launcher` (see WIDGET_STYLES); the label is always its
|
|
699
|
+
* accessible name, and `aria-controls` names the panel only while it exists.
|
|
700
|
+
*/
|
|
701
|
+
function Launcher({ label, onToggle, open, panelId }) {
|
|
702
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
703
|
+
"aria-controls": open ? panelId : void 0,
|
|
704
|
+
"aria-expanded": open,
|
|
705
|
+
className: "clivly-chat-widget__launcher",
|
|
706
|
+
onClick: onToggle,
|
|
707
|
+
type: "button",
|
|
708
|
+
children: [/* @__PURE__ */ jsx(LauncherIcon, { open }), /* @__PURE__ */ jsx("span", {
|
|
709
|
+
className: "clivly-chat-widget__launcher-label",
|
|
710
|
+
children: label
|
|
711
|
+
})]
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* A chat bubble while closed, a cross while open — so the icon-only launcher
|
|
716
|
+
* still tells a sighted visitor what tapping it will do. Decorative: the
|
|
717
|
+
* label and `aria-expanded` carry the meaning for assistive technology.
|
|
718
|
+
* Inline SVG because the script-tag build renders inside a shadow root with no
|
|
719
|
+
* icon font or stylesheet of the host's to borrow.
|
|
720
|
+
*/
|
|
721
|
+
function LauncherIcon({ open }) {
|
|
722
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
723
|
+
"aria-hidden": "true",
|
|
724
|
+
className: "clivly-chat-widget__launcher-icon",
|
|
725
|
+
fill: "none",
|
|
726
|
+
focusable: "false",
|
|
727
|
+
stroke: "currentColor",
|
|
728
|
+
strokeLinecap: "round",
|
|
729
|
+
strokeLinejoin: "round",
|
|
730
|
+
strokeWidth: 2,
|
|
731
|
+
viewBox: "0 0 24 24",
|
|
732
|
+
children: open ? /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" }) : /* @__PURE__ */ jsx("path", { d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" })
|
|
733
|
+
});
|
|
734
|
+
}
|
|
564
735
|
function MessageBubble({ message }) {
|
|
565
736
|
const badge = automationBadge(message);
|
|
566
737
|
const author = badge ? (message.author ?? "").replace(AUTHOR_LABEL_SUFFIX, "") : message.author;
|
|
@@ -671,9 +842,10 @@ function isCompleteVisitor(visitor) {
|
|
|
671
842
|
* the visitor is told to wait for something that will never arrive. The third
|
|
672
843
|
* string keeps the status honest in all three states.
|
|
673
844
|
*/
|
|
674
|
-
function composerStatusText(hasTransport, isReady) {
|
|
845
|
+
function composerStatusText(hasTransport, isReady, reconnecting) {
|
|
675
846
|
if (!hasTransport) return "Chat is unavailable";
|
|
676
|
-
|
|
847
|
+
if (isReady) return "Conversation ready";
|
|
848
|
+
return reconnecting ? "Reconnecting…" : "Connecting…";
|
|
677
849
|
}
|
|
678
850
|
/**
|
|
679
851
|
* Guided options under the greeting. Native buttons in a labelled group, so
|
|
@@ -739,8 +911,125 @@ function StatusNotice({ assistantThinking, email, showWaitingNotice }) {
|
|
|
739
911
|
children: waitingNoticeText(email)
|
|
740
912
|
});
|
|
741
913
|
}
|
|
742
|
-
|
|
914
|
+
const RECONNECT_BASE_MS = 1e3;
|
|
915
|
+
const RECONNECT_MAX_MS = 3e4;
|
|
916
|
+
/**
|
|
917
|
+
* How long to wait before reconnect attempt `attempt` (0-based): doubling from
|
|
918
|
+
* one second to a 30-second ceiling, ±25% jitter so a fleet of widgets dropped
|
|
919
|
+
* by the same server restart doesn't reconnect in lockstep.
|
|
920
|
+
*/
|
|
921
|
+
function reconnectDelayMs(attempt, random = Math.random) {
|
|
922
|
+
const base = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt);
|
|
923
|
+
return Math.round(base * (.75 + random() * .5));
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Keep the socket for `session` open, and reconnect when it drops — a laptop
|
|
927
|
+
* waking, a network change, a server deploy — instead of leaving the visitor at
|
|
928
|
+
* "Connecting…" until they reload. A reconnect refreshes the session (same
|
|
929
|
+
* conversation, fresh token) and the server's snapshot on connect brings back
|
|
930
|
+
* anything that arrived meanwhile. Returns whether a reconnect is pending.
|
|
931
|
+
*/
|
|
932
|
+
function useLiveConnection({ actions, createTransport, refreshSession, session, setIsReady, transportRef }) {
|
|
933
|
+
const [reconnecting, setReconnecting] = useState(false);
|
|
934
|
+
const attemptRef = useRef(0);
|
|
935
|
+
const refreshRef = useRef(refreshSession);
|
|
936
|
+
const actionsRef = useRef(actions);
|
|
937
|
+
useEffect(() => {
|
|
938
|
+
refreshRef.current = refreshSession;
|
|
939
|
+
actionsRef.current = actions;
|
|
940
|
+
});
|
|
941
|
+
useEffect(() => {
|
|
942
|
+
transportRef.current?.close();
|
|
943
|
+
transportRef.current = null;
|
|
944
|
+
setIsReady(false);
|
|
945
|
+
if (!(session && createTransport)) return;
|
|
946
|
+
let disposed = false;
|
|
947
|
+
let timer;
|
|
948
|
+
const retry = () => {
|
|
949
|
+
timer = void 0;
|
|
950
|
+
refreshRef.current(session).catch(() => {
|
|
951
|
+
scheduleReconnect();
|
|
952
|
+
});
|
|
953
|
+
};
|
|
954
|
+
const scheduleReconnect = () => {
|
|
955
|
+
if (disposed || timer !== void 0) return;
|
|
956
|
+
setReconnecting(true);
|
|
957
|
+
timer = setTimeout(retry, reconnectDelayMs(attemptRef.current));
|
|
958
|
+
attemptRef.current += 1;
|
|
959
|
+
};
|
|
960
|
+
const retryNow = () => {
|
|
961
|
+
if (timer !== void 0) {
|
|
962
|
+
clearTimeout(timer);
|
|
963
|
+
retry();
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
window.addEventListener("online", retryNow);
|
|
967
|
+
const connection = createTransport({
|
|
968
|
+
handlers: {
|
|
969
|
+
onClose: () => {
|
|
970
|
+
setIsReady(false);
|
|
971
|
+
actionsRef.current.setAssistantThinking(false);
|
|
972
|
+
scheduleReconnect();
|
|
973
|
+
},
|
|
974
|
+
onError: (transportError) => {
|
|
975
|
+
if (attemptRef.current === 0) actionsRef.current.setError(transportError.message);
|
|
976
|
+
},
|
|
977
|
+
onEvent: (event) => {
|
|
978
|
+
applyServerEvent(event, {
|
|
979
|
+
...actionsRef.current,
|
|
980
|
+
setIsReady
|
|
981
|
+
});
|
|
982
|
+
},
|
|
983
|
+
onOpen: () => {
|
|
984
|
+
if (attemptRef.current > 0) actionsRef.current.setError(null);
|
|
985
|
+
attemptRef.current = 0;
|
|
986
|
+
setReconnecting(false);
|
|
987
|
+
setIsReady(true);
|
|
988
|
+
}
|
|
989
|
+
},
|
|
990
|
+
session
|
|
991
|
+
});
|
|
992
|
+
transportRef.current = connection;
|
|
993
|
+
return () => {
|
|
994
|
+
disposed = true;
|
|
995
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
996
|
+
window.removeEventListener("online", retryNow);
|
|
997
|
+
connection.close();
|
|
998
|
+
if (transportRef.current === connection) transportRef.current = null;
|
|
999
|
+
};
|
|
1000
|
+
}, [createTransport, session]);
|
|
1001
|
+
return reconnecting;
|
|
1002
|
+
}
|
|
1003
|
+
/** Who the host says is signed in: their normalised email, or null. */
|
|
1004
|
+
function hostIdentityOf(defaultVisitor) {
|
|
1005
|
+
return isCompleteVisitor(defaultVisitor) ? normalizeVisitorEmail(defaultVisitor.email) : null;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* The host's signed-in visitor changed under an open session — a sign-out, or
|
|
1009
|
+
* a different account signing in without a page reload. The session on
|
|
1010
|
+
* screen is the previous person's: drop it, its transcript and its socket, so
|
|
1011
|
+
* the next open starts as whoever is signed in now (chat widget session
|
|
1012
|
+
* review 2026-10-28, S1). A session the visitor started from the pre-chat
|
|
1013
|
+
* form is theirs and is left alone.
|
|
1014
|
+
*/
|
|
1015
|
+
function useResetOnHostIdentityChange({ generationRef, hostIdentity, identityRef, reset, sessionSourceRef }) {
|
|
1016
|
+
const resetRef = useRef(reset);
|
|
1017
|
+
useEffect(() => {
|
|
1018
|
+
resetRef.current = reset;
|
|
1019
|
+
});
|
|
1020
|
+
useEffect(() => {
|
|
1021
|
+
const current = identityRef.current;
|
|
1022
|
+
if (!(current && sessionSourceRef.current === "host")) return;
|
|
1023
|
+
if (hostIdentity === normalizeVisitorEmail(current.email)) return;
|
|
1024
|
+
generationRef.current += 1;
|
|
1025
|
+
identityRef.current = null;
|
|
1026
|
+
sessionSourceRef.current = null;
|
|
1027
|
+
resetRef.current();
|
|
1028
|
+
}, [hostIdentity]);
|
|
1029
|
+
}
|
|
1030
|
+
function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcher = "auto", launcherLabel = "Chat with us", placement = "bottom-right", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
|
|
743
1031
|
const [isOpen, setIsOpen] = useState(initiallyOpen);
|
|
1032
|
+
const panelId = useId();
|
|
744
1033
|
const [error, setError] = useState(null);
|
|
745
1034
|
const [isConnecting, setIsConnecting] = useState(false);
|
|
746
1035
|
const [isReady, setIsReady] = useState(false);
|
|
@@ -755,6 +1044,12 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
755
1044
|
});
|
|
756
1045
|
const transportRef = useRef(null);
|
|
757
1046
|
const listRef = useRef(null);
|
|
1047
|
+
const identityRef = useRef(null);
|
|
1048
|
+
const sessionSourceRef = useRef(null);
|
|
1049
|
+
const generationRef = useRef(0);
|
|
1050
|
+
const hostIdentity = hostIdentityOf(defaultVisitor);
|
|
1051
|
+
const latestHostIdentityRef = useRef(hostIdentity);
|
|
1052
|
+
latestHostIdentityRef.current = hostIdentity;
|
|
758
1053
|
useEffect(() => {
|
|
759
1054
|
if (createTransport) return;
|
|
760
1055
|
console.warn("[clivly] <ChatWidget> was given no createTransport, so messages cannot be sent. Pass createTransport={createClivlyTransport()} — see https://www.npmjs.com/package/@clivly/chat-widget#five-minute-quick-start");
|
|
@@ -770,41 +1065,32 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
770
1065
|
if (!listRef.current) return;
|
|
771
1066
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
772
1067
|
}, [messages]);
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
},
|
|
800
|
-
session
|
|
801
|
-
});
|
|
802
|
-
transportRef.current = connection;
|
|
803
|
-
return () => {
|
|
804
|
-
connection.close();
|
|
805
|
-
if (transportRef.current === connection) transportRef.current = null;
|
|
806
|
-
};
|
|
807
|
-
}, [createTransport, session]);
|
|
1068
|
+
const reconnecting = useLiveConnection({
|
|
1069
|
+
actions: {
|
|
1070
|
+
setAssistantThinking,
|
|
1071
|
+
setError,
|
|
1072
|
+
setMessages,
|
|
1073
|
+
setSupportState
|
|
1074
|
+
},
|
|
1075
|
+
createTransport,
|
|
1076
|
+
refreshSession: async (current) => {
|
|
1077
|
+
const identity = identityRef.current;
|
|
1078
|
+
if (!identity) throw new Error("No visitor to reconnect as.");
|
|
1079
|
+
const generation = generationRef.current;
|
|
1080
|
+
const next = await getSession({
|
|
1081
|
+
visitor: identity,
|
|
1082
|
+
visitorToken: current.visitorToken,
|
|
1083
|
+
widgetId
|
|
1084
|
+
});
|
|
1085
|
+
if (generation !== generationRef.current) return;
|
|
1086
|
+
writeVisitorToken(storage, widgetId, identity.email, next.visitorToken);
|
|
1087
|
+
setSupportState(next.support?.state ?? null);
|
|
1088
|
+
setSession(next);
|
|
1089
|
+
},
|
|
1090
|
+
session,
|
|
1091
|
+
setIsReady,
|
|
1092
|
+
transportRef
|
|
1093
|
+
});
|
|
808
1094
|
useEffect(() => {
|
|
809
1095
|
if (!(isOpen && isReady)) return;
|
|
810
1096
|
const markReadIfVisible = () => {
|
|
@@ -822,30 +1108,53 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
822
1108
|
isReady,
|
|
823
1109
|
messages
|
|
824
1110
|
]);
|
|
825
|
-
const startSession = useCallback(async (identity) => {
|
|
1111
|
+
const startSession = useCallback(async (identity, source = "form") => {
|
|
826
1112
|
setError(null);
|
|
827
1113
|
setIsConnecting(true);
|
|
1114
|
+
const generation = generationRef.current;
|
|
1115
|
+
const isStale = () => generation !== generationRef.current || source === "host" && normalizeVisitorEmail(identity.email) !== latestHostIdentityRef.current;
|
|
828
1116
|
try {
|
|
829
1117
|
const nextSession = await getSession({
|
|
830
1118
|
visitor: identity,
|
|
831
|
-
visitorToken: storage
|
|
1119
|
+
visitorToken: readVisitorToken(storage, widgetId, identity.email),
|
|
832
1120
|
widgetId
|
|
833
1121
|
});
|
|
834
|
-
|
|
1122
|
+
if (isStale()) return;
|
|
1123
|
+
writeVisitorToken(storage, widgetId, identity.email, nextSession.visitorToken);
|
|
1124
|
+
identityRef.current = identity;
|
|
1125
|
+
sessionSourceRef.current = source;
|
|
835
1126
|
setSupportState(nextSession.support?.state ?? null);
|
|
836
1127
|
setSession(nextSession);
|
|
837
1128
|
setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
|
|
838
1129
|
} catch (sessionError) {
|
|
1130
|
+
if (isStale()) return;
|
|
839
1131
|
const resolved = sessionError instanceof Error ? sessionError : /* @__PURE__ */ new Error("Failed to start the conversation.");
|
|
840
1132
|
setError(resolved.message);
|
|
841
1133
|
} finally {
|
|
842
|
-
setIsConnecting(false);
|
|
1134
|
+
if (generation === generationRef.current) setIsConnecting(false);
|
|
843
1135
|
}
|
|
844
1136
|
}, [
|
|
845
1137
|
getSession,
|
|
846
1138
|
storage,
|
|
847
1139
|
widgetId
|
|
848
1140
|
]);
|
|
1141
|
+
useResetOnHostIdentityChange({
|
|
1142
|
+
generationRef,
|
|
1143
|
+
hostIdentity,
|
|
1144
|
+
identityRef,
|
|
1145
|
+
reset: () => {
|
|
1146
|
+
setSession(null);
|
|
1147
|
+
setMessages([]);
|
|
1148
|
+
setSupportState(null);
|
|
1149
|
+
setError(null);
|
|
1150
|
+
setIsConnecting(false);
|
|
1151
|
+
setVisitor({
|
|
1152
|
+
email: defaultVisitor?.email ?? "",
|
|
1153
|
+
name: defaultVisitor?.name ?? ""
|
|
1154
|
+
});
|
|
1155
|
+
},
|
|
1156
|
+
sessionSourceRef
|
|
1157
|
+
});
|
|
849
1158
|
const submitVisitor = async (event) => {
|
|
850
1159
|
event.preventDefault();
|
|
851
1160
|
await startSession(visitor);
|
|
@@ -856,7 +1165,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
856
1165
|
startSession({
|
|
857
1166
|
email: defaultVisitor.email.trim(),
|
|
858
1167
|
name: defaultVisitor.name.trim()
|
|
859
|
-
}).catch(() => {});
|
|
1168
|
+
}, "host").catch(() => {});
|
|
860
1169
|
}, [
|
|
861
1170
|
defaultVisitor,
|
|
862
1171
|
error,
|
|
@@ -909,21 +1218,24 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
909
1218
|
const topics = visibleTopics(session, supportState);
|
|
910
1219
|
return /* @__PURE__ */ jsxs("div", {
|
|
911
1220
|
className: `clivly-chat-widget ${className ?? ""}`.trim(),
|
|
1221
|
+
"data-clivly-launcher": launcher,
|
|
1222
|
+
"data-clivly-placement": placement,
|
|
912
1223
|
"data-clivly-theme": resolvedTheme,
|
|
913
1224
|
style: themeStyle,
|
|
914
1225
|
children: [
|
|
915
1226
|
/* @__PURE__ */ jsx("style", { children: WIDGET_STYLES }),
|
|
916
|
-
/* @__PURE__ */ jsx(
|
|
917
|
-
|
|
918
|
-
|
|
1227
|
+
/* @__PURE__ */ jsx(Launcher, {
|
|
1228
|
+
label: launcherLabel,
|
|
1229
|
+
onToggle: () => {
|
|
919
1230
|
setIsOpen((open) => !open);
|
|
920
1231
|
},
|
|
921
|
-
|
|
922
|
-
|
|
1232
|
+
open: isOpen,
|
|
1233
|
+
panelId
|
|
923
1234
|
}),
|
|
924
1235
|
isOpen ? /* @__PURE__ */ jsxs("section", {
|
|
925
1236
|
"aria-label": panelTitle,
|
|
926
1237
|
className: "clivly-chat-widget__panel",
|
|
1238
|
+
id: panelId,
|
|
927
1239
|
children: [
|
|
928
1240
|
/* @__PURE__ */ jsxs("header", {
|
|
929
1241
|
className: "clivly-chat-widget__header",
|
|
@@ -996,8 +1308,9 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
996
1308
|
}), /* @__PURE__ */ jsxs("div", {
|
|
997
1309
|
className: "clivly-chat-widget__actions",
|
|
998
1310
|
children: [/* @__PURE__ */ jsx("span", {
|
|
1311
|
+
"aria-live": "polite",
|
|
999
1312
|
className: "clivly-chat-widget__subtitle",
|
|
1000
|
-
children: composerStatusText(Boolean(createTransport), isReady)
|
|
1313
|
+
children: composerStatusText(Boolean(createTransport), isReady, reconnecting)
|
|
1001
1314
|
}), /* @__PURE__ */ jsx("button", {
|
|
1002
1315
|
className: "clivly-chat-widget__button",
|
|
1003
1316
|
disabled: !(messageDraft.trim() && canSend),
|
|
@@ -1096,8 +1409,7 @@ function PreChatForm() {
|
|
|
1096
1409
|
function ConversationView() {
|
|
1097
1410
|
return null;
|
|
1098
1411
|
}
|
|
1099
|
-
|
|
1100
|
-
//#region src/transport.ts
|
|
1412
|
+
// src/transport.ts
|
|
1101
1413
|
function isServerEvent(value) {
|
|
1102
1414
|
if (typeof value !== "object" || value === null) return false;
|
|
1103
1415
|
return typeof value.type === "string";
|
|
@@ -1199,8 +1511,7 @@ function createLoopbackTransport({ operatorName = "Clivly", operatorReply = (bod
|
|
|
1199
1511
|
};
|
|
1200
1512
|
};
|
|
1201
1513
|
}
|
|
1202
|
-
|
|
1203
|
-
//#region src/clivly-transport.ts
|
|
1514
|
+
// src/clivly-transport.ts
|
|
1204
1515
|
/**
|
|
1205
1516
|
* The transport a host app should use against a real Clivly backend.
|
|
1206
1517
|
*
|
|
@@ -1217,8 +1528,26 @@ function createClivlyTransport$1(options = {}) {
|
|
|
1217
1528
|
}
|
|
1218
1529
|
});
|
|
1219
1530
|
}
|
|
1220
|
-
|
|
1221
|
-
|
|
1531
|
+
// src/layout.ts
|
|
1532
|
+
const PLACEMENTS = [
|
|
1533
|
+
"bottom-left",
|
|
1534
|
+
"bottom-right",
|
|
1535
|
+
"inline"
|
|
1536
|
+
];
|
|
1537
|
+
const LAUNCHERS = [
|
|
1538
|
+
"auto",
|
|
1539
|
+
"icon",
|
|
1540
|
+
"pill"
|
|
1541
|
+
];
|
|
1542
|
+
/** A script tag's `data-placement`; anything unrecognised floats bottom-right. */
|
|
1543
|
+
function parsePlacement(value) {
|
|
1544
|
+
return PLACEMENTS.find((placement) => placement === value) ?? "bottom-right";
|
|
1545
|
+
}
|
|
1546
|
+
/** A script tag's `data-launcher`; anything unrecognised is `"auto"`. */
|
|
1547
|
+
function parseLauncher(value) {
|
|
1548
|
+
return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
|
|
1549
|
+
}
|
|
1550
|
+
// src/session.ts
|
|
1222
1551
|
function isWidgetSessionPayload(value) {
|
|
1223
1552
|
return typeof value === "object" && value !== null;
|
|
1224
1553
|
}
|
|
@@ -1240,8 +1569,7 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
|
|
|
1240
1569
|
return data;
|
|
1241
1570
|
};
|
|
1242
1571
|
}
|
|
1243
|
-
|
|
1244
|
-
//#region src/browser-mount.tsx
|
|
1572
|
+
// src/browser-mount.tsx
|
|
1245
1573
|
const MOUNTED_DATA_ATTR = "clivlyMounted";
|
|
1246
1574
|
/**
|
|
1247
1575
|
* The identity a script-tag host already knows, read off the tag.
|
|
@@ -1315,6 +1643,8 @@ function mountChatWidgetScript$1(script = document.currentScript) {
|
|
|
1315
1643
|
const { root, setTheme } = createWidgetMount({
|
|
1316
1644
|
container,
|
|
1317
1645
|
defaultVisitor: readScriptVisitor(script.dataset),
|
|
1646
|
+
launcher: parseLauncher(script.dataset.launcher),
|
|
1647
|
+
placement: parsePlacement(script.dataset.placement),
|
|
1318
1648
|
sessionUrl,
|
|
1319
1649
|
socketUrlTemplate: script.dataset.socketUrlTemplate,
|
|
1320
1650
|
theme: parseTheme(script.dataset.theme),
|
|
@@ -1331,11 +1661,9 @@ function autoMountChatWidgetScript$1(script = document.currentScript) {
|
|
|
1331
1661
|
if (!script || scriptAlreadyMounted(script)) return null;
|
|
1332
1662
|
return mountChatWidgetScript$1(script);
|
|
1333
1663
|
}
|
|
1334
|
-
|
|
1335
|
-
//#region src/index.ts
|
|
1664
|
+
// src/index.ts
|
|
1336
1665
|
const autoMountChatWidgetScript = autoMountChatWidgetScript$1;
|
|
1337
1666
|
const createClivlyTransport = createClivlyTransport$1;
|
|
1338
1667
|
const mountChatWidget = mountChatWidget$1;
|
|
1339
1668
|
const mountChatWidgetScript = mountChatWidgetScript$1;
|
|
1340
|
-
//#endregion
|
|
1341
1669
|
export { ChatLauncher, ChatPanel, ChatWidget, ConversationView, MessageComposer, PreChatForm, autoMountChatWidgetScript, createClivlyTransport, createLoopbackTransport, createSessionFetcher, createWebSocketTransport, mountChatWidget, mountChatWidgetScript, resolveTheme };
|