@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.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
- //#region src/theme.ts
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
- //#endregion
114
- //#region src/chat-widget.tsx
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
- storage?.setItem(createStorageKey(widgetId), next.visitorToken);
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?.getItem(createStorageKey(widgetId)) ?? null,
1120
+ visitorToken: readVisitorToken(storage, widgetId, identity.email),
1045
1121
  widgetId
1046
1122
  });
1047
- storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
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,
@@ -1299,8 +1395,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
1299
1395
  ]
1300
1396
  });
1301
1397
  }
1302
- //#endregion
1303
- //#region src/transport.ts
1398
+ // src/transport.ts
1304
1399
  function isServerEvent(value) {
1305
1400
  if (typeof value !== "object" || value === null) return false;
1306
1401
  return typeof value.type === "string";
@@ -1345,8 +1440,7 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
1345
1440
  };
1346
1441
  };
1347
1442
  }
1348
- //#endregion
1349
- //#region src/clivly-transport.ts
1443
+ // src/clivly-transport.ts
1350
1444
  /**
1351
1445
  * The transport a host app should use against a real Clivly backend.
1352
1446
  *
@@ -1363,8 +1457,7 @@ function createClivlyTransport(options = {}) {
1363
1457
  }
1364
1458
  });
1365
1459
  }
1366
- //#endregion
1367
- //#region src/layout.ts
1460
+ // src/layout.ts
1368
1461
  const PLACEMENTS = [
1369
1462
  "bottom-left",
1370
1463
  "bottom-right",
@@ -1383,8 +1476,7 @@ function parsePlacement(value) {
1383
1476
  function parseLauncher(value) {
1384
1477
  return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
1385
1478
  }
1386
- //#endregion
1387
- //#region src/session.ts
1479
+ // src/session.ts
1388
1480
  function isWidgetSessionPayload(value) {
1389
1481
  return typeof value === "object" && value !== null;
1390
1482
  }
@@ -1406,8 +1498,7 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
1406
1498
  return data;
1407
1499
  };
1408
1500
  }
1409
- //#endregion
1410
- //#region src/browser-mount.tsx
1501
+ // src/browser-mount.tsx
1411
1502
  const MOUNTED_DATA_ATTR = "clivlyMounted";
1412
1503
  /**
1413
1504
  * The identity a script-tag host already knows, read off the tag.
@@ -1499,13 +1590,11 @@ function autoMountChatWidgetScript$1(script = document.currentScript) {
1499
1590
  if (!script || scriptAlreadyMounted(script)) return null;
1500
1591
  return mountChatWidgetScript$1(script);
1501
1592
  }
1502
- //#endregion
1503
- //#region src/browser.tsx
1593
+ // src/browser.tsx
1504
1594
  const mountChatWidget = mountChatWidget$1;
1505
1595
  const mountChatWidgetScript = mountChatWidgetScript$1;
1506
1596
  const autoMountChatWidgetScript = autoMountChatWidgetScript$1;
1507
1597
  if (typeof document !== "undefined") autoMountChatWidgetScript();
1508
- //#endregion
1509
1598
  exports.autoMountChatWidgetScript = autoMountChatWidgetScript;
1510
1599
  exports.mountChatWidget = mountChatWidget;
1511
1600
  exports.mountChatWidgetScript = mountChatWidgetScript;
@@ -1,6 +1,5 @@
1
1
  import { Root } from "react-dom/client";
2
2
  import { CSSProperties } 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
  *
@@ -32,8 +29,6 @@ type ChatWidgetLauncher = "auto" | "icon" | "pill";
32
29
  * does, and why it is a plain DOM module rather than a stylesheet.
33
30
  */
34
31
  type ChatWidgetTheme = "auto" | "dark" | "light";
35
- //#endregion
36
- //#region src/types.d.ts
37
32
  type WidgetSenderType = "visitor" | "operator" | "system";
