1/** 2 * Message edit, pin, and live update helpers. 3 */ 4window.ChatroomMessageMeta = (function () { 5 'use strict'; 6 7 let pinnedListener = null; 8 let pinnedRef = null; 9 let changedListener = null; 10 let changedRef = null; 11 let currentChannel = null; 12 let cachedPinned = null; 13 let popupOpen = false; 14 let outsideClickHandler = null; 15 let escapeHandler = null; 16 17 function escapeHtml(text) { 18 const div = document.createElement('div'); 19 div.textContent = text == null ? '' : String(text); 20 return div.innerHTML; 21 } 22 23 function getMessageUsername(messageEl) { 24 const el = messageEl.querySelector('.message-username'); 25 if (!el) return ''; 26 return el.textContent.replace(/ð/g, '').trim(); 27 } 28 29 function getMessagesRef() { 30 const currentDM = window.ChatroomDMs && window.ChatroomDMs.getCurrentDM 31 ? window.ChatroomDMs.getCurrentDM() 32 : null; 33 const currentUser = window.__sharedUsername ? window.__sharedUsername.get() : ''; 34 if (currentDM && currentUser) { 35 const conversationId = [currentUser, currentDM].sort().join('_'); 36 return firebase.database().ref('chatroom/dms/' + conversationId + '/messages'); 37 } 38 const channel = window.Chatroom && window.Chatroom.getCurrentChannel 39 ? window.Chatroom.getCurrentChannel() 40 : 'general'; 41 return firebase.database().ref('chatroom/channels/' + channel + '/messages'); 42 } 43 44 function getPinnedRef(channelId) { 45 return firebase.database().ref('chatroom/channels/' + channelId + '/pinned'); 46 } 47 48 function findMessageKey(snapshot, messageId) { 49 if (!snapshot.exists()) return null; 50 const messages = snapshot.val(); 51 if (messages[messageId]) return messageId; 52 for (const key in messages) { 53 if (messages[key] && messages[key].id === messageId) return key; 54 } 55 return null; 56 } 57 58 function editedLabelHtml(message) { 59 if (!message || (!message.edited && !message.editedAt)) return ''; 60 return '<span class="message-edited" title="Edited">(edited)</span>'; 61 } 62 63 function applyEditedLabel(messageEl, message) { 64 const header = messageEl.querySelector('.message-header'); 65 if (!header) return; 66 let label = header.querySelector('.message-edited'); 67 if (message && (message.edited || message.editedAt)) { 68 if (!label) { 69 label = document.createElement('span'); 70 label.className = 'message-edited'; 71 label.title = 'Edited'; 72 label.textContent = '(edited)'; 73 const time = header.querySelector('.message-time'); 74 if (time) header.insertBefore(label, time); 75 else header.appendChild(label); 76 } 77 } else if (label) { 78 label.remove(); 79 } 80 } 81 82 function updateMessageTextDom(messageId, text, messageData) { 83 const el = document.querySelector('.message[data-message-id="' + messageId + '"]'); 84 if (!el) return; 85 const textEl = el.querySelector('.message-text'); 86 if (textEl) { 87 textEl.innerHTML = window.ChatroomMessageFormatter 88 ? window.ChatroomMessageFormatter.formatMessage(String(text || '')) 89 : escapeHtml(String(text || '')); 90 } 91 applyEditedLabel(el, messageData || { edited: true }); 92 } 93 94 function saveMessageEdit(messageEl, newText) { 95 const messageId = messageEl.dataset.messageId; 96 if (!messageId || !newText) return Promise.resolve(false); 97 98 const ref = getMessagesRef(); 99 return ref.once('value').then(function (snapshot) { 100 const key = findMessageKey(snapshot, messageId); 101 if (!key) { 102 showToast('Message not found.'); 103 return false; 104 } 105 const msg = snapshot.val()[key]; 106 const owner = msg && msg.username; 107 const me = window.__sharedUsername ? window.__sharedUsername.get() : ''; 108 const isMod = window.ChatroomModerator && window.ChatroomModerator.checkIsModerator(); 109 if (owner !== me && !isMod) { 110 showToast('You can only edit your own messages.'); 111 return false;
112 } 113 return ref.child(key).update({ 114 text: newText, 115 edited: true, 116 editedAt: Date.now() 117 }).then(function () { 118 updateMessageTextDom(messageId, newText, { edited: true, editedAt: Date.now() }); 119 showToast('Message updated.'); 120 return true; 121 }); 122 }).catch(function (err) { 123 console.error('[ChatroomMessageMeta] edit:', err); 124 showToast('Could not save edit.'); 125 return false; 126 }); 127 } 128 129 function startEdit(messageEl) { 130 const messageText = messageEl.querySelector('.message-text'); 131 if (!messageText) { 132 showToast('This message has no text to edit.'); 133 return; 134 } 135 const originalText = messageText.textContent; 136 137 const input = document.createElement('textarea'); 138 input.value = originalText; 139 input.className = 'message-edit-input'; 140 input.rows = 3; 141 142 messageText.replaceWith(input); 143 input.focus(); 144 input.select(); 145 146 const scrollEditedMessageIntoView = function () { 147 messageEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 148 }; 149 requestAnimationFrame(scrollEditedMessageIntoView); 150 if (typeof ResizeObserver !== 'undefined') { 151 const editResizeObserver = new ResizeObserver(scrollEditedMessageIntoView); 152 editResizeObserver.observe(input); 153 input.addEventListener('blur', function () { 154 editResizeObserver.disconnect(); 155 }, { once: true }); 156 } 157 158 let saving = false; 159 160 const restore = function () { 161 if (input.parentNode) input.replaceWith(messageText); 162 }; 163 164 const saveEdit = function () { 165 if (saving) return; 166 const newText = input.value.trim(); 167 if (!newText) { 168 restore(); 169 return; 170 } 171 if (newText === originalText) { 172 restore(); 173 return; 174 } 175 saving = true; 176 saveMessageEdit(messageEl, newText).then(function (ok) { 177 if (ok) { 178 if (window.ChatroomMessageFormatter) { 179 messageText.innerHTML = window.ChatroomMessageFormatter.formatMessage(newText); 180 } else { 181 messageText.textContent = newText; 182 } 183 applyEditedLabel(messageEl, { edited: true, editedAt: Date.now() }); 184 } 185 }).finally(function () { 186 saving = false; 187 restore(); 188 }); 189 }; 190 191 input.addEventListener('blur', function () { 192 setTimeout(saveEdit, 0); 193 }); 194 input.addEventListener('keydown', function (e) { 195 if (e.key === 'Enter' && !e.shiftKey) { 196 e.preventDefault(); 197 input.blur(); 198 } 199 if (e.key === 'Escape') { 200 saving = true; 201 restore(); 202 } 203 }); 204 } 205 206 function pinMessage(messageEl) { 207 const isMod = window.ChatroomModerator && window.ChatroomModerator.checkIsModerator(); 208 if (!isMod) { 209 showToast('Only moderators can pin messages.'); 210 return; 211 } 212 const currentDM = window.ChatroomDMs && window.ChatroomDMs.getCurrentDM 213 ? window.ChatroomDMs.getCurrentDM() 214 : null; 215 if (currentDM) { 216 showToast('Pinning is only available in server channels.'); 217 return; 218 } 219 220 const channel = window.Chatroom && window.Chatroom.getCurrentChannel 221 ? window.Chatroom.getCurrentChannel() 222 : 'general'; 223 const messageId = messageEl.dataset.messageId; 224 const me = window.__sharedUsername ? window.__sharedUsername.get() : ''; 225 226 const ref = getMessagesRef(); 227 ref.once('value').then(function (snapshot) { 228 const key = findMessageKey(snapshot, messageId); 229 if (!key) { 230 showToast('Message not found.'); 231 return; 232 } 233 const msg = snapshot.val()[key]; 234 const username = (msg && msg.username) || getMessageUsername(messageEl) || 'Unknown'; 235 const rawText = msg && msg.text != null 236 ? String(msg.text) 237 : ((messageEl.querySelector('.message-text') || {}).textContent || ''); 238 return getPinnedRef(channel).child(key).set({ 239 messageKey: key, 240 messageId: messageId, 241 username: username, 242 text: rawText.substring(0, 500), 243 messageTimestamp: (msg && msg.timestamp) || Date.now(), 244 pinnedBy: me, 245 pinnedAt: Date.now() 246 }).then(function () { 247 showToast('Message pinned.'); 248 }); 249 }).catch(function (err) { 250 console.error('[ChatroomMessageMeta] pin:', err); 251 showToast('Could not pin message.'); 252 }); 253 } 254 255 function unpinMessage(channelId, messageKey) { 256 const isMod = window.ChatroomModerator && window.ChatroomModerator.checkIsModerator(); 257 if (!isMod) return Promise.resolve(false); 258 return getPinnedRef(channelId).child(messageKey).remove() 259 .then(function () { 260 showToast('Message unpinned.'); 261 return true; 262 }); 263 } 264 265 function formatPinnedText(text) { 266 const raw = (text || '').trim(); 267 if (!raw) return 'Attachment'; 268 if (window.ChatroomMessageFormatter && window.ChatroomMessageFormatter.formatMessage) { 269 return window.ChatroomMessageFormatter.formatMessage(raw); 270 } 271 return escapeHtml(raw).replace(/\n/g, '<br>'); 272 } 273 274 function formatPinnedTime(ts) { 275 const date = new Date(ts); 276 if (Number.isNaN(date.getTime())) return ''; 277 return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { 278 hour: 'numeric', 279 minute: '2-digit' 280 }); 281 } 282 283 function getPinnedAvatarHtml(username) { 284 const profilePicture = window.ChatroomProfilePicture && window.ChatroomProfilePicture.getProfilePicture 285 ? window.ChatroomProfilePicture.getProfilePicture(username) 286 : null; 287 if (profilePicture) { 288 return '<img src="' + escapeHtml(profilePicture) + '" alt="" class="pinned-messages-popup__avatar-img">'; 289 } 290 const avatars = ['ð¾', 'ð±', 'ð¶', 'ð¦', 'ð°', 'ð»']; 291 let hash = 0; 292 for (let i = 0; i < (username || '').length; i++) { 293 hash = username.charCodeAt(i) + ((hash << 5) - hash); 294 } 295 return '<span class="pinned-messages-popup__avatar-emoji" aria-hidden="true">' + 296 avatars[Math.abs(hash) % avatars.length] + '</span>'; 297 } 298 299 function scrollToPinnedMessage(messageId) { 300 if (!messageId) return; 301 if (window.ChatroomReplies && window.ChatroomReplies.scrollToMessage) { 302 window.ChatroomReplies.scrollToMessage(messageId); 303 } else if (window.Chatroom && window.Chatroom.scrollToMessage) { 304 window.Chatroom.scrollToMessage(messageId); 305 } 306 } 307 308 function isPopupOpen() { 309 const popup = document.getElementById('pinned-messages-popup'); 310 return !!(popup && popup.classList.contains('is-open')); 311 } 312 313 function unbindPopupDismiss() { 314 if (outsideClickHandler) { 315 document.removeEventListener('mousedown', outsideClickHandler, true); 316 outsideClickHandler = null; 317 } 318 if (escapeHandler) { 319 document.removeEventListener('keydown', escapeHandler); 320 escapeHandler = null; 321 } 322 } 323 324 function closePinnedPopup() { 325 const popup = document.getElementById('pinned-messages-popup'); 326 const btn = document.getElementById('pinned-messages-btn'); 327 if (popup) { 328 popup.classList.remove('is-open'); 329 popup.hidden = true; 330 } 331 if (btn) btn.setAttribute('aria-expanded', 'false'); 332 popupOpen = false;
333 unbindPopupDismiss(); 334 } 335 336 function openPinnedPopup() { 337 const popup = document.getElementById('pinned-messages-popup'); 338 const btn = document.getElementById('pinned-messages-btn'); 339 if (!popup) return; 340 popup.classList.add('is-open'); 341 popup.hidden = false; 342 popupOpen = true; 343 if (btn) btn.setAttribute('aria-expanded', 'true'); 344 if (currentChannel && cachedPinned) { 345 renderPinnedPopup(currentChannel, cachedPinned); 346 } 347 bindPopupDismiss(); 348 } 349 350 function bindPopupDismiss() { 351 unbindPopupDismiss(); 352 outsideClickHandler = function (e) { 353 const popup = document.getElementById('pinned-messages-popup'); 354 const btn = document.getElementById('pinned-messages-btn'); 355 if (!popup || !popup.classList.contains('is-open')) return; 356 if (popup.contains(e.target) || (btn && btn.contains(e.target))) return; 357 closePinnedPopup(); 358 }; 359 escapeHandler = function (e) { 360 if (e.key === 'Escape') closePinnedPopup(); 361 }; 362 setTimeout(function () { 363 document.addEventListener('mousedown', outsideClickHandler, true); 364 }, 0); 365 document.addEventListener('keydown', escapeHandler); 366 } 367 368 function ensurePinnedUi() { 369 let btn = document.getElementById('pinned-messages-btn'); 370 if (!btn) { 371 const headerRight = document.querySelector('.header-right'); 372 const settingsBtn = document.getElementById('settings-btn'); 373 if (!headerRight) return; 374 btn = document.createElement('button'); 375 btn.type = 'button'; 376 btn.id = 'pinned-messages-btn'; 377 btn.className = 'header-btn cr-icon-btn'; 378 btn.title = 'Pinned Messages'; 379 btn.setAttribute('aria-label', 'Pinned Messages'); 380 btn.setAttribute('aria-expanded', 'false'); 381 btn.hidden = true; 382 if (window.ChatroomIcons) window.ChatroomIcons.set(btn, 'thumbtack'); 383 if (settingsBtn) headerRight.insertBefore(btn, settingsBtn); 384 else headerRight.appendChild(btn); 385 btn.addEventListener('click', function (e) { 386 e.stopPropagation(); 387 togglePinnedPopup(); 388 }); 389 } 390 391 let popup = document.getElementById('pinned-messages-popup'); 392 if (!popup) { 393 const chatArea = document.querySelector('.chat-area'); 394 if (!chatArea) return; 395 popup = document.createElement('div'); 396 popup.id = 'pinned-messages-popup'; 397 popup.className = 'pinned-messages-popup'; 398 popup.hidden = true; 399 popup.setAttribute('role', 'dialog'); 400 popup.setAttribute('aria-label', 'Pinned Messages'); 401 chatArea.appendChild(popup); 402 } 403 } 404 405 function togglePinnedPopup() { 406 if (isPopupOpen()) { 407 closePinnedPopup(); 408 return; 409 } 410 openPinnedPopup(); 411 } 412 413 function updatePinButton(count) { 414 const btn = document.getElementById('pinned-messages-btn'); 415 if (!btn) return; 416 const show = count > 0 && !!currentChannel; 417 btn.hidden = !show; 418 btn.title = show ? ('Pinned Messages (' + count + ')') : 'Pinned Messages'; 419 btn.setAttribute('aria-label', show ? ('Pinned Messages, ' + count) : 'Pinned Messages'); 420 } 421 422 function renderPinnedPopup(channelId, pinnedData) { 423 const popup = document.getElementById('pinned-messages-popup'); 424 if (!popup) return; 425 426 const entries = pinnedData ? Object.entries(pinnedData) : []; 427 entries.sort(function (a, b) { 428 return (b[1].pinnedAt || 0) - (a[1].pinnedAt || 0); 429 }); 430 431 const isMod = window.ChatroomModerator && window.ChatroomModerator.checkIsModerator(); 432 let html = '<div class="pinned-messages-popup__header">' + 433 '<div class="pinned-messages-popup__header-title">' + 434 '<i class="fa-solid fa-thumbtack fa-width-auto" aria-hidden="true"></i>' + 435 '<span>Pinned Messages</span></div>' + 436 '<button type="button" class="pinned-messages-popup__close" aria-label="Close pinned messages">' + 437 '<i class="fa-solid fa-xmark fa-width-auto" aria-hidden="true"></i></button></div>'; 438 439 if (!entries.length) { 440 html += '<p class="pinned-messages-popup__empty">No pinned messages in this channel.</p>'; 441 } else { 442 html += '<div class="pinned-messages-popup__list">';
443 entries.forEach(function (pair) { 444 const key = pair[0]; 445 const pin = pair[1]; 446 const author = escapeHtml(pin.username || 'Unknown'); 447 const text = formatPinnedText(pin.text); 448 const messageId = pin.messageId || key; 449 const time = formatPinnedTime(pin.messageTimestamp || pin.pinnedAt || Date.now()); 450 html += '<article class="pinned-messages-popup__item" data-pin-key="' + escapeHtml(key) + '">'; 451 html += '<div class="pinned-messages-popup__avatar">' + getPinnedAvatarHtml(pin.username) + '</div>'; 452 html += '<div class="pinned-messages-popup__body">'; 453 html += '<div class="pinned-messages-popup__meta">'; 454 html += '<span class="pinned-messages-popup__author">' + author + '</span>'; 455 if (time) html += '<span class="pinned-messages-popup__time">' + escapeHtml(time) + '</span>'; 456 html += '</div>'; 457 html += '<div class="pinned-messages-popup__text message-text">' + text + '</div>'; 458 html += '</div>'; 459 html += '<div class="pinned-messages-popup__actions">'; 460 html += '<button type="button" class="pinned-messages-popup__jump" data-message-id="' + escapeHtml(messageId) + '">Jump</button>'; 461 if (isMod) { 462 html += '<button type="button" class="pinned-messages-popup__unpin" data-unpin-key="' + escapeHtml(key) + '" title="Unpin" aria-label="Unpin"><i class="fa-solid fa-xmark fa-width-auto" aria-hidden="true"></i></button>'; 463 } 464 html += '</div></article>'; 465 }); 466 html += '</div>'; 467 } 468 469 popup.innerHTML = html; 470 471 const closeBtn = popup.querySelector('.pinned-messages-popup__close'); 472 if (closeBtn) { 473 closeBtn.addEventListener('click', function (e) { 474 e.stopPropagation(); 475 closePinnedPopup(); 476 }); 477 } 478 479 popup.querySelectorAll('.pinned-messages-popup__jump').forEach(function (btn) { 480 btn.addEventListener('click', function (e) { 481 e.stopPropagation(); 482 scrollToPinnedMessage(btn.dataset.messageId); 483 closePinnedPopup(); 484 }); 485 }); 486 487 popup.querySelectorAll('.pinned-messages-popup__unpin').forEach(function (btn) { 488 btn.addEventListener('click', function (e) { 489 e.stopPropagation(); 490 const pinKey = btn.dataset.unpinKey; 491 if (pinKey) unpinMessage(channelId, pinKey); 492 }); 493 }); 494 } 495 496 function renderPinnedState(channelId, pinnedData) { 497 ensurePinnedUi(); 498 cachedPinned = pinnedData; 499 const count = pinnedData ? Object.keys(pinnedData).length : 0; 500 updatePinButton(count); 501 502 if (!count) { 503 closePinnedPopup(); 504 const popup = document.getElementById('pinned-messages-popup'); 505 if (popup) popup.innerHTML = ''; 506 return; 507 } 508 509 if (popupOpen) { 510 renderPinnedPopup(channelId, pinnedData); 511 } 512 } 513 514 function removeLegacyPinnedBar() { 515 const bar = document.getElementById('pinned-messages-bar'); 516 if (bar) bar.remove(); 517 } 518 519 function teardownChannelMeta() { 520 if (pinnedRef && pinnedListener) { 521 pinnedRef.off('value', pinnedListener); 522 } 523 if (changedRef && changedListener) { 524 changedRef.off('child_changed', changedListener); 525 } 526 pinnedRef = null; 527 pinnedListener = null; 528 changedRef = null; 529 changedListener = null; 530 currentChannel = null; 531 cachedPinned = null; 532 closePinnedPopup(); 533 updatePinButton(0); 534 removeLegacyPinnedBar(); 535 } 536 537 function bindMessageChangeListener(ref) { 538 changedRef = ref; 539 changedListener = changedRef.on('child_changed', function (snap) { 540 const data = snap.val(); 541 if (!data) return; 542 const id = snap.key; 543 updateMessageTextDom(id, data.text, data); 544 }); 545 } 546 547 function bindChannel(channelId) { 548 if (typeof firebase === 'undefined' || !firebase.database) return; 549 teardownChannelMeta(); 550 currentChannel = channelId; 551 552 pinnedRef = getPinnedRef(channelId); 553 ensurePinnedUi(); 554 pinnedListener = pinnedRef.on('value', function (snap) { 555 renderPinnedState(channelId, snap.exists() ? snap.val() : null); 556 }); 557 558 bindMessageChangeListener(firebase.database().ref('chatroom/channels/' + channelId + '/messages')); 559 } 560 561 function bindDM(otherUsername) { 562 if (typeof firebase === 'undefined' || !firebase.database) return; 563 teardownChannelMeta(); 564 currentChannel = null; 565 const me = window.__sharedUsername ? window.__sharedUsername.get() : ''; 566 if (!me || !otherUsername) return; 567 const conversationId = [me, otherUsername].sort().join('_'); 568 bindMessageChangeListener(firebase.database().ref('chatroom/dms/' + conversationId + '/messages')); 569 } 570 571 function showToast(message) { 572 const toast = document.createElement('div'); 573 toast.className = 'toast'; 574 toast.textContent = message; 575 document.body.appendChild(toast); 576 setTimeout(function () { toast.classList.add('show'); }, 10); 577 setTimeout(function () { 578 toast.classList.remove('show'); 579 setTimeout(function () { toast.remove(); }, 300); 580 }, 2000); 581 } 582 583 return { 584 startEdit: startEdit, 585 pinMessage: pinMessage, 586 unpinMessage: unpinMessage, 587 bindChannel: bindChannel, 588 bindDM: bindDM, 589 teardownChannelMeta: teardownChannelMeta, 590 editedLabelHtml: editedLabelHtml, 591 applyEditedLabel: applyEditedLabel 592 }; 593})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.