@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/index.d.mts 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
- //#endregion
208
- //#region src/chat-widget.d.ts
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
- storage?: Pick<Storage, "getItem" | "setItem">;
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 };
package/dist/index.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
- //#region src/theme.ts
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
- //#endregion
113
- //#region src/chat-widget.tsx
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
- storage?.setItem(createStorageKey(widgetId), next.visitorToken);
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?.getItem(createStorageKey(widgetId)) ?? null,
1119
+ visitorToken: readVisitorToken(storage, widgetId, identity.email),
1044
1120
  widgetId
1045
1121
  });
1046
- storage?.setItem(createStorageKey(widgetId), nextSession.visitorToken);
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,
@@ -1313,8 +1409,7 @@ function PreChatForm() {
1313
1409
  function ConversationView() {
1314
1410
  return null;
1315
1411
  }
1316
- //#endregion
1317
- //#region src/transport.ts
1412
+ // src/transport.ts
1318
1413
  function isServerEvent(value) {
1319
1414
  if (typeof value !== "object" || value === null) return false;
1320
1415
  return typeof value.type === "string";
@@ -1416,8 +1511,7 @@ function createLoopbackTransport({ operatorName = "Clivly", operatorReply = (bod
1416
1511
  };
1417
1512
  };
1418
1513
  }
1419
- //#endregion
1420
- //#region src/clivly-transport.ts
1514
+ // src/clivly-transport.ts
1421
1515
  /**
1422
1516
  * The transport a host app should use against a real Clivly backend.
1423
1517
  *
@@ -1434,8 +1528,7 @@ function createClivlyTransport$1(options = {}) {
1434
1528
  }
1435
1529
  });
1436
1530
  }
1437
- //#endregion
1438
- //#region src/layout.ts
1531
+ // src/layout.ts
1439
1532
  const PLACEMENTS = [
1440
1533
  "bottom-left",
1441
1534
  "bottom-right",
@@ -1454,8 +1547,7 @@ function parsePlacement(value) {
1454
1547
  function parseLauncher(value) {
1455
1548
  return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
1456
1549
  }
1457
- //#endregion
1458
- //#region src/session.ts
1550
+ // src/session.ts
1459
1551
  function isWidgetSessionPayload(value) {
1460
1552
  return typeof value === "object" && value !== null;
1461
1553
  }
@@ -1477,8 +1569,7 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
1477
1569
  return data;
1478
1570
  };
1479
1571
  }
1480
- //#endregion
1481
- //#region src/browser-mount.tsx
1572
+ // src/browser-mount.tsx
1482
1573
  const MOUNTED_DATA_ATTR = "clivlyMounted";
1483
1574
  /**
1484
1575
  * The identity a script-tag host already knows, read off the tag.
@@ -1570,11 +1661,9 @@ function autoMountChatWidgetScript$1(script = document.currentScript) {
1570
1661
  if (!script || scriptAlreadyMounted(script)) return null;
1571
1662
  return mountChatWidgetScript$1(script);
1572
1663
  }
1573
- //#endregion
1574
- //#region src/index.ts
1664
+ // src/index.ts
1575
1665
  const autoMountChatWidgetScript = autoMountChatWidgetScript$1;
1576
1666
  const createClivlyTransport = createClivlyTransport$1;
1577
1667
  const mountChatWidget = mountChatWidget$1;
1578
1668
  const mountChatWidgetScript = mountChatWidgetScript$1;
1579
- //#endregion
1580
1669
  export { ChatLauncher, ChatPanel, ChatWidget, ConversationView, MessageComposer, PreChatForm, autoMountChatWidgetScript, createClivlyTransport, createLoopbackTransport, createSessionFetcher, createWebSocketTransport, mountChatWidget, mountChatWidgetScript, resolveTheme };