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.
@@ -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">&times;</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 };