@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/browser-mount.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),
|
|
@@ -1081,8 +1394,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1081
1394
|
]
|
|
1082
1395
|
});
|
|
1083
1396
|
}
|
|
1084
|
-
|
|
1085
|
-
//#region src/transport.ts
|
|
1397
|
+
// src/transport.ts
|
|
1086
1398
|
function isServerEvent(value) {
|
|
1087
1399
|
if (typeof value !== "object" || value === null) return false;
|
|
1088
1400
|
return typeof value.type === "string";
|
|
@@ -1127,8 +1439,7 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
|
|
|
1127
1439
|
};
|
|
1128
1440
|
};
|
|
1129
1441
|
}
|
|
1130
|
-
|
|
1131
|
-
//#region src/clivly-transport.ts
|
|
1442
|
+
// src/clivly-transport.ts
|
|
1132
1443
|
/**
|
|
1133
1444
|
* The transport a host app should use against a real Clivly backend.
|
|
1134
1445
|
*
|
|
@@ -1145,8 +1456,26 @@ function createClivlyTransport(options = {}) {
|
|
|
1145
1456
|
}
|
|
1146
1457
|
});
|
|
1147
1458
|
}
|
|
1148
|
-
|
|
1149
|
-
|
|
1459
|
+
// src/layout.ts
|
|
1460
|
+
const PLACEMENTS = [
|
|
1461
|
+
"bottom-left",
|
|
1462
|
+
"bottom-right",
|
|
1463
|
+
"inline"
|
|
1464
|
+
];
|
|
1465
|
+
const LAUNCHERS = [
|
|
1466
|
+
"auto",
|
|
1467
|
+
"icon",
|
|
1468
|
+
"pill"
|
|
1469
|
+
];
|
|
1470
|
+
/** A script tag's `data-placement`; anything unrecognised floats bottom-right. */
|
|
1471
|
+
function parsePlacement(value) {
|
|
1472
|
+
return PLACEMENTS.find((placement) => placement === value) ?? "bottom-right";
|
|
1473
|
+
}
|
|
1474
|
+
/** A script tag's `data-launcher`; anything unrecognised is `"auto"`. */
|
|
1475
|
+
function parseLauncher(value) {
|
|
1476
|
+
return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
|
|
1477
|
+
}
|
|
1478
|
+
// src/session.ts
|
|
1150
1479
|
function isWidgetSessionPayload(value) {
|
|
1151
1480
|
return typeof value === "object" && value !== null;
|
|
1152
1481
|
}
|
|
@@ -1168,8 +1497,7 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
|
|
|
1168
1497
|
return data;
|
|
1169
1498
|
};
|
|
1170
1499
|
}
|
|
1171
|
-
|
|
1172
|
-
//#region src/browser-mount.tsx
|
|
1500
|
+
// src/browser-mount.tsx
|
|
1173
1501
|
const MOUNTED_DATA_ATTR = "clivlyMounted";
|
|
1174
1502
|
/**
|
|
1175
1503
|
* The identity a script-tag host already knows, read off the tag.
|
|
@@ -1243,6 +1571,8 @@ function mountChatWidgetScript(script = document.currentScript) {
|
|
|
1243
1571
|
const { root, setTheme } = createWidgetMount({
|
|
1244
1572
|
container,
|
|
1245
1573
|
defaultVisitor: readScriptVisitor(script.dataset),
|
|
1574
|
+
launcher: parseLauncher(script.dataset.launcher),
|
|
1575
|
+
placement: parsePlacement(script.dataset.placement),
|
|
1246
1576
|
sessionUrl,
|
|
1247
1577
|
socketUrlTemplate: script.dataset.socketUrlTemplate,
|
|
1248
1578
|
theme: parseTheme(script.dataset.theme),
|
|
@@ -1259,5 +1589,4 @@ function autoMountChatWidgetScript(script = document.currentScript) {
|
|
|
1259
1589
|
if (!script || scriptAlreadyMounted(script)) return null;
|
|
1260
1590
|
return mountChatWidgetScript(script);
|
|
1261
1591
|
}
|
|
1262
|
-
//#endregion
|
|
1263
1592
|
export { autoMountChatWidgetScript, mountChatWidget, mountChatWidgetScript };
|