1/** 2 * Chatroom Core System 3 * Main chatroom functionality with channels and messaging 4 */ 5 6window.Chatroom = (function() { 7 'use strict'; 8 9 const CHANNEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes - don't re-fetch if opened recently 10 const INITIAL_MESSAGE_LIMIT = 50; 11 const LOAD_OLDER_BATCH_SIZE = 50; 12 const SCROLL_LOAD_THRESHOLD = 150; // px from top to trigger load older 13 const STRIKE_RESET_MS = 60 * 60 * 1000; // 1 hour - strikes reset after this 14 const STRIKE1_TIMEOUT_MS = 15 * 1000; // 15 seconds for 1st strike 15 const STRIKE2_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes for 2nd strike 16 17 let messagesRef = null; 18 let channelsRef = null; 19 let currentChannel = 'general'; 20 let currentUsername = null; 21 let messageListener = null; 22 23 // Channel cache: only fetch from Firebase when channel hasn't been opened recently 24 let channelCache = new Map(); // channelId -> { messages, lastFetchTime, oldestTimestamp, newestTimestamp } 25 26 // Scroll-to-load-older state 27 let loadingOlder = false; 28 let hasMoreOlder = true; 29 let scrollLoadHandler = null; 30 let composerResizeObserver = null; 31 let composerLastHeight = 0; 32 let suppressMessageAnimations = false; 33 let chatroomActive = false; 34 let chatroomSetupDone = false; 35 let chatroomUiReady = false; 36 let chatroomSession = 0; 37 let usernameCheckInterval = null; 38 let initUsernameTimeout = null; 39 let initUsernameRetryInterval = null; 40 let initUsernameRetryStopTimeout = null; 41 let channelsValueHandler = null; 42 let channelOrderValueHandler = null; 43 let channelOrderListenerAttached = false; 44 let channelListenerLatestChannels = {}; 45 let channelListenerLatestIds = []; 46 let channelOrderRef = null; 47 48 /** Safe unread API calls (tolerates stale cached unreadCount.js on CDN/browser). */ 49 function callUnread(method) { 50 const mod = window.ChatroomUnreadCount; 51 if (!mod || typeof mod[method] !== 'function') return; 52 return mod[method].apply(mod, Array.prototype.slice.call(arguments, 1)); 53 } 54 55 /** Prefer the chat mount inside Citrus #page-wrap over bare document.getElementById. */ 56 function getMessagesList() { 57 if (window.ChatroomSpa && typeof window.ChatroomSpa.getPageWrap === 'function') { 58 const wrap = window.ChatroomSpa.getPageWrap(); 59 if (wrap) { 60 const scoped = wrap.querySelector('#messages-list'); 61 if (scoped) return scoped; 62 } 63 } 64 return document.getElementById('messages-list'); 65 } 66 67 function getMessageSortTimestamp(message) { 68 if (window.ChatroomMessageGrouping && typeof window.ChatroomMessageGrouping.getMessageTimestamp === 'function') { 69 return window.ChatroomMessageGrouping.getMessageTimestamp(message) || 0; 70 } 71 return message && message.timestamp ? Number(message.timestamp) || 0 : 0; 72 } 73 74 function syncMessageGrouping(messagesList) { 75 const list = messagesList || getMessagesList(); 76 if (!list) return; 77 if (window.ChatroomMessageGrouping && typeof window.ChatroomMessageGrouping.reapplyDom === 'function') { 78 window.ChatroomMessageGrouping.reapplyDom(list); 79 } 80 } 81 82 function scheduleMessageGroupingSync(messagesList) { 83 const list = messagesList || getMessagesList(); 84 if (!list) return; 85 requestAnimationFrame(function () { 86 requestAnimationFrame(function () { 87 syncMessageGrouping(list); 88 }); 89 }); 90 } 91 92 /** 93 * Initialize chatroom 94 */ 95 function notifyChatroomReady() { 96 chatroomUiReady = true; 97 hideChatroomLoadingScreen(); 98 try { 99 document.dispatchEvent(new CustomEvent('pupwife:chatroom-ready')); 100 } catch (e) { /* ignore */ } 101 } 102 103 function isUiReady() { 104 return chatroomUiReady; 105 } 106 107 function isChatroomDomReady() { 108 const list = document.getElementById('channel-list'); 109 const wrap = document.getElementById('page-wrap'); 110 return !!(list && wrap && wrap.contains(list) && chatroomActive); 111 } 112 113 function clearInitTimers() { 114 if (usernameCheckInterval) { 115 clearInterval(usernameCheckInterval); 116 usernameCheckInterval = null; 117 } 118 if (initUsernameTimeout) { 119 clearTimeout(initUsernameTimeout); 120 initUsernameTimeout = null; 121 } 122 if (initUsernameRetryInterval) { 123 clearInterval(initUsernameRetryInterval); 124 initUsernameRetryInterval = null; 125 } 126 if (initUsernameRetryStopTimeout) { 127 clearTimeout(initUsernameRetryStopTimeout); 128 initUsernameRetryStopTimeout = null; 129 } 130 } 131
132 function init() { 133 if (!document.getElementById('channel-list')) { 134 return false; 135 } 136 if (chatroomActive) { 137 return reinit(); 138 } 139 if (typeof firebase === 'undefined' || !firebase.database) { 140 console.error('Firebase not available'); 141 return false; 142 } 143 144 chatroomActive = true; 145 const session = chatroomSession; 146 147 messagesRef = firebase.database().ref('chatroom/channels'); 148 channelsRef = firebase.database().ref('chatroom/channels'); 149 150 clearInitTimers(); 151 152 // Try to get username immediately from localStorage as fallback 153 const storedUsername = localStorage.getItem('puppy_shared_username'); 154 if (storedUsername) { 155 currentUsername = storedUsername; 156 } 157 158 // Update username display immediately if we have it 159 if (currentUsername) { 160 setupStrikeSync(currentUsername); 161 updateUserInfo(); 162 setupChatroomOnce(); 163 } 164 165 // Wait for username from sharedUsername system 166 const checkUsername = setInterval(() => { 167 if (session !== chatroomSession || !chatroomActive) { 168 clearInterval(checkUsername); 169 return; 170 } 171 usernameCheckInterval = checkUsername; 172 if (window.__sharedUsername) { 173 const username = window.__sharedUsername.get(); 174 if (username) { 175 currentUsername = username; 176 setupStrikeSync(username); 177 updateUserInfo(); 178 if (!chatroomSetupDone) { 179 clearInterval(checkUsername); 180 usernameCheckInterval = null; 181 setupChatroomOnce(); 182 } 183 } 184 } 185 }, 100); 186 187 initUsernameTimeout = setTimeout(() => { 188 initUsernameTimeout = null; 189 if (session !== chatroomSession || !chatroomActive) return; 190 191 clearInterval(checkUsername); 192 usernameCheckInterval = null; 193 194 // Try one more time to get username 195 if (!currentUsername && window.__sharedUsername) { 196 currentUsername = window.__sharedUsername.get(); 197 } 198 199 // Fallback to localStorage 200 if (!currentUsername) { 201 currentUsername = localStorage.getItem('puppy_shared_username'); 202 } 203 204 if (!currentUsername) { 205 console.error('â No username found - user must be logged in to access chatroom'); 206 const hasPassword = localStorage.getItem('puppy_username_password'); 207 if (!hasPassword) { 208 console.error('â User not authenticated - redirecting to homepage'); 209 window.location.href = '/index.html'; 210 return; 211 } 212 console.warn('â ï¸ Username not loaded yet, waiting...'); 213 updateUserInfo(); 214 215 initUsernameRetryInterval = setInterval(() => { 216 if (session !== chatroomSession || !chatroomActive) return; 217 if (window.__sharedUsername) { 218 const username = window.__sharedUsername.get(); 219 if (username) { 220 currentUsername = username; 221 setupStrikeSync(username); 222 clearInterval(initUsernameRetryInterval); 223 initUsernameRetryInterval = null; 224 updateUserInfo(); 225 if (!chatroomSetupDone) setupChatroomOnce(); 226 } 227 } 228 }, 500); 229 230 initUsernameRetryStopTimeout = setTimeout(() => { 231 initUsernameRetryStopTimeout = null; 232 if (initUsernameRetryInterval) { 233 clearInterval(initUsernameRetryInterval); 234 initUsernameRetryInterval = null; 235 } 236 }, 30000); 237 238 return; 239 } 240 241 const hasPassword = localStorage.getItem('puppy_username_password'); 242 const isGuestMode = localStorage.getItem('puppy_guest_mode') === 'true'; 243 if (!hasPassword || isGuestMode || currentUsername.startsWith('guest_')) { 244 console.error('â Guest user detected - redirecting to homepage'); 245 window.location.href = '/index.html'; 246 return; 247 } 248 updateUserInfo(); 249 if (!chatroomSetupDone) setupChatroomOnce(); 250 }, 5000); 251 252 return true; 253 } 254 255 /** 256 * Re-bind chatroom UI after SPA #page-wrap swap (Firebase + DOM listeners). 257 */ 258 function reinit() { 259 if (!document.getElementById('channel-list')) { 260 return false;
261 } 262 if (!chatroomActive) { 263 return init(); 264 } 265 if (!isChatroomDomReady()) { 266 return false; 267 } 268 chatroomUiReady = false; 269 teardownChannelListener(); 270 chatroomSetupDone = false; 271 setupChatroomOnce(); 272 return true; 273 } 274 275 function setupChatroomOnce() { 276 if (chatroomSetupDone) return; 277 if (!isChatroomDomReady()) return; 278 chatroomSetupDone = true; 279 setupChatroom(); 280 } 281 282 /** 283 * Setup chatroom 284 */ 285 function setupChatroom() { 286 updateUserInfo(); 287 setupMessageInput(); 288 setupComposerResizeObserver(); 289 setupUI(); 290 291 if (window.ChatroomIcons && window.ChatroomIcons.initStaticButtons) { 292 window.ChatroomIcons.initStaticButtons(); 293 } 294 295 // Setup channels and WAIT for them to be ready before loading initial channel. 296 // This fixes whimsical-chatting (and other channels) not loading messages on first visit - 297 // they were being loaded before the channel metadata was created/verified. 298 setupChannels().then(() => { 299 notifyChatroomReady(); 300 // Check URL parameters for navigation from notifications 301 const urlParams = new URLSearchParams(window.location.search); 302 const channelParam = urlParams.get('channel'); 303 const dmParam = urlParams.get('dm'); 304 const messageParam = urlParams.get('message'); 305 306 if (channelParam) { 307 // Navigate to specific channel 308 loadChannel(channelParam); 309 // Scroll to message if specified 310 if (messageParam) { 311 setTimeout(() => { 312 scrollToMessage(messageParam); 313 }, 1000); 314 } 315 } else if (dmParam && window.ChatroomDMs) { 316 // Navigate to specific DM 317 window.ChatroomDMs.openDM(dmParam); 318 hideChatroomLoadingScreen(); 319 // Scroll to message if specified 320 if (messageParam) { 321 setTimeout(() => { 322 scrollToMessage(messageParam); 323 }, 1000); 324 } 325 } else { 326 // Restore last channel or load general 327 try { 328 const lastChannel = localStorage.getItem('chatroom_current_channel'); 329 if (lastChannel) { 330 loadChannel(lastChannel); 331 } else { 332 loadChannel('general'); 333 } 334 } catch (e) { 335 loadChannel('general'); 336 } 337 } 338 }).catch((err) => { 339 console.error('Channel setup failed, loading general:', err); 340 loadChannel('general'); 341 notifyChatroomReady(); 342 }); 343 344 console.log('â Chatroom initialized for:', currentUsername); 345 346 // Update user info after a delay to allow profile pictures to load 347 setTimeout(() => { 348 updateUserInfo(); 349 // Also trigger profile picture update if available 350 if (window.ChatroomProfilePicture && window.ChatroomProfilePicture.updateAllAvatars) { 351 window.ChatroomProfilePicture.updateAllAvatars(); 352 } 353 }, 1000); 354 } 355 356 /** 357 * Update user info display 358 */ 359 function updateUserInfo() { 360 const userNameEl = document.getElementById('user-name'); 361 const userAvatarEl = document.getElementById('user-avatar'); 362 363 if (userNameEl) { 364 const displayName = currentUsername || 'guest'; 365 userNameEl.textContent = displayName; 366 } else { 367 console.warn('â ï¸ user-name element not found'); 368 } 369 370 if (userAvatarEl) { 371 // Check for profile picture first 372 const profilePicture = window.ChatroomProfilePicture && window.ChatroomProfilePicture.getProfilePicture(currentUsername); 373 if (profilePicture) { 374 userAvatarEl.innerHTML = `<img src="${escapeHtml(profilePicture)}" alt="${escapeHtml(currentUsername)}
374" class="profile-picture-avatar">`; 375 } else { 376 userAvatarEl.textContent = getAvatar(currentUsername); 377 } 378 } 379 380 if (window.ChatroomLanyard && window.ChatroomLanyard.updateUserPanelStatus) { 381 window.ChatroomLanyard.updateUserPanelStatus(); 382 } else { 383 const statusEl = document.getElementById('user-status-text'); 384 if (statusEl) { 385 const profileStatus = window.ChatroomProfilePicture && window.ChatroomProfilePicture.getStatus 386 ? window.ChatroomProfilePicture.getStatus(currentUsername) 387 : ''; 388 statusEl.textContent = profileStatus || 'online'; 389 } 390 } 391 } 392 393 /** 394 * Setup channels - returns a Promise that resolves when channels are ready 395 */ 396 function setupChannels() { 397 console.log('ð§ Setting up channels...'); 398 399 // Create channels first, then set up listener 400 const createGeneralChannel = channelsRef.child('general').child('metadata').once('value').then((snapshot) => { 401 if (!snapshot.exists()) { 402 console.log('ð Creating general channel...'); 403 return channelsRef.child('general').child('metadata').set({ 404 name: 'general', 405 topic: 'Welcome to puppy\'s chatroom!', 406 created: Date.now(), 407 createdBy: 'system' 408 }).then(() => { 409 console.log('â General channel created'); 410 // Add to channel order 411 const orderRef = firebase.database().ref('chatroom/channelOrder'); 412 return orderRef.once('value').then((orderSnapshot) => { 413 let currentOrder = []; 414 if (orderSnapshot.exists()) { 415 const orderData = orderSnapshot.val(); 416 if (Array.isArray(orderData)) { 417 currentOrder = orderData; 418 } else if (orderData && typeof orderData === 'object') { 419 currentOrder = Object.values(orderData); 420 } 421 } 422 if (!currentOrder.includes('general')) { 423 currentOrder.unshift('general'); 424 return orderRef.set(currentOrder).catch(err => { 425 console.error('Error updating channel order:', err); 426 }); 427 } 428 }); 429 }).catch(err => { 430 console.error('â Error creating general channel:', err); 431 console.error('Error details:', err.code, err.message); 432 throw err; 433 }); 434 } else { 435 console.log('â General channel already exists'); 436 return Promise.resolve(); 437 } 438 }); 439 440 const createWhimsicalChannel = channelsRef.child('whimsical-chatting').child('metadata').once('value').then((snapshot) => { 441 if (!snapshot.exists()) { 442 console.log('ð Creating whimsical-chatting channel...'); 443 return channelsRef.child('whimsical-chatting').child('metadata').set({ 444 name: 'whimsical chatting', 445 topic: 'Text chat for Whimsy Zone voice channel', 446 created: Date.now(), 447 createdBy: 'system', 448 type: 'voice-text' 449 }).then(() => { 450 console.log('â Whimsical-chatting channel created'); 451 // Add to channel order 452 const orderRef = firebase.database().ref('chatroom/channelOrder'); 453 return orderRef.once('value').then((orderSnapshot) => { 454 let currentOrder = []; 455 if (orderSnapshot.exists()) { 456 const orderData = orderSnapshot.val(); 457 if (Array.isArray(orderData)) { 458 currentOrder = orderData; 459 } else if (orderData && typeof orderData === 'object') { 460 currentOrder = Object.values(orderData); 461 } 462 } 463 if (!currentOrder.includes('whimsical-chatting')) { 464 currentOrder.push('whimsical-chatting'); 465 return orderRef.set(currentOrder).catch(err => { 466 console.error('Error updating channel order:', err); 467 }); 468 } 469 }); 470 }).catch(err => { 471 console.error('â Error creating whimsical-chatting channel:', err); 472 console.error('Error details:', err.code, err.message); 473 throw err; 474 }); 475 } else { 476 console.log('â Whimsical-chatting channel already exists'); 477 return Promise.resolve(); 478 } 479 }); 480 481 // Wait for both channels to be created/verified, then set up listener. 482 // Return the promise so callers can wait for channels to be ready before loading. 483 return Promise.all([createGeneralChannel, createWhimsicalChannel]).then(() => { 484 console.log('â All default channels ready, setting up channel listener...'); 485 setupChannelListener(); 486 }).catch(err => { 487 console.error('â Error setting up channels:', err); 488 // Still set up listener even if channel creation failed 489 console.log('â ï¸ Setting up channel listener anyway...'); 490 setupChannelListener(); 491 }); 492 } 493 494 /** 495 * Whimsy Zone voice channel + Discord-style participant list under the channel row. 496 */ 497 function ensureVoiceChannelInList(channelList) { 498 if (!channelList) return; 499 500 let block = channelList.querySelector('.voice-channel-block'); 501 let voiceChannelItem = channelList.querySelector('.channel-item[data-channel="voice-chat"]'); 502 503 if (!voiceChannelItem) { 504 block = document.createElement('div'); 505 block.className = 'voice-channel-block'; 506 507 voiceChannelItem = document.createElement('div'); 508 voiceChannelItem.className = 'channel-item voice-channel-item';
509 voiceChannelItem.dataset.channel = 'voice-chat'; 510 voiceChannelItem.innerHTML = 511 '<span class="channel-prefix channel-prefix-icon">' + 512 (window.ChatroomIcons ? window.ChatroomIcons.icon('volumeHigh') : '') + 513 '</span>' + 514 '<div class="voice-channel-label">' + 515 '<span class="channel-name">Whimsy Zone</span>' + 516 '<div class="voice-channel-status-wrap" id="voice-channel-status-wrap" hidden>' + 517 '<button type="button" class="voice-channel-status-row" id="voice-channel-status-row">' + 518 '<span class="voice-channel-status-text" id="voice-channel-status-text">Set a channel status</span>' + 519 '<span class="voice-channel-status-pencil" id="voice-channel-status-pencil" aria-hidden="true"></span>' + 520 '</button>' + 521 '<input type="text" class="voice-channel-status-input" id="voice-channel-status-input" maxlength="120" placeholder="Set a channel status" autocomplete="off" hidden>' + 522 '</div></div>'; 523 524 const participantsList = document.createElement('div'); 525 participantsList.className = 'voice-channel-participants'; 526 participantsList.id = 'voice-channel-participants-list'; 527 528 block.appendChild(voiceChannelItem); 529 block.appendChild(participantsList); 530 531 voiceChannelItem.addEventListener('click', async function (e) { 532 if (e.target.closest('#voice-channel-status-wrap')) { 533 e.preventDefault(); 534 e.stopPropagation(); 535 if (window.ChatroomVoiceChat && typeof window.ChatroomVoiceChat.beginChannelStatusEdit === 'function') { 536 window.ChatroomVoiceChat.beginChannelStatusEdit(); 537 } 538 return; 539 } 540 e.preventDefault(); 541 e.stopPropagation(); 542 if (!window.ChatroomVoiceChat) { 543 console.warn('â ï¸ ChatroomVoiceChat not available'); 544 return; 545 } 546 const connected = window.ChatroomVoiceChat.getStatus 547 ? window.ChatroomVoiceChat.getStatus().connected 548 : false; 549 if (!connected) { 550 try { 551 const connectFn = window.ChatroomVoiceChat.connectWithConfirmation || window.ChatroomVoiceChat.connect; 552 await connectFn(); 553 } catch (error) { 554 console.error('â Error joining voice chat:', error); 555 } 556 } 557 }); 558 559 voiceChannelItem.style.cursor = 'pointer'; 560 voiceChannelItem.style.userSelect = 'none'; 561 channelList.insertBefore(block, channelList.firstChild); 562 } else { 563 if (!block) { 564 block = document.createElement('div'); 565 block.className = 'voice-channel-block'; 566 voiceChannelItem.parentNode.insertBefore(block, voiceChannelItem); 567 block.appendChild(voiceChannelItem); 568 } 569 voiceChannelItem.querySelectorAll('.voice-channel-participants').forEach(function (el) { 570 el.remove(); 571 }); 572 if (!document.getElementById('voice-channel-participants-list')) { 573 const participantsList = document.createElement('div'); 574 participantsList.className = 'voice-channel-participants'; 575 participantsList.id = 'voice-channel-participants-list'; 576 block.appendChild(participantsList); 577 } 578 if (!voiceChannelItem.querySelector('#voice-channel-status-wrap') && 579 window.ChatroomVoiceChat && 580 window.ChatroomVoiceChat.ensureChannelStatusDom) { 581 window.ChatroomVoiceChat.ensureChannelStatusDom(); 582 } 583 } 584 585 const isConnected = window.ChatroomVoiceChat && window.ChatroomVoiceChat.getStatus 586 ? window.ChatroomVoiceChat.getStatus().connected 587 : false;
588 voiceChannelItem.classList.toggle('active', !!isConnected); 589 590 if (window.ChatroomVoiceChat && window.ChatroomVoiceChat.refreshParticipantsList) { 591 window.ChatroomVoiceChat.refreshParticipantsList(); 592 } 593 if (window.ChatroomVoiceChat && window.ChatroomVoiceChat.ensureChannelStatusDom) { 594 window.ChatroomVoiceChat.ensureChannelStatusDom(); 595 } 596 if (window.ChatroomVoiceChat && window.ChatroomVoiceChat.initChannelStatusUi) { 597 window.ChatroomVoiceChat.initChannelStatusUi(); 598 } 599 } 600 601 function teardownChannelListener() { 602 if (channelsRef && channelsValueHandler) { 603 channelsRef.off('value', channelsValueHandler); 604 channelsValueHandler = null; 605 } 606 if (channelOrderRef && channelOrderValueHandler) { 607 channelOrderRef.off('value', channelOrderValueHandler); 608 channelOrderValueHandler = null; 609 } 610 channelOrderListenerAttached = false; 611 channelListenerLatestChannels = {}; 612 channelListenerLatestIds = []; 613 } 614 615 function sortAndRenderChannels(orderSnapshot) { 616 const channelList = document.getElementById('channel-list'); 617 if (!channelList) return; 618 619 const channels = channelListenerLatestChannels; 620 const channelIds = [...channelListenerLatestIds]; 621 if (channelIds.length === 0) return; 622 623 let savedOrder = []; 624 if (orderSnapshot && orderSnapshot.exists()) { 625 const orderData = orderSnapshot.val(); 626 if (Array.isArray(orderData)) savedOrder = orderData; 627 else if (orderData && typeof orderData === 'object') savedOrder = Object.values(orderData); 628 } 629 630 const sortedChannels = [...channelIds].sort((a, b) => { 631 const aIndex = savedOrder.indexOf(a); 632 const bIndex = savedOrder.indexOf(b); 633 if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; 634 if (aIndex !== -1) return -1; 635 if (bIndex !== -1) return 1; 636 const aName = channels[a]?.metadata?.name?.toLowerCase() || a; 637 const bName = channels[b]?.metadata?.name?.toLowerCase() || b; 638 return aName.localeCompare(bName); 639 }); 640 641 const newOrder = [...savedOrder]; 642 sortedChannels.forEach(id => { if (!newOrder.includes(id)) newOrder.push(id); }); 643 const finalOrder = newOrder.filter(id => channelIds.includes(id)); 644 if (channelOrderRef && JSON.stringify(finalOrder) !== JSON.stringify(savedOrder)) { 645 channelOrderRef.set(finalOrder).catch(err => console.error('Error saving channel order:', err)); 646 } 647 renderChannels(sortedChannels, channels, channelList); 648 } 649 650 function applyChannelsSnapshotToDom() { 651 const channelList = document.getElementById('channel-list'); 652 if (!channelList) return false; 653 654 console.log('ð¡ Channels snapshot received:', channelListenerLatestIds.length + ' channels'); 655 656 const existingTextChannels = channelList.querySelectorAll('.channel-item[data-channel]:not([data-channel="voice-chat"])'); 657 existingTextChannels.forEach(item => item.remove()); 658 659 ensureVoiceChannelInList(channelList); 660 661 if (channelListenerLatestIds.length > 0) { 662 if (!channelOrderListenerAttached && channelOrderRef) { 663 channelOrderListenerAttached = true; 664 channelOrderValueHandler = (orderSnapshot) => sortAndRenderChannels(orderSnapshot); 665 channelOrderRef.on('value', channelOrderValueHandler, (err) => { 666 console.error('Error loading channel order from Firebase:', err); 667 sortAndRenderChannels(null); 668 }); 669 } else if (channelOrderRef) { 670 channelOrderRef.once('value', (orderSnapshot) => sortAndRenderChannels(orderSnapshot)); 671 } 672 } else { 673 console.warn('â ï¸ No channels found in Firebase. Creating default channels...'); 674 renderChannels([], {}, channelList); 675 } 676 return true; 677 } 678 679 function scheduleApplyChannelsToDom(attempt) {
680 if (applyChannelsSnapshotToDom()) return; 681 if (!chatroomActive) return; 682 if (attempt >= 90) { 683 console.warn('â ï¸ Channel list element not found after SPA swap retries'); 684 return; 685 } 686 requestAnimationFrame(function () { 687 scheduleApplyChannelsToDom(attempt + 1); 688 }); 689 } 690 691 function onChannelsSnapshot(snapshot) { 692 const channels = snapshot.val() || {}; 693 channelListenerLatestChannels = channels; 694 channelListenerLatestIds = Object.keys(channels).filter(id => channels[id] && channels[id].metadata); 695 696 if (!applyChannelsSnapshotToDom()) { 697 scheduleApplyChannelsToDom(0); 698 } 699 } 700 701 /** 702 * Setup channel listener (separated for clarity) 703 */ 704 function setupChannelListener() { 705 console.log('ð¡ Setting up channel listener...'); 706 teardownChannelListener(); 707 channelOrderRef = firebase.database().ref('chatroom/channelOrder'); 708 channelsValueHandler = onChannelsSnapshot; 709 channelsRef.on('value', channelsValueHandler); 710 } 711 712 /** 713 * Render channel items (helper function) 714 */ 715 function renderChannels(sortedChannels, channels, channelList) { 716 console.log(`ð¨ Rendering ${sortedChannels.length} channels:`, sortedChannels); 717 718 // Clear existing channels (but keep voice channel) 719 const existingChannels = channelList.querySelectorAll('.channel-item[data-channel]:not([data-channel="voice-chat"])'); 720 existingChannels.forEach(item => item.remove()); 721 722 if (sortedChannels.length === 0) { 723 console.log('â¹ï¸ No text channels to render'); 724 return; 725 } 726 727 // Create channel items 728 sortedChannels.forEach(channelId => { 729 const metadata = channels[channelId]?.metadata; 730 if (!metadata) { 731 console.warn(`â ï¸ Channel ${channelId} has no metadata, skipping`); 732 return; 733 } 734 const channelItem = document.createElement('div'); 735 channelItem.className = 'channel-item'; 736 737 // Only make draggable if user is moderator 738 const isModerator = window.ChatroomModerator && window.ChatroomModerator.checkIsModerator(); 739 if (isModerator) { 740 channelItem.draggable = true; 741 } 742 743 if (channelId === currentChannel) { 744 channelItem.classList.add('active'); 745 } 746 channelItem.dataset.channel = channelId; 747 channelItem.innerHTML = ` 748 <span class="channel-prefix">#</span> 749 <span class="channel-name">${escapeHtml(metadata.name)}</span> 750 <span class="channel-unread" style="display: none;"></span> 751 `; 752 channelItem.addEventListener('click', () => { 753 switchToChannel(channelId); 754 }); 755 756 // Drag and drop handlers (only for moderators) 757 if (isModerator) { 758 channelItem.addEventListener('dragstart', (e) => { 759 e.dataTransfer.effectAllowed = 'move'; 760 e.dataTransfer.setData('text/plain', channelId); 761 channelItem.classList.add('dragging'); 762 }); 763 764 channelItem.addEventListener('dragend', (e) => { 765 channelItem.classList.remove('dragging'); 766 // Remove any drag-over classes 767 document.querySelectorAll('.channel-item').forEach(item => { 768 item.classList.remove('drag-over'); 769 }); 770 }); 771 772 channelItem.addEventListener('dragover', (e) => { 773 e.preventDefault(); 774 e.dataTransfer.dropEffect = 'move'; 775 const draggingItem = document.querySelector('.channel-item.dragging'); 776 if (draggingItem && draggingItem !== channelItem) { 777 const rect = channelItem.getBoundingClientRect(); 778 const midpoint = rect.top + rect.height / 2; 779 if (e.clientY < midpoint) { 780 channelItem.classList.add('drag-over-top'); 781 channelItem.classList.remove('drag-over-bottom'); 782 } else { 783 channelItem.classList.add('drag-over-bottom'); 784 channelItem.classList.remove('drag-over-top'); 785 } 786 } 787 }); 788 789 channelItem.addEventListener('dragleave', (e) => { 790 channelItem.classList.remove('drag-over-top', 'drag-over-bottom'); 791 }); 792 793 channelItem.addEventListener('drop', (e) => { 794 e.preventDefault(); 795 channelItem.classList.remove('drag-over-top', 'drag-over-bottom'); 796 797 const draggedChannelId = e.dataTransfer.getData('text/plain'); 798 if (draggedChannelId && draggedChannelId !== channelId) { 799 // Get current order from Firebase 800 const orderRef = firebase.database().ref('chatroom/channelOrder'); 801 orderRef.once('value', (snapshot) => { 802 let currentOrder = []; 803 if (snapshot.exists()) { 804 currentOrder = snapshot.val() || []; 805 } 806 807 // If order is empty, build it from current DOM order 808 if (currentOrder.length === 0) { 809 const items = Array.from(channelList.querySelectorAll('.channel-item[data-channel]:not([data-channel="voice-chat"])')); 810 currentOrder = items.map(item => item.dataset.channel); 811 } 812 813 // Remove dragged item from order 814 currentOrder = currentOrder.filter(id => id !== draggedChannelId); 815 816 // Find target index 817 const targetIndex = currentOrder.indexOf(channelId); 818 819 // Determine insert position based on drag-over class 820 const rect = channelItem.getBoundingClientRect(); 821 const midpoint = rect.top + rect.height / 2; 822 const insertIndex = e.clientY < midpoint ? targetIndex : targetIndex + 1; 823 824 // Insert dragged item at new position 825 currentOrder.splice(insertIndex, 0, draggedChannelId); 826 827 // Save new order to Firebase (global for everyone) 828 orderRef.set(currentOrder).then(() => { 829 // Re-render channels to apply new order 830 setupChannels(); 831 }).catch(err => { 832 console.error('Error saving channel order to Firebase:', err); 833 alert('Error saving channel order. You may not have permission.'); 834 }); 835 }); 836 } 837 }); 838 } 839
840 channelList.appendChild(channelItem); 841 console.log(`â Rendered channel: ${channelId} (${metadata.name})`); 842 }); 843 } 844 845 /** 846 * Load a channel (lazy: only fetches from Firebase when not in recent cache) 847 */ 848 function loadChannel(channelId) { 849 // Clear DM state 850 if (window.ChatroomDMs && window.ChatroomDMs.clearCurrentDM) { 851 window.ChatroomDMs.clearCurrentDM(); 852 } 853 854 // Mark as read immediately when switching (fixes unread badge showing while loading) 855 callUnread('markAllAsRead', channelId); 856 callUnread('resetBanner'); 857 858 if (window.ChatroomMessageMeta && window.ChatroomMessageMeta.bindChannel) { 859 window.ChatroomMessageMeta.bindChannel(channelId); 860 } 861 862 // Hide loading screen as soon as chatroom shell is ready (don't wait for messages) 863 hideChatroomLoadingScreen(); 864 865 // Update input placeholder 866 const messageInput = document.getElementById('message-input'); 867 if (messageInput) { 868 messageInput.placeholder = `Message #${channelId}`; 869 } 870 871 // Remove old listener (from previous channel before we switch) 872 const previousChannel = currentChannel; 873 if (messageListener && messagesRef) { 874 messagesRef.child(previousChannel).child('messages').off('child_added', messageListener); 875 } 876 877 currentChannel = channelId; 878 879 // Save to localStorage for persistence 880 try { 881 localStorage.setItem('chatroom_current_channel', channelId); 882 } catch (e) { 883 console.error('Error saving channel state:', e); 884 } 885 886 // Update UI 887 updateChannelHeader(channelId); 888 updateActiveChannel(); 889 890 // Clear messages and reset load-older state 891 const messagesList = getMessagesList(); 892 if (messagesList) messagesList.innerHTML = ''; 893 894 loadingOlder = false; 895 hasMoreOlder = true; 896 897 // Remove previous scroll load indicator 898 const oldIndicator = document.getElementById('load-older-indicator'); 899 if (oldIndicator) oldIndicator.remove(); 900 901 // Load channel metadata (always - lightweight) 902 channelsRef.child(channelId).child('metadata').once('value', (snapshot) => { 903 const metadata = snapshot.val(); 904 if (metadata) { 905 const channelName = document.getElementById('current-channel-name'); 906 const channelTopic = document.getElementById('channel-topic'); 907 908 if (channelName) channelName.textContent = metadata.name; 909 if (channelTopic) channelTopic.textContent = metadata.topic || ''; 910 } 911 }); 912 913 // Check if we have a valid cache for this channel (opened within last 5 min) 914 const cached = channelCache.get(channelId); 915 const now = Date.now(); 916 const cacheValid = cached && (now - cached.lastFetchTime) < CHANNEL_CACHE_TTL; 917 918 if (cacheValid && cached.messages.length > 0) { 919 // Use cache - no Firebase fetch! Display cached messages and set up listener for new ones only 920 displayMessagesFromArray(cached.messages); 921 callUnread('markAllAsRead', channelId); 922 setupMessageListener(channelId, cached.newestTimestamp); 923 if (window.ChatroomSettings?.getSetting?.('autoScrollNewMessages') !== false) { 924 scrollToBottom(); 925 [300, 800, 1500, 2500].forEach(d => setTimeout(() => { if (window.ChatroomSettings?.getSetting?.('autoScrollNewMessages') !== false) scrollToBottom(); }, d)); 926 } 927 setupScrollToLoadOlder(channelId, cached.oldestTimestamp); 928 } else { 929 // No cache or stale - fetch from Firebase 930 fetchAndDisplayChannelMessages(channelId); 931 } 932 } 933 934 /** 935 * Fetch initial messages from Firebase and display 936 */ 937 function fetchAndDisplayChannelMessages(channelId) { 938 let lastLoadedTimestamp = 0; 939 const channelMessagesRef = messagesRef.child(channelId).child('messages'); 940 941 channelMessagesRef.orderByChild('timestamp').limitToLast(INITIAL_MESSAGE_LIMIT).once('value', (snapshot) => { 942 const messages = snapshot.val(); 943 let oldestTimestamp = 0; 944 if (messages) { 945 const messagesArray = Object.entries(messages || {}) 946 .map(([id, m]) => ({ ...m, id })) // Use Firebase key as id (required for reaction paths) 947 .filter(m => !isMessageEmpty(m)); 948 if (window.ChatroomMessageGrouping && typeof window.ChatroomMessageGrouping.compareMessagesByTime === 'function') { 949 messagesArray.sort(window.ChatroomMessageGrouping.compareMessagesByTime); 950 } else { 951 messagesArray.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); 952 } 953 oldestTimestamp = messagesArray.length > 0 954 ? Math.min(...messagesArray.map(m => getMessageSortTimestamp(m))) 955 : 0; 956 const newestTimestamp = messagesArray.length > 0 957 ? Math.max(...messagesArray.map(m => getMessageSortTimestamp(m))) 958 : 0; 959 lastLoadedTimestamp = newestTimestamp; 960 961 displayMessagesFromArray(messagesArray); 962 963 // Store in cache for lazy re-use 964 channelCache.set(channelId, { 965 messages: messagesArray, 966 lastFetchTime: Date.now(), 967 oldestTimestamp, 968 newestTimestamp 969 }); 970 971 // Mark as read 972 callUnread('markAllAsRead', channelId); 973 messagesArray.forEach(function (m) { 974 callUnread('markMessageAsRead', channelId, m.id); 975 }); 976 } else { 977 showWelcomeMessage(); 978 channelCache.set(channelId, { 979 messages: [], 980 lastFetchTime: Date.now(), 981 oldestTimestamp: 0, 982 newestTimestamp: 0 983 }); 984 } 985 986 setupMessageListener(channelId, lastLoadedTimestamp); 987 setupScrollToLoadOlder(channelId, oldestTimestamp); 988 if (window.ChatroomSettings?.getSetting?.('autoScrollNewMessages') !== false) { 989 scrollToBottom();
990 [300, 800, 1500, 2500].forEach(d => setTimeout(() => { if (window.ChatroomSettings?.getSetting?.('autoScrollNewMessages') !== false) scrollToBottom(); }, d)); 991 } 992 }); 993 } 994 995 /** 996 * Hide chatroom loading screen (called when chatroom shell is ready) 997 */ 998 function hideChatroomLoadingScreen() { 999 if (window.ChatroomLoadingScreen && typeof window.ChatroomLoadingScreen.hide === 'function') { 1000 window.ChatroomLoadingScreen.hide(); 1001 } 1002 } 1003 1004 /** 1005 * Display messages from a plain array (for cache restore) 1006 */ 1007 function displayMessagesFromArray(messagesArray) { 1008 if (!messagesArray || messagesArray.length === 0) return; 1009 displayMessagesArray(messagesArray); 1010 } 1011 1012 function sortMessagesForDisplay(messagesArray) { 1013 const list = (messagesArray || []).slice(); 1014 if (window.ChatroomMessageGrouping && typeof window.ChatroomMessageGrouping.compareMessagesByTime === 'function') { 1015 list.sort(window.ChatroomMessageGrouping.compareMessagesByTime); 1016 return list; 1017 } 1018 return list.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); 1019 } 1020 1021 function displayMessagesArray(messagesArray) { 1022 const messagesList = getMessagesList(); 1023 if (!messagesList) return; 1024 1025 messagesList.innerHTML = ''; 1026 suppressMessageAnimations = true; 1027 1028 const prepared = window.ChatroomMessageGrouping && typeof window.ChatroomMessageGrouping.prepareMessages === 'function' 1029 ? window.ChatroomMessageGrouping.prepareMessages(messagesArray) 1030 : (messagesArray || []).slice(); 1031 const sorted = sortMessagesForDisplay(prepared).filter(m => !isMessageEmpty(m)); 1032 1033 sorted.forEach((message) => { 1034 const forDisplay = window.ChatroomMessageGrouping && typeof window.ChatroomMessageGrouping.stripClientGrouping === 'function' 1035 ? window.ChatroomMessageGrouping.stripClientGrouping(message) 1036 : Object.assign({}, message); 1037 if (forDisplay && typeof forDisplay === 'object') { 1038 delete forDisplay.grouped; 1039 } 1040 addMessage(forDisplay); 1041 callUnread('markMessageAsRead', currentChannel, message.id); 1042 }); 1043 1044 suppressMessageAnimations = false; 1045 1046 syncMessageGrouping(messagesList); 1047 scheduleMessageGroupingSync(messagesList); 1048 1049 callUnread('markAllAsRead', currentChannel); 1050 1051 if (window.ChatroomUnreadCount) { 1052 if (currentUsername) { 1053 const notifiedKey = `chatroom_notified_${currentUsername}`; 1054 try { 1055 const notified = JSON.parse(localStorage.getItem(notifiedKey) || '{}'); 1056 notified[`channel_${currentChannel}_last_viewed`] = Date.now(); 1057 localStorage.setItem(notifiedKey, JSON.stringify(notified)); 1058 } catch (e) { 1059 console.error('Error marking channel notifications as read:', e); 1060 } 1061 } 1062 } 1063 1064 setTimeout(() => { 1065 if (window.ChatroomProfilePicture && window.ChatroomProfilePicture.updateAllAvatars) { 1066 window.ChatroomProfilePicture.updateAllAvatars(); 1067 } 1068 }, 100); 1069 1070 if (window.ChatroomSettings?.getSetting?.('autoScrollNewMessages') !== false) { 1071 scrollToBottom(); 1072 [300, 800, 1500, 2500].forEach(delay => { 1073 setTimeout(() => { 1074 if (window.ChatroomSettings?.getSetting?.('autoScrollNewMessages') !== false) { 1075 scrollToBottom(); 1076 } 1077 }, delay); 1078 }); 1079 } 1080 } 1081 1082 /** 1083 * Re-apply message grouping / layout after settings change (no full page refresh). 1084 */ 1085 function refreshMessagesLayout() { 1086 const messagesList = getMessagesList(); 1087 if (!messagesList) return; 1088 1089 const scrollContainer = document.getElementById('messages-container'); 1090 const scrollTop = scrollContainer ? scrollContainer.scrollTop : 0;
1091 const atBottom = scrollContainer 1092 ? scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 80 1093 : false; 1094 1095 syncMessageGrouping(messagesList); 1096 1097 if (scrollContainer) { 1098 if (atBottom && window.ChatroomSettings?.getSetting?.('autoScrollNewMessages') !== false) { 1099 scrollToBottom(); 1100 } else { 1101 scrollContainer.scrollTop = scrollTop; 1102 } 1103 } 1104 } 1105 1106 /** 1107 * Set up real-time listener for new messages only (after given timestamp) 1108 */ 1109 function setupMessageListener(channelId, afterTimestamp) { 1110 const channelMessagesRef = messagesRef.child(channelId).child('messages'); 1111 let lastLoadedTimestamp = afterTimestamp || 0; 1112 1113 messageListener = channelMessagesRef.on('child_added', (snapshot) => { 1114 if (!snapshot.exists()) return; 1115 const message = snapshot.val(); 1116 if (!message) return; 1117 message.id = snapshot.key; // Firebase key required for reaction paths 1118 if (isMessageEmpty(message)) return; 1119 const messageSortTs = getMessageSortTimestamp(message) || Number(message.timestamp) || 0; 1120 if (messageSortTs && messageSortTs <= lastLoadedTimestamp) return; 1121 1122 // Remove optimistic message when real one arrives (avoid duplicate) 1123 const messagesList = getMessagesList(); 1124 if (messagesList && message.username === currentUsername) { 1125 const pendings = messagesList.querySelectorAll('[data-message-id^="pending_"]'); 1126 let bestMatch = null; 1127 let bestDiff = Infinity; 1128 const msgTs = message.timestamp || 0;
1129 pendings.forEach((el) => { 1130 const ts = parseInt(el.dataset.messageId?.replace('pending_', '') || '0', 10); 1131 const diff = Math.abs(ts - msgTs); 1132 if (diff < 5000 && diff < bestDiff) { 1133 bestDiff = diff; 1134 bestMatch = el; 1135 } 1136 }); 1137 if (bestMatch) bestMatch.remove(); 1138 } 1139 1140 const fromOther = message.username !== currentUsername; 1141 if (fromOther) { 1142 callUnread('onIncomingMessage', true); 1143 } 1144 if (!fromOther || callUnread('isNearBottom')) { 1145 callUnread('markMessageAsRead', channelId, message.id); 1146 } 1147 if (window.ChatroomMessageGrouping) { 1148 const headEl = window.ChatroomMessageGrouping.getGroupHeadElement(messagesList); 1149 const headMeta = window.ChatroomMessageGrouping.getMetaFromElement(headEl); 1150 message.grouped = window.ChatroomMessageGrouping.shouldGroupWithHead(headMeta, message); 1151 } 1152 1153 addMessage(message); 1154 lastLoadedTimestamp = Math.max(lastLoadedTimestamp, getMessageSortTimestamp(message), message.timestamp || 0); 1155 1156 // Update cache with new message (never persist client-only grouped flag) 1157 const cached = channelCache.get(channelId); 1158 if (cached) { 1159 const forCache = window.ChatroomMessageGrouping && typeof window.ChatroomMessageGrouping.stripClientGrouping === 'function' 1160 ? window.ChatroomMessageGrouping.stripClientGrouping(message) 1161 : Object.assign({}, message); 1162 cached.messages.push(forCache); 1163 cached.newestTimestamp = lastLoadedTimestamp; 1164 } 1165 }); 1166 } 1167 1168 /** 1169 * Set up scroll-to-load-older: when user scrolls near top, fetch older messages 1170 */ 1171 function setupScrollToLoadOlder(channelId, currentOldestTimestamp) { 1172 const container = document.getElementById('messages-container'); 1173 if (!container) return; 1174 1175 // Remove previous handler 1176 if (scrollLoadHandler) { 1177 container.removeEventListener('scroll', scrollLoadHandler); 1178 } 1179 1180 let oldestTimestamp = currentOldestTimestamp; 1181 1182 scrollLoadHandler = () => { 1183 if (window.ChatroomDMs && window.ChatroomDMs.getCurrentDM()) return; // Don't run when viewing DM 1184 if (currentChannel !== channelId) return; 1185 if (loadingOlder || !hasMoreOlder) return; 1186 if (container.scrollTop > SCROLL_LOAD_THRESHOLD) return; 1187 1188 loadingOlder = true; 1189 showLoadOlderIndicator(true); 1190 1191 const channelMessagesRef = messagesRef.child(channelId).child('messages'); 1192 const query = channelMessagesRef 1193 .orderByChild('timestamp') 1194 .endAt(oldestTimestamp - 1) 1195 .limitToLast(LOAD_OLDER_BATCH_SIZE); 1196 1197 query.once('value', (snapshot) => { 1198 const messages = snapshot.val(); 1199 showLoadOlderIndicator(false); 1200 loadingOlder = false; 1201 1202 if (!messages || Object.keys(messages).length === 0) { 1203 hasMoreOlder = false; 1204 return; 1205 } 1206 1207 const messagesArray = Object.entries(messages || {}) 1208 .map(([id, m]) => ({ ...m, id })) // Use Firebase key as id (required for reaction paths) 1209 .filter(m => !isMessageEmpty(m)); 1210 if (window.ChatroomMessageGrouping && typeof window.ChatroomMessageGrouping.compareMessagesByTime === 'function') { 1211 messagesArray.sort(window.ChatroomMessageGrouping.compareMessagesByTime); 1212 } else { 1213 messagesArray.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); 1214 } 1215 const prevScrollHeight = container.scrollHeight; 1216 const prevScrollTop = container.scrollTop; 1217 1218 // Prepend older messages (oldest first for correct order when prepending) 1219 const messagesList = document.getElementById('messages-list'); 1220 messagesArray.forEach((message) => { 1221 const forDisplay = window.ChatroomMessageGrouping && typeof window.ChatroomMessageGrouping.stripClientGrouping === 'function' 1222 ? window.ChatroomMessageGrouping.stripClientGrouping(message) 1223 : Object.assign({}, message); 1224 if (forDisplay && typeof forDisplay === 'object') { 1225 delete forDisplay.grouped; 1226 } 1227 prependMessage(forDisplay, messagesList); 1228 }); 1229 if (window.ChatroomMessageGrouping && messagesList) { 1230 window.ChatroomMessageGrouping.reapplyDom(messagesList); 1231 } 1232 1233 // Preserve scroll position (user stays at same visual position) 1234 requestAnimationFrame(() => { 1235 const newScrollHeight = container.scrollHeight; 1236 container.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight); 1237 }); 1238 1239 oldestTimestamp = Math.min(...messagesArray.map(m => m.timestamp || 0)); 1240 if (messagesArray.length < LOAD_OLDER_BATCH_SIZE) { 1241 hasMoreOlder = false;
1242 } 1243 1244 // Update cache 1245 const cached = channelCache.get(channelId); 1246 if (cached) { 1247 cached.messages = [...messagesArray, ...cached.messages]; 1248 cached.oldestTimestamp = oldestTimestamp; 1249 } 1250 }).catch(err => { 1251 console.error('Error loading older messages:', err); 1252 showLoadOlderIndicator(false); 1253 loadingOlder = false; 1254 }); 1255 }; 1256 1257 container.addEventListener('scroll', scrollLoadHandler); 1258 } 1259 1260 /** 1261 * Prepend a message to the top of the list 1262 */ 1263 function prependMessage(message, messagesList) { 1264 if (!messagesList) return; 1265 const existing = messagesList.querySelector(`[data-message-id="${message.id}"]`); 1266 if (existing) return; 1267 1268 const safeUsername = message.username != null && String(message.username).trim() ? String(message.username).trim() : 'Someone'; 1269 const safeMessage = { ...message, username: safeUsername, text: message.text != null ? String(message.text) : '' }; 1270 1271 const isMod = window.ChatroomModerator && window.ChatroomModerator.isUserModerator(safeUsername); 1272 const modBadge = isMod ? '<span class="mod-badge" title="Moderator">ð</span>' : ''; 1273 1274 let messageDiv; 1275 if (window.ChatroomMessageComponent) { 1276 messageDiv = window.ChatroomMessageComponent.create(safeMessage, { 1277 grouped: message.grouped, 1278 showAvatar: !message.grouped, 1279 showTimestamp: true, 1280 showUsername: !message.grouped 1281 }); 1282 } else { 1283 messageDiv = document.createElement('div'); 1284 messageDiv.className = 'message' + (message.grouped ? ' grouped' : ''); 1285 const prependText = message.text != null ? String(message.text) : ''; 1286 if (prependText && !(message.images && message.images.length > 0) && window.ChatroomMessageFormatter?.shouldScaleEmojiMessage?.(prependText)) { 1287 messageDiv.classList.add('message-emoji-only'); 1288 } 1289 messageDiv.dataset.messageId = message.id; 1290 const profilePicture = window.ChatroomProfilePicture && window.ChatroomProfilePicture.getProfilePicture(safeUsername); 1291 const avatarDisplay = profilePicture 1292 ? `<img src="${escapeHtml(profilePicture)}" alt="${escapeHtml(safeUsername)}" class="profile-picture-avatar">` 1293 : getAvatar(safeUsername); 1294 let imagesHTML = ''; 1295 if (message.images && message.images.length > 0) { 1296 imagesHTML = '<div class="message-images">'; 1297 message.images.forEach((image, i) => { 1298 const imageData = typeof image === 'string' ? image : (image.data || image); 1299 imagesHTML += `<div class="message-image-container"><img src="${imageData}" alt="Image ${i + 1}" class="message-image" loading="lazy"><a href="${imageData}" target="_blank" rel="noopener noreferrer" class="message-image-link" title="Open">ð</a></div>`; 1300 }); 1301 imagesHTML += '</div>'; 1302 } 1303 const replyHTML = message.replyTo && window.ChatroomReplies 1304 ? window.ChatroomReplies.buildReplyReferenceHTML(message.replyTo) 1305 : ''; 1306 messageDiv.innerHTML = ` 1307 <div class="message-avatar">${avatarDisplay}</div> 1308 <div class="message-content"> 1309 <div class="message-header"> 1310 <span class="message-username">${modBadge}${escapeHtml(safeUsername)}</span> 1311 ${window.ChatroomMessageMeta && window.ChatroomMessageMeta.editedLabelHtml ? window.ChatroomMessageMeta.editedLabelHtml(message) : ''} 1312 <span class="message-time">${window.ChatroomMessageFormatter ? window.ChatroomMessageFormatter.formatTimestamp(message.timestamp) : formatTime(message.timestamp)}</span> 1313 </div> 1314 ${replyHTML} 1315 ${message.text ? `<div class="message-text">${window.ChatroomMessageFormatter ? window.ChatroomMessageFormatter.formatMessage(String(message.text)) : escapeHtml(String(message.text))}</div>` : ''} 1316 ${imagesHTML} 1317 <div class="message-reactions"></div> 1318 </div> 1319 `; 1320 } 1321 1322 if (window.ChatroomMessageMeta && window.ChatroomMessageMeta.applyEditedLabel) { 1323 window.ChatroomMessageMeta.applyEditedLabel(messageDiv, message); 1324 } 1325 if (window.ChatroomMessageGrouping && window.ChatroomMessageGrouping.applyMetaToElement) { 1326 window.ChatroomMessageGrouping.applyMetaToElement(messageDiv, message); 1327 window.ChatroomMessageGrouping.applyGroupedPresentation( 1328 messageDiv, 1329 !!message.grouped, 1330 window.ChatroomSettings?.getSetting('showAvatars') !== false, 1331 window.ChatroomSettings?.getSetting('showTimestamps') !== false 1332 ); 1333 } 1334 1335 messagesList.insertBefore(messageDiv, messagesList.firstChild); 1336 1337 if (window.ChatroomProfilePicture && window.ChatroomProfilePicture.updateAllAvatars) { 1338 setTimeout(() => window.ChatroomProfilePicture.updateAllAvatars(), 50); 1339 } 1340 if (window.ChatroomReactions && window.ChatroomReactions.initMessageReactions) { 1341 window.ChatroomReactions.initMessageReactions(messageDiv); 1342 } 1343 if (window.ChatroomLinkEmbeds?.initMessageEmbeds) { 1344 window.ChatroomLinkEmbeds.initMessageEmbeds(messageDiv); 1345 } 1346 if (window.ChatroomMessageActions && window.ChatroomMessageActions.setupMessageHover) { 1347 window.ChatroomMessageActions.setupMessageHover(messageDiv); 1348 } 1349 } 1350 1351 /** 1352 * Show/hide "Loading older messages..." indicator at top 1353 */ 1354 function showLoadOlderIndicator(show) { 1355 let indicator = document.getElementById('load-older-indicator'); 1356 if (show) { 1357 if (!indicator) { 1358 indicator = document.createElement('div'); 1359 indicator.id = 'load-older-indicator'; 1360 indicator.className = 'load-older-indicator'; 1361 indicator.textContent = 'Loading older messages...'; 1362 const messagesList = document.getElementById('messages-list'); 1363 if (messagesList) { 1364 messagesList.insertBefore(indicator, messagesList.firstChild); 1365 } 1366 } 1367 indicator.style.display = 'block'; 1368 } else if (indicator) { 1369 indicator.style.display = 'none'; 1370 } 1371 } 1372 1373 /**
1374 * Show welcome message 1375 */ 1376 function showWelcomeMessage() { 1377 const messagesList = document.getElementById('messages-list'); 1378 if (!messagesList) return; 1379 1380 const welcomeDiv = document.createElement('div'); 1381 welcomeDiv.className = 'message'; 1382 welcomeDiv.style.opacity = '0.7'; 1383 welcomeDiv.innerHTML = ` 1384 <div class="message-avatar">ð¾</div> 1385 <div class="message-content"> 1386 <div class="message-header"> 1387 <span class="message-username">System</span> 1388 <span class="message-time">now</span> 1389 </div> 1390 <div class="message-text">Welcome to ${currentChannel}! Start chatting below â¨</div> 1391 </div> 1392 `; 1393 messagesList.appendChild(welcomeDiv); 1394 } 1395 1396 /** 1397 * Update channel header 1398 */ 1399 function updateChannelHeader(channelId) { 1400 const channelIcon = document.getElementById('channel-icon-header'); 1401 if (channelIcon) channelIcon.textContent = '#'; 1402 1403 const channelName = document.getElementById('current-channel-name'); 1404 if (channelName && channelId) { 1405 channelName.textContent = channelId; 1406 } 1407 1408 const channelTopic = document.getElementById('channel-topic'); 1409 if (channelTopic) { 1410 // Try to get topic from channel metadata 1411 channelsRef.child(channelId).child('metadata').once('value', (snapshot) => { 1412 const metadata = snapshot.val(); 1413 if (metadata && metadata.topic) { 1414 channelTopic.textContent = metadata.topic; 1415 } else { 1416 channelTopic.textContent = ''; 1417 } 1418 }); 1419 } 1420 } 1421 1422 /** 1423 * Update active channel in sidebar 1424 */ 1425 function updateActiveChannel() { 1426 document.querySelectorAll('.channel-item').forEach(item => { 1427 item.classList.remove('active'); 1428 if (item.dataset.channel === currentChannel) { 1429 item.classList.add('active'); 1430 } 1431 }); 1432 1433 // Also clear DM active state when switching to channel 1434 document.querySelectorAll('.dm-item').forEach(item => { 1435 item.classList.remove('active'); 1436 }); 1437 } 1438 1439 /** 1440 * Setup message input 1441 */ 1442 function setupMessageInput() { 1443 const messageInput = document.getElementById('message-input'); 1444 const inputWrapper = messageInput ? messageInput.closest('.input-wrapper') : null; 1445 1446 if (!messageInput) return; 1447 1448 // Create emoji preview element 1449 const emojiPreview = document.createElement('div'); 1450 emojiPreview.className = 'emoji-preview'; 1451 if (inputWrapper) { 1452 inputWrapper.appendChild(emojiPreview); 1453 } 1454 1455 // Emoji map for shortcodes 1456 const emojiMap = { 1457 'smile': 'ð', 1458 'heart': 'â¤ï¸', 1459 'laughing': 'ð', 1460 'fire': 'ð¥', 1461 'thumbsup': 'ð', 1462 'thumbsdown': 'ð', 1463 'party': 'ð', 1464 'star': 'â', 1465 'sparkles': 'â¨', 1466 'heart_eyes': 'ð', 1467 'kiss': 'ð', 1468 'wink': 'ð', 1469 'cool': 'ð', 1470 'sad': 'ð¢', 1471 'angry': 'ð ', 1472 'confused': 'ð', 1473 'surprised': 'ð®', 1474 'tired': 'ð´', 1475 'poop': 'ð©', 1476 'clap': 'ð', 1477 'pray': 'ð', 1478 'ok_hand': 'ð', 1479 'v': 'âï¸', 1480 'wave': 'ð', 1481 'muscle': 'ðª', 1482 '100': 'ð¯', 1483 'rainbow': 'ð', 1484 'unicorn': 'ð¦', 1485 'rocket': 'ð', 1486 'tada': 'ð', 1487 'confetti': 'ð', 1488 'balloon': 'ð', 1489 'gift': 'ð' 1490 }; 1491 1492 // Show emoji preview for shortcodes 1493 function updateEmojiPreview() { 1494 const text = messageInput.value; 1495 const cursorPos = messageInput.selectionStart; 1496 1497 // Find the last :shortcode: before cursor 1498 const beforeCursor = text.substring(0, cursorPos); 1499 const match = beforeCursor.match(/:([a-z_]+):?$/); 1500 1501 if (match) { 1502 const shortcode = match[1].toLowerCase(); 1503 const emoji = emojiMap[shortcode]; 1504 if (emoji) { 1505 emojiPreview.textContent = emoji; 1506 emojiPreview.classList.add('show'); 1507 } else { 1508 emojiPreview.classList.remove('show'); 1509 } 1510 } else { 1511 emojiPreview.classList.remove('show'); 1512 } 1513 } 1514 1515 const SINGLE_LINE_HEIGHT = 28; 1516 1517 function syncMessageInputLayout() { 1518 messageInput.style.height = 'auto'; 1519 messageInput.style.height = messageInput.scrollHeight + 'px'; 1520 if (inputWrapper) { 1521 inputWrapper.classList.toggle('is-multiline', messageInput.scrollHeight > SINGLE_LINE_HEIGHT + 4); 1522 } 1523 bumpMessagesForComposer(); 1524 } 1525 1526 // Auto-resize textarea 1527 messageInput.addEventListener('input', () => { 1528 syncMessageInputLayout(); 1529 updateEmojiPreview(); 1530 }); 1531 1532 syncMessageInputLayout(); 1533 1534 // Update preview on cursor movement 1535 messageInput.addEventListener('keyup', updateEmojiPreview); 1536 messageInput.addEventListener('click', updateEmojiPreview); 1537 1538 // Send on Enter (Shift+Enter for new line) - respects enterToSend setting 1539 messageInput.addEventListener('keydown', (e) => { 1540 if (e.key !== 'Enter') return; 1541 const enterToSend = window.ChatroomSettings?.getSetting?.('enterToSend') !== false;
1542 const shouldSend = enterToSend ? !e.shiftKey : e.shiftKey; 1543 if (shouldSend) { 1544 e.preventDefault(); 1545 sendMessage(); 1546 } 1547 }); 1548 1549 // Setup emoji button in input 1550 const emojiBtn = document.querySelector('.input-emoji-btn'); 1551 if (emojiBtn) { 1552 emojiBtn.addEventListener('click', (e) => { 1553 e.preventDefault(); 1554 e.stopPropagation(); 1555 if (window.EmojiPicker) { 1556 const anchor = document.querySelector('.message-input-container') || messageInput; 1557 window.EmojiPicker.show((emoji) => { 1558 insertEmojiAtCursor(messageInput, emoji); 1559 }, anchor); 1560 } else if (window.ChatroomReactions) { 1561 window.ChatroomReactions.showReactionPicker(null, (emoji) => { 1562 if (emoji) { 1563 insertEmojiAtCursor(messageInput, emoji); 1564 } 1565 }, true); 1566 } 1567 }); 1568 } 1569 1570 // Setup GIF button - open KLIPY picker (same position as emoji picker) 1571 const gifBtn = document.querySelector('.input-gif-btn'); 1572 if (gifBtn && window.KlipyPicker) { 1573 gifBtn.addEventListener('click', (e) => { 1574 e.preventDefault(); 1575 e.stopPropagation(); 1576 const anchor = document.querySelector('.message-input-container') || messageInput; 1577 window.KlipyPicker.show((url) => { 1578 if (window.ChatroomImageAttachments?.addImageFromUrl) { 1579 window.ChatroomImageAttachments.addImageFromUrl(url); 1580 sendMessage(); 1581 } 1582 }, anchor); 1583 }); 1584 } 1585 1586 } 1587 1588 1589 /** 1590 * Insert emoji at cursor position in textarea 1591 */ 1592 function insertEmojiAtCursor(textarea, emoji) { 1593 const start = textarea.selectionStart; 1594 const end = textarea.selectionEnd; 1595 const text = textarea.value; 1596 1597 textarea.value = text.substring(0, start) + emoji + text.substring(end); 1598 textarea.selectionStart = textarea.selectionEnd = start + emoji.length; 1599 textarea.focus(); 1600 1601 // Trigger input event to update preview and resize 1602 textarea.dispatchEvent(new Event('input')); 1603 } 1604 1605 /** 1606 * Banned word strike system - escalating punishments 1607 * Synced to Firebase so admin can view/clear strikes remotely. 1608 */ 1609 let strikeSyncRef = null; 1610 let strikeSyncListener = null; 1611 let strikeSyncUsername = null; 1612 1613 function emptyStrikeData() { 1614 return { count: 0, firstStrikeTime: 0, timeoutUntil: 0, updatedAt: 0 }; 1615 } 1616 1617 function normalizeStrikeData(data) { 1618 if (!data || typeof data !== 'object') return emptyStrikeData(); 1619 return { 1620 count: data.count || 0, 1621 firstStrikeTime: data.firstStrikeTime || 0, 1622 timeoutUntil: data.timeoutUntil || 0, 1623 updatedAt: data.updatedAt || 0, 1624 lastMatchedRule: data.lastMatchedRule || '', 1625 lastMessagePreview: data.lastMessagePreview || '' 1626 }; 1627 } 1628 1629 function getBannedWordStrikeData() { 1630 try { 1631 const raw = localStorage.getItem('puppy_chatroom_bannedword'); 1632 if (!raw) return emptyStrikeData(); 1633 return normalizeStrikeData(JSON.parse(raw)); 1634 } catch (e) { 1635 return emptyStrikeData(); 1636 } 1637 } 1638 1639 function writeStrikeDataLocal(data) { 1640 try { 1641 localStorage.setItem('puppy_chatroom_bannedword', JSON.stringify(normalizeStrikeData(data))); 1642 } catch (e) { 1643 console.error('Could not save strike data:', e); 1644 } 1645 } 1646 1647 function syncStrikesToFirebase(data, meta) { 1648 if (!currentUsername || String(currentUsername).indexOf('guest_') === 0) return; 1649 if (typeof firebase === 'undefined' || !firebase.database) return; 1650 1651 const normalized = normalizeStrikeData(data); 1652 const ref = firebase.database().ref('chatroom/moderationStrikes').child(currentUsername); 1653 1654 if (!normalized.count) { 1655 ref.remove().catch(function (err) {
1656 console.warn('Could not clear remote strike data:', err); 1657 }); 1658 return; 1659 } 1660 1661 const payload = { 1662 count: normalized.count, 1663 firstStrikeTime: normalized.firstStrikeTime, 1664 timeoutUntil: normalized.timeoutUntil, 1665 updatedAt: Date.now() 1666 }; 1667 if (meta && meta.matchedRule) payload.lastMatchedRule = String(meta.matchedRule).slice(0, 80); 1668 if (meta && meta.messagePreview) payload.lastMessagePreview = String(meta.messagePreview).slice(0, 240); 1669 1670 ref.set(payload).catch(function (err) { 1671 console.warn('Could not sync strike data to Firebase:', err); 1672 }); 1673 } 1674 1675 function setBannedWordStrikeData(data, meta) { 1676 const normalized = normalizeStrikeData(Object.assign({}, data, { updatedAt: Date.now() })); 1677 writeStrikeDataLocal(normalized); 1678 syncStrikesToFirebase(normalized, meta); 1679 } 1680 1681 function applyRemoteStrikeData(remote) { 1682 const normalized = normalizeStrikeData(remote); 1683 writeStrikeDataLocal(normalized); 1684 } 1685 1686 function teardownStrikeSync() { 1687 if (strikeSyncRef && strikeSyncListener) { 1688 strikeSyncRef.off('value', strikeSyncListener); 1689 } 1690 strikeSyncRef = null; 1691 strikeSyncListener = null; 1692 strikeSyncUsername = null; 1693 } 1694 1695 function setupStrikeSync(username) { 1696 if (!username || String(username).indexOf('guest_') === 0) return; 1697 if (typeof firebase === 'undefined' || !firebase.database) return; 1698 if (strikeSyncUsername === username && strikeSyncRef) return; 1699 1700 teardownStrikeSync(); 1701 strikeSyncUsername = username; 1702 strikeSyncRef = firebase.database().ref('chatroom/moderationStrikes').child(username); 1703 1704 strikeSyncListener = function (snapshot) { 1705 if (!snapshot.exists()) { 1706 applyRemoteStrikeData(emptyStrikeData()); 1707 return; 1708 } 1709 const remote = normalizeStrikeData(snapshot.val()); 1710 const local = getBannedWordStrikeData(); 1711 if ((remote.updatedAt || 0) >= (local.updatedAt || 0)) { 1712 applyRemoteStrikeData(remote); 1713 } 1714 }; 1715 1716 strikeSyncRef.on('value', strikeSyncListener); 1717 1718 strikeSyncRef.once('value').then(function (snapshot) { 1719 const local = getBannedWordStrikeData(); 1720 if (!snapshot.exists()) { 1721 if (local.count > 0) syncStrikesToFirebase(local); 1722 return; 1723 } 1724 const remote = normalizeStrikeData(snapshot.val()); 1725 if ((remote.updatedAt || 0) >= (local.updatedAt || 0)) { 1726 applyRemoteStrikeData(remote); 1727 } else if (local.count > 0) { 1728 syncStrikesToFirebase(local); 1729 } 1730 }).catch(function (err) { 1731 console.warn('Could not load remote strike data:', err); 1732 }); 1733 } 1734 1735 function resetStrikesIfExpired() { 1736 const data = getBannedWordStrikeData(); 1737 if (data.count === 0) return; 1738 const now = Date.now(); 1739 if (data.firstStrikeTime && (now - data.firstStrikeTime) > STRIKE_RESET_MS) { 1740 setBannedWordStrikeData(emptyStrikeData()); 1741 return true; 1742 } 1743 return false; 1744 } 1745 1746 /** Returns { blocked, timeoutUntil, strikeCount, shouldBanIP } */ 1747 function recordBannedWordStrike(meta) { 1748 resetStrikesIfExpired(); 1749 const data = getBannedWordStrikeData(); 1750 const now = Date.now(); 1751 1752 // Still in timeout from previous strike? Don't increment, just block 1753 if (data.timeoutUntil && now < data.timeoutUntil) { 1754 return { blocked: true, timeoutUntil: data.timeoutUntil, strikeCount: data.count, shouldBanIP: false }; 1755 } 1756 1757 const newCount = data.count + 1; 1758 const firstStrikeTime = data.firstStrikeTime || now; 1759 1760 if (newCount === 1) { 1761 const timeoutUntil = now + STRIKE1_TIMEOUT_MS; 1762 setBannedWordStrikeData({ count: 1, firstStrikeTime, timeoutUntil }, meta);
1763 return { blocked: false, timeoutUntil, strikeCount: 1, shouldBanIP: false }; 1764 } 1765 if (newCount === 2) { 1766 const timeoutUntil = now + STRIKE2_TIMEOUT_MS; 1767 setBannedWordStrikeData({ count: 2, firstStrikeTime, timeoutUntil }, meta); 1768 return { blocked: false, timeoutUntil, strikeCount: 2, shouldBanIP: false }; 1769 } 1770 // 3rd strike (within 1 hour of first) - ban IP and refresh 1771 if (newCount >= 3 && (now - firstStrikeTime) <= STRIKE_RESET_MS) { 1772 setBannedWordStrikeData({ count: 3, firstStrikeTime, timeoutUntil: now + 999999 }, meta); 1773 return { blocked: false, timeoutUntil: now, strikeCount: 3, shouldBanIP: true }; 1774 } 1775 // 3rd strike but outside 1hr - treat as fresh 1st strike 1776 const timeoutUntil = now + STRIKE1_TIMEOUT_MS; 1777 setBannedWordStrikeData({ count: 1, firstStrikeTime: now, timeoutUntil }, meta); 1778 return { blocked: false, timeoutUntil, strikeCount: 1, shouldBanIP: false }; 1779 } 1780 1781 function canSendFromStrikeCheck() { 1782 resetStrikesIfExpired(); 1783 const data = getBannedWordStrikeData(); 1784 if (!data.timeoutUntil) return true; 1785 return Date.now() >= data.timeoutUntil; 1786 } 1787 1788 function getRemainingTimeoutMs() { 1789 const data = getBannedWordStrikeData(); 1790 if (!data.timeoutUntil) return 0; 1791 const remaining = data.timeoutUntil - Date.now(); 1792 return Math.max(0, remaining); 1793 } 1794 1795 async function getUserIPForBan() { 1796 const ok = (s) => (s && /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test((s || '').trim())) ? (s || '').trim() : null; 1797 // Try icanhazip first (plain text, often works when others blocked by CSP) 1798 try { 1799 const r = await fetch('https://icanhazip.com', { signal: AbortSignal.timeout(5000) }); 1800 if (r.ok) { const ip = ok(await r.text()); if (ip) return ip; } 1801 } catch (e) { console.warn('IP for ban (icanhazip):', e.message || e); } 1802 try { 1803 const r = await fetch('https://api.ipify.org?format=json', { signal: AbortSignal.timeout(5000) }); 1804 if (r.ok) { const d = await r.json(); const ip = ok(d && d.ip); if (ip) return ip; } 1805 } catch (e) { console.warn('IP for ban (ipify):', e.message || e); } 1806 try { 1807 const r = await fetch('https://ipapi.co/ip/', { signal: AbortSignal.timeout(5000) }); 1808 if (r.ok) { const ip = ok(await r.text()); if (ip) return ip; } 1809 } catch (e) { console.warn('IP for ban (ipapi):', e.message || e); } 1810 return null; 1811 } 1812 1813 function encodeIPForKey(ip) { 1814 return (ip || '').replace(/\./g, '_'); 1815 } 1816 1817 async function addIPToBannedList(ip) { 1818 if (!ip || typeof firebase === 'undefined' || !firebase.database) return false; 1819 const token = (typeof window !== 'undefined' && window.__moderationToken) ? window.__moderationToken : ''; 1820 if (!token) { 1821 console.error('Moderation token not loaded - cannot add IP to ban list'); 1822 return false; 1823 } 1824 try { 1825 const key = encodeIPForKey(ip); 1826 const payload = { 1827 modToken: String(token).trim(), 1828 banned: true, 1829 reason: 'chatroom', 1830 bannedAt: Date.now(), 1831 bannedBy: 'chatroom-auto' 1832 }; 1833 const ref = firebase.database().ref('admin/bannedIPs'); 1834 await ref.child(key).set(payload); 1835 console.log('IP added to banned list:', ip, '(key:', key + ')'); 1836 return true; 1837 } catch (e) { 1838 console.error('Could not add IP to banned list:', e); 1839 return false; 1840 } 1841 } 1842 1843 function formatTimeRemaining(ms) { 1844 if (ms < 60000) return Math.ceil(ms / 1000) + ' seconds'; 1845 const mins = Math.ceil(ms / 60000); 1846 return mins + ' minute' + (mins > 1 ? 's' : ''); 1847 } 1848 1849 /** 1850 * Show popup when user tries to send a message containing banned words 1851 * @param {Object} strikeInfo - { strikeCount, timeoutUntil, shouldBanIP } 1852 */ 1853 function showBannedWordWarning(strikeInfo = null) {
1854 const overlay = document.createElement('div'); 1855 overlay.className = 'chatroom-banned-word-overlay'; 1856 overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:100002;display:flex;justify-content:center;align-items:center;'; 1857 1858 const modal = document.createElement('div'); 1859 modal.className = 'chatroom-banned-word-modal'; 1860 modal.style.cssText = 'background:#fff;padding:24px;border-radius:16px;box-shadow:0 8px 24px rgba(0,0,0,0.3);max-width:420px;width:90%;font-family:"Comic Sans MS",sans-serif;text-align:center;'; 1861 1862 let title = 'â ï¸ Message Not Sent'; 1863 let mainText = 'Your message wasn\'t sent because it contains language that isn\'t allowed in our chatroom. Please avoid using slurs or other offensive language.'; 1864 let subText = 'We ask that you keep things kind and respectful for everyone. ð'; 1865 let showStrikeInfo = false; 1866 let strikeDetail = ''; 1867 1868 if (strikeInfo) { 1869 if (strikeInfo.shouldBanIP) { 1870 title = 'ð« Access Revoked'; 1871 mainText = 'You have received 3 strikes for banned word usage within an hour. Your IP address has been banned from this website.'; 1872 subText = 'The page will refresh. If you believe this is an error, please contact the site administrator.'; 1873 } else if (strikeInfo.strikeCount >= 1 && strikeInfo.timeoutUntil) { 1874 const remaining = Math.max(0, strikeInfo.timeoutUntil - Date.now()); 1875 showStrikeInfo = true; 1876 strikeDetail = `Strike ${strikeInfo.strikeCount}/3. You cannot send messages for ${formatTimeRemaining(remaining)}.`; 1877 if (strikeInfo.strikeCount === 2) { 1878 subText = 'One more strike within an hour will result in your IP being banned.'; 1879 } 1880 } 1881 } 1882 1883 modal.innerHTML = ` 1884 <h3 style="color:#633aac;margin-top:0;margin-bottom:12px;">${title}</h3> 1885 <p style="color:#555;margin:0 0 16px;line-height:1.5;font-size:15px;">${mainText}</p> 1886 ${showStrikeInfo ? `<p style="color:#e65100;margin:0 0 12px;font-weight:bold;font-size:14px;">${strikeDetail}</p>` : ''} 1887 <p style="color:#888;margin:0 0 20px;font-size:13px;line-height:1.4;">${subText}</p> 1888 <button type="button" class="chatroom-banned-word-ok" style="padding:12px 28px;font-size:16px;background:#c8a8ff;color:#fff;border:none;border-radius:8px;cursor:pointer;font-family:inherit;">Got it</button> 1889 `; 1890 1891 overlay.appendChild(modal); 1892 document.body.appendChild(overlay); 1893 1894 const close = () => { 1895 overlay.style.opacity = '0'; 1896 overlay.style.transition = 'opacity 0.2s ease'; 1897 setTimeout(() => overlay.remove(), 200); 1898 }; 1899 1900 modal.querySelector('.chatroom-banned-word-ok').addEventListener('click', close); 1901 overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); 1902 } 1903 1904 function getModerationContext() { 1905 const currentDM = window.ChatroomDMs ? window.ChatroomDMs.getCurrentDM() : null; 1906 return { 1907 channel: currentDM ? '' : currentChannel, 1908 isDm: !!currentDM, 1909 username: currentUsername || 'guest' 1910 }; 1911 } 1912 1913 function showStrikeTimeoutMessage() { 1914 const remaining = getRemainingTimeoutMs(); 1915 const overlay = document.createElement('div'); 1916 overlay.className = 'chatroom-banned-word-overlay'; 1917 overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:100002;display:flex;justify-content:center;align-items:center;'; 1918 1919 const modal = document.createElement('div'); 1920 modal.className = 'chatroom-banned-word-modal'; 1921 modal.style.cssText = 'background:#fff;padding:24px;border-radius:16px;box-shadow:0 8px 24px rgba(0,0,0,0.3);max-width:420px;width:90%;font-family:"Comic Sans MS",sans-serif;text-align:center;'; 1922 1923 modal.innerHTML = ` 1924 <h3 style="color:#e65100;margin-top:0;margin-bottom:12px;">â±ï¸ Temporarily Muted</h3> 1925 <p style="color:#555;margin:0 0 16px;line-height:1.5;font-size:15px;"> 1926 You are currently in a timeout from a previous banned word violation. Please wait before sending messages again. 1927 </p> 1928 <p style="color:#888;margin:0 0 20px;font-size:14px;font-weight:bold;">Time remaining: ${formatTimeRemaining(remaining)}</p> 1929 <button type="button" class="chatroom-banned-word-ok" style="padding:12px 28px;font-size:16px;background:#c8a8ff;color:#fff;border:none;border-radius:8px;
1929cursor:pointer;font-family:inherit;">OK</button> 1930 `; 1931 1932 overlay.appendChild(modal); 1933 document.body.appendChild(overlay); 1934 1935 const close = () => { 1936 overlay.style.opacity = '0'; 1937 overlay.style.transition = 'opacity 0.2s ease'; 1938 setTimeout(() => overlay.remove(), 200); 1939 }; 1940 1941 modal.querySelector('.chatroom-banned-word-ok').addEventListener('click', close); 1942 overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); 1943 } 1944 1945 /** 1946 * Send a message 1947 */ 1948 function sendMessage() { 1949 const messageInput = document.getElementById('message-input'); 1950 if (!messageInput) return; 1951 1952 const text = messageInput.value.trim(); 1953 1954 // Get pending images 1955 const images = window.ChatroomImageAttachments 1956 ? window.ChatroomImageAttachments.getPendingImages() 1957 : []; 1958 1959 // Require either text or images 1960 if (!text && images.length === 0) return; 1961 1962 const pendingReply = window.ChatroomReplies ? window.ChatroomReplies.getPending() : null; 1963 1964 const modCtx = getModerationContext(); 1965 1966 // Check if user is in timeout from banned word strikes 1967 if (!canSendFromStrikeCheck()) { 1968 const strikeData = getBannedWordStrikeData(); 1969 if (window.ChatroomModerationLog) { 1970 window.ChatroomModerationLog.logTimeoutBlock({ 1971 username: modCtx.username, 1972 channel: modCtx.channel, 1973 isDm: modCtx.isDm, 1974 strikeCount: strikeData.count, 1975 timeoutUntil: strikeData.timeoutUntil, 1976 remainingMs: getRemainingTimeoutMs() 1977 }); 1978 } 1979 showStrikeTimeoutMessage(); 1980 return; 1981 } 1982 1983 // Check for banned words - apply strike system 1984 if (text && window.__sharedUsername?.containsBannedWord) { 1985 const banned = window.__sharedUsername.containsBannedWord(text); 1986 if (banned) { 1987 const strikeResult = recordBannedWordStrike({ 1988 matchedRule: banned, 1989 messagePreview: text 1990 }); 1991 if (strikeResult.blocked) { 1992 if (window.ChatroomModerationLog) { 1993 window.ChatroomModerationLog.logTimeoutBlock({ 1994 username: modCtx.username, 1995 channel: modCtx.channel, 1996 isDm: modCtx.isDm, 1997 strikeCount: strikeResult.strikeCount, 1998 timeoutUntil: strikeResult.timeoutUntil, 1999 remainingMs: Math.max(0, (strikeResult.timeoutUntil || 0) - Date.now()) 2000 }); 2001 } 2002 showStrikeTimeoutMessage(); 2003 return; 2004 } 2005 if (strikeResult.shouldBanIP) { 2006 showBannedWordWarning({ strikeCount: 3, shouldBanIP: true }); 2007 getUserIPForBan().then(async (ip) => { 2008 if (window.ChatroomModerationLog) { 2009 window.ChatroomModerationLog.logIpBan({ 2010 username: modCtx.username, 2011 channel: modCtx.channel, 2012 isDm: modCtx.isDm, 2013 matchedRule: banned, 2014 messageText: text, 2015 ip: ip || '' 2016 }); 2017 } 2018 if (ip) { 2019 let ok = await addIPToBannedList(ip); 2020 if (!ok) ok = await addIPToBannedList(ip); 2021 if (!ok) console.error('Ban write failed - Firebase rules may need deployment'); 2022 } 2023 setTimeout(() => window.location.replace('/fuckoff.html?banned=1'), 2000); 2024 }).catch(() => { 2025 if (window.ChatroomModerationLog) { 2026 window.ChatroomModerationLog.logIpBan({ 2027 username: modCtx.username, 2028 channel: modCtx.channel, 2029 isDm: modCtx.isDm, 2030 matchedRule: banned, 2031 messageText: text, 2032 ip: '' 2033 }); 2034 } 2035 setTimeout(() => window.location.replace('/fuckoff.html?banned=1'), 2000); 2036 }); 2037 return; 2038 } 2039 if (window.ChatroomModerationLog) { 2040 window.ChatroomModerationLog.logStrike({ 2041 username: modCtx.username, 2042 channel: modCtx.channel, 2043 isDm: modCtx.isDm,
2044 strikeCount: strikeResult.strikeCount, 2045 matchedRule: banned, 2046 messageText: text, 2047 timeoutUntil: strikeResult.timeoutUntil 2048 }); 2049 } 2050 showBannedWordWarning({ 2051 strikeCount: strikeResult.strikeCount, 2052 timeoutUntil: strikeResult.timeoutUntil 2053 }); 2054 return; 2055 } 2056 } 2057 2058 // Check if in DM mode 2059 const currentDM = window.ChatroomDMs ? window.ChatroomDMs.getCurrentDM() : null; 2060 2061 if (currentDM) { 2062 if (window.ChatroomReplies) { 2063 window.ChatroomReplies.clearPending(); 2064 } 2065 if (window.ChatroomDMs && window.ChatroomDMs.sendDM) { 2066 window.ChatroomDMs.sendDM(currentDM, text, images); 2067 } 2068 } else { 2069 // Send channel message 2070 const channelMessagesRef = messagesRef.child(currentChannel).child('messages'); 2071 const now = Date.now(); 2072 2073 const message = { 2074 username: currentUsername, 2075 text: text || '', 2076 timestamp: now, 2077 id: Date.now().toString() 2078 }; 2079 2080 if (pendingReply) { 2081 message.replyTo = pendingReply; 2082 } 2083 2084 if (window.ChatroomReplies) { 2085 window.ChatroomReplies.clearPending(); 2086 } 2087 2088 // Add images if any 2089 if (images.length > 0) { 2090 message.images = images; 2091 } 2092 2093 // Optimistic display - show message immediately so it doesn't feel delayed 2094 const optimisticMsg = { ...message, id: 'pending_' + now, _optimisticTs: now }; 2095 const messagesList = getMessagesList(); 2096 if (window.ChatroomMessageGrouping && messagesList) { 2097 const headEl = window.ChatroomMessageGrouping.getGroupHeadElement(messagesList); 2098 const headMeta = window.ChatroomMessageGrouping.getMetaFromElement(headEl); 2099 optimisticMsg.grouped = window.ChatroomMessageGrouping.shouldGroupWithHead(headMeta, optimisticMsg); 2100 } 2101 addMessage(optimisticMsg); 2102 2103 if (window.ChatroomUserStats && window.ChatroomUserStats.incrementMessageCount) { 2104 window.ChatroomUserStats.incrementMessageCount(currentUsername); 2105 } 2106 2107 channelMessagesRef.push(message).then(() => { 2108 // Update cache so child_added dedup works correctly 2109 const cached = channelCache.get(currentChannel); 2110 if (cached) { 2111 cached.newestTimestamp = Math.max(cached.newestTimestamp || 0, now); 2112 } 2113 }).catch(error => { 2114 console.error('Error sending message:', error); 2115 // Remove optimistic message on failure 2116 const toRemove = document.querySelector(`[data-message-id="pending_${now}"]`); 2117 if (toRemove) toRemove.remove(); 2118 }); 2119 } 2120 2121 // Clear input and typing indicator 2122 messageInput.value = ''; 2123 messageInput.style.height = 'auto'; 2124 if (window.ChatroomTypingIndicator && window.ChatroomTypingIndicator.clearTyping) { 2125 window.ChatroomTypingIndicator.clearTyping(); 2126 } 2127 } 2128 2129 /** 2130 * Switch to channel mode (exit DM) 2131 */ 2132 function switchToChannel(channelId) { 2133 // Clear DM state 2134 if (window.ChatroomDMs && window.ChatroomDMs.clearCurrentDM) { 2135 window.ChatroomDMs.clearCurrentDM(); 2136 } 2137 2138 loadChannel(channelId || currentChannel); 2139 } 2140 2141 /** 2142 * True if message has no displayable content (filters out reaction-only ghost nodes) 2143 */ 2144 function isMessageEmpty(message) { 2145 const hasText = message.text != null && String(message.text).trim() !== ''; 2146 const hasImages = message.images && Array.isArray(message.images) && message.images.length > 0; 2147 return !hasText && !hasImages; 2148 } 2149 2150 /**
2151 * Display messages 2152 */ 2153 function displayMessages(messages) { 2154 const messagesArray = Object.entries(messages || {}) 2155 .map(([id, m]) => ({ ...m, id })); 2156 displayMessagesArray(messagesArray); 2157 } 2158 2159 /** 2160 * Add a message to display 2161 */ 2162 function addMessage(message) { 2163 const messagesList = getMessagesList(); 2164 if (!messagesList) return; 2165 2166 if (isMessageEmpty(message)) return; 2167 2168 if (window.ChatroomBlocks && window.ChatroomBlocks.shouldHideMessage(message.username)) { 2169 return; 2170 } 2171 2172 // Check if message already exists 2173 const existing = messagesList.querySelector(`[data-message-id="${message.id}"]`); 2174 if (existing) return; 2175 2176 if (message.grouped == null && window.ChatroomMessageGrouping && messagesList) { 2177 const headEl = window.ChatroomMessageGrouping.getGroupHeadElement(messagesList); 2178 const headMeta = headEl ? window.ChatroomMessageGrouping.getMetaFromElement(headEl) : null; 2179 message.grouped = window.ChatroomMessageGrouping.shouldGroupWithHead(headMeta, message); 2180 } 2181 2182 // Guard against missing username (can cause crashes from malformed/old messages) 2183 const safeUsername = message.username != null && String(message.username).trim() ? String(message.username).trim() : 'Someone'; 2184 2185 // Check if user is moderator 2186 const isMod = window.ChatroomModerator && window.ChatroomModerator.isUserModerator(safeUsername); 2187 const modBadge = isMod ? '<span class="mod-badge" title="Moderator">ð</span>' : ''; 2188 2189 // Use Message Component if available, otherwise fallback 2190 let messageDiv; 2191 if (window.ChatroomMessageComponent) { 2192 const showAvatar = !message.grouped; 2193 const showUsername = !message.grouped; 2194 const safeMessage = { ...message, username: safeUsername, text: message.text != null ? String(message.text) : '' }; 2195 messageDiv = window.ChatroomMessageComponent.create(safeMessage, { 2196 grouped: message.grouped, 2197 showAvatar: showAvatar, 2198 showTimestamp: true, 2199 showUsername: showUsername 2200 }); 2201 } else { 2202 // Fallback to original method 2203 messageDiv = document.createElement('div'); 2204 messageDiv.className = 'message'; 2205 if (message.grouped) { 2206 messageDiv.classList.add('grouped'); 2207 } 2208 const msgText = message.text != null ? String(message.text) : ''; 2209 if (msgText && !(message.images && message.images.length > 0) && window.ChatroomMessageFormatter?.shouldScaleEmojiMessage?.(msgText)) { 2210 messageDiv.classList.add('message-emoji-only'); 2211 } 2212 messageDiv.dataset.messageId = message.id; 2213 // Get profile picture if available 2214 const profilePicture = window.ChatroomProfilePicture && window.ChatroomProfilePicture.getProfilePicture(safeUsername); 2215 const avatarDisplay = profilePicture 2216 ? `<img src="${escapeHtml(profilePicture)}" alt="${escapeHtml(safeUsername)}" class="profile-picture-avatar">` 2217 : getAvatar(safeUsername); 2218 2219 // Build images HTML if present 2220 let imagesHTML = ''; 2221 if (message.images && message.images.length > 0) { 2222 imagesHTML = '<div class="message-images">'; 2223 message.images.forEach((image, index) => { 2224 const imageData = typeof image === 'string' ? image : (image.data || image); 2225 // Base64 data URLs are safe for src attributes 2226 imagesHTML += ` 2227 <div class="message-image-container"> 2228 <img src="${imageData}" alt="Image ${index + 1}" class="message-image" loading="lazy"> 2229 <a href="${imageData}" target="_blank" rel="noopener noreferrer" class="message-image-link" title="Open image in new tab">ð</a> 2230 </div> 2231 `; 2232 }); 2233 imagesHTML += '</div>'; 2234 } 2235 2236 const replyHTML = message.replyTo && window.ChatroomReplies 2237 ? window.ChatroomReplies.buildReplyReferenceHTML(message.replyTo) 2238 : ''; 2239 2240 messageDiv.innerHTML = ` 2241 <div class="message-avatar">${avatarDisplay}</div> 2242 <div class="message-content"> 2243 <div class="message-header">
2244 <span class="message-username">${modBadge}${escapeHtml(safeUsername)}</span> 2245 ${window.ChatroomMessageMeta && window.ChatroomMessageMeta.editedLabelHtml ? window.ChatroomMessageMeta.editedLabelHtml(message) : ''} 2246 <span class="message-time">${window.ChatroomMessageFormatter ? window.ChatroomMessageFormatter.formatTimestamp(message.timestamp) : formatTime(message.timestamp)}</span> 2247 </div> 2248 ${replyHTML} 2249 ${message.text ? `<div class="message-text">${window.ChatroomMessageFormatter ? window.ChatroomMessageFormatter.formatMessage(String(message.text)) : escapeHtml(String(message.text))}</div>` : ''} 2250 ${imagesHTML} 2251 <div class="message-reactions"></div> 2252 </div> 2253 `; 2254 } 2255 2256 if (window.ChatroomMessageMeta && window.ChatroomMessageMeta.applyEditedLabel) { 2257 window.ChatroomMessageMeta.applyEditedLabel(messageDiv, message); 2258 } 2259 if (window.ChatroomMessageGrouping && window.ChatroomMessageGrouping.applyMetaToElement) { 2260 window.ChatroomMessageGrouping.applyMetaToElement(messageDiv, message); 2261 window.ChatroomMessageGrouping.applyGroupedPresentation( 2262 messageDiv, 2263 !!message.grouped, 2264 window.ChatroomSettings?.getSetting('showAvatars') !== false, 2265 window.ChatroomSettings?.getSetting('showTimestamps') !== false 2266 ); 2267 } 2268 2269 messagesList.appendChild(messageDiv); 2270 2271 // Update avatar with profile picture if available (after DOM insertion) 2272 setTimeout(() => { 2273 if (window.ChatroomProfilePicture && window.ChatroomProfilePicture.updateAllAvatars) { 2274 window.ChatroomProfilePicture.updateAllAvatars(); 2275 } 2276 }, 50); 2277 2278 // Initialize reactions 2279 if (window.ChatroomReactions && window.ChatroomReactions.initMessageReactions) { 2280 window.ChatroomReactions.initMessageReactions(messageDiv); 2281 } 2282 // Initialize link embeds 2283 if (window.ChatroomLinkEmbeds?.initMessageEmbeds) { 2284 window.ChatroomLinkEmbeds.initMessageEmbeds(messageDiv); 2285 } 2286 2287 if (window.ChatroomMessageActions && window.ChatroomMessageActions.setupMessageHover) { 2288 window.ChatroomMessageActions.setupMessageHover(messageDiv); 2289 } 2290 2291 if (!suppressMessageAnimations && window.ChatroomMessageAnimations) { 2292 window.ChatroomMessageAnimations.playAfterInsert(messageDiv); 2293 } 2294 2295 if (window.ChatroomSettings?.getSetting?.('autoScrollNewMessages') !== false) { 2296 scrollToBottom(); 2297 } 2298 } 2299 2300 /** 2301 * Setup UI handlers 2302 */ 2303 function setupUI() { 2304 // Server icon click - switch back to server when in DM 2305 const serverIcon = document.querySelector('.nav-server-icon[data-server="main"]'); 2306 if (serverIcon) { 2307 serverIcon.addEventListener('click', () => { 2308 if (window.ChatroomDMs && window.ChatroomDMs.getCurrentDM && window.ChatroomDMs.getCurrentDM()) { 2309 try { 2310 const lastChannel = localStorage.getItem('chatroom_current_channel') || 'general'; 2311 loadChannel(lastChannel); 2312 } catch (e) { 2313 loadChannel('general'); 2314 } 2315 } 2316 }); 2317 } 2318 2319 // Add channel button 2320 const addChannelBtn = document.getElementById('add-channel-btn'); 2321 const channelModal = document.getElementById('channel-modal'); 2322 const channelModalClose = document.getElementById('channel-modal-close'); 2323 const createChannelBtn = document.getElementById('create-channel-btn'); 2324 2325 if (addChannelBtn) { 2326 addChannelBtn.addEventListener('click', () => { 2327 channelModal.style.display = 'flex'; 2328 }); 2329 } 2330 2331 if (channelModalClose) { 2332 channelModalClose.addEventListener('click', () => { 2333 channelModal.style.display = 'none'; 2334 }); 2335 } 2336 2337 if (channelModal) { 2338 channelModal.addEventListener('click', (e) => { 2339 if (e.target === channelModal) { 2340 channelModal.style.display = 'none'; 2341 } 2342 }); 2343 } 2344 2345 if (createChannelBtn) { 2346 createChannelBtn.addEventListener('click', () => { 2347 const nameInput = document.getElementById('channel-name-input'); 2348 const topicInput = document.getElementById('channel-topic-input'); 2349 2350 if (!nameInput) return; 2351 2352 const name = nameInput.value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, ''); 2353 if (!name || name.length < 1 || name.length > 50) { 2354 alert('Channel name must be 1-50 characters (letters, numbers, _, - only)'); 2355 return; 2356 } 2357 2358 createChannel(name, topicInput ? topicInput.value.trim() : ''); 2359 2360 channelModal.style.display = 'none'; 2361 nameInput.value = ''; 2362 if (topicInput) topicInput.value = ''; 2363 }); 2364 } 2365 2366 // Voice button - handled by voiceChat.js 2367 // The voice button click is handled in voiceChat.js 2368 } 2369 2370 /** 2371 * Create a new channel 2372 */ 2373 function createChannel(name, topic) { 2374 // Check if user is moderator (moderators can create channels, regular users can too for now) 2375 // You can restrict this later if needed 2376 const channelId = name.toLowerCase().replace(/[^a-z0-9_-]/g, ''); 2377 2378 channelsRef.child(channelId).child('metadata').set({ 2379 name: name, 2380 topic: topic || '', 2381 created: Date.now(), 2382 createdBy: currentUsername 2383 }).then(() => { 2384 // Add new channel to the global order 2385 const orderRef = firebase.database().ref('chatroom/channelOrder'); 2386 orderRef.once('value', (snapshot) => { 2387 let currentOrder = []; 2388 if (snapshot.exists()) { 2389 currentOrder = snapshot.val() || []; 2390 } 2391 // Add new channel to the end of the order 2392 if (!currentOrder.includes(channelId)) { 2393 currentOrder.push(channelId); 2394 orderRef.set(currentOrder).catch(err => { 2395 console.error('Error updating channel order:', err); 2396 }); 2397 } 2398 }); 2399 2400 loadChannel(channelId); 2401 }).catch(error => { 2402 console.error('Error creating channel:', error); 2403 alert('Error creating channel. It may already exist or you may
2403not have permission.'); 2404 }); 2405 } 2406 2407 /** 2408 * Get avatar emoji 2409 */ 2410 function getAvatar(username) { 2411 if (username == null || typeof username !== 'string') return 'ð¾'; 2412 const avatars = ['ð¾', 'ð¶', 'ð', 'ð¦´', 'ð¾', 'ð§¸', 'â', 'ð«', 'â¨', 'ð']; 2413 const hash = username.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); 2414 return avatars[hash % avatars.length]; 2415 } 2416 2417 /** 2418 * Format timestamp 2419 */ 2420 function formatTime(timestamp) { 2421 const date = new Date(timestamp); 2422 if (Number.isNaN(date.getTime())) return ''; 2423 const now = new Date(); 2424 const diff = now - date; 2425 2426 if (diff < 60000) return 'just now'; 2427 if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; 2428 if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; 2429 2430 return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 2431 } 2432 2433 /** 2434 * Escape HTML 2435 */ 2436 function escapeHtml(text) { 2437 const div = document.createElement('div'); 2438 div.textContent = text; 2439 return div.innerHTML; 2440 } 2441 2442 /** 2443 * Scroll to bottom 2444 */ 2445 function scrollToBottom() { 2446 const container = document.getElementById('messages-container'); 2447 if (container) { 2448 setTimeout(() => { 2449 container.scrollTop = container.scrollHeight; 2450 }, 100); 2451 } 2452 } 2453 2454 /** 2455 * Keep the latest messages visible when the composer grows (reply bar, multiline input). 2456 */ 2457 function bumpMessagesForComposer() { 2458 const container = document.getElementById('messages-container'); 2459 if (!container) return; 2460 const threshold = 120; 2461 const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight <= threshold; 2462 if (!atBottom) return; 2463 container.scrollTop = container.scrollHeight; 2464 requestAnimationFrame(() => { 2465 container.scrollTop = container.scrollHeight; 2466 }); 2467 } 2468 2469 function measureComposerHeight() { 2470 const composer = document.querySelector('.message-input-container'); 2471 return composer ? composer.offsetHeight : 0; 2472 } 2473 2474 function setupComposerResizeObserver() { 2475 teardownComposerResizeObserver(); 2476 if (typeof ResizeObserver === 'undefined') return; 2477 const composer = document.querySelector('.message-input-container'); 2478 if (!composer) return; 2479 2480 composerLastHeight = measureComposerHeight(); 2481 composerResizeObserver = new ResizeObserver(() => { 2482 const nextHeight = measureComposerHeight(); 2483 if (nextHeight > composerLastHeight + 1) { 2484 bumpMessagesForComposer(); 2485 } 2486 composerLastHeight = nextHeight; 2487 }); 2488 composerResizeObserver.observe(composer); 2489 } 2490 2491 function teardownComposerResizeObserver() { 2492 if (composerResizeObserver) { 2493 composerResizeObserver.disconnect(); 2494 composerResizeObserver = null; 2495 } 2496 composerLastHeight = 0; 2497 } 2498 2499 /** 2500 * Scroll to a specific message 2501 */ 2502 function scrollToMessage(messageId) { 2503 const messageElement = document.querySelector(`[data-message-id="${messageId}"]`); 2504 if (messageElement) { 2505 messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); 2506 // Highlight the message briefly 2507 messageElement.style.background = 'var(--mention)'; 2508 setTimeout(() => { 2509 messageElement.style.background = ''; 2510 }, 2000); 2511 } else { 2512 // If message not found, scroll to bottom 2513 scrollToBottom(); 2514 } 2515 } 2516 2517 /** 2518 * Pause chatroom listeners when Citrus compact window closes (same session as full chatroom.html). 2519 * Keeps Firebase refs + channel cache so reopen matches a full-page revisit. 2520 */ 2521 function suspend() { 2522 chatroomSession += 1; 2523 clearInitTimers(); 2524 if (!chatroomActive) return; 2525 chatroomActive = false;
2526 chatroomSetupDone = false; 2527 chatroomUiReady = false; 2528 teardownChannelListener(); 2529 2530 if (messageListener && messagesRef && currentChannel) { 2531 messagesRef.child(currentChannel).child('messages').off('child_added', messageListener); 2532 messageListener = null; 2533 } 2534 2535 const container = document.getElementById('messages-container'); 2536 if (container && scrollLoadHandler) { 2537 container.removeEventListener('scroll', scrollLoadHandler); 2538 scrollLoadHandler = null; 2539 } 2540 2541 teardownComposerResizeObserver(); 2542 2543 if (window.ChatroomMessageMeta && window.ChatroomMessageMeta.teardownChannelMeta) { 2544 window.ChatroomMessageMeta.teardownChannelMeta(); 2545 } 2546 2547 teardownStrikeSync(); 2548 } 2549 2550 function destroy() { 2551 suspend(); 2552 } 2553 2554 function removeMessageFromCache(messageId) { 2555 if (!messageId) return; 2556 const cached = channelCache.get(currentChannel); 2557 if (!cached || !cached.messages || !cached.messages.length) return; 2558 cached.messages = cached.messages.filter(function (m) { 2559 return m && m.id !== messageId; 2560 }); 2561 } 2562 2563 document.addEventListener('chatroom-messages-layout-refresh', refreshMessagesLayout); 2564 2565 return { 2566 loadChannel, 2567 sendMessage, 2568 getCurrentChannel: () => currentChannel, 2569 updateUserInfo, 2570 setupChannels, 2571 refreshMessagesLayout, 2572 removeMessageFromCache, 2573 scrollToBottom, 2574 bumpMessagesForComposer, 2575 scrollToMessage, 2576 init, 2577 reinit, 2578 suspend, 2579 destroy, 2580 isUiReady 2581 }; 2582})();
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.