@paramms/chat-widget 1.0.32 → 1.0.34

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
@@ -90,7 +90,7 @@ export default function MessagesPage({ session }) {
90
90
  <div style={{ height: '600px' }}>
91
91
  <ChatApp
92
92
  url={process.env.NEXT_PUBLIC_RELAY_URL}
93
- profileId={process.env.NEXT_PUBLIC_RELAY_PROFILE_ID}
93
+ tenantId={process.env.NEXT_PUBLIC_RELAY_TENANT_ID} // or profileId={…} — either works
94
94
  userId={session?.user.id}
95
95
  userName={session?.user.name}
96
96
  />
@@ -101,7 +101,7 @@ export default function MessagesPage({ session }) {
101
101
 
102
102
  Prefer a floating bubble that opens the same app in a panel? `<ChatAppLauncher … floating />`.
103
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.
104
+ > **Multi-tenancy note:** conversations are created and keyed under the tenant the server resolves *from* `profileId` guests can't spoof it. For **reading your own inbox**, `ChatApp`/`ChatAppLauncher` (and `useRelayChatList`/`mountChatList`/`GET /conversations/mine`) also accept a **`tenantId`** directly: it lists every conversation the user has with that business across all of its chatrooms, and is equally safe because the list is always self-scoped to the caller's own identity. `scope="tenant"` / `tenantId` never crosses into another business's data.
105
105
 
106
106
  ### Old section below (kept for reference)
107
107
 
@@ -51,7 +51,15 @@ export interface ChatListOptions {
51
51
  * @deprecated pass a single `url`; kept for back-compat. */
52
52
  apiUrl?: string;
53
53
  /** Profile ID to scope conversations to */
54
- profileId: string;
54
+ profileId?: string;
55
+ /** Tenant-level identification — list EVERY conversation this user has
56
+ * with the business, across ALL of its chatrooms, without naming one
57
+ * (e.g. a platform running a marketplace chatroom AND a general-support
58
+ * chatroom). Provide `profileId` OR `tenantId` (profileId wins if both;
59
+ * it also fixes where "new conversation" opens). With only `tenantId`,
60
+ * the compose target is the server-reported defaultProfileId (the
61
+ * tenant's oldest chatroom). */
62
+ tenantId?: string;
55
63
  /** A signed identity token (ES256 JWT) — the production identity tier for
56
64
  * chatrooms with signed identity enabled. Wins over `userId`. */
57
65
  token?: string;
@@ -88,6 +96,10 @@ export interface ChatListHandle {
88
96
  refresh(): void;
89
97
  /** Unmount and clean up */
90
98
  close(): void;
99
+ /** Where "new conversation" should open: the configured profileId, else the
100
+ * server-reported tenant default (oldest chatroom). Undefined until the
101
+ * first successful fetch when only tenantId was configured. */
102
+ defaultProfileId(): string | undefined;
91
103
  }
92
104
  /** Mount a standalone chat list widget. */
93
105
  export declare function mountChatList(opts: ChatListOptions): ChatListHandle;
package/dist/chatlist.js CHANGED
@@ -1,13 +1,13 @@
1
- import { p as A, r as _ } from "./uid.js";
2
- function B(n) {
3
- const r = Math.floor((Date.now() - n) / 1e3);
1
+ import { p as A, r as B } from "./uid.js";
2
+ function O(o) {
3
+ const r = Math.floor((Date.now() - o) / 1e3);
4
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 t(n, r, u) {
7
- const a = document.createElement(n);
6
+ function n(o, r, u) {
7
+ const a = document.createElement(o);
8
8
  return r && (a.className = r), u !== void 0 && (a.textContent = u), a;
9
9
  }
10
- const O = `
10
+ const P = `
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; }
@@ -40,97 +40,103 @@ const O = `
40
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; }
41
41
  @media (max-width:480px) { .ocl-row { padding:12px 14px; } .ocl-name { font-size:14px; } }
42
42
  `;
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";
43
+ function D(o) {
44
+ const r = o.token ?? o.userId ?? A(), { httpBase: u } = B(o.url, o.apiUrl), a = o.i18n ?? {}, U = o.accent ?? "#4F63F5";
45
45
  if (!document.getElementById("ocl-styles")) {
46
46
  const e = document.createElement("style");
47
- e.id = "ocl-styles", e.textContent = O.replace(/#f5713c/g, E), document.head.append(e);
47
+ e.id = "ocl-styles", e.textContent = P.replace(/#f5713c/g, U), document.head.append(e);
48
48
  }
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);
49
+ const k = n("div", "ocl"), m = n("div", "ocl-head");
50
+ if (m.append(n("span", "ocl-title", a.title ?? "Messages")), o.onNewChat) {
51
+ const e = n("button", "ocl-compose", "✎");
52
+ e.title = a.newChat ?? "New conversation", e.addEventListener("click", () => o.onNewChat()), m.append(e);
53
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)}`;
54
+ const I = n("div", "ocl-search-wrap"), p = n("input", "ocl-search");
55
+ p.placeholder = a.search ?? "🔍 Search", p.type = "search", I.append(p);
56
+ const d = n("div", "ocl-body");
57
+ if (d.append(n("div", "ocl-spinner", "Loading…")), k.append(m, I, d), o.el.replaceChildren(k), !o.profileId && !o.tenantId) throw new Error("[relay chatlist] provide profileId or tenantId");
58
+ let C;
59
+ const y = `ocl_seen_${o.profileId ?? `t_${o.tenantId}`}_${(o.userId ?? r).slice(-8)}`;
59
60
  let g = {};
60
61
  try {
61
62
  g = JSON.parse(localStorage.getItem(y) ?? "{}");
62
63
  } catch {
63
64
  }
64
- const U = () => {
65
+ const R = () => {
65
66
  try {
66
67
  localStorage.setItem(y, JSON.stringify(g));
67
68
  } catch {
68
69
  }
69
70
  };
70
71
  let S = [], h = !1;
71
- const R = async () => {
72
- const e = await fetch(
73
- `${u}/conversations/mine?profileId=${encodeURIComponent(n.profileId)}${n.scope === "tenant" ? "&scope=tenant" : ""}`,
72
+ const _ = async () => {
73
+ const e = o.profileId ? `profileId=${encodeURIComponent(o.profileId)}${o.scope === "tenant" ? "&scope=tenant" : ""}` : `tenantId=${encodeURIComponent(o.tenantId)}`, c = await fetch(
74
+ `${u}/conversations/mine?${e}`,
74
75
  { headers: { authorization: `Bearer ${r}` } }
75
76
  );
76
- return e.ok ? ((await e.json()).conversations ?? []).sort((l, s) => s.updatedAt - l.updatedAt) : [];
77
- }, z = (e, i) => {
77
+ if (!c.ok) return [];
78
+ const i = await c.json();
79
+ return i.defaultProfileId && (C = i.defaultProfileId), (i.conversations ?? []).sort((s, l) => l.updatedAt - s.updatedAt);
80
+ }, $ = (e, c) => {
78
81
  if (h) return;
79
- const l = i ? e.filter(
80
- (o) => L(o).toLowerCase().includes(i) || (o.lastMessage ?? "").toLowerCase().includes(i)
82
+ const i = c ? e.filter(
83
+ (t) => z(t).toLowerCase().includes(c) || (t.lastMessage ?? "").toLowerCase().includes(c)
81
84
  ) : e;
82
- if (c.replaceChildren(), !l.length) {
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);
85
+ if (d.replaceChildren(), !i.length) {
86
+ const t = n("div", "ocl-empty", c ? "No results." : a.empty ?? "No conversations yet.");
87
+ if (!c && o.onNewChat) {
88
+ t.append(n("br"));
89
+ const v = n("button", "ocl-start", a.newChat ?? "Start a conversation");
90
+ v.addEventListener("click", () => o.onNewChat()), t.append(v);
88
91
  }
89
- c.append(o);
92
+ d.append(t);
90
93
  return;
91
94
  }
92
- const s = (o) => (o.lastSeq ?? 0) > (g[o.id] ?? 0), d = l.filter(s), f = l.filter((o) => !s(o));
93
- if (d.length) {
94
- c.append(t("div", "ocl-section", `${a.unread ?? "Unread"} (${d.length})`));
95
- for (const o of d) c.append($(o, s(o)));
95
+ const s = (t) => (t.lastSeq ?? 0) > (g[t.id] ?? 0), l = i.filter(s), f = i.filter((t) => !s(t));
96
+ if (l.length) {
97
+ d.append(n("div", "ocl-section", `${a.unread ?? "Unread"} (${l.length})`));
98
+ for (const t of l) d.append(L(t, s(t)));
96
99
  }
97
100
  if (f.length) {
98
- c.append(t("div", "ocl-section", d.length ? a.all ?? "All conversations" : ""));
99
- for (const o of f) c.append($(o, !1));
101
+ d.append(n("div", "ocl-section", l.length ? a.all ?? "All conversations" : ""));
102
+ for (const t of f) d.append(L(t, !1));
100
103
  }
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));
104
+ }, z = (e) => e.subjectTitle ?? (e.kind === "direct" ? e.peerId ?? "Direct message" : "General enquiry"), L = (e, c) => {
105
+ var E;
106
+ const i = z(e), s = ((E = i[0]) == null ? void 0 : E.toUpperCase()) ?? "?", l = e.lastSeq ?? 0, f = c ? Math.max(1, l - (g[e.id] ?? 0)) : 0, t = n("button", `ocl-row${c ? " unread" : ""}`), v = n("div", "ocl-av", s), w = n("div", "ocl-dot");
107
+ e.state === "open" ? w.classList.add("open") : e.state === "awaiting_staff" && w.classList.add("waiting"), v.append(w), t.append(v);
108
+ const b = n("div", "ocl-info");
109
+ b.append(n("div", "ocl-name", i));
107
110
  const j = {
108
111
  open: "Open",
109
112
  awaiting_staff: "Waiting for reply…",
110
113
  resolved: "Resolved ✓",
111
114
  closed: "Closed"
112
115
  };
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", () => {
116
+ b.append(n("div", "ocl-preview", e.lastMessage ?? j[e.state] ?? e.state)), t.append(b);
117
+ const x = n("div", "ocl-right");
118
+ return x.append(n("div", "ocl-time", O(e.updatedAt))), f > 0 && x.append(n("div", "ocl-badge", String(f > 99 ? "99+" : f))), t.append(x), t.addEventListener("click", () => {
116
119
  var M;
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()));
120
+ l > 0 && (g[e.id] = l, R()), t.classList.remove("unread"), (M = x.querySelector(".ocl-badge")) == null || M.remove(), o.onSelect(e);
121
+ }), t;
122
+ }, N = () => {
123
+ h || _().then((e) => {
124
+ h || (S = e, $(e, p.value.trim().toLowerCase()));
122
125
  }).catch((e) => {
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.")));
126
+ h || (console.error(`[chat-widget] failed to load conversations from ${u}/conversations/mine — check the apiUrl/CORS config.`, e), d.replaceChildren(n("div", "ocl-empty", a.error ?? "Could not load conversations.")));
124
127
  });
125
128
  };
126
- return p.addEventListener("input", () => z(S, p.value.trim().toLowerCase())), I(), {
127
- refresh: I,
129
+ return p.addEventListener("input", () => $(S, p.value.trim().toLowerCase())), N(), {
130
+ refresh: N,
128
131
  close() {
129
- h = !0, n.el.replaceChildren();
132
+ h = !0, o.el.replaceChildren();
133
+ },
134
+ defaultProfileId() {
135
+ return o.profileId ?? C;
130
136
  }
131
137
  };
132
138
  }
133
139
  export {
134
- F as mountChatList
140
+ D as mountChatList
135
141
  };
136
142
  //# sourceMappingURL=chatlist.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"chatlist.js","sources":["../src/chatlist.ts"],"sourcesContent":["/**\n * chatlist.ts — standalone chat list widget.\n *\n * Completely separate from mount() / the chat widget.\n * Shows all conversations for a given userId / guest on a profile.\n * Tapping a row fires onSelect(entry) — the caller decides what to do\n * (navigate to a new page, open a ChatWidget inline, etc.)\n *\n * Usage (vanilla):\n * import { mountChatList } from '@paramms/chat-widget/chatlist'\n * const handle = mountChatList({\n * el: document.getElementById('chat-list'),\n * url: 'https://api.relay.paramms.com', // ONE url, any scheme\n * profileId: 'p_usedcars',\n * userId: currentUser.id, // optional — uses localStorage UID if omitted\n * onSelect: (entry) => {\n * window.location.href = `/listings/${entry.subjectId}#chat`\n * },\n * })\n * handle.refresh() // manually re-fetch the list\n * handle.close() // unmount and clean up\n */\n\nimport { resolveRelayUrls } from './history.js'\nimport { persistentUid } from './uid.js'\n\nexport interface ChatListEntry {\n id: string\n /** Chatroom this conversation belongs to. With `scope: 'tenant'` this can\n * differ from the profileId the list was mounted with — open the chat\n * against THIS profileId. */\n profileId?: string\n /** 'support' (default) or 'direct' (user↔user DM). */\n kind?: string\n /** For direct conversations: the other participant's user id. */\n peerId?: string\n subjectId?: string\n subjectTitle?: string\n /** One-line detail — e.g. \"45,000 km · Auto\" */\n subjectMeta?: string\n /** URL of the listing/item page — stored automatically when the widget first opens */\n subjectUrl?: string\n state: string\n updatedAt: number\n lastSeq?: number\n lastMessage?: string\n}\n\nexport interface ChatListOptions {\n /** Mount target element */\n el: HTMLElement\n /** Relay URL — ONE url, any scheme (https recommended). The WebSocket URL\n * and REST base are derived automatically. */\n url: string\n /** HTTP(S) base for REST — only when REST is on a different origin.\n * @deprecated pass a single `url`; kept for back-compat. */\n apiUrl?: string\n /** Profile ID to scope conversations to */\n profileId: string\n /** 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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"}
1
+ {"version":3,"file":"chatlist.js","sources":["../src/chatlist.ts"],"sourcesContent":["/**\n * chatlist.ts — standalone chat list widget.\n *\n * Completely separate from mount() / the chat widget.\n * Shows all conversations for a given userId / guest on a profile.\n * Tapping a row fires onSelect(entry) — the caller decides what to do\n * (navigate to a new page, open a ChatWidget inline, etc.)\n *\n * Usage (vanilla):\n * import { mountChatList } from '@paramms/chat-widget/chatlist'\n * const handle = mountChatList({\n * el: document.getElementById('chat-list'),\n * url: 'https://api.relay.paramms.com', // ONE url, any scheme\n * profileId: 'p_usedcars',\n * userId: currentUser.id, // optional — uses localStorage UID if omitted\n * onSelect: (entry) => {\n * window.location.href = `/listings/${entry.subjectId}#chat`\n * },\n * })\n * handle.refresh() // manually re-fetch the list\n * handle.close() // unmount and clean up\n */\n\nimport { resolveRelayUrls } from './history.js'\nimport { persistentUid } from './uid.js'\n\nexport interface ChatListEntry {\n id: string\n /** Chatroom this conversation belongs to. With `scope: 'tenant'` this can\n * differ from the profileId the list was mounted with — open the chat\n * against THIS profileId. */\n profileId?: string\n /** 'support' (default) or 'direct' (user↔user DM). */\n kind?: string\n /** For direct conversations: the other participant's user id. */\n peerId?: string\n subjectId?: string\n subjectTitle?: string\n /** One-line detail — e.g. \"45,000 km · Auto\" */\n subjectMeta?: string\n /** URL of the listing/item page — stored automatically when the widget first opens */\n subjectUrl?: string\n state: string\n updatedAt: number\n lastSeq?: number\n lastMessage?: string\n}\n\nexport interface ChatListOptions {\n /** Mount target element */\n el: HTMLElement\n /** Relay URL — ONE url, any scheme (https recommended). The WebSocket URL\n * and REST base are derived automatically. */\n url: string\n /** HTTP(S) base for REST — only when REST is on a different origin.\n * @deprecated pass a single `url`; kept for back-compat. */\n apiUrl?: string\n /** Profile ID to scope conversations to */\n profileId?: string\n /** Tenant-level identification — list EVERY conversation this user has\n * with the business, across ALL of its chatrooms, without naming one\n * (e.g. a platform running a marketplace chatroom AND a general-support\n * chatroom). Provide `profileId` OR `tenantId` (profileId wins if both;\n * it also fixes where \"new conversation\" opens). With only `tenantId`,\n * the compose target is the server-reported defaultProfileId (the\n * tenant's oldest chatroom). */\n tenantId?: string\n /** A signed identity token (ES256 JWT) — the production identity tier for\n * chatrooms with signed identity enabled. Wins over `userId`. */\n token?: string\n /** Your logged-in user's stable ID. Omit for anonymous (uses localStorage UID) */\n userId?: string\n /** Which conversations to list (default 'profile'):\n * 'profile' — only this chatroom's threads.\n * 'tenant' — every conversation this user has with the chatroom's owning\n * business, across ALL of its chatrooms (a real chat-app inbox). Rows\n * carry `profileId` so each opens against the right chatroom. */\n scope?: 'profile' | 'tenant'\n /** Called when the user taps a conversation row */\n onSelect: (entry: ChatListEntry) => void\n /** When provided, the list shows a ✎ compose button in the header (and a\n * \"Start a conversation\" button in the empty state) that calls this —\n * wire it to open a fresh/general thread. Without it a user with no\n * conversations yet has nothing to tap. */\n onNewChat?: () => void\n /** Brand colour hex — default '#4F63F5' */\n accent?: string\n /** i18n overrides */\n i18n?: {\n title?: string // default 'Messages'\n search?: string // default '🔍 Search'\n empty?: string // default 'No conversations yet.'\n unread?: string // default 'Unread'\n all?: string // default 'All conversations'\n error?: string // default 'Could not load conversations.'\n newChat?: string // default 'New conversation' / 'Start a conversation'\n }\n}\n\nexport interface ChatListHandle {\n /** Re-fetch and re-render the list */\n refresh(): void\n /** Unmount and clean up */\n close(): void\n /** Where \"new conversation\" should open: the configured profileId, else the\n * server-reported tenant default (oldest chatroom). Undefined until the\n * first successful fetch when only tenantId was configured. */\n defaultProfileId(): string | undefined\n}\n\nfunction timeAgo(ts: number): string {\n const s = Math.floor((Date.now() - ts) / 1000)\n if (s < 60) return 'just now'\n if (s < 3600) return `${Math.floor(s / 60)}m`\n if (s < 86400) return `${Math.floor(s / 3600)}h`\n return `${Math.floor(s / 86400)}d`\n}\n\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 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 res = await fetch(\n `${httpBase}/conversations/mine?${who}`,\n { headers: { authorization: `Bearer ${token}` } },\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 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 defaultProfileId() { return opts.profileId ?? serverDefaultProfileId },\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","serverDefaultProfileId","seenKey","seenSeq","saveSeenSeq","allEntries","destroyed","fetchEntries","who","res","data","a","b","renderRows","entries","query","filtered","rowName","empty","start","isUnread","unread","read","buildRow","entry","name","initial","_a","lastSeq","unreadCount","row","av","dot","info","stateMap","right","refresh"],"mappings":"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"}
package/dist/core.js CHANGED
@@ -1,20 +1,21 @@
1
1
  var c = Object.defineProperty;
2
2
  var d = (t, e, s) => e in t ? c(t, e, { enumerable: !0, configurable: !0, writable: !0, value: s }) : t[e] = s;
3
3
  var n = (t, e, s) => d(t, typeof e != "symbol" ? e + "" : e, s);
4
- import { a as o, C as u, b as h } from "./e2e.js";
5
- import { A as C, E as k, P as A, c as E, d as j, e as U, f as R, g as w, h as x, i as F, j as L, k as N, l as O } from "./e2e.js";
6
- import { p as l, r as I } from "./uid.js";
7
- import { h as H, a as P } from "./uid.js";
8
- import { mountChatList as Y } from "./chatlist.js";
9
- const m = /* @__PURE__ */ new Set([
4
+ import { a as o, C as u, b as h } from "./outbox.js";
5
+ import { A as C, P as k, c as A, d as j, e as E, f as U, g as x, h as R, i as w, j as F, k as L, l as N } from "./outbox.js";
6
+ import { E2ESession as q } from "./e2e.js";
7
+ import { p as l, r as m } from "./uid.js";
8
+ import { h as P, a as X } from "./uid.js";
9
+ import { mountChatList as D } from "./chatlist.js";
10
+ const I = /* @__PURE__ */ new Set([
10
11
  "resolved",
11
12
  "closed",
12
13
  "sold",
13
14
  "issued",
14
15
  "checked_out"
15
16
  ]);
16
- function p(t) {
17
- return m.has(t);
17
+ function _(t) {
18
+ return I.has(t);
18
19
  }
19
20
  function T(t) {
20
21
  const e = t.effect.type === "form" ? t.effect.fields : void 0;
@@ -51,7 +52,7 @@ class f {
51
52
  ...e.subjectTitle ? { subjectTitle: e.subjectTitle } : {},
52
53
  ...e.kind === "direct" ? { kind: "direct", peerId: o(e.peerId ?? "") } : {},
53
54
  ...e.user ? { userInfo: e.user } : {}
54
- }, { wsUrl: i } = I(e.url, e.apiUrl);
55
+ }, { wsUrl: i } = m(e.url, e.apiUrl);
55
56
  this.conn = new h({
56
57
  url: i,
57
58
  token: e.token ?? e.me,
@@ -118,29 +119,29 @@ export {
118
119
  C as ANONYMOUS_TENANT,
119
120
  u as ChatStore,
120
121
  h as ConnectionManager,
121
- k as E2ESession,
122
+ q as E2ESession,
122
123
  v as LIMITS,
123
- A as PersistentOutbox,
124
+ k as PersistentOutbox,
124
125
  b as RelayClient,
125
126
  f as RelayConversation,
126
- m as TERMINAL_STATES,
127
- E as asActionId,
127
+ I as TERMINAL_STATES,
128
+ A as asActionId,
128
129
  j as asConnectionId,
129
- U as asConversationId,
130
- R as asMessageId,
131
- w as asProfileId,
132
- x as asSubjectId,
133
- F as asTenantId,
130
+ E as asConversationId,
131
+ U as asMessageId,
132
+ x as asProfileId,
133
+ R as asSubjectId,
134
+ w as asTenantId,
134
135
  o as asUserId,
135
- L as decodeFrame,
136
- N as encodeFrame,
137
- H as httpBaseFromWsUrl,
138
- O as isClientFrame,
139
- p as isTerminalState,
140
- Y as mountChatList,
136
+ F as decodeFrame,
137
+ L as encodeFrame,
138
+ P as httpBaseFromWsUrl,
139
+ N as isClientFrame,
140
+ _ as isTerminalState,
141
+ D as mountChatList,
141
142
  l as persistentUid,
142
- I as resolveRelayUrls,
143
- P as restoreHistory,
143
+ m as resolveRelayUrls,
144
+ X as restoreHistory,
144
145
  T as toManifestAction
145
146
  };
146
147
  //# sourceMappingURL=core.js.map
package/dist/core.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"core.js","sources":["../src/protocol/entities.ts","../src/protocol/actions.ts","../src/protocol/frames.ts","../src/core.ts"],"sourcesContent":["import type {\n ConversationId, MessageId, ProfileId, SubjectId, TenantId, UserId,\n} from './ids.js'\nimport type { ActionId } from './ids.js'\n\n// ── Message content (discriminated union) ─────────────────────────────────────\n// The runtime stays generic by never hard-coding business content: a message is\n// one of a small, fixed set of shapes. `card`/`form`/`system` are how action\n// results and structured prompts render — they subsume most \"rich messaging\"\n// features without a per-feature content zoo.\n\nexport interface CardField { label: string; value: string }\n\n/** A reference to an action a card/quick-reply can invoke. */\nexport interface InlineActionRef { actionId: ActionId; label: string }\n\nexport type MessageContent =\n | { kind: 'text'; text: string; enc?: boolean; iv?: string }\n | { kind: 'attachment'; url: string; mime: string; name?: string; size?: number }\n | { kind: 'card'; title?: string; body?: string; fields?: CardField[]; actions?: InlineActionRef[] }\n | { kind: 'form'; prompt: string; actionId: ActionId }\n | { kind: 'system'; event: string; data?: Record<string, string | number | boolean> }\n | { kind: 'appointment'; title: string; startIso: string; endIso: string; location?: string; description?: string; googleUrl: string; icalUrl: string; confirmed?: boolean }\n\nexport type SenderRole = 'guest' | 'agent' | 'system' | 'bot'\n\n// ── Message ───────────────────────────────────────────────────────────────────\n// Ordering is by `seq` (server-assigned, monotonic per conversation), never by\n// `ts`. `ts` is wall-clock for display only. This kills the reorder/duplicate/\n// lost-on-reconnect class of bugs that millisecond-timestamp ordering caused.\n\nexport interface Message {\n id: MessageId\n conversationId: ConversationId\n seq: number\n senderId: UserId\n senderRole: SenderRole\n content: MessageContent\n ts: number\n replyToId?: MessageId\n editedAt?: number\n deletedAt?: number\n reactions?: Record<string, UserId[]>\n internal?: boolean // true = internal note, only visible to agents\n}\n\n// ── Conversation (the room; messages partition by conversationId) ─────────────\n// The room is the conversation, NOT the subject. Two guests discussing the same\n// subject get two conversations. `subjectId` is a nullable reference, never part\n// of the room identity — so \"one thread per (guest, subject)\" is enforced as\n// app logic at open-time, and many-threads-per-subject stays possible for free.\n\nexport interface Conversation {\n id: ConversationId\n tenantId: TenantId\n profileId: ProfileId // behavior profile → which actions this room has\n subjectId?: SubjectId // optional: the thing it's about\n guestId: UserId // the end-user\n /** Host-supplied display info for an identified guest (widget `user` option).\n * Display metadata only — identity is still the token/guestId. */\n guestName?: string\n guestEmail?: string\n guestAvatar?: string\n guestMeta?: Record<string, string>\n /** True once the guest's identity has been proven by a signed ES256 JWT\n * against the chatroom's guestPublicKey (secure identity mode). */\n guestVerified?: boolean\n participants: UserId[] // guest + any assigned agents (membership = authz)\n assignedAgentId?: UserId // routing/ownership\n aiActive?: boolean // staff assigned the AI to answer this room\n state: string // conversation state-machine state\n firstResponseAt?: number // first agent reply ts (SLA)\n csat?: number // satisfaction score 1–5 (set on resolution)\n lastSeq: number // highest seq assigned in this conversation\n tags?: string[] // macro/manual tags (e.g. \"refund\", \"vip\")\n /** Live sentiment of the guest's most recent message (best-effort, async). */\n sentiment?: 'positive' | 'neutral' | 'frustrated'\n /** -1 (very frustrated) .. +1 (very positive); paired with `sentiment`. */\n sentimentScore?: number\n /** Set once an SLA-breach escalation macro has fired, so it only fires once. */\n slaEscalatedAt?: number\n /** If set, the conversation is snoozed until this Unix ms timestamp.\n * Hidden from the inbox until the timestamp passes, then resurfaces. */\n snoozedUntil?: number\n /** Page URL where the widget was open when the conversation started. */\n pageUrl?: string\n /** Browser tab title at conversation start — gives agents context. */\n pageTitle?: string\n createdAt: number\n updatedAt: number\n}\n\n// ── Co-browsing / shared annotation ────────────────────────────────────────---\n// A lightweight shared whiteboard layered over a conversation: agent and guest\n// can draw freehand strokes that both sides see live. Strokes are relayed\n// (not stored as messages) and kept per-conversation so late joiners can catch\n// up via the `opened` frame's `annotations` field.\n\nexport interface AnnotationPoint { x: number; y: number }\nexport interface AnnotationStroke {\n id: string\n points: AnnotationPoint[]\n color: string\n width: number\n by: UserId\n}\n\n// ── Subject (the referenced entity — Intercom \"custom object\") ────────────────\n// Carries shared state (available → reserved → sold) and fields (price, vin…)\n// that actions read/write. Many conversations reference one subject. Never a room.\n\nexport interface Subject {\n id: SubjectId\n tenantId: TenantId\n title: string\n state: string\n fields: Record<string, string | number | boolean>\n /** URL of the page where the subject lives (e.g. the listing page URL).\n * Captured automatically by the widget and stored on first open. */\n url?: string\n createdAt: number\n updatedAt: number\n}\n\n// ── Conversation lifecycle ────────────────────────────────────────────────────\n/** States in which a conversation is no longer \"open\": it's done with, so inbox\n * / proactive sweeps skip it and load balancing frees the assigned agent. The\n * single source of truth for \"is this conversation finished?\". */\nexport const TERMINAL_STATES: ReadonlySet<string> = new Set([\n 'resolved', 'closed', 'sold', 'issued', 'checked_out',\n])\nexport function isTerminalState(state: string): boolean {\n return TERMINAL_STATES.has(state)\n}\n\nexport type Channel = 'widget' | 'email' | 'sms' | 'whatsapp' | 'instagram' | 'kakao' | 'messenger' | 'line'\n","import type { ActionId, ProfileId, TenantId } from './ids.js'\n\n// ── Actions: the product primitive ────────────────────────────────────────────\n// An action is data an admin authors in the dashboard; the runtime stays generic\n// and only knows how to execute a small, fixed set of EFFECTS. Adding \"make\n// offer\" or \"schedule meeting\" is a config row, not a code deploy.\n\nexport type ActionAudience = 'guest' | 'agent' | 'both'\nexport type ActionSurface = 'toolbar' | 'inline' | 'quick_reply'\n\nexport interface ActionInputField {\n name: string\n label: string\n type: 'text' | 'number' | 'date' | 'select'\n required?: boolean\n options?: string[] // for type: 'select'\n}\n\n// A terminal effect produces a result and ends the action. Actions are\n// single-shot: structured multi-step lives in the conversation state machine,\n// and conversational multi-step is the `bot` effect — not an action workflow.\nexport type TerminalEffect =\n | { type: 'webhook'; url: string } // signed POST to tenant system\n | { type: 'state_transition'; target: 'conversation' | 'subject'; toState: string }\n | { type: 'bot' } // route to the AI resolver\n | { type: 'builtin'; name: string } // e.g. 'handoff'\n\n// The ONLY composition allowed is \"collect a form, then run one terminal\n// effect\" — exactly one level deep. This covers input-gathering (e.g. an offer\n// amount) without becoming a workflow engine.\nexport type ActionEffect =\n | TerminalEffect\n | { type: 'form'; fields: ActionInputField[]; then: TerminalEffect }\n\nexport type ActionResult =\n | { kind: 'system_message'; template?: string } // post a system line into the chat\n | { kind: 'card' } // render the effect's response as a card\n | { kind: 'state_badge' } // reflect a state change\n | { kind: 'none' }\n\nexport interface ActionDef {\n id: ActionId\n label: string\n icon?: string\n confirm?: boolean\n audience: ActionAudience\n surface: ActionSurface\n availableInStates?: string[] // conversation/subject states; omit = always available\n effect: ActionEffect\n result: ActionResult\n}\n\n// Client-safe projection of an action: enough for the widget to render it and\n// collect inputs, but NONE of the effect internals (webhook URLs, transition\n// targets) — those stay server-side and execute on `invoke`. The client filters\n// by `availableInStates` locally against the current conversation state, so a\n// state change needs no manifest round-trip; the server re-validates on invoke.\nexport interface ManifestAction {\n id: ActionId\n label: string\n icon?: string\n confirm?: boolean\n audience: ActionAudience\n surface: ActionSurface\n availableInStates?: string[]\n input?: ActionInputField[] // present when the action collects input (form effect)\n}\n\n/** Project an internal action to its client-safe manifest form. */\nexport function toManifestAction(a: ActionDef): ManifestAction {\n const input = a.effect.type === 'form' ? a.effect.fields : undefined\n return {\n id: a.id, label: a.label, audience: a.audience, surface: a.surface,\n ...(a.icon ? { icon: a.icon } : {}),\n ...(a.confirm ? { confirm: a.confirm } : {}),\n ...(a.availableInStates ? { availableInStates: a.availableInStates } : {}),\n ...(input ? { input } : {}),\n }\n}\n\n// ── Behavior profile (what \"domain\" becomes) ──────────────────────────────────\n// A reusable, admin-composed bundle of actions + defaults + state machine. Not a\n// built-in taxonomy — the 7 old templates become starter presets of this shape.\n// `version` lets an in-flight invocation validate against a consistent snapshot.\n\n/** Operating hours slot: 0=Sun … 6=Sat, times in \"HH:MM\" 24h local. */\nexport interface OperatingHoursSlot { day: 0|1|2|3|4|5|6; open: string; close: string }\n\nexport interface BehaviorProfile {\n id: ProfileId\n tenantId: TenantId\n name: string\n actions: ActionDef[]\n defaults: {\n greeting?: string\n theme?: { accent: string }\n e2e?: boolean\n persona?: string\n /** Paid-tier flag: when true, hides the \"Powered by Relay\" footer in the widget. */\n whiteLabel?: boolean\n /** White-label: serve/embed the widget from this hostname (e.g.\n * \"chat.acmeco.com\"). Allowed automatically as a CORS origin for the\n * control-plane API so the widget works from the custom domain. */\n customDomain?: string\n }\n states: string[]\n initialState: string\n version: number\n welcomeMessage?: string // first message guests see when opening the widget\n operatingHours?: OperatingHoursSlot[] // empty/absent = always open\n offlineMessage?: string // shown outside operating hours instead of chat\n /** Base64-encoded ECDSA P-256 SPKI public key. When set, guest tokens must be\n * signed JWTs — unsigned opaque tokens are rejected. */\n guestPublicKey?: string\n createdAt: number\n updatedAt: number\n}\n","import type {\n ConnectionId, ConversationId, MessageId, ProfileId, SubjectId, UserId,\n} from './ids.js'\nimport type { Channel, Conversation, Message, MessageContent, Subject, AnnotationStroke } from './entities.js'\nimport type { ManifestAction } from './actions.js'\n\n// ── Wire protocol ─────────────────────────────────────────────────────────────\n// One shared contract, imported by server + widget + dashboard. A change here is\n// a compile error in every consumer — which is the whole reason this lives in a\n// shared package instead of being hand-copied three times.\n\nexport type ErrorCode =\n | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' | 'BAD_REQUEST'\n | 'RATE_LIMITED' | 'PAYLOAD_TOO_LARGE' | 'CONFLICT' | 'INTERNAL'\n\nexport type ClientFrame =\n | { type: 'auth'; token: string }\n // Open an existing conversation, or find-or-create one. Find-or-create keys on\n // (guest, subject) when subjectId is given; otherwise a fresh conversation.\n | { type: 'open'; conversationId?: ConversationId; subjectId?: SubjectId; profileId?: ProfileId; pageUrl?: string; pageTitle?: string; subjectTitle?: string; subjectMeta?: string; linkFrom?: UserId;\n /** Host-supplied display info for the guest — persisted onto the\n * conversation server-side so agents see who they're talking to.\n * Display metadata only, never used for authorization. */\n userInfo?: { name?: string; email?: string; avatar?: string; meta?: Record<string, string> } }\n | { type: 'send'; conversationId: ConversationId; clientMsgId: string; content: MessageContent; replyToId?: MessageId; via?: Channel[] }\n | { type: 'sync'; conversationId: ConversationId; sinceSeq: number } // catch-up after cursor\n | { type: 'history'; conversationId: ConversationId; beforeSeq: number; limit?: number } // load older\n | { type: 'read'; conversationId: ConversationId; seq: number } // read up to seq\n | { type: 'typing'; conversationId: ConversationId; isTyping: boolean; preview?: string }\n | { type: 'react'; conversationId: ConversationId; messageId: MessageId; emoji: string; remove?: boolean }\n | { type: 'edit'; conversationId: ConversationId; messageId: MessageId; content: MessageContent }\n | { type: 'delete'; conversationId: ConversationId; messageId: MessageId }\n | { type: 'invoke'; conversationId: ConversationId; actionId: string; clientInvokeId: string; inputs?: Record<string, unknown> }\n | { type: 'assign'; conversationId: ConversationId; agentId: UserId | null } // null = unassign\n | { type: 'tag'; conversationId: ConversationId; tag: string; remove?: boolean }\n | { type: 'note'; conversationId: ConversationId; clientMsgId: string; text: string } // internal note\n | { type: 'agent_status'; status: 'online' | 'away' | 'offline' } // agent sets their availability\n // Co-browsing: a freehand stroke (or \"clear\") on the shared annotation canvas\n // for a subject-anchored conversation. Relayed live to the other participant.\n | { type: 'annotate'; conversationId: ConversationId; stroke: Omit<AnnotationStroke, 'by'> }\n | { type: 'annotate_clear'; conversationId: ConversationId }\n | { type: 'pubkey'; conversationId: ConversationId; key: string }\n // X3DH async E2E: a client uploads a batch of one-time prekeys so peers can\n // encrypt to them while they are offline. The server stores them opaquely and\n // vends one on demand — it never derives or uses the keys.\n | { type: 'uploadPrekeys'; identityKey: string; signedPrekey: string; signedPrekeyId: string; signature: string; oneTimePrekeys: string[] }\n | { type: 'fetchPrekey'; targetUserId: UserId }\n // Inbox stream subscription — used by the agent dashboard, which reuses this\n // ConnectionManager. Typed here so the dashboard doesn't need `as never`.\n | { type: 'subscribe_inbox' }\n | { type: 'unsubscribe_inbox' }\n | { type: 'ping' }\n\nexport type ServerFrame =\n | { type: 'authed'; userId: UserId; connectionId: ConnectionId }\n | { type: 'opened'; conversation: Conversation; subject?: Subject; annotations?: AnnotationStroke[] }\n | { type: 'manifest'; conversationId: ConversationId; actions: ManifestAction[]; version: number; name?: string; theme?: { accent: string }; e2e?: boolean; offline?: boolean; offlineMessage?: string; whiteLabel?: boolean }\n | { type: 'message'; message: Message }\n | { type: 'ack'; clientMsgId: string; messageId: MessageId; seq: number; ts: number }\n | { type: 'delivered'; conversationId: ConversationId; seq: number; to: UserId }\n | { type: 'read'; conversationId: ConversationId; seq: number; by: UserId }\n | { type: 'sync'; conversationId: ConversationId; messages: Message[] }\n | { type: 'history'; conversationId: ConversationId; messages: Message[]; hasMore: boolean }\n | { type: 'typing'; conversationId: ConversationId; userId: UserId; isTyping: boolean; preview?: string }\n | { type: 'reaction'; conversationId: ConversationId; messageId: MessageId; emoji: string; by: UserId; removed: boolean }\n | { type: 'edited'; conversationId: ConversationId; messageId: MessageId; content: MessageContent; editedAt: number }\n | { type: 'deleted'; conversationId: ConversationId; messageId: MessageId; ts: number }\n | { type: 'state'; conversationId: ConversationId; state: string }\n | { type: 'assigned'; conversationId: ConversationId; agentId: UserId | null }\n | { type: 'tagged'; conversationId: ConversationId; tag: string; removed: boolean }\n | { type: 'visitor_count'; count: number } // broadcast to agents: guests currently connected\n | { type: 'agent_status_changed'; agentId: UserId; status: 'online' | 'away' | 'offline' }\n // Live sentiment of a guest's most recent message — relayed to agents only so\n // the inbox can flag frustrated conversations as they happen.\n | { type: 'sentiment'; conversationId: ConversationId; label: 'positive' | 'neutral' | 'frustrated'; score: number }\n // Co-browsing: relay of an annotation stroke / clear to everyone in the room.\n | { type: 'annotation'; conversationId: ConversationId; stroke: AnnotationStroke }\n | { type: 'annotation_clear'; conversationId: ConversationId; by: UserId }\n | { type: 'subjectState'; subjectId: SubjectId; state: string }\n | { type: 'presence'; conversationId: ConversationId; userId: UserId; status: 'online' | 'offline'; lastSeen?: number }\n | { type: 'invoked'; clientInvokeId: string; ok: boolean; error?: string }\n | { type: 'error'; code: ErrorCode; message: string }\n | { type: 'peerkey'; conversationId: ConversationId; userId: UserId; key: string }\n // X3DH bundle vended to a requesting client so they can encrypt to an offline peer.\n // Contains null when the target user has no registered prekeys.\n | { type: 'prekeyBundle'; targetUserId: UserId; bundle: { identityKey: string; signedPrekey: string; signedPrekeyId: string; signature: string; oneTimePrekey?: string } | null }\n | { type: 'pong' }\n\n/** Limits referenced by both ends so validation stays consistent. */\nexport const LIMITS = {\n MAX_TEXT_LEN: 8_000,\n MAX_HISTORY_LIMIT: 100,\n DEFAULT_HISTORY: 50,\n} as const\n","// ── @paramms/chat-widget/core — the headless SDK ─────────────────────────────\n// Everything you need to build your OWN chat UI (an in-app messenger, a\n// marketplace inbox, a full chat app) on the Relay protocol, with zero DOM or\n// React dependencies. This is not a new client: it is the exact transport,\n// store, outbox, and E2E machinery the bundled widget AND the agent dashboard\n// run on — re-exported behind a stable boundary, plus a small convenience\n// client for the common case.\n//\n// import { RelayClient } from '@paramms/chat-widget/core'\n//\n// // ONE url, any scheme — wss/ws/http(s) all work; ws + REST derived from it.\n// const relay = new RelayClient({ url: 'https://api.relay.paramms.com', token, profileId: 'p_x' })\n// const convo = relay.open({ subjectId: 'listing_42' }) // support thread\n// const dm = relay.open({ kind: 'direct', peerId: 'user_bob' }) // user↔user (signed identity required)\n// convo.onChange(() => render(convo.store.messages()))\n// convo.send('hello!')\n//\n// For React, see '@paramms/chat-widget/hooks'.\nexport { ConnectionManager, type SocketLike } from './connection.js'\nexport { ChatStore } from './store.js'\nexport { PersistentOutbox } from './outbox.js'\nexport { E2ESession } from './e2e.js'\nexport { restoreHistory, resolveRelayUrls, httpBaseFromWsUrl } from './history.js'\nexport { mountChatList, type ChatListEntry, type ChatListHandle, type ChatListOptions } from './chatlist.js'\nexport { persistentUid } from './uid.js'\nexport * from './protocol/index.js'\n\nimport { ConnectionManager } from './connection.js'\nimport { ChatStore } from './store.js'\nimport type { ClientFrame, ServerFrame, ConversationId, UserId } from './protocol/index.js'\nimport { asUserId } from './protocol/index.js'\nimport { persistentUid } from './uid.js'\nimport { resolveRelayUrls } from './history.js'\n\nexport interface RelayClientOptions {\n /** Relay URL — ONE url, any scheme. `https://api.relay.paramms.com` is the\n * recommended form; the WebSocket URL (`wss://…/ws`) and REST base are\n * derived from it automatically. `wss://`/`ws://`/`http://` also accepted. */\n url: string\n /** HTTP(S) base for REST calls — only when the REST API lives on a\n * DIFFERENT origin than the socket. Normally omit.\n * @deprecated pass a single `url`; kept for back-compat. */\n apiUrl?: string\n /** Identity: a signed JWT (secure), a stable userId (host-vouched), or omit\n * for an anonymous per-browser guest (browser environments only). */\n token?: string\n /** Chatroom id (from the dashboard). Required to open conversations. */\n profileId: string\n}\n\nexport interface OpenOptions {\n /** Support thread scoped to a subject (listing/order/…): one thread per\n * (user, subject). Omit for the profile's single support thread. */\n subjectId?: string\n subjectTitle?: string\n /** User↔user conversation (requires the chatroom to have signed identity\n * and `token` to be a valid signed JWT). */\n kind?: 'direct'\n peerId?: string\n /** Display info persisted for agents (support threads only). */\n user?: { name?: string; email?: string; avatar?: string; meta?: Record<string, string> }\n}\n\n/** One conversation = one connection + one store. Deliberately thin: the\n * store is the source of truth, `onChange` is the render signal, everything\n * else is the same primitives the first-party UIs use. */\nexport class RelayConversation {\n readonly store: ChatStore\n private readonly conn: ConnectionManager\n private readonly listeners = new Set<() => void>()\n private msgSeq = 0\n private _status = 'connecting'\n private _statusMessage: string | undefined\n\n constructor(opts: RelayClientOptions & OpenOptions & { me: UserId }) {\n this.store = new ChatStore(opts.me)\n const open: Extract<ClientFrame, { type: 'open' }> = {\n type: 'open',\n profileId: opts.profileId as never,\n ...(opts.subjectId ? { subjectId: opts.subjectId as never } : {}),\n ...(opts.subjectTitle ? { subjectTitle: opts.subjectTitle } : {}),\n ...(opts.kind === 'direct' ? { kind: 'direct' as const, peerId: asUserId(opts.peerId ?? '') } : {}),\n ...(opts.user ? { userInfo: opts.user } : {}),\n }\n // Accept any scheme (https/http/wss/ws) — a plain `https://api.…` URL is\n // resolved to the concrete `wss://…/ws` socket endpoint, exactly like the\n // bundled widget's mount(). Before this, RelayClient required a raw\n // WebSocket URL while the React components took `https://` — one URL now\n // works across the entire SDK.\n const { wsUrl } = resolveRelayUrls(opts.url, opts.apiUrl)\n this.conn = new ConnectionManager({\n url: wsUrl,\n token: opts.token ?? opts.me,\n open,\n getCursor: () => this.store.highestSeq(),\n onFrame: (f: ServerFrame) => {\n // The server tells us our CANONICAL id on auth (a signed JWT's sub,\n // not the raw token) — capture it so `mine` checks work under every\n // identity tier.\n if (f.type === 'authed') this._me = f.userId as UserId\n this.store.apply(f); this.emit()\n },\n onStatusChange: (s, msg) => { this._status = s; this._statusMessage = msg; this.emit() },\n })\n this.conn.connect()\n }\n\n /** Subscribe to any change (message, typing, status). Returns unsubscribe. */\n onChange(fn: () => void): () => void {\n this.listeners.add(fn)\n return () => this.listeners.delete(fn)\n }\n private emit(): void { for (const fn of this.listeners) fn() }\n\n private _me: UserId | undefined\n /** Our canonical user id as resolved by the server (JWT sub / userId / anon id). */\n get me(): UserId | undefined { return this._me }\n get conversationId(): ConversationId | undefined { return this.store.conversationId }\n get status(): string { return this._status }\n get statusMessage(): string | undefined { return this._statusMessage }\n\n send(text: string): void {\n const clientMsgId = `c_${Date.now().toString(36)}_${++this.msgSeq}`\n const cid = this.store.conversationId\n if (!cid) return\n this.store.addOptimistic(clientMsgId, { kind: 'text', text })\n this.conn.send({ type: 'send', conversationId: cid, clientMsgId, content: { kind: 'text', text } })\n this.emit()\n }\n\n typing(isTyping: boolean, preview?: string): void {\n const cid = this.store.conversationId\n if (!cid) return\n this.conn.send({ type: 'typing', conversationId: cid, isTyping, ...(preview ? { preview } : {}) })\n }\n\n markRead(): void {\n const cid = this.store.conversationId\n if (!cid) return\n this.conn.send({ type: 'read', conversationId: cid, seq: this.store.highestSeq() })\n }\n\n close(): void { this.conn.close(); this.listeners.clear() }\n}\n\nexport class RelayClient {\n constructor(private readonly opts: RelayClientOptions) {}\n\n /** The identity this client will act as: the token's subject (resolved\n * server-side), the raw userId, or a persistent anonymous browser id. */\n me(): UserId {\n return asUserId(this.opts.token ?? persistentUid())\n }\n\n open(open: OpenOptions = {}): RelayConversation {\n return new RelayConversation({ ...this.opts, ...open, me: this.me() })\n }\n}\n"],"names":["TERMINAL_STATES","isTerminalState","state","toManifestAction","a","input","LIMITS","RelayConversation","opts","__publicField","ChatStore","open","asUserId","wsUrl","resolveRelayUrls","ConnectionManager","f","s","msg","fn","text","clientMsgId","cid","isTyping","preview","RelayClient","persistentUid"],"mappings":"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"}
1
+ {"version":3,"file":"core.js","sources":["../src/protocol/entities.ts","../src/protocol/actions.ts","../src/protocol/frames.ts","../src/core.ts"],"sourcesContent":["import type {\n ConversationId, MessageId, ProfileId, SubjectId, TenantId, UserId,\n} from './ids.js'\nimport type { ActionId } from './ids.js'\n\n// ── Message content (discriminated union) ─────────────────────────────────────\n// The runtime stays generic by never hard-coding business content: a message is\n// one of a small, fixed set of shapes. `card`/`form`/`system` are how action\n// results and structured prompts render — they subsume most \"rich messaging\"\n// features without a per-feature content zoo.\n\nexport interface CardField { label: string; value: string }\n\n/** A reference to an action a card/quick-reply can invoke. */\nexport interface InlineActionRef { actionId: ActionId; label: string }\n\nexport type MessageContent =\n | { kind: 'text'; text: string; enc?: boolean; iv?: string }\n | { kind: 'attachment'; url: string; mime: string; name?: string; size?: number }\n | { kind: 'card'; title?: string; body?: string; fields?: CardField[]; actions?: InlineActionRef[] }\n | { kind: 'form'; prompt: string; actionId: ActionId }\n | { kind: 'system'; event: string; data?: Record<string, string | number | boolean> }\n | { kind: 'appointment'; title: string; startIso: string; endIso: string; location?: string; description?: string; googleUrl: string; icalUrl: string; confirmed?: boolean }\n\nexport type SenderRole = 'guest' | 'agent' | 'system' | 'bot'\n\n// ── Message ───────────────────────────────────────────────────────────────────\n// Ordering is by `seq` (server-assigned, monotonic per conversation), never by\n// `ts`. `ts` is wall-clock for display only. This kills the reorder/duplicate/\n// lost-on-reconnect class of bugs that millisecond-timestamp ordering caused.\n\nexport interface Message {\n id: MessageId\n conversationId: ConversationId\n seq: number\n senderId: UserId\n senderRole: SenderRole\n content: MessageContent\n ts: number\n replyToId?: MessageId\n editedAt?: number\n deletedAt?: number\n reactions?: Record<string, UserId[]>\n internal?: boolean // true = internal note, only visible to agents\n}\n\n// ── Conversation (the room; messages partition by conversationId) ─────────────\n// The room is the conversation, NOT the subject. Two guests discussing the same\n// subject get two conversations. `subjectId` is a nullable reference, never part\n// of the room identity — so \"one thread per (guest, subject)\" is enforced as\n// app logic at open-time, and many-threads-per-subject stays possible for free.\n\nexport interface Conversation {\n id: ConversationId\n tenantId: TenantId\n profileId: ProfileId // behavior profile → which actions this room has\n subjectId?: SubjectId // optional: the thing it's about\n guestId: UserId // the end-user\n /** Host-supplied display info for an identified guest (widget `user` option).\n * Display metadata only — identity is still the token/guestId. */\n guestName?: string\n guestEmail?: string\n guestAvatar?: string\n guestMeta?: Record<string, string>\n /** True once the guest's identity has been proven by a signed ES256 JWT\n * against the chatroom's guestPublicKey (secure identity mode). */\n guestVerified?: boolean\n participants: UserId[] // guest + any assigned agents (membership = authz)\n assignedAgentId?: UserId // routing/ownership\n aiActive?: boolean // staff assigned the AI to answer this room\n state: string // conversation state-machine state\n firstResponseAt?: number // first agent reply ts (SLA)\n csat?: number // satisfaction score 1–5 (set on resolution)\n lastSeq: number // highest seq assigned in this conversation\n tags?: string[] // macro/manual tags (e.g. \"refund\", \"vip\")\n /** Live sentiment of the guest's most recent message (best-effort, async). */\n sentiment?: 'positive' | 'neutral' | 'frustrated'\n /** -1 (very frustrated) .. +1 (very positive); paired with `sentiment`. */\n sentimentScore?: number\n /** Set once an SLA-breach escalation macro has fired, so it only fires once. */\n slaEscalatedAt?: number\n /** If set, the conversation is snoozed until this Unix ms timestamp.\n * Hidden from the inbox until the timestamp passes, then resurfaces. */\n snoozedUntil?: number\n /** Page URL where the widget was open when the conversation started. */\n pageUrl?: string\n /** Browser tab title at conversation start — gives agents context. */\n pageTitle?: string\n createdAt: number\n updatedAt: number\n}\n\n// ── Co-browsing / shared annotation ────────────────────────────────────────---\n// A lightweight shared whiteboard layered over a conversation: agent and guest\n// can draw freehand strokes that both sides see live. Strokes are relayed\n// (not stored as messages) and kept per-conversation so late joiners can catch\n// up via the `opened` frame's `annotations` field.\n\nexport interface AnnotationPoint { x: number; y: number }\nexport interface AnnotationStroke {\n id: string\n points: AnnotationPoint[]\n color: string\n width: number\n by: UserId\n}\n\n// ── Subject (the referenced entity — Intercom \"custom object\") ────────────────\n// Carries shared state (available → reserved → sold) and fields (price, vin…)\n// that actions read/write. Many conversations reference one subject. Never a room.\n\nexport interface Subject {\n id: SubjectId\n tenantId: TenantId\n title: string\n state: string\n fields: Record<string, string | number | boolean>\n /** URL of the page where the subject lives (e.g. the listing page URL).\n * Captured automatically by the widget and stored on first open. */\n url?: string\n createdAt: number\n updatedAt: number\n}\n\n// ── Conversation lifecycle ────────────────────────────────────────────────────\n/** States in which a conversation is no longer \"open\": it's done with, so inbox\n * / proactive sweeps skip it and load balancing frees the assigned agent. The\n * single source of truth for \"is this conversation finished?\". */\nexport const TERMINAL_STATES: ReadonlySet<string> = new Set([\n 'resolved', 'closed', 'sold', 'issued', 'checked_out',\n])\nexport function isTerminalState(state: string): boolean {\n return TERMINAL_STATES.has(state)\n}\n\nexport type Channel = 'widget' | 'email' | 'sms' | 'whatsapp' | 'instagram' | 'kakao' | 'messenger' | 'line'\n","import type { ActionId, ProfileId, TenantId } from './ids.js'\n\n// ── Actions: the product primitive ────────────────────────────────────────────\n// An action is data an admin authors in the dashboard; the runtime stays generic\n// and only knows how to execute a small, fixed set of EFFECTS. Adding \"make\n// offer\" or \"schedule meeting\" is a config row, not a code deploy.\n\nexport type ActionAudience = 'guest' | 'agent' | 'both'\nexport type ActionSurface = 'toolbar' | 'inline' | 'quick_reply'\n\nexport interface ActionInputField {\n name: string\n label: string\n type: 'text' | 'number' | 'date' | 'select'\n required?: boolean\n options?: string[] // for type: 'select'\n}\n\n// A terminal effect produces a result and ends the action. Actions are\n// single-shot: structured multi-step lives in the conversation state machine,\n// and conversational multi-step is the `bot` effect — not an action workflow.\nexport type TerminalEffect =\n | { type: 'webhook'; url: string } // signed POST to tenant system\n | { type: 'state_transition'; target: 'conversation' | 'subject'; toState: string }\n | { type: 'bot' } // route to the AI resolver\n | { type: 'builtin'; name: string } // e.g. 'handoff'\n\n// The ONLY composition allowed is \"collect a form, then run one terminal\n// effect\" — exactly one level deep. This covers input-gathering (e.g. an offer\n// amount) without becoming a workflow engine.\nexport type ActionEffect =\n | TerminalEffect\n | { type: 'form'; fields: ActionInputField[]; then: TerminalEffect }\n\nexport type ActionResult =\n | { kind: 'system_message'; template?: string } // post a system line into the chat\n | { kind: 'card' } // render the effect's response as a card\n | { kind: 'state_badge' } // reflect a state change\n | { kind: 'none' }\n\nexport interface ActionDef {\n id: ActionId\n label: string\n icon?: string\n confirm?: boolean\n audience: ActionAudience\n surface: ActionSurface\n availableInStates?: string[] // conversation/subject states; omit = always available\n effect: ActionEffect\n result: ActionResult\n}\n\n// Client-safe projection of an action: enough for the widget to render it and\n// collect inputs, but NONE of the effect internals (webhook URLs, transition\n// targets) — those stay server-side and execute on `invoke`. The client filters\n// by `availableInStates` locally against the current conversation state, so a\n// state change needs no manifest round-trip; the server re-validates on invoke.\nexport interface ManifestAction {\n id: ActionId\n label: string\n icon?: string\n confirm?: boolean\n audience: ActionAudience\n surface: ActionSurface\n availableInStates?: string[]\n input?: ActionInputField[] // present when the action collects input (form effect)\n}\n\n/** Project an internal action to its client-safe manifest form. */\nexport function toManifestAction(a: ActionDef): ManifestAction {\n const input = a.effect.type === 'form' ? a.effect.fields : undefined\n return {\n id: a.id, label: a.label, audience: a.audience, surface: a.surface,\n ...(a.icon ? { icon: a.icon } : {}),\n ...(a.confirm ? { confirm: a.confirm } : {}),\n ...(a.availableInStates ? { availableInStates: a.availableInStates } : {}),\n ...(input ? { input } : {}),\n }\n}\n\n// ── Behavior profile (what \"domain\" becomes) ──────────────────────────────────\n// A reusable, admin-composed bundle of actions + defaults + state machine. Not a\n// built-in taxonomy — the 7 old templates become starter presets of this shape.\n// `version` lets an in-flight invocation validate against a consistent snapshot.\n\n/** Operating hours slot: 0=Sun … 6=Sat, times in \"HH:MM\" 24h local. */\nexport interface OperatingHoursSlot { day: 0|1|2|3|4|5|6; open: string; close: string }\n\nexport interface BehaviorProfile {\n id: ProfileId\n tenantId: TenantId\n name: string\n actions: ActionDef[]\n defaults: {\n greeting?: string\n theme?: { accent: string }\n e2e?: boolean\n persona?: string\n /** Paid-tier flag: when true, hides the \"Powered by Relay\" footer in the widget. */\n whiteLabel?: boolean\n /** White-label: serve/embed the widget from this hostname (e.g.\n * \"chat.acmeco.com\"). Allowed automatically as a CORS origin for the\n * control-plane API so the widget works from the custom domain. */\n customDomain?: string\n }\n states: string[]\n initialState: string\n version: number\n welcomeMessage?: string // first message guests see when opening the widget\n operatingHours?: OperatingHoursSlot[] // empty/absent = always open\n offlineMessage?: string // shown outside operating hours instead of chat\n /** Base64-encoded ECDSA P-256 SPKI public key. When set, guest tokens must be\n * signed JWTs — unsigned opaque tokens are rejected. */\n guestPublicKey?: string\n createdAt: number\n updatedAt: number\n}\n","import type {\n ConnectionId, ConversationId, MessageId, ProfileId, SubjectId, UserId,\n} from './ids.js'\nimport type { Channel, Conversation, Message, MessageContent, Subject, AnnotationStroke } from './entities.js'\nimport type { ManifestAction } from './actions.js'\n\n/** Dashboard-configured pre-chat qualification form, delivered in the manifest. */\nexport interface PreChatConfig {\n enabled: boolean\n showWhen?: 'always' | 'offline'\n fields?: ('name' | 'email' | 'phone')[]\n topics?: string[]\n callbackOption?: boolean\n title?: string\n}\n\n// ── Wire protocol ─────────────────────────────────────────────────────────────\n// One shared contract, imported by server + widget + dashboard. A change here is\n// a compile error in every consumer — which is the whole reason this lives in a\n// shared package instead of being hand-copied three times.\n\nexport type ErrorCode =\n | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' | 'BAD_REQUEST'\n | 'RATE_LIMITED' | 'PAYLOAD_TOO_LARGE' | 'CONFLICT' | 'INTERNAL'\n\nexport type ClientFrame =\n | { type: 'auth'; token: string }\n // Open an existing conversation, or find-or-create one. Find-or-create keys on\n // (guest, subject) when subjectId is given; otherwise a fresh conversation.\n | { type: 'open'; conversationId?: ConversationId; subjectId?: SubjectId; profileId?: ProfileId; pageUrl?: string; pageTitle?: string; subjectTitle?: string; subjectMeta?: string; linkFrom?: UserId;\n /** Host-supplied display info for the guest — persisted onto the\n * conversation server-side so agents see who they're talking to.\n * Display metadata only, never used for authorization. */\n userInfo?: { name?: string; email?: string; avatar?: string; meta?: Record<string, string> } }\n | { type: 'send'; conversationId: ConversationId; clientMsgId: string; content: MessageContent; replyToId?: MessageId; via?: Channel[] }\n | { type: 'sync'; conversationId: ConversationId; sinceSeq: number } // catch-up after cursor\n | { type: 'history'; conversationId: ConversationId; beforeSeq: number; limit?: number } // load older\n | { type: 'read'; conversationId: ConversationId; seq: number } // read up to seq\n | { type: 'typing'; conversationId: ConversationId; isTyping: boolean; preview?: string }\n | { type: 'react'; conversationId: ConversationId; messageId: MessageId; emoji: string; remove?: boolean }\n | { type: 'edit'; conversationId: ConversationId; messageId: MessageId; content: MessageContent }\n | { type: 'delete'; conversationId: ConversationId; messageId: MessageId }\n | { type: 'invoke'; conversationId: ConversationId; actionId: string; clientInvokeId: string; inputs?: Record<string, unknown> }\n | { type: 'assign'; conversationId: ConversationId; agentId: UserId | null } // null = unassign\n | { type: 'tag'; conversationId: ConversationId; tag: string; remove?: boolean }\n | { type: 'note'; conversationId: ConversationId; clientMsgId: string; text: string } // internal note\n | { type: 'agent_status'; status: 'online' | 'away' | 'offline' } // agent sets their availability\n // Co-browsing: a freehand stroke (or \"clear\") on the shared annotation canvas\n // for a subject-anchored conversation. Relayed live to the other participant.\n | { type: 'annotate'; conversationId: ConversationId; stroke: Omit<AnnotationStroke, 'by'> }\n | { type: 'annotate_clear'; conversationId: ConversationId }\n | { type: 'pubkey'; conversationId: ConversationId; key: string }\n // X3DH async E2E: a client uploads a batch of one-time prekeys so peers can\n // encrypt to them while they are offline. The server stores them opaquely and\n // vends one on demand — it never derives or uses the keys.\n | { type: 'uploadPrekeys'; identityKey: string; signedPrekey: string; signedPrekeyId: string; signature: string; oneTimePrekeys: string[] }\n | { type: 'fetchPrekey'; targetUserId: UserId }\n // Inbox stream subscription — used by the agent dashboard, which reuses this\n // ConnectionManager. Typed here so the dashboard doesn't need `as never`.\n | { type: 'subscribe_inbox' }\n | { type: 'unsubscribe_inbox' }\n | { type: 'ping' }\n\nexport type ServerFrame =\n | { type: 'authed'; userId: UserId; connectionId: ConnectionId }\n | { type: 'opened'; conversation: Conversation; subject?: Subject; annotations?: AnnotationStroke[] }\n | { type: 'manifest'; conversationId: ConversationId; actions: ManifestAction[]; version: number; name?: string; theme?: { accent: string }; e2e?: boolean; offline?: boolean; offlineMessage?: string; whiteLabel?: boolean; preChat?: PreChatConfig }\n | { type: 'message'; message: Message }\n | { type: 'ack'; clientMsgId: string; messageId: MessageId; seq: number; ts: number }\n | { type: 'delivered'; conversationId: ConversationId; seq: number; to: UserId }\n | { type: 'read'; conversationId: ConversationId; seq: number; by: UserId }\n | { type: 'sync'; conversationId: ConversationId; messages: Message[] }\n | { type: 'history'; conversationId: ConversationId; messages: Message[]; hasMore: boolean }\n | { type: 'typing'; conversationId: ConversationId; userId: UserId; isTyping: boolean; preview?: string }\n | { type: 'reaction'; conversationId: ConversationId; messageId: MessageId; emoji: string; by: UserId; removed: boolean }\n | { type: 'edited'; conversationId: ConversationId; messageId: MessageId; content: MessageContent; editedAt: number }\n | { type: 'deleted'; conversationId: ConversationId; messageId: MessageId; ts: number }\n | { type: 'state'; conversationId: ConversationId; state: string }\n | { type: 'assigned'; conversationId: ConversationId; agentId: UserId | null }\n | { type: 'tagged'; conversationId: ConversationId; tag: string; removed: boolean }\n | { type: 'visitor_count'; count: number } // broadcast to agents: guests currently connected\n | { type: 'agent_status_changed'; agentId: UserId; status: 'online' | 'away' | 'offline' }\n // Live sentiment of a guest's most recent message — relayed to agents only so\n // the inbox can flag frustrated conversations as they happen.\n | { type: 'sentiment'; conversationId: ConversationId; label: 'positive' | 'neutral' | 'frustrated'; score: number }\n // Co-browsing: relay of an annotation stroke / clear to everyone in the room.\n | { type: 'annotation'; conversationId: ConversationId; stroke: AnnotationStroke }\n | { type: 'annotation_clear'; conversationId: ConversationId; by: UserId }\n | { type: 'subjectState'; subjectId: SubjectId; state: string }\n | { type: 'presence'; conversationId: ConversationId; userId: UserId; status: 'online' | 'offline'; lastSeen?: number }\n | { type: 'invoked'; clientInvokeId: string; ok: boolean; error?: string }\n | { type: 'error'; code: ErrorCode; message: string }\n | { type: 'peerkey'; conversationId: ConversationId; userId: UserId; key: string }\n // X3DH bundle vended to a requesting client so they can encrypt to an offline peer.\n // Contains null when the target user has no registered prekeys.\n | { type: 'prekeyBundle'; targetUserId: UserId; bundle: { identityKey: string; signedPrekey: string; signedPrekeyId: string; signature: string; oneTimePrekey?: string } | null }\n | { type: 'pong' }\n\n/** Limits referenced by both ends so validation stays consistent. */\nexport const LIMITS = {\n MAX_TEXT_LEN: 8_000,\n MAX_HISTORY_LIMIT: 100,\n DEFAULT_HISTORY: 50,\n} as const\n","// ── @paramms/chat-widget/core — the headless SDK ─────────────────────────────\n// Everything you need to build your OWN chat UI (an in-app messenger, a\n// marketplace inbox, a full chat app) on the Relay protocol, with zero DOM or\n// React dependencies. This is not a new client: it is the exact transport,\n// store, outbox, and E2E machinery the bundled widget AND the agent dashboard\n// run on — re-exported behind a stable boundary, plus a small convenience\n// client for the common case.\n//\n// import { RelayClient } from '@paramms/chat-widget/core'\n//\n// // ONE url, any scheme — wss/ws/http(s) all work; ws + REST derived from it.\n// const relay = new RelayClient({ url: 'https://api.relay.paramms.com', token, profileId: 'p_x' })\n// const convo = relay.open({ subjectId: 'listing_42' }) // support thread\n// const dm = relay.open({ kind: 'direct', peerId: 'user_bob' }) // user↔user (signed identity required)\n// convo.onChange(() => render(convo.store.messages()))\n// convo.send('hello!')\n//\n// For React, see '@paramms/chat-widget/hooks'.\nexport { ConnectionManager, type SocketLike } from './connection.js'\nexport { ChatStore } from './store.js'\nexport { PersistentOutbox } from './outbox.js'\nexport { E2ESession } from './e2e.js'\nexport { restoreHistory, resolveRelayUrls, httpBaseFromWsUrl } from './history.js'\nexport { mountChatList, type ChatListEntry, type ChatListHandle, type ChatListOptions } from './chatlist.js'\nexport { persistentUid } from './uid.js'\nexport * from './protocol/index.js'\n\nimport { ConnectionManager } from './connection.js'\nimport { ChatStore } from './store.js'\nimport type { ClientFrame, ServerFrame, ConversationId, UserId } from './protocol/index.js'\nimport { asUserId } from './protocol/index.js'\nimport { persistentUid } from './uid.js'\nimport { resolveRelayUrls } from './history.js'\n\nexport interface RelayClientOptions {\n /** Relay URL — ONE url, any scheme. `https://api.relay.paramms.com` is the\n * recommended form; the WebSocket URL (`wss://…/ws`) and REST base are\n * derived from it automatically. `wss://`/`ws://`/`http://` also accepted. */\n url: string\n /** HTTP(S) base for REST calls — only when the REST API lives on a\n * DIFFERENT origin than the socket. Normally omit.\n * @deprecated pass a single `url`; kept for back-compat. */\n apiUrl?: string\n /** Identity: a signed JWT (secure), a stable userId (host-vouched), or omit\n * for an anonymous per-browser guest (browser environments only). */\n token?: string\n /** Chatroom id (from the dashboard). Required to open conversations. */\n profileId: string\n}\n\nexport interface OpenOptions {\n /** Support thread scoped to a subject (listing/order/…): one thread per\n * (user, subject). Omit for the profile's single support thread. */\n subjectId?: string\n subjectTitle?: string\n /** User↔user conversation (requires the chatroom to have signed identity\n * and `token` to be a valid signed JWT). */\n kind?: 'direct'\n peerId?: string\n /** Display info persisted for agents (support threads only). */\n user?: { name?: string; email?: string; avatar?: string; meta?: Record<string, string> }\n}\n\n/** One conversation = one connection + one store. Deliberately thin: the\n * store is the source of truth, `onChange` is the render signal, everything\n * else is the same primitives the first-party UIs use. */\nexport class RelayConversation {\n readonly store: ChatStore\n private readonly conn: ConnectionManager\n private readonly listeners = new Set<() => void>()\n private msgSeq = 0\n private _status = 'connecting'\n private _statusMessage: string | undefined\n\n constructor(opts: RelayClientOptions & OpenOptions & { me: UserId }) {\n this.store = new ChatStore(opts.me)\n const open: Extract<ClientFrame, { type: 'open' }> = {\n type: 'open',\n profileId: opts.profileId as never,\n ...(opts.subjectId ? { subjectId: opts.subjectId as never } : {}),\n ...(opts.subjectTitle ? { subjectTitle: opts.subjectTitle } : {}),\n ...(opts.kind === 'direct' ? { kind: 'direct' as const, peerId: asUserId(opts.peerId ?? '') } : {}),\n ...(opts.user ? { userInfo: opts.user } : {}),\n }\n // Accept any scheme (https/http/wss/ws) — a plain `https://api.…` URL is\n // resolved to the concrete `wss://…/ws` socket endpoint, exactly like the\n // bundled widget's mount(). Before this, RelayClient required a raw\n // WebSocket URL while the React components took `https://` — one URL now\n // works across the entire SDK.\n const { wsUrl } = resolveRelayUrls(opts.url, opts.apiUrl)\n this.conn = new ConnectionManager({\n url: wsUrl,\n token: opts.token ?? opts.me,\n open,\n getCursor: () => this.store.highestSeq(),\n onFrame: (f: ServerFrame) => {\n // The server tells us our CANONICAL id on auth (a signed JWT's sub,\n // not the raw token) — capture it so `mine` checks work under every\n // identity tier.\n if (f.type === 'authed') this._me = f.userId as UserId\n this.store.apply(f); this.emit()\n },\n onStatusChange: (s, msg) => { this._status = s; this._statusMessage = msg; this.emit() },\n })\n this.conn.connect()\n }\n\n /** Subscribe to any change (message, typing, status). Returns unsubscribe. */\n onChange(fn: () => void): () => void {\n this.listeners.add(fn)\n return () => this.listeners.delete(fn)\n }\n private emit(): void { for (const fn of this.listeners) fn() }\n\n private _me: UserId | undefined\n /** Our canonical user id as resolved by the server (JWT sub / userId / anon id). */\n get me(): UserId | undefined { return this._me }\n get conversationId(): ConversationId | undefined { return this.store.conversationId }\n get status(): string { return this._status }\n get statusMessage(): string | undefined { return this._statusMessage }\n\n send(text: string): void {\n const clientMsgId = `c_${Date.now().toString(36)}_${++this.msgSeq}`\n const cid = this.store.conversationId\n if (!cid) return\n this.store.addOptimistic(clientMsgId, { kind: 'text', text })\n this.conn.send({ type: 'send', conversationId: cid, clientMsgId, content: { kind: 'text', text } })\n this.emit()\n }\n\n typing(isTyping: boolean, preview?: string): void {\n const cid = this.store.conversationId\n if (!cid) return\n this.conn.send({ type: 'typing', conversationId: cid, isTyping, ...(preview ? { preview } : {}) })\n }\n\n markRead(): void {\n const cid = this.store.conversationId\n if (!cid) return\n this.conn.send({ type: 'read', conversationId: cid, seq: this.store.highestSeq() })\n }\n\n close(): void { this.conn.close(); this.listeners.clear() }\n}\n\nexport class RelayClient {\n constructor(private readonly opts: RelayClientOptions) {}\n\n /** The identity this client will act as: the token's subject (resolved\n * server-side), the raw userId, or a persistent anonymous browser id. */\n me(): UserId {\n return asUserId(this.opts.token ?? persistentUid())\n }\n\n open(open: OpenOptions = {}): RelayConversation {\n return new RelayConversation({ ...this.opts, ...open, me: this.me() })\n }\n}\n"],"names":["TERMINAL_STATES","isTerminalState","state","toManifestAction","a","input","LIMITS","RelayConversation","opts","__publicField","ChatStore","open","asUserId","wsUrl","resolveRelayUrls","ConnectionManager","f","s","msg","fn","text","clientMsgId","cid","isTyping","preview","RelayClient","persistentUid"],"mappings":"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"}