@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/README.md +23 -0
- package/build-preview.js +136 -0
- package/index.html +37 -0
- package/package.json +44 -0
- package/src/__tests__/chatlist.test.ts +133 -0
- package/src/__tests__/connection.test.ts +163 -0
- package/src/__tests__/crypto.test.ts +28 -0
- package/src/__tests__/history.test.ts +91 -0
- package/src/__tests__/ime.test.ts +93 -0
- package/src/__tests__/render.test.ts +58 -0
- package/src/__tests__/render_new.test.ts +441 -0
- package/src/__tests__/store.test.ts +86 -0
- package/src/__tests__/x3dh.test.ts +204 -0
- package/src/connection.ts +133 -0
- package/src/crypto.ts +252 -0
- package/src/e2e.ts +161 -0
- package/src/history.ts +43 -0
- package/src/index.ts +380 -0
- package/src/outbox.ts +58 -0
- package/src/protocol/actions.ts +114 -0
- package/src/protocol/codec.ts +35 -0
- package/src/protocol/entities.ts +104 -0
- package/src/protocol/frames.ts +86 -0
- package/src/protocol/ids.ts +27 -0
- package/src/protocol/index.ts +5 -0
- package/src/react.tsx +37 -0
- package/src/renderer.ts +906 -0
- package/src/store.ts +207 -0
- package/tsconfig.json +33 -0
- package/vercel.json +22 -0
- package/vite.config.ts +26 -0
- package/vitest.config.ts +2 -0
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
|
+
|
package/vitest.config.ts
ADDED