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/README.md +1435 -0
- package/chat-api.js +436 -0
- package/chat-sync.js +65 -0
- package/chat.css +2573 -0
- package/chat.js +834 -0
- package/components/attachment.js +400 -0
- package/components/confirm.js +125 -0
- package/components/context-menu.js +461 -0
- package/components/files-panel.js +228 -0
- package/components/mention.js +198 -0
- package/components/message-area.js +612 -0
- package/components/message.js +200 -0
- package/components/new-chat.js +130 -0
- package/components/pinned-panel.js +184 -0
- package/components/presence.js +45 -0
- package/components/search-panel.js +201 -0
- package/components/sidebar.js +278 -0
- package/components/tabs.js +130 -0
- package/index.js +12 -0
- package/package.json +41 -0
- package/themes.js +495 -0
- package/utils/dom.js +75 -0
- package/utils/format.js +133 -0
- package/utils/linkify.js +80 -0
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 };
|