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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Panel component for tacel-chat
|
|
3
|
+
* Allows searching within the current conversation/room messages by keyword.
|
|
4
|
+
* Shows matching results with click-to-scroll.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { createElement } = require('../utils/dom');
|
|
8
|
+
const { formatTime } = require('../utils/format');
|
|
9
|
+
|
|
10
|
+
class SearchPanel {
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.onScrollToMessage = options.onScrollToMessage || (() => {});
|
|
13
|
+
this.labels = options.labels || {};
|
|
14
|
+
|
|
15
|
+
this.containerEl = null;
|
|
16
|
+
this.inputEl = null;
|
|
17
|
+
this.resultsEl = null;
|
|
18
|
+
this.isOpen = false;
|
|
19
|
+
this.messages = []; // all messages in current context
|
|
20
|
+
this.query = '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create the toggle button for the header
|
|
25
|
+
* @returns {HTMLElement}
|
|
26
|
+
*/
|
|
27
|
+
createToggleButton() {
|
|
28
|
+
const btn = createElement('button', {
|
|
29
|
+
className: 'tc-search-toggle-btn',
|
|
30
|
+
innerHTML: '<i class="fas fa-search"></i>',
|
|
31
|
+
attrs: { type: 'button', title: this.labels.searchMessages || 'Search Messages' },
|
|
32
|
+
events: { click: () => this.toggle() }
|
|
33
|
+
});
|
|
34
|
+
this.toggleBtnEl = btn;
|
|
35
|
+
return btn;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render the panel into a parent (the tc-main container)
|
|
40
|
+
* @param {HTMLElement} parent
|
|
41
|
+
*/
|
|
42
|
+
render(parent) {
|
|
43
|
+
this.containerEl = createElement('div', { className: 'tc-search-panel' });
|
|
44
|
+
this.containerEl.style.display = 'none';
|
|
45
|
+
|
|
46
|
+
// Header
|
|
47
|
+
const header = createElement('div', { className: 'tc-search-panel-header' });
|
|
48
|
+
header.innerHTML = `
|
|
49
|
+
<div class="tc-search-panel-title">
|
|
50
|
+
<i class="fas fa-search"></i>
|
|
51
|
+
<span>${this.labels.searchMessages || 'Search Messages'}</span>
|
|
52
|
+
</div>
|
|
53
|
+
`;
|
|
54
|
+
const closeBtn = createElement('button', {
|
|
55
|
+
className: 'tc-search-panel-close',
|
|
56
|
+
innerHTML: '<i class="fas fa-times"></i>',
|
|
57
|
+
attrs: { type: 'button' },
|
|
58
|
+
events: { click: () => this.close() }
|
|
59
|
+
});
|
|
60
|
+
header.appendChild(closeBtn);
|
|
61
|
+
this.containerEl.appendChild(header);
|
|
62
|
+
|
|
63
|
+
// Search input
|
|
64
|
+
this.inputEl = createElement('input', {
|
|
65
|
+
className: 'tc-search-panel-input',
|
|
66
|
+
attrs: { type: 'text', placeholder: this.labels.searchPlaceholder || 'Search messages...' }
|
|
67
|
+
});
|
|
68
|
+
this.inputEl.addEventListener('input', () => {
|
|
69
|
+
this.query = this.inputEl.value;
|
|
70
|
+
this._renderResults();
|
|
71
|
+
});
|
|
72
|
+
this.containerEl.appendChild(this.inputEl);
|
|
73
|
+
|
|
74
|
+
// Results
|
|
75
|
+
this.resultsEl = createElement('div', { className: 'tc-search-panel-results' });
|
|
76
|
+
this.containerEl.appendChild(this.resultsEl);
|
|
77
|
+
|
|
78
|
+
parent.appendChild(this.containerEl);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Update the messages list (called when messages change)
|
|
83
|
+
* @param {Array} messages
|
|
84
|
+
*/
|
|
85
|
+
updateMessages(messages) {
|
|
86
|
+
this.messages = messages || [];
|
|
87
|
+
if (this.isOpen && this.query) this._renderResults();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
toggle() {
|
|
91
|
+
if (this.isOpen) {
|
|
92
|
+
this.close();
|
|
93
|
+
} else {
|
|
94
|
+
this.open();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
open() {
|
|
99
|
+
this.isOpen = true;
|
|
100
|
+
if (this.containerEl) this.containerEl.style.display = '';
|
|
101
|
+
if (this.toggleBtnEl) this.toggleBtnEl.classList.add('tc-search-toggle-active');
|
|
102
|
+
if (this.inputEl) {
|
|
103
|
+
this.inputEl.value = '';
|
|
104
|
+
this.query = '';
|
|
105
|
+
setTimeout(() => this.inputEl.focus(), 100);
|
|
106
|
+
}
|
|
107
|
+
this._renderResults();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
close() {
|
|
111
|
+
this.isOpen = false;
|
|
112
|
+
if (this.containerEl) this.containerEl.style.display = 'none';
|
|
113
|
+
if (this.toggleBtnEl) this.toggleBtnEl.classList.remove('tc-search-toggle-active');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_renderResults() {
|
|
117
|
+
if (!this.resultsEl) return;
|
|
118
|
+
this.resultsEl.innerHTML = '';
|
|
119
|
+
|
|
120
|
+
if (!this.query || this.query.trim().length === 0) {
|
|
121
|
+
const hint = createElement('div', { className: 'tc-search-panel-empty' });
|
|
122
|
+
hint.innerHTML = `
|
|
123
|
+
<i class="fas fa-search" style="font-size:28px;opacity:0.2;margin-bottom:8px;"></i>
|
|
124
|
+
<div>${this.labels.searchHint || 'Type to search messages'}</div>
|
|
125
|
+
`;
|
|
126
|
+
this.resultsEl.appendChild(hint);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const q = this.query.trim().toLowerCase();
|
|
131
|
+
const matches = this.messages.filter(m =>
|
|
132
|
+
m.content && m.content.toLowerCase().includes(q)
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
if (matches.length === 0) {
|
|
136
|
+
const empty = createElement('div', { className: 'tc-search-panel-empty' });
|
|
137
|
+
empty.innerHTML = `
|
|
138
|
+
<i class="fas fa-search" style="font-size:28px;opacity:0.2;margin-bottom:8px;"></i>
|
|
139
|
+
<div>${this.labels.noSearchResults || 'No messages found'}</div>
|
|
140
|
+
`;
|
|
141
|
+
this.resultsEl.appendChild(empty);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Count label
|
|
146
|
+
const countEl = createElement('div', { className: 'tc-search-panel-count' });
|
|
147
|
+
countEl.textContent = `${matches.length} result${matches.length !== 1 ? 's' : ''}`;
|
|
148
|
+
this.resultsEl.appendChild(countEl);
|
|
149
|
+
|
|
150
|
+
for (const msg of matches) {
|
|
151
|
+
const item = createElement('div', { className: 'tc-search-panel-item' });
|
|
152
|
+
item.dataset.msgId = msg.id;
|
|
153
|
+
|
|
154
|
+
const info = createElement('div', { className: 'tc-search-panel-item-info' });
|
|
155
|
+
|
|
156
|
+
const senderRow = createElement('div', { className: 'tc-search-panel-item-sender' });
|
|
157
|
+
senderRow.textContent = msg.senderName || 'Unknown';
|
|
158
|
+
|
|
159
|
+
const timeEl = createElement('span', { className: 'tc-search-panel-item-time' });
|
|
160
|
+
timeEl.textContent = formatTime(msg.timestamp);
|
|
161
|
+
senderRow.appendChild(timeEl);
|
|
162
|
+
info.appendChild(senderRow);
|
|
163
|
+
|
|
164
|
+
const textEl = createElement('div', { className: 'tc-search-panel-item-text' });
|
|
165
|
+
// Highlight the matching text
|
|
166
|
+
textEl.innerHTML = this._highlightMatch(msg.content, q);
|
|
167
|
+
info.appendChild(textEl);
|
|
168
|
+
|
|
169
|
+
item.appendChild(info);
|
|
170
|
+
|
|
171
|
+
// Click to scroll to message
|
|
172
|
+
item.addEventListener('click', () => {
|
|
173
|
+
this.onScrollToMessage(msg.id);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
this.resultsEl.appendChild(item);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_highlightMatch(text, query) {
|
|
181
|
+
if (!text) return '';
|
|
182
|
+
const preview = text.substring(0, 150);
|
|
183
|
+
const escaped = preview.replace(/[&<>"']/g, c => ({
|
|
184
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
185
|
+
})[c]);
|
|
186
|
+
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
|
187
|
+
return escaped.replace(regex, '<mark class="tc-search-highlight">$1</mark>');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
destroy() {
|
|
191
|
+
if (this.containerEl) {
|
|
192
|
+
this.containerEl.remove();
|
|
193
|
+
this.containerEl = null;
|
|
194
|
+
}
|
|
195
|
+
this.inputEl = null;
|
|
196
|
+
this.resultsEl = null;
|
|
197
|
+
this.toggleBtnEl = null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = { SearchPanel };
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar component for conversation mode in tacel-chat
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { getInitials } = require('../utils/dom');
|
|
6
|
+
const { formatChatListTime, truncate } = require('../utils/format');
|
|
7
|
+
const { checkOnline } = require('./presence');
|
|
8
|
+
|
|
9
|
+
class Sidebar {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.onSelectConversation = options.onSelectConversation || (() => {});
|
|
12
|
+
this.onNewChat = options.onNewChat || (() => {});
|
|
13
|
+
this.onSearch = options.onSearch || (() => {});
|
|
14
|
+
this.onContextMenu = options.onContextMenu || null;
|
|
15
|
+
this.labels = options.labels || {};
|
|
16
|
+
this.showPresence = options.showPresence !== false;
|
|
17
|
+
this.showSearch = options.showSearch !== false;
|
|
18
|
+
this.showNewChat = options.showNewChat !== false;
|
|
19
|
+
this.resizable = options.resizable !== false;
|
|
20
|
+
this.presenceStaleMs = options.presenceStaleMs || 10000;
|
|
21
|
+
this.containerEl = null;
|
|
22
|
+
this.listEl = null;
|
|
23
|
+
this.searchInput = null;
|
|
24
|
+
this.searchText = '';
|
|
25
|
+
this._resizeCleanup = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Render the sidebar into a container
|
|
30
|
+
* @param {HTMLElement} parent
|
|
31
|
+
*/
|
|
32
|
+
render(parent) {
|
|
33
|
+
this.containerEl = document.createElement('div');
|
|
34
|
+
this.containerEl.className = 'tc-sidebar';
|
|
35
|
+
|
|
36
|
+
// Header
|
|
37
|
+
const header = document.createElement('div');
|
|
38
|
+
header.className = 'tc-sidebar-header';
|
|
39
|
+
|
|
40
|
+
const titleRow = document.createElement('div');
|
|
41
|
+
titleRow.className = 'tc-sidebar-title-row';
|
|
42
|
+
titleRow.innerHTML = `
|
|
43
|
+
<div class="tc-sidebar-title">
|
|
44
|
+
<i class="fas fa-comments"></i>
|
|
45
|
+
<h2>${this.labels.sidebarTitle || 'Messages'}</h2>
|
|
46
|
+
</div>
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
if (this.showNewChat) {
|
|
50
|
+
const newBtn = document.createElement('button');
|
|
51
|
+
newBtn.className = 'tc-new-chat-btn';
|
|
52
|
+
newBtn.innerHTML = '<i class="fas fa-plus"></i>';
|
|
53
|
+
newBtn.title = 'New conversation';
|
|
54
|
+
newBtn.addEventListener('click', () => this.onNewChat());
|
|
55
|
+
titleRow.appendChild(newBtn);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
header.appendChild(titleRow);
|
|
59
|
+
|
|
60
|
+
if (this.showSearch) {
|
|
61
|
+
const searchContainer = document.createElement('div');
|
|
62
|
+
searchContainer.className = 'tc-search-container';
|
|
63
|
+
this.searchInput = document.createElement('input');
|
|
64
|
+
this.searchInput.className = 'tc-search-input';
|
|
65
|
+
this.searchInput.type = 'text';
|
|
66
|
+
this.searchInput.placeholder = this.labels.searchPlaceholder || 'Search conversations...';
|
|
67
|
+
this.searchInput.addEventListener('input', (e) => {
|
|
68
|
+
this.searchText = e.target.value;
|
|
69
|
+
this.onSearch(this.searchText);
|
|
70
|
+
});
|
|
71
|
+
searchContainer.appendChild(this.searchInput);
|
|
72
|
+
header.appendChild(searchContainer);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.containerEl.appendChild(header);
|
|
76
|
+
|
|
77
|
+
// Chat list
|
|
78
|
+
this.listEl = document.createElement('div');
|
|
79
|
+
this.listEl.className = 'tc-chat-list';
|
|
80
|
+
this.containerEl.appendChild(this.listEl);
|
|
81
|
+
|
|
82
|
+
parent.appendChild(this.containerEl);
|
|
83
|
+
|
|
84
|
+
// Resize handle
|
|
85
|
+
if (this.resizable) {
|
|
86
|
+
this._initResize();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
_initResize() {
|
|
91
|
+
const handle = document.createElement('div');
|
|
92
|
+
handle.className = 'tc-sidebar-resize-handle';
|
|
93
|
+
this.containerEl.appendChild(handle);
|
|
94
|
+
|
|
95
|
+
let startX = 0;
|
|
96
|
+
let startWidth = 0;
|
|
97
|
+
let dragging = false;
|
|
98
|
+
|
|
99
|
+
const onMouseDown = (e) => {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
dragging = true;
|
|
102
|
+
startX = e.clientX;
|
|
103
|
+
startWidth = this.containerEl.getBoundingClientRect().width;
|
|
104
|
+
handle.classList.add('tc-sidebar-resize-active');
|
|
105
|
+
document.body.style.cursor = 'col-resize';
|
|
106
|
+
document.body.style.userSelect = 'none';
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const onMouseMove = (e) => {
|
|
110
|
+
if (!dragging) return;
|
|
111
|
+
const delta = e.clientX - startX;
|
|
112
|
+
const newWidth = Math.min(Math.max(startWidth + delta, 200), 500);
|
|
113
|
+
const container = this.containerEl.closest('.tc-container');
|
|
114
|
+
if (container) {
|
|
115
|
+
container.style.gridTemplateColumns = newWidth + 'px 1fr';
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const onMouseUp = () => {
|
|
120
|
+
if (!dragging) return;
|
|
121
|
+
dragging = false;
|
|
122
|
+
handle.classList.remove('tc-sidebar-resize-active');
|
|
123
|
+
document.body.style.cursor = '';
|
|
124
|
+
document.body.style.userSelect = '';
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
handle.addEventListener('mousedown', onMouseDown);
|
|
128
|
+
document.addEventListener('mousemove', onMouseMove);
|
|
129
|
+
document.addEventListener('mouseup', onMouseUp);
|
|
130
|
+
|
|
131
|
+
this._resizeCleanup = () => {
|
|
132
|
+
handle.removeEventListener('mousedown', onMouseDown);
|
|
133
|
+
document.removeEventListener('mousemove', onMouseMove);
|
|
134
|
+
document.removeEventListener('mouseup', onMouseUp);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Update the conversation list
|
|
140
|
+
* @param {Array} conversations - Universal conversation format
|
|
141
|
+
* @param {string} activeId - Currently active conversation ID
|
|
142
|
+
* @param {Array} users - All chat users (for presence)
|
|
143
|
+
*/
|
|
144
|
+
update(conversations, activeId, users = []) {
|
|
145
|
+
if (!this.listEl) return;
|
|
146
|
+
this.listEl.innerHTML = '';
|
|
147
|
+
|
|
148
|
+
const search = this.searchText.toLowerCase();
|
|
149
|
+
const teams = [];
|
|
150
|
+
const directs = [];
|
|
151
|
+
|
|
152
|
+
for (const conv of conversations) {
|
|
153
|
+
if (search) {
|
|
154
|
+
const nameMatch = (conv.name || '').toLowerCase().includes(search);
|
|
155
|
+
const msgMatch = (conv.lastMessage || '').toLowerCase().includes(search);
|
|
156
|
+
if (!nameMatch && !msgMatch) continue;
|
|
157
|
+
}
|
|
158
|
+
if (conv.type === 'team') teams.push(conv);
|
|
159
|
+
else directs.push(conv);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (teams.length > 0) {
|
|
163
|
+
const teamHeader = document.createElement('div');
|
|
164
|
+
teamHeader.className = 'tc-list-section-header';
|
|
165
|
+
teamHeader.textContent = this.labels.teamsHeader || 'Teams';
|
|
166
|
+
this.listEl.appendChild(teamHeader);
|
|
167
|
+
teams.forEach(c => this._renderItem(c, activeId, users));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (directs.length > 0) {
|
|
171
|
+
const directHeader = document.createElement('div');
|
|
172
|
+
directHeader.className = 'tc-list-section-header';
|
|
173
|
+
directHeader.textContent = this.labels.directsHeader || 'Direct Messages';
|
|
174
|
+
this.listEl.appendChild(directHeader);
|
|
175
|
+
directs.forEach(c => this._renderItem(c, activeId, users));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (teams.length === 0 && directs.length === 0) {
|
|
179
|
+
const empty = document.createElement('div');
|
|
180
|
+
empty.className = 'tc-list-empty';
|
|
181
|
+
empty.textContent = search ? (this.labels.noResults || 'No conversations found') : (this.labels.noConversations || 'No conversations yet');
|
|
182
|
+
this.listEl.appendChild(empty);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_renderItem(conv, activeId, users) {
|
|
187
|
+
const item = document.createElement('div');
|
|
188
|
+
item.className = `tc-chat-item ${String(conv.id) === String(activeId) ? 'tc-chat-item-active' : ''}`;
|
|
189
|
+
item.dataset.conversationId = conv.id;
|
|
190
|
+
|
|
191
|
+
const isTeam = conv.type === 'team';
|
|
192
|
+
|
|
193
|
+
// Avatar
|
|
194
|
+
const avatar = document.createElement('div');
|
|
195
|
+
avatar.className = `tc-chat-item-avatar ${isTeam ? 'tc-chat-item-avatar-team' : ''}`;
|
|
196
|
+
avatar.textContent = getInitials(conv.name);
|
|
197
|
+
|
|
198
|
+
// Info
|
|
199
|
+
const info = document.createElement('div');
|
|
200
|
+
info.className = 'tc-chat-item-info';
|
|
201
|
+
|
|
202
|
+
const nameRow = document.createElement('div');
|
|
203
|
+
nameRow.className = 'tc-chat-item-name-row';
|
|
204
|
+
|
|
205
|
+
const nameEl = document.createElement('span');
|
|
206
|
+
nameEl.className = 'tc-chat-item-name';
|
|
207
|
+
|
|
208
|
+
// Presence dot for direct messages
|
|
209
|
+
if (!isTeam && this.showPresence) {
|
|
210
|
+
const user = users.find(u => u.username.toLowerCase() === (conv.name || '').toLowerCase());
|
|
211
|
+
const online = user ? checkOnline(user, this.presenceStaleMs) : false;
|
|
212
|
+
const dot = document.createElement('span');
|
|
213
|
+
dot.className = `tc-presence-dot ${online ? 'tc-online' : 'tc-offline'}`;
|
|
214
|
+
nameEl.appendChild(dot);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const nameText = document.createTextNode(conv.name || 'Unknown');
|
|
218
|
+
nameEl.appendChild(nameText);
|
|
219
|
+
|
|
220
|
+
const timeEl = document.createElement('span');
|
|
221
|
+
timeEl.className = 'tc-chat-item-time';
|
|
222
|
+
timeEl.textContent = formatChatListTime(conv.lastMessageTime);
|
|
223
|
+
|
|
224
|
+
nameRow.appendChild(nameEl);
|
|
225
|
+
nameRow.appendChild(timeEl);
|
|
226
|
+
|
|
227
|
+
const previewRow = document.createElement('div');
|
|
228
|
+
previewRow.className = 'tc-chat-item-preview-row';
|
|
229
|
+
|
|
230
|
+
const preview = document.createElement('span');
|
|
231
|
+
preview.className = 'tc-chat-item-preview';
|
|
232
|
+
preview.textContent = truncate(conv.lastMessage || '', 30);
|
|
233
|
+
|
|
234
|
+
previewRow.appendChild(preview);
|
|
235
|
+
|
|
236
|
+
if (conv.mentionCount > 0) {
|
|
237
|
+
const mentionBadge = document.createElement('span');
|
|
238
|
+
mentionBadge.className = 'tc-mention-badge';
|
|
239
|
+
mentionBadge.innerHTML = `<i class="fas fa-at"></i>${conv.mentionCount > 99 ? '99+' : conv.mentionCount}`;
|
|
240
|
+
mentionBadge.title = `${conv.mentionCount} mention${conv.mentionCount > 1 ? 's' : ''}`;
|
|
241
|
+
previewRow.appendChild(mentionBadge);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (conv.unreadCount > 0) {
|
|
245
|
+
const badge = document.createElement('span');
|
|
246
|
+
badge.className = 'tc-unread-badge';
|
|
247
|
+
badge.textContent = conv.unreadCount > 99 ? '99+' : conv.unreadCount;
|
|
248
|
+
previewRow.appendChild(badge);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
info.appendChild(nameRow);
|
|
252
|
+
info.appendChild(previewRow);
|
|
253
|
+
|
|
254
|
+
item.appendChild(avatar);
|
|
255
|
+
item.appendChild(info);
|
|
256
|
+
|
|
257
|
+
item.addEventListener('click', () => this.onSelectConversation(conv.id));
|
|
258
|
+
if (this.onContextMenu) {
|
|
259
|
+
item.addEventListener('contextmenu', (e) => this.onContextMenu(conv, e));
|
|
260
|
+
}
|
|
261
|
+
this.listEl.appendChild(item);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
destroy() {
|
|
265
|
+
if (this._resizeCleanup) {
|
|
266
|
+
this._resizeCleanup();
|
|
267
|
+
this._resizeCleanup = null;
|
|
268
|
+
}
|
|
269
|
+
if (this.containerEl) {
|
|
270
|
+
this.containerEl.remove();
|
|
271
|
+
this.containerEl = null;
|
|
272
|
+
}
|
|
273
|
+
this.listEl = null;
|
|
274
|
+
this.searchInput = null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = { Sidebar };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab bar component for room mode in tacel-chat
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
class TabBar {
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.rooms = options.rooms || [];
|
|
8
|
+
this.onSelectRoom = options.onSelectRoom || (() => {});
|
|
9
|
+
this.onContextMenu = options.onContextMenu || null;
|
|
10
|
+
this.labels = options.labels || {};
|
|
11
|
+
this.containerEl = null;
|
|
12
|
+
this.activeRoomType = null;
|
|
13
|
+
this.badges = {}; // roomType -> count
|
|
14
|
+
this.mentionBadges = {}; // roomType -> mention count
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Render the tab bar into a container
|
|
19
|
+
* @param {HTMLElement} parent
|
|
20
|
+
*/
|
|
21
|
+
render(parent) {
|
|
22
|
+
this.containerEl = document.createElement('div');
|
|
23
|
+
this.containerEl.className = 'tc-topbar';
|
|
24
|
+
|
|
25
|
+
const topContent = document.createElement('div');
|
|
26
|
+
topContent.className = 'tc-topbar-content';
|
|
27
|
+
topContent.innerHTML = `<h1 class="tc-topbar-title">${this.labels.roomTitle || 'Chat Center'}</h1>`;
|
|
28
|
+
|
|
29
|
+
const tabsRow = document.createElement('div');
|
|
30
|
+
tabsRow.className = 'tc-tabs';
|
|
31
|
+
|
|
32
|
+
this.rooms.forEach(room => {
|
|
33
|
+
const tab = document.createElement('div');
|
|
34
|
+
tab.className = 'tc-tab';
|
|
35
|
+
tab.dataset.roomType = room.type;
|
|
36
|
+
tab.innerHTML = `
|
|
37
|
+
<i class="${room.icon || 'fas fa-comments'}"></i>
|
|
38
|
+
<span>${room.name}</span>
|
|
39
|
+
<div class="tc-tab-mention-badge" style="display:none;"><i class="fas fa-at"></i><span>0</span></div>
|
|
40
|
+
<div class="tc-tab-badge" style="display:none;">0</div>
|
|
41
|
+
`;
|
|
42
|
+
tab.addEventListener('click', () => this.onSelectRoom(room.type));
|
|
43
|
+
if (this.onContextMenu) {
|
|
44
|
+
tab.addEventListener('contextmenu', (e) => this.onContextMenu(room, e));
|
|
45
|
+
}
|
|
46
|
+
tabsRow.appendChild(tab);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Actions area (search, pinned, files buttons will be placed here)
|
|
50
|
+
this.actionsEl = document.createElement('div');
|
|
51
|
+
this.actionsEl.className = 'tc-tabs-actions';
|
|
52
|
+
tabsRow.appendChild(this.actionsEl);
|
|
53
|
+
|
|
54
|
+
this.containerEl.appendChild(topContent);
|
|
55
|
+
this.containerEl.appendChild(tabsRow);
|
|
56
|
+
parent.appendChild(this.containerEl);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Set the active tab
|
|
61
|
+
* @param {string} roomType
|
|
62
|
+
*/
|
|
63
|
+
setActive(roomType) {
|
|
64
|
+
this.activeRoomType = roomType;
|
|
65
|
+
if (!this.containerEl) return;
|
|
66
|
+
const tabs = this.containerEl.querySelectorAll('.tc-tab');
|
|
67
|
+
tabs.forEach(tab => {
|
|
68
|
+
const isActive = tab.dataset.roomType === roomType;
|
|
69
|
+
tab.classList.toggle('tc-tab-active', isActive);
|
|
70
|
+
// Hide badges on active tab
|
|
71
|
+
if (isActive) {
|
|
72
|
+
const badge = tab.querySelector('.tc-tab-badge');
|
|
73
|
+
if (badge) badge.style.display = 'none';
|
|
74
|
+
const mBadge = tab.querySelector('.tc-tab-mention-badge');
|
|
75
|
+
if (mBadge) mBadge.style.display = 'none';
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Set badge count for a room
|
|
82
|
+
* @param {string} roomType
|
|
83
|
+
* @param {number} count
|
|
84
|
+
*/
|
|
85
|
+
setBadge(roomType, count) {
|
|
86
|
+
this.badges[roomType] = count;
|
|
87
|
+
if (!this.containerEl) return;
|
|
88
|
+
if (roomType === this.activeRoomType) return; // Don't show badge on active tab
|
|
89
|
+
const tab = this.containerEl.querySelector(`.tc-tab[data-room-type="${roomType}"]`);
|
|
90
|
+
if (!tab) return;
|
|
91
|
+
const badge = tab.querySelector('.tc-tab-badge');
|
|
92
|
+
if (!badge) return;
|
|
93
|
+
if (count > 0) {
|
|
94
|
+
badge.textContent = count > 99 ? '99+' : count;
|
|
95
|
+
badge.style.display = '';
|
|
96
|
+
} else {
|
|
97
|
+
badge.style.display = 'none';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Set mention badge count for a room
|
|
103
|
+
* @param {string} roomType
|
|
104
|
+
* @param {number} count
|
|
105
|
+
*/
|
|
106
|
+
setMentionBadge(roomType, count) {
|
|
107
|
+
this.mentionBadges[roomType] = count;
|
|
108
|
+
if (!this.containerEl) return;
|
|
109
|
+
if (roomType === this.activeRoomType) return;
|
|
110
|
+
const tab = this.containerEl.querySelector(`.tc-tab[data-room-type="${roomType}"]`);
|
|
111
|
+
if (!tab) return;
|
|
112
|
+
const badge = tab.querySelector('.tc-tab-mention-badge');
|
|
113
|
+
if (!badge) return;
|
|
114
|
+
if (count > 0) {
|
|
115
|
+
badge.querySelector('span').textContent = count > 99 ? '99+' : count;
|
|
116
|
+
badge.style.display = '';
|
|
117
|
+
} else {
|
|
118
|
+
badge.style.display = 'none';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
destroy() {
|
|
123
|
+
if (this.containerEl) {
|
|
124
|
+
this.containerEl.remove();
|
|
125
|
+
this.containerEl = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { TabBar };
|
package/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tacel-chat — Entry point
|
|
3
|
+
* Universal chat module for Tacel Electron applications.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { TacelChat } = require('./chat');
|
|
7
|
+
const { initChatAPI } = require('./chat-api');
|
|
8
|
+
const { syncUser, normalizeUsername } = require('./chat-sync');
|
|
9
|
+
const { THEMES } = require('./themes');
|
|
10
|
+
const { ConfirmDialog } = require('./components/confirm');
|
|
11
|
+
|
|
12
|
+
module.exports = { TacelChat, initChatAPI, syncUser, normalizeUsername, themes: THEMES, ConfirmDialog };
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tacel-chat",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Universal chat module for Tacel Electron apps -- conversation and room modes with presence, read receipts, file attachments, reply system, pinned messages, in-chat search, confirm dialogs, resizable sidebar, responsive design, context menus, @mentions, 30 built-in themes, and full CSS variable theming.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"chat.js",
|
|
9
|
+
"chat.css",
|
|
10
|
+
"chat-api.js",
|
|
11
|
+
"chat-sync.js",
|
|
12
|
+
"themes.js",
|
|
13
|
+
"components/",
|
|
14
|
+
"utils/",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"tacel",
|
|
19
|
+
"chat",
|
|
20
|
+
"electron",
|
|
21
|
+
"messaging",
|
|
22
|
+
"real-time",
|
|
23
|
+
"attachments",
|
|
24
|
+
"themes",
|
|
25
|
+
"pinned-messages",
|
|
26
|
+
"search",
|
|
27
|
+
"responsive",
|
|
28
|
+
"css-variables"
|
|
29
|
+
],
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/Tacel-ltd/Modules.git",
|
|
33
|
+
"directory": "chat-module"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/Tacel-ltd/Modules/tree/main/chat-module#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/Tacel-ltd/Modules/issues"
|
|
38
|
+
},
|
|
39
|
+
"author": "Tacel",
|
|
40
|
+
"license": "ISC"
|
|
41
|
+
}
|