@paramms/chat-widget 1.0.42 → 1.0.43

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.
@@ -78,6 +78,10 @@ export interface ChatListOptions {
78
78
  * wire it to open a fresh/general thread. Without it a user with no
79
79
  * conversations yet has nothing to tap. */
80
80
  onNewChat?: () => void;
81
+ /** Set when the CALLER draws its own close (✕) control overlaying the list's
82
+ * top-right corner — reserves header space so it doesn't sit on top of the
83
+ * ✎ compose button. */
84
+ reserveCloseSpace?: boolean;
81
85
  /** Brand colour hex — default '#4F63F5' */
82
86
  accent?: string;
83
87
  /** i18n overrides */
package/dist/chatlist.js CHANGED
@@ -49,6 +49,10 @@ const ee = `
49
49
  .ocl-spinner { padding:24px; text-align:center; color:var(--ocl-mut); font-size:13px; }
50
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; }
51
51
  .ocl-compose:hover { background:var(--ocl-line); }
52
+ /* When the HOST overlays a ✕ (ChatApp's onClose / embed's inbox stack), it is
53
+ absolutely positioned at top-right and used to land straight on top of the ✎
54
+ compose button. Reserve the space instead of stacking them. */
55
+ .ocl-has-close .ocl-head { padding-right:52px; }
52
56
  .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; }
53
57
  /* Compact sizing keyed on the LIST'S OWN width (ResizeObserver toggles
54
58
  * .ocl-compact below 400px) — covers phones and narrow desktop embeds alike.
@@ -58,30 +62,30 @@ const ee = `
58
62
  .ocl-compact .ocl-compose { width:36px; height:36px; }
59
63
  `;
60
64
  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";
65
+ const a = o.token ?? o.userId ?? V(), { httpBase: p, wsUrl: x } = Q(o.url, o.apiUrl), f = o.i18n ?? {}, H = o.accent ?? "#4F63F5";
62
66
  if (!document.getElementById("ocl-styles")) {
63
67
  const e = document.createElement("style");
64
68
  e.id = "ocl-styles", e.textContent = ee, document.head.append(e);
65
69
  }
66
70
  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) {
71
+ h.style.setProperty("--ocl-accent", H), o.reserveCloseSpace && h.classList.add("ocl-has-close");
72
+ const L = t("div", "ocl-head"), C = t("span", "ocl-title");
73
+ if (C.setAttribute("role", "img"), C.setAttribute("aria-label", f.title ?? "Messages"), C.innerHTML = Z, L.append(C), o.onNewChat) {
70
74
  const e = t("button", "ocl-compose", "✎");
71
- e.title = f.newChat ?? "New conversation", e.addEventListener("click", () => o.onNewChat()), z.append(e);
75
+ e.title = f.newChat ?? "New conversation", e.addEventListener("click", () => o.onNewChat()), L.append(e);
72
76
  }
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);
75
- const d = t("div", "ocl-body");
76
- d.append(t("div", "ocl-spinner", "Loading…")), h.append(z, M, d), o.el.replaceChildren(h);
77
- const R = (e) => {
77
+ const R = t("div", "ocl-search-wrap"), m = t("input", "ocl-search");
78
+ m.placeholder = f.search ?? "🔍 Search", m.type = "search", R.append(m);
79
+ const s = t("div", "ocl-body");
80
+ s.append(t("div", "ocl-spinner", "Loading…")), h.append(L, R, s), o.el.replaceChildren(h);
81
+ const M = (e) => {
78
82
  h.classList.toggle("ocl-compact", e > 0 && e < 400);
79
83
  };
80
- R(h.clientWidth);
84
+ M(h.clientWidth);
81
85
  let v = null;
82
86
  if (typeof ResizeObserver < "u" && (v = new ResizeObserver((e) => {
83
87
  var r;
84
- return R(((r = e[0]) == null ? void 0 : r.contentRect.width) ?? h.clientWidth);
88
+ return M(((r = e[0]) == null ? void 0 : r.contentRect.width) ?? h.clientWidth);
85
89
  }), v.observe(h)), !o.profileId && !o.tenantId) throw new Error("[relay chatlist] provide profileId or tenantId");
86
90
  let N;
87
91
  const E = `ocl_seen_${o.profileId ?? `t_${o.tenantId}`}_${(o.userId ?? a).slice(-8)}`;
@@ -90,52 +94,52 @@ function te(o) {
90
94
  b = JSON.parse(localStorage.getItem(E) ?? "{}");
91
95
  } catch {
92
96
  }
93
- const H = () => {
97
+ const G = () => {
94
98
  try {
95
99
  localStorage.setItem(E, JSON.stringify(b));
96
100
  } catch {
97
101
  }
98
102
  };
99
- let U = [], g = !1;
103
+ let T = [], g = !1;
100
104
  const J = async () => {
101
105
  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}` };