38
33
  interface WidgetConfig {
39
34
  brandColor?: string | null;
@@ -145,8 +140,6 @@ interface WidgetErrorEvent {
145
140
  type: "error";
146
141
  }
147
142
  type WidgetServerEvent = WidgetAssistantStatusEvent | WidgetConversationSnapshotEvent | WidgetErrorEvent | WidgetMessageAckEvent | WidgetMessageCreatedEvent | WidgetSessionReadyEvent | WidgetSupportStateEvent;
148
- //#endregion
149
- //#region src/transport.d.ts
150
143
  interface ChatTransportHandlers {
151
144
  onClose: () => void;
152
145
  onError: (error: Error) => void;
@@ -173,8 +166,21 @@ interface CreateChatTransportOptions {
173
166
  session: WidgetSessionPayload;
174
167
  }
175
168
  type CreateChatTransport = (options: CreateChatTransportOptions) => ChatTransportConnection;
176
- //#endregion
177
- //#region src/chat-widget.d.ts
169
+ /**
170
+ * Where the widget keeps the visitor token that resumes a conversation.
171
+ *
172
+ * Keyed by widget AND by who the token was issued to. One key per widget used
173
+ * to hand the token to whoever opened the widget next on that browser — after
174
+ * a sign-out, or between two people who don't sign in at all — and the server
175
+ * resumed the previous person's thread for them (chat widget session review
176
+ * 2026-10-28, S1). The server now refuses that resume on its own; this keeps
177
+ * each person's token to themselves so a returning visitor still resumes.
178
+ *
179
+ * The identity part is a short hash of the normalised email, so the address
180
+ * itself isn't written into storage keys. It only partitions storage: the
181
+ * server's identity check is the security boundary, not this.
182
+ */
183
+ type VisitorTokenStorage = Pick<Storage, "getItem" | "setItem"> & Partial<Pick<Storage, "removeItem">>;
178
184
  interface ChatWidgetProps {
179
185
  className?: string;
180
186
  createTransport?: CreateChatTransport;
@@ -209,7 +215,12 @@ interface ChatWidgetProps {
209
215
  * `style` to sit beneath the host's own dialogs.
210
216
  */
211
217
  placement?: ChatWidgetPlacement;
212
- storage?: Pick<Storage, "getItem" | "setItem">;
218
+ /**
219
+ * Where the visitor token is kept, per widget and per visitor (see
220
+ * visitor-token-storage.ts). `removeItem` is optional; with it, the
221
+ * pre-0.16 shared token is retired after its first use.
222
+ */
223
+ storage?: VisitorTokenStorage;
213
224
  /**
214
225
  * Inline styles on the widget root. The intended use is overriding palette
215
226
  * tokens — `{ "--clivly-chat-panel": "#0b0f19" }` — which works because
@@ -224,8 +235,6 @@ interface ChatWidgetProps {
224
235
  theme?: ChatWidgetTheme;
225
236
  widgetId: string;
226
237
  }
227
- //#endregion
228
- //#region src/browser-mount.d.ts
229
238
  interface MountChatWidgetOptions$1 extends Omit<ChatWidgetProps, "getSession" | "widgetId"> {
230
239
  container: Element | ShadowRoot;
231
240
  sessionUrl: string;
@@ -250,12 +259,10 @@ interface ScriptMountResult$1 extends ChatWidgetMount$1 {
250
259
  }
251
260
  declare function mountChatWidgetScript$1(script?: HTMLScriptElement): ScriptMountResult$1;
252
261
  declare function autoMountChatWidgetScript$1(script?: HTMLScriptElement | null): ScriptMountResult$1 | null;
253
- //#endregion
254
- //#region src/browser.d.ts
255
262
  export type ChatWidgetMount = ChatWidgetMount$1;
256
263
  export type MountChatWidgetOptions = MountChatWidgetOptions$1;
257
264
  export type ScriptMountResult = ScriptMountResult$1;
258
265
  export declare const mountChatWidget: typeof mountChatWidget$1;
259
266
  export declare const mountChatWidgetScript: typeof mountChatWidgetScript$1;
260
267
  export declare const autoMountChatWidgetScript: typeof autoMountChatWidgetScript$1;
261
- //#endregion
268
+ export {};
@@ -1,6 +1,5 @@
1
1
  import { Root } from "react-dom/client";
2
2
  import { CSSProperties } 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
  *
@@ -32,8 +29,6 @@ type ChatWidgetLauncher = "auto" | "icon" | "pill";
32
29
  * does, and why it is a plain DOM module rather than a stylesheet.
33
30
  */
34
31
  type ChatWidgetTheme = "auto" | "dark" | "light";
35
- //#endregion
36
- //#region src/types.d.ts
37
32
  type WidgetSenderType = "visitor" | "operator" | "system";
38
33
  interface WidgetConfig {
39
34
  brandColor?: string | null;
@@ -145,8 +140,6 @@ interface WidgetErrorEvent {
145
140
  type: "error";
146
141
  }
147
142
  type WidgetServerEvent = WidgetAssistantStatusEvent | WidgetConversationSnapshotEvent | WidgetErrorEvent | WidgetMessageAckEvent | WidgetMessageCreatedEvent | WidgetSessionReadyEvent | WidgetSupportStateEvent;
148
- //#endregion
149
- //#region src/transport.d.ts
150
143
  interface ChatTransportHandlers {
151
144
  onClose: () => void;
152
145
  onError: (error: Error) => void;
@@ -173,8 +166,21 @@ interface CreateChatTransportOptions {
173
166
  session: WidgetSessionPayload;
174
167
  }
175
168
  type CreateChatTransport = (options: CreateChatTransportOptions) => ChatTransportConnection;
176
- //#endregion
177
- //#region src/chat-widget.d.ts
169
+ /**
170
+ * Where the widget keeps the visitor token that resumes a conversation.
171
+ *
172
+ * Keyed by widget AND by who the token was issued to. One key per widget used
173
+ * to hand the token to whoever opened the widget next on that browser — after
174
+ * a sign-out, or between two people who don't sign in at all — and the server
175
+ * resumed the previous person's thread for them (chat widget session review
176
+ * 2026-10-28, S1). The server now refuses that resume on its own; this keeps
177
+ * each person's token to themselves so a returning visitor still resumes.
178
+ *
179
+ * The identity part is a short hash of the normalised email, so the address
180
+ * itself isn't written into storage keys. It only partitions storage: the
181
+ * server's identity check is the security boundary, not this.
182
+ */
183
+ type VisitorTokenStorage = Pick<Storage, "getItem" | "setItem"> & Partial<Pick<Storage, "removeItem">>;
178
184
  interface ChatWidgetProps {
179
185
  className?: string;
180
186
  createTransport?: CreateChatTransport;
@@ -209,7 +215,12 @@ interface ChatWidgetProps {
209
215
  * `style` to sit beneath the host's own dialogs.
210
216
  */
211
217
  placement?: ChatWidgetPlacement;
212
- storage?: Pick<Storage, "getItem" | "setItem">;
218
+ /**
219
+ * Where the visitor token is kept, per widget and per visitor (see
220
+ * visitor-token-storage.ts). `removeItem` is optional; with it, the
221
+ * pre-0.16 shared token is retired after its first use.
222
+ */
223
+ storage?: VisitorTokenStorage;
213
224
  /**
214
225
  * Inline styles on the widget root. The intended use is overriding palette
215
226
  * tokens — `{ "--clivly-chat-panel": "#0b0f19" }` — which works because
@@ -224,8 +235,6 @@ interface ChatWidgetProps {
224
235
  theme?: ChatWidgetTheme;
225
236
  widgetId: string;
226
237
  }
227
- //#endregion
228
- //#region src/browser-mount.d.ts
229
238
  interface MountChatWidgetOptions$1 extends Omit<ChatWidgetProps, "getSession" | "widgetId"> {
230
239
  container: Element | ShadowRoot;
231
240
  sessionUrl: string;
@@ -250,12 +259,10 @@ interface ScriptMountResult$1 extends ChatWidgetMount$1 {
250
259
  }
251
260
  declare function mountChatWidgetScript$1(script?: HTMLScriptElement): ScriptMountResult$1;
252
261
  declare function autoMountChatWidgetScript$1(script?: HTMLScriptElement | null): ScriptMountResult$1 | null;
253
- //#endregion
254
- //#region src/browser.d.ts
255
262
  export type ChatWidgetMount = ChatWidgetMount$1;
256
263
  export type MountChatWidgetOptions = MountChatWidgetOptions$1;
257
264
  export type ScriptMountResult = ScriptMountResult$1;
258
265
  export declare const mountChatWidget: typeof mountChatWidget$1;
259
266
  export declare const mountChatWidgetScript: typeof mountChatWidgetScript$1;
260
267
  export declare const autoMountChatWidgetScript: typeof autoMountChatWidgetScript$1;
261
- //#endregion
268
+ export {};