PageSourceSearch

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

js pupwife.com collected 2026-09-30 15:07:10 UTC 58,235 bytes, 1,363 lines download raw bytes

1/**
2 * Direct Messaging System
3 * Handles private messages between users
4 */
5
6window.ChatroomDMs = (function() {
7    'use strict';
8
9    const DM_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;
13
14    let dmsRef = null;
15    let currentUsername = null;
16    let currentDM = null;
17    let dmListeners = new Map();
18    let displayedMessageIds = new Set();
19
20    // DM cache: only fetch when conversation hasn't been opened recently
21    let dmCache = new Map();  // conversationId -> { messages, lastFetchTime, oldestTimestamp, newestTimestamp }
22    let loadingOlderDM = false;
23    let hasMoreOlderDM = true;
24    let dmScrollLoadHandler = null;
25    let suppressDMAnimations = false;
26
27    function callUnread(method) {
28        const mod = window.ChatroomUnreadCount;
29        if (!mod || typeof mod[method] !== 'function') return;
30        return mod[method].apply(mod, Array.prototype.slice.call(arguments, 1));
31    }
32
33    function isMessageEmpty(message) {
34        const hasText = message.text != null && String(message.text).trim() !== '';
35        const hasImages = message.images && Array.isArray(message.images) && message.images.length > 0;
36        return !hasText && !hasImages;
37    }
38
39    function applyDmInVoiceIndicators(inCall, partnerUsername) {
40        document.querySelectorAll('#channels-column-dm-list .dm-item').forEach(function (item) {
41            const other = item.dataset.dm;
42            let line = item.querySelector('.dm-in-voice-line');
43            if (inCall && partnerUsername && other === partnerUsername) {
44                item.classList.add('dm-in-voice');
45                if (!line) {
46                    line = document.createElement('span');
47                    line.className = 'dm-in-voice-line';
48                    line.innerHTML = (window.ChatroomIcons ? window.ChatroomIcons.icon('phone') : '📞') + ' In voice';
49                    item.appendChild(line);
50                }
51            } else {
52                item.classList.remove('dm-in-voice');
53                if (line) line.remove();
54            }
55        });
56    }
57
58    /**
59     * Initialize DM system
60     */
61    function init() {
62        window.addEventListener('chatroomDmCallChanged', function (e) {
63            const detail = e.detail || {};
64            applyDmInVoiceIndicators(detail.inCall, detail.username);
65        });
66
67        if (typeof firebase === 'undefined' || !firebase.database) {
68            console.error('Firebase not available for DMs');
69            return false;
70        }
71
72        dmsRef = firebase.database().ref('chatroom/dms');
73
74        // Wait for username
75        const checkUsername = setInterval(() => {
76            if (window.__sharedUsername) {
77                currentUsername = window.__sharedUsername.get();
78                if (currentUsername) {
79                    clearInterval(checkUsername);
80                    setupUI();
81                }
82            }
83        }, 100);
84
85        setTimeout(() => {
86            clearInterval(checkUsername);
87            if (!currentUsername) {
88                currentUsername = 'guest_' + Date.now();
89                setupUI();
90            } else {
91                // Update DM list after username is loaded
92                setTimeout(() => {
93                    updateDMList();
94                }, 500);
95            }
96        }, 5000);
97
98        // Periodically update DM list
99        setInterval(() => {
100            if (currentUsername) {
101                updateDMList();
102            }
103        }, 30000); // Update every 30 seconds
104
105        // Restore last DM after UI is ready (longer delay to ensure everything is loaded)
106        setTimeout(() => {
107            restoreLastDM();
108        }, 2000);
109
110        return true;
111    }
112
113    /**
114     * Setup DM UI handlers
115     */
116    function setupUI() {
117        const newDMBtn = document.getElementById('new-dm-btn');
118        const dmModal = document.getElementById('dm-modal');
119        const dmModalClose = document.getElementById('dm-modal-close');
120        const dmUsernameInput = document.getElementById('dm-username-input');
121        const userSuggestions = document.getElementById('user-suggestions');
122
123        if (newDMBtn) {
124            newDMBtn.addEventListener('click', () => {
125                dmModal.style.display = 'flex';
126                if (dmUsernameInput) dmUsernameInput.focus();
127            });
128        }
129
130        if (dmModalClose) {
131            dmModalClose.addEventListener('click', () => {
132                dmModal.style.display = 'none';
133            });
134        }
135
136        // Close modal on outside click
137        if (dmModal) {
138            dmModal.addEventListener('click', (e) => {
139                if (e.target === dmModal) {
140                    dmModal.style.display = 'none';
141                }
142            });
143        }
144
145        // Username input with suggestions
146        if (dmUsernameInput) {
147            dmUsernameInput.addEventListener('input', (e) => {
148                const query = e.target.value.trim().toLowerCase();
149                if (query.length > 0) {
150                    showUserSuggestions(query);
151                } else {
152                    userSuggestions.innerHTML = '';
153                }
154            });
155
156            dmUsernameInput.addEventListener('keydown', (e) => {
157                if (e.key === 'Enter') {
158                    e.preventDefault();
159                    const username = e.target.value.trim();
160                    if (username) {
161                        openDM(username);
162                        dmModal.style.display = 'none';
163                        e.target.value = '';
164                    }
165                }
166            });
167        }
168    }
169
170    /**
171     * Show user suggestions
172     */
173    function showUserSuggestions(query) {
174        const userSuggestions = document.getElementById('user-suggestions');
175        if (!userSuggestions) return;
176
177        // Get online users from presence system
178        const onlineUsers = window.ChatroomPresence ? window.ChatroomPresence.getOnlineUsers() : [];
179        
180        userSuggestions.innerHTML = '';
181
182        onlineUsers.forEach(username => {
183            if (username.toLowerCase().includes(query) && username !== currentUsername) {
184                const item = document.createElement('div');
185                item.className = 'suggestion-item';
186                item.textContent = username;
187                item.addEventListener('click', () => {
188                    openDM(username);
189                    document.getElementById('dm-modal').style.display = 'none';
190                    document.getElementById('dm-username-input').value = '';
191                });
192                userSuggestions.appendChild(item);
193            }
194        });
195    }
196
197    /**
198     * Open or create a DM conversation
199     */
200    function openDM(otherUsername) {
201        if (!otherUsername || otherUsername === currentUsername) return;
202
203        if (window.ChatroomBlocks && window.ChatroomBlocks.isBlocked(otherUsername)) {
204            alert('You have blocked this user. Unblock them from their profile to message again.');
205            return;
206        }
207
208        // Create conversation ID (sorted usernames)
209        const conversationId = [currentUsername, otherUsername].sort().join('_');
210        
211        // If opening the same DM, don't re-setup listener (prevents duplicates)
212        if (currentDM === otherUsername && dmListeners.has(conversationId)) {
213            // Just switch to it
214            switchToDM(otherUsername);
215            return;
216        }
217        
218        currentDM = otherUsername;
219
220        // Save to localStorage for persistence
221        try {
222            localStorage.setItem('chatroom_current_dm', otherUsername);
223            const dmHistory = JSON.parse(localStorage.getItem('chatroom_dm_history') || '[]');
224            if (!dmHistory.includes(otherUsername)) {
225                dmHistory.unshift(otherUsername);
226                // Keep only last 20 DMs
227                if (dmHistory.length > 20) {
228                    dmHistory.pop();
229                }
230                localStorage.setItem('chatroom_dm_history', JSON.stringify(dmHistory));
231            }
232        } catch (e) {
233            console.error('Error saving DM state:', e);
234        }
235
236        // Update UI
237        updateDMList();
238        switchToDM(otherUsername);
239
240        // Setup message listener for this DM
241        setupDMListener(conversationId, otherUsername);
242
243        console.log('💌 Opened DM with:', otherUsername);
244    }
245
246    /**
247     * Setup listener for a DM conversation (lazy: uses cache when valid)
248     */
249    function setupDMListener(conversationId, otherUsername) {
250        // Remove old listener if exists
251        if (dmListeners.has(conversationId)) {
252            const oldRef = dmListeners.get(conversationId);
253            oldRef.off('child_added');
254            dmListeners.delete(conversationId);
255        }
256
257        displayedMessageIds.clear();
258        loadingOlderDM = false;
259        hasMoreOlderDM = true;
260
261        const oldIndicator = document.getElementById('load-older-indicator');
262        if (oldIndicator) oldIndicator.remove();
263
264        const messagesRef = dmsRef.child(conversationId).child('messages');
265
266        // Check cache
267        const cached = dmCache.get(conversationId);
268        const now = Date.now();
269        const cacheValid = cached && (now - cached.lastFetchTime) < DM_CACHE_TTL;
270
271        if (cacheValid && cached.messages.length > 0) {
272            const messagesObj = {};
273            cached.messages.forEach(m => { messagesObj[m.id || m.timestamp] = m; });
274            displayDMMessages(messagesObj, otherUsername);
275            setupDMRealtimeListener(messagesRef, conversationId, otherUsername, cached.newestTimestamp);
276            setupDMScrollToLoadOlder(conversationId, otherUsername, cached.oldestTimestamp);
277        } else {
278            fetchAndDisplayDMMessages(messagesRef, conversationId, otherUsername);
279        }
280    }
281
282    /**
283     * Fetch initial DM messages from Firebase
284     */
285    function fetchAndDisplayDMMessages(messagesRef, conversationId, otherUsername) {
286        let lastLoadedTimestamp = 0;
287        let initialLoadComplete = false;
288
289        messagesRef.orderByChild('timestamp').limitToLast(INITIAL_MESSAGE_LIMIT).once('value', (snapshot) => {
290            const messages = snapshot.val();
291            let oldestTimestamp = 0;
292
293            if (messages) {
294                const messagesArray = Object.entries(messages || {})
295                    .map(([id, m]) => ({ ...m, id }))  // Use Firebase key as id (required for reaction paths)
296                    .filter(m => !isMessageEmpty(m))
297                    .sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
298                oldestTimestamp = messagesArray.length > 0 ? Math.min(...messagesArray.map(m => m.timestamp || 0)) : 0;
299                lastLoadedTimestamp = messagesArray.length > 0 ? Math.max(...messagesArray.map(m => m.timestamp || 0)) : 0;
300
301                const messagesObj = {};
302                messagesArray.forEach(m => { messagesObj[m.id] = m; });
303                displayDMMessages(messagesObj, otherUsername);
304
305                dmCache.set(conversationId, {
306                    messages: messagesArray,
307                    lastFetchTime: Date.now(),
308                    oldestTimestamp,
309                    newestTimestamp: lastLoadedTimestamp
310                });
311            } else {
312                dmCache.set(conversationId, {
313                    messages: [],
314                    lastFetchTime: Date.now(),
315                    oldestTimestamp: 0,
316                    newestTimestamp: 0
317                });
318            }
319
320            initialLoadComplete = true;
321            setupDMRealtimeListener(messagesRef, conversationId, otherUsername, lastLoadedTimestamp);
322            setupDMScrollToLoadOlder(conversationId, otherUsername, oldestTimestamp);
323            scrollToBottom();
324        });
325    }
326
327    /**
328     * Real-time listener for new DM messages only
329     */
330    function setupDMRealtimeListener(messagesRef, conversationId, otherUsername, afterTimestamp) {
331        let lastLoadedTimestamp = afterTimestamp || 0;
332
333        const listener = messagesRef.on('child_added', (snapshot) => {
334            if (!snapshot.exists()) return;
335            const message = snapshot.val();
336            if (!message) return;
337            message.id = snapshot.key;  // Firebase key required for reaction paths
338            if (isMessageEmpty(message)) return;
339            if (message.timestamp && message.timestamp <= lastLoadedTimestamp) return;
340            if (displayedMessageIds.has(message.id)) return;
341
342            const messagesList = document.getElementById('messages-list');
343            if (messagesList) {
344                const existing = messagesList.querySelector(`[data-message-id="${message.id}"]`);
345                if (existing) {
346                    displayedMessageIds.add(message.id);
347                    return;
348                }
349            }
350
351            if (message.username === otherUsername || message.username === currentUsername) {
352                const fromOther = message.username === otherUsername;
353                if (fromOther) {
354                    callUnread('onIncomingMessage', true);
355                }
356                if (!fromOther || callUnread('isNearBottom')) {
357                    callUnread('markMessageAsRead', 'dm_' + otherUsername, message.id);
358                }
359                if (window.ChatroomMessageGrouping && messagesList) {
360                    const headEl = window.ChatroomMessageGrouping.getGroupHeadElement(messagesList);
361                    const headMeta = window.ChatroomMessageGrouping.getMetaFromElement(headEl);
362                    message.grouped = window.ChatroomMessageGrouping.shouldGroupWithHead(headMeta, message);
363                }
364                addDMMessage(message, otherUsername);
365            }
366            lastLoadedTimestamp = Math.max(lastLoadedTimestamp, message.timestamp || 0);
367
368            const cached = dmCache.get(conversationId);
369            if (cached) {
370                cached.messages.push(message);
371                cached.newestTimestamp = lastLoadedTimestamp;
372            }
373        });
374
375        dmListeners.set(conversationId, messagesRef);
376    }
377
378    /**
379     * Scroll-to-load-older for DMs
380     */
381    function setupDMScrollToLoadOlder(conversationId, otherUsername, currentOldestTimestamp) {
382        const container = document.getElementById('messages-container');
383        if (!container || currentDM !== otherUsername) return;
384
385        if (dmScrollLoadHandler) {
386            container.removeEventListener('scroll', dmScrollLoadHandler);
387        }
388
389        let oldestTimestamp = currentOldestTimestamp;
390
391        dmScrollLoadHandler = () => {
392            if (currentDM !== otherUsername) return;
393            if (loadingOlderDM || !hasMoreOlderDM) return;
394            if (container.scrollTop > SCROLL_LOAD_THRESHOLD) return;
395
396            loadingOlderDM = true;
397            showLoadOlderIndicator(true);
398
399            const messagesRef = dmsRef.child(conversationId).child('messages');
400            messagesRef.orderByChild('timestamp').endAt(oldestTimestamp - 1).limitToLast(LOAD_OLDER_BATCH_SIZE)
401                .once('value', (snapshot) => {
402                    const messages = snapshot.val();
403                    showLoadOlderIndicator(false);
404                    loadingOlderDM = false;
405
406                    if (!messages || Object.keys(messages).length === 0) {
407                        hasMoreOlderDM = false;
408                        return;
409                    }
410
411                    const messagesArray = Object.entries(messages || {})
412                        .map(([id, m]) => ({ ...m, id }))  // Use Firebase key as id (required for reaction paths)
413                        .filter(m => !isMessageEmpty(m))
414                        .sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
415                    const prevScrollHeight = container.scrollHeight;
416                    const prevScrollTop = container.scrollTop;
417
418                    const messagesList = document.getElementById('messages-list');
419                    const groupedBatch = window.ChatroomMessageGrouping
420                        ? window.ChatroomMessageGrouping.assignFlags(messagesArray)
421                        : messagesArray;
422                    groupedBatch.forEach((message) => {
423                        prependDMMessage(message, otherUsername, messagesList);
424                    });
425                    if (window.ChatroomMessageGrouping && messagesList) {
426                        window.ChatroomMessageGrouping.reapplyDom(messagesList);
427                    }
428
429                    requestAnimationFrame(() => {
430                        const newScrollHeight = container.scrollHeight;
431                        container.scrollTop = prevScrollTop + (newScrollHeight - prevScrollHeight);
432                    });
433
434                    oldestTimestamp = Math.min(...messagesArray.map(m => m.timestamp || 0));
435                    if (messagesArray.length < LOAD_OLDER_BATCH_SIZE) hasMoreOlderDM = false;
436
437                    const cached = dmCache.get(conversationId);
438                    if (cached) {
439                        cached.messages = [...messagesArray, ...cached.messages];
440                        cached.oldestTimestamp = oldestTimestamp;
441                    }
442                }).catch(err => {
443                    console.error('Error loading older DM messages:', err);
444                    showLoadOlderIndicator(false);
445                    loadingOlderDM = false;
446                });
447        };
448
449        container.addEventListener('scroll', dmScrollLoadHandler);
450    }
451
452    function prependDMMessage(message, otherUsername, messagesList) {
453        if (!messagesList) return;
454        const existing = messagesList.querySelector(`[data-message-id="${message.id}"]`);
455        if (existing) return;
456
457        const isMod = window.ChatroomModerator && window.ChatroomModerator.isUserModerator(message.username);
458        const modBadge = isMod ? '<span class="mod-badge" title="Moderator">👑</span>' : '';
459        const profilePicture = window.ChatroomProfilePicture && window.ChatroomProfilePicture.getProfilePicture(message.username);
460        const avatarDisplay = profilePicture 
461            ? `<img src="${escapeHtml(profilePicture)}" alt="${escapeHtml(message.username)}" class="profile-picture-avatar">`
462            : getAvatar(message.username);
463        let imagesHTML = '';
464        if (message.images && message.images.length > 0) {
465            imagesHTML = '<div class="message-images">';
466            message.images.forEach((image, i) => {
467                const imageData = typeof image === 'string' ? image : (image.data || image);
468                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>`;
469            });
470            imagesHTML += '</div>';
471        }
472
473        const messageDiv = document.createElement('div');
474        messageDiv.className = 'message' + (message.grouped ? ' grouped' : '');
475        const prependDmText = message.text != null ? String(message.text) : '';
476        if (prependDmText && !(message.images && message.images.length > 0) && window.ChatroomMessageFormatter?.shouldScaleEmojiMessage?.(prependDmText)) {
477            messageDiv.classList.add('message-emoji-only');
478        }
479        messageDiv.dataset.messageId = message.id;
480        messageDiv.innerHTML = `
481            <div class="message-avatar">${avatarDisplay}</div>
482            <div class="message-content">
483                <div class="message-header">
484                    <span class="message-username">${modBadge}${escapeHtml(message.username)}</span>
485                    <span class="message-time">${window.ChatroomMessageFormatter ? window.ChatroomMessageFormatter.formatTimestamp(message.timestamp) : formatTime(message.timestamp)}</span>
486                </div>
487                ${message.text ? `<div class="message-text">${window.ChatroomMessageFormatter ? window.ChatroomMessageFormatter.formatMessage(message.text) : escapeHtml(message.text)}</div>` : ''}
488                ${imagesHTML}
489                <div class="message-reactions"></div>
490            </div>
491        `;
492        if (window.ChatroomMessageGrouping) {
493            window.ChatroomMessageGrouping.applyMetaToElement(messageDiv, message);
494            window.ChatroomMessageGrouping.applyGroupedPresentation(
495                messageDiv,
496                !!message.grouped,
497                window.ChatroomSettings?.getSetting('showAvatars') !== false,
498                window.ChatroomSettings?.getSetting('showTimestamps') !== false
499            );
500        }
501        messagesList.insertBefore(messageDiv, messagesList.firstChild);
502
503        displayedMessageIds.add(message.id);
504        if (window.ChatroomProfilePicture && window.ChatroomProfilePicture.updateAllAvatars) {
505            setTimeout(() => window.ChatroomProfilePicture.updateAllAvatars(), 50);
506        }
507        if (window.ChatroomReactions && window.ChatroomReactions.initMessageReactions) {
508            window.ChatroomReactions.initMessageReactions(messageDiv);
509        }
510        if (window.ChatroomLinkEmbeds?.initMessageEmbeds) {
511            window.ChatroomLinkEmbeds.initMessageEmbeds(messageDiv);
512        }
513    }
514
515    function showLoadOlderIndicator(show) {
516        let indicator = document.getElementById('load-older-indicator');
517        if (show) {
518            if (!indicator) {
519                indicator = document.createElement('div');
520                indicator.id = 'load-older-indicator';
521                indicator.className = 'load-older-indicator';
522                indicator.textContent = 'Loading older messages...';
523                const messagesList = document.getElementById('messages-list');
524                if (messagesList) messagesList.insertBefore(indicator, messagesList.firstChild);
525            }
526            indicator.style.display = 'block';
527        } else if (indicator) {
528            indicator.style.display = 'none';
529        }
530    }
531
532    /**
533     * Send a DM
534     */
535    function sendDM(otherUsername, text, images = []) {
536        if ((!text || !text.trim()) && (!images || images.length === 0)) return;
537        if (!otherUsername) return;
538
539        const conversationId = [currentUsername, otherUsername].sort().join('_');
540        const messagesRef = dmsRef.child(conversationId).child('messages');
541
542        const message = {
543            username: currentUsername,
544            text: text ? text.trim() : '',
545            timestamp: Date.now(),
546            id: Date.now().toString()
547        };
548        
549        // Add images if any
550        if (images && images.length > 0) {
551            message.images = images;
552        }
553
554        if (window.ChatroomUserStats && window.ChatroomUserStats.incrementMessageCount) {
555            window.ChatroomUserStats.incrementMessageCount(currentUsername);
556        }
557
558        messagesRef.push(message).catch(error => {
559            console.error('Error sending DM:', error);
560        });
561
562        // Update conversation metadata
563        dmsRef.child(conversationId).child('metadata').set({
564            participants: [currentUsername, otherUsername].sort(),
565            lastMessage: message.timestamp,
566            lastMessageFrom: currentUsername
567        }).then(() => {
568            // Update DM list after sending
569            updateDMList();
570        });
571
572    }
573
574    /**
575     * Display DM messages
576     */
577    function displayDMMessages(messages, otherUsername) {
578        const messagesList = document.getElementById('messages-list');
579        if (!messagesList) return;
580
581        // Clear the list and reset tracking
582        messagesList.innerHTML = '';
583        displayedMessageIds.clear();
584        suppressDMAnimations = true;
585
586        const messagesArray = Object.entries(messages || {})
587            .map(([id, m]) => ({ ...m, id }))  // Use Firebase key as id (required for reaction paths)
588            .filter(m => !isMessageEmpty(m))
589            .sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
590        
591        // Remove duplicates by message ID before displaying
592        const uniqueMessages = [];
593        const seenIds = new Set();
594        
595        messagesArray.forEach(message => {
596            // Skip if we've already seen this message ID
597            if (!message.id || seenIds.has(message.id)) {
598                return;
599            }
600            seenIds.add(message.id);
601            uniqueMessages.push(message);
602        });
603        
604        const groupedMessages = window.ChatroomMessageGrouping
605            ? window.ChatroomMessageGrouping.assignFlags(uniqueMessages)
606            : uniqueMessages;
607
608        // Display unique messages
609        groupedMessages.forEach(message => {
610            addDMMessage(message, otherUsername);
611            // Mark each message as read immediately
612            if (window.ChatroomUnreadCount) {
613                const key = `dm_${otherUsername}`;
614                window.ChatroomUnreadCount.markMessageAsRead(key, message.id);
615            }
616            
617            // Also mark notification as read in localStorage immediately
618            if (currentUsername && message.id) {
619                const conversationId = [currentUsername, otherUsername].sort().join('_');
620                const notifiedKey = `chatroom_notified_${currentUsername}`;
621                try {
622                    const notified = JSON.parse(localStorage.getItem(notifiedKey) || '{}');
623                    const notificationKey = `dm_${conversationId}_${message.id}`;
624                    notified[notificationKey] = true;
625                    localStorage.setItem(notifiedKey, JSON.stringify(notified));
626                } catch (e) {
627                    console.error('Error marking notification as read:', e);
628                }
629            }
630        });
631
632        suppressDMAnimations = false;
633        
634        // Mark all messages as read when viewing DM
635        if (window.ChatroomUnreadCount) {
636            const key = `dm_${otherUsername}`;
637            window.ChatroomUnreadCount.markAllAsRead(key);
638        }
639        
640        // Track unread count for this DM
641        if (window.ChatroomUnreadCount) {
642            window.ChatroomUnreadCount.trackDM(otherUsername);
643        }
644
645        // Update all avatars after displaying all messages
646        setTimeout(() => {
647            if (window.ChatroomProfilePicture && window.ChatroomProfilePicture.updateAllAvatars) {
648                window.ChatroomProfilePicture.updateAllAvatars();
649            }
650        }, 100);
651
652        scrollToBottom();
653        // Delayed scrolls to account for async embeds loading
654        if (window.ChatroomSettings?.getSetting?.('autoScrollNewMessages') !== false) {
655            [300, 800, 1500, 2500].forEach(delay => {
656                setTimeout(() => {
657                    if (window.ChatroomSettings?.getSetting?.('autoScrollNewMessages') !== false) {
658                        scrollToBottom();
659                    }
660                }, delay);
661            });
662        }
663    }
664
665    /**
666     * Add a single DM message to display
667     */
668    function addDMMessage(message, otherUsername) {
669        const messagesList = document.getElementById('messages-list');
670        if (!messagesList) return;
671
672        if (isMessageEmpty(message)) return;
673
674        if (window.ChatroomBlocks && window.ChatroomBlocks.shouldHideMe
674ssage(message.username)) {
675            return;
676        }
677
678        // Skip if message already displayed
679        if (displayedMessageIds.has(message.id)) {
680            return;
681        }
682
683        // Check if message already exists in DOM
684        const existing = messagesList.querySelector(`[data-message-id="${message.id}"]`);
685        if (existing) {
686            displayedMessageIds.add(message.id);
687            return;
688        }
689        
690        // Additional check: look for messages with same content and timestamp (duplicate detection)
691        const allMessages = messagesList.querySelectorAll('.message');
692        for (let msgEl of allMessages) {
693            const msgId = msgEl.dataset.messageId;
694            const msgTimeEl = msgEl.querySelector('.message-time');
695            const msgTextEl = msgEl.querySelector('.message-text');
696            const msgUsernameEl = msgEl.querySelector('.message-username');
697            
698            if (msgId === message.id || 
699                (msgTimeEl && msgTextEl && msgUsernameEl &&
700                 msgTimeEl.textContent.trim() === (window.ChatroomMessageFormatter ? window.ChatroomMessageFormatter.formatTimestamp(message.timestamp) : formatTime(message.timestamp)) &&
701                 msgTextEl.textContent.trim() === (message.text || '').trim() &&
702                 msgUsernameEl.textContent.replace('👑', '').trim() === message.username)) {
703                // This is a duplicate
704                displayedMessageIds.add(message.id);
705                return;
706            }
707        }
708
709        // Mark as displayed
710        displayedMessageIds.add(message.id);
711
712        if (message.grouped == null && window.ChatroomMessageGrouping) {
713            const headEl = window.ChatroomMessageGrouping.getGroupHeadElement(messagesList);
714            const headMeta = window.ChatroomMessageGrouping.getMetaFromElement(headEl);
715            message.grouped = window.ChatroomMessageGrouping.shouldGroupWithHead(headMeta, message);
716        }
717
718        // Check if user is moderator
719        const isMod = window.ChatroomModerator && window.ChatroomModerator.isUserModerator(message.username);
720        const modBadge = isMod ? '<span class="mod-badge" title="Moderator">👑</span>' : '';
721
722        const messageDiv = document.createElement('div');
723        messageDiv.className = 'message';
724        if (message.grouped) {
725            messageDiv.classList.add('grouped');
726        }
727        const dmText = message.text != null ? String(message.text) : '';
728        if (dmText && !(message.images && message.images.length > 0) && window.ChatroomMessageFormatter?.shouldScaleEmojiMessage?.(dmText)) {
729            messageDiv.classList.add('message-emoji-only');
730        }
731        messageDiv.dataset.messageId = message.id;
732        
733        // Get profile picture if available
734        const profilePicture = window.ChatroomProfilePicture && window.ChatroomProfilePicture.getProfilePicture(message.username);
735        const avatarDisplay = profilePicture 
736            ? `<img src="${escapeHtml(profilePicture)}" alt="${escapeHtml(message.username)}" class="profile-picture-avatar">`
737            : getAvatar(message.username);
738        
739        // Build images HTML if present
740        let imagesHTML = '';
741        if (message.images && message.images.length > 0) {
742            imagesHTML = '<div class="message-images">';
743            message.images.forEach((image, index) => {
744                const imageData = typeof image === 'string' ? image : (image.data || image);
745                // Base64 data URLs are safe for src attributes
746                imagesHTML += `
747                    <div class="message-image-container">
748                        <img src="${imageData}" alt="Image ${index + 1}" class="message-image" loading="lazy">
749                        <a href="${imageData}" target="_blank" rel="noopener noreferrer" class="message-image-link" title="Open image in new tab">🔗</a>
750                    </div>
751                `;
752            });
753            imagesHTML += '</div>';
754        }
755        
756        messageDiv.innerHTML = `
757            <div class="message-avatar">${avatarDisplay}</div>
758            <div class="message-content">
759                <div class="message-header">
760                    <span class="message-username">${modBadge}${escapeHtml(message.username)}</span>
761                    ${window.ChatroomMessageMeta && window.ChatroomMessageMeta.editedLabelHtml ? window.ChatroomMessageMeta.editedLabelHtml(message) : ''}
762                    <span class="message-time">${window.ChatroomMessageFormatter ? window.ChatroomMessageFormatter.formatTimestamp(message.timestamp) : formatTime(message.timestamp)}</span>
763                </div>
764                ${message.text ? `<div class="message-text">${window.ChatroomMessageFormatter ? window.ChatroomMessageFormatter.formatMessage(message.text) : escapeHtml(message.text)}</div>` : ''}
765                ${imagesHTML}
766                <div class="message-reactions"></div>
767            </div>
768        `;
769
770        if (window.ChatroomMessageMeta && window.ChatroomMessageMeta.applyEditedLabel) {
771            window.ChatroomMessageMeta.applyEditedLabel(messageDiv, message);
772        }
773        if (window.ChatroomMessageGrouping && window.ChatroomMessageGrouping.applyMetaToElement) {
774            window.ChatroomMessageGrouping.applyMetaToElement(messageDiv, message);
775            window.ChatroomMessageGrouping.applyGroupedPresentation(
776                messageDiv,
777                !!message.grouped,
778                window.ChatroomSettings?.getSetting('showAvatars') !== false,
779                window.ChatroomSettings?.getSetting('showTimestamps') !== false
780            );
781        }
782
783        messagesList.appendChild(messageDiv);
784
785        if (!suppressDMAnimations && window.ChatroomMessageAnimations) {
786            window.ChatroomMessageAnimations.playAfterInsert(messageDiv);
787        }
788        
789        // Mark message as read if viewing this DM (for new messages coming in)
790        if (currentDM === otherUsername) {
791            if (window.ChatroomUnreadCount) {
792                const key = `dm_${otherUsername}`;
793                window.ChatroomUnreadCount.markMessageAsRead(key, message.id);
794            }
795            
796            // Also mark notification as read in localStorage immediately
797            if (currentUsername && message.id) {
798                const conversationId = [currentUsername, otherUsername].sort().join('_');
799                const notifiedKey = `chatroom_notified_${currentUsername}`;
800                try {
801                    const notified = JSON.parse(localStorage.getItem(notifiedKey) || '{}');
802                    const notificationKey = `dm_${conversationId}_${message.id}`;
803                    notified[notificationKey] = true;
804                    localStorage.setItem(notifiedKey, JSON.stringify(notified));
805                } catch (e) {
806                    console.error('Error marking notification as read:', e);
807                }
808            }
809        }
810        
811        // Update avatar with profile picture if available (after DOM insertion)
812        setTimeout(() => {
813            if (window.ChatroomProfilePicture && window.ChatroomProfilePicture.updateAllAvatars) {
814                window.ChatroomProfilePicture.updateAllAvatars();
815            }
816        }, 50);
817        
818        // Initialize reactions
819        if (window.ChatroomReactions && window.ChatroomReactions.initMessageReactions) {
820            window.ChatroomReactions.initMessageReactions(messageDiv);
821        }
822        // Initialize link embeds
823        if (window.ChatroomLinkEmbeds?.initMessageEmbeds) {
824            window.ChatroomLinkEmbeds.initMessageEmbeds(messageDiv);
825        }
826        
827        // Add moderator controls if current user is moderator
828        if (window.ChatroomMessageActions && window.ChatroomMessageActions.setupMessageHover) {
829            window.ChatroomMessageActions.setupMessageHover(messageDiv);
830        }
831        
832        scrollToBottom();
833    }
834
835    /**
836     * Switch UI to DM view
837     */
838    function switchToDM(otherUsername) {
839        // Hide server channel list, show recent DMs in channels column
840        const serverChannelsCategory = document.getElementById('server-channels-category');
841        const voiceChatCategory = document.getElementById('voice-chat-category');
842        const dmCallCategory = document.getElementById('dm-call-category');
843        const recentDmsCategory = document.getElementById('recent-dms-category');
844        if (serverChannelsCategory) serverChannelsCategory.style.display = 'none';
845        if (voiceChatCategory) voiceChatCategory.style.display = 'none';
846        if (recentDmsCategory) recentDmsCategory.style.display = 'block';
847        if (dmCallCategory) {
848            const inDmCall = window.ChatroomDMCalls && window.ChatroomDMCalls.getIsInCall();
849            const call = window.ChatroomDMCalls && window.ChatroomDMCalls.getCurrentCall();
850            dmCallCategory.style.display = (inDmCall && call && call.username === otherUsername) ? 'block' : 'none';
851        }
852
853        // Update header
854        const channelIcon = document.getElementById('channel-icon-header');
855        const channelName = document.getElementById('current-channel-name');
856        const channelTopic = document.getElementById('channel-topic');
857        
858        if (channelIcon) channelIcon.textContent = '💌';
859        if (channelName) channelName.textContent = otherUsername;
860        if (channelTopic) channelTopic.textContent = 'Direct message';
861
862        callUnread('markAllAsRead', 'dm_' + otherUsername);
863        callUnread('resetBanner');
864        if (window.ChatroomMessageMeta) {
865            if (window.ChatroomMessageMeta.bindDM) {
866                window.ChatroomMessageMeta.bindDM(otherUsername);
867            } else if (window.ChatroomMessageMeta.teardownChannelMeta) {
868                window.ChatroomMessageMeta.teardownChannelMeta();
869            }
870        }
871
872        // Add/update call button in header
873        setupCallButton(otherUsername);
874
875        // Update input placeholder
876        const messageInput = document.getElementById('message-input');
877        if (messageInput) {
878            messageInput.placeholder = `Message @${otherUsername}`;
879        }
880
881        // Update active state
882        document.querySelectorAll('.channel-item').forEach(item => {
883            item.classList.remove('active');
884        });
885        document.querySelectorAll('.dm-item, .nav-dm-item').forEach(item => {
886            item.classList.remove('active');
887            if (item.dataset.dm === otherUsername) {
888                item.classList.add('active');
889            }
890        });
891
892        // Populate channels column with recent DMs (sorted by activity)
893        updateChannelsColumnDMList();
894
895        if (window.ChatroomDMCalls && window.ChatroomDMCalls.refreshUi) {
896            window.ChatroomDMCalls.refreshUi();
897        }
898
899        // Clear messages
900        const messagesList = document.getElementById('messages-list');
901        if (messagesList) messagesList.innerHTML = '';
902    }
903
904    /**
905     * Setup call button in DM header
906     */
907    function setupCallButton(otherUsername) {
908        const headerRight = document.querySelector('.header-right');
909        if (!headerRight) return;
910
911        // Remove existing call button if any
912        const existingCallBtn = document.getElementById('dm-call-btn');
913        if (existingCallBtn) {
914            existingCallBtn.remove();
915        }
916
917        // Remove existing call status if any
918        const existingStatus = document.getElementById('dm-call-status');
919        if (existingStatus) {
920            existingStatus.remove();
921        }
922
923        // Only show call button if not in a call with someone else
924        const isInCall = window.ChatroomDMCalls && window.ChatroomDMCalls.getIsInCall();
925        const currentCall = window.ChatroomDMCalls && window.ChatroomDMCalls.getCurrentCall();
926        
927        if (isInCall && currentCall && currentCall.username !== otherUsername) {
928            // In a call with someone else, don't show button
929            return;
930        }
931
932        // Create call button
933        const callBtn = document.createElement('button');
934        callBtn.id = 'dm-call-btn';
935        callBtn.className = 'header-btn cr-icon-btn';
936        callBtn.title = isInCall ? 'End call' : 'Call';
937        if (window.ChatroomIcons) window.ChatroomIcons.set(callBtn, 'phone');
938        
939        if (isInCall && currentCall && currentCall.username === otherUsername) {
940            callBtn.classList.add('active');
941        }
942
943        // Add click handler
944        callBtn.addEventListener('click', () => {
945            if (isInCall && currentCall && currentCall.username === otherUsername) {
946                // End call
947                if (window.ChatroomDMCalls) {
948                    window.ChatroomDMCalls.endCall();
949                }
950            } else {
951                // Start call (with confirmation)
952                if (window.ChatroomDMCalls) {
953                    window.ChatroomDMCalls.startCallWithConfirmation(otherUsername);
954                }
955            }
956        });
957
958        // Insert before settings button
959        const settingsBtn = document.getElementById('settings-btn');
960        if (settingsBtn) {
961            headerRight.insertBefore(callBtn, settingsBtn);
962        } else {
963            headerRight.appendChild(callBtn);
964        }
965
966        // Create call status element (hidden by default)
967        const callStatus = document.createElement('span');
968        callStatus.id = 'dm-call-status';
969        callStatus.style.display = 'none';
970        callStatus.style.fontSize = '12px';
971        callStatus.style.color = 'var(--text-4)';
972        callStatus.style.marginRight = '8px';
973        
974        if (isInCall && currentCall && currentCall.username === otherUsername) {
975            callStatus.textContent = `Calling ${otherUsername}...`;
976            callStatus.style.display = 'block';
977        }
978
979        // Insert call status before call button
980        headerRight.insertBefore(callStatus, callBtn);
981    }
982
983    /**
984     * Update the recent DMs list in the channels column (when in DM view)
985     */
986    function updateChannelsColumnDMList() {
987        if (!currentUsername || !currentDM) return;
988        const dmList = document.getElementById('channels-column-dm-list');
989        if (!dmList) return;
990
991        // Same sorting logic as updateDMList
992        let dmHistory = [];
993        try {
994            dmHistory = JSON.parse(localStorage.getItem('chatroom_dm_history') || '[]');
995        } catch (e) { /* ignore */ }
996
997        dmsRef.once('value', (snapshot) => {
998            const conversations = snapshot.val();
999            const dmMap = new Map();
1000            if (conversations) {
1001                Object.keys(conversations).forEach(conversationId => {
1002                    const conversation = conversations[conversationId];
1003                    if (conversation.metadata && conversation.metadata.participants) {
1004                        const participants = conversation.metadata.participants;
1005                        if (participants.includes(currentUsername)) {
1006                            const otherUser = participants.find(u => u !== currentUsername);
1007                            if (otherUser) {
1008                                dmMap.set(otherUser, conversation.metadata.lastMessage || 0);
1009                            }
1010                        }
1011                    }
1012                });
1013            }
1014            dmHistory.forEach(username => {
1015                if (!dmMap.has(username)) dmMap.set(username, 0);
1016            });
1017            const sortedDMs = Array.from(dmMap.entries()).sort((a, b) => {
1018                if (a[1] > 0 && b[1] > 0) return b[1] - a[1];
1019                if (a[1] > 0 && b[1] === 0) return -1;
1020                if (a[1] === 0 && b[1] > 0) return 1;
1021                const aIndex = dmHistory.indexOf(a[0]);
1022                const bIndex = dmHistory.indexOf(b[0]);
1023                if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
1024                if (aIndex !== -1) return -1;
1025                if (bIndex !== -1) return 1;
1026                return 0;
1027            });
1028
1029            dmList.innerHTML = '';
1030            sortedDMs.forEach(([otherUser]) => {
1031                const dmItem = document.createElement('div');
1032                dmItem.className = 'dm-item';
1033                if (currentDM === otherUser) dmItem.classList.add('active');
1034                dmItem.dataset.dm = otherUser;
1035                dmItem.innerHTML = `
1036                    <span class="channel-prefix">💌</span>
1037                    <span class="channel-name">${escapeHtml(otherUser)}</span>
1038                `;
1039                dmItem.addEventListener('click', () => openDM(otherUser));
1040                dmList.appendChild(dmItem);
1041            });
1042        });
1043    }
1044
1045    /**
1046     * Update DM list in sidebar
1047     */
1048    function updateDMList() {
1049        if (!currentUsername) return;
1050
1051        // Try navigation column DM list first, fallback to channels column
1052        const dmList = document.getElementById('dm-list') || document.querySelector('.nav-dm-list');
1053        if (!dmList) return;
1054
1055        // Get DM history from localStorage
1056        let dmHistory = [];
1057        try {
1058            dmHistory = JSON.parse(localStorage.getItem('chatroom_dm_history') || '[]');
1059        } catch (e) {
1060            console.error('Error loading DM history:', e);
1061        }
1062
1063        // Get all conversations from Firebase
1064        dmsRef.once('value', (snapshot) => {
1065            const conversations = snapshot.val();
1066            const dmMap = new Map();
1067
1068            // Find all conversations this user is part of
1069            if (conversations) {
1070                Object.keys(conversations).forEach(conversationId => {
1071                    const conversation = conversations[conversationId];
1072                    if (conversation.metadata && conversation.metadata.participants) {
1073                        const participants = conversation.metadata.participants;
1074                        if (participants.includes(currentUsername)) {
1075                            const otherUser = participants.find(u => u !== currentUsername);
1076                            if (otherUser) {
1077                                dmMap.set(otherUser, conversation.metadata.lastMessage || 0);
1078                            }
1079                        }
1080                    }
1081                });
1082            }
1083
1084            // Merge with history (include DMs from history even if no recent messages)
1085            dmHistory.forEach(username => {
1086                if (!dmMap.has(username)) {
1087                    dmMap.set(username, 0);
1088                }
1089            });
1090
1091            // Sort by last message time, then by history order
1092            const sortedDMs = Array.from(dmMap.entries()).sort((a, b) => {
1093                // If both have messages, sort by timestamp
1094                if (a[1] > 0 && b[1] > 0) {
1095                    return b[1] - a[1];
1096                }
1097                // If one has messages and one doesn't, prioritize the one with messages
1098                if (a[1] > 0 && b[1] === 0) return -1;
1099                if (a[1] === 0 && b[1] > 0) return 1;
1100                // Both have no messages, sort by history order
1101                const aIndex = dmHistory.indexOf(a[0]);
1102                const bIndex = dmHistory.indexOf(b[0]);
1103                if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
1104                if (aIndex !== -1) return -1;
1105                if (bIndex !== -1) return 1;
1106                return 0;
1107            });
1108
1109            dmList.innerHTML = '';
1110
1111            sortedDMs.forEach(([otherUser, lastMessage]) => {
1112                const dmItem = document.createElement('div');
1113                // Use nav-dm-item if in navigation column, otherwise dm-item
1114                const isNavColumn = dmList.classList.contains('nav-dm-list');
1115                dmItem.className = isNavColumn ? 'nav-dm-item' : 'dm-item';
1116                if (currentDM === otherUser) {
1117                    dmItem.classList.add('active');
1118                }
1119                dmItem.dataset.dm = otherUser;
1120                dmItem.title = otherUser; // Tooltip for navigation column
1121                
1122                if (isNavColumn) {
1123                    // Navigation column: show profile picture or avatar/emoji
1124                    const profilePicture = window.ChatroomProfilePicture && window.ChatroomProfilePicture.getProfilePicture(otherUser);
1125                    if (profilePicture) {
1126                        dmItem.innerHTML = `<span><img src="${escapeHtml(profilePicture)}" alt="${escapeHtml(otherUser)}" class="profile-picture-avatar"></span>`;
1127                    } else {
1128                        dmItem.innerHTML = `<span>${getAvatar(otherUser)}</span>`;
1129                    }
1130                } else {
1131                    const inVoice =
1132                        window.ChatroomDMCalls &&
1133                        window.ChatroomDMCalls.getIsInCall() &&
1134                        window.ChatroomDMCalls.getCurrentCall() &&
1135                        window.ChatroomDMCalls.getCurrentCall().username === otherUser;
1136                    dmItem.innerHTML =
1137                        '<span class="dm-icon">💌</span>' +
1138                        '<span class="dm-name">' + escapeHtml(otherUser) + '</span>' +
1139                        (inVoice
1140                            ? '<span class="dm-in-voice-line">' +
1141                              (window.ChatroomIcons ? window.ChatroomIcons.icon('phone') : '📞') +
1142                              ' In voice</span>'
1143                            : '');
1144                    if (inVoice) {
1145                        dmItem.classList.add('dm-in-voice');
1146                    }
1147                }
1148                
1149                dmItem.addEventListener('click', () => {
1150                    openDM(otherUser);
1151                });
1152                dmList.appendChild(dmItem);
1153                
1154                // Track unread count for this DM
1155                if (window.ChatroomUnreadCount) {
1156                    window.ChatroomUnreadCount.trackDM(otherUser);
1157                }
1158            });
1159            
1160            // Update all avatars after DM list is populated
1161            setTimeout(() => {
1162                if (window.ChatroomProfilePicture && window.ChatroomProfilePicture.updateAllAvatars) {
1163                    window.ChatroomProfilePicture.updateAllAvatars();
1164                }
1165            }, 100);
1166
1167            // If in DM view, also update the channels column DM list
1168            if (currentDM) {
1169                updateChannelsColumnDMList();
1170            }
1171        });
1172    }
1173
1174    /**
1175     * Get avatar emoji for username
1176     */
1177    function getAvatar(username) {
1178        const avatars = ['🐾', '🐶', '🐕', '🦴', '🎾', '🧸', '⭐', '💫', '✨', '🌟'];
1179        const hash = username.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
1180        return avatars[hash % avatars.length];
1181    }
1182
1183    /**
1184     * Format timestamp
1185     */
1186    function formatTime(timestamp) {
1187        const date = new Date(timestamp);
1188        if (Number.isNaN(date.getTime())) return '';
1189        const now = new Date();
1190        const diff = now - date;
1191
1192        if (diff < 60000) return 'just now';
1193        if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
1194        if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
1195        
1196        return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1197    }
1198
1199    /**
1200     * Escape HTML
1201     */
1202    function escapeHtml(text) {
1203        const div = document.createElement('div');
1204        div.textContent = text;
1205        return div.innerHTML;
1206    }
1207
1208    /**
1209     * Scroll to bottom
1210     */
1211    function scrollToBottom() {
1212        const container = document.getElementById('messages-container');
1213        if (container) {
1214            container.scrollTop = container.scrollHeight;
1215        }
1216    }
1217
1218    /**
1219     * Get current DM
1220     */
1221    function getCurrentDM() {
1222        return currentDM;
1223    }
1224
1225    // Initialize on load
1226    if (document.readyState === 'loading') {
1227        document.addEventListener('DOMContentLoaded', init);
1228    } else {
1229        init();
1230    }
1231
1232    /**
1233     * Clear current DM (switch back to channel)
1234     */
1235    function clearCurrentDM() {
1236        currentDM = null;
1237        
1238        // Remove from localStorage
1239        try {
1240            localStorage.removeItem('chatroom_current_dm');
1241        } catch (e) {
1242            console.error('Error clearing DM state:', e);
1243        }
1244        
1245        // Remove all DM listeners
1246        dmListeners.forEach((ref) => {
1247            ref.off('child_added');
1248        });
1249        dmListeners.clear();
1250        
1251        // Update UI - unhighlight all DM icons immediately (nav and channels column)
1252        document.querySelectorAll('.dm-item, .nav-dm-item').forEach(item => {
1253            item.classList.remove('active');
1254        });
1255        
1256        // Remove DM call button and status immediately
1257        const callBtn = document.getElementById('dm-call-btn');
1258        const callStatus = document.getElementById('dm-call-status');
1259        if (callBtn) callBtn.remove();
1260        if (callStatus) callStatus.remove();
1261        
1262        // Show server channels, hide recent DMs
1263        const serverChannelsCategory = document.getElementById('server-channels-category');
1264        const voiceChatCategory = document.getElementById('voice-chat-category');
1265        const dmCallCategory = document.getElementById('dm-call-category');
1266        const recentDmsCategory = document.getElementById('recent-dms-category');
1267        const dmCallStage = document.getElementById('dm-call-stage');
1268        const chatArea = document.querySelector('.chat-area');
1269        if (serverChannelsCategory) serverChannelsCategory.style.display = '';
1270        if (dmCallCategory) dmCallCategory.style.display = 'none';
1271        if (dmCallStage) dmCallStage.hidden = true;
1272        if (chatArea) chatArea.classList.remove('dm-call-active');
1273        // Restore voice chat visibility based on connection state
1274        if (voiceChatCategory) {
1275            const isConnected = window.ChatroomVoiceChat && window.ChatroomVoiceChat.getStatus && window.ChatroomVoiceChat.getStatus().connected;
1276            voiceChatCategory.style.display = isConnected ? 'block' : 'none';
1277        }
1278        if (recentDmsCategory) recentDmsCategory.style.display = 'none';
1279    }
1280
1281    /**
1282     * Restore last open DM from localStorage
1283     */
1284    function restoreLastDM() {
1285        if (!currentUsername) {
1286            setTimeout(restoreLastDM, 500);
1287            return;
1288        }
1289
1290        try {
1291            const lastDM = localStorage.getItem('chatroom_current_dm');
1292            if (lastDM && lastDM !== currentUsername) {
1293                // Verify user still exists in DM history
1294                const dmHistory = JSON.parse(localStorage.getItem('chatroom_dm_history') || '[]');
1295                if (dmHistory.includes(lastDM)) {
1296                    // Small delay to ensure UI is ready
1297                    setTimeout(() => {
1298                        openDM(lastDM);
1299                        console.log('💌 Restored last DM:', lastDM);
1300                    }, 800);
1301                } else {
1302                    // Clear invalid DM state
1303                    localStorage.removeItem('chatroom_current_dm');
1304                }
1305            }
1306        } catch (e) {
1307            console.error('Error restoring last DM:', e);
1308        }
1309    }
1310
1311    function refreshDMMessagesLayout() {
1312        if (!currentDM || !currentUsername) return;
1313        const conversationId = [currentUsername, currentDM].sort().join('_');
1314        const cached = dmCache.get(conversationId);
1315        if (!cached || !cached.messages || !cached.messages.length) return;
1316
1317        const scrollContainer = document.getElementById('messages-container');
1318        const scrollTop = scrollContainer ? scrollContainer.scrollTop : 0;
1319        const atBottom = scrollContainer
1320            ? scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollC
1320ontainer.clientHeight < 80
1321            : false;
1322
1323        const messagesObj = {};
1324        cached.messages.forEach(function (m) {
1325            messagesObj[m.id || m.timestamp] = m;
1326        });
1327        displayDMMessages(messagesObj, currentDM);
1328
1329        if (scrollContainer) {
1330            if (atBottom && window.ChatroomSettings?.getSetting?.('autoScrollNewMessages') !== false) {
1331                scrollToBottom();
1332            } else {
1333                scrollContainer.scrollTop = scrollTop;
1334            }
1335        }
1336    }
1337
1338    function removeMessageFromCache(messageId) {
1339        if (!messageId || !currentUsername || !currentDM) return;
1340        const conversationId = [currentUsername, currentDM].sort().join('_');
1341        const cached = dmCache.get(conversationId);
1342        if (!cached || !cached.messages || !cached.messages.length) return;
1343        cached.messages = cached.messages.filter(function (m) {
1344            return m && m.id !== messageId;
1345        });
1346        displayedMessageIds.delete(messageId);
1347    }
1348
1349    document.addEventListener('chatroom-messages-layout-refresh', refreshDMMessagesLayout);
1350
1351    return {
1352        openDM,
1353        sendDM,
1354        getCurrentDM,
1355        clearCurrentDM,
1356        restoreLastDM,
1357        updateDMList,
1358        setupCallButton,
1359        refreshDMMessagesLayout,
1360        removeMessageFromCache,
1361        init
1362    };
1363})();

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.