@meetelise/chat 1.55.3 → 1.57.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.
@@ -0,0 +1,99 @@
1
+ import { expect } from "@esm-bundle/chai";
2
+ import { restore, stub } from "sinon/pkg/sinon-esm";
3
+ import {
4
+ consumeReopenChatAfterNavigation,
5
+ getWebchatPageContext,
6
+ markReopenChatAfterNavigation,
7
+ pageAddress,
8
+ parseWebchatNavigation,
9
+ REOPEN_CHAT_AFTER_NAVIGATION_KEY,
10
+ takeMeThere,
11
+ } from "./webchatNavigation";
12
+
13
+ declare const afterEach: (callback: () => void) => void;
14
+ declare const it: (name: string, callback: () => void | Promise<void>) => void;
15
+
16
+ afterEach(() => {
17
+ restore();
18
+ sessionStorage.clear();
19
+ });
20
+
21
+ it("reads a navigation with an http(s) url and a label", () => {
22
+ expect(
23
+ parseWebchatNavigation({
24
+ url: "https://www.macapartments.com/chicago",
25
+ label: "Chicago",
26
+ })
27
+ ).to.deep.equal({
28
+ url: "https://www.macapartments.com/chicago",
29
+ label: "Chicago",
30
+ });
31
+ });
32
+
33
+ it("drops navigations whose url is unsafe or malformed, or whose label is empty", () => {
34
+ expect(
35
+ parseWebchatNavigation({ url: "javascript:alert(1)", label: "Chicago" })
36
+ ).to.equal(null);
37
+ expect(
38
+ parseWebchatNavigation({ url: "data:text/html,hi", label: "Chicago" })
39
+ ).to.equal(null);
40
+ expect(parseWebchatNavigation({ url: "http://[::1", label: "x" })).to.equal(
41
+ null
42
+ );
43
+ expect(
44
+ parseWebchatNavigation({ url: "https://example.com", label: " " })
45
+ ).to.equal(null);
46
+ expect(parseWebchatNavigation({ url: "https://example.com" })).to.equal(null);
47
+ expect(parseWebchatNavigation(undefined)).to.equal(null);
48
+ expect(parseWebchatNavigation("https://example.com")).to.equal(null);
49
+ });
50
+
51
+ it("sends the page url and title, and a declared building id only when it is a real id", () => {
52
+ document.title = "Chicago Apartments";
53
+ expect(getWebchatPageContext(445038)).to.deep.equal({
54
+ pageUrl: window.location.origin + window.location.pathname,
55
+ pageTitle: "Chicago Apartments",
56
+ declaredBuildingId: 445038,
57
+ });
58
+ for (const missing of [undefined, null, 0, NaN, 1.5, -3]) {
59
+ expect(getWebchatPageContext(missing)).not.to.have.property(
60
+ "declaredBuildingId"
61
+ );
62
+ }
63
+ });
64
+
65
+ it("sends the page address without its query string or fragment", () => {
66
+ expect(
67
+ pageAddress(
68
+ "https://www.mac.com/apartments/cornell-court?email=a%40b.com&gclid=x#tour"
69
+ )
70
+ ).to.equal("https://www.mac.com/apartments/cornell-court");
71
+ expect(pageAddress("not a url")).to.equal("");
72
+ });
73
+
74
+ it("reopens the chat on exactly one page load after a Take me there click", () => {
75
+ expect(consumeReopenChatAfterNavigation()).to.equal(false);
76
+ markReopenChatAfterNavigation();
77
+ expect(sessionStorage.getItem(REOPEN_CHAT_AFTER_NAVIGATION_KEY)).to.equal(
78
+ "true"
79
+ );
80
+ expect(consumeReopenChatAfterNavigation()).to.equal(true);
81
+ expect(consumeReopenChatAfterNavigation()).to.equal(false);
82
+ });
83
+
84
+ it("keeps working when sessionStorage is blocked", () => {
85
+ stub(Storage.prototype, "setItem").throws(new Error("blocked"));
86
+ stub(Storage.prototype, "getItem").throws(new Error("blocked"));
87
+ stub(Storage.prototype, "removeItem").throws(new Error("blocked"));
88
+ expect(() => markReopenChatAfterNavigation()).not.to.throw();
89
+ expect(consumeReopenChatAfterNavigation()).to.equal(false);
90
+ });
91
+
92
+ it("Take me there sets the reopen flag and then navigates the same tab", () => {
93
+ const assign = stub();
94
+ takeMeThere({ url: "https://example.com/chicago", label: "Chicago" }, (url) =>
95
+ assign(url, sessionStorage.getItem(REOPEN_CHAT_AFTER_NAVIGATION_KEY))
96
+ );
97
+ expect(assign.calledOnceWithExactly("https://example.com/chicago", "true")).to
98
+ .be.true;
99
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Org-level webchat navigation: the page context the widget sends with each
3
+ * visitor message, the "Take me there" navigation the agent can send back, and
4
+ * the per-tab flag that keeps the chat open across the page load that follows.
5
+ *
6
+ * The backend only acts on these for orgs in the org-level webchat rollout, so
7
+ * for every other customer the extra message fields are ignored.
8
+ */
9
+
10
+ export interface WebchatNavigation {
11
+ url: string;
12
+ label: string;
13
+ }
14
+
15
+ export interface WebchatPageContext {
16
+ pageUrl: string;
17
+ pageTitle: string;
18
+ declaredBuildingId?: number;
19
+ }
20
+
21
+ /** sessionStorage key, so the flag is per tab and gone when the tab closes. */
22
+ export const REOPEN_CHAT_AFTER_NAVIGATION_KEY =
23
+ "com.elise.webchat.reopenAfterNavigation";
24
+
25
+ /**
26
+ * Read a navigation from a PubNub message's `navigation` field.
27
+ * Returns null unless it has an http(s) url and a non-empty label, so a
28
+ * malformed or unsafe (e.g. `javascript:`) url is never rendered.
29
+ */
30
+ export const parseWebchatNavigation = (
31
+ raw: unknown
32
+ ): WebchatNavigation | null => {
33
+ if (!raw || typeof raw !== "object") return null;
34
+ const { url, label } = raw as { url?: unknown; label?: unknown };
35
+ if (typeof url !== "string" || typeof label !== "string") return null;
36
+ if (!label.trim()) return null;
37
+ try {
38
+ const parsed = new URL(url, window.location.href);
39
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
40
+ return null;
41
+ }
42
+ return { url: parsed.href, label: label.trim() };
43
+ } catch {
44
+ return null;
45
+ }
46
+ };
47
+
48
+ /**
49
+ * A page's address without its query string or fragment, which can carry a
50
+ * visitor's details (an email in a link, ad click ids) and aren't needed to
51
+ * tell which page they are on.
52
+ */
53
+ export const pageAddress = (href: string): string => {
54
+ try {
55
+ const url = new URL(href);
56
+ return `${url.origin}${url.pathname}`;
57
+ } catch {
58
+ return "";
59
+ }
60
+ };
61
+
62
+ /** The visitor's current page, sent with each visitor message. */
63
+ export const getWebchatPageContext = (
64
+ declaredBuildingId?: number | null
65
+ ): WebchatPageContext => {
66
+ const context: WebchatPageContext = {
67
+ pageUrl: pageAddress(window.location.href),
68
+ pageTitle: document.title,
69
+ };
70
+ if (
71
+ typeof declaredBuildingId === "number" &&
72
+ Number.isInteger(declaredBuildingId) &&
73
+ declaredBuildingId > 0
74
+ ) {
75
+ context.declaredBuildingId = declaredBuildingId;
76
+ }
77
+ return context;
78
+ };
79
+
80
+ export const markReopenChatAfterNavigation = (): void => {
81
+ try {
82
+ sessionStorage.setItem(REOPEN_CHAT_AFTER_NAVIGATION_KEY, "true");
83
+ } catch {
84
+ // Storage can be blocked (e.g. some privacy modes); the chat then just
85
+ // opens closed on the next page, as it does today.
86
+ }
87
+ };
88
+
89
+ /** Whether the last page load came from a "Take me there" click. Clears the flag. */
90
+ export const consumeReopenChatAfterNavigation = (): boolean => {
91
+ try {
92
+ const shouldReopen =
93
+ sessionStorage.getItem(REOPEN_CHAT_AFTER_NAVIGATION_KEY) === "true";
94
+ sessionStorage.removeItem(REOPEN_CHAT_AFTER_NAVIGATION_KEY);
95
+ return shouldReopen;
96
+ } catch {
97
+ return false;
98
+ }
99
+ };
100
+
101
+ /** Same-tab navigation, after which the chat reopens on the new page. */
102
+ export const takeMeThere = (
103
+ navigation: WebchatNavigation,
104
+ assign: (url: string) => void = (url) => window.location.assign(url)
105
+ ): void => {
106
+ markReopenChatAfterNavigation();
107
+ assign(navigation.url);
108
+ };