@paramms/chat-widget 0.1.0

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/src/store.ts ADDED
@@ -0,0 +1,207 @@
1
+ import type {
2
+ ServerFrame, Message, ManifestAction, MessageContent,
3
+ ConversationId, MessageId, UserId, Subject, AnnotationStroke,
4
+ } from './protocol/index.js'
5
+
6
+ export type SendStatus = 'pending' | 'sent' | 'delivered' | 'read'
7
+
8
+ export interface RenderMessage extends Message {
9
+ clientMsgId?: string
10
+ status?: SendStatus
11
+ }
12
+
13
+ /** Pure, DOM-free conversation state. Feed it ServerFrames (and local optimistic
14
+ * sends); read an ordered, de-duplicated view out. Ordering is by `seq`; the
15
+ * same message arriving twice (live + sync on reconnect) is collapsed by id —
16
+ * the structural fix for the old duplicate-bubble bug. */
17
+ export class ChatStore {
18
+ conversationId?: ConversationId
19
+ state = ''
20
+ version = 0
21
+ hasMoreHistory = true
22
+ lastReadByOthers = 0
23
+ assignedAgentId: UserId | undefined
24
+ accent: string | undefined
25
+ subject: Subject | undefined
26
+ name: string | undefined
27
+ e2e = false
28
+ offline = false
29
+ offlineMessage = ''
30
+ whiteLabel = false
31
+ readonly typing = new Set<string>()
32
+ readonly online = new Set<string>()
33
+ /** Co-browsing: shared whiteboard strokes for this conversation. */
34
+ annotations: AnnotationStroke[] = []
35
+ /** Bumped on any annotation change so the renderer can cheaply detect updates. */
36
+ annotationVersion = 0
37
+ /** Live sentiment of the guest's latest message (agent-side only). */
38
+ sentiment: 'positive' | 'neutral' | 'frustrated' | undefined
39
+ sentimentScore: number | undefined
40
+
41
+ private actions: ManifestAction[] = []
42
+ private readonly byId = new Map<string, RenderMessage>()
43
+ private readonly keyByClient = new Map<string, string>()
44
+ private _maxSeq = 0
45
+ private _sorted: RenderMessage[] | null = null
46
+
47
+ constructor(private readonly me: UserId) {}
48
+
49
+ messages(): RenderMessage[] {
50
+ if (!this._sorted) {
51
+ this._sorted = [...this.byId.values()].sort((a, b) => {
52
+ const ap = a.status === 'pending', bp = b.status === 'pending'
53
+ if (ap !== bp) return ap ? 1 : -1
54
+ if (ap && bp) return a.ts - b.ts
55
+ return a.seq - b.seq
56
+ })
57
+ }
58
+ return this._sorted
59
+ }
60
+
61
+ visibleActions(): ManifestAction[] {
62
+ return this.actions.filter(a => !a.availableInStates || a.availableInStates.includes(this.state))
63
+ }
64
+
65
+ highestSeq(): number { return this._maxSeq }
66
+
67
+ addOptimistic(clientMsgId: string, content: MessageContent): RenderMessage {
68
+ const msg: RenderMessage = {
69
+ id: clientMsgId as MessageId, conversationId: this.conversationId as ConversationId,
70
+ seq: 0, senderId: this.me, senderRole: 'guest', content, ts: Date.now(),
71
+ clientMsgId, status: 'pending',
72
+ }
73
+ this.byId.set(clientMsgId, msg)
74
+ this.keyByClient.set(clientMsgId, clientMsgId)
75
+ this._sorted = null
76
+ return msg
77
+ }
78
+
79
+ apply(frame: ServerFrame): void {
80
+ switch (frame.type) {
81
+ case 'opened':
82
+ this.conversationId = frame.conversation.id
83
+ this.state = frame.conversation.state
84
+ if (frame.subject) this.subject = frame.subject
85
+ if (frame.annotations) { this.annotations = frame.annotations; this.annotationVersion++ }
86
+ return
87
+ case 'manifest':
88
+ this.actions = frame.actions
89
+ this.version = frame.version
90
+ if (frame.name) this.name = frame.name
91
+ if (frame.theme?.accent) this.accent = frame.theme.accent
92
+ if (frame.e2e) this.e2e = true
93
+ if (frame.offline) this.offline = true
94
+ if (frame.offlineMessage) this.offlineMessage = frame.offlineMessage
95
+ if (frame.whiteLabel) this.whiteLabel = true
96
+ return
97
+ case 'message':
98
+ this.upsert({ ...frame.message })
99
+ return
100
+ case 'ack': {
101
+ const key = this.keyByClient.get(frame.clientMsgId)
102
+ const msg = key ? this.byId.get(key) : undefined
103
+ if (msg && key) {
104
+ this.byId.delete(key)
105
+ const confirmed: RenderMessage = { ...msg, id: frame.messageId, seq: frame.seq, ts: frame.ts, status: 'sent' }
106
+ this.byId.set(frame.messageId, confirmed)
107
+ this.keyByClient.set(frame.clientMsgId, frame.messageId)
108
+ if (frame.seq > this._maxSeq) this._maxSeq = frame.seq
109
+ }
110
+ this._sorted = null
111
+ return
112
+ }
113
+ case 'delivered':
114
+ this.markOwnStatus(frame.seq, 'delivered')
115
+ return
116
+ case 'read':
117
+ if (frame.by !== this.me) {
118
+ this.lastReadByOthers = Math.max(this.lastReadByOthers, frame.seq)
119
+ this.markOwnStatus(frame.seq, 'read')
120
+ }
121
+ return
122
+ case 'sync':
123
+ for (const m of frame.messages) this.upsert({ ...m })
124
+ return
125
+ case 'history':
126
+ for (const m of frame.messages) this.upsert({ ...m })
127
+ this.hasMoreHistory = frame.hasMore
128
+ return
129
+ case 'typing':
130
+ if (frame.userId !== this.me) {
131
+ if (frame.isTyping) this.typing.add(frame.userId)
132
+ else this.typing.delete(frame.userId)
133
+ }
134
+ return
135
+ case 'reaction': {
136
+ const m = this.byId.get(frame.messageId)
137
+ if (!m) return
138
+ const reactions: Record<string, UserId[]> = { ...(m.reactions ?? {}) }
139
+ const users = (reactions[frame.emoji] ?? []).filter(u => u !== frame.by)
140
+ if (!frame.removed) users.push(frame.by)
141
+ if (users.length) reactions[frame.emoji] = users; else delete reactions[frame.emoji]
142
+ this.byId.set(frame.messageId, { ...m, reactions })
143
+ this._sorted = null
144
+ return
145
+ }
146
+ case 'edited': {
147
+ const m = this.byId.get(frame.messageId)
148
+ if (m) { this.byId.set(frame.messageId, { ...m, content: frame.content, editedAt: frame.editedAt }); this._sorted = null }
149
+ return
150
+ }
151
+ case 'deleted': {
152
+ const m = this.byId.get(frame.messageId)
153
+ if (m) { this.byId.set(frame.messageId, { ...m, deletedAt: frame.ts }); this._sorted = null }
154
+ return
155
+ }
156
+ case 'state':
157
+ this.state = frame.state
158
+ return
159
+ case 'assigned':
160
+ this.assignedAgentId = frame.agentId ?? undefined
161
+ return
162
+ case 'presence':
163
+ if (frame.status === 'online') this.online.add(frame.userId)
164
+ else this.online.delete(frame.userId)
165
+ return
166
+ case 'subjectState':
167
+ case 'invoked':
168
+ case 'authed':
169
+ case 'error':
170
+ case 'pong':
171
+ return
172
+ case 'annotation':
173
+ this.annotations = [...this.annotations, frame.stroke]
174
+ this.annotationVersion++
175
+ return
176
+ case 'annotation_clear':
177
+ this.annotations = []
178
+ this.annotationVersion++
179
+ return
180
+ case 'sentiment':
181
+ this.sentiment = frame.label
182
+ this.sentimentScore = frame.score
183
+ return
184
+ default:
185
+ return
186
+ }
187
+ }
188
+
189
+ private upsert(msg: RenderMessage): void {
190
+ const existing = this.byId.get(msg.id)
191
+ this.byId.set(msg.id, existing ? { ...existing, ...msg } : msg)
192
+ if (msg.seq > this._maxSeq) this._maxSeq = msg.seq
193
+ this._sorted = null
194
+ }
195
+
196
+ private markOwnStatus(uptoSeq: number, status: SendStatus): void {
197
+ for (const [k, m] of this.byId) {
198
+ if (m.senderId === this.me && m.seq > 0 && m.seq <= uptoSeq && rank(status) > rank(m.status)) {
199
+ this.byId.set(k, { ...m, status })
200
+ this._sorted = null
201
+ }
202
+ }
203
+ }
204
+ }
205
+ function rank(s: SendStatus | undefined): number {
206
+ switch (s) { case 'read': return 3; case 'delivered': return 2; case 'sent': return 1; default: return 0 }
207
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "lib": [
8
+ "ES2022",
9
+ "DOM",
10
+ "DOM.Iterable"
11
+ ],
12
+ "strict": true,
13
+ "noUnusedLocals": true,
14
+ "noUncheckedIndexedAccess": true,
15
+ "exactOptionalPropertyTypes": true,
16
+ "noImplicitOverride": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "forceConsistentCasingInFileNames": true,
19
+ "declaration": true,
20
+ "sourceMap": true,
21
+ "esModuleInterop": true,
22
+ "skipLibCheck": true,
23
+ "types": [],
24
+ "rootDir": "src",
25
+ "outDir": "dist"
26
+ },
27
+ "include": [
28
+ "src/**/*"
29
+ ],
30
+ "exclude": [
31
+ "src/**/__tests__/**"
32
+ ]
33
+ }
package/vercel.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "buildCommand": "npm run build",
3
+ "outputDirectory": "dist",
4
+ "installCommand": "npm install",
5
+ "framework": null,
6
+ "headers": [
7
+ {
8
+ "source": "/index.html",
9
+ "headers": [
10
+ { "key": "Cache-Control", "value": "no-cache, must-revalidate" },
11
+ { "key": "Access-Control-Allow-Origin", "value": "*" }
12
+ ]
13
+ },
14
+ {
15
+ "source": "/(index|react)\\.js",
16
+ "headers": [
17
+ { "key": "Cache-Control", "value": "public, max-age=300, must-revalidate" },
18
+ { "key": "Access-Control-Allow-Origin", "value": "*" }
19
+ ]
20
+ }
21
+ ]
22
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { defineConfig } from 'vite'
2
+
3
+ export default defineConfig(({ command }) => {
4
+ if (command === 'build') {
5
+ // Two entry points: index.js (vanilla, no deps) and react.js (the optional
6
+ // React wrapper — react/react-dom are externalized so they're never
7
+ // bundled; consumers' own React instance is used via peerDependencies).
8
+ return {
9
+ build: {
10
+ lib: {
11
+ entry: { index: 'src/index.ts', react: 'src/react.tsx' },
12
+ formats: ['es'],
13
+ },
14
+ outDir: 'dist',
15
+ sourcemap: true,
16
+ emptyOutDir: false,
17
+ rollupOptions: {
18
+ external: ['react', 'react-dom', 'react/jsx-runtime'],
19
+ },
20
+ },
21
+ }
22
+ }
23
+ // Dev server serves the full index.html preview
24
+ return { server: { port: 5174 } }
25
+ })
26
+
@@ -0,0 +1,2 @@
1
+ import { defineConfig } from 'vitest/config'
2
+ export default defineConfig({ test: { environment: 'node' } })