@paramms/chat-widget 1.0.29 → 1.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ import { RelayConversation, type RelayClientOptions, type OpenOptions } from './core.js';
2
+ import type { RenderMessage } from './store.js';
3
+ export type UseRelayConversationOptions = RelayClientOptions & OpenOptions;
4
+ export interface RelayConversationHook {
5
+ /** Messages in seq order (optimistic sends included, pending-marked). */
6
+ messages: RenderMessage[];
7
+ send: (text: string) => void;
8
+ /** Call from your input's onChange for typing indicators. */
9
+ typing: (isTyping: boolean) => void;
10
+ markRead: () => void;
11
+ /** connecting | connected | reconnecting | error */
12
+ status: string;
13
+ statusMessage: string | undefined;
14
+ conversationId: string | undefined;
15
+ /** Our canonical user id (server-resolved) — compare with message.senderId for "mine". */
16
+ me: string | undefined;
17
+ /** userIds currently typing (excluding you). */
18
+ peersTyping: string[];
19
+ /** The underlying conversation for anything the hook doesn't surface
20
+ * (reactions, invoke, raw store). */
21
+ conversation: RelayConversation;
22
+ }
23
+ /** One live conversation (support thread or DM). The connection opens on
24
+ * mount and closes on unmount; options changes reopen. */
25
+ export declare function useRelayConversation(opts: UseRelayConversationOptions): RelayConversationHook;
26
+ export interface ChatListRow {
27
+ conversationId: string;
28
+ subjectId?: string;
29
+ title?: string;
30
+ lastMessage?: string;
31
+ updatedAt: number;
32
+ unread?: boolean;
33
+ }
34
+ /** The current user's conversation list for a chatroom (the "inbox" screen of
35
+ * a chat app). Polls the REST endpoint; pass `refreshMs: 0` to fetch once. */
36
+ export declare function useRelayChatList(opts: RelayClientOptions & {
37
+ refreshMs?: number;
38
+ }): {
39
+ conversations: ChatListRow[];
40
+ loading: boolean;
41
+ error: string | null;
42
+ refresh: () => void;
43
+ };
package/dist/hooks.js ADDED
@@ -0,0 +1,71 @@
1
+ import { useState as d, useEffect as k, useRef as y, useSyncExternalStore as M, useMemo as h } from "react";
2
+ import { RelayClient as j } from "./core.js";
3
+ import { r as b } from "./uid.js";
4
+ function E(e) {
5
+ var l;
6
+ const c = `${e.url}|${e.token ?? ""}|${e.profileId}|${e.subjectId ?? ""}|${e.kind ?? ""}|${e.peerId ?? ""}`, s = y(null);
7
+ (!s.current || s.current.key !== c) && ((l = s.current) == null || l.convo.close(), s.current = { key: c, convo: new j(e).open(e) });
8
+ const n = s.current.convo;
9
+ k(() => () => {
10
+ var t;
11
+ (t = s.current) == null || t.convo.close(), s.current = null;
12
+ }, []);
13
+ const o = y(0), i = M(
14
+ (t) => n.onChange(() => {
15
+ o.current++, t();
16
+ }),
17
+ () => o.current,
18
+ () => o.current
19
+ ), u = h(() => n.store.messages(), [n, i]), f = h(() => [...n.store.typing], [n, i]);
20
+ return {
21
+ messages: u,
22
+ send: (t) => n.send(t),
23
+ typing: (t) => n.typing(t),
24
+ markRead: () => n.markRead(),
25
+ status: n.status,
26
+ statusMessage: n.statusMessage,
27
+ conversationId: n.conversationId,
28
+ me: n.me,
29
+ peersTyping: f,
30
+ conversation: n
31
+ };
32
+ }
33
+ function S(e) {
34
+ const [c, s] = d([]), [n, o] = d(!0), [i, u] = d(null), [f, l] = d(0);
35
+ return k(() => {
36
+ let t = !1;
37
+ const { httpBase: R } = b(e.url), v = e.token ?? "";
38
+ async function m() {
39
+ try {
40
+ const a = await fetch(`${R}/conversations/mine?profileId=${encodeURIComponent(e.profileId)}`, {
41
+ headers: v ? { authorization: `Bearer ${v}` } : {}
42
+ });
43
+ if (!a.ok) throw new Error(`chat list failed: ${a.status}`);
44
+ const $ = await a.json();
45
+ if (t) return;
46
+ s($.conversations.map((r) => ({
47
+ conversationId: r.id,
48
+ ...r.subjectId ? { subjectId: r.subjectId } : {},
49
+ ...r.subjectTitle ? { title: r.subjectTitle } : {},
50
+ ...r.lastMessage ? { lastMessage: r.lastMessage } : {},
51
+ updatedAt: r.updatedAt,
52
+ ...r.unread !== void 0 ? { unread: r.unread } : {}
53
+ }))), u(null);
54
+ } catch (a) {
55
+ t || u(a.message);
56
+ } finally {
57
+ t || o(!1);
58
+ }
59
+ }
60
+ m();
61
+ const g = e.refreshMs ?? 15e3, I = g > 0 ? setInterval(() => void m(), g) : null;
62
+ return () => {
63
+ t = !0, I && clearInterval(I);
64
+ };
65
+ }, [e.url, e.token, e.profileId, e.refreshMs, f]), { conversations: c, loading: n, error: i, refresh: () => l((t) => t + 1) };
66
+ }
67
+ export {
68
+ S as useRelayChatList,
69
+ E as useRelayConversation
70
+ };
71
+ //# sourceMappingURL=hooks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hooks.js","sources":["../src/hooks.ts"],"sourcesContent":["// ── @paramms/chat-widget/hooks — React bindings for the headless SDK ─────────\n// Build your own chat UI with two hooks. Same primitives as the bundled\n// widget and the agent dashboard — these are bindings, not a reimplementation.\n//\n// const { messages, send, status, typing } = useRelayConversation({\n// url, token, profileId: 'p_x', subjectId: 'listing_42',\n// })\n// const dm = useRelayConversation({ url, token, profileId: 'p_x', kind: 'direct', peerId: 'user_bob' })\n// const { conversations } = useRelayChatList({ url, token, profileId: 'p_x' })\nimport { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'\nimport { RelayClient, RelayConversation, type RelayClientOptions, type OpenOptions } from './core.js'\nimport { resolveRelayUrls } from './history.js'\nimport type { RenderMessage } from './store.js'\n\nexport type UseRelayConversationOptions = RelayClientOptions & OpenOptions\n\nexport interface RelayConversationHook {\n /** Messages in seq order (optimistic sends included, pending-marked). */\n messages: RenderMessage[]\n send: (text: string) => void\n /** Call from your input's onChange for typing indicators. */\n typing: (isTyping: boolean) => void\n markRead: () => void\n /** connecting | connected | reconnecting | error */\n status: string\n statusMessage: string | undefined\n conversationId: string | undefined\n /** Our canonical user id (server-resolved) — compare with message.senderId for \"mine\". */\n me: string | undefined\n /** userIds currently typing (excluding you). */\n peersTyping: string[]\n /** The underlying conversation for anything the hook doesn't surface\n * (reactions, invoke, raw store). */\n conversation: RelayConversation\n}\n\n/** One live conversation (support thread or DM). The connection opens on\n * mount and closes on unmount; options changes reopen. */\nexport function useRelayConversation(opts: UseRelayConversationOptions): RelayConversationHook {\n // Key that captures every option that requires a NEW conversation/socket.\n const key = `${opts.url}|${opts.token ?? ''}|${opts.profileId}|${opts.subjectId ?? ''}|${opts.kind ?? ''}|${opts.peerId ?? ''}`\n const convoRef = useRef<{ key: string; convo: RelayConversation } | null>(null)\n if (!convoRef.current || convoRef.current.key !== key) {\n convoRef.current?.convo.close()\n convoRef.current = { key, convo: new RelayClient(opts).open(opts) }\n }\n const convo = convoRef.current.convo\n\n // Close on unmount (and on Strict-Mode double-invoke, reopen lazily above).\n useEffect(() => () => { convoRef.current?.convo.close(); convoRef.current = null }, [])\n\n // Subscribe React to the store via useSyncExternalStore: `version` bumps on\n // every change, and the snapshot getters below memo off it. (This is the\n // lesson from the dashboard's stale-useMemo bug baked into the API.)\n const versionRef = useRef(0)\n const version = useSyncExternalStore(\n (onStoreChange) => convo.onChange(() => { versionRef.current++; onStoreChange() }),\n () => versionRef.current,\n () => versionRef.current,\n )\n\n const messages = useMemo(() => convo.store.messages(), [convo, version])\n const peersTyping = useMemo(() => [...convo.store.typing], [convo, version])\n\n return {\n messages,\n send: (text: string) => convo.send(text),\n typing: (isTyping: boolean) => convo.typing(isTyping),\n markRead: () => convo.markRead(),\n status: convo.status,\n statusMessage: convo.statusMessage,\n conversationId: convo.conversationId as string | undefined,\n me: convo.me as string | undefined,\n peersTyping,\n conversation: convo,\n }\n}\n\nexport interface ChatListRow {\n conversationId: string\n subjectId?: string\n title?: string\n lastMessage?: string\n updatedAt: number\n unread?: boolean\n}\n\n/** The current user's conversation list for a chatroom (the \"inbox\" screen of\n * a chat app). Polls the REST endpoint; pass `refreshMs: 0` to fetch once. */\nexport function useRelayChatList(opts: RelayClientOptions & { refreshMs?: number }): {\n conversations: ChatListRow[]\n loading: boolean\n error: string | null\n refresh: () => void\n} {\n const [conversations, setConversations] = useState<ChatListRow[]>([])\n const [loading, setLoading] = useState(true)\n const [error, setError] = useState<string | null>(null)\n const [nonce, setNonce] = useState(0)\n\n useEffect(() => {\n let cancelled = false\n const { httpBase } = resolveRelayUrls(opts.url)\n const token = opts.token ?? ''\n async function load(): Promise<void> {\n try {\n const res = await fetch(`${httpBase}/conversations/mine?profileId=${encodeURIComponent(opts.profileId)}`, {\n headers: token ? { authorization: `Bearer ${token}` } : {},\n })\n if (!res.ok) throw new Error(`chat list failed: ${res.status}`)\n const data = await res.json() as { conversations: Array<{ id: string; subjectId?: string; subjectTitle?: string; lastMessage?: string; updatedAt: number; unread?: boolean }> }\n if (cancelled) return\n setConversations(data.conversations.map(c => ({\n conversationId: c.id,\n ...(c.subjectId ? { subjectId: c.subjectId } : {}),\n ...(c.subjectTitle ? { title: c.subjectTitle } : {}),\n ...(c.lastMessage ? { lastMessage: c.lastMessage } : {}),\n updatedAt: c.updatedAt,\n ...(c.unread !== undefined ? { unread: c.unread } : {}),\n })))\n setError(null)\n } catch (e) {\n if (!cancelled) setError((e as Error).message)\n } finally {\n if (!cancelled) setLoading(false)\n }\n }\n void load()\n const ms = opts.refreshMs ?? 15_000\n const timer = ms > 0 ? setInterval(() => void load(), ms) : null\n return () => { cancelled = true; if (timer) clearInterval(timer) }\n }, [opts.url, opts.token, opts.profileId, opts.refreshMs, nonce])\n\n return { conversations, loading, error, refresh: () => setNonce(n => n + 1) }\n}\n"],"names":["useRelayConversation","opts","key","convoRef","useRef","_a","RelayClient","convo","useEffect","versionRef","version","useSyncExternalStore","onStoreChange","messages","useMemo","peersTyping","text","isTyping","useRelayChatList","conversations","setConversations","useState","loading","setLoading","error","setError","nonce","setNonce","cancelled","httpBase","resolveRelayUrls","token","load","res","data","c","e","ms","timer","n"],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -34,6 +34,9 @@ export interface MountOptions {
34
34
  * origin (first-party cookie + localStorage). A returning visitor on the same
