@paramms/chat-widget 1.0.31 → 1.0.33

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
@@ -16,7 +16,7 @@ npm install @paramms/chat-widget
16
16
  import { mount } from 'https://relay.paramms.com/index.js'
17
17
  mount({
18
18
  el: document.getElementById('chat'),
19
- url: 'wss://api.paramms.com/ws',
19
+ url: 'https://api.relay.paramms.com', // ONE url, any scheme — ws + REST derived
20
20
  profileId: 'YOUR_PROFILE_ID',
21
21
  })
22
22
  </script>
@@ -33,7 +33,7 @@ import { ChatWidget } from '@paramms/chat-widget/react'
33
33
  export default function SupportPage({ session }) {
34
34
  return (
35
35
  <ChatWidget
36
- url={process.env.NEXT_PUBLIC_RELAY_WS_URL}
36
+ url={process.env.NEXT_PUBLIC_RELAY_URL}
37
37
  profileId={process.env.NEXT_PUBLIC_RELAY_PROFILE_ID}
38
38
  userId={session?.user.id} // optional — anonymous if omitted
39
39
  userName={session?.user.name} // optional — shown to agents
@@ -60,7 +60,7 @@ export default function ListingPage({ car, session }) {
60
60
  <YourPageContent />
61
61
 
62
62
  <MarketplaceChat
63
- url={process.env.NEXT_PUBLIC_RELAY_WS_URL}
63
+ url={process.env.NEXT_PUBLIC_RELAY_URL}
64
64
  profileId={process.env.NEXT_PUBLIC_RELAY_PROFILE_ID}
65
65
  listingId={car.id}
66
66
  listingTitle={car.title} // shown in chat header
@@ -77,25 +77,32 @@ export default function ListingPage({ car, session }) {
77
77
  }
78
78
  ```
79
79
 
80
- **On a /messages page** (without `listingId`)shows the buyer's full thread list (WhatsApp-style). No conversation opens until they tap one.
80
+ **Without `listingId`**opens the buyer's single general (non-listing) thread.
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):
81
83
 
82
84
  ```tsx
83
85
  // Dedicated inbox page — e.g. /messages
86
+ import { ChatApp } from '@paramms/chat-widget/react'
87
+
84
88
  export default function MessagesPage({ session }) {
85
89
  return (
86
90
  <div style={{ height: '600px' }}>
87
- <MarketplaceChat
88
- url={process.env.NEXT_PUBLIC_RELAY_WS_URL}
91
+ <ChatApp
92
+ url={process.env.NEXT_PUBLIC_RELAY_URL}
89
93
  profileId={process.env.NEXT_PUBLIC_RELAY_PROFILE_ID}
90
94
  userId={session?.user.id}
91
95
  userName={session?.user.name}
92
- // no listingId = inbox mode, shows all threads
93
96
  />
94
97
  </div>
95
98
  )
96
99
  }
97
100
  ```
98
101
 
102
+ Prefer a floating bubble that opens the same app in a panel? `<ChatAppLauncher … floating />`.
103
+
104
+ > **Multi-tenancy note:** there is no `tenantId` prop anywhere — the server resolves your tenant *from* `profileId` (the profile record carries its owning tenant, and every conversation/list query is keyed under it server-side). Guests can't spoof it, and `scope="tenant"` only widens the list to *your* chatrooms, never another business's.
105
+
99
106
  ### Old section below (kept for reference)
100
107
 
101
108
  ```tsx
@@ -105,7 +112,7 @@ import { ChatWidget } from '@paramms/chat-widget/react'
105
112
  export default function SupportChat() {
106
113
  return (
107
114
  <ChatWidget
108
- url="wss://api.paramms.com/ws"
115
+ url="https://api.relay.paramms.com"
109
116
  profileId="YOUR_PROFILE_ID"
110
117
  />
111
118
  )
@@ -118,7 +125,7 @@ Pass your own user's ID as the token and their details via `user`. Anonymous use
118
125
 
119
126
  ```tsx
120
127
  <ChatWidget
121
- url="wss://api.paramms.com/ws"
128
+ url="https://api.relay.paramms.com"
122
129
  profileId="YOUR_PROFILE_ID"
123
130
  token={currentUser.id} // your own stable user ID — ties history across devices
124
131
  user={{
@@ -157,7 +164,7 @@ const signedToken = `${hdr}.${pay}.${sig}`
157
164
 
158
165
  ```tsx
159
166
  <ChatWidget
160
- url="wss://api.paramms.com/ws"
167
+ url="https://api.relay.paramms.com"
161
168
  profileId="YOUR_PROFILE_ID"
162
169
  subjectId={`car_${listing.id}`} // one conversation per item
163
170
  showChatList={true} // guest can switch between their threads
@@ -168,7 +175,7 @@ const signedToken = `${hdr}.${pay}.${sig}`
168
175
 
169
176
  ```tsx
170
177
  <ChatWidget
171
- url="wss://api.paramms.com/ws"
178
+ url="https://api.relay.paramms.com"
172
179
  profileId="YOUR_PROFILE_ID"
173
180
  launcher={true}
174
181
  position="bottom-right"
@@ -180,7 +187,7 @@ const signedToken = `${hdr}.${pay}.${sig}`
180
187
 
181
188
  ```tsx
182
189
  <ChatWidget
183
- url="wss://api.paramms.com/ws"
190
+ url="https://api.relay.paramms.com"
184
191
  profileId="YOUR_PROFILE_ID"
185
192
  i18n={{
186
193
  placeholder: 'Écrivez un message…',
@@ -198,7 +205,7 @@ RTL is detected automatically for Arabic, Hebrew, Persian and Urdu browsers.
198
205
  | Option | Type | Default | Description |
199
206
  |---|---|---|---|
200
207
  | `el` | `HTMLElement` | required | Mount target |
201
- | `url` | `string` | required | WebSocket URL (`wss://...`) |
208
+ | `url` | `string` | required | Relay URL — ONE url, any scheme (`https://api.relay.paramms.com`); the WebSocket URL and REST base are derived |
202
209
  | `profileId` | `string` | required | Domain profile ID |
203
210
  | `token` | `string` | auto-generated | Guest identity token — pass your user's stable ID to tie history across devices |
204
211
  | `user` | `UserInfo` | — | Name, email, avatar, custom metadata — shown to agents |
@@ -10,7 +10,7 @@
10
10
  * import { mountChatList } from '@paramms/chat-widget/chatlist'
11
11
  * const handle = mountChatList({
12
12
  * el: document.getElementById('chat-list'),
13
- * url: 'wss://api.paramms.com/ws',
13
+ * url: 'https://api.relay.paramms.com', // ONE url, any scheme
14
14
  * profileId: 'p_usedcars',
15
15
  * userId: currentUser.id, // optional — uses localStorage UID if omitted
16
16
  * onSelect: (entry) => {
@@ -22,6 +22,14 @@
22
22
  */
23
23
  export interface ChatListEntry {
24
24
  id: string;
25
+ /** Chatroom this conversation belongs to. With `scope: 'tenant'` this can
26
+ * differ from the profileId the list was mounted with — open the chat
27
+ * against THIS profileId. */
28
+ profileId?: string;
29
+ /** 'support' (default) or 'direct' (user↔user DM). */
30
+ kind?: string;
31
+ /** For direct conversations: the other participant's user id. */
32
+ peerId?: string;
25
33
  subjectId?: string;
26
34
  subjectTitle?: string;
27
35
  /** One-line detail — e.g. "45,000 km · Auto" */
@@ -36,17 +44,32 @@ export interface ChatListEntry {
36
44
  export interface ChatListOptions {
37
45
  /** Mount target element */
38
46
  el: HTMLElement;
39
- /** Relay WebSocket URL — e.g. wss://api.paramms.com/ws */
47
+ /** Relay URL — ONE url, any scheme (https recommended). The WebSocket URL
48
+ * and REST base are derived automatically. */
40
49
  url: string;
41
- /** HTTP(S) base for the REST API. Provide when the REST API is on a different
42
- * host/path than the WebSocket. Defaults to the WS origin with `/ws` stripped. */
50
+ /** HTTP(S) base for REST only when REST is on a different origin.
51
+ * @deprecated pass a single `url`; kept for back-compat. */
43
52
  apiUrl?: string;
44
53
  /** Profile ID to scope conversations to */
45
54
  profileId: string;
55
+ /** A signed identity token (ES256 JWT) — the production identity tier for
56
+ * chatrooms with signed identity enabled. Wins over `userId`. */
57
+ token?: string;
46
58
  /** Your logged-in user's stable ID. Omit for anonymous (uses localStorage UID) */
47
59
  userId?: string;
60
+ /** Which conversations to list (default 'profile'):
61
+ * 'profile' — only this chatroom's threads.
62
+ * 'tenant' — every conversation this user has with the chatroom's owning
63
+ * business, across ALL of its chatrooms (a real chat-app inbox). Rows
64
+ * carry `profileId` so each opens against the right chatroom. */
65
+ scope?: 'profile' | 'tenant';
48
66
  /** Called when the user taps a conversation row */
49
67
  onSelect: (entry: ChatListEntry) => void;
68
+ /** When provided, the list shows a ✎ compose button in the header (and a
69
+ * "Start a conversation" button in the empty state) that calls this —
70
+ * wire it to open a fresh/general thread. Without it a user with no
71
+ * conversations yet has nothing to tap. */
72
+ onNewChat?: () => void;
50
73
  /** Brand colour hex — default '#4F63F5' */
51
74
  accent?: string;
52
75
  /** i18n overrides */
@@ -57,6 +80,7 @@ export interface ChatListOptions {
57
80
  unread?: string;
58
81
  all?: string;
59
82
  error?: string;
83
+ newChat?: string;
60
84
  };
61
85
  }
62
86
  export interface ChatListHandle {
package/dist/chatlist.js CHANGED
@@ -1,13 +1,13 @@
1
- import { p as q, r as A } from "./uid.js";
2
- function N(n) {
3
- const a = Math.floor((Date.now() - n) / 1e3);
4
- return a < 60 ? "just now" : a < 3600 ? `${Math.floor(a / 60)}m` : a < 86400 ? `${Math.floor(a / 3600)}h` : `${Math.floor(a / 86400)}d`;
1
+ import { p as A, r as _ } from "./uid.js";
2
+ function B(n) {
3
+ const r = Math.floor((Date.now() - n) / 1e3);
4
+ return r < 60 ? "just now" : r < 3600 ? `${Math.floor(r / 60)}m` : r < 86400 ? `${Math.floor(r / 3600)}h` : `${Math.floor(r / 86400)}d`;
5
5
  }
6
- function o(n, a, u) {
7
- const r = document.createElement(n);
8
- return a && (r.className = a), u !== void 0 && (r.textContent = u), r;
6
+ function t(n, r, u) {
7
+ const a = document.createElement(n);
8
+ return r && (a.className = r), u !== void 0 && (a.textContent = u), a;
9
9
  }
10
- const _ = `
10
+ const O = `
11
11
  .ocl { --ocl-accent:#f5713c; --ocl-bg:#f3efe9; --ocl-card:#fff; --ocl-line:#ececec; --ocl-ink:#1c1b1a; --ocl-mut:#9b9690;
12
12
  display:flex; flex-direction:column; height:100%; background:var(--ocl-bg);
13
13
  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; color:var(--ocl-ink); overflow:hidden; }
@@ -35,90 +35,102 @@ const _ = `
35
35
  .ocl-row.unread .ocl-time { color:var(--ocl-accent); font-weight:600; }
36
36
  .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; }
37
37
  .ocl-spinner { padding:24px; text-align:center; color:var(--ocl-mut); font-size:13px; }
38
+ .ocl-compose { border:none; background:var(--ocl-bg); color:var(--ocl-ink); width:32px; height:32px; border-radius:50%; font-size:15px; cursor:pointer; }
39
+ .ocl-compose:hover { background:var(--ocl-line); }
40
+ .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; }
38
41
  @media (max-width:480px) { .ocl-row { padding:12px 14px; } .ocl-name { font-size:14px; } }
39
42
  `;
40
- function O(n) {
41
- const a = n.userId ?? q(), { httpBase: u } = A(n.url, n.apiUrl), r = n.i18n ?? {}, U = n.accent ?? "#4F63F5";
43
+ function F(n) {
44
+ const r = n.token ?? n.userId ?? A(), { httpBase: u } = _(n.url, n.apiUrl), a = n.i18n ?? {}, E = n.accent ?? "#4F63F5";
42
45
  if (!document.getElementById("ocl-styles")) {
43
46
  const e = document.createElement("style");
44
- e.id = "ocl-styles", e.textContent = _.replace(/#f5713c/g, U), document.head.append(e);
47
+ e.id = "ocl-styles", e.textContent = O.replace(/#f5713c/g, E), document.head.append(e);
45
48
  }
46
- const w = o("div", "ocl"), b = o("div", "ocl-head");
47
- b.append(o("span", "ocl-title", r.title ?? "Messages"));
48
- const y = o("div", "ocl-search-wrap"), p = o("input", "ocl-search");
49
- p.placeholder = r.search ?? "🔍 Search", p.type = "search", y.append(p);
50
- const c = o("div", "ocl-body");
51
- c.append(o("div", "ocl-spinner", "Loading…")), w.append(b, y, c), n.el.replaceChildren(w);
52
- const k = `ocl_seen_${n.profileId}_${a.slice(-8)}`;
49
+ const k = t("div", "ocl"), m = t("div", "ocl-head");
50
+ if (m.append(t("span", "ocl-title", a.title ?? "Messages")), n.onNewChat) {
51
+ const e = t("button", "ocl-compose", "");
52
+ e.title = a.newChat ?? "New conversation", e.addEventListener("click", () => n.onNewChat()), m.append(e);
53
+ }
54
+ const C = t("div", "ocl-search-wrap"), p = t("input", "ocl-search");
55
+ p.placeholder = a.search ?? "🔍 Search", p.type = "search", C.append(p);
56
+ const c = t("div", "ocl-body");
57
+ c.append(t("div", "ocl-spinner", "Loading…")), k.append(m, C, c), n.el.replaceChildren(k);
58
+ const y = `ocl_seen_${n.profileId}_${(n.userId ?? r).slice(-8)}`;
53
59
  let g = {};
54
60
  try {
55
- g = JSON.parse(localStorage.getItem(k) ?? "{}");
61
+ g = JSON.parse(localStorage.getItem(y) ?? "{}");
56
62
  } catch {
57
63
  }
58
- const E = () => {
64
+ const U = () => {
59
65
  try {
60
- localStorage.setItem(k, JSON.stringify(g));
66
+ localStorage.setItem(y, JSON.stringify(g));
61
67
  } catch {
62
68
  }
63
69
  };
64
70
  let S = [], h = !1;
65
- const j = async () => {
71
+ const R = async () => {
66
72
  const e = await fetch(
67
- `${u}/conversations/mine?profileId=${encodeURIComponent(n.profileId)}`,
68
- { headers: { authorization: `Bearer ${a}` } }
73
+ `${u}/conversations/mine?profileId=${encodeURIComponent(n.profileId)}${n.scope === "tenant" ? "&scope=tenant" : ""}`,
74
+ { headers: { authorization: `Bearer ${r}` } }
69
75
  );
70
76
  return e.ok ? ((await e.json()).conversations ?? []).sort((l, s) => s.updatedAt - l.updatedAt) : [];
71
- }, C = (e, i) => {
77
+ }, z = (e, i) => {
72
78
  if (h) return;
73
79
  const l = i ? e.filter(
74
- (t) => (t.subjectTitle ?? "General enquiry").toLowerCase().includes(i) || (t.lastMessage ?? "").toLowerCase().includes(i)
80
+ (o) => L(o).toLowerCase().includes(i) || (o.lastMessage ?? "").toLowerCase().includes(i)
75
81
  ) : e;
76
82
  if (c.replaceChildren(), !l.length) {
77
- c.append(o("div", "ocl-empty", i ? "No results." : r.empty ?? "No conversations yet."));
83
+ const o = t("div", "ocl-empty", i ? "No results." : a.empty ?? "No conversations yet.");
84
+ if (!i && n.onNewChat) {
85
+ o.append(t("br"));
86
+ const x = t("button", "ocl-start", a.newChat ?? "Start a conversation");
87
+ x.addEventListener("click", () => n.onNewChat()), o.append(x);
88
+ }
89
+ c.append(o);
78
90
  return;
79
91
  }
80
- const s = (t) => (t.lastSeq ?? 0) > (g[t.id] ?? 0), d = l.filter(s), f = l.filter((t) => !s(t));
92
+ const s = (o) => (o.lastSeq ?? 0) > (g[o.id] ?? 0), d = l.filter(s), f = l.filter((o) => !s(o));
81
93
  if (d.length) {
82
- c.append(o("div", "ocl-section", `${r.unread ?? "Unread"} (${d.length})`));
83
- for (const t of d) c.append(z(t, s(t)));
94
+ c.append(t("div", "ocl-section", `${a.unread ?? "Unread"} (${d.length})`));
95
+ for (const o of d) c.append($(o, s(o)));
84
96
  }
85
97
  if (f.length) {
86
- c.append(o("div", "ocl-section", d.length ? r.all ?? "All conversations" : ""));
87
- for (const t of f) c.append(z(t, !1));
98
+ c.append(t("div", "ocl-section", d.length ? a.all ?? "All conversations" : ""));
99
+ for (const o of f) c.append($(o, !1));
88
100
  }
89
- }, z = (e, i) => {
90
- var I;
91
- const l = e.subjectTitle ?? "General enquiry", s = ((I = l[0]) == null ? void 0 : I.toUpperCase()) ?? "?", d = e.lastSeq ?? 0, f = i ? Math.max(1, d - (g[e.id] ?? 0)) : 0, t = o("button", `ocl-row${i ? " unread" : ""}`), L = o("div", "ocl-av", s), v = o("div", "ocl-dot");
92
- e.state === "open" ? v.classList.add("open") : e.state === "awaiting_staff" && v.classList.add("waiting"), L.append(v), t.append(L);
93
- const m = o("div", "ocl-info");
94
- m.append(o("div", "ocl-name", l));
95
- const R = {
101
+ }, L = (e) => e.subjectTitle ?? (e.kind === "direct" ? e.peerId ?? "Direct message" : "General enquiry"), $ = (e, i) => {
102
+ var N;
103
+ const l = L(e), s = ((N = l[0]) == null ? void 0 : N.toUpperCase()) ?? "?", d = e.lastSeq ?? 0, f = i ? Math.max(1, d - (g[e.id] ?? 0)) : 0, o = t("button", `ocl-row${i ? " unread" : ""}`), x = t("div", "ocl-av", s), w = t("div", "ocl-dot");
104
+ e.state === "open" ? w.classList.add("open") : e.state === "awaiting_staff" && w.classList.add("waiting"), x.append(w), o.append(x);
105
+ const b = t("div", "ocl-info");
106
+ b.append(t("div", "ocl-name", l));
107
+ const j = {
96
108
  open: "Open",
97
109
  awaiting_staff: "Waiting for reply…",
98
110
  resolved: "Resolved ✓",
99
111
  closed: "Closed"
100
112
  };
101
- m.append(o("div", "ocl-preview", e.lastMessage ?? R[e.state] ?? e.state)), t.append(m);
102
- const x = o("div", "ocl-right");
103
- return x.append(o("div", "ocl-time", N(e.updatedAt))), f > 0 && x.append(o("div", "ocl-badge", String(f > 99 ? "99+" : f))), t.append(x), t.addEventListener("click", () => {
113
+ b.append(t("div", "ocl-preview", e.lastMessage ?? j[e.state] ?? e.state)), o.append(b);
114
+ const v = t("div", "ocl-right");
115
+ return v.append(t("div", "ocl-time", B(e.updatedAt))), f > 0 && v.append(t("div", "ocl-badge", String(f > 99 ? "99+" : f))), o.append(v), o.addEventListener("click", () => {
104
116
  var M;
105
- d > 0 && (g[e.id] = d, E()), t.classList.remove("unread"), (M = x.querySelector(".ocl-badge")) == null || M.remove(), n.onSelect(e);
106
- }), t;
107
- }, $ = () => {
108
- h || j().then((e) => {
109
- h || (S = e, C(e, p.value.trim().toLowerCase()));
117
+ d > 0 && (g[e.id] = d, U()), o.classList.remove("unread"), (M = v.querySelector(".ocl-badge")) == null || M.remove(), n.onSelect(e);
118
+ }), o;
119
+ }, I = () => {
120
+ h || R().then((e) => {
121
+ h || (S = e, z(e, p.value.trim().toLowerCase()));
110
122
  }).catch((e) => {
111
- h || (console.error(`[chat-widget] failed to load conversations from ${u}/conversations/mine — check the apiUrl/CORS config.`, e), c.replaceChildren(o("div", "ocl-empty", r.error ?? "Could not load conversations.")));
123
+ h || (console.error(`[chat-widget] failed to load conversations from ${u}/conversations/mine — check the apiUrl/CORS config.`, e), c.replaceChildren(t("div", "ocl-empty", a.error ?? "Could not load conversations.")));
112
124
  });
113
125
  };
114
- return p.addEventListener("input", () => C(S, p.value.trim().toLowerCase())), $(), {
115
- refresh: $,
126
+ return p.addEventListener("input", () => z(S, p.value.trim().toLowerCase())), I(), {
127
+ refresh: I,
116
128
  close() {
117
129
  h = !0, n.el.replaceChildren();
118
130
  }
119
131
  };
120
132
  }
121
133
  export {
122
- O as mountChatList
134
+ F as mountChatList
123
135
  };
124
136
  //# 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: 'wss://api.paramms.com/ws',\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 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 WebSocket URL — e.g. wss://api.paramms.com/ws */\n url: string\n /** HTTP(S) base for the REST API. Provide when the REST API is on a different\n * host/path than the WebSocket. Defaults to the WS origin with `/ws` stripped. */\n apiUrl?: string\n /** Profile ID to scope conversations to */\n profileId: string\n /** Your logged-in user's stable ID. Omit for anonymous (uses localStorage UID) */\n userId?: string\n /** Called when the user taps a conversation row */\n onSelect: (entry: ChatListEntry) => 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 }\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}\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\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; font-size:18px; font-weight:700; }\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@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.userId ?? persistentUid()\n const { httpBase } = resolveRelayUrls(opts.url, opts.apiUrl)\n const i18n = opts.i18n ?? {}\n const accent = opts.accent ?? '#4F63F5'\n\n // Inject CSS once\n if (!document.getElementById('ocl-styles')) {\n const s = document.createElement('style'); s.id = 'ocl-styles'\n s.textContent = CSS.replace(/#f5713c/g, accent)\n document.head.append(s)\n }\n\n // Build DOM\n const root = el('div', 'ocl')\n const head = el('div', 'ocl-head')\n head.append(el('span', 'ocl-title', i18n.title ?? 'Messages'))\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 const seenKey = `ocl_seen_${opts.profileId}_${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 res = await fetch(\n `${httpBase}/conversations/mine?profileId=${encodeURIComponent(opts.profileId)}`,\n { headers: { authorization: `Bearer ${token}` } },\n )\n if (!res.ok) return []\n const data = await res.json() as { conversations?: ChatListEntry[] }\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 (e.subjectTitle ?? 'General enquiry').toLowerCase().includes(query) ||\n (e.lastMessage ?? '').toLowerCase().includes(query)\n )\n : entries\n\n body.replaceChildren()\n\n if (!filtered.length) {\n body.append(el('div', 'ocl-empty', query ? 'No results.' : (i18n.empty ?? 'No conversations yet.')))\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 buildRow = (entry: ChatListEntry, unread: boolean): HTMLElement => {\n const name = entry.subjectTitle ?? 'General enquiry'\n const initial = name[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 body.replaceChildren(el('div', 'ocl-empty', i18n.error ?? 'Could not load conversations.'))\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 }\n}\n"],"names":["timeAgo","ts","s","el","tag","cls","text","e","CSS","mountChatList","opts","token","persistentUid","httpBase","resolveRelayUrls","i18n","accent","root","head","searchWrap","searchIn","body","seenKey","seenSeq","saveSeenSeq","allEntries","destroyed","fetchEntries","res","a","b","renderRows","entries","query","filtered","isUnread","unread","read","buildRow","entry","name","initial","_a","lastSeq","unreadCount","row","av","dot","info","stateMap","right","refresh"],"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'\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 /** 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 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}\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\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; font-size:18px; font-weight:700; }\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 CSS once\n if (!document.getElementById('ocl-styles')) {\n const s = document.createElement('style'); s.id = 'ocl-styles'\n s.textContent = CSS.replace(/#f5713c/g, accent)\n document.head.append(s)\n }\n\n // Build DOM\n const root = el('div', 'ocl')\n const head = el('div', 'ocl-head')\n head.append(el('span', 'ocl-title', i18n.title ?? 'Messages'))\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 const seenKey = `ocl_seen_${opts.profileId}_${(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 res = await fetch(\n `${httpBase}/conversations/mine?profileId=${encodeURIComponent(opts.profileId)}${opts.scope === 'tenant' ? '&scope=tenant' : ''}`,\n { headers: { authorization: `Bearer ${token}` } },\n )\n if (!res.ok) return []\n const data = await res.json() as { conversations?: ChatListEntry[] }\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 const initial = name[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 body.replaceChildren(el('div', 'ocl-empty', i18n.error ?? 'Could not load conversations.'))\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 }\n}\n"],"names":["timeAgo","ts","s","el","tag","cls","text","e","CSS","mountChatList","opts","token","persistentUid","httpBase","resolveRelayUrls","i18n","accent","root","head","compose","searchWrap","searchIn","body","seenKey","seenSeq","saveSeenSeq","allEntries","destroyed","fetchEntries","res","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"],"mappings":"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"}
package/dist/core.d.ts CHANGED
@@ -9,8 +9,14 @@ export * from './protocol/index.js';
9
9
  import { ChatStore } from './store.js';
10
10
  import type { ConversationId, UserId } from './protocol/index.js';
11
11
  export interface RelayClientOptions {
12
- /** ws(s):// endpoint of the relay server. */
12
+ /** Relay URL ONE url, any scheme. `https://api.relay.paramms.com` is the
13
+ * recommended form; the WebSocket URL (`wss://…/ws`) and REST base are
14
+ * derived from it automatically. `wss://`/`ws://`/`http://` also accepted. */
13
15
  url: string;
16
+ /** HTTP(S) base for REST calls — only when the REST API lives on a
17
+ * DIFFERENT origin than the socket. Normally omit.
18
+ * @deprecated pass a single `url`; kept for back-compat. */
19
+ apiUrl?: string;
14
20
  /** Identity: a signed JWT (secure), a stable userId (host-vouched), or omit
15
21
  * for an anonymous per-browser guest (browser environments only). */
16
22
  token?: string;