@clivly/chat-widget 0.12.0 → 0.14.1
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 +19 -0
- package/dist/browser-mount.cjs +95 -8
- package/dist/browser-mount.d.cts +6 -0
- package/dist/browser-mount.d.mts +6 -0
- package/dist/browser-mount.mjs +95 -8
- package/dist/browser.cjs +95 -8
- package/dist/browser.d.cts +6 -0
- package/dist/browser.d.mts +6 -0
- package/dist/browser.mjs +95 -8
- package/dist/index.cjs +95 -8
- package/dist/index.d.cts +12 -2
- package/dist/index.d.mts +12 -2
- package/dist/index.mjs +95 -8
- package/dist/widget.global.js +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -307,6 +307,25 @@ The default browser mount behavior is:
|
|
|
307
307
|
2. otherwise prefer `session.socketUrl`
|
|
308
308
|
3. otherwise fall back to `socketUrlTemplate` when provided
|
|
309
309
|
|
|
310
|
+
### Read receipts
|
|
311
|
+
|
|
312
|
+
While the panel is open, connected and on screen, the widget tells the server
|
|
313
|
+
the visitor has seen the thread. It sends again when new messages arrive and when
|
|
314
|
+
the tab becomes visible, so a teammate's reply the visitor already read isn't also
|
|
315
|
+
emailed to them. The built-in transports send this as a
|
|
316
|
+
`{ type: "conversation.read" }` client event (`WidgetClientReadEvent`).
|
|
317
|
+
|
|
318
|
+
A **custom transport** opts in by implementing the optional
|
|
319
|
+
`ChatTransportConnection.markRead()`. Without it the widget still works; its
|
|
320
|
+
visitors just receive email copies of replies they saw in the widget.
|
|
321
|
+
|
|
322
|
+
### Waiting notice
|
|
323
|
+
|
|
324
|
+
If the visitor's latest message has gone unanswered for two minutes, the widget
|
|
325
|
+
shows a short note that the team has been
|
|
326
|
+
notified, and that the reply will also go to the visitor's email when one is
|
|
327
|
+
known. A failed send doesn't start the clock, because nobody was notified about it.
|
|
328
|
+
|
|
310
329
|
## Build outputs
|
|
311
330
|
|
|
312
331
|
The package currently produces:
|
package/dist/browser-mount.cjs
CHANGED
|
@@ -364,6 +364,14 @@ const WIDGET_STYLES = `
|
|
|
364
364
|
font-size: 0.875rem;
|
|
365
365
|
padding: 0.75rem;
|
|
366
366
|
}
|
|
367
|
+
.clivly-chat-widget__notice {
|
|
368
|
+
background: var(--clivly-chat-operator);
|
|
369
|
+
border-radius: 0.8rem;
|
|
370
|
+
color: var(--clivly-chat-muted);
|
|
371
|
+
font-size: 0.8125rem;
|
|
372
|
+
margin: 0;
|
|
373
|
+
padding: 0.625rem 0.75rem;
|
|
374
|
+
}
|
|
367
375
|
.clivly-chat-widget__empty {
|
|
368
376
|
color: var(--clivly-chat-muted);
|
|
369
377
|
font-size: 0.875rem;
|
|
@@ -516,6 +524,56 @@ function MessageList({ greeting, hasSession, messages }) {
|
|
|
516
524
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: messages.map((message) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MessageBubble, { message }, message.id)) });
|
|
517
525
|
}
|
|
518
526
|
/**
|
|
527
|
+
* How often an open, visible widget re-sends its read receipt. The room drops
|
|
528
|
+
* a receipt that arrives within 5 s of the last one, so a reply read in that
|
|
529
|
+
* window would otherwise stay "unread" and be emailed to the visitor.
|
|
530
|
+
*/
|
|
531
|
+
const READ_RECEIPT_REFRESH_MS = 15e3;
|
|
532
|
+
/** How long a visitor waits unanswered before the widget reassures them (D20a). */
|
|
533
|
+
const WAITING_NOTICE_AFTER_MS = 12e4;
|
|
534
|
+
/**
|
|
535
|
+
* When the visitor's latest message was sent, if nobody has answered it yet;
|
|
536
|
+
* null when the latest message is a reply or there is nothing waiting. Failed
|
|
537
|
+
* sends don't count: nobody was notified about them.
|
|
538
|
+
*/
|
|
539
|
+
function awaitingReplySince(messages) {
|
|
540
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
541
|
+
const message = messages[index];
|
|
542
|
+
if (!message || message.delivery === "failed") continue;
|
|
543
|
+
if (message.senderType !== "visitor") return null;
|
|
544
|
+
return new Date(message.createdAt).getTime();
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
/** True once the visitor has waited `WAITING_NOTICE_AFTER_MS` unanswered. */
|
|
549
|
+
function useWaitingNotice(messages) {
|
|
550
|
+
const [due, setDue] = (0, react.useState)(false);
|
|
551
|
+
(0, react.useEffect)(() => {
|
|
552
|
+
const since = awaitingReplySince(messages);
|
|
553
|
+
if (since === null) {
|
|
554
|
+
setDue(false);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const remaining = since + WAITING_NOTICE_AFTER_MS - Date.now();
|
|
558
|
+
if (remaining <= 0) {
|
|
559
|
+
setDue(true);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
setDue(false);
|
|
563
|
+
const timer = setTimeout(() => {
|
|
564
|
+
setDue(true);
|
|
565
|
+
}, remaining);
|
|
566
|
+
return () => {
|
|
567
|
+
clearTimeout(timer);
|
|
568
|
+
};
|
|
569
|
+
}, [messages]);
|
|
570
|
+
return due;
|
|
571
|
+
}
|
|
572
|
+
function waitingNoticeText(email) {
|
|
573
|
+
const address = email?.trim();
|
|
574
|
+
return address ? `We've notified the team — we'll reply here or by email at ${address}.` : "We've notified the team — we'll reply here as soon as we can.";
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
519
577
|
* Whether a seeded visitor is complete enough to skip the form.
|
|
520
578
|
*
|
|
521
579
|
* Both fields have to be present and non-blank: the server requires each, so
|
|
@@ -553,6 +611,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
553
611
|
if (createTransport) return;
|
|
554
612
|
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");
|
|
555
613
|
}, []);
|
|
614
|
+
const showWaitingNotice = useWaitingNotice(messages);
|
|
556
615
|
const themeStyle = useThemeStyle(session, style);
|
|
557
616
|
const resolvedTheme = useResolvedTheme(theme);
|
|
558
617
|
(0, react.useEffect)(() => () => {
|
|
@@ -595,6 +654,23 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
595
654
|
if (transportRef.current === connection) transportRef.current = null;
|
|
596
655
|
};
|
|
597
656
|
}, [createTransport, session]);
|
|
657
|
+
(0, react.useEffect)(() => {
|
|
658
|
+
if (!(isOpen && isReady)) return;
|
|
659
|
+
const markReadIfVisible = () => {
|
|
660
|
+
if (document.visibilityState === "visible") transportRef.current?.markRead?.();
|
|
661
|
+
};
|
|
662
|
+
markReadIfVisible();
|
|
663
|
+
const interval = window.setInterval(markReadIfVisible, READ_RECEIPT_REFRESH_MS);
|
|
664
|
+
document.addEventListener("visibilitychange", markReadIfVisible);
|
|
665
|
+
return () => {
|
|
666
|
+
window.clearInterval(interval);
|
|
667
|
+
document.removeEventListener("visibilitychange", markReadIfVisible);
|
|
668
|
+
};
|
|
669
|
+
}, [
|
|
670
|
+
isOpen,
|
|
671
|
+
isReady,
|
|
672
|
+
messages
|
|
673
|
+
]);
|
|
598
674
|
const startSession = (0, react.useCallback)(async (identity) => {
|
|
599
675
|
setError(null);
|
|
600
676
|
setIsConnecting(true);
|
|
@@ -711,14 +787,22 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
711
787
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
712
788
|
className: "clivly-chat-widget__body",
|
|
713
789
|
ref: listRef,
|
|
714
|
-
children: [
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
790
|
+
children: [
|
|
791
|
+
error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
792
|
+
className: "clivly-chat-widget__error",
|
|
793
|
+
children: error
|
|
794
|
+
}) : null,
|
|
795
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MessageList, {
|
|
796
|
+
greeting,
|
|
797
|
+
hasSession: Boolean(session),
|
|
798
|
+
messages
|
|
799
|
+
}),
|
|
800
|
+
session && showWaitingNotice ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
801
|
+
className: "clivly-chat-widget__notice",
|
|
802
|
+
role: "status",
|
|
803
|
+
children: waitingNoticeText(session.contact?.email)
|
|
804
|
+
}) : null
|
|
805
|
+
]
|
|
722
806
|
}),
|
|
723
807
|
session ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
|
|
724
808
|
className: "clivly-chat-widget__composer",
|
|
@@ -861,6 +945,9 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
|
|
|
861
945
|
close: () => {
|
|
862
946
|
socket.close();
|
|
863
947
|
},
|
|
948
|
+
markRead: () => {
|
|
949
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "conversation.read" }));
|
|
950
|
+
},
|
|
864
951
|
sendMessage: (message) => {
|
|
865
952
|
if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
|
|
866
953
|
socket.send(JSON.stringify(message));
|
package/dist/browser-mount.d.cts
CHANGED
|
@@ -98,6 +98,12 @@ interface ChatTransportHandlers {
|
|
|
98
98
|
}
|
|
99
99
|
interface ChatTransportConnection {
|
|
100
100
|
close: () => void;
|
|
101
|
+
/**
|
|
102
|
+
* Tell the server the visitor has seen the thread, so a teammate's reply
|
|
103
|
+
* they've read isn't also emailed to them. Optional: a custom transport
|
|
104
|
+
* without it still works, its visitors are just emailed replies they saw.
|
|
105
|
+
*/
|
|
106
|
+
markRead?: () => void;
|
|
101
107
|
sendMessage: (event: WidgetClientCreateMessageEvent) => void;
|
|
102
108
|
}
|
|
103
109
|
interface CreateChatTransportOptions {
|
package/dist/browser-mount.d.mts
CHANGED
|
@@ -98,6 +98,12 @@ interface ChatTransportHandlers {
|
|
|
98
98
|
}
|
|
99
99
|
interface ChatTransportConnection {
|
|
100
100
|
close: () => void;
|
|
101
|
+
/**
|
|
102
|
+
* Tell the server the visitor has seen the thread, so a teammate's reply
|
|
103
|
+
* they've read isn't also emailed to them. Optional: a custom transport
|
|
104
|
+
* without it still works, its visitors are just emailed replies they saw.
|
|
105
|
+
*/
|
|
106
|
+
markRead?: () => void;
|
|
101
107
|
sendMessage: (event: WidgetClientCreateMessageEvent) => void;
|
|
102
108
|
}
|
|
103
109
|
interface CreateChatTransportOptions {
|
package/dist/browser-mount.mjs
CHANGED
|
@@ -363,6 +363,14 @@ const WIDGET_STYLES = `
|
|
|
363
363
|
font-size: 0.875rem;
|
|
364
364
|
padding: 0.75rem;
|
|
365
365
|
}
|
|
366
|
+
.clivly-chat-widget__notice {
|
|
367
|
+
background: var(--clivly-chat-operator);
|
|
368
|
+
border-radius: 0.8rem;
|
|
369
|
+
color: var(--clivly-chat-muted);
|
|
370
|
+
font-size: 0.8125rem;
|
|
371
|
+
margin: 0;
|
|
372
|
+
padding: 0.625rem 0.75rem;
|
|
373
|
+
}
|
|
366
374
|
.clivly-chat-widget__empty {
|
|
367
375
|
color: var(--clivly-chat-muted);
|
|
368
376
|
font-size: 0.875rem;
|
|
@@ -515,6 +523,56 @@ function MessageList({ greeting, hasSession, messages }) {
|
|
|
515
523
|
return /* @__PURE__ */ jsx(Fragment, { children: messages.map((message) => /* @__PURE__ */ jsx(MessageBubble, { message }, message.id)) });
|
|
516
524
|
}
|
|
517
525
|
/**
|
|
526
|
+
* How often an open, visible widget re-sends its read receipt. The room drops
|
|
527
|
+
* a receipt that arrives within 5 s of the last one, so a reply read in that
|
|
528
|
+
* window would otherwise stay "unread" and be emailed to the visitor.
|
|
529
|
+
*/
|
|
530
|
+
const READ_RECEIPT_REFRESH_MS = 15e3;
|
|
531
|
+
/** How long a visitor waits unanswered before the widget reassures them (D20a). */
|
|
532
|
+
const WAITING_NOTICE_AFTER_MS = 12e4;
|
|
533
|
+
/**
|
|
534
|
+
* When the visitor's latest message was sent, if nobody has answered it yet;
|
|
535
|
+
* null when the latest message is a reply or there is nothing waiting. Failed
|
|
536
|
+
* sends don't count: nobody was notified about them.
|
|
537
|
+
*/
|
|
538
|
+
function awaitingReplySince(messages) {
|
|
539
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
540
|
+
const message = messages[index];
|
|
541
|
+
if (!message || message.delivery === "failed") continue;
|
|
542
|
+
if (message.senderType !== "visitor") return null;
|
|
543
|
+
return new Date(message.createdAt).getTime();
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
/** True once the visitor has waited `WAITING_NOTICE_AFTER_MS` unanswered. */
|
|
548
|
+
function useWaitingNotice(messages) {
|
|
549
|
+
const [due, setDue] = useState(false);
|
|
550
|
+
useEffect(() => {
|
|
551
|
+
const since = awaitingReplySince(messages);
|
|
552
|
+
if (since === null) {
|
|
553
|
+
setDue(false);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const remaining = since + WAITING_NOTICE_AFTER_MS - Date.now();
|
|
557
|
+
if (remaining <= 0) {
|
|
558
|
+
setDue(true);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
setDue(false);
|
|
562
|
+
const timer = setTimeout(() => {
|
|
563
|
+
setDue(true);
|
|
564
|
+
}, remaining);
|
|
565
|
+
return () => {
|
|
566
|
+
clearTimeout(timer);
|
|
567
|
+
};
|
|
568
|
+
}, [messages]);
|
|
569
|
+
return due;
|
|
570
|
+
}
|
|
571
|
+
function waitingNoticeText(email) {
|
|
572
|
+
const address = email?.trim();
|
|
573
|
+
return address ? `We've notified the team — we'll reply here or by email at ${address}.` : "We've notified the team — we'll reply here as soon as we can.";
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
518
576
|
* Whether a seeded visitor is complete enough to skip the form.
|
|
519
577
|
*
|
|
520
578
|
* Both fields have to be present and non-blank: the server requires each, so
|
|
@@ -552,6 +610,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
552
610
|
if (createTransport) return;
|
|
553
611
|
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");
|
|
554
612
|
}, []);
|
|
613
|
+
const showWaitingNotice = useWaitingNotice(messages);
|
|
555
614
|
const themeStyle = useThemeStyle(session, style);
|
|
556
615
|
const resolvedTheme = useResolvedTheme(theme);
|
|
557
616
|
useEffect(() => () => {
|
|
@@ -594,6 +653,23 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
594
653
|
if (transportRef.current === connection) transportRef.current = null;
|
|
595
654
|
};
|
|
596
655
|
}, [createTransport, session]);
|
|
656
|
+
useEffect(() => {
|
|
657
|
+
if (!(isOpen && isReady)) return;
|
|
658
|
+
const markReadIfVisible = () => {
|
|
659
|
+
if (document.visibilityState === "visible") transportRef.current?.markRead?.();
|
|
660
|
+
};
|
|
661
|
+
markReadIfVisible();
|
|
662
|
+
const interval = window.setInterval(markReadIfVisible, READ_RECEIPT_REFRESH_MS);
|
|
663
|
+
document.addEventListener("visibilitychange", markReadIfVisible);
|
|
664
|
+
return () => {
|
|
665
|
+
window.clearInterval(interval);
|
|
666
|
+
document.removeEventListener("visibilitychange", markReadIfVisible);
|
|
667
|
+
};
|
|
668
|
+
}, [
|
|
669
|
+
isOpen,
|
|
670
|
+
isReady,
|
|
671
|
+
messages
|
|
672
|
+
]);
|
|
597
673
|
const startSession = useCallback(async (identity) => {
|
|
598
674
|
setError(null);
|
|
599
675
|
setIsConnecting(true);
|
|
@@ -710,14 +786,22 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
710
786
|
/* @__PURE__ */ jsxs("div", {
|
|
711
787
|
className: "clivly-chat-widget__body",
|
|
712
788
|
ref: listRef,
|
|
713
|
-
children: [
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
789
|
+
children: [
|
|
790
|
+
error ? /* @__PURE__ */ jsx("div", {
|
|
791
|
+
className: "clivly-chat-widget__error",
|
|
792
|
+
children: error
|
|
793
|
+
}) : null,
|
|
794
|
+
/* @__PURE__ */ jsx(MessageList, {
|
|
795
|
+
greeting,
|
|
796
|
+
hasSession: Boolean(session),
|
|
797
|
+
messages
|
|
798
|
+
}),
|
|
799
|
+
session && showWaitingNotice ? /* @__PURE__ */ jsx("p", {
|
|
800
|
+
className: "clivly-chat-widget__notice",
|
|
801
|
+
role: "status",
|
|
802
|
+
children: waitingNoticeText(session.contact?.email)
|
|
803
|
+
}) : null
|
|
804
|
+
]
|
|
721
805
|
}),
|
|
722
806
|
session ? /* @__PURE__ */ jsxs("form", {
|
|
723
807
|
className: "clivly-chat-widget__composer",
|
|
@@ -860,6 +944,9 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
|
|
|
860
944
|
close: () => {
|
|
861
945
|
socket.close();
|
|
862
946
|
},
|
|
947
|
+
markRead: () => {
|
|
948
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "conversation.read" }));
|
|
949
|
+
},
|
|
863
950
|
sendMessage: (message) => {
|
|
864
951
|
if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
|
|
865
952
|
socket.send(JSON.stringify(message));
|
package/dist/browser.cjs
CHANGED
|
@@ -364,6 +364,14 @@ const WIDGET_STYLES = `
|
|
|
364
364
|
font-size: 0.875rem;
|
|
365
365
|
padding: 0.75rem;
|
|
366
366
|
}
|
|
367
|
+
.clivly-chat-widget__notice {
|
|
368
|
+
background: var(--clivly-chat-operator);
|
|
369
|
+
border-radius: 0.8rem;
|
|
370
|
+
color: var(--clivly-chat-muted);
|
|
371
|
+
font-size: 0.8125rem;
|
|
372
|
+
margin: 0;
|
|
373
|
+
padding: 0.625rem 0.75rem;
|
|
374
|
+
}
|
|
367
375
|
.clivly-chat-widget__empty {
|
|
368
376
|
color: var(--clivly-chat-muted);
|
|
369
377
|
font-size: 0.875rem;
|
|
@@ -516,6 +524,56 @@ function MessageList({ greeting, hasSession, messages }) {
|
|
|
516
524
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: messages.map((message) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MessageBubble, { message }, message.id)) });
|
|
517
525
|
}
|
|
518
526
|
/**
|
|
527
|
+
* How often an open, visible widget re-sends its read receipt. The room drops
|
|
528
|
+
* a receipt that arrives within 5 s of the last one, so a reply read in that
|
|
529
|
+
* window would otherwise stay "unread" and be emailed to the visitor.
|
|
530
|
+
*/
|
|
531
|
+
const READ_RECEIPT_REFRESH_MS = 15e3;
|
|
532
|
+
/** How long a visitor waits unanswered before the widget reassures them (D20a). */
|
|
533
|
+
const WAITING_NOTICE_AFTER_MS = 12e4;
|
|
534
|
+
/**
|
|
535
|
+
* When the visitor's latest message was sent, if nobody has answered it yet;
|
|
536
|
+
* null when the latest message is a reply or there is nothing waiting. Failed
|
|
537
|
+
* sends don't count: nobody was notified about them.
|
|
538
|
+
*/
|
|
539
|
+
function awaitingReplySince(messages) {
|
|
540
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
541
|
+
const message = messages[index];
|
|
542
|
+
if (!message || message.delivery === "failed") continue;
|
|
543
|
+
if (message.senderType !== "visitor") return null;
|
|
544
|
+
return new Date(message.createdAt).getTime();
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
/** True once the visitor has waited `WAITING_NOTICE_AFTER_MS` unanswered. */
|
|
549
|
+
function useWaitingNotice(messages) {
|
|
550
|
+
const [due, setDue] = (0, react.useState)(false);
|
|
551
|
+
(0, react.useEffect)(() => {
|
|
552
|
+
const since = awaitingReplySince(messages);
|
|
553
|
+
if (since === null) {
|
|
554
|
+
setDue(false);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const remaining = since + WAITING_NOTICE_AFTER_MS - Date.now();
|
|
558
|
+
if (remaining <= 0) {
|
|
559
|
+
setDue(true);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
setDue(false);
|
|
563
|
+
const timer = setTimeout(() => {
|
|
564
|
+
setDue(true);
|
|
565
|
+
}, remaining);
|
|
566
|
+
return () => {
|
|
567
|
+
clearTimeout(timer);
|
|
568
|
+
};
|
|
569
|
+
}, [messages]);
|
|
570
|
+
return due;
|
|
571
|
+
}
|
|
572
|
+
function waitingNoticeText(email) {
|
|
573
|
+
const address = email?.trim();
|
|
574
|
+
return address ? `We've notified the team — we'll reply here or by email at ${address}.` : "We've notified the team — we'll reply here as soon as we can.";
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
519
577
|
* Whether a seeded visitor is complete enough to skip the form.
|
|
520
578
|
*
|
|
521
579
|
* Both fields have to be present and non-blank: the server requires each, so
|
|
@@ -553,6 +611,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
553
611
|
if (createTransport) return;
|
|
554
612
|
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");
|
|
555
613
|
}, []);
|
|
614
|
+
const showWaitingNotice = useWaitingNotice(messages);
|
|
556
615
|
const themeStyle = useThemeStyle(session, style);
|
|
557
616
|
const resolvedTheme = useResolvedTheme(theme);
|
|
558
617
|
(0, react.useEffect)(() => () => {
|
|
@@ -595,6 +654,23 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
595
654
|
if (transportRef.current === connection) transportRef.current = null;
|
|
596
655
|
};
|
|
597
656
|
}, [createTransport, session]);
|
|
657
|
+
(0, react.useEffect)(() => {
|
|
658
|
+
if (!(isOpen && isReady)) return;
|
|
659
|
+
const markReadIfVisible = () => {
|
|
660
|
+
if (document.visibilityState === "visible") transportRef.current?.markRead?.();
|
|
661
|
+
};
|
|
662
|
+
markReadIfVisible();
|
|
663
|
+
const interval = window.setInterval(markReadIfVisible, READ_RECEIPT_REFRESH_MS);
|
|
664
|
+
document.addEventListener("visibilitychange", markReadIfVisible);
|
|
665
|
+
return () => {
|
|
666
|
+
window.clearInterval(interval);
|
|
667
|
+
document.removeEventListener("visibilitychange", markReadIfVisible);
|
|
668
|
+
};
|
|
669
|
+
}, [
|
|
670
|
+
isOpen,
|
|
671
|
+
isReady,
|
|
672
|
+
messages
|
|
673
|
+
]);
|
|
598
674
|
const startSession = (0, react.useCallback)(async (identity) => {
|
|
599
675
|
setError(null);
|
|
600
676
|
setIsConnecting(true);
|
|
@@ -711,14 +787,22 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
711
787
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
712
788
|
className: "clivly-chat-widget__body",
|
|
713
789
|
ref: listRef,
|
|
714
|
-
children: [
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
790
|
+
children: [
|
|
791
|
+
error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
792
|
+
className: "clivly-chat-widget__error",
|
|
793
|
+
children: error
|
|
794
|
+
}) : null,
|
|
795
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MessageList, {
|
|
796
|
+
greeting,
|
|
797
|
+
hasSession: Boolean(session),
|
|
798
|
+
messages
|
|
799
|
+
}),
|
|
800
|
+
session && showWaitingNotice ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
801
|
+
className: "clivly-chat-widget__notice",
|
|
802
|
+
role: "status",
|
|
803
|
+
children: waitingNoticeText(session.contact?.email)
|
|
804
|
+
}) : null
|
|
805
|
+
]
|
|
722
806
|
}),
|
|
723
807
|
session ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
|
|
724
808
|
className: "clivly-chat-widget__composer",
|
|
@@ -861,6 +945,9 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
|
|
|
861
945
|
close: () => {
|
|
862
946
|
socket.close();
|
|
863
947
|
},
|
|
948
|
+
markRead: () => {
|
|
949
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "conversation.read" }));
|
|
950
|
+
},
|
|
864
951
|
sendMessage: (message) => {
|
|
865
952
|
if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
|
|
866
953
|
socket.send(JSON.stringify(message));
|
package/dist/browser.d.cts
CHANGED
|
@@ -98,6 +98,12 @@ interface ChatTransportHandlers {
|
|
|
98
98
|
}
|
|
99
99
|
interface ChatTransportConnection {
|
|
100
100
|
close: () => void;
|
|
101
|
+
/**
|
|
102
|
+
* Tell the server the visitor has seen the thread, so a teammate's reply
|
|
103
|
+
* they've read isn't also emailed to them. Optional: a custom transport
|
|
104
|
+
* without it still works, its visitors are just emailed replies they saw.
|
|
105
|
+
*/
|
|
106
|
+
markRead?: () => void;
|
|
101
107
|
sendMessage: (event: WidgetClientCreateMessageEvent) => void;
|
|
102
108
|
}
|
|
103
109
|
interface CreateChatTransportOptions {
|
package/dist/browser.d.mts
CHANGED
|
@@ -98,6 +98,12 @@ interface ChatTransportHandlers {
|
|
|
98
98
|
}
|
|
99
99
|
interface ChatTransportConnection {
|
|
100
100
|
close: () => void;
|
|
101
|
+
/**
|
|
102
|
+
* Tell the server the visitor has seen the thread, so a teammate's reply
|
|
103
|
+
* they've read isn't also emailed to them. Optional: a custom transport
|
|
104
|
+
* without it still works, its visitors are just emailed replies they saw.
|
|
105
|
+
*/
|
|
106
|
+
markRead?: () => void;
|
|
101
107
|
sendMessage: (event: WidgetClientCreateMessageEvent) => void;
|
|
102
108
|
}
|
|
103
109
|
interface CreateChatTransportOptions {
|
package/dist/browser.mjs
CHANGED
|
@@ -363,6 +363,14 @@ const WIDGET_STYLES = `
|
|
|
363
363
|
font-size: 0.875rem;
|
|
364
364
|
padding: 0.75rem;
|
|
365
365
|
}
|
|
366
|
+
.clivly-chat-widget__notice {
|
|
367
|
+
background: var(--clivly-chat-operator);
|
|
368
|
+
border-radius: 0.8rem;
|
|
369
|
+
color: var(--clivly-chat-muted);
|
|
370
|
+
font-size: 0.8125rem;
|
|
371
|
+
margin: 0;
|
|
372
|
+
padding: 0.625rem 0.75rem;
|
|
373
|
+
}
|
|
366
374
|
.clivly-chat-widget__empty {
|
|
367
375
|
color: var(--clivly-chat-muted);
|
|
368
376
|
font-size: 0.875rem;
|
|
@@ -515,6 +523,56 @@ function MessageList({ greeting, hasSession, messages }) {
|
|
|
515
523
|
return /* @__PURE__ */ jsx(Fragment, { children: messages.map((message) => /* @__PURE__ */ jsx(MessageBubble, { message }, message.id)) });
|
|
516
524
|
}
|
|
517
525
|
/**
|
|
526
|
+
* How often an open, visible widget re-sends its read receipt. The room drops
|
|
527
|
+
* a receipt that arrives within 5 s of the last one, so a reply read in that
|
|
528
|
+
* window would otherwise stay "unread" and be emailed to the visitor.
|
|
529
|
+
*/
|
|
530
|
+
const READ_RECEIPT_REFRESH_MS = 15e3;
|
|
531
|
+
/** How long a visitor waits unanswered before the widget reassures them (D20a). */
|
|
532
|
+
const WAITING_NOTICE_AFTER_MS = 12e4;
|
|
533
|
+
/**
|
|
534
|
+
* When the visitor's latest message was sent, if nobody has answered it yet;
|
|
535
|
+
* null when the latest message is a reply or there is nothing waiting. Failed
|
|
536
|
+
* sends don't count: nobody was notified about them.
|
|
537
|
+
*/
|
|
538
|
+
function awaitingReplySince(messages) {
|
|
539
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
540
|
+
const message = messages[index];
|
|
541
|
+
if (!message || message.delivery === "failed") continue;
|
|
542
|
+
if (message.senderType !== "visitor") return null;
|
|
543
|
+
return new Date(message.createdAt).getTime();
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
/** True once the visitor has waited `WAITING_NOTICE_AFTER_MS` unanswered. */
|
|
548
|
+
function useWaitingNotice(messages) {
|
|
549
|
+
const [due, setDue] = useState(false);
|
|
550
|
+
useEffect(() => {
|
|
551
|
+
const since = awaitingReplySince(messages);
|
|
552
|
+
if (since === null) {
|
|
553
|
+
setDue(false);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const remaining = since + WAITING_NOTICE_AFTER_MS - Date.now();
|
|
557
|
+
if (remaining <= 0) {
|
|
558
|
+
setDue(true);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
setDue(false);
|
|
562
|
+
const timer = setTimeout(() => {
|
|
563
|
+
setDue(true);
|
|
564
|
+
}, remaining);
|
|
565
|
+
return () => {
|
|
566
|
+
clearTimeout(timer);
|
|
567
|
+
};
|
|
568
|
+
}, [messages]);
|
|
569
|
+
return due;
|
|
570
|
+
}
|
|
571
|
+
function waitingNoticeText(email) {
|
|
572
|
+
const address = email?.trim();
|
|
573
|
+
return address ? `We've notified the team — we'll reply here or by email at ${address}.` : "We've notified the team — we'll reply here as soon as we can.";
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
518
576
|
* Whether a seeded visitor is complete enough to skip the form.
|
|
519
577
|
*
|
|
520
578
|
* Both fields have to be present and non-blank: the server requires each, so
|
|
@@ -552,6 +610,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
552
610
|
if (createTransport) return;
|
|
553
611
|
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");
|
|
554
612
|
}, []);
|
|
613
|
+
const showWaitingNotice = useWaitingNotice(messages);
|
|
555
614
|
const themeStyle = useThemeStyle(session, style);
|
|
556
615
|
const resolvedTheme = useResolvedTheme(theme);
|
|
557
616
|
useEffect(() => () => {
|
|
@@ -594,6 +653,23 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
594
653
|
if (transportRef.current === connection) transportRef.current = null;
|
|
595
654
|
};
|
|
596
655
|
}, [createTransport, session]);
|
|
656
|
+
useEffect(() => {
|
|
657
|
+
if (!(isOpen && isReady)) return;
|
|
658
|
+
const markReadIfVisible = () => {
|
|
659
|
+
if (document.visibilityState === "visible") transportRef.current?.markRead?.();
|
|
660
|
+
};
|
|
661
|
+
markReadIfVisible();
|
|
662
|
+
const interval = window.setInterval(markReadIfVisible, READ_RECEIPT_REFRESH_MS);
|
|
663
|
+
document.addEventListener("visibilitychange", markReadIfVisible);
|
|
664
|
+
return () => {
|
|
665
|
+
window.clearInterval(interval);
|
|
666
|
+
document.removeEventListener("visibilitychange", markReadIfVisible);
|
|
667
|
+
};
|
|
668
|
+
}, [
|
|
669
|
+
isOpen,
|
|
670
|
+
isReady,
|
|
671
|
+
messages
|
|
672
|
+
]);
|
|
597
673
|
const startSession = useCallback(async (identity) => {
|
|
598
674
|
setError(null);
|
|
599
675
|
setIsConnecting(true);
|
|
@@ -710,14 +786,22 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
710
786
|
/* @__PURE__ */ jsxs("div", {
|
|
711
787
|
className: "clivly-chat-widget__body",
|
|
712
788
|
ref: listRef,
|
|
713
|
-
children: [
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
789
|
+
children: [
|
|
790
|
+
error ? /* @__PURE__ */ jsx("div", {
|
|
791
|
+
className: "clivly-chat-widget__error",
|
|
792
|
+
children: error
|
|
793
|
+
}) : null,
|
|
794
|
+
/* @__PURE__ */ jsx(MessageList, {
|
|
795
|
+
greeting,
|
|
796
|
+
hasSession: Boolean(session),
|
|
797
|
+
messages
|
|
798
|
+
}),
|
|
799
|
+
session && showWaitingNotice ? /* @__PURE__ */ jsx("p", {
|
|
800
|
+
className: "clivly-chat-widget__notice",
|
|
801
|
+
role: "status",
|
|
802
|
+
children: waitingNoticeText(session.contact?.email)
|
|
803
|
+
}) : null
|
|
804
|
+
]
|
|
721
805
|
}),
|
|
722
806
|
session ? /* @__PURE__ */ jsxs("form", {
|
|
723
807
|
className: "clivly-chat-widget__composer",
|
|
@@ -860,6 +944,9 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
|
|
|
860
944
|
close: () => {
|
|
861
945
|
socket.close();
|
|
862
946
|
},
|
|
947
|
+
markRead: () => {
|
|
948
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "conversation.read" }));
|
|
949
|
+
},
|
|
863
950
|
sendMessage: (message) => {
|
|
864
951
|
if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
|
|
865
952
|
socket.send(JSON.stringify(message));
|
package/dist/index.cjs
CHANGED
|
@@ -364,6 +364,14 @@ const WIDGET_STYLES = `
|
|
|
364
364
|
font-size: 0.875rem;
|
|
365
365
|
padding: 0.75rem;
|
|
366
366
|
}
|
|
367
|
+
.clivly-chat-widget__notice {
|
|
368
|
+
background: var(--clivly-chat-operator);
|
|
369
|
+
border-radius: 0.8rem;
|
|
370
|
+
color: var(--clivly-chat-muted);
|
|
371
|
+
font-size: 0.8125rem;
|
|
372
|
+
margin: 0;
|
|
373
|
+
padding: 0.625rem 0.75rem;
|
|
374
|
+
}
|
|
367
375
|
.clivly-chat-widget__empty {
|
|
368
376
|
color: var(--clivly-chat-muted);
|
|
369
377
|
font-size: 0.875rem;
|
|
@@ -516,6 +524,56 @@ function MessageList({ greeting, hasSession, messages }) {
|
|
|
516
524
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: messages.map((message) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MessageBubble, { message }, message.id)) });
|
|
517
525
|
}
|
|
518
526
|
/**
|
|
527
|
+
* How often an open, visible widget re-sends its read receipt. The room drops
|
|
528
|
+
* a receipt that arrives within 5 s of the last one, so a reply read in that
|
|
529
|
+
* window would otherwise stay "unread" and be emailed to the visitor.
|
|
530
|
+
*/
|
|
531
|
+
const READ_RECEIPT_REFRESH_MS = 15e3;
|
|
532
|
+
/** How long a visitor waits unanswered before the widget reassures them (D20a). */
|
|
533
|
+
const WAITING_NOTICE_AFTER_MS = 12e4;
|
|
534
|
+
/**
|
|
535
|
+
* When the visitor's latest message was sent, if nobody has answered it yet;
|
|
536
|
+
* null when the latest message is a reply or there is nothing waiting. Failed
|
|
537
|
+
* sends don't count: nobody was notified about them.
|
|
538
|
+
*/
|
|
539
|
+
function awaitingReplySince(messages) {
|
|
540
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
541
|
+
const message = messages[index];
|
|
542
|
+
if (!message || message.delivery === "failed") continue;
|
|
543
|
+
if (message.senderType !== "visitor") return null;
|
|
544
|
+
return new Date(message.createdAt).getTime();
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
/** True once the visitor has waited `WAITING_NOTICE_AFTER_MS` unanswered. */
|
|
549
|
+
function useWaitingNotice(messages) {
|
|
550
|
+
const [due, setDue] = (0, react.useState)(false);
|
|
551
|
+
(0, react.useEffect)(() => {
|
|
552
|
+
const since = awaitingReplySince(messages);
|
|
553
|
+
if (since === null) {
|
|
554
|
+
setDue(false);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const remaining = since + WAITING_NOTICE_AFTER_MS - Date.now();
|
|
558
|
+
if (remaining <= 0) {
|
|
559
|
+
setDue(true);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
setDue(false);
|
|
563
|
+
const timer = setTimeout(() => {
|
|
564
|
+
setDue(true);
|
|
565
|
+
}, remaining);
|
|
566
|
+
return () => {
|
|
567
|
+
clearTimeout(timer);
|
|
568
|
+
};
|
|
569
|
+
}, [messages]);
|
|
570
|
+
return due;
|
|
571
|
+
}
|
|
572
|
+
function waitingNoticeText(email) {
|
|
573
|
+
const address = email?.trim();
|
|
574
|
+
return address ? `We've notified the team — we'll reply here or by email at ${address}.` : "We've notified the team — we'll reply here as soon as we can.";
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
519
577
|
* Whether a seeded visitor is complete enough to skip the form.
|
|
520
578
|
*
|
|
521
579
|
* Both fields have to be present and non-blank: the server requires each, so
|
|
@@ -553,6 +611,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
553
611
|
if (createTransport) return;
|
|
554
612
|
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");
|
|
555
613
|
}, []);
|
|
614
|
+
const showWaitingNotice = useWaitingNotice(messages);
|
|
556
615
|
const themeStyle = useThemeStyle(session, style);
|
|
557
616
|
const resolvedTheme = useResolvedTheme(theme);
|
|
558
617
|
(0, react.useEffect)(() => () => {
|
|
@@ -595,6 +654,23 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
595
654
|
if (transportRef.current === connection) transportRef.current = null;
|
|
596
655
|
};
|
|
597
656
|
}, [createTransport, session]);
|
|
657
|
+
(0, react.useEffect)(() => {
|
|
658
|
+
if (!(isOpen && isReady)) return;
|
|
659
|
+
const markReadIfVisible = () => {
|
|
660
|
+
if (document.visibilityState === "visible") transportRef.current?.markRead?.();
|
|
661
|
+
};
|
|
662
|
+
markReadIfVisible();
|
|
663
|
+
const interval = window.setInterval(markReadIfVisible, READ_RECEIPT_REFRESH_MS);
|
|
664
|
+
document.addEventListener("visibilitychange", markReadIfVisible);
|
|
665
|
+
return () => {
|
|
666
|
+
window.clearInterval(interval);
|
|
667
|
+
document.removeEventListener("visibilitychange", markReadIfVisible);
|
|
668
|
+
};
|
|
669
|
+
}, [
|
|
670
|
+
isOpen,
|
|
671
|
+
isReady,
|
|
672
|
+
messages
|
|
673
|
+
]);
|
|
598
674
|
const startSession = (0, react.useCallback)(async (identity) => {
|
|
599
675
|
setError(null);
|
|
600
676
|
setIsConnecting(true);
|
|
@@ -711,14 +787,22 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
711
787
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
712
788
|
className: "clivly-chat-widget__body",
|
|
713
789
|
ref: listRef,
|
|
714
|
-
children: [
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
790
|
+
children: [
|
|
791
|
+
error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
792
|
+
className: "clivly-chat-widget__error",
|
|
793
|
+
children: error
|
|
794
|
+
}) : null,
|
|
795
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MessageList, {
|
|
796
|
+
greeting,
|
|
797
|
+
hasSession: Boolean(session),
|
|
798
|
+
messages
|
|
799
|
+
}),
|
|
800
|
+
session && showWaitingNotice ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
801
|
+
className: "clivly-chat-widget__notice",
|
|
802
|
+
role: "status",
|
|
803
|
+
children: waitingNoticeText(session.contact?.email)
|
|
804
|
+
}) : null
|
|
805
|
+
]
|
|
722
806
|
}),
|
|
723
807
|
session ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
|
|
724
808
|
className: "clivly-chat-widget__composer",
|
|
@@ -876,6 +960,9 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
|
|
|
876
960
|
close: () => {
|
|
877
961
|
socket.close();
|
|
878
962
|
},
|
|
963
|
+
markRead: () => {
|
|
964
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "conversation.read" }));
|
|
965
|
+
},
|
|
879
966
|
sendMessage: (message) => {
|
|
880
967
|
if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
|
|
881
968
|
socket.send(JSON.stringify(message));
|
package/dist/index.d.cts
CHANGED
|
@@ -79,7 +79,11 @@ interface WidgetClientCreateMessageEvent {
|
|
|
79
79
|
id: string;
|
|
80
80
|
type: "message.create";
|
|
81
81
|
}
|
|
82
|
-
|
|
82
|
+
/** The visitor has seen the thread (see `ChatTransportConnection.markRead`). */
|
|
83
|
+
interface WidgetClientReadEvent {
|
|
84
|
+
type: "conversation.read";
|
|
85
|
+
}
|
|
86
|
+
type WidgetClientEvent = WidgetClientCreateMessageEvent | WidgetClientReadEvent;
|
|
83
87
|
interface WidgetSessionReadyEvent {
|
|
84
88
|
conversationId: string;
|
|
85
89
|
type: "session.ready";
|
|
@@ -113,6 +117,12 @@ interface ChatTransportHandlers {
|
|
|
113
117
|
}
|
|
114
118
|
interface ChatTransportConnection {
|
|
115
119
|
close: () => void;
|
|
120
|
+
/**
|
|
121
|
+
* Tell the server the visitor has seen the thread, so a teammate's reply
|
|
122
|
+
* they've read isn't also emailed to them. Optional: a custom transport
|
|
123
|
+
* without it still works, its visitors are just emailed replies they saw.
|
|
124
|
+
*/
|
|
125
|
+
markRead?: () => void;
|
|
116
126
|
sendMessage: (event: WidgetClientCreateMessageEvent) => void;
|
|
117
127
|
}
|
|
118
128
|
interface CreateChatTransportOptions {
|
|
@@ -236,4 +246,4 @@ export declare const createClivlyTransport: typeof createClivlyTransport$1;
|
|
|
236
246
|
export declare const mountChatWidget: typeof mountChatWidget$1;
|
|
237
247
|
export declare const mountChatWidgetScript: typeof mountChatWidgetScript$1;
|
|
238
248
|
//#endregion
|
|
239
|
-
export type { ChatTransportConnection, ChatTransportHandlers, ChatWidgetProps, ChatWidgetTheme, ChatWidgetVisitor, CreateChatTransport, CreateChatTransportOptions, CreateLoopbackTransportOptions, CreateWebSocketTransportOptions, GetSession, GetSessionOptions, ResolvedTheme, WidgetClientCreateMessageEvent, WidgetClientEvent, WidgetConfig, WidgetContact, WidgetConversation, WidgetConversationSnapshotEvent, WidgetErrorEvent, WidgetMessage, WidgetMessageAckEvent, WidgetMessageCreatedEvent, WidgetSenderType, WidgetServerEvent, WidgetSessionPayload, WidgetSessionReadyEvent, WidgetSummary };
|
|
249
|
+
export type { ChatTransportConnection, ChatTransportHandlers, ChatWidgetProps, ChatWidgetTheme, ChatWidgetVisitor, CreateChatTransport, CreateChatTransportOptions, CreateLoopbackTransportOptions, CreateWebSocketTransportOptions, GetSession, GetSessionOptions, ResolvedTheme, WidgetClientCreateMessageEvent, WidgetClientEvent, WidgetClientReadEvent, WidgetConfig, WidgetContact, WidgetConversation, WidgetConversationSnapshotEvent, WidgetErrorEvent, WidgetMessage, WidgetMessageAckEvent, WidgetMessageCreatedEvent, WidgetSenderType, WidgetServerEvent, WidgetSessionPayload, WidgetSessionReadyEvent, WidgetSummary };
|
package/dist/index.d.mts
CHANGED
|
@@ -79,7 +79,11 @@ interface WidgetClientCreateMessageEvent {
|
|
|
79
79
|
id: string;
|
|
80
80
|
type: "message.create";
|
|
81
81
|
}
|
|
82
|
-
|
|
82
|
+
/** The visitor has seen the thread (see `ChatTransportConnection.markRead`). */
|
|
83
|
+
interface WidgetClientReadEvent {
|
|
84
|
+
type: "conversation.read";
|
|
85
|
+
}
|
|
86
|
+
type WidgetClientEvent = WidgetClientCreateMessageEvent | WidgetClientReadEvent;
|
|
83
87
|
interface WidgetSessionReadyEvent {
|
|
84
88
|
conversationId: string;
|
|
85
89
|
type: "session.ready";
|
|
@@ -113,6 +117,12 @@ interface ChatTransportHandlers {
|
|
|
113
117
|
}
|
|
114
118
|
interface ChatTransportConnection {
|
|
115
119
|
close: () => void;
|
|
120
|
+
/**
|
|
121
|
+
* Tell the server the visitor has seen the thread, so a teammate's reply
|
|
122
|
+
* they've read isn't also emailed to them. Optional: a custom transport
|
|
123
|
+
* without it still works, its visitors are just emailed replies they saw.
|
|
124
|
+
*/
|
|
125
|
+
markRead?: () => void;
|
|
116
126
|
sendMessage: (event: WidgetClientCreateMessageEvent) => void;
|
|
117
127
|
}
|
|
118
128
|
interface CreateChatTransportOptions {
|
|
@@ -236,4 +246,4 @@ export declare const createClivlyTransport: typeof createClivlyTransport$1;
|
|
|
236
246
|
export declare const mountChatWidget: typeof mountChatWidget$1;
|
|
237
247
|
export declare const mountChatWidgetScript: typeof mountChatWidgetScript$1;
|
|
238
248
|
//#endregion
|
|
239
|
-
export type { ChatTransportConnection, ChatTransportHandlers, ChatWidgetProps, ChatWidgetTheme, ChatWidgetVisitor, CreateChatTransport, CreateChatTransportOptions, CreateLoopbackTransportOptions, CreateWebSocketTransportOptions, GetSession, GetSessionOptions, ResolvedTheme, WidgetClientCreateMessageEvent, WidgetClientEvent, WidgetConfig, WidgetContact, WidgetConversation, WidgetConversationSnapshotEvent, WidgetErrorEvent, WidgetMessage, WidgetMessageAckEvent, WidgetMessageCreatedEvent, WidgetSenderType, WidgetServerEvent, WidgetSessionPayload, WidgetSessionReadyEvent, WidgetSummary };
|
|
249
|
+
export type { ChatTransportConnection, ChatTransportHandlers, ChatWidgetProps, ChatWidgetTheme, ChatWidgetVisitor, CreateChatTransport, CreateChatTransportOptions, CreateLoopbackTransportOptions, CreateWebSocketTransportOptions, GetSession, GetSessionOptions, ResolvedTheme, WidgetClientCreateMessageEvent, WidgetClientEvent, WidgetClientReadEvent, WidgetConfig, WidgetContact, WidgetConversation, WidgetConversationSnapshotEvent, WidgetErrorEvent, WidgetMessage, WidgetMessageAckEvent, WidgetMessageCreatedEvent, WidgetSenderType, WidgetServerEvent, WidgetSessionPayload, WidgetSessionReadyEvent, WidgetSummary };
|
package/dist/index.mjs
CHANGED
|
@@ -363,6 +363,14 @@ const WIDGET_STYLES = `
|
|
|
363
363
|
font-size: 0.875rem;
|
|
364
364
|
padding: 0.75rem;
|
|
365
365
|
}
|
|
366
|
+
.clivly-chat-widget__notice {
|
|
367
|
+
background: var(--clivly-chat-operator);
|
|
368
|
+
border-radius: 0.8rem;
|
|
369
|
+
color: var(--clivly-chat-muted);
|
|
370
|
+
font-size: 0.8125rem;
|
|
371
|
+
margin: 0;
|
|
372
|
+
padding: 0.625rem 0.75rem;
|
|
373
|
+
}
|
|
366
374
|
.clivly-chat-widget__empty {
|
|
367
375
|
color: var(--clivly-chat-muted);
|
|
368
376
|
font-size: 0.875rem;
|
|
@@ -515,6 +523,56 @@ function MessageList({ greeting, hasSession, messages }) {
|
|
|
515
523
|
return /* @__PURE__ */ jsx(Fragment, { children: messages.map((message) => /* @__PURE__ */ jsx(MessageBubble, { message }, message.id)) });
|
|
516
524
|
}
|
|
517
525
|
/**
|
|
526
|
+
* How often an open, visible widget re-sends its read receipt. The room drops
|
|
527
|
+
* a receipt that arrives within 5 s of the last one, so a reply read in that
|
|
528
|
+
* window would otherwise stay "unread" and be emailed to the visitor.
|
|
529
|
+
*/
|
|
530
|
+
const READ_RECEIPT_REFRESH_MS = 15e3;
|
|
531
|
+
/** How long a visitor waits unanswered before the widget reassures them (D20a). */
|
|
532
|
+
const WAITING_NOTICE_AFTER_MS = 12e4;
|
|
533
|
+
/**
|
|
534
|
+
* When the visitor's latest message was sent, if nobody has answered it yet;
|
|
535
|
+
* null when the latest message is a reply or there is nothing waiting. Failed
|
|
536
|
+
* sends don't count: nobody was notified about them.
|
|
537
|
+
*/
|
|
538
|
+
function awaitingReplySince(messages) {
|
|
539
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
540
|
+
const message = messages[index];
|
|
541
|
+
if (!message || message.delivery === "failed") continue;
|
|
542
|
+
if (message.senderType !== "visitor") return null;
|
|
543
|
+
return new Date(message.createdAt).getTime();
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
/** True once the visitor has waited `WAITING_NOTICE_AFTER_MS` unanswered. */
|
|
548
|
+
function useWaitingNotice(messages) {
|
|
549
|
+
const [due, setDue] = useState(false);
|
|
550
|
+
useEffect(() => {
|
|
551
|
+
const since = awaitingReplySince(messages);
|
|
552
|
+
if (since === null) {
|
|
553
|
+
setDue(false);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const remaining = since + WAITING_NOTICE_AFTER_MS - Date.now();
|
|
557
|
+
if (remaining <= 0) {
|
|
558
|
+
setDue(true);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
setDue(false);
|
|
562
|
+
const timer = setTimeout(() => {
|
|
563
|
+
setDue(true);
|
|
564
|
+
}, remaining);
|
|
565
|
+
return () => {
|
|
566
|
+
clearTimeout(timer);
|
|
567
|
+
};
|
|
568
|
+
}, [messages]);
|
|
569
|
+
return due;
|
|
570
|
+
}
|
|
571
|
+
function waitingNoticeText(email) {
|
|
572
|
+
const address = email?.trim();
|
|
573
|
+
return address ? `We've notified the team — we'll reply here or by email at ${address}.` : "We've notified the team — we'll reply here as soon as we can.";
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
518
576
|
* Whether a seeded visitor is complete enough to skip the form.
|
|
519
577
|
*
|
|
520
578
|
* Both fields have to be present and non-blank: the server requires each, so
|
|
@@ -552,6 +610,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
552
610
|
if (createTransport) return;
|
|
553
611
|
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");
|
|
554
612
|
}, []);
|
|
613
|
+
const showWaitingNotice = useWaitingNotice(messages);
|
|
555
614
|
const themeStyle = useThemeStyle(session, style);
|
|
556
615
|
const resolvedTheme = useResolvedTheme(theme);
|
|
557
616
|
useEffect(() => () => {
|
|
@@ -594,6 +653,23 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
594
653
|
if (transportRef.current === connection) transportRef.current = null;
|
|
595
654
|
};
|
|
596
655
|
}, [createTransport, session]);
|
|
656
|
+
useEffect(() => {
|
|
657
|
+
if (!(isOpen && isReady)) return;
|
|
658
|
+
const markReadIfVisible = () => {
|
|
659
|
+
if (document.visibilityState === "visible") transportRef.current?.markRead?.();
|
|
660
|
+
};
|
|
661
|
+
markReadIfVisible();
|
|
662
|
+
const interval = window.setInterval(markReadIfVisible, READ_RECEIPT_REFRESH_MS);
|
|
663
|
+
document.addEventListener("visibilitychange", markReadIfVisible);
|
|
664
|
+
return () => {
|
|
665
|
+
window.clearInterval(interval);
|
|
666
|
+
document.removeEventListener("visibilitychange", markReadIfVisible);
|
|
667
|
+
};
|
|
668
|
+
}, [
|
|
669
|
+
isOpen,
|
|
670
|
+
isReady,
|
|
671
|
+
messages
|
|
672
|
+
]);
|
|
597
673
|
const startSession = useCallback(async (identity) => {
|
|
598
674
|
setError(null);
|
|
599
675
|
setIsConnecting(true);
|
|
@@ -710,14 +786,22 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
710
786
|
/* @__PURE__ */ jsxs("div", {
|
|
711
787
|
className: "clivly-chat-widget__body",
|
|
712
788
|
ref: listRef,
|
|
713
|
-
children: [
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
789
|
+
children: [
|
|
790
|
+
error ? /* @__PURE__ */ jsx("div", {
|
|
791
|
+
className: "clivly-chat-widget__error",
|
|
792
|
+
children: error
|
|
793
|
+
}) : null,
|
|
794
|
+
/* @__PURE__ */ jsx(MessageList, {
|
|
795
|
+
greeting,
|
|
796
|
+
hasSession: Boolean(session),
|
|
797
|
+
messages
|
|
798
|
+
}),
|
|
799
|
+
session && showWaitingNotice ? /* @__PURE__ */ jsx("p", {
|
|
800
|
+
className: "clivly-chat-widget__notice",
|
|
801
|
+
role: "status",
|
|
802
|
+
children: waitingNoticeText(session.contact?.email)
|
|
803
|
+
}) : null
|
|
804
|
+
]
|
|
721
805
|
}),
|
|
722
806
|
session ? /* @__PURE__ */ jsxs("form", {
|
|
723
807
|
className: "clivly-chat-widget__composer",
|
|
@@ -875,6 +959,9 @@ function createWebSocketTransport({ createSocket = (url, protocols) => new WebSo
|
|
|
875
959
|
close: () => {
|
|
876
960
|
socket.close();
|
|
877
961
|
},
|
|
962
|
+
markRead: () => {
|
|
963
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "conversation.read" }));
|
|
964
|
+
},
|
|
878
965
|
sendMessage: (message) => {
|
|
879
966
|
if (socket.readyState !== WebSocket.OPEN) throw new Error("Chat socket is not connected.");
|
|
880
967
|
socket.send(JSON.stringify(message));
|
package/dist/widget.global.js
CHANGED
|
@@ -344,7 +344,7 @@ process.env.NODE_ENV!==`production`&&(function(){function t(e){if(e==null)return
|
|
|
344
344
|
<%s {...props} />
|
|
345
345
|
React keys must be passed directly to JSX without using spread:
|
|
346
346
|
let props = %s;
|
|
347
|
-
<%s key={someKey} {...props} />`,o,f,m,f),le[f+o]=!0)}if(f=null,i!==void 0&&(r(i),f=``+i),c(n)&&(r(n.key),f=``+n.key),`key`in n)for(var h in i={},n)h!==`key`&&(i[h]=n[h]);else i=n;return f&&l(i,typeof e==`function`?e.displayName||e.name||`Unknown`:e),d(e,f,i,a(),s,u)}function p(e){m(e)?e._store&&(e._store.validated=1):typeof e==`object`&&e&&e.$$typeof===re&&(e._payload.status===`fulfilled`?m(e._payload.value)&&e._payload.value._store&&(e._payload.value._store.validated=1):e._store&&(e._store.validated=1))}function m(e){return typeof e==`object`&&!!e&&e.$$typeof===g}var h=o(),g=Symbol.for(`react.transitional.element`),_=Symbol.for(`react.portal`),v=Symbol.for(`react.fragment`),y=Symbol.for(`react.strict_mode`),b=Symbol.for(`react.profiler`),x=Symbol.for(`react.consumer`),ee=Symbol.for(`react.context`),S=Symbol.for(`react.forward_ref`),C=Symbol.for(`react.suspense`),te=Symbol.for(`react.suspense_list`),ne=Symbol.for(`react.memo`),re=Symbol.for(`react.lazy`),w=Symbol.for(`react.activity`),ie=Symbol.for(`react.client.reference`),T=h.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ae=Object.prototype.hasOwnProperty,oe=Array.isArray,E=console.createTask?console.createTask:function(){return null};h={react_stack_bottom_frame:function(e){return e()}};var se,ce={},D=h.react_stack_bottom_frame.bind(h,s)(),O=E(i(s)),le={};e.Fragment=v,e.jsx=function(e,t,n){var r=1e4>T.recentlyCreatedOwnerStacks++;return f(e,t,n,!1,r?Error(`react-stack-top-frame`):D,r?E(i(e)):O)},e.jsxs=function(e,t,n){var r=1e4>T.recentlyCreatedOwnerStacks++;return f(e,t,n,!0,r?Error(`react-stack-top-frame`):D,r?E(i(e)):O)}})()})),w=e(((e,t)=>{t.exports=process.env.NODE_ENV===`production`?ne():re()}))();let ie={failed:`Failed to send`,pending:`Sending…`,sent:null},T=typeof window>`u`?void 0:window.localStorage;function ae(e){return e.contact?.name??`You`}function oe(e,t){return e?e.contact?`${e.contact.name} • ${e.contact.email}`:`Verified visitor`:t}function E(e,t=`sent`){return{...e,delivery:t}}function se(e){return`clivly-chat-widget:${e}:visitor-token`}function ce(e){let t=new Date(e);return Number.isNaN(t.getTime())?``:new Intl.DateTimeFormat(void 0,{hour:`numeric`,minute:`2-digit`,month:`short`,day:`numeric`}).format(t)}function D(e){return e.key===`Enter`&&!(e.shiftKey||e.nativeEvent.isComposing)}function O(){return typeof crypto<`u`&&`randomUUID`in crypto?crypto.randomUUID():`msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,10)}`}function le(e,t){let n=e.findIndex(e=>e.id===t.id);if(n===-1)return[...e,E(t)];let r=[...e];return r[n]=E(t),r}function ue(e,t,n){return e.map(e=>e.id===t?E(n):e)}function de(e,t){return e.map(e=>e.id===t?{...e,delivery:`failed`}:e)}function fe(e){return[...e].sort((e,t)=>e.createdAt.localeCompare(t.createdAt))}function pe(e,t){return(0,p.useMemo)(()=>{let n=e?.widget.config.brandColor??null;return n?{"--clivly-chat-accent":n,...t}:t},[e?.widget.config.brandColor,t])}function me(e){let[t,n]=(0,p.useState)(()=>S(e));return(0,p.useEffect)(()=>(n(S(e)),C(e,n)),[e]),t}function he(e,{setError:t,setIsReady:n,setMessages:r}){switch(e.type){case`conversation.snapshot`:r(fe(e.messages.map(e=>E(e))));return;case`error`:t(e.message);return;case`message.ack`:r(t=>fe(ue(t,e.clientMessageId,e.message)));return;case`message.created`:{let t=e.clientMessageId;r(n=>fe(t?ue(n,t,e.message):le(n,e.message)));return}case`session.ready`:n(!0);return;default:return}}function ge({message:e}){return(0,w.jsxs)(`article`,{className:`clivly-chat-widget__message`,"data-sender":e.senderType,children:[(0,w.jsxs)(`div`,{className:`clivly-chat-widget__message-meta`,children:[(0,w.jsx)(`span`,{children:e.author??`Clivly`}),(0,w.jsx)(`span`,{children:ce(e.createdAt)})]}),(0,w.jsx)(`div`,{children:e.body}),e.delivery===`sent`?null:(0,w.jsx)(`div`,{className:`clivly-chat-widget__status`,children:ie[e.delivery]})]})}function _e({greeting:e,hasSession:t,messages:n}){return t?n.length===0?(0,w.jsx)(`p`,{className:`clivly-chat-widget__empty`,children:`No messages yet. Say hello to get the conversation started.`}):(0,w.jsx)(w.Fragment,{children:n.map(e=>(0,w.jsx)(ge,{message:e},e.id))}):(0,w.jsx)(`p`,{className:`clivly-chat-widget__empty`,children:e})}function ve(e){return!!(e?.name?.trim()&&e?.email?.trim())}function
|
|
347
|
+
<%s key={someKey} {...props} />`,o,f,m,f),le[f+o]=!0)}if(f=null,i!==void 0&&(r(i),f=``+i),c(n)&&(r(n.key),f=``+n.key),`key`in n)for(var h in i={},n)h!==`key`&&(i[h]=n[h]);else i=n;return f&&l(i,typeof e==`function`?e.displayName||e.name||`Unknown`:e),d(e,f,i,a(),s,u)}function p(e){m(e)?e._store&&(e._store.validated=1):typeof e==`object`&&e&&e.$$typeof===re&&(e._payload.status===`fulfilled`?m(e._payload.value)&&e._payload.value._store&&(e._payload.value._store.validated=1):e._store&&(e._store.validated=1))}function m(e){return typeof e==`object`&&!!e&&e.$$typeof===g}var h=o(),g=Symbol.for(`react.transitional.element`),_=Symbol.for(`react.portal`),v=Symbol.for(`react.fragment`),y=Symbol.for(`react.strict_mode`),b=Symbol.for(`react.profiler`),x=Symbol.for(`react.consumer`),ee=Symbol.for(`react.context`),S=Symbol.for(`react.forward_ref`),C=Symbol.for(`react.suspense`),te=Symbol.for(`react.suspense_list`),ne=Symbol.for(`react.memo`),re=Symbol.for(`react.lazy`),w=Symbol.for(`react.activity`),ie=Symbol.for(`react.client.reference`),T=h.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ae=Object.prototype.hasOwnProperty,oe=Array.isArray,E=console.createTask?console.createTask:function(){return null};h={react_stack_bottom_frame:function(e){return e()}};var se,ce={},D=h.react_stack_bottom_frame.bind(h,s)(),O=E(i(s)),le={};e.Fragment=v,e.jsx=function(e,t,n){var r=1e4>T.recentlyCreatedOwnerStacks++;return f(e,t,n,!1,r?Error(`react-stack-top-frame`):D,r?E(i(e)):O)},e.jsxs=function(e,t,n){var r=1e4>T.recentlyCreatedOwnerStacks++;return f(e,t,n,!0,r?Error(`react-stack-top-frame`):D,r?E(i(e)):O)}})()})),w=e(((e,t)=>{t.exports=process.env.NODE_ENV===`production`?ne():re()}))();let ie={failed:`Failed to send`,pending:`Sending…`,sent:null},T=typeof window>`u`?void 0:window.localStorage;function ae(e){return e.contact?.name??`You`}function oe(e,t){return e?e.contact?`${e.contact.name} • ${e.contact.email}`:`Verified visitor`:t}function E(e,t=`sent`){return{...e,delivery:t}}function se(e){return`clivly-chat-widget:${e}:visitor-token`}function ce(e){let t=new Date(e);return Number.isNaN(t.getTime())?``:new Intl.DateTimeFormat(void 0,{hour:`numeric`,minute:`2-digit`,month:`short`,day:`numeric`}).format(t)}function D(e){return e.key===`Enter`&&!(e.shiftKey||e.nativeEvent.isComposing)}function O(){return typeof crypto<`u`&&`randomUUID`in crypto?crypto.randomUUID():`msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,10)}`}function le(e,t){let n=e.findIndex(e=>e.id===t.id);if(n===-1)return[...e,E(t)];let r=[...e];return r[n]=E(t),r}function ue(e,t,n){return e.map(e=>e.id===t?E(n):e)}function de(e,t){return e.map(e=>e.id===t?{...e,delivery:`failed`}:e)}function fe(e){return[...e].sort((e,t)=>e.createdAt.localeCompare(t.createdAt))}function pe(e,t){return(0,p.useMemo)(()=>{let n=e?.widget.config.brandColor??null;return n?{"--clivly-chat-accent":n,...t}:t},[e?.widget.config.brandColor,t])}function me(e){let[t,n]=(0,p.useState)(()=>S(e));return(0,p.useEffect)(()=>(n(S(e)),C(e,n)),[e]),t}function he(e,{setError:t,setIsReady:n,setMessages:r}){switch(e.type){case`conversation.snapshot`:r(fe(e.messages.map(e=>E(e))));return;case`error`:t(e.message);return;case`message.ack`:r(t=>fe(ue(t,e.clientMessageId,e.message)));return;case`message.created`:{let t=e.clientMessageId;r(n=>fe(t?ue(n,t,e.message):le(n,e.message)));return}case`session.ready`:n(!0);return;default:return}}function ge({message:e}){return(0,w.jsxs)(`article`,{className:`clivly-chat-widget__message`,"data-sender":e.senderType,children:[(0,w.jsxs)(`div`,{className:`clivly-chat-widget__message-meta`,children:[(0,w.jsx)(`span`,{children:e.author??`Clivly`}),(0,w.jsx)(`span`,{children:ce(e.createdAt)})]}),(0,w.jsx)(`div`,{children:e.body}),e.delivery===`sent`?null:(0,w.jsx)(`div`,{className:`clivly-chat-widget__status`,children:ie[e.delivery]})]})}function _e({greeting:e,hasSession:t,messages:n}){return t?n.length===0?(0,w.jsx)(`p`,{className:`clivly-chat-widget__empty`,children:`No messages yet. Say hello to get the conversation started.`}):(0,w.jsx)(w.Fragment,{children:n.map(e=>(0,w.jsx)(ge,{message:e},e.id))}):(0,w.jsx)(`p`,{className:`clivly-chat-widget__empty`,children:e})}function ve(e){for(let t=e.length-1;t>=0;t--){let n=e[t];if(n&&n.delivery!==`failed`)return n.senderType===`visitor`?new Date(n.createdAt).getTime():null}return null}function ye(e){let[t,n]=(0,p.useState)(!1);return(0,p.useEffect)(()=>{let t=ve(e);if(t===null){n(!1);return}let r=t+12e4-Date.now();if(r<=0){n(!0);return}n(!1);let i=setTimeout(()=>{n(!0)},r);return()=>{clearTimeout(i)}},[e]),t}function be(e){let t=e?.trim();return t?`We've notified the team — we'll reply here or by email at ${t}.`:`We've notified the team — we'll reply here as soon as we can.`}function xe(e){return!!(e?.name?.trim()&&e?.email?.trim())}function k(e,t){return e?t?`Conversation ready`:`Connecting…`:`Chat is unavailable`}function Se({className:e,createTransport:t,defaultVisitor:n,getSession:r,initiallyOpen:i=!1,launcherLabel:a=`Chat with us`,storage:o=T,style:s,theme:c=`auto`,widgetId:l}){let[u,d]=(0,p.useState)(i),[f,m]=(0,p.useState)(null),[h,g]=(0,p.useState)(!1),[_,v]=(0,p.useState)(!1),[y,b]=(0,p.useState)(``),[x,ee]=(0,p.useState)([]),[S,C]=(0,p.useState)(null),[te,ne]=(0,p.useState)({email:n?.email??``,name:n?.name??``}),re=(0,p.useRef)(null),ie=(0,p.useRef)(null);(0,p.useEffect)(()=>{t||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`)},[]);let ce=ye(x),le=pe(S,s),ue=me(c);(0,p.useEffect)(()=>()=>{re.current?.close(),re.current=null},[]),(0,p.useEffect)(()=>{ie.current&&(ie.current.scrollTop=ie.current.scrollHeight)},[x]),(0,p.useEffect)(()=>{if(re.current?.close(),re.current=null,v(!1),!(S&&t))return;let e=t({handlers:{onClose:()=>{v(!1)},onError:e=>{m(e.message)},onEvent:e=>{he(e,{setError:m,setIsReady:v,setMessages:ee})},onOpen:()=>{v(!0)}},session:S});return re.current=e,()=>{e.close(),re.current===e&&(re.current=null)}},[t,S]),(0,p.useEffect)(()=>{if(!(u&&_))return;let e=()=>{document.visibilityState===`visible`&&re.current?.markRead?.()};e();let t=window.setInterval(e,15e3);return document.addEventListener(`visibilitychange`,e),()=>{window.clearInterval(t),document.removeEventListener(`visibilitychange`,e)}},[u,_,x]);let ge=(0,p.useCallback)(async e=>{m(null),g(!0);try{let t=await r({visitor:e,visitorToken:o?.getItem(se(l))??null,widgetId:l});o?.setItem(se(l),t.visitorToken),C(t),ee(fe(t.messages.map(e=>E(e))))}catch(e){let t=e instanceof Error?e:Error(`Failed to start the conversation.`);m(t.message)}finally{g(!1)}},[r,o,l]),ve=async e=>{e.preventDefault(),await ge(te)};(0,p.useEffect)(()=>{u&&xe(n)&&(S||h||f||ge({email:n.email.trim(),name:n.name.trim()}).catch(()=>{}))},[n,f,h,u,S,ge]);let Se=e=>{if(e?.preventDefault(),!(S&&re.current)){m(`Chat is not connected yet.`);return}let t=y.trim();if(!t)return;let n=O(),r={author:ae(S),body:t,createdAt:new Date().toISOString(),id:n,senderType:`visitor`};ee(e=>fe([...e,E(r,`pending`)])),b(``),m(null);try{re.current.sendMessage({body:t,id:n,type:`message.create`})}catch(e){let t=e instanceof Error?e:Error(`Failed to send your message.`);ee(e=>de(e,n)),m(t.message)}},Ce=!!t&&_,we=S?.widget.config.greeting??`Tell us a little about yourself and we’ll pick up the conversation from there.`,Te=S?.widget.name??`Start a conversation`,A=oe(S,we);return(0,w.jsxs)(`div`,{className:`clivly-chat-widget ${e??``}`.trim(),"data-clivly-theme":ue,style:le,children:[(0,w.jsx)(`style`,{children:`
|
|
348
348
|
.clivly-chat-widget, .clivly-chat-widget * { box-sizing: border-box; }
|
|
349
349
|
.clivly-chat-widget {
|
|
350
350
|
--clivly-chat-accent: #185adb;
|
|
@@ -591,10 +591,18 @@ React keys must be passed directly to JSX without using spread:
|
|
|
591
591
|
font-size: 0.875rem;
|
|
592
592
|
padding: 0.75rem;
|
|
593
593
|
}
|
|
594
|
+
.clivly-chat-widget__notice {
|
|
595
|
+
background: var(--clivly-chat-operator);
|
|
596
|
+
border-radius: 0.8rem;
|
|
597
|
+
color: var(--clivly-chat-muted);
|
|
598
|
+
font-size: 0.8125rem;
|
|
599
|
+
margin: 0;
|
|
600
|
+
padding: 0.625rem 0.75rem;
|
|
601
|
+
}
|
|
594
602
|
.clivly-chat-widget__empty {
|
|
595
603
|
color: var(--clivly-chat-muted);
|
|
596
604
|
font-size: 0.875rem;
|
|
597
605
|
margin: auto 0;
|
|
598
606
|
text-align: center;
|
|
599
607
|
}
|
|
600
|
-
`}),(0,w.jsx)(`button`,{className:`clivly-chat-widget__launcher`,onClick:()=>{d(e=>!e)},type:`button`,children:(0,w.jsx)(`span`,{children:a})}),u?(0,w.jsxs)(`section`,{"aria-label":
|
|
608
|
+
`}),(0,w.jsx)(`button`,{className:`clivly-chat-widget__launcher`,onClick:()=>{d(e=>!e)},type:`button`,children:(0,w.jsx)(`span`,{children:a})}),u?(0,w.jsxs)(`section`,{"aria-label":Te,className:`clivly-chat-widget__panel`,children:[(0,w.jsxs)(`header`,{className:`clivly-chat-widget__header`,children:[(0,w.jsxs)(`div`,{children:[(0,w.jsx)(`h2`,{className:`clivly-chat-widget__title`,children:Te}),(0,w.jsx)(`p`,{className:`clivly-chat-widget__subtitle`,children:A})]}),(0,w.jsx)(`button`,{"aria-label":`Close chat`,className:`clivly-chat-widget__close`,onClick:()=>{d(!1)},type:`button`,children:`Close`})]}),(0,w.jsxs)(`div`,{className:`clivly-chat-widget__body`,ref:ie,children:[f?(0,w.jsx)(`div`,{className:`clivly-chat-widget__error`,children:f}):null,(0,w.jsx)(_e,{greeting:we,hasSession:!!S,messages:x}),S&&ce?(0,w.jsx)(`p`,{className:`clivly-chat-widget__notice`,role:`status`,children:be(S.contact?.email)}):null]}),S?(0,w.jsxs)(`form`,{className:`clivly-chat-widget__composer`,onSubmit:Se,children:[(0,w.jsxs)(`label`,{className:`clivly-chat-widget__field`,children:[(0,w.jsx)(`span`,{className:`clivly-chat-widget__label`,children:`Message`}),(0,w.jsx)(`textarea`,{className:`clivly-chat-widget__textarea`,onChange:e=>{b(e.target.value)},onKeyDown:e=>{D(e)&&(e.preventDefault(),y.trim()&&Ce&&Se())},placeholder:`Write your message`,value:y})]}),(0,w.jsxs)(`div`,{className:`clivly-chat-widget__actions`,children:[(0,w.jsx)(`span`,{className:`clivly-chat-widget__subtitle`,children:k(!!t,_)}),(0,w.jsx)(`button`,{className:`clivly-chat-widget__button`,disabled:!(y.trim()&&Ce),type:`submit`,children:`Send`})]})]}):(0,w.jsxs)(`form`,{className:`clivly-chat-widget__form`,onSubmit:ve,children:[(0,w.jsxs)(`label`,{className:`clivly-chat-widget__field`,children:[(0,w.jsx)(`span`,{className:`clivly-chat-widget__label`,children:`Name`}),(0,w.jsx)(`input`,{autoComplete:`name`,className:`clivly-chat-widget__input`,onChange:e=>{ne(t=>({...t,name:e.target.value}))},required:!0,value:te.name})]}),(0,w.jsxs)(`label`,{className:`clivly-chat-widget__field`,children:[(0,w.jsx)(`span`,{className:`clivly-chat-widget__label`,children:`Email`}),(0,w.jsx)(`input`,{autoComplete:`email`,className:`clivly-chat-widget__input`,onChange:e=>{ne(t=>({...t,email:e.target.value}))},required:!0,type:`email`,value:te.email})]}),(0,w.jsxs)(`div`,{className:`clivly-chat-widget__actions`,children:[(0,w.jsx)(`span`,{className:`clivly-chat-widget__subtitle`,children:`We use this to start the conversation.`}),(0,w.jsx)(`button`,{className:`clivly-chat-widget__button`,disabled:h||!te.name.trim()||!te.email.trim(),type:`submit`,children:h?`Starting…`:`Start chat`})]})]}),(0,w.jsx)(`p`,{className:`clivly-chat-widget__powered`,children:(0,w.jsxs)(`a`,{href:`https://clivly.com`,rel:`noopener`,target:`_blank`,children:[`Powered by`,` `,(0,w.jsx)(`span`,{className:`clivly-chat-widget__powered-mark`,children:`Clivly`})]})})]}):null]})}function Ce(e){return typeof e!=`object`||!e?!1:typeof e.type==`string`}function we({createSocket:e=(e,t)=>new WebSocket(e,t),getProtocols:t,resolveUrl:n}){return({handlers:r,session:i})=>{let a=e(n(i),t?.(i));return a.addEventListener(`open`,()=>{r.onOpen?.()}),a.addEventListener(`close`,()=>{r.onClose()}),a.addEventListener(`error`,()=>{r.onError(Error(`Chat socket error.`))}),a.addEventListener(`message`,e=>{try{let t=JSON.parse(String(e.data));if(!Ce(t))throw Error(`Chat socket event had an invalid shape.`);r.onEvent(t)}catch(e){let t=e instanceof Error?e:Error(`Chat socket event could not be parsed.`);r.onError(t)}}),{close:()=>{a.close()},markRead:()=>{a.readyState===WebSocket.OPEN&&a.send(JSON.stringify({type:`conversation.read`}))},sendMessage:e=>{if(a.readyState!==WebSocket.OPEN)throw Error(`Chat socket is not connected.`);a.send(JSON.stringify(e))}}}}function Te(e={}){return we({...e,resolveUrl:e=>{if(!e.socketUrl)throw Error(`Chat session response is missing socketUrl. The Clivly server is older than this widget.`);return e.socketUrl}})}function A(e){return typeof e==`object`&&!!e}function Ee({fetchImpl:e=fetch,sessionUrl:t}){return async({visitor:n,visitorToken:r,widgetId:i})=>{let a=await e(t,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({visitor:n,visitorToken:r,widgetId:i})});if(!a.ok)throw Error(`Chat session request failed with ${a.status}.`);let o=await a.json();if(!A(o))throw Error(`Chat session response was not an object.`);return`session`in o?o.session:o}}let De=`clivlyMounted`;function Oe(e){let t=e.visitorEmail?.trim(),n=e.visitorName?.trim();if(t||n)return{...t?{email:t}:{},...n?{name:n}:{}}}function ke(e,t,n){return e.replaceAll(`{conversationId}`,encodeURIComponent(t)).replaceAll(`{token}`,encodeURIComponent(n))}function Ae({container:e,createTransport:t,sessionUrl:n,socketUrlTemplate:r,theme:i=`auto`,widgetId:a,...o}){let s=t??(r?we({resolveUrl:e=>e.socketUrl??ke(r,e.conversation.id,e.token)}):Te()),c=Ee({sessionUrl:n}),l=(0,m.createRoot)(e),u=e=>{l.render((0,w.jsx)(Se,{...o,createTransport:s,getSession:c,theme:e,widgetId:a}))};return u(i),{root:l,setTheme:u}}function je(e){e.dataset[De]=`true`}function Me(e){return e.dataset[De]===`true`}function Ne(e=document.currentScript){if(Me(e))throw Error(`Widget script has already been mounted.`);let t=e.dataset.widgetId,n=e.dataset.sessionUrl;if(!t)throw Error(`Widget script is missing data-widget-id.`);if(!n)throw Error(`Widget script is missing data-session-url.`);let r=document.createElement(`div`);e.insertAdjacentElement(`afterend`,r);let i=r.attachShadow({mode:`open`}),a=document.createElement(`div`);i.append(a);let{root:o,setTheme:s}=Ae({container:a,defaultVisitor:Oe(e.dataset),sessionUrl:n,socketUrlTemplate:e.dataset.socketUrlTemplate,theme:te(e.dataset.theme),widgetId:t});return je(e),{root:o,setTheme:s,shadowRoot:i}}function Pe(e=document.currentScript){return!e||Me(e)?null:Ne(e)}Pe()})();
|