35
35
  * browser keeps their history; if storage is blocked they get a fresh chat. */
36
36
  token?: string;
37
+ /** Called when a signed token is rejected (expired): return a fresh token
38
+ * from your backend to renew the session without a reload. */
39
+ refreshToken?: () => Promise<string | null>;
37
40
  userId?: string;
38
41
  subject?: WidgetConfig['subject'];
39
42
  quickReplies?: string[];
@@ -0,0 +1,176 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Relay Chat Widget</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ html, body { height: 100%; }
10
+ body {
11
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
12
+ min-height: 100vh;
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: center;
16
+ padding: 24px;
17
+ }
18
+
19
+ /* Marketing mode (no profileId in URL): purple gradient + info column */
20
+ body.marketing {
21
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
22
+ }
23
+ .container {
24
+ max-width: 900px;
25
+ width: 100%;
26
+ display: flex;
27
+ gap: 48px;
28
+ align-items: flex-start;
29
+ }
30
+ .info { flex: 1; color: #fff; }
31
+ .info h1 { font-size: 36px; font-weight: 800; margin-bottom: 12px; }
32
+ .info p { font-size: 16px; opacity: .85; line-height: 1.6; margin-bottom: 24px; }
33
+ .snippet {
34
+ background: rgba(0,0,0,.3);
35
+ border-radius: 12px;
36
+ padding: 20px;
37
+ font-family: 'SF Mono', 'Fira Code', monospace;
38
+ font-size: 13px;
39
+ color: #e2e8f0;
40
+ line-height: 1.6;
41
+ white-space: pre;
42
+ }
43
+
44
+ /* Widget-only mode (?profileId=... present): bare contained card on a
45
+ neutral backdrop — what the widget actually looks like embedded on a
46
+ real page, just centered for easy viewing/sharing as a test link. */
47
+ body.widget-only { background: #e9eaee; }
48
+
49
+ /* Both modes use the same card shape for the widget itself */
50
+ .preview {
51
+ width: 390px;
52
+ height: 680px;
53
+ max-width: calc(100vw - 48px);
54
+ max-height: calc(100vh - 48px);
55
+ border-radius: 22px;
56
+ overflow: hidden;
57
+ box-shadow: 0 24px 60px rgba(0,0,0,.25);
58
+ background: #fff;
59
+ flex-shrink: 0;
60
+ }
61
+
62
+ #app { width: 100%; height: 100%; }
63
+ </style>
64
+ </head>
65
+ <body>
66
+ <div id="marketing-info" class="info" style="display:none">
67
+ <h1>Relay Chat Widget</h1>
68
+ <p>Add a real-time chat widget to any website in two lines of code.</p>
69
+ <div class="snippet">&lt;div id="chat"&gt;&lt;/div&gt;
70
+ &lt;script type="module"&gt;
71
+ import { mount } from 'https://relay.paramms.com/index.js'
72
+ mount({
73
+ el: document.getElementById('chat'),
74
+ url: 'wss://api.paramms.com/ws',
75
+ profileId: 'YOUR_PROFILE_ID',
76
+ })
77
+ &lt;/script&gt;</div>
78
+ </div>
79
+ <div id="layout"></div>
80
+ <script type="module">
81
+ import { mount } from './index.js'
82
+
83
+ const q = new URLSearchParams(location.search)
84
+ const profileId = q.get('profileId')
85
+ const isWidgetOnly = !!profileId
86
+
87
+ document.body.className = isWidgetOnly ? 'widget-only' : 'marketing'
88
+
89
+ const layout = document.getElementById('layout')
90
+ const preview = document.createElement('div')
91
+ preview.className = 'preview'
92
+ const app = document.createElement('div')
93
+ app.id = 'app'
94
+ preview.append(app)
95
+
96
+ if (isWidgetOnly) {
97
+ // Just the contained card, centered on a neutral backdrop.
98
+ layout.append(preview)
99
+ } else {
100
+ // Marketing landing page: info column + card preview, side by side.
101
+ const container = document.createElement('div')
102
+ container.className = 'container'
103
+ const info = document.getElementById('marketing-info')
104
+ info.style.display = ''
105
+ container.append(info, preview)
106
+ layout.append(container)
107
+ }
108
+
109
+ const wsUrl = q.get('url') || 'wss://api.paramms.com/ws'
110
+ const subjectId = q.get('subjectId') || undefined
111
+
112
+ // ── Persistent guest identity ────────────────────────────────────────────
113
+ // Resolved here and persisted via BOTH a first-party cookie and localStorage.
114
+ // On this top-level page the cookie survives refreshes even when localStorage
115
+ // is cleared/blocked, so the guest keeps the same id across reloads and the
116
+ // server resumes their existing conversation instead of starting a new chat.
117
+ function readCookie(name) {
118
+ const m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'))
119
+ return m ? decodeURIComponent(m[1]) : null
120
+ }
121
+ function persistentGuestId() {
122
+ let id = q.get('uid') // explicit pin wins
123
+ if (!id) { try { id = localStorage.getItem('oc_uid') } catch (e) {} }
124
+ if (!id) id = readCookie('oc_uid')
125
+ if (!id) id = 'g_' + Math.random().toString(36).slice(2) + Date.now().toString(36)
126
+ try { localStorage.setItem('oc_uid', id) } catch (e) {}
127
+ const secure = location.protocol === 'https:' ? '; Secure' : ''
128
+ document.cookie = 'oc_uid=' + encodeURIComponent(id) +
129
+ '; Max-Age=' + (60 * 60 * 24 * 365) + '; Path=/; SameSite=Lax' + secure
130
+ return id
131
+ }
132
+
133
+ // Prefer SERVER-OWNED identity: the relay mints a signed guest token and
134
+ // sets an httpOnly cookie scoped to the parent domain, so the same guest is
135
+ // recognised across refreshes regardless of client storage. Falls back to
136
+ // the client-persisted id if the relay can't be reached — and NEVER hangs:
137
+ // fetch() has no built-in timeout, so without this a slow/unreachable relay
138
+ // would leave the promise pending forever and the widget would never mount
139
+ // (a blank page). We race it against a short timeout.
140
+ let apiBase = wsUrl.replace('wss://', 'https://').replace('ws://', 'http://')
141
+ if (apiBase.endsWith('/ws')) apiBase = apiBase.slice(0, -3)
142
+ async function resolveToken() {
143
+ const pinned = q.get('token') || q.get('uid') // host-supplied identity wins
144
+ if (pinned) return pinned
145
+ try {
146
+ const ctrl = new AbortController()
147
+ const timer = setTimeout(() => ctrl.abort(), 3000)
148
+ const r = await fetch(apiBase + '/auth/guest', { method: 'POST', credentials: 'include', signal: ctrl.signal })
149
+ clearTimeout(timer)
150
+ if (r.ok) { const d = await r.json(); if (d && d.token) return d.token }
151
+ } catch (e) { /* unreachable/slow/aborted — fall back below */ }
152
+ return persistentGuestId()
153
+ }
154
+
155
+ // Mount only ever depends on a resolved token, and any failure surfaces as a
156
+ // visible message rather than a blank page.
157
+ resolveToken()
158
+ .catch(() => persistentGuestId())
159
+ .then((token) => {
160
+ try {
161
+ mount({
162
+ el: app,
163
+ token,
164
+ url: wsUrl,
165
+ profileId: profileId || 'p_hotel',
166
+ ...(subjectId ? { subjectId } : {}),
167
+ accent: q.get('accent') || '#4F63F5',
168
+ })
169
+ } catch (err) {
170
+ app.innerHTML = '<div style="padding:24px;font:14px system-ui;color:#b91c1c">' +
171
+ 'Chat failed to load. ' + (err && err.message ? String(err.message) : '') + '</div>'
172
+ }
173
+ })
174
+ </script>
175
+ </body>
176
+ </html>