@scalemule/chat 0.0.5 → 0.0.8
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/dist/chat.embed.global.js +1 -1
- package/dist/chat.umd.global.js +288 -12
- package/dist/{chunk-ZLMMNFZL.js → chunk-5O5YLRJL.js} +386 -16
- package/dist/chunk-GTMAK3IA.js +285 -0
- package/dist/chunk-TRCELAZQ.cjs +287 -0
- package/dist/{chunk-YDLRISR7.cjs → chunk-W2PWFS3E.cjs} +386 -15
- package/dist/element.cjs +542 -51
- package/dist/element.js +541 -50
- package/dist/index.cjs +34 -5
- package/dist/index.js +29 -4
- package/dist/react.cjs +1260 -50
- package/dist/react.js +1212 -13
- package/dist/support-widget.global.js +485 -157
- package/package.json +5 -2
- package/dist/ChatClient-BoZaTtyM.d.cts +0 -88
- package/dist/ChatClient-COmdEJ11.d.ts +0 -88
- package/dist/element.d.cts +0 -2
- package/dist/element.d.ts +0 -2
- package/dist/iframe.d.cts +0 -17
- package/dist/iframe.d.ts +0 -17
- package/dist/index.d.cts +0 -77
- package/dist/index.d.ts +0 -77
- package/dist/react.d.cts +0 -49
- package/dist/react.d.ts +0 -49
- package/dist/types-BmD7f1gV.d.cts +0 -232
- package/dist/types-BmD7f1gV.d.ts +0 -232
package/dist/element.js
CHANGED
|
@@ -1,15 +1,140 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ChatController } from './chunk-GTMAK3IA.js';
|
|
2
|
+
import { ChatClient } from './chunk-5O5YLRJL.js';
|
|
2
3
|
|
|
3
4
|
// src/element.ts
|
|
5
|
+
var REACTION_EMOJIS = ["\u{1F44D}", "\u2764\uFE0F", "\u{1F602}", "\u{1F389}", "\u{1F62E}", "\u{1F440}"];
|
|
6
|
+
function escapeHtml(value) {
|
|
7
|
+
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
8
|
+
}
|
|
9
|
+
function sanitizeUrl(url) {
|
|
10
|
+
if (!url) return null;
|
|
11
|
+
try {
|
|
12
|
+
const parsed = new URL(url, typeof window !== "undefined" ? window.location.href : "https://scalemule.com");
|
|
13
|
+
if (["http:", "https:", "blob:"].includes(parsed.protocol)) {
|
|
14
|
+
return parsed.toString();
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
function sanitizeThemeColor(value) {
|
|
22
|
+
const candidate = value?.trim();
|
|
23
|
+
if (!candidate) return "#2563eb";
|
|
24
|
+
if (typeof document !== "undefined") {
|
|
25
|
+
const style = document.createElement("span").style;
|
|
26
|
+
style.color = "";
|
|
27
|
+
style.color = candidate;
|
|
28
|
+
if (style.color) {
|
|
29
|
+
return candidate;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (/^#[0-9a-f]{3,8}$/i.test(candidate)) {
|
|
33
|
+
return candidate;
|
|
34
|
+
}
|
|
35
|
+
return "#2563eb";
|
|
36
|
+
}
|
|
37
|
+
function formatDayLabel(value) {
|
|
38
|
+
return new Date(value).toLocaleDateString([], {
|
|
39
|
+
month: "short",
|
|
40
|
+
day: "numeric",
|
|
41
|
+
year: "numeric"
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function isSameDay(left, right) {
|
|
45
|
+
const leftDate = new Date(left);
|
|
46
|
+
const rightDate = new Date(right);
|
|
47
|
+
return leftDate.getFullYear() === rightDate.getFullYear() && leftDate.getMonth() === rightDate.getMonth() && leftDate.getDate() === rightDate.getDate();
|
|
48
|
+
}
|
|
49
|
+
function getUnreadIndex(messages, unreadSince) {
|
|
50
|
+
if (!unreadSince) return -1;
|
|
51
|
+
return messages.findIndex(
|
|
52
|
+
(message) => new Date(message.created_at).getTime() > new Date(unreadSince).getTime()
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
function renderAttachment(attachment) {
|
|
56
|
+
const fileName = escapeHtml(attachment.file_name);
|
|
57
|
+
const url = sanitizeUrl(attachment.presigned_url ?? void 0);
|
|
58
|
+
if (!url) {
|
|
59
|
+
return `<div class="attachment attachment-link">${fileName}</div>`;
|
|
60
|
+
}
|
|
61
|
+
if (attachment.mime_type.startsWith("image/")) {
|
|
62
|
+
return `<img class="attachment attachment-image" src="${escapeHtml(url)}" alt="${fileName}" />`;
|
|
63
|
+
}
|
|
64
|
+
if (attachment.mime_type.startsWith("video/")) {
|
|
65
|
+
return `<video class="attachment attachment-video" src="${escapeHtml(url)}" controls></video>`;
|
|
66
|
+
}
|
|
67
|
+
if (attachment.mime_type.startsWith("audio/")) {
|
|
68
|
+
return `<audio class="attachment attachment-audio" src="${escapeHtml(url)}" controls></audio>`;
|
|
69
|
+
}
|
|
70
|
+
return `<a class="attachment attachment-link" href="${escapeHtml(url)}" target="_blank" rel="noreferrer">${fileName}</a>`;
|
|
71
|
+
}
|
|
72
|
+
function renderMessage(message, currentUserId, showReactionPicker = false) {
|
|
73
|
+
const isOwn = Boolean(currentUserId && message.sender_id === currentUserId);
|
|
74
|
+
const attachments = (message.attachments ?? []).map(renderAttachment).join("");
|
|
75
|
+
const edited = message.is_edited ? '<span class="message-edited">edited</span>' : "";
|
|
76
|
+
const reactions = (message.reactions ?? []).map((reaction) => {
|
|
77
|
+
const reacted = Boolean(currentUserId && reaction.user_ids.includes(currentUserId));
|
|
78
|
+
return `
|
|
79
|
+
<button
|
|
80
|
+
class="reaction-badge ${reacted ? "reaction-badge-active" : ""}"
|
|
81
|
+
type="button"
|
|
82
|
+
data-action="toggle-reaction"
|
|
83
|
+
data-message-id="${escapeHtml(message.id)}"
|
|
84
|
+
data-emoji="${escapeHtml(reaction.emoji)}"
|
|
85
|
+
data-reacted="${reacted ? "true" : "false"}"
|
|
86
|
+
>
|
|
87
|
+
${escapeHtml(reaction.emoji)} ${reaction.count}
|
|
88
|
+
</button>
|
|
89
|
+
`;
|
|
90
|
+
}).join("");
|
|
91
|
+
const picker = showReactionPicker ? `
|
|
92
|
+
<div class="reaction-picker">
|
|
93
|
+
${REACTION_EMOJIS.map(
|
|
94
|
+
(emoji) => `
|
|
95
|
+
<button
|
|
96
|
+
class="reaction-picker-btn"
|
|
97
|
+
type="button"
|
|
98
|
+
data-action="add-reaction"
|
|
99
|
+
data-message-id="${escapeHtml(message.id)}"
|
|
100
|
+
data-emoji="${escapeHtml(emoji)}"
|
|
101
|
+
>
|
|
102
|
+
${escapeHtml(emoji)}
|
|
103
|
+
</button>
|
|
104
|
+
`
|
|
105
|
+
).join("")}
|
|
106
|
+
</div>
|
|
107
|
+
` : "";
|
|
108
|
+
return `
|
|
109
|
+
<div class="message ${isOwn ? "message-own" : "message-other"}">
|
|
110
|
+
<div class="message-bubble">
|
|
111
|
+
${message.content ? `<div class="message-content">${escapeHtml(message.content)}</div>` : ""}
|
|
112
|
+
${attachments}
|
|
113
|
+
</div>
|
|
114
|
+
<div class="message-meta">
|
|
115
|
+
<span>${new Date(message.created_at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}</span>
|
|
116
|
+
${edited}
|
|
117
|
+
<button class="message-action" type="button" data-action="toggle-picker" data-message-id="${escapeHtml(message.id)}">React</button>
|
|
118
|
+
</div>
|
|
119
|
+
${reactions ? `<div class="reactions">${reactions}</div>` : ""}
|
|
120
|
+
${picker}
|
|
121
|
+
</div>
|
|
122
|
+
`;
|
|
123
|
+
}
|
|
4
124
|
var ScaleMuleChatElement = class extends HTMLElement {
|
|
5
125
|
constructor() {
|
|
6
126
|
super();
|
|
7
127
|
this.client = null;
|
|
8
|
-
this.
|
|
128
|
+
this.controller = null;
|
|
129
|
+
this.cleanupFns = [];
|
|
130
|
+
this.pendingAttachments = [];
|
|
131
|
+
this.currentState = null;
|
|
132
|
+
this.openReactionMessageId = null;
|
|
133
|
+
this.didScrollToUnread = false;
|
|
9
134
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
10
135
|
}
|
|
11
136
|
static get observedAttributes() {
|
|
12
|
-
return ["api-key", "conversation-id", "api-base-url", "embed-token"];
|
|
137
|
+
return ["api-key", "conversation-id", "api-base-url", "embed-token", "ws-url", "theme-color"];
|
|
13
138
|
}
|
|
14
139
|
connectedCallback() {
|
|
15
140
|
this.initialize();
|
|
@@ -26,78 +151,444 @@ var ScaleMuleChatElement = class extends HTMLElement {
|
|
|
26
151
|
const conversationId = this.getAttribute("conversation-id");
|
|
27
152
|
const apiBaseUrl = this.getAttribute("api-base-url") ?? void 0;
|
|
28
153
|
const embedToken = this.getAttribute("embed-token") ?? void 0;
|
|
29
|
-
|
|
154
|
+
const wsUrl = this.getAttribute("ws-url") ?? void 0;
|
|
155
|
+
const themeColor = sanitizeThemeColor(this.getAttribute("theme-color") ?? "#2563eb");
|
|
156
|
+
if (!apiKey && !embedToken || !conversationId) return;
|
|
30
157
|
const config = {
|
|
31
158
|
apiKey,
|
|
32
159
|
embedToken,
|
|
33
|
-
apiBaseUrl
|
|
160
|
+
apiBaseUrl,
|
|
161
|
+
wsUrl
|
|
34
162
|
};
|
|
35
163
|
this.client = new ChatClient(config);
|
|
164
|
+
this.controller = new ChatController(this.client, conversationId);
|
|
36
165
|
this.shadow.innerHTML = `
|
|
37
166
|
<style>
|
|
38
|
-
:host { display: block; width: 100%; height: 100%; }
|
|
39
|
-
.chat-container {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
167
|
+
:host { display: block; width: 100%; height: 100%; color: #111827; }
|
|
168
|
+
.chat-container {
|
|
169
|
+
--sm-primary: ${themeColor};
|
|
170
|
+
--sm-primary-muted: color-mix(in srgb, var(--sm-primary) 10%, white);
|
|
171
|
+
width: 100%;
|
|
172
|
+
height: 100%;
|
|
173
|
+
display: flex;
|
|
174
|
+
flex-direction: column;
|
|
175
|
+
font-family: system-ui, sans-serif;
|
|
176
|
+
background: #fff;
|
|
177
|
+
border: 1px solid #e5e7eb;
|
|
178
|
+
border-radius: 16px;
|
|
179
|
+
overflow: hidden;
|
|
180
|
+
}
|
|
181
|
+
.header {
|
|
182
|
+
display: flex;
|
|
183
|
+
align-items: center;
|
|
184
|
+
justify-content: space-between;
|
|
185
|
+
padding: 14px 16px;
|
|
186
|
+
border-bottom: 1px solid #e5e7eb;
|
|
187
|
+
background: #fff;
|
|
188
|
+
}
|
|
189
|
+
.header-title {
|
|
190
|
+
font-size: 15px;
|
|
191
|
+
font-weight: 700;
|
|
192
|
+
}
|
|
193
|
+
.header-status-copy {
|
|
194
|
+
font-size: 12px;
|
|
195
|
+
color: #6b7280;
|
|
196
|
+
margin-top: 2px;
|
|
197
|
+
}
|
|
198
|
+
.header-presence {
|
|
199
|
+
display: inline-flex;
|
|
200
|
+
align-items: center;
|
|
201
|
+
gap: 8px;
|
|
202
|
+
font-size: 12px;
|
|
203
|
+
color: #6b7280;
|
|
204
|
+
white-space: nowrap;
|
|
205
|
+
}
|
|
206
|
+
.header-presence-dot {
|
|
207
|
+
width: 10px;
|
|
208
|
+
height: 10px;
|
|
209
|
+
border-radius: 999px;
|
|
210
|
+
background: #94a3b8;
|
|
211
|
+
}
|
|
212
|
+
.header-presence-dot.online {
|
|
213
|
+
background: #22c55e;
|
|
214
|
+
}
|
|
215
|
+
.messages {
|
|
216
|
+
flex: 1;
|
|
217
|
+
overflow-y: auto;
|
|
218
|
+
padding: 16px;
|
|
219
|
+
background: #f8fafc;
|
|
220
|
+
display: flex;
|
|
221
|
+
flex-direction: column;
|
|
222
|
+
gap: 12px;
|
|
223
|
+
}
|
|
224
|
+
.message {
|
|
225
|
+
display: flex;
|
|
226
|
+
flex-direction: column;
|
|
227
|
+
gap: 6px;
|
|
228
|
+
max-width: 82%;
|
|
229
|
+
}
|
|
230
|
+
.message-own { align-self: flex-end; }
|
|
231
|
+
.message-other { align-self: flex-start; }
|
|
232
|
+
.message-bubble {
|
|
233
|
+
padding: 10px 12px;
|
|
234
|
+
border-radius: 16px;
|
|
235
|
+
background: #f3f4f6;
|
|
236
|
+
color: #111827;
|
|
237
|
+
white-space: pre-wrap;
|
|
238
|
+
word-break: break-word;
|
|
239
|
+
}
|
|
240
|
+
.message-own .message-bubble {
|
|
241
|
+
background: var(--sm-primary);
|
|
242
|
+
color: #fff;
|
|
243
|
+
}
|
|
244
|
+
.message-meta {
|
|
245
|
+
display: flex;
|
|
246
|
+
gap: 8px;
|
|
247
|
+
align-items: center;
|
|
248
|
+
font-size: 12px;
|
|
249
|
+
color: #6b7280;
|
|
250
|
+
}
|
|
251
|
+
.message-edited { text-transform: lowercase; }
|
|
252
|
+
.message-action {
|
|
253
|
+
border: none;
|
|
254
|
+
background: transparent;
|
|
255
|
+
color: inherit;
|
|
256
|
+
cursor: pointer;
|
|
257
|
+
font-size: 12px;
|
|
258
|
+
padding: 0;
|
|
259
|
+
}
|
|
260
|
+
.date-divider,
|
|
261
|
+
.unread-divider {
|
|
262
|
+
align-self: center;
|
|
263
|
+
font-size: 12px;
|
|
264
|
+
color: #6b7280;
|
|
265
|
+
}
|
|
266
|
+
.date-divider {
|
|
267
|
+
padding: 4px 10px;
|
|
268
|
+
border-radius: 999px;
|
|
269
|
+
background: rgba(148, 163, 184, 0.12);
|
|
270
|
+
}
|
|
271
|
+
.unread-divider {
|
|
272
|
+
width: 100%;
|
|
273
|
+
display: flex;
|
|
274
|
+
align-items: center;
|
|
275
|
+
gap: 10px;
|
|
276
|
+
color: var(--sm-primary);
|
|
277
|
+
font-weight: 600;
|
|
278
|
+
}
|
|
279
|
+
.unread-divider::before,
|
|
280
|
+
.unread-divider::after {
|
|
281
|
+
content: '';
|
|
282
|
+
flex: 1;
|
|
283
|
+
height: 1px;
|
|
284
|
+
background: color-mix(in srgb, var(--sm-primary) 28%, white);
|
|
285
|
+
}
|
|
286
|
+
.attachment {
|
|
287
|
+
display: block;
|
|
288
|
+
width: 100%;
|
|
289
|
+
margin-top: 8px;
|
|
290
|
+
border-radius: 12px;
|
|
291
|
+
}
|
|
292
|
+
.attachment-image,
|
|
293
|
+
.attachment-video { max-width: 320px; }
|
|
294
|
+
.attachment-link { color: inherit; }
|
|
295
|
+
.reactions {
|
|
296
|
+
display: flex;
|
|
297
|
+
gap: 6px;
|
|
298
|
+
flex-wrap: wrap;
|
|
299
|
+
}
|
|
300
|
+
.reaction-badge,
|
|
301
|
+
.reaction-picker-btn {
|
|
302
|
+
border: 1px solid #dbe3ef;
|
|
303
|
+
border-radius: 999px;
|
|
304
|
+
background: #fff;
|
|
305
|
+
padding: 4px 8px;
|
|
306
|
+
cursor: pointer;
|
|
307
|
+
font-size: 12px;
|
|
308
|
+
}
|
|
309
|
+
.reaction-badge-active {
|
|
310
|
+
border-color: color-mix(in srgb, var(--sm-primary) 40%, white);
|
|
311
|
+
background: color-mix(in srgb, var(--sm-primary) 10%, white);
|
|
312
|
+
}
|
|
313
|
+
.reaction-picker {
|
|
314
|
+
display: flex;
|
|
315
|
+
flex-wrap: wrap;
|
|
316
|
+
gap: 6px;
|
|
317
|
+
}
|
|
318
|
+
.typing {
|
|
319
|
+
min-height: 20px;
|
|
320
|
+
padding: 0 16px 10px;
|
|
321
|
+
font-size: 12px;
|
|
322
|
+
color: #6b7280;
|
|
323
|
+
}
|
|
324
|
+
.attachments {
|
|
325
|
+
display: flex;
|
|
326
|
+
flex-wrap: wrap;
|
|
327
|
+
gap: 8px;
|
|
328
|
+
padding: 0 12px 12px;
|
|
329
|
+
}
|
|
330
|
+
.attachment-chip {
|
|
331
|
+
display: inline-flex;
|
|
332
|
+
align-items: center;
|
|
333
|
+
gap: 8px;
|
|
334
|
+
padding: 6px 10px;
|
|
335
|
+
border-radius: 999px;
|
|
336
|
+
border: 1px solid #dbe3ef;
|
|
337
|
+
background: #f8fafc;
|
|
338
|
+
font-size: 12px;
|
|
339
|
+
}
|
|
340
|
+
.attachment-chip-error {
|
|
341
|
+
border-color: #fecaca;
|
|
342
|
+
background: #fef2f2;
|
|
343
|
+
color: #b91c1c;
|
|
344
|
+
}
|
|
345
|
+
.attachment-remove {
|
|
346
|
+
border: none;
|
|
347
|
+
background: transparent;
|
|
348
|
+
color: inherit;
|
|
349
|
+
cursor: pointer;
|
|
350
|
+
font: inherit;
|
|
351
|
+
}
|
|
352
|
+
.input-area {
|
|
353
|
+
display: flex;
|
|
354
|
+
gap: 10px;
|
|
355
|
+
padding: 12px;
|
|
356
|
+
border-top: 1px solid #e5e7eb;
|
|
357
|
+
background: #fff;
|
|
358
|
+
}
|
|
359
|
+
.input-area textarea {
|
|
360
|
+
flex: 1;
|
|
361
|
+
min-height: 44px;
|
|
362
|
+
resize: vertical;
|
|
363
|
+
border: 1px solid #d1d5db;
|
|
364
|
+
border-radius: 12px;
|
|
365
|
+
padding: 10px 12px;
|
|
366
|
+
font: inherit;
|
|
367
|
+
outline: none;
|
|
368
|
+
}
|
|
369
|
+
.input-area button {
|
|
370
|
+
border: none;
|
|
371
|
+
border-radius: 12px;
|
|
372
|
+
padding: 0 16px;
|
|
373
|
+
background: var(--sm-primary);
|
|
374
|
+
color: white;
|
|
375
|
+
cursor: pointer;
|
|
376
|
+
}
|
|
377
|
+
.input-area .attach-btn {
|
|
378
|
+
background: var(--sm-primary-muted);
|
|
379
|
+
color: #0f172a;
|
|
380
|
+
}
|
|
381
|
+
.empty {
|
|
382
|
+
color: #6b7280;
|
|
383
|
+
font-size: 14px;
|
|
384
|
+
padding: 32px 0;
|
|
385
|
+
text-align: center;
|
|
386
|
+
}
|
|
45
387
|
</style>
|
|
46
388
|
<div class="chat-container">
|
|
47
|
-
<div class="
|
|
389
|
+
<div class="header">
|
|
390
|
+
<div>
|
|
391
|
+
<div class="header-title">Chat</div>
|
|
392
|
+
<div class="header-status-copy" id="status-copy">Loading conversation\u2026</div>
|
|
393
|
+
</div>
|
|
394
|
+
<div class="header-presence">
|
|
395
|
+
<span class="header-presence-dot" id="presence-dot"></span>
|
|
396
|
+
<span id="presence-label">Away</span>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
<div class="messages" id="messages" role="log" aria-live="polite"></div>
|
|
400
|
+
<div class="typing" id="typing" aria-live="polite"></div>
|
|
401
|
+
<div class="attachments" id="attachments" aria-live="polite"></div>
|
|
48
402
|
<div class="input-area">
|
|
49
|
-
<input type="
|
|
50
|
-
<button id="
|
|
403
|
+
<input type="file" id="file-input" hidden multiple accept="image/*,video/*,audio/*" aria-label="Attach files" />
|
|
404
|
+
<button class="attach-btn" id="attach" type="button" aria-label="Attach files">Attach</button>
|
|
405
|
+
<textarea placeholder="Type a message..." id="input" aria-label="Message"></textarea>
|
|
406
|
+
<button id="send" aria-label="Send message">Send</button>
|
|
51
407
|
</div>
|
|
52
408
|
</div>
|
|
53
409
|
`;
|
|
54
410
|
const messagesEl = this.shadow.getElementById("messages");
|
|
411
|
+
const typingEl = this.shadow.getElementById("typing");
|
|
412
|
+
const attachmentsEl = this.shadow.getElementById("attachments");
|
|
413
|
+
const statusCopyEl = this.shadow.getElementById("status-copy");
|
|
414
|
+
const presenceDotEl = this.shadow.getElementById("presence-dot");
|
|
415
|
+
const presenceLabelEl = this.shadow.getElementById("presence-label");
|
|
55
416
|
const inputEl = this.shadow.getElementById("input");
|
|
417
|
+
const fileInputEl = this.shadow.getElementById("file-input");
|
|
418
|
+
const attachBtn = this.shadow.getElementById("attach");
|
|
56
419
|
const sendBtn = this.shadow.getElementById("send");
|
|
57
|
-
|
|
58
|
-
this.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
420
|
+
const renderPendingAttachments = () => {
|
|
421
|
+
attachmentsEl.innerHTML = this.pendingAttachments.length ? this.pendingAttachments.map(
|
|
422
|
+
(attachment) => `
|
|
423
|
+
<div class="attachment-chip ${attachment.error ? "attachment-chip-error" : ""}">
|
|
424
|
+
<span>${escapeHtml(attachment.fileName)}</span>
|
|
425
|
+
<span>${escapeHtml(attachment.error ?? `${attachment.progress}%`)}</span>
|
|
426
|
+
<button class="attachment-remove" type="button" data-pending-id="${escapeHtml(attachment.id)}" aria-label="Remove attachment">x</button>
|
|
427
|
+
</div>
|
|
428
|
+
`
|
|
429
|
+
).join("") : "";
|
|
430
|
+
attachmentsEl.querySelectorAll("[data-pending-id]").forEach((button) => {
|
|
431
|
+
button.addEventListener("click", () => {
|
|
432
|
+
const id = button.dataset.pendingId;
|
|
433
|
+
if (!id) return;
|
|
434
|
+
this.pendingAttachments = this.pendingAttachments.filter((item) => item.id !== id);
|
|
435
|
+
renderPendingAttachments();
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
};
|
|
439
|
+
const renderState = (state) => {
|
|
440
|
+
this.currentState = state;
|
|
441
|
+
const currentUserId = this.client?.userId;
|
|
442
|
+
const activeMembers = state.members.filter((member) => member.userId !== currentUserId);
|
|
443
|
+
const online = activeMembers.some((member) => member.status === "online");
|
|
444
|
+
const onlineCount = activeMembers.filter((member) => member.status === "online").length;
|
|
445
|
+
const unreadSince = currentUserId ? state.readStatuses.find((status) => status.user_id === currentUserId)?.last_read_at : void 0;
|
|
446
|
+
const unreadIndex = getUnreadIndex(state.messages, unreadSince);
|
|
447
|
+
presenceDotEl.className = `header-presence-dot ${online ? "online" : ""}`;
|
|
448
|
+
presenceLabelEl.textContent = online ? "Online" : "Away";
|
|
449
|
+
statusCopyEl.textContent = state.error ? state.error : state.typingUsers.some((userId) => userId !== currentUserId) ? "Someone is typing\u2026" : onlineCount ? `${onlineCount} online` : "No one online";
|
|
450
|
+
if (!state.messages.length) {
|
|
451
|
+
messagesEl.innerHTML = `<div class="empty">${escapeHtml(state.error ?? (state.isLoading ? "Loading messages..." : "Start the conversation"))}</div>`;
|
|
452
|
+
} else {
|
|
453
|
+
messagesEl.innerHTML = state.messages.map((message, index) => {
|
|
454
|
+
const previousMessage = state.messages[index - 1];
|
|
455
|
+
const showDateDivider = !previousMessage || !isSameDay(previousMessage.created_at, message.created_at);
|
|
456
|
+
const showUnreadDivider = unreadIndex === index;
|
|
457
|
+
return `
|
|
458
|
+
${showDateDivider ? `<div class="date-divider">${escapeHtml(formatDayLabel(message.created_at))}</div>` : ""}
|
|
459
|
+
${showUnreadDivider ? `<div class="unread-divider" id="unread-divider">New messages</div>` : ""}
|
|
460
|
+
${renderMessage(message, currentUserId, this.openReactionMessageId === message.id)}
|
|
461
|
+
`;
|
|
462
|
+
}).join("");
|
|
463
|
+
}
|
|
464
|
+
typingEl.textContent = state.typingUsers.some((userId) => userId !== currentUserId) ? "Someone is typing..." : "";
|
|
465
|
+
const unreadDivider = messagesEl.querySelector("#unread-divider");
|
|
466
|
+
if (unreadDivider && !this.didScrollToUnread) {
|
|
467
|
+
unreadDivider.scrollIntoView({ block: "center" });
|
|
468
|
+
this.didScrollToUnread = true;
|
|
469
|
+
} else {
|
|
470
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
this.cleanupFns.push(
|
|
474
|
+
this.controller.on("state", (state) => {
|
|
475
|
+
renderState(state);
|
|
476
|
+
})
|
|
477
|
+
);
|
|
478
|
+
this.cleanupFns.push(
|
|
479
|
+
this.client.on("message", () => {
|
|
480
|
+
this.dispatchEvent(
|
|
481
|
+
new CustomEvent("chat-message", { composed: true, bubbles: true })
|
|
482
|
+
);
|
|
483
|
+
})
|
|
484
|
+
);
|
|
485
|
+
messagesEl.addEventListener("click", (event) => {
|
|
486
|
+
const target = event.target.closest("[data-action]");
|
|
487
|
+
if (!target || !this.controller || !this.currentState) return;
|
|
488
|
+
const action = target.dataset.action;
|
|
489
|
+
const messageId = target.dataset.messageId;
|
|
490
|
+
if (!messageId) return;
|
|
491
|
+
if (action === "toggle-picker") {
|
|
492
|
+
this.openReactionMessageId = this.openReactionMessageId === messageId ? null : messageId;
|
|
493
|
+
renderState(this.currentState);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const emoji = target.dataset.emoji;
|
|
497
|
+
if (!emoji) return;
|
|
498
|
+
if (action === "add-reaction") {
|
|
499
|
+
this.openReactionMessageId = null;
|
|
500
|
+
void this.controller.addReaction(messageId, emoji);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (action === "toggle-reaction") {
|
|
504
|
+
const reacted = target.dataset.reacted === "true";
|
|
505
|
+
if (reacted) {
|
|
506
|
+
void this.controller.removeReaction(messageId, emoji);
|
|
507
|
+
} else {
|
|
508
|
+
void this.controller.addReaction(messageId, emoji);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
62
511
|
});
|
|
63
|
-
const
|
|
512
|
+
const handleFiles = async (files) => {
|
|
513
|
+
if (!this.controller) return;
|
|
514
|
+
for (const file of Array.from(files)) {
|
|
515
|
+
const pendingId = `${file.name}:${file.size}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
516
|
+
this.pendingAttachments = [
|
|
517
|
+
...this.pendingAttachments,
|
|
518
|
+
{
|
|
519
|
+
id: pendingId,
|
|
520
|
+
fileName: file.name,
|
|
521
|
+
progress: 0
|
|
522
|
+
}
|
|
523
|
+
];
|
|
524
|
+
renderPendingAttachments();
|
|
525
|
+
const result = await this.controller.uploadAttachment(file, (progress) => {
|
|
526
|
+
this.pendingAttachments = this.pendingAttachments.map(
|
|
527
|
+
(attachment) => attachment.id === pendingId ? { ...attachment, progress } : attachment
|
|
528
|
+
);
|
|
529
|
+
renderPendingAttachments();
|
|
530
|
+
});
|
|
531
|
+
this.pendingAttachments = this.pendingAttachments.map((attachment) => {
|
|
532
|
+
if (attachment.id !== pendingId) return attachment;
|
|
533
|
+
if (result?.data) {
|
|
534
|
+
return {
|
|
535
|
+
...attachment,
|
|
536
|
+
progress: 100,
|
|
537
|
+
attachment: result.data
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
...attachment,
|
|
542
|
+
error: result?.error?.message ?? "Upload failed"
|
|
543
|
+
};
|
|
544
|
+
});
|
|
545
|
+
renderPendingAttachments();
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
const send = async () => {
|
|
64
549
|
const content = inputEl.value.trim();
|
|
65
|
-
|
|
66
|
-
|
|
550
|
+
const readyAttachments = this.pendingAttachments.filter((attachment) => attachment.attachment).map((attachment) => attachment.attachment);
|
|
551
|
+
if (!content && !readyAttachments.length || !this.controller) return;
|
|
67
552
|
inputEl.value = "";
|
|
553
|
+
await this.controller.sendMessage(content, readyAttachments);
|
|
554
|
+
this.pendingAttachments = [];
|
|
555
|
+
renderPendingAttachments();
|
|
556
|
+
await this.controller.markRead();
|
|
68
557
|
};
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (e.key === "Enter") doSend();
|
|
558
|
+
attachBtn.addEventListener("click", () => {
|
|
559
|
+
fileInputEl.click();
|
|
72
560
|
});
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
el.className = "message";
|
|
92
|
-
el.textContent = message.content;
|
|
93
|
-
container.appendChild(el);
|
|
94
|
-
container.scrollTop = container.scrollHeight;
|
|
561
|
+
fileInputEl.addEventListener("change", () => {
|
|
562
|
+
if (fileInputEl.files) {
|
|
563
|
+
void handleFiles(fileInputEl.files);
|
|
564
|
+
fileInputEl.value = "";
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
sendBtn.addEventListener("click", () => {
|
|
568
|
+
void send();
|
|
569
|
+
});
|
|
570
|
+
inputEl.addEventListener("keydown", (event) => {
|
|
571
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
572
|
+
event.preventDefault();
|
|
573
|
+
void send();
|
|
574
|
+
} else {
|
|
575
|
+
this.controller?.sendTyping(true);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
void this.controller.init().then(() => this.controller?.markRead());
|
|
95
579
|
}
|
|
96
580
|
cleanup() {
|
|
97
|
-
this.
|
|
98
|
-
|
|
581
|
+
for (const cleanup of this.cleanupFns) {
|
|
582
|
+
cleanup();
|
|
583
|
+
}
|
|
584
|
+
this.cleanupFns = [];
|
|
585
|
+
this.controller?.destroy();
|
|
586
|
+
this.controller = null;
|
|
99
587
|
this.client?.destroy();
|
|
100
588
|
this.client = null;
|
|
589
|
+
this.currentState = null;
|
|
590
|
+
this.openReactionMessageId = null;
|
|
591
|
+
this.didScrollToUnread = false;
|
|
101
592
|
this.shadow.innerHTML = "";
|
|
102
593
|
}
|
|
103
594
|
};
|