@paramms/chat-widget 1.0.36 → 1.0.38

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 CHANGED
@@ -79,7 +79,13 @@ export default function ListingPage({ car, session }) {
79
79
 
80
80
  **Without `listingId`** → opens the buyer's single general (non-listing) thread.
81
81
 
82
- **For a /messages inbox page** (WhatsApp-style thread list, tap to open, ✎ to start a new chat) use `ChatApp` — by default it lists **every conversation the user has with your business, across all your chatrooms**, and opens each against its own chatroom (`scope="tenant"`; pass `scope="profile"` for one chatroom only). It's a single-pane **stack**, like a native messaging app: the list fills the surface, tapping a row pushes the chatroom over it, and the chatroom's back chevron (‹) returns to the list — the same on desktop, in a launcher panel, and on mobile:
82
+ **For a /messages inbox page** (WhatsApp-style thread list, tap to open, ✎ to start a new chat) use `ChatApp` — by default it lists **every conversation the user has with your business, across all your chatrooms**, and opens each against its own chatroom (`scope="tenant"`; pass `scope="profile"` for one chatroom only). It's a single-pane **stack**, like a native messaging app: the list fills the surface, tapping a row pushes the chatroom over it, and the chatroom's back chevron (‹) returns to the list — the same on desktop, in a launcher panel, and on mobile. The list updates **live** over the socket (a `subscribe_inbox` stream keyed to the user), so new messages and threads appear without a refresh.
83
+
84
+ **Add an inbox to a single-thread widget.** Pass `inbox` to `ChatWidget` or `MarketplaceChat` to add a back chevron in the chatroom header that opens the full `ChatApp` list (and the list's ✕ returns to the thread). `inboxScope="tenant"` (default) lists the user's threads across all chatrooms; `"profile"` limits it to the current one:
85
+
86
+ ```tsx
87
+ <MarketplaceChat url={RELAY} profileId={PID} listingId={car.id} listingTitle={car.title} inbox />
88
+ ```
83
89
 
84
90
  ```tsx
85
91
  // Dedicated inbox page — e.g. /messages
@@ -219,6 +225,8 @@ RTL is detected automatically for Arabic, Hebrew, Persian and Urdu browsers.
219
225
  | `translateLang` | `string` | — | Auto-translate incoming messages (ISO language code) |
220
226
  | `quickReplies` | `string[]` | — | Pre-set reply chips shown above input |
221
227
  | `i18n` | `I18nStrings` | English | UI string overrides |
228
+ | `inbox` | `boolean` | `false` | Add a ‹ back in the chatroom header that opens the full `ChatApp` conversation list (inline widgets only) |
229
+ | `inboxScope` | `'tenant' \| 'profile'` | `'tenant'` | With `inbox`: list threads across all chatrooms (`tenant`) or just this one (`profile`) |
222
230
 
223
231
  ## Development
224
232
 
package/dist/chatlist.js CHANGED
@@ -1,14 +1,23 @@
1
- import { p as B, r as O } from "./uid.js";
2
- function P(o) {
3
- const i = Math.floor((Date.now() - o) / 1e3);
4
- return i < 60 ? "just now" : i < 3600 ? `${Math.floor(i / 60)}m` : i < 86400 ? `${Math.floor(i / 3600)}h` : `${Math.floor(i / 86400)}d`;
1
+ import { p as V, r as Q, e as q, d as X } from "./codec.js";
2
+ function Y(o) {
3
+ const a = Math.floor((Date.now() - o) / 1e3);
4
+ if (a < 60) return "just now";
5
+ if (a < 3600) return `${Math.floor(a / 60)}m`;
6
+ if (a < 86400) return `${Math.floor(a / 3600)}h`;
7
+ const p = Math.floor(a / 86400);
8
+ if (p <= 7) return `${p}d`;
9
+ try {
10
+ return new Date(o).toLocaleDateString(void 0, { month: "short", day: "numeric" });
11
+ } catch {
12
+ return `${p}d`;
13
+ }
5
14
  }
6
- const q = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" fill="currentColor" fill-opacity="0.12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
7
- function t(o, i, u) {
8
- const a = document.createElement(o);
9
- return i && (a.className = i), u !== void 0 && (a.textContent = u), a;
15
+ const Z = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" fill="currentColor" fill-opacity="0.12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
16
+ function t(o, a, p) {
17
+ const x = document.createElement(o);
18
+ return a && (x.className = a), p !== void 0 && (x.textContent = p), x;
10
19
  }
11
- const D = `
20
+ const ee = `
12
21
  .ocl { --ocl-accent:#f5713c; --ocl-bg:#f3efe9; --ocl-card:#fff; --ocl-line:#ececec; --ocl-ink:#1c1b1a; --ocl-mut:#9b9690;
13
22
  display:flex; flex-direction:column; height:100%; background:var(--ocl-bg);
14
23
  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; color:var(--ocl-ink); overflow:hidden; }
@@ -41,117 +50,171 @@ const D = `
41
50
  .ocl-compose { border:none; background:var(--ocl-bg); color:var(--ocl-ink); width:32px; height:32px; border-radius:50%; font-size:15px; cursor:pointer; }
42
51
  .ocl-compose:hover { background:var(--ocl-line); }
43
52
  .ocl-start { margin-top:12px; border:none; background:var(--ocl-accent); color:#fff; border-radius:20px; padding:8px 18px; font:inherit; font-size:13px; font-weight:600; cursor:pointer; }
44
- @media (max-width:480px) { .ocl-row { padding:12px 14px; } .ocl-name { font-size:14px; } }
53
+ /* Compact sizing keyed on the LIST'S OWN width (ResizeObserver toggles
54
+ * .ocl-compact below 400px) — covers phones and narrow desktop embeds alike.
55
+ * Search must be ≥16px in compact or iOS zooms the page on focus. */
56
+ .ocl-compact .ocl-row { padding:13px 14px; }
57
+ .ocl-compact .ocl-search { font-size:16px; }
58
+ .ocl-compact .ocl-compose { width:36px; height:36px; }
45
59
  `;
46
- function T(o) {
47
- const i = o.token ?? o.userId ?? B(), { httpBase: u } = O(o.url, o.apiUrl), a = o.i18n ?? {}, A = o.accent ?? "#4F63F5";
60
+ function te(o) {
61
+ const a = o.token ?? o.userId ?? V(), { httpBase: p, wsUrl: x } = Q(o.url, o.apiUrl), f = o.i18n ?? {}, G = o.accent ?? "#4F63F5";
48
62
  if (!document.getElementById("ocl-styles")) {
49
63
  const e = document.createElement("style");
50
- e.id = "ocl-styles", e.textContent = D, document.head.append(e);
64
+ e.id = "ocl-styles", e.textContent = ee, document.head.append(e);
51
65
  }
52
- const w = t("div", "ocl");
53
- w.style.setProperty("--ocl-accent", A);
54
- const b = t("div", "ocl-head"), x = t("span", "ocl-title");
55
- if (x.setAttribute("role", "img"), x.setAttribute("aria-label", a.title ?? "Messages"), x.innerHTML = q, b.append(x), o.onNewChat) {
66
+ const h = t("div", "ocl");
67
+ h.style.setProperty("--ocl-accent", G);
68
+ const z = t("div", "ocl-head"), C = t("span", "ocl-title");
69
+ if (C.setAttribute("role", "img"), C.setAttribute("aria-label", f.title ?? "Messages"), C.innerHTML = Z, z.append(C), o.onNewChat) {
56
70
  const e = t("button", "ocl-compose", "✎");
57
- e.title = a.newChat ?? "New conversation", e.addEventListener("click", () => o.onNewChat()), b.append(e);
71
+ e.title = f.newChat ?? "New conversation", e.addEventListener("click", () => o.onNewChat()), z.append(e);
58
72
  }
59
- const I = t("div", "ocl-search-wrap"), f = t("input", "ocl-search");
60
- f.placeholder = a.search ?? "🔍 Search", f.type = "search", I.append(f);
73
+ const M = t("div", "ocl-search-wrap"), m = t("input", "ocl-search");
74
+ m.placeholder = f.search ?? "🔍 Search", m.type = "search", M.append(m);
61
75
  const d = t("div", "ocl-body");
62
- if (d.append(t("div", "ocl-spinner", "Loading…")), w.append(b, I, d), o.el.replaceChildren(w), !o.profileId && !o.tenantId) throw new Error("[relay chatlist] provide profileId or tenantId");
63
- let S;
64
- const L = `ocl_seen_${o.profileId ?? `t_${o.tenantId}`}_${(o.userId ?? i).slice(-8)}`;
65
- let h = {};
76
+ d.append(t("div", "ocl-spinner", "Loading…")), h.append(z, M, d), o.el.replaceChildren(h);
77
+ const R = (e) => {
78
+ h.classList.toggle("ocl-compact", e > 0 && e < 400);
79
+ };
80
+ R(h.clientWidth);
81
+ let v = null;
82
+ if (typeof ResizeObserver < "u" && (v = new ResizeObserver((e) => {
83
+ var r;
84
+ return R(((r = e[0]) == null ? void 0 : r.contentRect.width) ?? h.clientWidth);
85
+ }), v.observe(h)), !o.profileId && !o.tenantId) throw new Error("[relay chatlist] provide profileId or tenantId");
86
+ let N;
87
+ const E = `ocl_seen_${o.profileId ?? `t_${o.tenantId}`}_${(o.userId ?? a).slice(-8)}`;
88
+ let b = {};
66
89
  try {
67
- h = JSON.parse(localStorage.getItem(L) ?? "{}");
90
+ b = JSON.parse(localStorage.getItem(E) ?? "{}");
68
91
  } catch {
69
92
  }
70
- const R = () => {
93
+ const H = () => {
71
94
  try {
72
- localStorage.setItem(L, JSON.stringify(h));
95
+ localStorage.setItem(E, JSON.stringify(b));
73
96
  } catch {
74
97
  }
75
98
  };
76
- let z = [], g = !1;
77
- const _ = async () => {
78
- const e = o.profileId ? `profileId=${encodeURIComponent(o.profileId)}${o.scope === "tenant" ? "&scope=tenant" : ""}` : `tenantId=${encodeURIComponent(o.tenantId)}`, r = `${u}/conversations/mine?${e}`, c = { authorization: `Bearer ${i}` };
99
+ let U = [], g = !1;
100
+ const J = async () => {
101
+ const e = o.profileId ? `profileId=${encodeURIComponent(o.profileId)}${o.scope === "tenant" ? "&scope=tenant" : ""}` : `tenantId=${encodeURIComponent(o.tenantId)}`, r = `${p}/conversations/mine?${e}`, i = { authorization: `Bearer ${a}` };
79
102
  let s;
80
103
  try {
81
- s = await fetch(r, { headers: c });
104
+ s = await fetch(r, { headers: i });
82
105
  } catch {
83
- s = await fetch(r, { headers: c });
106
+ s = await fetch(r, { headers: i });
84
107
  }
85
108
  if (!s.ok) return [];
86
109
  const l = await s.json();
87
- return l.defaultProfileId && (S = l.defaultProfileId), (l.conversations ?? []).sort((p, n) => n.updatedAt - p.updatedAt);
88
- }, $ = (e, r) => {
110
+ return l.defaultProfileId && (N = l.defaultProfileId), (l.conversations ?? []).sort((u, n) => n.updatedAt - u.updatedAt);
111
+ }, T = (e, r) => {
89
112
  if (g) return;
90
- const c = r ? e.filter(
91
- (n) => E(n).toLowerCase().includes(r) || (n.lastMessage ?? "").toLowerCase().includes(r)
113
+ const i = r ? e.filter(
114
+ (n) => _(n).toLowerCase().includes(r) || (n.lastMessage ?? "").toLowerCase().includes(r)
92
115
  ) : e;
93
- if (d.replaceChildren(), !c.length) {
94
- const n = t("div", "ocl-empty", r ? "No results." : a.empty ?? "No conversations yet.");
116
+ if (d.replaceChildren(), !i.length) {
117
+ const n = t("div", "ocl-empty", r ? "No results." : f.empty ?? "No conversations yet.");
95
118
  if (!r && o.onNewChat) {
96
119
  n.append(t("br"));
97
- const v = t("button", "ocl-start", a.newChat ?? "Start a conversation");
98
- v.addEventListener("click", () => o.onNewChat()), n.append(v);
120
+ const k = t("button", "ocl-start", f.newChat ?? "Start a conversation");
121
+ k.addEventListener("click", () => o.onNewChat()), n.append(k);
99
122
  }
100
123
  d.append(n);
101
124
  return;
102
125
  }
103
- const s = (n) => (n.lastSeq ?? 0) > (h[n.id] ?? 0), l = c.filter(s), p = c.filter((n) => !s(n));
126
+ const s = (n) => (n.lastSeq ?? 0) > (b[n.id] ?? 0), l = i.filter(s), u = i.filter((n) => !s(n));
104
127
  if (l.length) {
105
- d.append(t("div", "ocl-section", `${a.unread ?? "Unread"} (${l.length})`));
106
- for (const n of l) d.append(M(n, s(n)));
128
+ d.append(t("div", "ocl-section", `${f.unread ?? "Unread"} (${l.length})`));
129
+ for (const n of l) d.append(A(n, s(n)));
107
130
  }
108
- if (p.length) {
109
- d.append(t("div", "ocl-section", l.length ? a.all ?? "All conversations" : ""));
110
- for (const n of p) d.append(M(n, !1));
131
+ if (u.length) {
132
+ d.append(t("div", "ocl-section", l.length ? f.all ?? "All conversations" : ""));
133
+ for (const n of u) d.append(A(n, !1));
111
134
  }
112
- }, E = (e) => e.subjectTitle ?? (e.kind === "direct" ? e.peerId ?? "Direct message" : "General enquiry"), M = (e, r) => {
113
- var N;
114
- const c = E(e), s = (((N = c.match(new RegExp("\\p{L}", "u"))) == null ? void 0 : N[0]) ?? c.trim()[0] ?? "?").toUpperCase(), l = e.lastSeq ?? 0, p = r ? Math.max(1, l - (h[e.id] ?? 0)) : 0, n = t("button", `ocl-row${r ? " unread" : ""}`), v = t("div", "ocl-av", s), y = t("div", "ocl-dot");
115
- e.state === "open" ? y.classList.add("open") : e.state === "awaiting_staff" && y.classList.add("waiting"), v.append(y), n.append(v);
116
- const C = t("div", "ocl-info");
117
- C.append(t("div", "ocl-name", c));
118
- const j = {
135
+ }, _ = (e) => e.subjectTitle ?? (e.kind === "direct" ? e.peerId ?? "Direct message" : "General enquiry"), A = (e, r) => {
136
+ var F;
137
+ const i = _(e), s = (((F = i.match(new RegExp("\\p{L}", "u"))) == null ? void 0 : F[0]) ?? i.trim()[0] ?? "?").toUpperCase(), l = e.lastSeq ?? 0, u = r ? Math.max(1, l - (b[e.id] ?? 0)) : 0, n = t("button", `ocl-row${r ? " unread" : ""}`), k = t("div", "ocl-av", s), L = t("div", "ocl-dot");
138
+ e.state === "open" ? L.classList.add("open") : e.state === "awaiting_staff" && L.classList.add("waiting"), k.append(L), n.append(k);
139
+ const $ = t("div", "ocl-info");
140
+ $.append(t("div", "ocl-name", i));
141
+ const K = {
119
142
  open: "Open",
120
143
  awaiting_staff: "Waiting for reply…",
121
144
  resolved: "Resolved ✓",
122
145
  closed: "Closed"
123
146
  };
124
- C.append(t("div", "ocl-preview", e.lastMessage ?? j[e.state] ?? e.state)), n.append(C);
125
- const m = t("div", "ocl-right");
126
- return m.append(t("div", "ocl-time", P(e.updatedAt))), p > 0 && m.append(t("div", "ocl-badge", String(p > 99 ? "99+" : p))), n.append(m), n.addEventListener("click", () => {
127
- var U;
128
- l > 0 && (h[e.id] = l, R()), n.classList.remove("unread"), (U = m.querySelector(".ocl-badge")) == null || U.remove(), o.onSelect(e);
147
+ $.append(t("div", "ocl-preview", e.lastMessage ?? K[e.state] ?? e.state)), n.append($);
148
+ const S = t("div", "ocl-right");
149
+ return S.append(t("div", "ocl-time", Y(e.updatedAt))), u > 0 && S.append(t("div", "ocl-badge", String(u > 99 ? "99+" : u))), n.append(S), n.addEventListener("click", () => {
150
+ var P;
151
+ l > 0 && (b[e.id] = l, H()), n.classList.remove("unread"), (P = S.querySelector(".ocl-badge")) == null || P.remove(), o.onSelect(e);
129
152
  }), n;
130
- }, k = () => {
131
- g || _().then((e) => {
132
- g || (z = e, $(e, f.value.trim().toLowerCase()));
153
+ }, I = () => {
154
+ g || J().then((e) => {
155
+ g || (U = e, T(e, m.value.trim().toLowerCase()));
133
156
  }).catch((e) => {
134
157
  if (g) return;
135
- console.error(`[chat-widget] failed to load conversations from ${u}/conversations/mine — check the apiUrl/CORS config.`, e);
136
- const r = t("div", "ocl-empty", a.error ?? "Could not load conversations.");
158
+ console.error(`[chat-widget] failed to load conversations from ${p}/conversations/mine — check the apiUrl/CORS config.`, e);
159
+ const r = t("div", "ocl-empty", f.error ?? "Could not load conversations.");
137
160
  r.append(t("br"));
138
- const c = t("button", "ocl-retry", a.retry ?? "Retry");
139
- c.addEventListener("click", () => {
140
- d.replaceChildren(t("div", "ocl-spinner", "Loading…")), k();
141
- }), r.append(c), d.replaceChildren(r);
161
+ const i = t("button", "ocl-retry", f.retry ?? "Retry");
162
+ i.addEventListener("click", () => {
163
+ d.replaceChildren(t("div", "ocl-spinner", "Loading…")), I();
164
+ }), r.append(i), d.replaceChildren(r);
142
165
  });
143
166
  };
144
- return f.addEventListener("input", () => $(z, f.value.trim().toLowerCase())), k(), {
145
- refresh: k,
167
+ m.addEventListener("input", () => T(U, m.value.trim().toLowerCase())), I();
168
+ let c = null, w, y, O = 0, B = !1;
169
+ const j = () => {
170
+ y || (y = setTimeout(() => {
171
+ y = void 0, I();
172
+ }, 300));
173
+ }, W = () => {
174
+ if (!g) {
175
+ try {
176
+ c = new WebSocket(x);
177
+ } catch {
178
+ D();
179
+ return;
180
+ }
181
+ c.binaryType = "arraybuffer", c.onopen = () => {
182
+ O = 0, c.send(q({ type: "auth", token: a })), c.send(q({ type: "subscribe_inbox" })), B && j(), B = !0;
183
+ }, c.onmessage = (e) => {
184
+ const r = X(new Uint8Array(e.data));
185
+ r && r.type === "inbox_event" && j();
186
+ }, c.onclose = () => {
187
+ c = null, D();
188
+ }, c.onerror = () => {
189
+ try {
190
+ c == null || c.close();
191
+ } catch {
192
+ }
193
+ };
194
+ }
195
+ }, D = () => {
196
+ if (g || w) return;
197
+ const e = Math.min(15e3, 500 * 2 ** O++) + Math.random() * 250;
198
+ w = setTimeout(() => {
199
+ w = void 0, W();
200
+ }, e);
201
+ };
202
+ return W(), {
203
+ refresh: I,
146
204
  close() {
147
- g = !0, o.el.replaceChildren();
205
+ g = !0, w && clearTimeout(w), y && clearTimeout(y), v == null || v.disconnect(), v = null;
206
+ try {
207
+ c == null || c.close();
208
+ } catch {
209
+ }
210
+ c = null, o.el.replaceChildren();
148
211
  },
149
212
  defaultProfileId() {
150
- return o.profileId ?? S;
213
+ return o.profileId ?? N;
151
214
  }
152
215
  };
153
216
  }
154
217
  export {
155
- T as mountChatList
218
+ te as mountChatList
156
219
  };
157
220
  //# sourceMappingURL=chatlist.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"chatlist.js","sources":["../src/chatlist.ts"],"sourcesContent":["/**\n * chatlist.ts — standalone chat list widget.\n *\n * Completely separate from mount() / the chat widget.\n * Shows all conversations for a given userId / guest on a profile.\n * Tapping a row fires onSelect(entry) — the caller decides what to do\n * (navigate to a new page, open a ChatWidget inline, etc.)\n *\n * Usage (vanilla):\n * import { mountChatList } from '@paramms/chat-widget/chatlist'\n * const handle = mountChatList({\n * el: document.getElementById('chat-list'),\n * url: 'https://api.relay.paramms.com', // ONE url, any scheme\n * profileId: 'p_usedcars',\n * userId: currentUser.id, // optional — uses localStorage UID if omitted\n * onSelect: (entry) => {\n * window.location.href = `/listings/${entry.subjectId}#chat`\n * },\n * })\n * handle.refresh() // manually re-fetch the list\n * handle.close() // unmount and clean up\n */\n\nimport { resolveRelayUrls } from './history.js'\nimport { persistentUid } from './uid.js'\n\nexport interface ChatListEntry {\n id: string\n /** Chatroom this conversation belongs to. With `scope: 'tenant'` this can\n * differ from the profileId the list was mounted with — open the chat\n * against THIS profileId. */\n profileId?: string\n /** 'support' (default) or 'direct' (user↔user DM). */\n kind?: string\n /** For direct conversations: the other participant's user id. */\n peerId?: string\n subjectId?: string\n subjectTitle?: string\n /** One-line detail — e.g. \"45,000 km · Auto\" */\n subjectMeta?: string\n /** URL of the listing/item page — stored automatically when the widget first opens */\n subjectUrl?: string\n state: string\n updatedAt: number\n lastSeq?: number\n lastMessage?: string\n}\n\nexport interface ChatListOptions {\n /** Mount target element */\n el: HTMLElement\n /** Relay URL — ONE url, any scheme (https recommended). The WebSocket URL\n * and REST base are derived automatically. */\n url: string\n /** HTTP(S) base for REST — only when REST is on a different origin.\n * @deprecated pass a single `url`; kept for back-compat. */\n apiUrl?: string\n /** Profile ID to scope conversations to */\n profileId?: string\n /** Tenant-level identification — list EVERY conversation this user has\n * with the business, across ALL of its chatrooms, without naming one\n * (e.g. a platform running a marketplace chatroom AND a general-support\n * chatroom). Provide `profileId` OR `tenantId` (profileId wins if both;\n * it also fixes where \"new conversation\" opens). With only `tenantId`,\n * the compose target is the server-reported defaultProfileId (the\n * tenant's oldest chatroom). */\n tenantId?: string\n /** A signed identity token (ES256 JWT) — the production identity tier for\n * chatrooms with signed identity enabled. Wins over `userId`. */\n token?: string\n /** Your logged-in user's stable ID. Omit for anonymous (uses localStorage UID) */\n userId?: string\n /** Which conversations to list (default 'profile'):\n * 'profile' — only this chatroom's threads.\n * 'tenant' — every conversation this user has with the chatroom's owning\n * business, across ALL of its chatrooms (a real chat-app inbox). Rows\n * carry `profileId` so each opens against the right chatroom. */\n scope?: 'profile' | 'tenant'\n /** Called when the user taps a conversation row */\n onSelect: (entry: ChatListEntry) => void\n /** When provided, the list shows a ✎ compose button in the header (and a\n * \"Start a conversation\" button in the empty state) that calls this —\n * wire it to open a fresh/general thread. Without it a user with no\n * conversations yet has nothing to tap. */\n onNewChat?: () => void\n /** Brand colour hex — default '#4F63F5' */\n accent?: string\n /** i18n overrides */\n i18n?: {\n title?: string // default 'Messages'\n search?: string // default '🔍 Search'\n empty?: string // default 'No conversations yet.'\n unread?: string // default 'Unread'\n all?: string // default 'All conversations'\n error?: string // default 'Could not load conversations.'\n retry?: string // default 'Retry'\n close?: string // default 'Close' (aria-label for the ✕ control)\n newChat?: string // default 'New conversation' / 'Start a conversation'\n }\n}\n\nexport interface ChatListHandle {\n /** Re-fetch and re-render the list */\n refresh(): void\n /** Unmount and clean up */\n close(): void\n /** Where \"new conversation\" should open: the configured profileId, else the\n * server-reported tenant default (oldest chatroom). Undefined until the\n * first successful fetch when only tenantId was configured. */\n defaultProfileId(): string | undefined\n}\n\nfunction timeAgo(ts: number): string {\n const s = Math.floor((Date.now() - ts) / 1000)\n if (s < 60) return 'just now'\n if (s < 3600) return `${Math.floor(s / 60)}m`\n if (s < 86400) return `${Math.floor(s / 3600)}h`\n return `${Math.floor(s / 86400)}d`\n}\n\n// Inline, dependency-free chat-bubble glyph. `currentColor` picks up the\n// header's accent tint. No external font/emoji so it renders identically\n// across platforms (the old '💬'/text wordmark varied per-OS).\nconst CHAT_ICON_SVG =\n '<svg viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" aria-hidden=\"true\">' +\n '<path d=\"M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z\" ' +\n 'fill=\"currentColor\" fill-opacity=\"0.12\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>'\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag)\n if (cls) e.className = cls\n if (text !== undefined) e.textContent = text\n return e\n}\n\nconst CSS = `\n.ocl { --ocl-accent:#f5713c; --ocl-bg:#f3efe9; --ocl-card:#fff; --ocl-line:#ececec; --ocl-ink:#1c1b1a; --ocl-mut:#9b9690;\n display:flex; flex-direction:column; height:100%; background:var(--ocl-bg);\n font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; color:var(--ocl-ink); overflow:hidden; }\n.ocl-head { display:flex; align-items:center; padding:14px 16px 10px; background:var(--ocl-card); border-bottom:1px solid var(--ocl-line); }\n.ocl-title { flex:1; display:flex; align-items:center; color:var(--ocl-accent); }\n.ocl-title svg { width:24px; height:24px; display:block; }\n.ocl-retry { margin-top:14px; border:none; background:var(--ocl-accent); color:#fff; border-radius:20px; padding:8px 20px; font:inherit; font-size:13px; font-weight:600; cursor:pointer; }\n.ocl-search-wrap { padding:8px 12px; background:var(--ocl-card); border-bottom:1px solid var(--ocl-line); }\n.ocl-search { width:100%; box-sizing:border-box; border:none; background:var(--ocl-bg); border-radius:20px; padding:8px 14px; font:inherit; font-size:14px; outline:none; }\n.ocl-body { flex:1; overflow-y:auto; }\n.ocl-section { padding:6px 16px 4px; font-size:11px; font-weight:700; color:var(--ocl-mut); text-transform:uppercase; letter-spacing:.5px; background:var(--ocl-bg); }\n.ocl-empty { padding:40px 20px; text-align:center; color:var(--ocl-mut); font-size:14px; }\n.ocl-row { display:flex; align-items:center; gap:12px; padding:11px 16px; background:var(--ocl-card); border:none; width:100%; text-align:left; cursor:pointer; border-bottom:1px solid var(--ocl-line); transition:background .1s; }\n.ocl-row:hover { background:#f7f5f2; }\n.ocl-row.unread { background:var(--ocl-card); }\n.ocl-av { width:46px; height:46px; border-radius:50%; background:var(--ocl-accent); color:#fff; font-size:18px; font-weight:700; display:flex; align-items:center; justify-content:center; flex:none; position:relative; }\n.ocl-dot { position:absolute; bottom:1px; right:1px; width:12px; height:12px; border-radius:50%; border:2px solid var(--ocl-card); background:var(--ocl-mut); }\n.ocl-dot.open { background:#22c55e; }\n.ocl-dot.waiting { background:#f59e0b; }\n.ocl-info { flex:1; min-width:0; }\n.ocl-name { font-size:15px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:2px; }\n.ocl-row.unread .ocl-name { font-weight:700; }\n.ocl-preview { font-size:13px; color:var(--ocl-mut); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }\n.ocl-row.unread .ocl-preview { color:var(--ocl-ink); }\n.ocl-right { display:flex; flex-direction:column; align-items:flex-end; gap:4px; flex:none; }\n.ocl-time { font-size:11px; color:var(--ocl-mut); }\n.ocl-row.unread .ocl-time { color:var(--ocl-accent); font-weight:600; }\n.ocl-badge { background:var(--ocl-accent); color:#fff; border-radius:999px; font-size:11px; font-weight:700; min-width:20px; height:20px; padding:0 5px; display:flex; align-items:center; justify-content:center; }\n.ocl-spinner { padding:24px; text-align:center; color:var(--ocl-mut); font-size:13px; }\n.ocl-compose { border:none; background:var(--ocl-bg); color:var(--ocl-ink); width:32px; height:32px; border-radius:50%; font-size:15px; cursor:pointer; }\n.ocl-compose:hover { background:var(--ocl-line); }\n.ocl-start { margin-top:12px; border:none; background:var(--ocl-accent); color:#fff; border-radius:20px; padding:8px 18px; font:inherit; font-size:13px; font-weight:600; cursor:pointer; }\n@media (max-width:480px) { .ocl-row { padding:12px 14px; } .ocl-name { font-size:14px; } }\n`\n\n/** Mount a standalone chat list widget. */\nexport function mountChatList(opts: ChatListOptions): ChatListHandle {\n const token = opts.token ?? opts.userId ?? persistentUid()\n const { httpBase } = resolveRelayUrls(opts.url, opts.apiUrl)\n const i18n = opts.i18n ?? {}\n const accent = opts.accent ?? '#4F63F5'\n\n // Inject the shared stylesheet ONCE, and keep it accent-FREE. The accent was\n // previously baked into this shared <style> (CSS.replace(/#f5713c/g, accent)),\n // which meant the FIRST list mounted on a page won the accent for EVERY list\n // after it (the style tag already existed, so a second list's colour was\n // ignored) — real interference when a project runs more than one widget. The\n // accent now lives in a per-instance CSS variable set on the root element\n // below, so each list keeps its own colour and the shared sheet stays static\n // (also friendlier to HTTP caching).\n if (!document.getElementById('ocl-styles')) {\n const s = document.createElement('style'); s.id = 'ocl-styles'\n s.textContent = CSS\n document.head.append(s)\n }\n\n // Build DOM\n const root = el('div', 'ocl')\n root.style.setProperty('--ocl-accent', accent) // per-instance accent\n const head = el('div', 'ocl-head')\n // Header shows a chat icon (accent-tinted) rather than a \"Messages\" wordmark.\n // The i18n title is still exposed as the accessible label for screen readers.\n const titleEl = el('span', 'ocl-title')\n titleEl.setAttribute('role', 'img')\n titleEl.setAttribute('aria-label', i18n.title ?? 'Messages')\n titleEl.innerHTML = CHAT_ICON_SVG\n head.append(titleEl)\n if (opts.onNewChat) {\n const compose = el('button', 'ocl-compose', '✎') as HTMLButtonElement\n compose.title = i18n.newChat ?? 'New conversation'\n compose.addEventListener('click', () => opts.onNewChat!())\n head.append(compose)\n }\n\n const searchWrap = el('div', 'ocl-search-wrap')\n const searchIn = el('input', 'ocl-search') as HTMLInputElement\n searchIn.placeholder = i18n.search ?? '🔍 Search'; searchIn.type = 'search'\n searchWrap.append(searchIn)\n\n const body = el('div', 'ocl-body')\n body.append(el('div', 'ocl-spinner', 'Loading…'))\n root.append(head, searchWrap, body)\n opts.el.replaceChildren(root)\n\n // Track seen seqs for unread counts (persisted in localStorage)\n // Key unread tracking by the STABLE id (userId beats token here: a signed\n // JWT changes every mint, which would reset unread counts on each load).\n if (!opts.profileId && !opts.tenantId) throw new Error('[relay chatlist] provide profileId or tenantId')\n let serverDefaultProfileId: string | undefined\n const seenKey = `ocl_seen_${opts.profileId ?? `t_${opts.tenantId}`}_${(opts.userId ?? token).slice(-8)}`\n let seenSeq: Record<string, number> = {}\n try { seenSeq = JSON.parse(localStorage.getItem(seenKey) ?? '{}') } catch {}\n\n const saveSeenSeq = () => {\n try { localStorage.setItem(seenKey, JSON.stringify(seenSeq)) } catch {}\n }\n\n let allEntries: ChatListEntry[] = []\n let destroyed = false\n\n // Fetch conversations from server\n const fetchEntries = async (): Promise<ChatListEntry[]> => {\n const who = opts.profileId\n ? `profileId=${encodeURIComponent(opts.profileId)}${opts.scope === 'tenant' ? '&scope=tenant' : ''}`\n : `tenantId=${encodeURIComponent(opts.tenantId!)}` // tenant-level is inherently tenant-scoped\n const url = `${httpBase}/conversations/mine?${who}`\n const headers = { authorization: `Bearer ${token}` }\n // A GET is safe to repeat. A single transient network reject — the socket\n // still warming up right after a reload, a relay that blipped — used to\n // dead-end straight to \"Could not load conversations.\" Retry once so a blip\n // self-heals; an HTTP error (4xx/5xx) is NOT a network failure and returns\n // an empty list rather than retrying or erroring.\n let res: Response\n try {\n res = await fetch(url, { headers })\n } catch {\n res = await fetch(url, { headers }) // one immediate retry\n }\n if (!res.ok) return []\n const data = await res.json() as { conversations?: ChatListEntry[]; defaultProfileId?: string }\n if (data.defaultProfileId) serverDefaultProfileId = data.defaultProfileId\n return (data.conversations ?? []).sort((a, b) => b.updatedAt - a.updatedAt)\n }\n\n // Render rows from entries, optionally filtered by search query\n const renderRows = (entries: ChatListEntry[], query: string) => {\n if (destroyed) return\n const filtered = query\n ? entries.filter(e =>\n rowName(e).toLowerCase().includes(query) ||\n (e.lastMessage ?? '').toLowerCase().includes(query)\n )\n : entries\n\n body.replaceChildren()\n\n if (!filtered.length) {\n const empty = el('div', 'ocl-empty', query ? 'No results.' : (i18n.empty ?? 'No conversations yet.'))\n if (!query && opts.onNewChat) {\n empty.append(el('br'))\n const start = el('button', 'ocl-start', i18n.newChat ?? 'Start a conversation') as HTMLButtonElement\n start.addEventListener('click', () => opts.onNewChat!())\n empty.append(start)\n }\n body.append(empty)\n return\n }\n\n const isUnread = (e: ChatListEntry) =>\n (e.lastSeq ?? 0) > (seenSeq[e.id] ?? 0)\n\n const unread = filtered.filter(isUnread)\n const read = filtered.filter(e => !isUnread(e))\n\n if (unread.length) {\n body.append(el('div', 'ocl-section', `${i18n.unread ?? 'Unread'} (${unread.length})`))\n for (const e of unread) body.append(buildRow(e, isUnread(e)))\n }\n if (read.length) {\n body.append(el('div', 'ocl-section', unread.length ? (i18n.all ?? 'All conversations') : ''))\n for (const e of read) body.append(buildRow(e, false))\n }\n }\n\n const rowName = (entry: ChatListEntry): string =>\n entry.subjectTitle ?? (entry.kind === 'direct' ? (entry.peerId ?? 'Direct message') : 'General enquiry')\n\n const buildRow = (entry: ChatListEntry, unread: boolean): HTMLElement => {\n const name = rowName(entry)\n // Prefer the first LETTER (any script), not a leading digit/symbol — a\n // listing titled \"2018 Kia K7\" should show \"K\", not \"2\", and Korean/other\n // scripts pick their first character too.\n const initial = (name.match(/\\p{L}/u)?.[0] ?? name.trim()[0] ?? '?').toUpperCase()\n const lastSeq = entry.lastSeq ?? 0\n const unreadCount = unread ? Math.max(1, lastSeq - (seenSeq[entry.id] ?? 0)) : 0\n\n const row = el('button', `ocl-row${unread ? ' unread' : ''}`) as HTMLButtonElement\n\n // Avatar\n const av = el('div', 'ocl-av', initial)\n const dot = el('div', 'ocl-dot')\n if (entry.state === 'open') dot.classList.add('open')\n else if (entry.state === 'awaiting_staff') dot.classList.add('waiting')\n av.append(dot)\n row.append(av)\n\n // Info\n const info = el('div', 'ocl-info')\n info.append(el('div', 'ocl-name', name))\n const stateMap: Record<string, string> = {\n open: 'Open', awaiting_staff: 'Waiting for reply…',\n resolved: 'Resolved ✓', closed: 'Closed',\n }\n info.append(el('div', 'ocl-preview', entry.lastMessage ?? stateMap[entry.state] ?? entry.state))\n row.append(info)\n\n // Right\n const right = el('div', 'ocl-right')\n right.append(el('div', 'ocl-time', timeAgo(entry.updatedAt)))\n if (unreadCount > 0) {\n right.append(el('div', 'ocl-badge', String(unreadCount > 99 ? '99+' : unreadCount)))\n }\n row.append(right)\n\n row.addEventListener('click', () => {\n // Mark as read\n if (lastSeq > 0) { seenSeq[entry.id] = lastSeq; saveSeenSeq() }\n row.classList.remove('unread')\n right.querySelector('.ocl-badge')?.remove()\n opts.onSelect(entry)\n })\n\n return row\n }\n\n const refresh = () => {\n if (destroyed) return\n fetchEntries().then(entries => {\n if (destroyed) return\n allEntries = entries\n renderRows(entries, searchIn.value.trim().toLowerCase())\n }).catch((e) => {\n if (destroyed) return\n console.error(`[chat-widget] failed to load conversations from ${httpBase}/conversations/mine — check the apiUrl/CORS config.`, e)\n const errBox = el('div', 'ocl-empty', i18n.error ?? 'Could not load conversations.')\n errBox.append(el('br'))\n const retry = el('button', 'ocl-retry', i18n.retry ?? 'Retry') as HTMLButtonElement\n retry.addEventListener('click', () => {\n body.replaceChildren(el('div', 'ocl-spinner', 'Loading…'))\n refresh()\n })\n errBox.append(retry)\n body.replaceChildren(errBox)\n })\n }\n\n searchIn.addEventListener('input', () => renderRows(allEntries, searchIn.value.trim().toLowerCase()))\n\n // Initial fetch\n refresh()\n\n return {\n refresh,\n close() {\n destroyed = true\n opts.el.replaceChildren()\n },\n defaultProfileId() { return opts.profileId ?? serverDefaultProfileId },\n }\n}\n"],"names":["timeAgo","ts","s","CHAT_ICON_SVG","el","tag","cls","text","e","CSS","mountChatList","opts","token","persistentUid","httpBase","resolveRelayUrls","i18n","accent","root","head","titleEl","compose","searchWrap","searchIn","body","serverDefaultProfileId","seenKey","seenSeq","saveSeenSeq","allEntries","destroyed","fetchEntries","who","url","headers","res","data","a","b","renderRows","entries","query","filtered","rowName","empty","start","isUnread","unread","read","buildRow","entry","name","initial","_a","lastSeq","unreadCount","row","av","dot","info","stateMap","right","refresh","errBox","retry"],"mappings":"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"}
1
+ {"version":3,"file":"chatlist.js","sources":["../src/chatlist.ts"],"sourcesContent":["/**\n * chatlist.ts — standalone chat list widget.\n *\n * Completely separate from mount() / the chat widget.\n * Shows all conversations for a given userId / guest on a profile.\n * Tapping a row fires onSelect(entry) — the caller decides what to do\n * (navigate to a new page, open a ChatWidget inline, etc.)\n *\n * Usage (vanilla):\n * import { mountChatList } from '@paramms/chat-widget/chatlist'\n * const handle = mountChatList({\n * el: document.getElementById('chat-list'),\n * url: 'https://api.relay.paramms.com', // ONE url, any scheme\n * profileId: 'p_usedcars',\n * userId: currentUser.id, // optional — uses localStorage UID if omitted\n * onSelect: (entry) => {\n * window.location.href = `/listings/${entry.subjectId}#chat`\n * },\n * })\n * handle.refresh() // manually re-fetch the list\n * handle.close() // unmount and clean up\n */\n\nimport { resolveRelayUrls } from './history.js'\nimport { persistentUid } from './uid.js'\nimport { encodeFrame, decodeFrame } from './protocol/codec.js'\n\nexport interface ChatListEntry {\n id: string\n /** Chatroom this conversation belongs to. With `scope: 'tenant'` this can\n * differ from the profileId the list was mounted with — open the chat\n * against THIS profileId. */\n profileId?: string\n /** 'support' (default) or 'direct' (user↔user DM). */\n kind?: string\n /** For direct conversations: the other participant's user id. */\n peerId?: string\n subjectId?: string\n subjectTitle?: string\n /** One-line detail — e.g. \"45,000 km · Auto\" */\n subjectMeta?: string\n /** URL of the listing/item page — stored automatically when the widget first opens */\n subjectUrl?: string\n state: string\n updatedAt: number\n lastSeq?: number\n lastMessage?: string\n}\n\nexport interface ChatListOptions {\n /** Mount target element */\n el: HTMLElement\n /** Relay URL — ONE url, any scheme (https recommended). The WebSocket URL\n * and REST base are derived automatically. */\n url: string\n /** HTTP(S) base for REST — only when REST is on a different origin.\n * @deprecated pass a single `url`; kept for back-compat. */\n apiUrl?: string\n /** Profile ID to scope conversations to */\n profileId?: string\n /** Tenant-level identification — list EVERY conversation this user has\n * with the business, across ALL of its chatrooms, without naming one\n * (e.g. a platform running a marketplace chatroom AND a general-support\n * chatroom). Provide `profileId` OR `tenantId` (profileId wins if both;\n * it also fixes where \"new conversation\" opens). With only `tenantId`,\n * the compose target is the server-reported defaultProfileId (the\n * tenant's oldest chatroom). */\n tenantId?: string\n /** A signed identity token (ES256 JWT) — the production identity tier for\n * chatrooms with signed identity enabled. Wins over `userId`. */\n token?: string\n /** Your logged-in user's stable ID. Omit for anonymous (uses localStorage UID) */\n userId?: string\n /** Which conversations to list (default 'profile'):\n * 'profile' — only this chatroom's threads.\n * 'tenant' — every conversation this user has with the chatroom's owning\n * business, across ALL of its chatrooms (a real chat-app inbox). Rows\n * carry `profileId` so each opens against the right chatroom. */\n scope?: 'profile' | 'tenant'\n /** Called when the user taps a conversation row */\n onSelect: (entry: ChatListEntry) => void\n /** When provided, the list shows a ✎ compose button in the header (and a\n * \"Start a conversation\" button in the empty state) that calls this —\n * wire it to open a fresh/general thread. Without it a user with no\n * conversations yet has nothing to tap. */\n onNewChat?: () => void\n /** Brand colour hex — default '#4F63F5' */\n accent?: string\n /** i18n overrides */\n i18n?: {\n title?: string // default 'Messages'\n search?: string // default '🔍 Search'\n empty?: string // default 'No conversations yet.'\n unread?: string // default 'Unread'\n all?: string // default 'All conversations'\n error?: string // default 'Could not load conversations.'\n retry?: string // default 'Retry'\n close?: string // default 'Close' (aria-label for the ✕ control)\n newChat?: string // default 'New conversation' / 'Start a conversation'\n }\n}\n\nexport interface ChatListHandle {\n /** Re-fetch and re-render the list */\n refresh(): void\n /** Unmount and clean up */\n close(): void\n /** Where \"new conversation\" should open: the configured profileId, else the\n * server-reported tenant default (oldest chatroom). Undefined until the\n * first successful fetch when only tenantId was configured. */\n defaultProfileId(): string | undefined\n}\n\nfunction timeAgo(ts: number): string {\n const s = Math.floor((Date.now() - ts) / 1000)\n if (s < 60) return 'just now'\n if (s < 3600) return `${Math.floor(s / 60)}m`\n if (s < 86400) return `${Math.floor(s / 3600)}h`\n const days = Math.floor(s / 86400)\n if (days <= 7) return `${days}d`\n // Beyond a week, a date scans better than \"43d\" (what Channel.io/Intercom do).\n try { return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) }\n catch { return `${days}d` }\n}\n\n// Inline, dependency-free chat-bubble glyph. `currentColor` picks up the\n// header's accent tint. No external font/emoji so it renders identically\n// across platforms (the old '💬'/text wordmark varied per-OS).\nconst CHAT_ICON_SVG =\n '<svg viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" aria-hidden=\"true\">' +\n '<path d=\"M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z\" ' +\n 'fill=\"currentColor\" fill-opacity=\"0.12\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>'\n\nfunction el(tag: string, cls?: string, text?: string): HTMLElement {\n const e = document.createElement(tag)\n if (cls) e.className = cls\n if (text !== undefined) e.textContent = text\n return e\n}\n\nconst CSS = `\n.ocl { --ocl-accent:#f5713c; --ocl-bg:#f3efe9; --ocl-card:#fff; --ocl-line:#ececec; --ocl-ink:#1c1b1a; --ocl-mut:#9b9690;\n display:flex; flex-direction:column; height:100%; background:var(--ocl-bg);\n font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; color:var(--ocl-ink); overflow:hidden; }\n.ocl-head { display:flex; align-items:center; padding:14px 16px 10px; background:var(--ocl-card); border-bottom:1px solid var(--ocl-line); }\n.ocl-title { flex:1; display:flex; align-items:center; color:var(--ocl-accent); }\n.ocl-title svg { width:24px; height:24px; display:block; }\n.ocl-retry { margin-top:14px; border:none; background:var(--ocl-accent); color:#fff; border-radius:20px; padding:8px 20px; font:inherit; font-size:13px; font-weight:600; cursor:pointer; }\n.ocl-search-wrap { padding:8px 12px; background:var(--ocl-card); border-bottom:1px solid var(--ocl-line); }\n.ocl-search { width:100%; box-sizing:border-box; border:none; background:var(--ocl-bg); border-radius:20px; padding:8px 14px; font:inherit; font-size:14px; outline:none; }\n.ocl-body { flex:1; overflow-y:auto; }\n.ocl-section { padding:6px 16px 4px; font-size:11px; font-weight:700; color:var(--ocl-mut); text-transform:uppercase; letter-spacing:.5px; background:var(--ocl-bg); }\n.ocl-empty { padding:40px 20px; text-align:center; color:var(--ocl-mut); font-size:14px; }\n.ocl-row { display:flex; align-items:center; gap:12px; padding:11px 16px; background:var(--ocl-card); border:none; width:100%; text-align:left; cursor:pointer; border-bottom:1px solid var(--ocl-line); transition:background .1s; }\n.ocl-row:hover { background:#f7f5f2; }\n.ocl-row.unread { background:var(--ocl-card); }\n.ocl-av { width:46px; height:46px; border-radius:50%; background:var(--ocl-accent); color:#fff; font-size:18px; font-weight:700; display:flex; align-items:center; justify-content:center; flex:none; position:relative; }\n.ocl-dot { position:absolute; bottom:1px; right:1px; width:12px; height:12px; border-radius:50%; border:2px solid var(--ocl-card); background:var(--ocl-mut); }\n.ocl-dot.open { background:#22c55e; }\n.ocl-dot.waiting { background:#f59e0b; }\n.ocl-info { flex:1; min-width:0; }\n.ocl-name { font-size:15px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:2px; }\n.ocl-row.unread .ocl-name { font-weight:700; }\n.ocl-preview { font-size:13px; color:var(--ocl-mut); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }\n.ocl-row.unread .ocl-preview { color:var(--ocl-ink); }\n.ocl-right { display:flex; flex-direction:column; align-items:flex-end; gap:4px; flex:none; }\n.ocl-time { font-size:11px; color:var(--ocl-mut); }\n.ocl-row.unread .ocl-time { color:var(--ocl-accent); font-weight:600; }\n.ocl-badge { background:var(--ocl-accent); color:#fff; border-radius:999px; font-size:11px; font-weight:700; min-width:20px; height:20px; padding:0 5px; display:flex; align-items:center; justify-content:center; }\n.ocl-spinner { padding:24px; text-align:center; color:var(--ocl-mut); font-size:13px; }\n.ocl-compose { border:none; background:var(--ocl-bg); color:var(--ocl-ink); width:32px; height:32px; border-radius:50%; font-size:15px; cursor:pointer; }\n.ocl-compose:hover { background:var(--ocl-line); }\n.ocl-start { margin-top:12px; border:none; background:var(--ocl-accent); color:#fff; border-radius:20px; padding:8px 18px; font:inherit; font-size:13px; font-weight:600; cursor:pointer; }\n/* Compact sizing keyed on the LIST'S OWN width (ResizeObserver toggles\n * .ocl-compact below 400px) — covers phones and narrow desktop embeds alike.\n * Search must be ≥16px in compact or iOS zooms the page on focus. */\n.ocl-compact .ocl-row { padding:13px 14px; }\n.ocl-compact .ocl-search { font-size:16px; }\n.ocl-compact .ocl-compose { width:36px; height:36px; }\n`\n\n/** Mount a standalone chat list widget. */\nexport function mountChatList(opts: ChatListOptions): ChatListHandle {\n const token = opts.token ?? opts.userId ?? persistentUid()\n const { httpBase, wsUrl } = resolveRelayUrls(opts.url, opts.apiUrl)\n const i18n = opts.i18n ?? {}\n const accent = opts.accent ?? '#4F63F5'\n\n // Inject the shared stylesheet ONCE, and keep it accent-FREE. The accent was\n // previously baked into this shared <style> (CSS.replace(/#f5713c/g, accent)),\n // which meant the FIRST list mounted on a page won the accent for EVERY list\n // after it (the style tag already existed, so a second list's colour was\n // ignored) — real interference when a project runs more than one widget. The\n // accent now lives in a per-instance CSS variable set on the root element\n // below, so each list keeps its own colour and the shared sheet stays static\n // (also friendlier to HTTP caching).\n if (!document.getElementById('ocl-styles')) {\n const s = document.createElement('style'); s.id = 'ocl-styles'\n s.textContent = CSS\n document.head.append(s)\n }\n\n // Build DOM\n const root = el('div', 'ocl')\n root.style.setProperty('--ocl-accent', accent) // per-instance accent\n const head = el('div', 'ocl-head')\n // Header shows a chat icon (accent-tinted) rather than a \"Messages\" wordmark.\n // The i18n title is still exposed as the accessible label for screen readers.\n const titleEl = el('span', 'ocl-title')\n titleEl.setAttribute('role', 'img')\n titleEl.setAttribute('aria-label', i18n.title ?? 'Messages')\n titleEl.innerHTML = CHAT_ICON_SVG\n head.append(titleEl)\n if (opts.onNewChat) {\n const compose = el('button', 'ocl-compose', '✎') as HTMLButtonElement\n compose.title = i18n.newChat ?? 'New conversation'\n compose.addEventListener('click', () => opts.onNewChat!())\n head.append(compose)\n }\n\n const searchWrap = el('div', 'ocl-search-wrap')\n const searchIn = el('input', 'ocl-search') as HTMLInputElement\n searchIn.placeholder = i18n.search ?? '🔍 Search'; searchIn.type = 'search'\n searchWrap.append(searchIn)\n\n const body = el('div', 'ocl-body')\n body.append(el('div', 'ocl-spinner', 'Loading…'))\n root.append(head, searchWrap, body)\n opts.el.replaceChildren(root)\n\n // Container-driven compact sizing (see the CSS note). Guarded for\n // jsdom/old runtimes without ResizeObserver — they keep desktop sizing.\n const applyCompact = (w: number): void => { root.classList.toggle('ocl-compact', w > 0 && w < 400) }\n applyCompact(root.clientWidth)\n let compactObserver: ResizeObserver | null = null\n if (typeof ResizeObserver !== 'undefined') {\n compactObserver = new ResizeObserver((entries) => applyCompact(entries[0]?.contentRect.width ?? root.clientWidth))\n compactObserver.observe(root)\n }\n\n // Track seen seqs for unread counts (persisted in localStorage)\n // Key unread tracking by the STABLE id (userId beats token here: a signed\n // JWT changes every mint, which would reset unread counts on each load).\n if (!opts.profileId && !opts.tenantId) throw new Error('[relay chatlist] provide profileId or tenantId')\n let serverDefaultProfileId: string | undefined\n const seenKey = `ocl_seen_${opts.profileId ?? `t_${opts.tenantId}`}_${(opts.userId ?? token).slice(-8)}`\n let seenSeq: Record<string, number> = {}\n try { seenSeq = JSON.parse(localStorage.getItem(seenKey) ?? '{}') } catch {}\n\n const saveSeenSeq = () => {\n try { localStorage.setItem(seenKey, JSON.stringify(seenSeq)) } catch {}\n }\n\n let allEntries: ChatListEntry[] = []\n let destroyed = false\n\n // Fetch conversations from server\n const fetchEntries = async (): Promise<ChatListEntry[]> => {\n const who = opts.profileId\n ? `profileId=${encodeURIComponent(opts.profileId)}${opts.scope === 'tenant' ? '&scope=tenant' : ''}`\n : `tenantId=${encodeURIComponent(opts.tenantId!)}` // tenant-level is inherently tenant-scoped\n const url = `${httpBase}/conversations/mine?${who}`\n const headers = { authorization: `Bearer ${token}` }\n // A GET is safe to repeat. A single transient network reject — the socket\n // still warming up right after a reload, a relay that blipped — used to\n // dead-end straight to \"Could not load conversations.\" Retry once so a blip\n // self-heals; an HTTP error (4xx/5xx) is NOT a network failure and returns\n // an empty list rather than retrying or erroring.\n let res: Response\n try {\n res = await fetch(url, { headers })\n } catch {\n res = await fetch(url, { headers }) // one immediate retry\n }\n if (!res.ok) return []\n const data = await res.json() as { conversations?: ChatListEntry[]; defaultProfileId?: string }\n if (data.defaultProfileId) serverDefaultProfileId = data.defaultProfileId\n return (data.conversations ?? []).sort((a, b) => b.updatedAt - a.updatedAt)\n }\n\n // Render rows from entries, optionally filtered by search query\n const renderRows = (entries: ChatListEntry[], query: string) => {\n if (destroyed) return\n const filtered = query\n ? entries.filter(e =>\n rowName(e).toLowerCase().includes(query) ||\n (e.lastMessage ?? '').toLowerCase().includes(query)\n )\n : entries\n\n body.replaceChildren()\n\n if (!filtered.length) {\n const empty = el('div', 'ocl-empty', query ? 'No results.' : (i18n.empty ?? 'No conversations yet.'))\n if (!query && opts.onNewChat) {\n empty.append(el('br'))\n const start = el('button', 'ocl-start', i18n.newChat ?? 'Start a conversation') as HTMLButtonElement\n start.addEventListener('click', () => opts.onNewChat!())\n empty.append(start)\n }\n body.append(empty)\n return\n }\n\n const isUnread = (e: ChatListEntry) =>\n (e.lastSeq ?? 0) > (seenSeq[e.id] ?? 0)\n\n const unread = filtered.filter(isUnread)\n const read = filtered.filter(e => !isUnread(e))\n\n if (unread.length) {\n body.append(el('div', 'ocl-section', `${i18n.unread ?? 'Unread'} (${unread.length})`))\n for (const e of unread) body.append(buildRow(e, isUnread(e)))\n }\n if (read.length) {\n body.append(el('div', 'ocl-section', unread.length ? (i18n.all ?? 'All conversations') : ''))\n for (const e of read) body.append(buildRow(e, false))\n }\n }\n\n const rowName = (entry: ChatListEntry): string =>\n entry.subjectTitle ?? (entry.kind === 'direct' ? (entry.peerId ?? 'Direct message') : 'General enquiry')\n\n const buildRow = (entry: ChatListEntry, unread: boolean): HTMLElement => {\n const name = rowName(entry)\n // Prefer the first LETTER (any script), not a leading digit/symbol — a\n // listing titled \"2018 Kia K7\" should show \"K\", not \"2\", and Korean/other\n // scripts pick their first character too.\n const initial = (name.match(/\\p{L}/u)?.[0] ?? name.trim()[0] ?? '?').toUpperCase()\n const lastSeq = entry.lastSeq ?? 0\n const unreadCount = unread ? Math.max(1, lastSeq - (seenSeq[entry.id] ?? 0)) : 0\n\n const row = el('button', `ocl-row${unread ? ' unread' : ''}`) as HTMLButtonElement\n\n // Avatar\n const av = el('div', 'ocl-av', initial)\n const dot = el('div', 'ocl-dot')\n if (entry.state === 'open') dot.classList.add('open')\n else if (entry.state === 'awaiting_staff') dot.classList.add('waiting')\n av.append(dot)\n row.append(av)\n\n // Info\n const info = el('div', 'ocl-info')\n info.append(el('div', 'ocl-name', name))\n const stateMap: Record<string, string> = {\n open: 'Open', awaiting_staff: 'Waiting for reply…',\n resolved: 'Resolved ✓', closed: 'Closed',\n }\n info.append(el('div', 'ocl-preview', entry.lastMessage ?? stateMap[entry.state] ?? entry.state))\n row.append(info)\n\n // Right\n const right = el('div', 'ocl-right')\n right.append(el('div', 'ocl-time', timeAgo(entry.updatedAt)))\n if (unreadCount > 0) {\n right.append(el('div', 'ocl-badge', String(unreadCount > 99 ? '99+' : unreadCount)))\n }\n row.append(right)\n\n row.addEventListener('click', () => {\n // Mark as read\n if (lastSeq > 0) { seenSeq[entry.id] = lastSeq; saveSeenSeq() }\n row.classList.remove('unread')\n right.querySelector('.ocl-badge')?.remove()\n opts.onSelect(entry)\n })\n\n return row\n }\n\n const refresh = () => {\n if (destroyed) return\n fetchEntries().then(entries => {\n if (destroyed) return\n allEntries = entries\n renderRows(entries, searchIn.value.trim().toLowerCase())\n }).catch((e) => {\n if (destroyed) return\n console.error(`[chat-widget] failed to load conversations from ${httpBase}/conversations/mine — check the apiUrl/CORS config.`, e)\n const errBox = el('div', 'ocl-empty', i18n.error ?? 'Could not load conversations.')\n errBox.append(el('br'))\n const retry = el('button', 'ocl-retry', i18n.retry ?? 'Retry') as HTMLButtonElement\n retry.addEventListener('click', () => {\n body.replaceChildren(el('div', 'ocl-spinner', 'Loading…'))\n refresh()\n })\n errBox.append(retry)\n body.replaceChildren(errBox)\n })\n }\n\n searchIn.addEventListener('input', () => renderRows(allEntries, searchIn.value.trim().toLowerCase()))\n\n // Initial fetch\n refresh()\n\n // ── Live inbox: subscribe over WS so the list updates the instant any of the\n // guest's conversations changes, instead of only on the periodic poll. The\n // server streams `inbox_event` to the guest's OWN inbox (keyed by guestId).\n // We coalesce bursts and re-fetch (the fetch already sorts/dedupes); the poll\n // stays as a backstop for a dropped socket. ────────────────────────────────\n let sock: WebSocket | null = null\n let reconnectTimer: ReturnType<typeof setTimeout> | undefined\n let refreshTimer: ReturnType<typeof setTimeout> | undefined\n let attempt = 0\n let connectedBefore = false\n const debouncedRefresh = () => {\n if (refreshTimer) return\n refreshTimer = setTimeout(() => { refreshTimer = undefined; refresh() }, 300)\n }\n const connectInbox = () => {\n if (destroyed) return\n try { sock = new WebSocket(wsUrl) } catch { scheduleReconnect(); return }\n sock.binaryType = 'arraybuffer'\n sock.onopen = () => {\n attempt = 0\n sock!.send(encodeFrame({ type: 'auth', token }))\n sock!.send(encodeFrame({ type: 'subscribe_inbox' }))\n // On a RECONNECT (not the first connect — the initial mount already\n // fetched), catch up on anything that changed while the socket was down.\n if (connectedBefore) debouncedRefresh()\n connectedBefore = true\n }\n sock.onmessage = (ev) => {\n const frame = decodeFrame(new Uint8Array(ev.data as ArrayBuffer))\n // Any inbox change for this guest → refresh the list. `new` (a freshly\n // created thread) and `update` (new message / state) both apply.\n if (frame && frame.type === 'inbox_event') debouncedRefresh()\n }\n sock.onclose = () => { sock = null; scheduleReconnect() }\n sock.onerror = () => { try { sock?.close() } catch { /* noop */ } }\n }\n const scheduleReconnect = () => {\n if (destroyed || reconnectTimer) return\n const delay = Math.min(15_000, 500 * 2 ** attempt++) + Math.random() * 250\n reconnectTimer = setTimeout(() => { reconnectTimer = undefined; connectInbox() }, delay)\n }\n connectInbox()\n\n return {\n refresh,\n close() {\n destroyed = true\n if (reconnectTimer) clearTimeout(reconnectTimer)\n if (refreshTimer) clearTimeout(refreshTimer)\n compactObserver?.disconnect()\n compactObserver = null\n try { sock?.close() } catch { /* noop */ }\n sock = null\n opts.el.replaceChildren()\n },\n defaultProfileId() { return opts.profileId ?? serverDefaultProfileId },\n }\n}\n"],"names":["timeAgo","ts","s","days","CHAT_ICON_SVG","el","tag","cls","text","e","CSS","mountChatList","opts","token","persistentUid","httpBase","wsUrl","resolveRelayUrls","i18n","accent","root","head","titleEl","compose","searchWrap","searchIn","body","applyCompact","w","compactObserver","entries","_a","serverDefaultProfileId","seenKey","seenSeq","saveSeenSeq","allEntries","destroyed","fetchEntries","who","url","headers","res","data","a","b","renderRows","query","filtered","rowName","empty","start","isUnread","unread","read","buildRow","entry","name","initial","lastSeq","unreadCount","row","av","dot","info","stateMap","right","refresh","errBox","retry","sock","reconnectTimer","refreshTimer","attempt","connectedBefore","debouncedRefresh","connectInbox","scheduleReconnect","encodeFrame","ev","frame","decodeFrame","delay"],"mappings":";AAiHA,SAASA,EAAQC,GAAoB;AACnC,QAAMC,IAAI,KAAK,OAAO,KAAK,IAAA,IAAQD,KAAM,GAAI;AAC7C,MAAIC,IAAI,GAAI,QAAO;AACnB,MAAIA,IAAI,KAAM,QAAO,GAAG,KAAK,MAAMA,IAAI,EAAE,CAAC;AAC1C,MAAIA,IAAI,MAAO,QAAO,GAAG,KAAK,MAAMA,IAAI,IAAI,CAAC;AAC7C,QAAMC,IAAO,KAAK,MAAMD,IAAI,KAAK;AACjC,MAAIC,KAAQ,EAAG,QAAO,GAAGA,CAAI;AAE7B,MAAI;AAAE,WAAO,IAAI,KAAKF,CAAE,EAAE,mBAAmB,QAAW,EAAE,OAAO,SAAS,KAAK,UAAA,CAAW;AAAA,EAAE,QACtF;AAAE,WAAO,GAAGE,CAAI;AAAA,EAAI;AAC5B;AAKA,MAAMC,IACJ;AAIF,SAASC,EAAGC,GAAaC,GAAcC,GAA4B;AACjE,QAAMC,IAAI,SAAS,cAAcH,CAAG;AACpC,SAAIC,QAAO,YAAYA,IACnBC,MAAS,WAAWC,EAAE,cAAcD,IACjCC;AACT;AAEA,MAAMC,KAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA0CL,SAASC,GAAcC,GAAuC;AACnE,QAAMC,IAAQD,EAAK,SAASA,EAAK,UAAUE,EAAA,GACrC,EAAE,UAAAC,GAAU,OAAAC,MAAUC,EAAiBL,EAAK,KAAKA,EAAK,MAAM,GAC5DM,IAAON,EAAK,QAAQ,CAAA,GACpBO,IAASP,EAAK,UAAU;AAU9B,MAAI,CAAC,SAAS,eAAe,YAAY,GAAG;AAC1C,UAAMV,IAAI,SAAS,cAAc,OAAO;AAAG,IAAAA,EAAE,KAAK,cAClDA,EAAE,cAAcQ,IAChB,SAAS,KAAK,OAAOR,CAAC;AAAA,EACxB;AAGA,QAAMkB,IAAOf,EAAG,OAAO,KAAK;AAC5B,EAAAe,EAAK,MAAM,YAAY,gBAAgBD,CAAM;AAC7C,QAAME,IAAOhB,EAAG,OAAO,UAAU,GAG3BiB,IAAUjB,EAAG,QAAQ,WAAW;AAKtC,MAJAiB,EAAQ,aAAa,QAAQ,KAAK,GAClCA,EAAQ,aAAa,cAAcJ,EAAK,SAAS,UAAU,GAC3DI,EAAQ,YAAYlB,GACpBiB,EAAK,OAAOC,CAAO,GACfV,EAAK,WAAW;AAClB,UAAMW,IAAUlB,EAAG,UAAU,eAAe,GAAG;AAC/C,IAAAkB,EAAQ,QAAQL,EAAK,WAAW,oBAChCK,EAAQ,iBAAiB,SAAS,MAAMX,EAAK,WAAY,GACzDS,EAAK,OAAOE,CAAO;AAAA,EACrB;AAEA,QAAMC,IAAanB,EAAG,OAAO,iBAAiB,GACxCoB,IAAWpB,EAAG,SAAS,YAAY;AACzC,EAAAoB,EAAS,cAAcP,EAAK,UAAU,cAAcO,EAAS,OAAO,UACpED,EAAW,OAAOC,CAAQ;AAE1B,QAAMC,IAAOrB,EAAG,OAAO,UAAU;AACjC,EAAAqB,EAAK,OAAOrB,EAAG,OAAO,eAAe,UAAU,CAAC,GAChDe,EAAK,OAAOC,GAAMG,GAAYE,CAAI,GAClCd,EAAK,GAAG,gBAAgBQ,CAAI;AAI5B,QAAMO,IAAe,CAACC,MAAoB;AAAE,IAAAR,EAAK,UAAU,OAAO,eAAeQ,IAAI,KAAKA,IAAI,GAAG;AAAA,EAAE;AACnG,EAAAD,EAAaP,EAAK,WAAW;AAC7B,MAAIS,IAAyC;AAS7C,MARI,OAAO,iBAAmB,QAC5BA,IAAkB,IAAI,eAAe,CAACC,MAAA;;AAAY,WAAAH,IAAaI,IAAAD,EAAQ,CAAC,MAAT,gBAAAC,EAAY,YAAY,UAASX,EAAK,WAAW;AAAA,GAAC,GACjHS,EAAgB,QAAQT,CAAI,IAM1B,CAACR,EAAK,aAAa,CAACA,EAAK,SAAU,OAAM,IAAI,MAAM,gDAAgD;AACvG,MAAIoB;AACJ,QAAMC,IAAU,YAAYrB,EAAK,aAAa,KAAKA,EAAK,QAAQ,EAAE,KAAKA,EAAK,UAAUC,GAAO,MAAM,EAAE,CAAC;AACtG,MAAIqB,IAAkC,CAAA;AACtC,MAAI;AAAE,IAAAA,IAAU,KAAK,MAAM,aAAa,QAAQD,CAAO,KAAK,IAAI;AAAA,EAAE,QAAQ;AAAA,EAAC;AAE3E,QAAME,IAAc,MAAM;AACxB,QAAI;AAAE,mBAAa,QAAQF,GAAS,KAAK,UAAUC,CAAO,CAAC;AAAA,IAAE,QAAQ;AAAA,IAAC;AAAA,EACxE;AAEA,MAAIE,IAA8B,CAAA,GAC9BC,IAAY;AAGhB,QAAMC,IAAe,YAAsC;AACzD,UAAMC,IAAM3B,EAAK,YACb,aAAa,mBAAmBA,EAAK,SAAS,CAAC,GAAGA,EAAK,UAAU,WAAW,kBAAkB,EAAE,KAChG,YAAY,mBAAmBA,EAAK,QAAS,CAAC,IAC5C4B,IAAM,GAAGzB,CAAQ,uBAAuBwB,CAAG,IAC3CE,IAAU,EAAE,eAAe,UAAU5B,CAAK,GAAA;AAMhD,QAAI6B;AACJ,QAAI;AACF,MAAAA,IAAM,MAAM,MAAMF,GAAK,EAAE,SAAAC,GAAS;AAAA,IACpC,QAAQ;AACN,MAAAC,IAAM,MAAM,MAAMF,GAAK,EAAE,SAAAC,GAAS;AAAA,IACpC;AACA,QAAI,CAACC,EAAI,GAAI,QAAO,CAAA;AACpB,UAAMC,IAAO,MAAMD,EAAI,KAAA;AACvB,WAAIC,EAAK,qBAAkBX,IAAyBW,EAAK,oBACjDA,EAAK,iBAAiB,CAAA,GAAI,KAAK,CAACC,GAAGC,MAAMA,EAAE,YAAYD,EAAE,SAAS;AAAA,EAC5E,GAGME,IAAa,CAAChB,GAA0BiB,MAAkB;AAC9D,QAAIV,EAAW;AACf,UAAMW,IAAWD,IACbjB,EAAQ;AAAA,MAAO,CAAArB,MACbwC,EAAQxC,CAAC,EAAE,cAAc,SAASsC,CAAK,MACtCtC,EAAE,eAAe,IAAI,YAAA,EAAc,SAASsC,CAAK;AAAA,IAAA,IAEpDjB;AAIJ,QAFAJ,EAAK,gBAAA,GAED,CAACsB,EAAS,QAAQ;AACpB,YAAME,IAAQ7C,EAAG,OAAO,aAAa0C,IAAQ,gBAAiB7B,EAAK,SAAS,uBAAwB;AACpG,UAAI,CAAC6B,KAASnC,EAAK,WAAW;AAC5B,QAAAsC,EAAM,OAAO7C,EAAG,IAAI,CAAC;AACrB,cAAM8C,IAAQ9C,EAAG,UAAU,aAAaa,EAAK,WAAW,sBAAsB;AAC9E,QAAAiC,EAAM,iBAAiB,SAAS,MAAMvC,EAAK,WAAY,GACvDsC,EAAM,OAAOC,CAAK;AAAA,MACpB;AACA,MAAAzB,EAAK,OAAOwB,CAAK;AACjB;AAAA,IACF;AAEA,UAAME,IAAW,CAAC3C,OACfA,EAAE,WAAW,MAAMyB,EAAQzB,EAAE,EAAE,KAAK,IAEjC4C,IAASL,EAAS,OAAOI,CAAQ,GACjCE,IAASN,EAAS,OAAO,OAAK,CAACI,EAAS3C,CAAC,CAAC;AAEhD,QAAI4C,EAAO,QAAQ;AACjB,MAAA3B,EAAK,OAAOrB,EAAG,OAAO,eAAe,GAAGa,EAAK,UAAU,QAAQ,KAAKmC,EAAO,MAAM,GAAG,CAAC;AACrF,iBAAW5C,KAAK4C,EAAQ,CAAA3B,EAAK,OAAO6B,EAAS9C,GAAG2C,EAAS3C,CAAC,CAAC,CAAC;AAAA,IAC9D;AACA,QAAI6C,EAAK,QAAQ;AACf,MAAA5B,EAAK,OAAOrB,EAAG,OAAO,eAAegD,EAAO,SAAUnC,EAAK,OAAO,sBAAuB,EAAE,CAAC;AAC5F,iBAAWT,KAAK6C,EAAM,CAAA5B,EAAK,OAAO6B,EAAS9C,GAAG,EAAK,CAAC;AAAA,IACtD;AAAA,EACF,GAEMwC,IAAU,CAACO,MACfA,EAAM,iBAAiBA,EAAM,SAAS,WAAYA,EAAM,UAAU,mBAAoB,oBAElFD,IAAW,CAACC,GAAsBH,MAAiC;;AACvE,UAAMI,IAAOR,EAAQO,CAAK,GAIpBE,OAAW3B,IAAA0B,EAAK,MAAM,WAAA,UAAA,GAAQ,OAAnB,gBAAA1B,EAAuB,OAAM0B,EAAK,KAAA,EAAO,CAAC,KAAK,KAAK,YAAA,GAC/DE,IAAUH,EAAM,WAAW,GAC3BI,IAAcP,IAAS,KAAK,IAAI,GAAGM,KAAWzB,EAAQsB,EAAM,EAAE,KAAK,EAAE,IAAI,GAEzEK,IAAMxD,EAAG,UAAU,UAAUgD,IAAS,YAAY,EAAE,EAAE,GAGtDS,IAAKzD,EAAG,OAAO,UAAUqD,CAAO,GAChCK,IAAM1D,EAAG,OAAO,SAAS;AAC/B,IAAImD,EAAM,UAAU,SAAQO,EAAI,UAAU,IAAI,MAAM,IAC3CP,EAAM,UAAU,oBAAkBO,EAAI,UAAU,IAAI,SAAS,GACtED,EAAG,OAAOC,CAAG,GACbF,EAAI,OAAOC,CAAE;AAGb,UAAME,IAAO3D,EAAG,OAAO,UAAU;AACjC,IAAA2D,EAAK,OAAO3D,EAAG,OAAO,YAAYoD,CAAI,CAAC;AACvC,UAAMQ,IAAmC;AAAA,MACvC,MAAM;AAAA,MAAQ,gBAAgB;AAAA,MAC9B,UAAU;AAAA,MAAc,QAAQ;AAAA,IAAA;AAElC,IAAAD,EAAK,OAAO3D,EAAG,OAAO,eAAemD,EAAM,eAAeS,EAAST,EAAM,KAAK,KAAKA,EAAM,KAAK,CAAC,GAC/FK,EAAI,OAAOG,CAAI;AAGf,UAAME,IAAQ7D,EAAG,OAAO,WAAW;AACnC,WAAA6D,EAAM,OAAO7D,EAAG,OAAO,YAAYL,EAAQwD,EAAM,SAAS,CAAC,CAAC,GACxDI,IAAc,KAChBM,EAAM,OAAO7D,EAAG,OAAO,aAAa,OAAOuD,IAAc,KAAK,QAAQA,CAAW,CAAC,CAAC,GAErFC,EAAI,OAAOK,CAAK,GAEhBL,EAAI,iBAAiB,SAAS,MAAM;;AAElC,MAAIF,IAAU,MAAKzB,EAAQsB,EAAM,EAAE,IAAIG,GAASxB,EAAA,IAChD0B,EAAI,UAAU,OAAO,QAAQ,IAC7B9B,IAAAmC,EAAM,cAAc,YAAY,MAAhC,QAAAnC,EAAmC,UACnCnB,EAAK,SAAS4C,CAAK;AAAA,IACrB,CAAC,GAEMK;AAAA,EACT,GAEMM,IAAU,MAAM;AACpB,IAAI9B,KACJC,EAAA,EAAe,KAAK,CAAAR,MAAW;AAC7B,MAAIO,MACJD,IAAaN,GACbgB,EAAWhB,GAASL,EAAS,MAAM,KAAA,EAAO,aAAa;AAAA,IACzD,CAAC,EAAE,MAAM,CAAC,MAAM;AACd,UAAIY,EAAW;AACf,cAAQ,MAAM,mDAAmDtB,CAAQ,uDAAuD,CAAC;AACjI,YAAMqD,IAAS/D,EAAG,OAAO,aAAaa,EAAK,SAAS,+BAA+B;AACnF,MAAAkD,EAAO,OAAO/D,EAAG,IAAI,CAAC;AACtB,YAAMgE,IAAQhE,EAAG,UAAU,aAAaa,EAAK,SAAS,OAAO;AAC7D,MAAAmD,EAAM,iBAAiB,SAAS,MAAM;AACpC,QAAA3C,EAAK,gBAAgBrB,EAAG,OAAO,eAAe,UAAU,CAAC,GACzD8D,EAAA;AAAA,MACF,CAAC,GACDC,EAAO,OAAOC,CAAK,GACnB3C,EAAK,gBAAgB0C,CAAM;AAAA,IAC7B,CAAC;AAAA,EACH;AAEA,EAAA3C,EAAS,iBAAiB,SAAS,MAAMqB,EAAWV,GAAYX,EAAS,MAAM,OAAO,YAAA,CAAa,CAAC,GAGpG0C,EAAA;AAOA,MAAIG,IAAyB,MACzBC,GACAC,GACAC,IAAU,GACVC,IAAkB;AACtB,QAAMC,IAAmB,MAAM;AAC7B,IAAIH,MACJA,IAAe,WAAW,MAAM;AAAE,MAAAA,IAAe,QAAWL,EAAA;AAAA,IAAU,GAAG,GAAG;AAAA,EAC9E,GACMS,IAAe,MAAM;AACzB,QAAI,CAAAvC,GACJ;AAAA,UAAI;AAAE,QAAAiC,IAAO,IAAI,UAAUtD,CAAK;AAAA,MAAE,QAAQ;AAAE,QAAA6D,EAAA;AAAqB;AAAA,MAAO;AACxE,MAAAP,EAAK,aAAa,eAClBA,EAAK,SAAS,MAAM;AAClB,QAAAG,IAAU,GACVH,EAAM,KAAKQ,EAAY,EAAE,MAAM,QAAQ,OAAAjE,EAAA,CAAO,CAAC,GAC/CyD,EAAM,KAAKQ,EAAY,EAAE,MAAM,kBAAA,CAAmB,CAAC,GAG/CJ,KAAiBC,EAAA,GACrBD,IAAkB;AAAA,MACpB,GACAJ,EAAK,YAAY,CAACS,MAAO;AACvB,cAAMC,IAAQC,EAAY,IAAI,WAAWF,EAAG,IAAmB,CAAC;AAGhE,QAAIC,KAASA,EAAM,SAAS,iBAAeL,EAAA;AAAA,MAC7C,GACAL,EAAK,UAAU,MAAM;AAAE,QAAAA,IAAO,MAAMO,EAAA;AAAA,MAAoB,GACxDP,EAAK,UAAU,MAAM;AAAE,YAAI;AAAE,UAAAA,KAAA,QAAAA,EAAM;AAAA,QAAQ,QAAQ;AAAA,QAAa;AAAA,MAAE;AAAA;AAAA,EACpE,GACMO,IAAoB,MAAM;AAC9B,QAAIxC,KAAakC,EAAgB;AACjC,UAAMW,IAAQ,KAAK,IAAI,MAAQ,MAAM,KAAKT,GAAS,IAAI,KAAK,OAAA,IAAW;AACvE,IAAAF,IAAiB,WAAW,MAAM;AAAE,MAAAA,IAAiB,QAAWK,EAAA;AAAA,IAAe,GAAGM,CAAK;AAAA,EACzF;AACA,SAAAN,EAAA,GAEO;AAAA,IACL,SAAAT;AAAA,IACA,QAAQ;AACN,MAAA9B,IAAY,IACRkC,kBAA6BA,CAAc,GAC3CC,kBAA2BA,CAAY,GAC3C3C,KAAA,QAAAA,EAAiB,cACjBA,IAAkB;AAClB,UAAI;AAAE,QAAAyC,KAAA,QAAAA,EAAM;AAAA,MAAQ,QAAQ;AAAA,MAAa;AACzC,MAAAA,IAAO,MACP1D,EAAK,GAAG,gBAAA;AAAA,IACV;AAAA,IACA,mBAAmB;AAAE,aAAOA,EAAK,aAAaoB;AAAA,IAAuB;AAAA,EAAA;AAEzE;"}