tacel-chat 1.2.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/chat.js ADDED
@@ -0,0 +1,834 @@
1
+ /**
2
+ * TacelChat — Main frontend module
3
+ * Supports conversation mode (sidebar + direct/team) and room mode (tabs + rooms)
4
+ */
5
+
6
+ const { Sidebar } = require('./components/sidebar');
7
+ const { TabBar } = require('./components/tabs');
8
+ const { MessageArea } = require('./components/message-area');
9
+ const { NewChatModal } = require('./components/new-chat');
10
+ const { ContextMenu, resolveMenuItems } = require('./components/context-menu');
11
+ const { THEMES } = require('./themes');
12
+
13
+ class TacelChat {
14
+ constructor() {
15
+ this.config = {};
16
+ this.containerEl = null;
17
+
18
+ // State
19
+ this.currentUsername = '';
20
+ this.conversations = [];
21
+ this.users = [];
22
+ this.activeConversationId = null;
23
+ this.messages = [];
24
+ this.messageReads = {};
25
+ this.unreadCounts = {};
26
+ this.isInitialized = false;
27
+
28
+ // Sub-components
29
+ this.sidebar = null;
30
+ this.tabBar = null;
31
+ this.messageArea = null;
32
+ this.newChatModal = null;
33
+ this.contextMenu = null;
34
+
35
+ // Mention counts
36
+ this.mentionCounts = {};
37
+
38
+ // Timers
39
+ this._refreshInterval = null;
40
+ this._heartbeatInterval = null;
41
+ this._messagePollingInterval = null;
42
+
43
+ // Bound handlers
44
+ this._boundBeforeUnload = null;
45
+ }
46
+
47
+ /**
48
+ * Initialize the chat module
49
+ * @param {HTMLElement} container - DOM element to mount into
50
+ * @param {object} config - Full configuration object
51
+ * @returns {TacelChat} instance
52
+ */
53
+ async initialize(container, config) {
54
+ this.config = this._mergeDefaults(config);
55
+ this.containerEl = container;
56
+ this.containerEl.innerHTML = '';
57
+ this.containerEl.className = (this.containerEl.className + ' tacel-chat').trim();
58
+
59
+ // Resolve and apply theme
60
+ let themeVars = this.config.theme || {};
61
+ if (typeof themeVars === 'string') {
62
+ themeVars = THEMES[themeVars] || {};
63
+ }
64
+ // Clear any previous inline CSS vars
65
+ this.containerEl.removeAttribute('style');
66
+ for (const [key, val] of Object.entries(themeVars)) {
67
+ this.containerEl.style.setProperty(key, val);
68
+ }
69
+ this._themeVars = themeVars;
70
+
71
+ // Get current user
72
+ this.currentUsername = this.config.currentUsername || '';
73
+
74
+ const features = this.config.features || {};
75
+ const mode = this.config.mode || 'conversation';
76
+
77
+ // Show loading
78
+ this.containerEl.innerHTML = `
79
+ <div class="tc-loading-full">
80
+ <div class="tc-spinner"></div>
81
+ <p>Loading chat...</p>
82
+ </div>
83
+ `;
84
+
85
+ try {
86
+ // Fetch initial data
87
+ this.users = await this._call(this.config.onFetchUsers) || [];
88
+ this.conversations = await this._call(this.config.onFetchConversations, this.currentUsername) || [];
89
+
90
+ if (features.unreadCounts) {
91
+ const counts = await this._call(this.config.onFetchUnreadCounts, this.currentUsername) || [];
92
+ this._applyUnreadCounts(counts);
93
+ }
94
+
95
+ if (features.mentions && this.config.onFetchMentionCounts) {
96
+ const mCounts = await this._call(this.config.onFetchMentionCounts, this.currentUsername) || [];
97
+ this._applyMentionCounts(mCounts);
98
+ }
99
+
100
+ // Clear loading
101
+ this.containerEl.innerHTML = '';
102
+
103
+ if (mode === 'room') {
104
+ this._initRoomMode();
105
+ } else {
106
+ this._initConversationMode();
107
+ }
108
+
109
+ this.isInitialized = true;
110
+
111
+ // Start timers
112
+ this._startRefreshInterval();
113
+ this._startHeartbeat();
114
+ this._startMessagePolling();
115
+
116
+ // Beforeunload
117
+ if (features.presence && this.config.onPresenceOffline) {
118
+ this._boundBeforeUnload = () => {
119
+ this._call(this.config.onPresenceOffline, this.currentUsername);
120
+ };
121
+ window.addEventListener('beforeunload', this._boundBeforeUnload);
122
+ }
123
+
124
+ } catch (err) {
125
+ console.error('[TacelChat] Initialization failed:', err);
126
+ this.containerEl.innerHTML = `
127
+ <div class="tc-error-full">
128
+ <i class="fas fa-exclamation-circle"></i>
129
+ <p>Failed to load chat</p>
130
+ <p style="font-size:12px;opacity:0.6;">${err.message || ''}</p>
131
+ </div>
132
+ `;
133
+ }
134
+
135
+ return this;
136
+ }
137
+
138
+ // ─── Conversation Mode ───────────────────────────────────────────
139
+
140
+ _initConversationMode() {
141
+ const features = this.config.features || {};
142
+
143
+ // Context menu
144
+ this.contextMenu = new ContextMenu(this.containerEl);
145
+
146
+ // Create layout
147
+ const wrapper = document.createElement('div');
148
+ wrapper.className = 'tc-container';
149
+
150
+ // Sidebar
151
+ this.sidebar = new Sidebar({
152
+ showPresence: features.presence !== false,
153
+ showSearch: features.search !== false,
154
+ showNewChat: features.newChat !== false,
155
+ resizable: features.resizableSidebar !== false,
156
+ presenceStaleMs: this.config.presenceStaleMs || 10000,
157
+ labels: this.config.labels,
158
+ onSelectConversation: (id) => this._selectConversation(id),
159
+ onNewChat: () => this._showNewChatModal(),
160
+ onSearch: () => this._updateSidebar(),
161
+ onContextMenu: (conv, event) => {
162
+ const items = resolveMenuItems('conversation', conv, this);
163
+ this.contextMenu.show(event, items);
164
+ }
165
+ });
166
+ this.sidebar.render(wrapper);
167
+
168
+ // Message area
169
+ this.messageArea = new MessageArea({
170
+ mode: 'conversation',
171
+ currentUsername: this.currentUsername,
172
+ features,
173
+ labels: this.config.labels,
174
+ presenceStaleMs: this.config.presenceStaleMs || 10000,
175
+ onSendMessage: (content, attachment, replyTo) => this._sendMessage(content, attachment, replyTo),
176
+ onUploadAttachment: this.config.onUploadAttachment,
177
+ onOpenFile: this.config.onOpenFile,
178
+ onOpenFolder: this.config.onOpenFolder || null,
179
+ onGetFileContent: this.config.onGetFileContent,
180
+ onFetchMentionUsers: this.config.onFetchMentionUsers,
181
+ onLinkClick: this.config.onLinkClick || ((url) => { window.open(url, '_blank'); }),
182
+ onPinMessage: this.config.onPinMessage || null,
183
+ onUnpinMessage: (msg) => this._unpinMessage(msg),
184
+ onFetchPinnedMessages: this.config.onFetchPinnedMessages || null,
185
+ onMessageContextMenu: (msg, event) => {
186
+ const items = resolveMenuItems('message', msg, this);
187
+ this.contextMenu.show(event, items);
188
+ },
189
+ onFileContextMenu: (file, event) => {
190
+ this._showFileContextMenu(file, event);
191
+ }
192
+ });
193
+ this.messageArea.render(wrapper);
194
+
195
+ // Mount confirm dialog inside the .tacel-chat container so CSS variables are inherited
196
+ if (this.messageArea.confirmDialog) {
197
+ this.messageArea.confirmDialog.mountEl = this.containerEl;
198
+ }
199
+
200
+ this.containerEl.appendChild(wrapper);
201
+
202
+ // New chat modal
203
+ if (features.newChat !== false) {
204
+ this.newChatModal = new NewChatModal({
205
+ mountEl: this.containerEl,
206
+ onStartDirect: async (currentUsername, otherUserId) => {
207
+ const result = await this._call(this.config.onStartDirect, currentUsername, otherUserId);
208
+ if (result && result.success && result.conversationId) {
209
+ await this.refresh();
210
+ this._selectConversation(result.conversationId);
211
+ }
212
+ return result;
213
+ }
214
+ });
215
+ }
216
+
217
+ // Select first conversation
218
+ this._updateSidebar();
219
+ if (this.conversations.length > 0) {
220
+ this._selectConversation(this.conversations[0].id);
221
+ }
222
+ }
223
+
224
+ _updateSidebar() {
225
+ if (!this.sidebar) return;
226
+ this.sidebar.update(this.conversations, this.activeConversationId, this.users);
227
+ }
228
+
229
+ async _selectConversation(id) {
230
+ this.activeConversationId = id;
231
+ this._updateSidebar();
232
+
233
+ const conv = this.conversations.find(c => String(c.id) === String(id));
234
+ this.messageArea.updateHeader(conv, this.users);
235
+ this.messageArea.setActiveConversation(id);
236
+ this.messageArea.showLoading();
237
+
238
+ try {
239
+ const messages = await this._call(this.config.onFetchMessages, id, {}) || [];
240
+ this.messages = messages;
241
+
242
+ // Fetch read receipts
243
+ let reads = {};
244
+ if (this.config.features.readReceipts && this.config.onFetchMessageReads) {
245
+ const rawReads = await this._call(this.config.onFetchMessageReads, id) || [];
246
+ reads = this._groupReads(rawReads);
247
+ }
248
+ this.messageReads = reads;
249
+
250
+ this.messageArea.renderMessages(messages, reads);
251
+
252
+ // Mark as read
253
+ if (this.config.features.unreadCounts && this.config.onMarkRead) {
254
+ await this._call(this.config.onMarkRead, id, this.currentUsername);
255
+ // Update local unread count
256
+ const conv = this.conversations.find(c => String(c.id) === String(id));
257
+ if (conv) conv.unreadCount = 0;
258
+ this._updateSidebar();
259
+ }
260
+
261
+ this.messageArea.focusInput();
262
+
263
+ // Load pinned messages for this conversation
264
+ await this._loadPinnedMessages();
265
+ } catch (err) {
266
+ console.error('[TacelChat] Failed to load messages:', err);
267
+ this.messageArea.showError('Failed to load messages', () => this._selectConversation(id));
268
+ }
269
+ }
270
+
271
+ async _showNewChatModal() {
272
+ if (!this.newChatModal) return;
273
+ let peerIds = [];
274
+ if (this.config.onFetchDirectPeers) {
275
+ peerIds = await this._call(this.config.onFetchDirectPeers, this.currentUsername) || [];
276
+ }
277
+ this.newChatModal.show(this.users, peerIds, this.currentUsername);
278
+ }
279
+
280
+ // ─── Room Mode ───────────────────────────────────────────────────
281
+
282
+ _initRoomMode() {
283
+ const features = this.config.features || {};
284
+ const rooms = this.config.rooms || [];
285
+
286
+ // Context menu
287
+ this.contextMenu = new ContextMenu(this.containerEl);
288
+
289
+ const wrapper = document.createElement('div');
290
+ wrapper.className = 'tc-container tc-room-mode';
291
+
292
+ // Tab bar
293
+ this.tabBar = new TabBar({
294
+ rooms,
295
+ labels: this.config.labels,
296
+ onSelectRoom: (type) => this._selectRoom(type),
297
+ onContextMenu: (room, event) => {
298
+ const items = resolveMenuItems('tab', room, this);
299
+ this.contextMenu.show(event, items);
300
+ }
301
+ });
302
+ this.tabBar.render(wrapper);
303
+
304
+ // Message area
305
+ this.messageArea = new MessageArea({
306
+ mode: 'room',
307
+ currentUsername: this.currentUsername,
308
+ features,
309
+ labels: this.config.labels,
310
+ presenceStaleMs: this.config.presenceStaleMs || 10000,
311
+ onSendMessage: (content, attachment, replyTo) => this._sendMessage(content, attachment, replyTo),
312
+ onUploadAttachment: this.config.onUploadAttachment,
313
+ onOpenFile: this.config.onOpenFile,
314
+ onOpenFolder: this.config.onOpenFolder || null,
315
+ onGetFileContent: this.config.onGetFileContent,
316
+ onFetchMentionUsers: this.config.onFetchMentionUsers,
317
+ onLinkClick: this.config.onLinkClick || ((url) => { window.open(url, '_blank'); }),
318
+ onPinMessage: this.config.onPinMessage || null,
319
+ onUnpinMessage: (msg) => this._unpinMessage(msg),
320
+ onFetchPinnedMessages: this.config.onFetchPinnedMessages || null,
321
+ onMessageContextMenu: (msg, event) => {
322
+ const items = resolveMenuItems('message', msg, this);
323
+ this.contextMenu.show(event, items);
324
+ },
325
+ onFileContextMenu: (file, event) => {
326
+ this._showFileContextMenu(file, event);
327
+ }
328
+ });
329
+ this.messageArea.render(wrapper);
330
+
331
+ // Mount confirm dialog inside the .tacel-chat container so CSS variables are inherited
332
+ if (this.messageArea.confirmDialog) {
333
+ this.messageArea.confirmDialog.mountEl = this.containerEl;
334
+ }
335
+
336
+ // In room mode, create action buttons directly in the tab bar
337
+ // and hide the message area header entirely (it just repeats the room name)
338
+ if (this.tabBar && this.tabBar.actionsEl) {
339
+ if (this.messageArea.searchPanel) {
340
+ this.tabBar.actionsEl.appendChild(this.messageArea.searchPanel.createToggleButton());
341
+ }
342
+ if (this.messageArea.pinnedPanel) {
343
+ this.tabBar.actionsEl.appendChild(this.messageArea.pinnedPanel.createToggleButton());
344
+ }
345
+ if (this.messageArea.filesPanel) {
346
+ this.tabBar.actionsEl.appendChild(this.messageArea.filesPanel.createToggleButton());
347
+ }
348
+ // Hide the message area header row
349
+ const header = this.messageArea.containerEl.querySelector('.tc-chat-header');
350
+ if (header) header.style.display = 'none';
351
+ }
352
+
353
+ this.containerEl.appendChild(wrapper);
354
+
355
+ // Select default room
356
+ const defaultType = this.config.defaultRoom || (rooms[0] && rooms[0].type) || 'general';
357
+ this._selectRoom(defaultType);
358
+ }
359
+
360
+ async _selectRoom(roomType) {
361
+ const rooms = this.config.rooms || [];
362
+ const room = rooms.find(r => r.type === roomType);
363
+ if (!room) return;
364
+
365
+ // Find the conversation/room data
366
+ let conv = this.conversations.find(c => c.type === 'room' && c.meta && c.meta.roomType === roomType);
367
+ if (!conv) {
368
+ // For room mode, conversations are the rooms themselves
369
+ conv = { id: room.id, name: room.name, type: 'room', meta: { roomType } };
370
+ }
371
+
372
+ this.activeConversationId = conv.id;
373
+ if (this.tabBar) this.tabBar.setActive(roomType);
374
+
375
+ this.messageArea.updateHeader(conv, this.users);
376
+ this.messageArea.setActiveConversation(conv.id);
377
+ this.messageArea.showLoading();
378
+
379
+ try {
380
+ const messages = await this._call(this.config.onFetchMessages, conv.id, { limit: 50, offset: 0 }) || [];
381
+ this.messages = messages;
382
+ this.messageArea.renderMessages(messages, {});
383
+
384
+ // Mark room notifications as read
385
+ if (this.config.features.mentionNotifications && this.config.onMarkRoomNotificationsRead) {
386
+ await this._call(this.config.onMarkRoomNotificationsRead, conv.id, this.currentUsername);
387
+ }
388
+
389
+ this.messageArea.focusInput();
390
+
391
+ // Load pinned messages for this room
392
+ await this._loadPinnedMessages();
393
+ } catch (err) {
394
+ console.error('[TacelChat] Failed to load room messages:', err);
395
+ this.messageArea.showError('Failed to load messages', () => this._selectRoom(roomType));
396
+ }
397
+ }
398
+
399
+ // ─── File Context Menu ───────────────────────────────────────────
400
+
401
+ _showFileContextMenu(file, event) {
402
+ const L = this.config.labels || {};
403
+ const items = [];
404
+
405
+ if (file.path) {
406
+ items.push({
407
+ label: L.openFile || 'Open File',
408
+ icon: 'fas fa-external-link-alt',
409
+ onClick: () => {
410
+ if (this.config.onOpenFile) this.config.onOpenFile(file.path);
411
+ }
412
+ });
413
+
414
+ if (this.config.onOpenFolder) {
415
+ items.push({
416
+ label: L.openFolder || 'Open Folder',
417
+ icon: 'fas fa-folder-open',
418
+ onClick: () => this.config.onOpenFolder(file.path)
419
+ });
420
+ }
421
+ }
422
+
423
+ if (file.msgId) {
424
+ items.push({
425
+ label: L.scrollToMessage || 'Scroll to Message',
426
+ icon: 'fas fa-search',
427
+ onClick: () => {
428
+ if (this.messageArea && this.messageArea.filesPanel) {
429
+ this.messageArea.filesPanel.close();
430
+ }
431
+ if (this.messageArea) this.messageArea.scrollToMessage(file.msgId);
432
+ }
433
+ });
434
+ }
435
+
436
+ if (items.length > 0) {
437
+ this.contextMenu.show(event, items);
438
+ }
439
+ }
440
+
441
+ // ─── Sending ─────────────────────────────────────────────────────
442
+
443
+ async _sendMessage(content, attachment, replyTo) {
444
+ if (!this.activeConversationId) return;
445
+
446
+ let uploadedAttachment = null;
447
+ if (attachment && this.config.onUploadAttachment) {
448
+ try {
449
+ // If no file path (e.g. clipboard paste), read the blob as base64
450
+ if (!attachment.path && attachment.file) {
451
+ const base64 = await this._readFileAsBase64(attachment.file);
452
+ attachment.data = base64;
453
+ }
454
+ const result = await this._call(this.config.onUploadAttachment, attachment);
455
+ if (result && result.success) {
456
+ uploadedAttachment = {
457
+ name: result.name || attachment.name,
458
+ type: result.type || attachment.type,
459
+ size: result.size || attachment.size,
460
+ path: result.path
461
+ };
462
+ }
463
+ } catch (err) {
464
+ console.error('[TacelChat] Attachment upload failed:', err);
465
+ }
466
+ }
467
+
468
+ await this._call(this.config.onSendMessage, this.activeConversationId, content, uploadedAttachment, replyTo);
469
+
470
+ // Reload messages for this conversation
471
+ await this._reloadActiveMessages();
472
+ }
473
+
474
+ /**
475
+ * Read a File/Blob as base64 data URL string
476
+ */
477
+ _readFileAsBase64(file) {
478
+ return new Promise((resolve, reject) => {
479
+ const reader = new FileReader();
480
+ reader.onload = () => resolve(reader.result);
481
+ reader.onerror = () => reject(reader.error);
482
+ reader.readAsDataURL(file);
483
+ });
484
+ }
485
+
486
+ async _loadPinnedMessages() {
487
+ if (!this.activeConversationId || !this.config.onFetchPinnedMessages) return;
488
+ try {
489
+ const pinned = await this._call(this.config.onFetchPinnedMessages, this.activeConversationId) || [];
490
+ this.messageArea.updatePinnedMessages(pinned);
491
+ // Re-render messages so pin icons update
492
+ if (this.messages && this.messages.length > 0) {
493
+ this.messageArea.renderMessages(this.messages, this.messageReads || {}, true);
494
+ }
495
+ } catch (err) {
496
+ console.error('[TacelChat] Failed to load pinned messages:', err);
497
+ }
498
+ }
499
+
500
+ async _unpinMessage(msg) {
501
+ if (this.config.onUnpinMessage) {
502
+ await this._call(this.config.onUnpinMessage, msg, this.activeConversationId);
503
+ await this._loadPinnedMessages();
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Pin a message (called from context menu action)
509
+ */
510
+ async pinMessage(msg) {
511
+ if (this.config.onPinMessage) {
512
+ await this._call(this.config.onPinMessage, msg, this.activeConversationId);
513
+ await this._loadPinnedMessages();
514
+ }
515
+ }
516
+
517
+ async _reloadActiveMessages() {
518
+ if (!this.activeConversationId) return;
519
+ try {
520
+ const messages = await this._call(this.config.onFetchMessages, this.activeConversationId, {}) || [];
521
+ this.messages = messages;
522
+
523
+ let reads = {};
524
+ if (this.config.features.readReceipts && this.config.onFetchMessageReads) {
525
+ const rawReads = await this._call(this.config.onFetchMessageReads, this.activeConversationId) || [];
526
+ reads = this._groupReads(rawReads);
527
+ }
528
+ this.messageReads = reads;
529
+
530
+ this.messageArea.renderMessages(messages, reads, true);
531
+ } catch (err) {
532
+ console.error('[TacelChat] Failed to reload messages:', err);
533
+ }
534
+ }
535
+
536
+ // ─── Real-Time Event Hooks ───────────────────────────────────────
537
+
538
+ /**
539
+ * Call when a new message arrives via socket/polling
540
+ */
541
+ async onNewMessage(payload) {
542
+ if (!this.isInitialized) return;
543
+ const convId = payload && (payload.conversationId || payload.conversation_id);
544
+
545
+ if (String(convId) === String(this.activeConversationId)) {
546
+ await this._reloadActiveMessages();
547
+ // Auto mark read
548
+ if (this.config.features.unreadCounts && this.config.onMarkRead) {
549
+ await this._call(this.config.onMarkRead, convId, this.currentUsername);
550
+ }
551
+ }
552
+
553
+ // Refresh conversations and unread counts
554
+ await this._refreshConversationsAndUnread();
555
+ }
556
+
557
+ /**
558
+ * Call when a read event arrives
559
+ */
560
+ async onReadEvent(payload) {
561
+ if (!this.isInitialized) return;
562
+ const convId = payload && (payload.conversationId || payload.conversation_id);
563
+ await this._refreshConversationsAndUnread();
564
+ if (String(convId) === String(this.activeConversationId)) {
565
+ await this._reloadActiveMessages();
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Call when a new conversation is created
571
+ */
572
+ async onNewConversation(payload) {
573
+ if (!this.isInitialized) return;
574
+ await this._refreshConversationsAndUnread();
575
+ }
576
+
577
+ /**
578
+ * Call when a presence update arrives
579
+ */
580
+ onPresenceUpdate(payload) {
581
+ if (!this.isInitialized) return;
582
+ if (!payload) return;
583
+ const user = this.users.find(u => String(u.id) === String(payload.userId) ||
584
+ u.username.toLowerCase() === (payload.username || '').toLowerCase());
585
+ if (user) {
586
+ user.isOnline = payload.isOnline;
587
+ user.lastSeen = payload.lastSeen;
588
+ }
589
+ this._updateSidebar();
590
+ // Update header if this user is the active conversation
591
+ if (this.messageArea && this.activeConversationId) {
592
+ const conv = this.conversations.find(c => String(c.id) === String(this.activeConversationId));
593
+ if (conv) this.messageArea.updateHeader(conv, this.users);
594
+ }
595
+ }
596
+
597
+ // ─── Refresh / Polling ───────────────────────────────────────────
598
+
599
+ _startRefreshInterval() {
600
+ const ms = this.config.refreshIntervalMs;
601
+ if (!ms || ms <= 0) return;
602
+ this._refreshInterval = setInterval(() => this._refreshConversationsAndUnread(), ms);
603
+ }
604
+
605
+ _startHeartbeat() {
606
+ const ms = this.config.heartbeatMs;
607
+ if (!ms || ms <= 0 || !this.config.features.presence) return;
608
+ // Immediate heartbeat
609
+ this._call(this.config.onPresenceHeartbeat, this.currentUsername);
610
+ this._heartbeatInterval = setInterval(() => {
611
+ this._call(this.config.onPresenceHeartbeat, this.currentUsername);
612
+ }, ms);
613
+ }
614
+
615
+ _startMessagePolling() {
616
+ const ms = this.config.messagePollingMs;
617
+ if (!ms || ms <= 0) return;
618
+ this._messagePollingInterval = setInterval(() => this._reloadActiveMessages(), ms);
619
+ }
620
+
621
+ async _refreshConversationsAndUnread() {
622
+ try {
623
+ this.users = await this._call(this.config.onFetchUsers) || this.users;
624
+ this.conversations = await this._call(this.config.onFetchConversations, this.currentUsername) || this.conversations;
625
+ if (this.config.features.unreadCounts) {
626
+ const counts = await this._call(this.config.onFetchUnreadCounts, this.currentUsername) || [];
627
+ this._applyUnreadCounts(counts);
628
+ }
629
+ if (this.config.features.mentions && this.config.onFetchMentionCounts) {
630
+ const mCounts = await this._call(this.config.onFetchMentionCounts, this.currentUsername) || [];
631
+ this._applyMentionCounts(mCounts);
632
+ }
633
+ this._updateSidebar();
634
+ // Also reload active messages so new messages from other users appear
635
+ if (this.activeConversationId) {
636
+ await this._reloadActiveMessages();
637
+ }
638
+ } catch (err) {
639
+ console.error('[TacelChat] Refresh failed:', err);
640
+ }
641
+ }
642
+
643
+ // ─── Public API ──────────────────────────────────────────────────
644
+
645
+ /**
646
+ * Programmatically open a conversation
647
+ */
648
+ async openConversation(conversationId) {
649
+ if (this.config.mode === 'room') return;
650
+ await this._selectConversation(conversationId);
651
+ }
652
+
653
+ /**
654
+ * Switch to a room tab (room mode)
655
+ */
656
+ async switchRoom(roomType) {
657
+ if (this.config.mode !== 'room') return;
658
+ await this._selectRoom(roomType);
659
+ }
660
+
661
+ /**
662
+ * Refresh all data
663
+ */
664
+ async refresh() {
665
+ await this._refreshConversationsAndUnread();
666
+ if (this.activeConversationId) {
667
+ await this._reloadActiveMessages();
668
+ }
669
+ }
670
+
671
+ /**
672
+ * Check if initialized
673
+ */
674
+ isReady() {
675
+ return this.isInitialized;
676
+ }
677
+
678
+ /**
679
+ * Clean up everything
680
+ */
681
+ destroy() {
682
+ if (this._refreshInterval) clearInterval(this._refreshInterval);
683
+ if (this._heartbeatInterval) clearInterval(this._heartbeatInterval);
684
+ if (this._messagePollingInterval) clearInterval(this._messagePollingInterval);
685
+ if (this._boundBeforeUnload) {
686
+ window.removeEventListener('beforeunload', this._boundBeforeUnload);
687
+ }
688
+ if (this.sidebar) this.sidebar.destroy();
689
+ if (this.tabBar) this.tabBar.destroy();
690
+ if (this.messageArea) this.messageArea.destroy();
691
+ if (this.newChatModal) this.newChatModal.destroy();
692
+ if (this.contextMenu) this.contextMenu.destroy();
693
+ if (this.containerEl) this.containerEl.innerHTML = '';
694
+ this.isInitialized = false;
695
+ }
696
+
697
+ // ─── Helpers ─────────────────────────────────────────────────────
698
+
699
+ _mergeDefaults(config) {
700
+ const defaults = {
701
+ mode: 'conversation',
702
+ features: {
703
+ directMessages: true,
704
+ teamConversations: true,
705
+ teamManagement: false,
706
+ presence: true,
707
+ readReceipts: true,
708
+ unreadCounts: true,
709
+ search: true,
710
+ newChat: true,
711
+ attachments: false,
712
+ attachmentPreview: false,
713
+ mentions: false,
714
+ mentionNotifications: false,
715
+ urlLinkify: true,
716
+ tabs: false,
717
+ darkMode: false
718
+ },
719
+ labels: {
720
+ sidebarTitle: 'Messages',
721
+ searchPlaceholder: 'Search conversations...',
722
+ newChatTitle: 'New Direct Message',
723
+ newChatSearch: 'Search users...',
724
+ teamsHeader: 'Teams',
725
+ directsHeader: 'Direct Messages',
726
+ noConversations: 'No conversations yet',
727
+ noResults: 'No conversations found',
728
+ noMessages: 'No messages yet',
729
+ noMessagesHint: 'Send a message to start the conversation',
730
+ inputPlaceholder: 'Type a message...',
731
+ roomTitle: 'Chat Center',
732
+ loadingText: 'Loading...',
733
+ errorText: 'Failed to load chat',
734
+ retryText: 'Retry',
735
+ seenBy: 'Seen by',
736
+ online: 'Online',
737
+ offline: 'Offline',
738
+ lastSeen: 'Last seen',
739
+ typing: 'typing...',
740
+ noUsers: 'No users available',
741
+ copyText: 'Copy Text',
742
+ reply: 'Reply',
743
+ quote: 'Quote',
744
+ forward: 'Forward',
745
+ editMessage: 'Edit Message',
746
+ deleteMessage: 'Delete Message',
747
+ pinMessage: 'Pin Message',
748
+ unpinMessage: 'Unpin Message',
749
+ markAsRead: 'Mark as Read',
750
+ muteNotifications: 'Mute Notifications',
751
+ unmuteNotifications: 'Unmute',
752
+ pinToTop: 'Pin to Top',
753
+ unpin: 'Unpin',
754
+ leaveConversation: 'Leave Conversation',
755
+ deleteConversation: 'Delete Conversation',
756
+ refreshMessages: 'Refresh Messages',
757
+ searchMessages: 'Search Messages',
758
+ clearChat: 'Clear Chat History',
759
+ openRoom: 'Open Room',
760
+ sendDirectMessage: 'Send Direct Message',
761
+ viewProfile: 'View Profile'
762
+ },
763
+ rooms: [],
764
+ defaultRoom: 'general',
765
+ refreshIntervalMs: 5000,
766
+ heartbeatMs: 5000,
767
+ presenceStaleMs: 10000,
768
+ messagePollingMs: 0,
769
+ currentUsername: '',
770
+ theme: {}
771
+ };
772
+
773
+ const merged = { ...defaults, ...config };
774
+ merged.features = { ...defaults.features, ...(config.features || {}) };
775
+ merged.labels = { ...defaults.labels, ...(config.labels || {}) };
776
+ return merged;
777
+ }
778
+
779
+ _applyUnreadCounts(counts) {
780
+ this.unreadCounts = {};
781
+ for (const item of counts) {
782
+ this.unreadCounts[item.conversationId || item.conversation_id] = item.unreadCount || item.unread_count || 0;
783
+ }
784
+ // Apply to conversations
785
+ for (const conv of this.conversations) {
786
+ const count = this.unreadCounts[conv.id];
787
+ if (count !== undefined) conv.unreadCount = count;
788
+ }
789
+ }
790
+
791
+ _applyMentionCounts(counts) {
792
+ this.mentionCounts = {};
793
+ for (const item of counts) {
794
+ this.mentionCounts[item.conversationId || item.conversation_id] = item.mentionCount || item.mention_count || 0;
795
+ }
796
+ for (const conv of this.conversations) {
797
+ const count = this.mentionCounts[conv.id];
798
+ if (count !== undefined) conv.mentionCount = count;
799
+ }
800
+ }
801
+
802
+ _groupReads(rawReads) {
803
+ const grouped = {};
804
+ for (const r of rawReads) {
805
+ const mid = r.messageId || r.message_id;
806
+ if (!grouped[mid]) grouped[mid] = [];
807
+ grouped[mid].push({
808
+ userId: r.userId || r.user_id,
809
+ username: r.username,
810
+ readAt: r.readAt || r.read_at
811
+ });
812
+ }
813
+ return grouped;
814
+ }
815
+
816
+ async _call(fn, ...args) {
817
+ if (typeof fn !== 'function') return null;
818
+ return fn(...args);
819
+ }
820
+
821
+ /**
822
+ * Static: load CSS dynamically
823
+ */
824
+ static loadCSS(path) {
825
+ const href = path || './node_modules/tacel-chat/chat.css';
826
+ if (document.querySelector(`link[href="${href}"]`)) return;
827
+ const link = document.createElement('link');
828
+ link.rel = 'stylesheet';
829
+ link.href = href;
830
+ document.head.appendChild(link);
831
+ }
832
+ }
833
+
834
+ module.exports = { TacelChat };