@clivly/chat-widget 0.16.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/dist/browser-mount.cjs +113 -23
- package/dist/browser-mount.d.cts +22 -13
- package/dist/browser-mount.d.mts +22 -13
- package/dist/browser-mount.mjs +113 -23
- package/dist/browser.cjs +114 -25
- package/dist/browser.d.cts +22 -15
- package/dist/browser.d.mts +22 -15
- package/dist/browser.mjs +114 -25
- package/dist/index.cjs +114 -25
- package/dist/index.d.cts +21 -19
- package/dist/index.d.mts +21 -19
- package/dist/index.mjs +114 -25
- package/dist/widget.global.js +11 -11
- package/package.json +2 -2
package/dist/browser.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRoot } from "react-dom/client";
|
|
2
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…",
|
|
@@ -532,9 +575,6 @@ function createLocalMessage(message, delivery = "sent") {
|
|
|
532
575
|
delivery
|
|
533
576
|
};
|
|
534
577
|
}
|
|
535
|
-
function createStorageKey(widgetId) {
|
|
536
|
-
return `clivly-chat-widget:${widgetId}:visitor-token`;
|
|
537
|
-
}
|
|
538
578
|
function formatTimestamp(value) {
|
|
539
579
|
const date = new Date(value);
|
|
540
580
|
if (Number.isNaN(date.getTime())) return "";
|
|
@@ -960,6 +1000,33 @@ function useLiveConnection({ actions, createTransport, refreshSession, session,
|
|
|
960
1000
|
}, [createTransport, session]);
|
|
961
1001
|
return reconnecting;
|
|
962
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
|
+
}
|
|
963
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 }) {
|
|
964
1031
|
const [isOpen, setIsOpen] = useState(initiallyOpen);
|
|
965
1032
|
const panelId = useId();
|
|
@@ -978,6 +1045,11 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
978
1045
|
const transportRef = useRef(null);
|
|
979
1046
|
const listRef = useRef(null);
|
|
980
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;
|
|
981
1053
|
useEffect(() => {
|
|
982
1054
|
if (createTransport) return;
|
|
983
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");
|
|
@@ -1004,12 +1076,14 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1004
1076
|
refreshSession: async (current) => {
|
|
1005
1077
|
const identity = identityRef.current;
|
|
1006
1078
|
if (!identity) throw new Error("No visitor to reconnect as.");
|
|
1079
|
+
const generation = generationRef.current;
|
|
1007
1080
|
const next = await getSession({
|
|
1008
1081
|
visitor: identity,
|
|
1009
1082
|
visitorToken: current.visitorToken,
|
|
1010
1083
|
widgetId
|
|
1011
1084
|
});
|
|
1012
|
-
|
|
1085
|
+
if (generation !== generationRef.current) return;
|
|
1086
|
+
writeVisitorToken(storage, widgetId, identity.email, next.visitorToken);
|
|
1013
1087
|
setSupportState(next.support?.state ?? null);
|
|
1014
1088
|
setSession(next);
|
|
1015
1089
|
},
|
|
@@ -1034,31 +1108,53 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1034
1108
|
isReady,
|
|
1035
1109
|
messages
|
|
1036
1110
|
]);
|
|
1037
|
-
const startSession = useCallback(async (identity) => {
|
|
1111
|
+
const startSession = useCallback(async (identity, source = "form") => {
|
|
1038
1112
|
setError(null);
|
|
1039
1113
|
setIsConnecting(true);
|
|
1114
|
+
const generation = generationRef.current;
|
|
1115
|
+
const isStale = () => generation !== generationRef.current || source === "host" && normalizeVisitorEmail(identity.email) !== latestHostIdentityRef.current;
|
|
1040
1116
|
try {
|
|
1041
1117
|
const nextSession = await getSession({
|
|
1042
1118
|
visitor: identity,
|
|
1043
|
-
visitorToken: storage
|
|
1119
|
+
visitorToken: readVisitorToken(storage, widgetId, identity.email),
|
|
1044
1120
|
widgetId
|
|
1045
1121
|
});
|
|
1046
|
-
|
|
1122
|
+
if (isStale()) return;
|
|
1123
|
+
writeVisitorToken(storage, widgetId, identity.email, nextSession.visitorToken);
|
|
1047
1124
|
identityRef.current = identity;
|
|
1125
|
+
sessionSourceRef.current = source;
|
|
1048
1126
|
setSupportState(nextSession.support?.state ?? null);
|
|
1049
1127
|
setSession(nextSession);
|
|
1050
1128
|
setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
|
|
1051
1129
|
} catch (sessionError) {
|
|
1130
|
+
if (isStale()) return;
|
|
1052
1131
|
const resolved = sessionError instanceof Error ? sessionError : /* @__PURE__ */ new Error("Failed to start the conversation.");
|
|
1053
1132
|
setError(resolved.message);
|
|
1054
1133
|
} finally {
|
|
1055
|
-
setIsConnecting(false);
|
|
1134
|
+
if (generation === generationRef.current) setIsConnecting(false);
|
|
1056
1135
|
}
|
|
1057
1136
|
}, [
|
|
1058
1137
|
getSession,
|
|
1059
1138
|
storage,
|
|
1060
1139
|
widgetId
|
|
1061
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
|
+
});
|
|
1062
1158
|
const submitVisitor = async (event) => {
|
|
1063
1159
|
event.preventDefault();
|
|
1064
1160
|
await startSession(visitor);
|
|
@@ -1069,7 +1165,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1069
1165
|
startSession({
|
|
1070
1166
|
email: defaultVisitor.email.trim(),
|
|
1071
1167
|
name: defaultVisitor.name.trim()
|
|
1072
|
-
}).catch(() => {});
|
|
1168
|
+
}, "host").catch(() => {});
|
|
1073
1169
|
}, [
|
|
1074
1170
|
defaultVisitor,
|
|
1075
1171
|
error,
|
|
@@ -1298,8 +1394,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1298
1394
|
]
|
|
1299
1395
|
});
|
|
1300
1396
|
}
|
|
1301
|
-
|
|
1302
|
-
//#region src/transport.ts
|
|
1397
|
+
// src/transport.ts
|
|
1303
1398
|
function isServerEvent(value) {
|
|
1304
1399
|
if (typeof value !== "object" || value === null) return false;
|
|
1305
1400
|
return typeof value.type === "string";
|
|
@@ -1344,8 +1439,7 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
|
|
|
1344
1439
|
};
|
|
1345
1440
|
};
|
|
1346
1441
|
}
|
|
1347
|
-
|
|
1348
|
-
//#region src/clivly-transport.ts
|
|
1442
|
+
// src/clivly-transport.ts
|
|
1349
1443
|
/**
|
|
1350
1444
|
* The transport a host app should use against a real Clivly backend.
|
|
1351
1445
|
*
|
|
@@ -1362,8 +1456,7 @@ function createClivlyTransport(options = {}) {
|
|
|
1362
1456
|
}
|
|
1363
1457
|
});
|
|
1364
1458
|
}
|
|
1365
|
-
|
|
1366
|
-
//#region src/layout.ts
|
|
1459
|
+
// src/layout.ts
|
|
1367
1460
|
const PLACEMENTS = [
|
|
1368
1461
|
"bottom-left",
|
|
1369
1462
|
"bottom-right",
|
|
@@ -1382,8 +1475,7 @@ function parsePlacement(value) {
|
|
|
1382
1475
|
function parseLauncher(value) {
|
|
1383
1476
|
return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
|
|
1384
1477
|
}
|
|
1385
|
-
|
|
1386
|
-
//#region src/session.ts
|
|
1478
|
+
// src/session.ts
|
|
1387
1479
|
function isWidgetSessionPayload(value) {
|
|
1388
1480
|
return typeof value === "object" && value !== null;
|
|
1389
1481
|
}
|
|
@@ -1405,8 +1497,7 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
|
|
|
1405
1497
|
return data;
|
|
1406
1498
|
};
|
|
1407
1499
|
}
|
|
1408
|
-
|
|
1409
|
-
//#region src/browser-mount.tsx
|
|
1500
|
+
// src/browser-mount.tsx
|
|
1410
1501
|
const MOUNTED_DATA_ATTR = "clivlyMounted";
|
|
1411
1502
|
/**
|
|
1412
1503
|
* The identity a script-tag host already knows, read off the tag.
|
|
@@ -1498,11 +1589,9 @@ function autoMountChatWidgetScript$1(script = document.currentScript) {
|
|
|
1498
1589
|
if (!script || scriptAlreadyMounted(script)) return null;
|
|
1499
1590
|
return mountChatWidgetScript$1(script);
|
|
1500
1591
|
}
|
|
1501
|
-
|
|
1502
|
-
//#region src/browser.tsx
|
|
1592
|
+
// src/browser.tsx
|
|
1503
1593
|
const mountChatWidget = mountChatWidget$1;
|
|
1504
1594
|
const mountChatWidgetScript = mountChatWidgetScript$1;
|
|
1505
1595
|
const autoMountChatWidgetScript = autoMountChatWidgetScript$1;
|
|
1506
1596
|
if (typeof document !== "undefined") autoMountChatWidgetScript();
|
|
1507
|
-
//#endregion
|
|
1508
1597
|
export { autoMountChatWidgetScript, mountChatWidget, mountChatWidgetScript };
|
package/dist/index.cjs
CHANGED
|
@@ -2,7 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
|
2
2
|
let react_dom_client = require("react-dom/client");
|
|
3
3
|
let react = require("react");
|
|
4
4
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
5
|
-
|
|
5
|
+
// src/theme.ts
|
|
6
6
|
const DARK_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
|
7
7
|
/** The attribute set by next-themes, daisyUI, and most `data-*` theme setups. */
|
|
8
8
|
const THEME_ATTRIBUTE = "data-theme";
|
|
@@ -110,8 +110,51 @@ function parseTheme(value) {
|
|
|
110
110
|
if (value === "auto" || value === "dark" || value === "light") return value;
|
|
111
111
|
return "auto";
|
|
112
112
|
}
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
// src/visitor-token-storage.ts
|
|
114
|
+
/** Normalised the way the server matches contacts: trimmed, lower-case. */
|
|
115
|
+
function normalizeVisitorEmail(email) {
|
|
116
|
+
return email.trim().toLowerCase();
|
|
117
|
+
}
|
|
118
|
+
const HASH_MODULUS = 4294967291;
|
|
119
|
+
/**
|
|
120
|
+
* A polynomial string hash, reduced mod a prime below 2^32: stable,
|
|
121
|
+
* synchronous, and exact in doubles (every intermediate stays under 2^53).
|
|
122
|
+
* Not a security primitive.
|
|
123
|
+
*/
|
|
124
|
+
function shortHash(value) {
|
|
125
|
+
let hash = 5381;
|
|
126
|
+
for (const char of value) hash = (hash * 33 + (char.codePointAt(0) ?? 0)) % HASH_MODULUS;
|
|
127
|
+
return hash.toString(16).padStart(8, "0");
|
|
128
|
+
}
|
|
129
|
+
function visitorTokenKey(widgetId, email) {
|
|
130
|
+
return `clivly-chat-widget:${widgetId}:visitor-token:${shortHash(normalizeVisitorEmail(email))}`;
|
|
131
|
+
}
|
|
132
|
+
/** The single per-widget key every widget before this change wrote. */
|
|
133
|
+
function legacyVisitorTokenKey(widgetId) {
|
|
134
|
+
return `clivly-chat-widget:${widgetId}:visitor-token`;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The token to offer for this person. Falls back to the legacy per-widget
|
|
138
|
+
* token once, so visitors who chatted before this change still resume — the
|
|
139
|
+
* server only honours it if they are the identity that thread belongs to.
|
|
140
|
+
*/
|
|
141
|
+
function readVisitorToken(storage, widgetId, email) {
|
|
142
|
+
if (!storage) return null;
|
|
143
|
+
try {
|
|
144
|
+
return storage.getItem(visitorTokenKey(widgetId, email)) ?? storage.getItem(legacyVisitorTokenKey(widgetId));
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Store this person's token and retire the legacy shared one. */
|
|
150
|
+
function writeVisitorToken(storage, widgetId, email, token) {
|
|
151
|
+
if (!storage) return;
|
|
152
|
+
try {
|
|
153
|
+
storage.setItem(visitorTokenKey(widgetId, email), token);
|
|
154
|
+
storage.removeItem?.(legacyVisitorTokenKey(widgetId));
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
// src/chat-widget.tsx
|
|
115
158
|
const DELIVERY_LABELS = {
|
|
116
159
|
failed: "Failed to send",
|
|
117
160
|
pending: "Sending…",
|
|
@@ -533,9 +576,6 @@ function createLocalMessage(message, delivery = "sent") {
|
|
|
533
576
|
delivery
|
|
534
577
|
};
|
|
535
578
|
}
|
|
536
|
-
function createStorageKey(widgetId) {
|
|
537
|
-
return `clivly-chat-widget:${widgetId}:visitor-token`;
|
|
538
|
-
}
|
|
539
579
|
function formatTimestamp(value) {
|
|
540
580
|
const date = new Date(value);
|
|
541
581
|
if (Number.isNaN(date.getTime())) return "";
|
|
@@ -961,6 +1001,33 @@ function useLiveConnection({ actions, createTransport, refreshSession, session,
|
|
|
961
1001
|
}, [createTransport, session]);
|
|
962
1002
|
return reconnecting;
|
|
963
1003
|
}
|
|
1004
|
+
/** Who the host says is signed in: their normalised email, or null. */
|
|
1005
|
+
function hostIdentityOf(defaultVisitor) {
|
|
1006
|
+
return isCompleteVisitor(defaultVisitor) ? normalizeVisitorEmail(defaultVisitor.email) : null;
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* The host's signed-in visitor changed under an open session — a sign-out, or
|
|
1010
|
+
* a different account signing in without a page reload. The session on
|
|
1011
|
+
* screen is the previous person's: drop it, its transcript and its socket, so
|
|
1012
|
+
* the next open starts as whoever is signed in now (chat widget session
|
|
1013
|
+
* review 2026-10-28, S1). A session the visitor started from the pre-chat
|
|
1014
|
+
* form is theirs and is left alone.
|
|
1015
|
+
*/
|
|
1016
|
+
function useResetOnHostIdentityChange({ generationRef, hostIdentity, identityRef, reset, sessionSourceRef }) {
|
|
1017
|
+
const resetRef = (0, react.useRef)(reset);
|
|
1018
|
+
(0, react.useEffect)(() => {
|
|
1019
|
+
resetRef.current = reset;
|
|
1020
|
+
});
|
|
1021
|
+
(0, react.useEffect)(() => {
|
|
1022
|
+
const current = identityRef.current;
|
|
1023
|
+
if (!(current && sessionSourceRef.current === "host")) return;
|
|
1024
|
+
if (hostIdentity === normalizeVisitorEmail(current.email)) return;
|
|
1025
|
+
generationRef.current += 1;
|
|
1026
|
+
identityRef.current = null;
|
|
1027
|
+
sessionSourceRef.current = null;
|
|
1028
|
+
resetRef.current();
|
|
1029
|
+
}, [hostIdentity]);
|
|
1030
|
+
}
|
|
964
1031
|
function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcher = "auto", launcherLabel = "Chat with us", placement = "bottom-right", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
|
|
965
1032
|
const [isOpen, setIsOpen] = (0, react.useState)(initiallyOpen);
|
|
966
1033
|
const panelId = (0, react.useId)();
|
|
@@ -979,6 +1046,11 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
979
1046
|
const transportRef = (0, react.useRef)(null);
|
|
980
1047
|
const listRef = (0, react.useRef)(null);
|
|
981
1048
|
const identityRef = (0, react.useRef)(null);
|
|
1049
|
+
const sessionSourceRef = (0, react.useRef)(null);
|
|
1050
|
+
const generationRef = (0, react.useRef)(0);
|
|
1051
|
+
const hostIdentity = hostIdentityOf(defaultVisitor);
|
|
1052
|
+
const latestHostIdentityRef = (0, react.useRef)(hostIdentity);
|
|
1053
|
+
latestHostIdentityRef.current = hostIdentity;
|
|
982
1054
|
(0, react.useEffect)(() => {
|
|
983
1055
|
if (createTransport) return;
|
|
984
1056
|
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");
|
|
@@ -1005,12 +1077,14 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1005
1077
|
refreshSession: async (current) => {
|
|
1006
1078
|
const identity = identityRef.current;
|
|
1007
1079
|
if (!identity) throw new Error("No visitor to reconnect as.");
|
|
1080
|
+
const generation = generationRef.current;
|
|
1008
1081
|
const next = await getSession({
|
|
1009
1082
|
visitor: identity,
|
|
1010
1083
|
visitorToken: current.visitorToken,
|
|
1011
1084
|
widgetId
|
|
1012
1085
|
});
|
|
1013
|
-
|
|
1086
|
+
if (generation !== generationRef.current) return;
|
|
1087
|
+
writeVisitorToken(storage, widgetId, identity.email, next.visitorToken);
|
|
1014
1088
|
setSupportState(next.support?.state ?? null);
|
|
1015
1089
|
setSession(next);
|
|
1016
1090
|
},
|
|
@@ -1035,31 +1109,53 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1035
1109
|
isReady,
|
|
1036
1110
|
messages
|
|
1037
1111
|
]);
|
|
1038
|
-
const startSession = (0, react.useCallback)(async (identity) => {
|
|
1112
|
+
const startSession = (0, react.useCallback)(async (identity, source = "form") => {
|
|
1039
1113
|
setError(null);
|
|
1040
1114
|
setIsConnecting(true);
|
|
1115
|
+
const generation = generationRef.current;
|
|
1116
|
+
const isStale = () => generation !== generationRef.current || source === "host" && normalizeVisitorEmail(identity.email) !== latestHostIdentityRef.current;
|
|
1041
1117
|
try {
|
|
1042
1118
|
const nextSession = await getSession({
|
|
1043
1119
|
visitor: identity,
|
|
1044
|
-
visitorToken: storage
|
|
1120
|
+
visitorToken: readVisitorToken(storage, widgetId, identity.email),
|
|
1045
1121
|
widgetId
|
|
1046
1122
|
});
|
|
1047
|
-
|
|
1123
|
+
if (isStale()) return;
|
|
1124
|
+
writeVisitorToken(storage, widgetId, identity.email, nextSession.visitorToken);
|
|
1048
1125
|
identityRef.current = identity;
|
|
1126
|
+
sessionSourceRef.current = source;
|
|
1049
1127
|
setSupportState(nextSession.support?.state ?? null);
|
|
1050
1128
|
setSession(nextSession);
|
|
1051
1129
|
setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
|
|
1052
1130
|
} catch (sessionError) {
|
|
1131
|
+
if (isStale()) return;
|
|
1053
1132
|
const resolved = sessionError instanceof Error ? sessionError : /* @__PURE__ */ new Error("Failed to start the conversation.");
|
|
1054
1133
|
setError(resolved.message);
|
|
1055
1134
|
} finally {
|
|
1056
|
-
setIsConnecting(false);
|
|
1135
|
+
if (generation === generationRef.current) setIsConnecting(false);
|
|
1057
1136
|
}
|
|
1058
1137
|
}, [
|
|
1059
1138
|
getSession,
|
|
1060
1139
|
storage,
|
|
1061
1140
|
widgetId
|
|
1062
1141
|
]);
|
|
1142
|
+
useResetOnHostIdentityChange({
|
|
1143
|
+
generationRef,
|
|
1144
|
+
hostIdentity,
|
|
1145
|
+
identityRef,
|
|
1146
|
+
reset: () => {
|
|
1147
|
+
setSession(null);
|
|
1148
|
+
setMessages([]);
|
|
1149
|
+
setSupportState(null);
|
|
1150
|
+
setError(null);
|
|
1151
|
+
setIsConnecting(false);
|
|
1152
|
+
setVisitor({
|
|
1153
|
+
email: defaultVisitor?.email ?? "",
|
|
1154
|
+
name: defaultVisitor?.name ?? ""
|
|
1155
|
+
});
|
|
1156
|
+
},
|
|
1157
|
+
sessionSourceRef
|
|
1158
|
+
});
|
|
1063
1159
|
const submitVisitor = async (event) => {
|
|
1064
1160
|
event.preventDefault();
|
|
1065
1161
|
await startSession(visitor);
|
|
@@ -1070,7 +1166,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1070
1166
|
startSession({
|
|
1071
1167
|
email: defaultVisitor.email.trim(),
|
|
1072
1168
|
name: defaultVisitor.name.trim()
|
|
1073
|
-
}).catch(() => {});
|
|
1169
|
+
}, "host").catch(() => {});
|
|
1074
1170
|
}, [
|
|
1075
1171
|
defaultVisitor,
|
|
1076
1172
|
error,
|
|
@@ -1314,8 +1410,7 @@ function PreChatForm() {
|
|
|
1314
1410
|
function ConversationView() {
|
|
1315
1411
|
return null;
|
|
1316
1412
|
}
|
|
1317
|
-
|
|
1318
|
-
//#region src/transport.ts
|
|
1413
|
+
// src/transport.ts
|
|
1319
1414
|
function isServerEvent(value) {
|
|
1320
1415
|
if (typeof value !== "object" || value === null) return false;
|
|
1321
1416
|
return typeof value.type === "string";
|
|
@@ -1417,8 +1512,7 @@ function createLoopbackTransport({ operatorName = "Clivly", operatorReply = (bod
|
|
|
1417
1512
|
};
|
|
1418
1513
|
};
|
|
1419
1514
|
}
|
|
1420
|
-
|
|
1421
|
-
//#region src/clivly-transport.ts
|
|
1515
|
+
// src/clivly-transport.ts
|
|
1422
1516
|
/**
|
|
1423
1517
|
* The transport a host app should use against a real Clivly backend.
|
|
1424
1518
|
*
|
|
@@ -1435,8 +1529,7 @@ function createClivlyTransport$1(options = {}) {
|
|
|
1435
1529
|
}
|
|
1436
1530
|
});
|
|
1437
1531
|
}
|
|
1438
|
-
|
|
1439
|
-
//#region src/layout.ts
|
|
1532
|
+
// src/layout.ts
|
|
1440
1533
|
const PLACEMENTS = [
|
|
1441
1534
|
"bottom-left",
|
|
1442
1535
|
"bottom-right",
|
|
@@ -1455,8 +1548,7 @@ function parsePlacement(value) {
|
|
|
1455
1548
|
function parseLauncher(value) {
|
|
1456
1549
|
return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
|
|
1457
1550
|
}
|
|
1458
|
-
|
|
1459
|
-
//#region src/session.ts
|
|
1551
|
+
// src/session.ts
|
|
1460
1552
|
function isWidgetSessionPayload(value) {
|
|
1461
1553
|
return typeof value === "object" && value !== null;
|
|
1462
1554
|
}
|
|
@@ -1478,8 +1570,7 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
|
|
|
1478
1570
|
return data;
|
|
1479
1571
|
};
|
|
1480
1572
|
}
|
|
1481
|
-
|
|
1482
|
-
//#region src/browser-mount.tsx
|
|
1573
|
+
// src/browser-mount.tsx
|
|
1483
1574
|
const MOUNTED_DATA_ATTR = "clivlyMounted";
|
|
1484
1575
|
/**
|
|
1485
1576
|
* The identity a script-tag host already knows, read off the tag.
|
|
@@ -1571,13 +1662,11 @@ function autoMountChatWidgetScript$1(script = document.currentScript) {
|
|
|
1571
1662
|
if (!script || scriptAlreadyMounted(script)) return null;
|
|
1572
1663
|
return mountChatWidgetScript$1(script);
|
|
1573
1664
|
}
|
|
1574
|
-
|
|
1575
|
-
//#region src/index.ts
|
|
1665
|
+
// src/index.ts
|
|
1576
1666
|
const autoMountChatWidgetScript = autoMountChatWidgetScript$1;
|
|
1577
1667
|
const createClivlyTransport = createClivlyTransport$1;
|
|
1578
1668
|
const mountChatWidget = mountChatWidget$1;
|
|
1579
1669
|
const mountChatWidgetScript = mountChatWidgetScript$1;
|
|
1580
|
-
//#endregion
|
|
1581
1670
|
exports.ChatLauncher = ChatLauncher;
|
|
1582
1671
|
exports.ChatPanel = ChatPanel;
|
|
1583
1672
|
exports.ChatWidget = ChatWidget;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Root } from "react-dom/client";
|
|
2
2
|
import { CSSProperties, ReactElement } from "react";
|
|
3
|
-
//#region src/layout.d.ts
|
|
4
3
|
/**
|
|
5
4
|
* Where the widget sits on the host page, and what its launcher looks like.
|
|
6
5
|
*
|
|
@@ -20,8 +19,6 @@ type ChatWidgetPlacement = "bottom-left" | "bottom-right" | "inline";
|
|
|
20
19
|
* The label stays the button's accessible name in every variant.
|
|
21
20
|
*/
|
|
22
21
|
type ChatWidgetLauncher = "auto" | "icon" | "pill";
|
|
23
|
-
//#endregion
|
|
24
|
-
//#region src/theme.d.ts
|
|
25
22
|
/**
|
|
26
23
|
* Theme resolution for the widget.
|
|
27
24
|
*
|
|
@@ -46,8 +43,6 @@ interface ResolveThemeOptions {
|
|
|
46
43
|
* the majority of pages.
|
|
47
44
|
*/
|
|
48
45
|
export declare function resolveTheme(theme: ChatWidgetTheme, { root, view }?: ResolveThemeOptions): ResolvedTheme;
|
|
49
|
-
//#endregion
|
|
50
|
-
//#region src/types.d.ts
|
|
51
46
|
type WidgetSenderType = "visitor" | "operator" | "system";
|
|
52
47
|
interface WidgetConfig {
|
|
53
48
|
brandColor?: string | null;
|
|
@@ -164,8 +159,6 @@ interface WidgetErrorEvent {
|
|
|
164
159
|
type: "error";
|
|
165
160
|
}
|
|
166
161
|
type WidgetServerEvent = WidgetAssistantStatusEvent | WidgetConversationSnapshotEvent | WidgetErrorEvent | WidgetMessageAckEvent | WidgetMessageCreatedEvent | WidgetSessionReadyEvent | WidgetSupportStateEvent;
|
|
167
|
-
//#endregion
|
|
168
|
-
//#region src/transport.d.ts
|
|
169
162
|
interface ChatTransportHandlers {
|
|
170
163
|
onClose: () => void;
|
|
171
164
|
onError: (error: Error) => void;
|
|
@@ -204,8 +197,21 @@ interface CreateLoopbackTransportOptions {
|
|
|
204
197
|
replyDelayMs?: number;
|
|
205
198
|
}
|
|
206
199
|
export declare function createLoopbackTransport({ operatorName, operatorReply, replyDelayMs }?: CreateLoopbackTransportOptions): CreateChatTransport;
|
|
207
|
-
|
|
208
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Where the widget keeps the visitor token that resumes a conversation.
|
|
202
|
+
*
|
|
203
|
+
* Keyed by widget AND by who the token was issued to. One key per widget used
|
|
204
|
+
* to hand the token to whoever opened the widget next on that browser — after
|
|
205
|
+
* a sign-out, or between two people who don't sign in at all — and the server
|
|
206
|
+
* resumed the previous person's thread for them (chat widget session review
|
|
207
|
+
* 2026-10-28, S1). The server now refuses that resume on its own; this keeps
|
|
208
|
+
* each person's token to themselves so a returning visitor still resumes.
|
|
209
|
+
*
|
|
210
|
+
* The identity part is a short hash of the normalised email, so the address
|
|
211
|
+
* itself isn't written into storage keys. It only partitions storage: the
|
|
212
|
+
* server's identity check is the security boundary, not this.
|
|
213
|
+
*/
|
|
214
|
+
type VisitorTokenStorage = Pick<Storage, "getItem" | "setItem"> & Partial<Pick<Storage, "removeItem">>;
|
|
209
215
|
interface ChatWidgetProps {
|
|
210
216
|
className?: string;
|
|
211
217
|
createTransport?: CreateChatTransport;
|
|
@@ -240,7 +246,12 @@ interface ChatWidgetProps {
|
|
|
240
246
|
* `style` to sit beneath the host's own dialogs.
|
|
241
247
|
*/
|
|
242
248
|
placement?: ChatWidgetPlacement;
|
|
243
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Where the visitor token is kept, per widget and per visitor (see
|
|
251
|
+
* visitor-token-storage.ts). `removeItem` is optional; with it, the
|
|
252
|
+
* pre-0.16 shared token is retired after its first use.
|
|
253
|
+
*/
|
|
254
|
+
storage?: VisitorTokenStorage;
|
|
244
255
|
/**
|
|
245
256
|
* Inline styles on the widget root. The intended use is overriding palette
|
|
246
257
|
* tokens — `{ "--clivly-chat-panel": "#0b0f19" }` — which works because
|
|
@@ -261,8 +272,6 @@ export declare function ChatPanel(): null;
|
|
|
261
272
|
export declare function MessageComposer(): null;
|
|
262
273
|
export declare function PreChatForm(): null;
|
|
263
274
|
export declare function ConversationView(): null;
|
|
264
|
-
//#endregion
|
|
265
|
-
//#region src/browser-mount.d.ts
|
|
266
275
|
interface MountChatWidgetOptions$1 extends Omit<ChatWidgetProps, "getSession" | "widgetId"> {
|
|
267
276
|
container: Element | ShadowRoot;
|
|
268
277
|
sessionUrl: string;
|
|
@@ -287,8 +296,6 @@ interface ScriptMountResult$1 extends ChatWidgetMount$1 {
|
|
|
287
296
|
}
|
|
288
297
|
declare function mountChatWidgetScript$1(script?: HTMLScriptElement): ScriptMountResult$1;
|
|
289
298
|
declare function autoMountChatWidgetScript$1(script?: HTMLScriptElement | null): ScriptMountResult$1 | null;
|
|
290
|
-
//#endregion
|
|
291
|
-
//#region src/clivly-transport.d.ts
|
|
292
299
|
interface CreateClivlyTransportOptions$1 {
|
|
293
300
|
createSocket?: (url: string, protocols?: string | string[]) => WebSocket;
|
|
294
301
|
}
|
|
@@ -300,15 +307,11 @@ interface CreateClivlyTransportOptions$1 {
|
|
|
300
307
|
* produce a silently dead socket.
|
|
301
308
|
*/
|
|
302
309
|
declare function createClivlyTransport$1(options?: CreateClivlyTransportOptions$1): CreateChatTransport;
|
|
303
|
-
//#endregion
|
|
304
|
-
//#region src/session.d.ts
|
|
305
310
|
interface CreateSessionFetcherOptions {
|
|
306
311
|
fetchImpl?: typeof fetch;
|
|
307
312
|
sessionUrl: string;
|
|
308
313
|
}
|
|
309
314
|
export declare function createSessionFetcher({ fetchImpl, sessionUrl }: CreateSessionFetcherOptions): GetSession;
|
|
310
|
-
//#endregion
|
|
311
|
-
//#region src/index.d.ts
|
|
312
315
|
export type ChatWidgetMount = ChatWidgetMount$1;
|
|
313
316
|
export type MountChatWidgetOptions = MountChatWidgetOptions$1;
|
|
314
317
|
export type ScriptMountResult = ScriptMountResult$1;
|
|
@@ -317,5 +320,4 @@ export type CreateClivlyTransportOptions = CreateClivlyTransportOptions$1;
|
|
|
317
320
|
export declare const createClivlyTransport: typeof createClivlyTransport$1;
|
|
318
321
|
export declare const mountChatWidget: typeof mountChatWidget$1;
|
|
319
322
|
export declare const mountChatWidgetScript: typeof mountChatWidgetScript$1;
|
|
320
|
-
//#endregion
|
|
321
323
|
export type { ChatTransportConnection, ChatTransportHandlers, ChatWidgetLauncher, ChatWidgetPlacement, ChatWidgetProps, ChatWidgetTheme, ChatWidgetVisitor, CreateChatTransport, CreateChatTransportOptions, CreateLoopbackTransportOptions, CreateWebSocketTransportOptions, GetSession, GetSessionOptions, ResolvedTheme, WidgetAssistantStatusEvent, WidgetClientCreateMessageEvent, WidgetClientEvent, WidgetClientReadEvent, WidgetClientTopicEvent, WidgetConfig, WidgetContact, WidgetConversation, WidgetConversationSnapshotEvent, WidgetErrorEvent, WidgetMessage, WidgetMessageAckEvent, WidgetMessageCreatedEvent, WidgetSenderType, WidgetServerEvent, WidgetSessionPayload, WidgetSessionReadyEvent, WidgetSummary, WidgetSupport, WidgetSupportState, WidgetSupportStateEvent, WidgetSupportTopic };
|