@paramms/chat-widget 0.1.0 → 1.0.1

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.
Files changed (51) hide show
  1. package/README.md +138 -12
  2. package/dist/connection.d.ts +48 -0
  3. package/dist/crypto.d.ts +69 -0
  4. package/dist/e2e.d.ts +75 -0
  5. package/dist/history.d.ts +14 -0
  6. package/dist/index.d.ts +60 -0
  7. package/dist/index.js +2638 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/outbox.d.ts +20 -0
  10. package/dist/protocol/actions.d.ts +98 -0
  11. package/dist/protocol/codec.d.ts +12 -0
  12. package/dist/protocol/entities.d.ts +109 -0
  13. package/dist/protocol/frames.d.ts +248 -0
  14. package/dist/protocol/ids.d.ts +22 -0
  15. package/dist/protocol/index.d.ts +5 -0
  16. package/dist/react.d.ts +124 -0
  17. package/dist/react.js +143 -0
  18. package/dist/react.js.map +1 -0
  19. package/dist/renderer.d.ts +163 -0
  20. package/dist/store.d.ts +48 -0
  21. package/package.json +31 -2
  22. package/build-preview.js +0 -136
  23. package/index.html +0 -37
  24. package/src/__tests__/chatlist.test.ts +0 -133
  25. package/src/__tests__/connection.test.ts +0 -163
  26. package/src/__tests__/crypto.test.ts +0 -28
  27. package/src/__tests__/history.test.ts +0 -91
  28. package/src/__tests__/ime.test.ts +0 -93
  29. package/src/__tests__/render.test.ts +0 -58
  30. package/src/__tests__/render_new.test.ts +0 -441
  31. package/src/__tests__/store.test.ts +0 -86
  32. package/src/__tests__/x3dh.test.ts +0 -204
  33. package/src/connection.ts +0 -133
  34. package/src/crypto.ts +0 -252
  35. package/src/e2e.ts +0 -161
  36. package/src/history.ts +0 -43
  37. package/src/index.ts +0 -380
  38. package/src/outbox.ts +0 -58
  39. package/src/protocol/actions.ts +0 -114
  40. package/src/protocol/codec.ts +0 -35
  41. package/src/protocol/entities.ts +0 -104
  42. package/src/protocol/frames.ts +0 -86
  43. package/src/protocol/ids.ts +0 -27
  44. package/src/protocol/index.ts +0 -5
  45. package/src/react.tsx +0 -37
  46. package/src/renderer.ts +0 -906
  47. package/src/store.ts +0 -207
  48. package/tsconfig.json +0 -33
  49. package/vercel.json +0 -22
  50. package/vite.config.ts +0 -26
  51. package/vitest.config.ts +0 -2
