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,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single message rendering component for tacel-chat
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { getInitials } = require('../utils/dom');
|
|
6
|
+
const { formatTime } = require('../utils/format');
|
|
7
|
+
const { processMessageText } = require('../utils/linkify');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Render a single message element
|
|
11
|
+
* @param {object} msg - Universal message format
|
|
12
|
+
* @param {object} options
|
|
13
|
+
* @param {string} options.mode - 'conversation' or 'room'
|
|
14
|
+
* @param {string} options.currentUsername
|
|
15
|
+
* @param {boolean} options.enableMentions
|
|
16
|
+
* @param {boolean} options.enableLinks
|
|
17
|
+
* @param {boolean} options.showSeen - Whether to show seen indicator on this message
|
|
18
|
+
* @param {Array} options.seenBy - Read receipt data for this message
|
|
19
|
+
* @param {object} options.attachmentManager - AttachmentManager instance
|
|
20
|
+
* @returns {HTMLElement}
|
|
21
|
+
*/
|
|
22
|
+
function renderMessage(msg, options = {}) {
|
|
23
|
+
const {
|
|
24
|
+
mode = 'conversation',
|
|
25
|
+
currentUsername = '',
|
|
26
|
+
enableMentions = false,
|
|
27
|
+
enableLinks = true,
|
|
28
|
+
showSeen = false,
|
|
29
|
+
seenBy = [],
|
|
30
|
+
attachmentManager = null
|
|
31
|
+
} = options;
|
|
32
|
+
|
|
33
|
+
const isOwn = msg.isOwn;
|
|
34
|
+
const container = document.createElement('div');
|
|
35
|
+
|
|
36
|
+
if (mode === 'room') {
|
|
37
|
+
// Room mode: card-style messages
|
|
38
|
+
container.className = `tc-msg-container ${isOwn ? 'tc-msg-own' : ''}`;
|
|
39
|
+
|
|
40
|
+
const avatar = document.createElement('div');
|
|
41
|
+
avatar.className = `tc-msg-avatar ${isOwn ? 'tc-msg-avatar-own' : ''}`;
|
|
42
|
+
avatar.textContent = getInitials(msg.senderName);
|
|
43
|
+
|
|
44
|
+
const content = document.createElement('div');
|
|
45
|
+
content.className = 'tc-msg-content tc-msg-card';
|
|
46
|
+
|
|
47
|
+
// Reply reference
|
|
48
|
+
if (msg.replyTo) {
|
|
49
|
+
content.appendChild(_buildReplyRef(msg.replyTo));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const header = document.createElement('div');
|
|
53
|
+
header.className = 'tc-msg-header';
|
|
54
|
+
const nameSpan = document.createElement('span');
|
|
55
|
+
nameSpan.className = 'tc-msg-sender';
|
|
56
|
+
nameSpan.textContent = isOwn ? 'You' : msg.senderName;
|
|
57
|
+
const timeSpan = document.createElement('span');
|
|
58
|
+
timeSpan.className = 'tc-msg-time';
|
|
59
|
+
timeSpan.textContent = formatTime(msg.timestamp);
|
|
60
|
+
header.appendChild(nameSpan);
|
|
61
|
+
if (msg.isPinned) {
|
|
62
|
+
const pinIcon = document.createElement('span');
|
|
63
|
+
pinIcon.className = 'tc-msg-pin-icon';
|
|
64
|
+
pinIcon.innerHTML = '<i class="fas fa-thumbtack"></i>';
|
|
65
|
+
pinIcon.title = 'Pinned message';
|
|
66
|
+
header.appendChild(pinIcon);
|
|
67
|
+
}
|
|
68
|
+
header.appendChild(timeSpan);
|
|
69
|
+
|
|
70
|
+
content.appendChild(header);
|
|
71
|
+
|
|
72
|
+
// Only render text if there is content (skip for file-only messages)
|
|
73
|
+
if (msg.content && msg.content.trim()) {
|
|
74
|
+
const textEl = document.createElement('div');
|
|
75
|
+
textEl.className = 'tc-msg-text';
|
|
76
|
+
textEl.innerHTML = processMessageText(msg.content, currentUsername, enableMentions, enableLinks);
|
|
77
|
+
content.appendChild(textEl);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Attachment preview
|
|
81
|
+
if (msg.hasAttachment && attachmentManager) {
|
|
82
|
+
const attachContainer = document.createElement('div');
|
|
83
|
+
attachContainer.className = 'tc-msg-attachment';
|
|
84
|
+
content.appendChild(attachContainer);
|
|
85
|
+
// Render async
|
|
86
|
+
attachmentManager.renderAttachmentPreview(msg, attachContainer);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
container.appendChild(avatar);
|
|
90
|
+
container.appendChild(content);
|
|
91
|
+
} else {
|
|
92
|
+
// Conversation mode: bubble-style messages
|
|
93
|
+
container.className = `tc-msg-container tc-msg-bubble-mode ${isOwn ? 'tc-msg-own' : ''}`;
|
|
94
|
+
|
|
95
|
+
const avatar = document.createElement('div');
|
|
96
|
+
avatar.className = 'tc-msg-avatar tc-msg-avatar-square';
|
|
97
|
+
avatar.textContent = getInitials(msg.senderName);
|
|
98
|
+
|
|
99
|
+
const content = document.createElement('div');
|
|
100
|
+
content.className = 'tc-msg-content';
|
|
101
|
+
|
|
102
|
+
// Reply reference
|
|
103
|
+
if (msg.replyTo) {
|
|
104
|
+
content.appendChild(_buildReplyRef(msg.replyTo));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const header = document.createElement('div');
|
|
108
|
+
header.className = 'tc-msg-header';
|
|
109
|
+
const nameSpan = document.createElement('span');
|
|
110
|
+
nameSpan.className = 'tc-msg-sender';
|
|
111
|
+
nameSpan.textContent = msg.senderName;
|
|
112
|
+
const timeSpan = document.createElement('span');
|
|
113
|
+
timeSpan.className = 'tc-msg-time';
|
|
114
|
+
timeSpan.textContent = formatTime(msg.timestamp);
|
|
115
|
+
header.appendChild(nameSpan);
|
|
116
|
+
if (msg.isPinned) {
|
|
117
|
+
const pinIcon = document.createElement('span');
|
|
118
|
+
pinIcon.className = 'tc-msg-pin-icon';
|
|
119
|
+
pinIcon.innerHTML = '<i class="fas fa-thumbtack"></i>';
|
|
120
|
+
pinIcon.title = 'Pinned message';
|
|
121
|
+
header.appendChild(pinIcon);
|
|
122
|
+
}
|
|
123
|
+
header.appendChild(timeSpan);
|
|
124
|
+
|
|
125
|
+
content.appendChild(header);
|
|
126
|
+
|
|
127
|
+
// Only render bubble if there is content (skip for file-only messages)
|
|
128
|
+
if (msg.content && msg.content.trim()) {
|
|
129
|
+
const bubble = document.createElement('div');
|
|
130
|
+
bubble.className = `tc-msg-bubble ${isOwn ? 'tc-msg-bubble-own' : 'tc-msg-bubble-other'}`;
|
|
131
|
+
bubble.innerHTML = processMessageText(msg.content, currentUsername, enableMentions, enableLinks);
|
|
132
|
+
content.appendChild(bubble);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Attachment preview
|
|
136
|
+
if (msg.hasAttachment && attachmentManager) {
|
|
137
|
+
const attachContainer = document.createElement('div');
|
|
138
|
+
attachContainer.className = 'tc-msg-attachment';
|
|
139
|
+
content.appendChild(attachContainer);
|
|
140
|
+
attachmentManager.renderAttachmentPreview(msg, attachContainer);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Seen indicator
|
|
144
|
+
if (showSeen && isOwn && seenBy && seenBy.length > 0) {
|
|
145
|
+
const seenEl = document.createElement('div');
|
|
146
|
+
seenEl.className = 'tc-msg-seen';
|
|
147
|
+
const names = seenBy.map(r => r.username).join(', ');
|
|
148
|
+
seenEl.innerHTML = `<span class="tc-msg-seen-icon">✓✓</span> Seen`;
|
|
149
|
+
seenEl.title = `Seen by: ${names}`;
|
|
150
|
+
content.appendChild(seenEl);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
container.appendChild(avatar);
|
|
154
|
+
container.appendChild(content);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return container;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build a reply reference element shown above a message.
|
|
162
|
+
* Mobile-style: compact quote with accent bar, original sender name, and preview text.
|
|
163
|
+
* Clickable — dispatches a custom event so the message area can scroll to the original.
|
|
164
|
+
* @param {object} replyTo - { id, senderName, content }
|
|
165
|
+
* @returns {HTMLElement}
|
|
166
|
+
*/
|
|
167
|
+
function _buildReplyRef(replyTo) {
|
|
168
|
+
const ref = document.createElement('div');
|
|
169
|
+
ref.className = 'tc-reply-ref';
|
|
170
|
+
if (replyTo.id) ref.dataset.replyToId = replyTo.id;
|
|
171
|
+
|
|
172
|
+
const senderEl = document.createElement('div');
|
|
173
|
+
senderEl.className = 'tc-reply-ref-sender';
|
|
174
|
+
senderEl.textContent = replyTo.senderName || 'Unknown';
|
|
175
|
+
|
|
176
|
+
const textEl = document.createElement('div');
|
|
177
|
+
textEl.className = 'tc-reply-ref-text';
|
|
178
|
+
const preview = (replyTo.content || '').substring(0, 120);
|
|
179
|
+
textEl.textContent = preview.length < (replyTo.content || '').length ? preview + '…' : preview;
|
|
180
|
+
|
|
181
|
+
ref.appendChild(senderEl);
|
|
182
|
+
ref.appendChild(textEl);
|
|
183
|
+
|
|
184
|
+
// Click to scroll to original message
|
|
185
|
+
if (replyTo.id) {
|
|
186
|
+
ref.style.cursor = 'pointer';
|
|
187
|
+
ref.title = 'Click to see original message';
|
|
188
|
+
ref.addEventListener('click', (e) => {
|
|
189
|
+
e.stopPropagation();
|
|
190
|
+
ref.dispatchEvent(new CustomEvent('tc-scroll-to-message', {
|
|
191
|
+
bubbles: true,
|
|
192
|
+
detail: { messageId: replyTo.id }
|
|
193
|
+
}));
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return ref;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { renderMessage };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* New chat modal component for tacel-chat
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { getInitials } = require('../utils/dom');
|
|
6
|
+
|
|
7
|
+
class NewChatModal {
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.onStartDirect = options.onStartDirect || (async () => ({ success: false }));
|
|
10
|
+
this.onClose = options.onClose || (() => {});
|
|
11
|
+
this.mountEl = options.mountEl || document.body;
|
|
12
|
+
this.labels = options.labels || {};
|
|
13
|
+
this.overlayEl = null;
|
|
14
|
+
this.users = [];
|
|
15
|
+
this.existingPeerIds = [];
|
|
16
|
+
this.currentUsername = '';
|
|
17
|
+
this.searchText = '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Show the modal
|
|
22
|
+
* @param {Array} users - All chat users
|
|
23
|
+
* @param {Array} existingPeerIds - User IDs that already have direct conversations
|
|
24
|
+
* @param {string} currentUsername
|
|
25
|
+
*/
|
|
26
|
+
show(users, existingPeerIds, currentUsername) {
|
|
27
|
+
this.users = users || [];
|
|
28
|
+
this.existingPeerIds = existingPeerIds || [];
|
|
29
|
+
this.currentUsername = currentUsername;
|
|
30
|
+
this.searchText = '';
|
|
31
|
+
this._render();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
hide() {
|
|
35
|
+
if (this.overlayEl) {
|
|
36
|
+
this.overlayEl.remove();
|
|
37
|
+
this.overlayEl = null;
|
|
38
|
+
}
|
|
39
|
+
this.onClose();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_render() {
|
|
43
|
+
if (this.overlayEl) this.overlayEl.remove();
|
|
44
|
+
|
|
45
|
+
this.overlayEl = document.createElement('div');
|
|
46
|
+
this.overlayEl.className = 'tc-modal-overlay';
|
|
47
|
+
this.overlayEl.addEventListener('click', (e) => {
|
|
48
|
+
if (e.target === this.overlayEl) this.hide();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const modal = document.createElement('div');
|
|
52
|
+
modal.className = 'tc-modal';
|
|
53
|
+
|
|
54
|
+
const header = document.createElement('div');
|
|
55
|
+
header.className = 'tc-modal-header';
|
|
56
|
+
header.innerHTML = `
|
|
57
|
+
<h3 class="tc-modal-title">${this.labels.newChatTitle || 'New Direct Message'}</h3>
|
|
58
|
+
<button class="tc-modal-close" type="button">×</button>
|
|
59
|
+
`;
|
|
60
|
+
header.querySelector('.tc-modal-close').addEventListener('click', () => this.hide());
|
|
61
|
+
|
|
62
|
+
const searchInput = document.createElement('input');
|
|
63
|
+
searchInput.className = 'tc-modal-search';
|
|
64
|
+
searchInput.type = 'text';
|
|
65
|
+
searchInput.placeholder = this.labels.newChatSearch || 'Search users...';
|
|
66
|
+
searchInput.addEventListener('input', (e) => {
|
|
67
|
+
this.searchText = e.target.value;
|
|
68
|
+
this._renderList(listContainer);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const listContainer = document.createElement('div');
|
|
72
|
+
listContainer.className = 'tc-modal-user-list';
|
|
73
|
+
|
|
74
|
+
modal.appendChild(header);
|
|
75
|
+
modal.appendChild(searchInput);
|
|
76
|
+
modal.appendChild(listContainer);
|
|
77
|
+
this.overlayEl.appendChild(modal);
|
|
78
|
+
this.mountEl.appendChild(this.overlayEl);
|
|
79
|
+
|
|
80
|
+
this._renderList(listContainer);
|
|
81
|
+
searchInput.focus();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_renderList(container) {
|
|
85
|
+
container.innerHTML = '';
|
|
86
|
+
const normalizedCurrent = this.currentUsername.toLowerCase().trim();
|
|
87
|
+
const search = this.searchText.toLowerCase();
|
|
88
|
+
|
|
89
|
+
const filtered = this.users.filter(u => {
|
|
90
|
+
if (u.username.toLowerCase().trim() === normalizedCurrent) return false;
|
|
91
|
+
if (this.existingPeerIds.includes(u.id)) return false;
|
|
92
|
+
if (search && !u.username.toLowerCase().includes(search)) return false;
|
|
93
|
+
return true;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (filtered.length === 0) {
|
|
97
|
+
const empty = document.createElement('div');
|
|
98
|
+
empty.className = 'tc-modal-empty';
|
|
99
|
+
empty.textContent = search ? 'No users found' : 'No new users to message';
|
|
100
|
+
container.appendChild(empty);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
filtered.forEach(user => {
|
|
105
|
+
const item = document.createElement('div');
|
|
106
|
+
item.className = 'tc-modal-user-item';
|
|
107
|
+
item.innerHTML = `
|
|
108
|
+
<div class="tc-modal-user-avatar">${getInitials(user.username)}</div>
|
|
109
|
+
<div class="tc-modal-user-info">
|
|
110
|
+
<span class="tc-modal-user-name">${user.username}</span>
|
|
111
|
+
${user.app ? `<span class="tc-modal-user-app">${user.app}</span>` : ''}
|
|
112
|
+
</div>
|
|
113
|
+
`;
|
|
114
|
+
item.addEventListener('click', () => this._selectUser(user));
|
|
115
|
+
container.appendChild(item);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async _selectUser(user) {
|
|
120
|
+
const result = await this.onStartDirect(this.currentUsername, user.id);
|
|
121
|
+
this.hide();
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
destroy() {
|
|
126
|
+
this.hide();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { NewChatModal };
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinned Messages Panel component for tacel-chat
|
|
3
|
+
* Shows a list of all pinned messages in the current conversation/room.
|
|
4
|
+
* Supports: click to scroll to message, unpin via callback.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { createElement } = require('../utils/dom');
|
|
8
|
+
const { formatTime } = require('../utils/format');
|
|
9
|
+
|
|
10
|
+
class PinnedPanel {
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.onScrollToMessage = options.onScrollToMessage || (() => {});
|
|
13
|
+
this.onUnpinMessage = options.onUnpinMessage || null;
|
|
14
|
+
this.labels = options.labels || {};
|
|
15
|
+
|
|
16
|
+
this.containerEl = null;
|
|
17
|
+
this.listEl = null;
|
|
18
|
+
this.isOpen = false;
|
|
19
|
+
this.pinnedMessages = []; // { id, content, senderName, timestamp }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create the toggle button for the header
|
|
24
|
+
* @returns {HTMLElement}
|
|
25
|
+
*/
|
|
26
|
+
createToggleButton() {
|
|
27
|
+
const btn = createElement('button', {
|
|
28
|
+
className: 'tc-pinned-toggle-btn',
|
|
29
|
+
innerHTML: '<i class="fas fa-thumbtack"></i>',
|
|
30
|
+
attrs: { type: 'button', title: this.labels.pinnedPanel || 'Pinned Messages' },
|
|
31
|
+
events: { click: () => this.toggle() }
|
|
32
|
+
});
|
|
33
|
+
this.toggleBtnEl = btn;
|
|
34
|
+
return btn;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Render the panel into a parent (the tc-main container)
|
|
39
|
+
* @param {HTMLElement} parent
|
|
40
|
+
*/
|
|
41
|
+
render(parent) {
|
|
42
|
+
this.containerEl = createElement('div', { className: 'tc-pinned-panel' });
|
|
43
|
+
this.containerEl.style.display = 'none';
|
|
44
|
+
|
|
45
|
+
// Header
|
|
46
|
+
const header = createElement('div', { className: 'tc-pinned-panel-header' });
|
|
47
|
+
header.innerHTML = `
|
|
48
|
+
<div class="tc-pinned-panel-title">
|
|
49
|
+
<i class="fas fa-thumbtack"></i>
|
|
50
|
+
<span>${this.labels.pinnedPanel || 'Pinned Messages'}</span>
|
|
51
|
+
</div>
|
|
52
|
+
`;
|
|
53
|
+
const closeBtn = createElement('button', {
|
|
54
|
+
className: 'tc-pinned-panel-close',
|
|
55
|
+
innerHTML: '<i class="fas fa-times"></i>',
|
|
56
|
+
attrs: { type: 'button' },
|
|
57
|
+
events: { click: () => this.close() }
|
|
58
|
+
});
|
|
59
|
+
header.appendChild(closeBtn);
|
|
60
|
+
this.containerEl.appendChild(header);
|
|
61
|
+
|
|
62
|
+
// List
|
|
63
|
+
this.listEl = createElement('div', { className: 'tc-pinned-panel-list' });
|
|
64
|
+
this.containerEl.appendChild(this.listEl);
|
|
65
|
+
|
|
66
|
+
parent.appendChild(this.containerEl);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Update the pinned messages list
|
|
71
|
+
* @param {Array} pinnedMessages - Array of { id, content, senderName, timestamp }
|
|
72
|
+
*/
|
|
73
|
+
updatePinned(pinnedMessages) {
|
|
74
|
+
this.pinnedMessages = pinnedMessages || [];
|
|
75
|
+
|
|
76
|
+
// Update badge on toggle button
|
|
77
|
+
if (this.toggleBtnEl) {
|
|
78
|
+
const existing = this.toggleBtnEl.querySelector('.tc-pinned-badge');
|
|
79
|
+
if (existing) existing.remove();
|
|
80
|
+
if (this.pinnedMessages.length > 0) {
|
|
81
|
+
const badge = createElement('span', {
|
|
82
|
+
className: 'tc-pinned-badge',
|
|
83
|
+
textContent: String(this.pinnedMessages.length)
|
|
84
|
+
});
|
|
85
|
+
this.toggleBtnEl.appendChild(badge);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (this.isOpen) this._renderList();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
toggle() {
|
|
93
|
+
if (this.isOpen) {
|
|
94
|
+
this.close();
|
|
95
|
+
} else {
|
|
96
|
+
this.open();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
open() {
|
|
101
|
+
this.isOpen = true;
|
|
102
|
+
if (this.containerEl) this.containerEl.style.display = '';
|
|
103
|
+
if (this.toggleBtnEl) this.toggleBtnEl.classList.add('tc-pinned-toggle-active');
|
|
104
|
+
this._renderList();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
close() {
|
|
108
|
+
this.isOpen = false;
|
|
109
|
+
if (this.containerEl) this.containerEl.style.display = 'none';
|
|
110
|
+
if (this.toggleBtnEl) this.toggleBtnEl.classList.remove('tc-pinned-toggle-active');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_renderList() {
|
|
114
|
+
if (!this.listEl) return;
|
|
115
|
+
this.listEl.innerHTML = '';
|
|
116
|
+
|
|
117
|
+
if (this.pinnedMessages.length === 0) {
|
|
118
|
+
const empty = createElement('div', { className: 'tc-pinned-panel-empty' });
|
|
119
|
+
empty.innerHTML = `
|
|
120
|
+
<i class="fas fa-thumbtack" style="font-size:28px;opacity:0.2;margin-bottom:8px;"></i>
|
|
121
|
+
<div>${this.labels.noPinned || 'No pinned messages'}</div>
|
|
122
|
+
`;
|
|
123
|
+
this.listEl.appendChild(empty);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const msg of this.pinnedMessages) {
|
|
128
|
+
const item = createElement('div', { className: 'tc-pinned-panel-item' });
|
|
129
|
+
item.dataset.msgId = msg.id;
|
|
130
|
+
|
|
131
|
+
const info = createElement('div', { className: 'tc-pinned-panel-item-info' });
|
|
132
|
+
|
|
133
|
+
const senderRow = createElement('div', { className: 'tc-pinned-panel-item-sender' });
|
|
134
|
+
senderRow.textContent = msg.senderName || 'Unknown';
|
|
135
|
+
info.appendChild(senderRow);
|
|
136
|
+
|
|
137
|
+
const textEl = createElement('div', { className: 'tc-pinned-panel-item-text' });
|
|
138
|
+
const preview = (msg.content || '').substring(0, 100);
|
|
139
|
+
textEl.textContent = preview.length < (msg.content || '').length ? preview + '…' : preview;
|
|
140
|
+
info.appendChild(textEl);
|
|
141
|
+
|
|
142
|
+
const timeEl = createElement('div', { className: 'tc-pinned-panel-item-time' });
|
|
143
|
+
timeEl.textContent = formatTime(msg.timestamp);
|
|
144
|
+
info.appendChild(timeEl);
|
|
145
|
+
|
|
146
|
+
const actions = createElement('div', { className: 'tc-pinned-panel-item-actions' });
|
|
147
|
+
if (this.onUnpinMessage) {
|
|
148
|
+
const unpinBtn = createElement('button', {
|
|
149
|
+
className: 'tc-pinned-panel-item-btn',
|
|
150
|
+
innerHTML: '<i class="fas fa-times"></i>',
|
|
151
|
+
attrs: { title: 'Unpin', type: 'button' },
|
|
152
|
+
events: {
|
|
153
|
+
click: (e) => {
|
|
154
|
+
e.stopPropagation();
|
|
155
|
+
this.onUnpinMessage(msg);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
actions.appendChild(unpinBtn);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
item.appendChild(info);
|
|
163
|
+
item.appendChild(actions);
|
|
164
|
+
|
|
165
|
+
// Click to scroll to message
|
|
166
|
+
item.addEventListener('click', () => {
|
|
167
|
+
this.onScrollToMessage(msg.id);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
this.listEl.appendChild(item);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
destroy() {
|
|
175
|
+
if (this.containerEl) {
|
|
176
|
+
this.containerEl.remove();
|
|
177
|
+
this.containerEl = null;
|
|
178
|
+
}
|
|
179
|
+
this.listEl = null;
|
|
180
|
+
this.toggleBtnEl = null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { PinnedPanel };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presence indicator helpers for tacel-chat
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { formatRelativeTime } = require('../utils/format');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a presence dot element
|
|
9
|
+
* @param {boolean} isOnline
|
|
10
|
+
* @returns {HTMLElement}
|
|
11
|
+
*/
|
|
12
|
+
function createPresenceDot(isOnline) {
|
|
13
|
+
const dot = document.createElement('span');
|
|
14
|
+
dot.className = `tc-presence-dot ${isOnline ? 'tc-online' : 'tc-offline'}`;
|
|
15
|
+
return dot;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get presence status text for the chat header
|
|
20
|
+
* @param {object} user - { isOnline, lastSeen }
|
|
21
|
+
* @param {number} staleMs - Staleness threshold in ms
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
function getPresenceText(user, staleMs = 10000) {
|
|
25
|
+
if (!user) return '';
|
|
26
|
+
const isOnline = checkOnline(user, staleMs);
|
|
27
|
+
if (isOnline) return 'Online';
|
|
28
|
+
return `Last seen ${formatRelativeTime(user.lastSeen)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if a user is truly online (considering staleness)
|
|
33
|
+
* @param {object} user - { isOnline, lastSeen }
|
|
34
|
+
* @param {number} staleMs - Staleness threshold
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
function checkOnline(user, staleMs = 10000) {
|
|
38
|
+
if (!user || !user.isOnline) return false;
|
|
39
|
+
if (!user.lastSeen || staleMs <= 0) return user.isOnline;
|
|
40
|
+
const lastSeen = new Date(user.lastSeen).getTime();
|
|
41
|
+
if (isNaN(lastSeen)) return user.isOnline;
|
|
42
|
+
return (Date.now() - lastSeen) < staleMs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { createPresenceDot, getPresenceText, checkOnline };
|