@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/README.md
CHANGED
|
@@ -211,6 +211,45 @@ createChatSessionHandler({
|
|
|
211
211
|
Use both together for a signed-in user: `defaultVisitor` removes the form,
|
|
212
212
|
`resolveUser` is what makes it trustworthy.
|
|
213
213
|
|
|
214
|
+
## Placement and launcher
|
|
215
|
+
|
|
216
|
+
The widget floats in the bottom-right corner of the viewport, clear of the
|
|
217
|
+
device's safe-area insets. Below 48rem the launcher is a 48px icon-only
|
|
218
|
+
button, so it does not cover the host page's pagination or form controls; on
|
|
219
|
+
wider screens it is a pill with an icon and your `launcherLabel`. The label is
|
|
220
|
+
the button's accessible name either way, and `aria-expanded` reports whether
|
|
221
|
+
the panel is open.
|
|
222
|
+
|
|
223
|
+
| React prop | Script attribute | Values | Default |
|
|
224
|
+
| --- | --- | --- | --- |
|
|
225
|
+
| `placement` | `data-placement` | `"bottom-right"`, `"bottom-left"`, `"inline"` | `"bottom-right"` |
|
|
226
|
+
| `launcher` | `data-launcher` | `"auto"`, `"pill"`, `"icon"` | `"auto"` |
|
|
227
|
+
|
|
228
|
+
- `"inline"` renders the widget in normal flow, for a host that positions it
|
|
229
|
+
itself. This was the only behaviour before 0.16.
|
|
230
|
+
- `"auto"` switches between the pill and the icon at 48rem; `"pill"` and
|
|
231
|
+
`"icon"` pin one shape at every width.
|
|
232
|
+
- A floating widget stacks at `--clivly-chat-z-index` (default `2147483000`).
|
|
233
|
+
Lower it to sit beneath your own dialogs:
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
<ChatWidget
|
|
237
|
+
style={{ "--clivly-chat-z-index": 40 } as React.CSSProperties}
|
|
238
|
+
/* … */
|
|
239
|
+
/>
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
```html
|
|
243
|
+
<script
|
|
244
|
+
src="https://api.clivly.com/widget.js"
|
|
245
|
+
data-widget-id="support"
|
|
246
|
+
data-session-url="/api/clivly/widget/session"
|
|
247
|
+
data-placement="bottom-left"
|
|
248
|
+
data-launcher="icon"
|
|
249
|
+
defer
|
|
250
|
+
></script>
|
|
251
|
+
```
|
|
252
|
+
|
|
214
253
|
## Theming
|
|
215
254
|
|
|
216
255
|
The widget ships light and dark palettes and follows the host app by default.
|
package/dist/browser-mount.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…",
|
|
@@ -170,16 +213,95 @@ const WIDGET_STYLES = `
|
|
|
170
213
|
font-size: max(16px, 1em);
|
|
171
214
|
}
|
|
172
215
|
}
|
|
216
|
+
/* Floating placements. The insets keep the launcher off a phone's home
|
|
217
|
+
indicator and rounded corners; --clivly-chat-z-index lets a host slot it
|
|
218
|
+
beneath its own dialogs. */
|
|
219
|
+
.clivly-chat-widget[data-clivly-placement="bottom-right"],
|
|
220
|
+
.clivly-chat-widget[data-clivly-placement="bottom-left"] {
|
|
221
|
+
bottom: max(1.25rem, env(safe-area-inset-bottom));
|
|
222
|
+
position: fixed;
|
|
223
|
+
z-index: var(--clivly-chat-z-index, 2147483000);
|
|
224
|
+
}
|
|
225
|
+
.clivly-chat-widget[data-clivly-placement="bottom-right"] {
|
|
226
|
+
right: max(1.25rem, env(safe-area-inset-right));
|
|
227
|
+
}
|
|
228
|
+
.clivly-chat-widget[data-clivly-placement="bottom-left"] {
|
|
229
|
+
left: max(1.25rem, env(safe-area-inset-left));
|
|
230
|
+
}
|
|
173
231
|
.clivly-chat-widget__launcher {
|
|
174
232
|
align-items: center;
|
|
175
233
|
background: var(--clivly-chat-accent);
|
|
176
234
|
border: 0;
|
|
177
235
|
border-radius: 999px;
|
|
236
|
+
box-shadow:
|
|
237
|
+
0 2px 6px rgba(0, 0, 0, 0.18),
|
|
238
|
+
0 10px 28px -10px var(--clivly-chat-accent);
|
|
178
239
|
color: var(--clivly-chat-accent-contrast);
|
|
179
240
|
cursor: pointer;
|
|
180
241
|
display: inline-flex;
|
|
242
|
+
font-weight: 500;
|
|
181
243
|
gap: 0.5rem;
|
|
182
|
-
|
|
244
|
+
justify-content: center;
|
|
245
|
+
min-height: 3rem;
|
|
246
|
+
padding: 0 1.15rem;
|
|
247
|
+
transition: transform 150ms ease-out, filter 150ms ease-out;
|
|
248
|
+
}
|
|
249
|
+
.clivly-chat-widget__launcher:hover {
|
|
250
|
+
filter: brightness(1.06);
|
|
251
|
+
transform: translateY(-1px);
|
|
252
|
+
}
|
|
253
|
+
.clivly-chat-widget__launcher:active {
|
|
254
|
+
transform: scale(0.96);
|
|
255
|
+
}
|
|
256
|
+
.clivly-chat-widget__launcher:focus-visible {
|
|
257
|
+
outline: 2px solid var(--clivly-chat-accent);
|
|
258
|
+
outline-offset: 3px;
|
|
259
|
+
}
|
|
260
|
+
.clivly-chat-widget__launcher-icon {
|
|
261
|
+
flex-shrink: 0;
|
|
262
|
+
height: 1.25rem;
|
|
263
|
+
width: 1.25rem;
|
|
264
|
+
}
|
|
265
|
+
/* Icon-only: a 48px circle — a comfortable touch target that does not cover
|
|
266
|
+
the host's pagination, toolbars or form controls the way a labelled pill
|
|
267
|
+
does on a phone. The label is clipped, not removed, so it stays the
|
|
268
|
+
button's accessible name. */
|
|
269
|
+
.clivly-chat-widget[data-clivly-launcher="icon"] .clivly-chat-widget__launcher {
|
|
270
|
+
height: 3rem;
|
|
271
|
+
padding: 0;
|
|
272
|
+
width: 3rem;
|
|
273
|
+
}
|
|
274
|
+
.clivly-chat-widget[data-clivly-launcher="icon"] .clivly-chat-widget__launcher-label {
|
|
275
|
+
clip-path: inset(50%);
|
|
276
|
+
height: 1px;
|
|
277
|
+
overflow: hidden;
|
|
278
|
+
position: absolute;
|
|
279
|
+
white-space: nowrap;
|
|
280
|
+
width: 1px;
|
|
281
|
+
}
|
|
282
|
+
@media (width < 48rem) {
|
|
283
|
+
.clivly-chat-widget[data-clivly-launcher="auto"] .clivly-chat-widget__launcher {
|
|
284
|
+
height: 3rem;
|
|
285
|
+
padding: 0;
|
|
286
|
+
width: 3rem;
|
|
287
|
+
}
|
|
288
|
+
.clivly-chat-widget[data-clivly-launcher="auto"] .clivly-chat-widget__launcher-label {
|
|
289
|
+
clip-path: inset(50%);
|
|
290
|
+
height: 1px;
|
|
291
|
+
overflow: hidden;
|
|
292
|
+
position: absolute;
|
|
293
|
+
white-space: nowrap;
|
|
294
|
+
width: 1px;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
@media (prefers-reduced-motion: reduce) {
|
|
298
|
+
.clivly-chat-widget__launcher {
|
|
299
|
+
transition: none;
|
|
300
|
+
}
|
|
301
|
+
.clivly-chat-widget__launcher:hover,
|
|
302
|
+
.clivly-chat-widget__launcher:active {
|
|
303
|
+
transform: none;
|
|
304
|
+
}
|
|
183
305
|
}
|
|
184
306
|
.clivly-chat-widget__panel {
|
|
185
307
|
background: var(--clivly-chat-panel);
|
|
@@ -195,6 +317,19 @@ const WIDGET_STYLES = `
|
|
|
195
317
|
bottom: 4.5rem;
|
|
196
318
|
width: min(24rem, calc(100vw - 2rem));
|
|
197
319
|
}
|
|
320
|
+
/* Opens toward the page from whichever corner the launcher is in. */
|
|
321
|
+
.clivly-chat-widget[data-clivly-placement="bottom-left"] .clivly-chat-widget__panel {
|
|
322
|
+
left: 0;
|
|
323
|
+
right: auto;
|
|
324
|
+
}
|
|
325
|
+
/* dvh: on a phone vh includes the collapsed URL bar, so a 75vh panel's top
|
|
326
|
+
edge could slide under it. The 48px launcher sits lower than the pill. */
|
|
327
|
+
@media (width < 48rem) {
|
|
328
|
+
.clivly-chat-widget__panel {
|
|
329
|
+
bottom: 4rem;
|
|
330
|
+
height: min(44rem, calc(100dvh - 6.5rem));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
198
333
|
.clivly-chat-widget__header {
|
|
199
334
|
border-bottom: 1px solid var(--clivly-chat-border);
|
|
200
335
|
display: flex;
|
|
@@ -441,9 +576,6 @@ function createLocalMessage(message, delivery = "sent") {
|
|
|
441
576
|
delivery
|
|
442
577
|
};
|
|
443
578
|
}
|
|
444
|
-
function createStorageKey(widgetId) {
|
|
445
|
-
return `clivly-chat-widget:${widgetId}:visitor-token`;
|
|
446
|
-
}
|
|
447
579
|
function formatTimestamp(value) {
|
|
448
580
|
const date = new Date(value);
|
|
449
581
|
if (Number.isNaN(date.getTime())) return "";
|
|
@@ -562,6 +694,45 @@ function automationBadge(message) {
|
|
|
562
694
|
if (message.senderType !== "system" || !message.automation) return null;
|
|
563
695
|
return AUTOMATION_BADGES[message.automation] ?? "Automated";
|
|
564
696
|
}
|
|
697
|
+
/**
|
|
698
|
+
* The button that opens and closes the panel. Its size and shape come from the
|
|
699
|
+
* root's `data-clivly-launcher` (see WIDGET_STYLES); the label is always its
|
|
700
|
+
* accessible name, and `aria-controls` names the panel only while it exists.
|
|
701
|
+
*/
|
|
702
|
+
function Launcher({ label, onToggle, open, panelId }) {
|
|
703
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
704
|
+
"aria-controls": open ? panelId : void 0,
|
|
705
|
+
"aria-expanded": open,
|
|
706
|
+
className: "clivly-chat-widget__launcher",
|
|
707
|
+
onClick: onToggle,
|
|
708
|
+
type: "button",
|
|
709
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LauncherIcon, { open }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
710
|
+
className: "clivly-chat-widget__launcher-label",
|
|
711
|
+
children: label
|
|
712
|
+
})]
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* A chat bubble while closed, a cross while open — so the icon-only launcher
|
|
717
|
+
* still tells a sighted visitor what tapping it will do. Decorative: the
|
|
718
|
+
* label and `aria-expanded` carry the meaning for assistive technology.
|
|
719
|
+
* Inline SVG because the script-tag build renders inside a shadow root with no
|
|
720
|
+
* icon font or stylesheet of the host's to borrow.
|
|
721
|
+
*/
|
|
722
|
+
function LauncherIcon({ open }) {
|
|
723
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
724
|
+
"aria-hidden": "true",
|
|
725
|
+
className: "clivly-chat-widget__launcher-icon",
|
|
726
|
+
fill: "none",
|
|
727
|
+
focusable: "false",
|
|
728
|
+
stroke: "currentColor",
|
|
729
|
+
strokeLinecap: "round",
|
|
730
|
+
strokeLinejoin: "round",
|
|
731
|
+
strokeWidth: 2,
|
|
732
|
+
viewBox: "0 0 24 24",
|
|
733
|
+
children: open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M18 6 6 18M6 6l12 12" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" })
|
|
734
|
+
});
|
|
735
|
+
}
|
|
565
736
|
function MessageBubble({ message }) {
|
|
566
737
|
const badge = automationBadge(message);
|
|
567
738
|
const author = badge ? (message.author ?? "").replace(AUTHOR_LABEL_SUFFIX, "") : message.author;
|
|
@@ -672,9 +843,10 @@ function isCompleteVisitor(visitor) {
|
|
|
672
843
|
* the visitor is told to wait for something that will never arrive. The third
|
|
673
844
|
* string keeps the status honest in all three states.
|
|
674
845
|
*/
|
|
675
|
-
function composerStatusText(hasTransport, isReady) {
|
|
846
|
+
function composerStatusText(hasTransport, isReady, reconnecting) {
|
|
676
847
|
if (!hasTransport) return "Chat is unavailable";
|
|
677
|
-
|
|
848
|
+
if (isReady) return "Conversation ready";
|
|
849
|
+
return reconnecting ? "Reconnecting…" : "Connecting…";
|
|
678
850
|
}
|
|
679
851
|
/**
|
|
680
852
|
* Guided options under the greeting. Native buttons in a labelled group, so
|
|
@@ -740,8 +912,125 @@ function StatusNotice({ assistantThinking, email, showWaitingNotice }) {
|
|
|
740
912
|
children: waitingNoticeText(email)
|
|
741
913
|
});
|
|
742
914
|
}
|
|
743
|
-
|
|
915
|
+
const RECONNECT_BASE_MS = 1e3;
|
|
916
|
+
const RECONNECT_MAX_MS = 3e4;
|
|
917
|
+
/**
|
|
918
|
+
* How long to wait before reconnect attempt `attempt` (0-based): doubling from
|
|
919
|
+
* one second to a 30-second ceiling, ±25% jitter so a fleet of widgets dropped
|
|
920
|
+
* by the same server restart doesn't reconnect in lockstep.
|
|
921
|
+
*/
|
|
922
|
+
function reconnectDelayMs(attempt, random = Math.random) {
|
|
923
|
+
const base = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt);
|
|
924
|
+
return Math.round(base * (.75 + random() * .5));
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Keep the socket for `session` open, and reconnect when it drops — a laptop
|
|
928
|
+
* waking, a network change, a server deploy — instead of leaving the visitor at
|
|
929
|
+
* "Connecting…" until they reload. A reconnect refreshes the session (same
|
|
930
|
+
* conversation, fresh token) and the server's snapshot on connect brings back
|
|
931
|
+
* anything that arrived meanwhile. Returns whether a reconnect is pending.
|
|
932
|
+
*/
|
|
933
|
+
function useLiveConnection({ actions, createTransport, refreshSession, session, setIsReady, transportRef }) {
|
|
934
|
+
const [reconnecting, setReconnecting] = (0, react.useState)(false);
|
|
935
|
+
const attemptRef = (0, react.useRef)(0);
|
|
936
|
+
const refreshRef = (0, react.useRef)(refreshSession);
|
|
937
|
+
const actionsRef = (0, react.useRef)(actions);
|
|
938
|
+
(0, react.useEffect)(() => {
|
|
939
|
+
refreshRef.current = refreshSession;
|
|
940
|
+
actionsRef.current = actions;
|
|
941
|
+
});
|
|
942
|
+
(0, react.useEffect)(() => {
|
|
943
|
+
transportRef.current?.close();
|
|
944
|
+
transportRef.current = null;
|
|
945
|
+
setIsReady(false);
|
|
946
|
+
if (!(session && createTransport)) return;
|
|
947
|
+
let disposed = false;
|
|
948
|
+
let timer;
|
|
949
|
+
const retry = () => {
|
|
950
|
+
timer = void 0;
|
|
951
|
+
refreshRef.current(session).catch(() => {
|
|
952
|
+
scheduleReconnect();
|
|
953
|
+
});
|
|
954
|
+
};
|
|
955
|
+
const scheduleReconnect = () => {
|
|
956
|
+
if (disposed || timer !== void 0) return;
|
|
957
|
+
setReconnecting(true);
|
|
958
|
+
timer = setTimeout(retry, reconnectDelayMs(attemptRef.current));
|
|
959
|
+
attemptRef.current += 1;
|
|
960
|
+
};
|
|
961
|
+
const retryNow = () => {
|
|
962
|
+
if (timer !== void 0) {
|
|
963
|
+
clearTimeout(timer);
|
|
964
|
+
retry();
|
|
965
|
+
}
|
|
966
|
+
};
|
|
967
|
+
window.addEventListener("online", retryNow);
|
|
968
|
+
const connection = createTransport({
|
|
969
|
+
handlers: {
|
|
970
|
+
onClose: () => {
|
|
971
|
+
setIsReady(false);
|
|
972
|
+
actionsRef.current.setAssistantThinking(false);
|
|
973
|
+
scheduleReconnect();
|
|
974
|
+
},
|
|
975
|
+
onError: (transportError) => {
|
|
976
|
+
if (attemptRef.current === 0) actionsRef.current.setError(transportError.message);
|
|
977
|
+
},
|
|
978
|
+
onEvent: (event) => {
|
|
979
|
+
applyServerEvent(event, {
|
|
980
|
+
...actionsRef.current,
|
|
981
|
+
setIsReady
|
|
982
|
+
});
|
|
983
|
+
},
|
|
984
|
+
onOpen: () => {
|
|
985
|
+
if (attemptRef.current > 0) actionsRef.current.setError(null);
|
|
986
|
+
attemptRef.current = 0;
|
|
987
|
+
setReconnecting(false);
|
|
988
|
+
setIsReady(true);
|
|
989
|
+
}
|
|
990
|
+
},
|
|
991
|
+
session
|
|
992
|
+
});
|
|
993
|
+
transportRef.current = connection;
|
|
994
|
+
return () => {
|
|
995
|
+
disposed = true;
|
|
996
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
997
|
+
window.removeEventListener("online", retryNow);
|
|
998
|
+
connection.close();
|
|
999
|
+
if (transportRef.current === connection) transportRef.current = null;
|
|
1000
|
+
};
|
|
1001
|
+
}, [createTransport, session]);
|
|
1002
|
+
return reconnecting;
|
|
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
|
+
}
|
|
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 }) {
|
|
744
1032
|
const [isOpen, setIsOpen] = (0, react.useState)(initiallyOpen);
|
|
1033
|
+
const panelId = (0, react.useId)();
|
|
745
1034
|
const [error, setError] = (0, react.useState)(null);
|
|
746
1035
|
const [isConnecting, setIsConnecting] = (0, react.useState)(false);
|
|
747
1036
|
const [isReady, setIsReady] = (0, react.useState)(false);
|
|
@@ -756,6 +1045,12 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
756
1045
|
});
|
|
757
1046
|
const transportRef = (0, react.useRef)(null);
|
|
758
1047
|
const listRef = (0, react.useRef)(null);
|
|
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;
|
|
759
1054
|
(0, react.useEffect)(() => {
|
|
760
1055
|
if (createTransport) return;
|
|
761
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");
|
|
@@ -771,41 +1066,32 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
771
1066
|
if (!listRef.current) return;
|
|
772
1067
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
773
1068
|
}, [messages]);
|
|
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
|
-
},
|
|
801
|
-
session
|
|
802
|
-
});
|
|
803
|
-
transportRef.current = connection;
|
|
804
|
-
return () => {
|
|
805
|
-
connection.close();
|
|
806
|
-
if (transportRef.current === connection) transportRef.current = null;
|
|
807
|
-
};
|
|
808
|
-
}, [createTransport, session]);
|
|
1069
|
+
const reconnecting = useLiveConnection({
|
|
1070
|
+
actions: {
|
|
1071
|
+
setAssistantThinking,
|
|
1072
|
+
setError,
|
|
1073
|
+
setMessages,
|
|
1074
|
+
setSupportState
|
|
1075
|
+
},
|
|
1076
|
+
createTransport,
|
|
1077
|
+
refreshSession: async (current) => {
|
|
1078
|
+
const identity = identityRef.current;
|
|
1079
|
+
if (!identity) throw new Error("No visitor to reconnect as.");
|
|
1080
|
+
const generation = generationRef.current;
|
|
1081
|
+
const next = await getSession({
|
|
1082
|
+
visitor: identity,
|
|
1083
|
+
visitorToken: current.visitorToken,
|
|
1084
|
+
widgetId
|
|
1085
|
+
});
|
|
1086
|
+
if (generation !== generationRef.current) return;
|
|
1087
|
+
writeVisitorToken(storage, widgetId, identity.email, next.visitorToken);
|
|
1088
|
+
setSupportState(next.support?.state ?? null);
|
|
1089
|
+
setSession(next);
|
|
1090
|
+
},
|
|
1091
|
+
session,
|
|
1092
|
+
setIsReady,
|
|
1093
|
+
transportRef
|
|
1094
|
+
});
|
|
809
1095
|
(0, react.useEffect)(() => {
|
|
810
1096
|
if (!(isOpen && isReady)) return;
|
|
811
1097
|
const markReadIfVisible = () => {
|
|
@@ -823,30 +1109,53 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
823
1109
|
isReady,
|
|
824
1110
|
messages
|
|
825
1111
|
]);
|
|
826
|
-
const startSession = (0, react.useCallback)(async (identity) => {
|
|
1112
|
+
const startSession = (0, react.useCallback)(async (identity, source = "form") => {
|
|
827
1113
|
setError(null);
|
|
828
1114
|
setIsConnecting(true);
|
|
1115
|
+
const generation = generationRef.current;
|
|
1116
|
+
const isStale = () => generation !== generationRef.current || source === "host" && normalizeVisitorEmail(identity.email) !== latestHostIdentityRef.current;
|
|
829
1117
|
try {
|
|
830
1118
|
const nextSession = await getSession({
|
|
831
1119
|
visitor: identity,
|
|
832
|
-
visitorToken: storage
|
|
1120
|
+
visitorToken: readVisitorToken(storage, widgetId, identity.email),
|
|
833
1121
|
widgetId
|
|
834
1122
|
});
|
|
835
|
-
|
|
1123
|
+
if (isStale()) return;
|
|
1124
|
+
writeVisitorToken(storage, widgetId, identity.email, nextSession.visitorToken);
|
|
1125
|
+
identityRef.current = identity;
|
|
1126
|
+
sessionSourceRef.current = source;
|
|
836
1127
|
setSupportState(nextSession.support?.state ?? null);
|
|
837
1128
|
setSession(nextSession);
|
|
838
1129
|
setMessages(sortMessages(nextSession.messages.map((message) => createLocalMessage(message))));
|
|
839
1130
|
} catch (sessionError) {
|
|
1131
|
+
if (isStale()) return;
|
|
840
1132
|
const resolved = sessionError instanceof Error ? sessionError : /* @__PURE__ */ new Error("Failed to start the conversation.");
|
|
841
1133
|
setError(resolved.message);
|
|
842
1134
|
} finally {
|
|
843
|
-
setIsConnecting(false);
|
|
1135
|
+
if (generation === generationRef.current) setIsConnecting(false);
|
|
844
1136
|
}
|
|
845
1137
|
}, [
|
|
846
1138
|
getSession,
|
|
847
1139
|
storage,
|
|
848
1140
|
widgetId
|
|
849
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
|
+
});
|
|
850
1159
|
const submitVisitor = async (event) => {
|
|
851
1160
|
event.preventDefault();
|
|
852
1161
|
await startSession(visitor);
|
|
@@ -857,7 +1166,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
857
1166
|
startSession({
|
|
858
1167
|
email: defaultVisitor.email.trim(),
|
|
859
1168
|
name: defaultVisitor.name.trim()
|
|
860
|
-
}).catch(() => {});
|
|
1169
|
+
}, "host").catch(() => {});
|
|
861
1170
|
}, [
|
|
862
1171
|
defaultVisitor,
|
|
863
1172
|
error,
|
|
@@ -910,21 +1219,24 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
910
1219
|
const topics = visibleTopics(session, supportState);
|
|
911
1220
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
912
1221
|
className: `clivly-chat-widget ${className ?? ""}`.trim(),
|
|
1222
|
+
"data-clivly-launcher": launcher,
|
|
1223
|
+
"data-clivly-placement": placement,
|
|
913
1224
|
"data-clivly-theme": resolvedTheme,
|
|
914
1225
|
style: themeStyle,
|
|
915
1226
|
children: [
|
|
916
1227
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: WIDGET_STYLES }),
|
|
917
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
918
|
-
|
|
919
|
-
|
|
1228
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Launcher, {
|
|
1229
|
+
label: launcherLabel,
|
|
1230
|
+
onToggle: () => {
|
|
920
1231
|
setIsOpen((open) => !open);
|
|
921
1232
|
},
|
|
922
|
-
|
|
923
|
-
|
|
1233
|
+
open: isOpen,
|
|
1234
|
+
panelId
|
|
924
1235
|
}),
|
|
925
1236
|
isOpen ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
926
1237
|
"aria-label": panelTitle,
|
|
927
1238
|
className: "clivly-chat-widget__panel",
|
|
1239
|
+
id: panelId,
|
|
928
1240
|
children: [
|
|
929
1241
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
|
|
930
1242
|
className: "clivly-chat-widget__header",
|
|
@@ -997,8 +1309,9 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
997
1309
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
998
1310
|
className: "clivly-chat-widget__actions",
|
|
999
1311
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1312
|
+
"aria-live": "polite",
|
|
1000
1313
|
className: "clivly-chat-widget__subtitle",
|
|
1001
|
-
children: composerStatusText(Boolean(createTransport), isReady)
|
|
1314
|
+
children: composerStatusText(Boolean(createTransport), isReady, reconnecting)
|
|
1002
1315
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1003
1316
|
className: "clivly-chat-widget__button",
|
|
1004
1317
|
disabled: !(messageDraft.trim() && canSend),
|
|
@@ -1082,8 +1395,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1082
1395
|
]
|
|
1083
1396
|
});
|
|
1084
1397
|
}
|
|
1085
|
-
|
|
1086
|
-
//#region src/transport.ts
|
|
1398
|
+
// src/transport.ts
|
|
1087
1399
|
function isServerEvent(value) {
|
|
1088
1400
|
if (typeof value !== "object" || value === null) return false;
|
|
1089
1401
|
return typeof value.type === "string";
|
|
@@ -1128,8 +1440,7 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
|
|
|
1128
1440
|
};
|
|
1129
1441
|
};
|
|
1130
1442
|
}
|
|
1131
|
-
|
|
1132
|
-
//#region src/clivly-transport.ts
|
|
1443
|
+
// src/clivly-transport.ts
|
|
1133
1444
|
/**
|
|
1134
1445
|
* The transport a host app should use against a real Clivly backend.
|
|
1135
1446
|
*
|
|
@@ -1146,8 +1457,26 @@ function createClivlyTransport(options = {}) {
|
|
|
1146
1457
|
}
|
|
1147
1458
|
});
|
|
1148
1459
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1460
|
+
// src/layout.ts
|
|
1461
|
+
const PLACEMENTS = [
|
|
1462
|
+
"bottom-left",
|
|
1463
|
+
"bottom-right",
|
|
1464
|
+
"inline"
|
|
1465
|
+
];
|
|
1466
|
+
const LAUNCHERS = [
|
|
1467
|
+
"auto",
|
|
1468
|
+
"icon",
|
|
1469
|
+
"pill"
|
|
1470
|
+
];
|
|
1471
|
+
/** A script tag's `data-placement`; anything unrecognised floats bottom-right. */
|
|
1472
|
+
function parsePlacement(value) {
|
|
1473
|
+
return PLACEMENTS.find((placement) => placement === value) ?? "bottom-right";
|
|
1474
|
+
}
|
|
1475
|
+
/** A script tag's `data-launcher`; anything unrecognised is `"auto"`. */
|
|
1476
|
+
function parseLauncher(value) {
|
|
1477
|
+
return LAUNCHERS.find((launcher) => launcher === value) ?? "auto";
|
|
1478
|
+
}
|
|
1479
|
+
// src/session.ts
|
|
1151
1480
|
function isWidgetSessionPayload(value) {
|
|
1152
1481
|
return typeof value === "object" && value !== null;
|
|
1153
1482
|
}
|
|
@@ -1169,8 +1498,7 @@ function createSessionFetcher({ fetchImpl = fetch, sessionUrl }) {
|
|
|
1169
1498
|
return data;
|
|
1170
1499
|
};
|
|
1171
1500
|
}
|
|
1172
|
-
|
|
1173
|
-
//#region src/browser-mount.tsx
|
|
1501
|
+
// src/browser-mount.tsx
|
|
1174
1502
|
const MOUNTED_DATA_ATTR = "clivlyMounted";
|
|
1175
1503
|
/**
|
|
1176
1504
|
* The identity a script-tag host already knows, read off the tag.
|
|
@@ -1244,6 +1572,8 @@ function mountChatWidgetScript(script = document.currentScript) {
|
|
|
1244
1572
|
const { root, setTheme } = createWidgetMount({
|
|
1245
1573
|
container,
|
|
1246
1574
|
defaultVisitor: readScriptVisitor(script.dataset),
|
|
1575
|
+
launcher: parseLauncher(script.dataset.launcher),
|
|
1576
|
+
placement: parsePlacement(script.dataset.placement),
|
|
1247
1577
|
sessionUrl,
|
|
1248
1578
|
socketUrlTemplate: script.dataset.socketUrlTemplate,
|
|
1249
1579
|
theme: parseTheme(script.dataset.theme),
|
|
@@ -1260,7 +1590,6 @@ function autoMountChatWidgetScript(script = document.currentScript) {
|
|
|
1260
1590
|
if (!script || scriptAlreadyMounted(script)) return null;
|
|
1261
1591
|
return mountChatWidgetScript(script);
|
|
1262
1592
|
}
|
|
1263
|
-
//#endregion
|
|
1264
1593
|
exports.autoMountChatWidgetScript = autoMountChatWidgetScript;
|
|
1265
1594
|
exports.mountChatWidget = mountChatWidget;
|
|
1266
1595
|
exports.mountChatWidgetScript = mountChatWidgetScript;
|