package/src/e2e.ts DELETED
@@ -1,161 +0,0 @@
1
- import type { MessageContent, UserId } from './protocol/index.js'
2
- import type { ServerFrame } from './protocol/index.js'
3
- import {
4
- type KeyPair, loadOrCreateKeyPair, exportPublicKey, deriveSharedKey, encrypt, decrypt,
5
- generateKeyPair, signPrekey, x3dhSend, x3dhReceive, type X3DHBundle,
6
- loadOrCreateIdentityKeyPair, type IdentityKeyPair,
7
- } from './crypto.js'
8
-
9
- // Number of one-time prekeys to generate per upload batch.
10
- const OTP_BATCH_SIZE = 20
11
-
12
- /**
13
- * Per-user E2E session. Supports two modes:
14
- *
15
- * LIVE (original): Both parties are online. ECDH P-256 key exchange via the
16
- * `pubkey`/`peerkey` frames. Instant but requires both parties to be connected.
17
- *
18
- * ASYNC (X3DH): The sender encrypts to the recipient's prekey bundle while the
19
- * recipient is offline. Uses X3DH (Extended Triple DH) with identity keys,
20
- * signed prekeys, and one-time prekeys. The recipient derives the same shared
21
- * key from the init message when they come online.
22
- *
23
- * Both modes produce an AES-GCM 256 shared key for message encryption.
24
- */
25
- export class E2ESession {
26
- // Live ECDH mode state
27
- private kp?: KeyPair
28
- private shared?: CryptoKey
29
-
30
- // X3DH async mode state
31
- private identityKP: IdentityKeyPair | undefined = undefined
32
- private signedPreKP: KeyPair | undefined = undefined
33
- private signedPrekeyId: string | undefined = undefined
34
- private readonly otpKeys: KeyPair[] = [] // one-time prekeys awaiting matching
35
- private x3dhShared?: CryptoKey
36
- // Queued init messages arriving before we could derive (shouldn't happen, but safe)
37
- private pendingX3DH: { senderIK: string; ephemeralKey: string; spkId: string } | undefined = undefined
38
-
39
- constructor(private readonly storageKey: string) {}
40
-
41
- get ready(): boolean { return !!(this.shared ?? this.x3dhShared) }
42
-
43
- /** Live ECDH mode: Generate/restore our keypair and return our public key to publish. */
44
- async begin(): Promise<string> {
45
- this.kp = await loadOrCreateKeyPair(this.storageKey)
46
- return exportPublicKey(this.kp.publicKey)
47
- }
48
-
49
- /** Live ECDH mode: A peer published their key — derive the shared secret. */
50
- async onPeerKey(peerKeyB64: string): Promise<void> {
51
- if (!this.kp) return
52
- this.shared = await deriveSharedKey(this.kp.privateKey, peerKeyB64)
53
- }
54
-
55
- // ── X3DH async mode ────────────────────────────────────────────────────────
56
-
57
- /** X3DH: Generate identity key, signed prekey, and OTP prekeys.
58
- * Returns the upload frame payload the caller should send to the server. */
59
- async initX3DH(): Promise<{
60
- identityKey: string; signedPrekey: string; signedPrekeyId: string;
61
- signature: string; oneTimePrekeys: string[]
62
- }> {
63
- // Restore or generate persistent identity keypair (ECDH + ECDSA).
64
- this.identityKP = await loadOrCreateIdentityKeyPair(this.storageKey)
65
- // Always generate a fresh signed prekey (rotation).
66
- this.signedPreKP = await generateKeyPair()
67
- this.signedPrekeyId = `spk-${Date.now()}-${Math.random().toString(36).slice(2)}`
68
- // Batch of one-time prekeys.
69
- for (let i = 0; i < OTP_BATCH_SIZE; i++) this.otpKeys.push(await generateKeyPair())
70
-
71
- const signedPrekeyPub = await exportPublicKey(this.signedPreKP.publicKey)
72
- const signature = await signPrekey(this.identityKP.ecdsaKP.privateKey, this.signedPreKP.publicKey)
73
- const oneTimePrekeys = await Promise.all(this.otpKeys.map(kp => exportPublicKey(kp.publicKey)))
74
-
75
- return {
76
- identityKey: this.identityKP.publicKeyB64,
77
- signedPrekey: signedPrekeyPub,
78
- signedPrekeyId: this.signedPrekeyId,
79
- signature,
80
- oneTimePrekeys,
81
- }
82
- }
83
-
84
- /** X3DH sender: given a recipient's prekey bundle, derive the shared key and
85
- * return the init message fields to embed in the first encrypted message. */
86
- async x3dhSendTo(bundle: X3DHBundle): Promise<{ ephemeralKey: string; spkId: string; usedOTP: boolean; senderIK: string }> {
87
- if (!this.identityKP) this.identityKP = await loadOrCreateIdentityKeyPair(this.storageKey)
88
- const { sharedKey, ephemeralPublicKey } = await x3dhSend(this.identityKP.ecdhKP, bundle)
89
- this.x3dhShared = sharedKey
90
- return { ephemeralKey: ephemeralPublicKey, spkId: bundle.signedPrekeyId, usedOTP: !!bundle.oneTimePrekey, senderIK: this.identityKP.publicKeyB64 }
91
- }
92
-
93
- /** X3DH recipient: given an init message's sender IK + EK + SPK ID, derive the shared key. */
94
- async x3dhReceiveFrom(senderIKb64: string, ephemeralKeyB64: string, spkId: string): Promise<void> {
95
- if (!this.identityKP || !this.signedPreKP) {
96
- // Keys not yet initialised — queue for when initX3DH completes.
97
- this.pendingX3DH = { senderIK: senderIKb64, ephemeralKey: ephemeralKeyB64, spkId }
98
- return
99
- }
100
- // Find OTP key if used (we pop the first available)
101
- const otp = this.otpKeys.shift()
102
- this.x3dhShared = await x3dhReceive(this.identityKP.ecdhKP, this.signedPreKP, senderIKb64, ephemeralKeyB64, otp)
103
- void spkId // we matched by position; full impl would look up by ID
104
- }
105
-
106
- /** Flush pending X3DH derivation after initX3DH() completes. */
107
- async flushPendingX3DH(): Promise<void> {
108
- if (!this.pendingX3DH) return
109
- const { senderIK, ephemeralKey, spkId } = this.pendingX3DH
110
- this.pendingX3DH = undefined
111
- await this.x3dhReceiveFrom(senderIK, ephemeralKey, spkId)
112
- }
113
-
114
- /** Encrypt outgoing text into a wire content object. For X3DH init messages,
115
- * the caller should pass x3dhInit fields to embed in the content. */
116
- async sealText(text: string, x3dhInit?: { ephemeralKey: string; spkId: string; senderIK: string }): Promise<MessageContent> {
117
- const key = this.x3dhShared ?? this.shared
118
- if (!key) throw new Error('secure channel not ready')
119
- const { ct, iv } = await encrypt(key, text)
120
- return {
121
- kind: 'text', text: ct, enc: true, iv,
122
- ...(x3dhInit ? { x3dhEK: x3dhInit.ephemeralKey, x3dhSPK: x3dhInit.spkId, x3dhIK: x3dhInit.senderIK } as never : {}),
123
- }
124
- }
125
-
126
- /** Decrypt one content object if it is encrypted (otherwise pass through). */
127
- private async openContent(content: MessageContent): Promise<MessageContent> {
128
- if (content.kind !== 'text' || !content.enc || !content.iv) return content
129
- const key = this.x3dhShared ?? this.shared
130
- if (!key) return { kind: 'text', text: '🔒 encrypted' }
131
- try { return { kind: 'text', text: await decrypt(key, content.text, content.iv) } }
132
- catch { return { kind: 'text', text: '🔒 unable to decrypt' } }
133
- }
134
-
135
- /** Decrypt any encrypted message content carried by an incoming frame, in place. */
136
- async openFrame(frame: ServerFrame): Promise<void> {
137
- if (frame.type === 'message') frame.message.content = await this.openContent(frame.message.content)
138
- else if (frame.type === 'sync') {
139
- for (const m of frame.messages) m.content = await this.openContent(m.content)
140
- }
141
- }
142
- }
143
-
144
- /** X3DH init fields embedded in a text MessageContent (as extra properties).
145
- * Present only on the very first message from a sender to an offline peer. */
146
- export interface X3DHInitFields {
147
- x3dhEK: string // sender's ephemeral public key (base64)
148
- x3dhSPK: string // recipient's signed prekey ID used
149
- x3dhIK: string // sender's identity public key (base64)
150
- }
151
-
152
- export function extractX3DHInit(content: MessageContent): X3DHInitFields | null {
153
- if (content.kind !== 'text' || !content.enc) return null
154
- const c = content as MessageContent & Partial<X3DHInitFields>
155
- if (!c.x3dhEK || !c.x3dhSPK || !c.x3dhIK) return null
156
- return { x3dhEK: c.x3dhEK, x3dhSPK: c.x3dhSPK, x3dhIK: c.x3dhIK }
157
- }
158
-
159
- export { type X3DHBundle } from './crypto.js'
160
- export { type UserId }
161
-
package/src/history.ts DELETED
@@ -1,43 +0,0 @@
1
- // history.ts — shared REST history-fetch logic, used by both the guest widget
2
- // (index.ts) and the agent dashboard (chat-dashboard/src/operate.ts) so there
3
- // is exactly one implementation of "restore chat history on open" rather than
4
- // two copies that can silently drift apart.
5
- import type { ChatStore } from './store.js'
6
- import type { Message, ConversationId } from './protocol/index.js'
7
- import type { Renderer } from './renderer.js'
8
-
9
- /** Derive the HTTP(S) base origin from a ws(s):// URL that may or may not
10
- * already end in /ws. Examples:
11
- * wss://api.paramms.com/ws → https://api.paramms.com
12
- * ws://localhost:3000/ws → http://localhost:3000
13
- * wss://api.paramms.com → https://api.paramms.com (no /ws suffix) */
14
- export function httpBaseFromWsUrl(wsUrl: string): string {
15
- return wsUrl.replace(/^ws/, 'http').replace(/\/ws\/?$/, '')
16
- }
17
-
18
- /** Fetch full message history for a conversation via REST and apply it to
19
- * the store, then trigger a render. Best-effort: network/auth failures are
20
- * swallowed since the live WS sync still covers reconnects — this is a
21
- * belt-and-suspenders restore for "did my history come back after reload",
22
- * not the only path messages can arrive through. */
23
- export async function restoreHistory(
24
- wsUrl: string,
25
- token: string,
26
- conversationId: ConversationId,
27
- store: ChatStore,
28
- renderer: Renderer,
29
- ): Promise<void> {
30
- const httpBase = httpBaseFromWsUrl(wsUrl)
31
- try {
32
- const res = await fetch(`${httpBase}/conversations/${conversationId}/history`, {
33
- headers: { authorization: `Bearer ${token}` },
34
- })
35
- if (!res.ok) return
36
- const data = await res.json() as { messages?: Message[] }
37
- if (!data.messages?.length) return
38
- store.apply({ type: 'sync', conversationId, messages: data.messages })
39
- renderer.render(store)
40
- } catch {
41
- // best-effort — WS sync still covers reconnects
42
- }
43
- }
package/src/index.ts DELETED
@@ -1,380 +0,0 @@
1
- import {
2
- asConversationId,
3
- type ClientFrame, type ConversationId,
4
- } from './protocol/index.js'
5
- import { ChatStore } from './store.js'
6
- import { ConnectionManager } from './connection.js'
7
- import { PersistentOutbox } from './outbox.js'
8
- import { E2ESession, extractX3DHInit } from './e2e.js'
9
- import { Renderer, type WidgetConfig, type ChatListEntry } from './renderer.js'
10
- import { restoreHistory, httpBaseFromWsUrl } from './history.js'
11
-
12
- export interface MountOptions {
13
- el: HTMLElement
14
- url: string
15
- profileId: string
16
- subjectId?: string
17
- token?: string
18
- subject?: WidgetConfig['subject']
19
- quickReplies?: string[]
20
- accent?: string
21
- /** If set, shows a 🌐 translate button on incoming messages that translates
22
- * them into this language (ISO code or language name) via the server's
23
- * /translate endpoint. Omit to disable the feature. */
24
- translateLang?: string
25
- /** If true, mount as a floating launcher button that opens/closes the chat */
26
- launcher?: boolean
27
- /** Position of the launcher button: default 'bottom-right' */
28
- position?: 'bottom-right' | 'bottom-left'
29
- /** If true, shows a 💬 button in the header that opens a list of the
30
- * guest's other conversations (e.g. a marketplace where each item gets
31
- * its own subjectId/thread) and lets them switch between them.
32
- * Off by default — single-conversation embeds don't need this. */
33
- showChatList?: boolean
34
- }
35
-
36
- export interface WidgetHandle { close(): void }
37
-
38
- /** Persistent anonymous identity, reused across reloads (per the old widget's
39
- * oc_uid pattern). */
40
- function persistentUid(): string {
41
- const key = 'oc_uid'
42
- try {
43
- const existing = localStorage.getItem(key)
44
- if (existing) return existing
45
- const id = `g_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
46
- localStorage.setItem(key, id)
47
- return id
48
- } catch {
49
- return `g_${Math.random().toString(36).slice(2)}`
50
- }
51
- }
52
-
53
- export function mount(opts: MountOptions): WidgetHandle {
54
- const token = opts.token ?? persistentUid()
55
- const store = new ChatStore(token as never)
56
- const outbox = new PersistentOutbox(token)
57
- let cid: ConversationId | undefined
58
- let outboxRestored = false
59
- // Self-reference so onSwitchChat can close this mount and replace it with
60
- // a fresh one for the newly-selected conversation, without the embedder
61
- // needing to manage that lifecycle themselves.
62
- let currentHandle: WidgetHandle | null = null
63
-
64
- // Give the host element a sane default size if the embedder didn't set one
65
- // (e.g. a bare `<div id="chat"></div>` with no CSS). Without this the
66
- // widget's internal `height:100%` collapses to near-zero. Only applies
67
- // when the element truly has no height set — explicit CSS always wins.
68
- if (!opts.launcher && !opts.el.style.height && opts.el.clientHeight === 0) {
69
- opts.el.style.width = opts.el.style.width || '100%'
70
- opts.el.style.height = '600px'
71
- }
72
-
73
- // Restore any messages that were queued but not yet acknowledged before the
74
- // last reload — show them as 'pending' immediately, matching the WhatsApp
75
- // pattern of never silently dropping a typed message.
76
- for (const item of outbox.load()) store.addOptimistic(item.clientMsgId, item.content)
77
-
78
- // ── Launcher mode ─────────────────────────────────────────────────────────
79
- let launcherEl: HTMLElement | null = null
80
- let badgeEl: HTMLElement | null = null
81
- let unread = 0
82
- let open = !opts.launcher // start open when not in launcher mode
83
-
84
- if (opts.launcher) {
85
- const pos = opts.position ?? 'bottom-right'
86
- launcherEl = document.createElement('div')
87
- launcherEl.style.cssText = `position:fixed;${pos.includes('right') ? 'right:20px' : 'left:20px'};bottom:20px;z-index:9999`
88
- const btn = document.createElement('button')
89
- btn.style.cssText = `width:56px;height:56px;border-radius:50%;background:${opts.accent ?? '#4F63F5'};color:#fff;border:none;font-size:24px;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.2);position:relative`
90
- btn.textContent = '💬'
91
- badgeEl = document.createElement('span')
92
- badgeEl.style.cssText = `position:absolute;top:-4px;right:-4px;background:#ef4444;color:#fff;border-radius:50%;width:20px;height:20px;font-size:11px;font-weight:700;display:none;align-items:center;justify-content:center`
93
- btn.append(badgeEl)
94
- launcherEl.append(btn)
95
- document.body.append(launcherEl)
96
- opts.el.style.display = 'none'
97
- btn.addEventListener('click', () => {
98
- open = !open
99
- opts.el.style.display = open ? 'block' : 'none'
100
- btn.textContent = open ? '✕' : '💬'
101
- if (open) { unread = 0; if (badgeEl) badgeEl.style.display = 'none' }
102
- btn.append(badgeEl!)
103
- })
104
- }
105
-
106
- const addUnread = () => {
107
- if (open) return
108
- unread++
109
- if (badgeEl) { badgeEl.textContent = String(unread); badgeEl.style.display = 'flex' }
110
- }
111
-
112
- // ── Notification sound ────────────────────────────────────────────────────
113
- const playSound = () => {
114
- try {
115
- const ctx = new AudioContext()
116
- const osc = ctx.createOscillator(); const gain = ctx.createGain()
117
- osc.connect(gain); gain.connect(ctx.destination)
118
- osc.frequency.setValueAtTime(880, ctx.currentTime)
119
- osc.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.15)
120
- gain.gain.setValueAtTime(0.3, ctx.currentTime)
121
- gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3)
122
- osc.start(); osc.stop(ctx.currentTime + 0.3)
123
- } catch { /* audio not available */ }
124
- }
125
-
126
- let conn: ConnectionManager
127
- const e2e = new E2ESession(`ocw-e2e-${opts.profileId}`)
128
- let e2eStarted = false
129
- // Live ECDH pending (peer not yet online)
130
- const pending: { clientMsgId: string; text: string }[] = []
131
- // X3DH async: pending send awaiting the peer's prekey bundle
132
- const x3dhPending: { clientMsgId: string; text: string }[] = []
133
- let x3dhBundleFetched = false
134
-
135
- const sendSealed = (clientMsgId: string, text: string): void => {
136
- void e2e.sealText(text).then((content) => {
137
- if (cid) conn.send({ type: 'send', conversationId: cid, clientMsgId, content })
138
- })
139
- }
140
-
141
- const sendSealedX3DH = (clientMsgId: string, text: string, x3dhInit: { ephemeralKey: string; spkId: string; senderIK: string }): void => {
142
- void e2e.sealText(text, x3dhInit).then((content) => {
143
- if (cid) conn.send({ type: 'send', conversationId: cid, clientMsgId, content })
144
- })
145
- }
146
-
147
- const flushPending = (): void => {
148
- while (pending.length) { const p = pending.shift()!; sendSealed(p.clientMsgId, p.text) }
149
- while (x3dhPending.length) { const p = x3dhPending.shift()!; sendSealed(p.clientMsgId, p.text) }
150
- }
151
-
152
- /** Fetch the peer's prekey bundle and perform X3DH sender init. */
153
- const fetchAndX3DH = (targetUserId: string): void => {
154
- conn.send({ type: 'fetchPrekey', targetUserId: targetUserId as never })
155
- }
156
-
157
- const renderer = new Renderer(opts.el, token, {
158
- onSend(text) {
159
- if (!cid) return
160
- const clientMsgId = `cm_${Math.random().toString(36).slice(2)}`
161
- store.addOptimistic(clientMsgId, { kind: 'text', text })
162
- renderer.render(store)
163
- if (store.e2e) {
164
- if (e2e.ready) {
165
- sendSealed(clientMsgId, text)
166
- } else if (x3dhBundleFetched) {
167
- x3dhPending.push({ clientMsgId, text })
168
- } else {
169
- pending.push({ clientMsgId, text })
170
- if (store.assignedAgentId) fetchAndX3DH(store.assignedAgentId)
171
- }
172
- } else {
173
- outbox.add({ clientMsgId, content: { kind: 'text', text }, ts: Date.now() })
174
- conn.send({ type: 'send', conversationId: cid, clientMsgId, content: { kind: 'text', text } })
175
- }
176
- },
177
- async onAttach(file: File) {
178
- if (!cid) return
179
- const wsUrl = opts.url // derive HTTP upload URL from WS URL
180
- const httpUrl = wsUrl.replace(/^ws/, 'http').replace(/\/ws$/, '')
181
- const uploadUrl = `${httpUrl}/upload?name=${encodeURIComponent(file.name)}`
182
- const clientMsgId = `cm_${Math.random().toString(36).slice(2)}`
183
- // Optimistic: show uploading state
184
- store.addOptimistic(clientMsgId, { kind: 'text', text: `📎 Uploading ${file.name}…` })
185
- renderer.render(store)
186
- try {
187
- const res = await fetch(uploadUrl, {
188
- method: 'POST',
189
- headers: { 'content-type': file.type, ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}) },
190
- body: file,
191
- })
192
- if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
193
- const { url, name, mime, size } = await res.json() as { url: string; name: string; mime: string; size: number }
194
- conn.send({ type: 'send', conversationId: cid, clientMsgId, content: { kind: 'attachment', url, name, mime, size } })
195
- } catch (e) {
196
- store.addOptimistic(clientMsgId, { kind: 'text', text: `⚠️ Upload failed: ${(e as Error).message}` })
197
- renderer.render(store)
198
- }
199
- },
200
- onInvoke(actionId, inputs) {
201
- if (!cid) return
202
- conn.send({ type: 'invoke', conversationId: cid, actionId, clientInvokeId: `iv_${Math.random().toString(36).slice(2)}`, ...(inputs ? { inputs } : {}) })
203
- },
204
- onTyping(isTyping) { if (cid) conn.send({ type: 'typing', conversationId: cid, isTyping }) },
205
- onReadUpTo(seq) { if (cid) conn.send({ type: 'read', conversationId: cid, seq }) },
206
- onLoadMore() {
207
- if (!cid) return
208
- const oldest = store.messages()[0]
209
- if (oldest) conn.send({ type: 'history', conversationId: cid, beforeSeq: oldest.seq, limit: 30 })
210
- },
211
- onEdit(messageId, newText) {
212
- if (cid) conn.send({ type: 'edit', conversationId: cid, messageId: messageId as never, content: { kind: 'text', text: newText } })
213
- },
214
- onDelete(messageId) {
215
- if (cid) conn.send({ type: 'delete', conversationId: cid, messageId: messageId as never })
216
- },
217
- onReact(messageId, emoji, remove) {
218
- if (!cid) return
219
- conn.send({ type: 'react', conversationId: cid, messageId: messageId as never, emoji, remove })
220
- },
221
- onCsat(score) {
222
- if (!cid) return
223
- // POST CSAT via fetch to the control-plane (the WS server also listens on HTTP).
224
- // We extract the base URL from the WS URL best-effort.
225
- const base = opts.url.replace(/^ws/, 'http').replace(/\/+$/, '').replace(/:\d+$/, m => m) // keep port
226
- // Use the conversation id once it's known — post to /conversations/:id/csat
227
- fetch(`${base}/conversations/${cid}/csat`, {
228
- method: 'POST',
229
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
230
- body: JSON.stringify({ score }),
231
- }).catch(() => {}) // best-effort
232
- },
233
- onAnnotate(stroke) {
234
- if (cid) conn.send({ type: 'annotate', conversationId: cid, stroke })
235
- },
236
- onAnnotateClear() {
237
- if (cid) conn.send({ type: 'annotate_clear', conversationId: cid })
238
- },
239
- ...(opts.translateLang ? {
240
- async onTranslate(text: string) {
241
- const base = opts.url.replace(/^ws/, 'http').replace(/\/+$/, '')
242
- try {
243
- const res = await fetch(`${base}/translate`, {
244
- method: 'POST',
245
- headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
246
- body: JSON.stringify({ text, targetLang: opts.translateLang }),
247
- })
248
- if (!res.ok) return null
249
- const { translated } = await res.json() as { translated: string | null }
250
- return translated
251
- } catch { return null }
252
- },
253
- } : {}),
254
- ...(opts.showChatList ? {
255
- async onListChats() {
256
- const base = httpBaseFromWsUrl(opts.url)
257
- try {
258
- const res = await fetch(`${base}/conversations/mine?profileId=${encodeURIComponent(opts.profileId)}`, {
259
- headers: { authorization: `Bearer ${token}` },
260
- })
261
- if (!res.ok) return []
262
- const data = await res.json() as { conversations?: ChatListEntry[] }
263
- return data.conversations ?? []
264
- } catch { return [] }
265
- },
266
- onSwitchChat(entry: ChatListEntry) {
267
- currentHandle?.close()
268
- opts.el.replaceChildren() // Renderer appends fresh DOM into the same host element
269
- const { subjectId: _drop, ...rest } = opts
270
- currentHandle = mount({ ...rest, token, ...(entry.subjectId ? { subjectId: entry.subjectId } : {}) })
271
- },
272
- } : {}),
273
- }, {
274
- ...(opts.subject ? { subject: opts.subject } : {}),
275
- ...(opts.quickReplies ? { quickReplies: opts.quickReplies } : {}),
276
- ...(opts.accent ? { accent: opts.accent } : {}),
277
- })
278
-
279
- const openFrame: Extract<ClientFrame, { type: 'open' }> = {
280
- type: 'open', profileId: opts.profileId as never,
281
- ...(opts.subjectId ? { subjectId: opts.subjectId as never } : {}),
282
- }
283
-
284
- conn = new ConnectionManager({
285
- url: opts.url, token, open: openFrame,
286
- getCursor: () => store.highestSeq(),
287
- onStatusChange: (s) => renderer.setConnStatus(s),
288
- onFrame(frame) {
289
- if (frame.type === 'opened') {
290
- const isFirstOpen = cid === undefined
291
- cid = frame.conversation.id
292
- if (!outboxRestored) {
293
- outboxRestored = true
294
- // Re-send anything that didn't get acknowledged before a reload/drop.
295
- // Each item is also already showing as a 'pending' bubble (restored below).
296
- for (const item of outbox.load()) {
297
- conn.send({ type: 'send', conversationId: cid, clientMsgId: item.clientMsgId, content: item.content })
298
- }
299
- }
300
- // Restore chat history via REST, independent of the WS sync/history
301
- // frames. Shared implementation with the dashboard's agent view —
302
- // see history.ts.
303
- if (isFirstOpen) {
304
- void restoreHistory(opts.url, token, cid, store, renderer)
305
- }
306
- }
307
-
308
- if (frame.type === 'ack') outbox.remove(frame.clientMsgId)
309
-
310
- // X3DH: handle incoming prekey bundle response
311
- if (frame.type === 'prekeyBundle') {
312
- if (frame.bundle) {
313
- x3dhBundleFetched = true
314
- void e2e.x3dhSendTo(frame.bundle).then((x3dhInit) => {
315
- // Drain any X3DH-pending messages with the derived key.
316
- const toSend = [...pending.splice(0), ...x3dhPending.splice(0)]
317
- for (const p of toSend) sendSealedX3DH(p.clientMsgId, p.text, x3dhInit)
318
- renderer.render(store)
319
- })
320
- }
321
- // If no bundle, peer has no prekeys; fall back to live ECDH queue
322
- return
323
- }
324
-
325
- // X3DH recipient: detect init message in incoming encrypted messages
326
- if (frame.type === 'message' && store.e2e) {
327
- const x3dh = extractX3DHInit(frame.message.content)
328
- if (x3dh && !e2e.ready) {
329
- void e2e.x3dhReceiveFrom(x3dh.x3dhIK, x3dh.x3dhEK, x3dh.x3dhSPK).then(async () => {
330
- // Now decrypt the message that carried the init
331
- await e2e.openFrame(frame)
332
- store.apply(frame)
333
- renderer.render(store)
334
- })
335
- return
336
- }
337
- }
338
-
339
- if (frame.type === 'peerkey') {
340
- void e2e.onPeerKey(frame.key).then(() => { flushPending(); renderer.render(store) })
341
- return
342
- }
343
- void (async () => {
344
- if (store.e2e) await e2e.openFrame(frame)
345
- store.apply(frame)
346
- // Trigger badge + sound for new messages from others
347
- if (frame.type === 'message' && frame.message.senderId !== (token as never) && !frame.message.internal) {
348
- addUnread()
349
- playSound()
350
- }
351
- // Once we learn the room is E2E, run the key handshake exactly once.
352
- if (store.e2e && cid && !e2eStarted) {
353
- e2eStarted = true
354
- // Upload our prekey bundle for async E2E support.
355
- const prekeyPayload = await e2e.initX3DH()
356
- conn.send({ type: 'uploadPrekeys', ...prekeyPayload })
357
- // Also do live ECDH handshake in case peer is already online.
358
- const liveKey = await e2e.begin()
359
- conn.send({ type: 'pubkey', conversationId: cid, key: liveKey })
360
- }
361
- renderer.render(store)
362
- })()
363
- },
364
- })
365
- conn.connect()
366
- // Show restored 'pending' bubbles (if any) immediately, before the socket opens.
367
- renderer.render(store)
368
-
369
- currentHandle = { close: () => { conn.close(); launcherEl?.remove() } }
370
- return currentHandle
371
- }
372
-
373
- export { ChatStore } from './store.js'
374
- export { ConnectionManager } from './connection.js'
375
- export { Renderer, type ChatListEntry } from './renderer.js'
376
- export { asConversationId }
377
- export { E2ESession, extractX3DHInit, type X3DHBundle } from './e2e.js'
378
- export { PersistentOutbox, type OutboxItem } from './outbox.js'
379
- export { restoreHistory, httpBaseFromWsUrl } from './history.js'
380
-
package/src/outbox.ts DELETED
@@ -1,58 +0,0 @@
1
- import type { MessageContent } from './protocol/index.js'
2
-
3
- export interface OutboxItem {
4
- clientMsgId: string
5
- content: MessageContent
6
- ts: number
7
- }
8
-
9
- const MAX_ITEMS = 200 // cap so a long outage can't grow storage unboundedly
10
- const MAX_AGE_MS = 7 * 86_400_000 // drop anything older than 7 days on load
11
-
12
- /** Persists not-yet-acknowledged outgoing messages to localStorage, keyed by
13
- * guest token, so a page reload during a connectivity drop doesn't silently
14
- * lose what the user typed (the "WhatsApp" guarantee: your message is queued
15
- * until it's confirmed sent, even across app restarts). */
16
- export class PersistentOutbox {
17
- private readonly key: string
18
-
19
- constructor(token: string) {
20
- this.key = `ocw_outbox_${token}`
21
- }
22
-
23
- /** All pending items, oldest first, with stale (>7d) entries dropped. */
24
- load(): OutboxItem[] {
25
- try {
26
- const raw = localStorage.getItem(this.key)
27
- if (!raw) return []
28
- const items = JSON.parse(raw) as OutboxItem[]
29
- const cutoff = Date.now() - MAX_AGE_MS
30
- const fresh = items.filter(i => i.ts >= cutoff)
31
- if (fresh.length !== items.length) this.save(fresh)
32
- return fresh
33
- } catch {
34
- return []
35
- }
36
- }
37
-
38
- add(item: OutboxItem): void {
39
- try {
40
- const items = this.load()
41
- items.push(item)
42
- // Evict oldest when full — matches the in-memory ConnectionManager outbox policy.
43
- this.save(items.length > MAX_ITEMS ? items.slice(items.length - MAX_ITEMS) : items)
44
- } catch { /* localStorage unavailable (private mode, quota) — best-effort only */ }
45
- }
46
-
47
- /** Remove an item once it's been acknowledged by the server. */
48
- remove(clientMsgId: string): void {
49
- try {
50
- const items = this.load().filter(i => i.clientMsgId !== clientMsgId)
51
- this.save(items)
52
- } catch { /* best-effort */ }
53
- }
54
-
55
- private save(items: OutboxItem[]): void {
56
- try { localStorage.setItem(this.key, JSON.stringify(items)) } catch { /* quota exceeded — drop silently */ }
57
- }
58
- }