102
- let s;
106
+ let d;
103
107
  try {
104
- s = await fetch(r, { headers: i });
108
+ d = await fetch(r, { headers: i });
105
109
  } catch {
106
- s = await fetch(r, { headers: i });
110
+ d = await fetch(r, { headers: i });
107
111
  }
108
- if (!s.ok) return [];
109
- const l = await s.json();
112
+ if (!d.ok) return [];
113
+ const l = await d.json();
110
114
  return l.defaultProfileId && (N = l.defaultProfileId), (l.conversations ?? []).sort((u, n) => n.updatedAt - u.updatedAt);
111
- }, T = (e, r) => {
115
+ }, U = (e, r) => {
112
116
  if (g) return;
113
117
  const i = r ? e.filter(
114
- (n) => _(n).toLowerCase().includes(r) || (n.lastMessage ?? "").toLowerCase().includes(r)
118
+ (n) => A(n).toLowerCase().includes(r) || (n.lastMessage ?? "").toLowerCase().includes(r)
115
119
  ) : e;
116
- if (d.replaceChildren(), !i.length) {
120
+ if (s.replaceChildren(), !i.length) {
117
121
  const n = t("div", "ocl-empty", r ? "No results." : f.empty ?? "No conversations yet.");
118
122
  if (!r && o.onNewChat) {
119
123
  n.append(t("br"));
120
124
  const k = t("button", "ocl-start", f.newChat ?? "Start a conversation");
121
125
  k.addEventListener("click", () => o.onNewChat()), n.append(k);
122
126
  }
123
- d.append(n);
127
+ s.append(n);
124
128
  return;
125
129
  }
126
- const s = (n) => (n.lastSeq ?? 0) > (b[n.id] ?? 0), l = i.filter(s), u = i.filter((n) => !s(n));
130
+ const d = (n) => (n.lastSeq ?? 0) > (b[n.id] ?? 0), l = i.filter(d), u = i.filter((n) => !d(n));
127
131
  if (l.length) {
128
- d.append(t("div", "ocl-section", `${f.unread ?? "Unread"} (${l.length})`));
129
- for (const n of l) d.append(A(n, s(n)));
132
+ s.append(t("div", "ocl-section", `${f.unread ?? "Unread"} (${l.length})`));
133
+ for (const n of l) s.append(O(n, d(n)));
130
134
  }
131
135
  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));
136
+ s.append(t("div", "ocl-section", l.length ? f.all ?? "All conversations" : ""));
137
+ for (const n of u) s.append(O(n, !1));
134
138
  }
135
- }, _ = (e) => e.subjectTitle ?? (e.kind === "direct" ? e.peerId ?? "Direct message" : "General enquiry"), A = (e, r) => {
139
+ }, A = (e) => e.subjectTitle ?? (e.kind === "direct" ? e.peerId ?? "Direct message" : "General enquiry"), O = (e, r) => {
136
140
  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);
