PageSourceSearch

https://pupwife.com/projects/chatroom/scripts/chatroom.js

js pupwife.com collected 2026-09-30 15:07:28 UTC 111,636 bytes, 2,582 lines download raw bytes

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.