141
+ const i = A(e), d = (((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", d), z = t("div", "ocl-dot");
142
+ e.state === "open" ? z.classList.add("open") : e.state === "awaiting_staff" && z.classList.add("waiting"), k.append(z), n.append(k);
139
143
  const $ = t("div", "ocl-info");
140
144
  $.append(t("div", "ocl-name", i));
141
145
  const K = {
@@ -148,11 +152,11 @@ function te(o) {
148
152
  const S = t("div", "ocl-right");
149
153
  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
154
  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);
155
+ l > 0 && (b[e.id] = l, G()), n.classList.remove("unread"), (P = S.querySelector(".ocl-badge")) == null || P.remove(), o.onSelect(e);
152
156
  }), n;
153
157
  }, I = () => {
154
158
  g || J().then((e) => {
155
- g || (U = e, T(e, m.value.trim().toLowerCase()));
159
+ g || (T = e, U(e, m.value.trim().toLowerCase()));
156
160
  }).catch((e) => {
157
161
  if (g) return;
158
162
  console.error(`[chat-widget] failed to load conversations from ${p}/conversations/mine — check the apiUrl/CORS config.`, e);
@@ -160,17 +164,17 @@ function te(o) {
160
164
  r.append(t("br"));
161
165
  const i = t("button", "ocl-retry", f.retry ?? "Retry");
162
166
  i.addEventListener("click", () => {
163
- d.replaceChildren(t("div", "ocl-spinner", "Loading…")), I();
164
- }), r.append(i), d.replaceChildren(r);
167
+ s.replaceChildren(t("div", "ocl-spinner", "Loading…")), I();
168
+ }), r.append(i), s.replaceChildren(r);
165
169
  });
166
170
  };
167
- m.addEventListener("input", () => T(U, m.value.trim().toLowerCase())), I();
168
- let c = null, w, y, O = 0, B = !1;
169
- const j = () => {
171
+ m.addEventListener("input", () => U(T, m.value.trim().toLowerCase())), I();
172
+ let c = null, w, y, _ = 0, B = !1;
173
+ const W = () => {
170
174
  y || (y = setTimeout(() => {
171
175
  y = void 0, I();
172
176
  }, 300));
173
- }, W = () => {
177
+ }, j = () => {
174
178
  if (!g) {
175
179
  try {
176
180
  c = new WebSocket(x);
@@ -179,10 +183,10 @@ function te(o) {
179
183
  return;
180
184
  }
181
185
  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;
186
+ _ = 0, c.send(q({ type: "auth", token: a })), c.send(q({ type: "subscribe_inbox" })), B && W(), B = !0;
183
187
  }, c.onmessage = (e) => {
184
188
  const r = X(new Uint8Array(e.data));
185
- r && r.type === "inbox_event" && j();
189
+ r && r.type === "inbox_event" && W();
186
190
  }, c.onclose = () => {
187
191
  c = null, D();
188
192
  }, c.onerror = () => {
@@ -194,12 +198,12 @@ function te(o) {
194
198
  }
195
199
  }, D = () => {
196
200
  if (g || w) return;
197
- const e = Math.min(15e3, 500 * 2 ** O++) + Math.random() * 250;
201
+ const e = Math.min(15e3, 500 * 2 ** _++) + Math.random() * 250;
198
202
  w = setTimeout(() => {
199
- w = void 0, W();
203
+ w = void 0, j();
200
204
  }, e);
201
205
  };
202
- return W(), {
206
+ return j(), {
203
207
  refresh: I,
204
208
  close() {
205
209
  g = !0, w && clearTimeout(w), y && clearTimeout(y), v == null || v.disconnect(), v = null;
@@ -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'\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":"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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 /** Set when the CALLER draws its own close (✕) control overlaying the list's\n * top-right corner — reserves header space so it doesn't sit on top of the\n * ✎ compose button. */\n reserveCloseSpace?: boolean\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/* When the HOST overlays a ✕ (ChatApp's onClose / embed's inbox stack), it is\n absolutely positioned at top-right and used to land straight on top of the ✎\n compose button. Reserve the space instead of stacking them. */\n.ocl-has-close .ocl-head { padding-right:52px; }\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 if (opts.reserveCloseSpace) root.classList.add('ocl-has-close')\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":"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"}