您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Messagerie dynamique
当前为
// ==UserScript== // @name Dreadcast Dynamic Messages V1 // @namespace http://tampermonkey.net/ // @version 1.0.1 // @description Messagerie dynamique // @author Laïn // @match https://www.dreadcast.net/Main* // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant unsafeWindow // @grant GM_info // ==/UserScript== (function() { 'use strict'; // --- Global Variables & Constants --- let MY_NAME = null; const ACTIVE_CONVERSATIONS = {}; // Stores { customWindow, originalWindow, latestMessageId, oldestMessageId, allMessagesLoaded, isLoadingOlder, participants, hasUnreadNotification, muteTimerIntervalId } let openingMutedOverride = null; let openingMutedOverrideTimer = null; const INITIAL_LOAD_COUNT = 20; const LOAD_MORE_COUNT = 25; // Click simulation delays const REFIND_DELAY = 50; const UI_CLICK_DELAY = 50; const UI_WAIT_DELAY = 100; const WAIT_FOR_ELEMENT_TIMEOUT = 1500; const NOTIFICATION_SOUND_URL = 'https://opengameart.org/sites/default/files/audio_preview/GUI%20Sound%20Effects_031.mp3.ogg'; const UNOPENED_NOTIFICATION_SOUND_URL = 'https://orangefreesounds.com/wp-content/uploads/2020/10/Simple-notification-alert.mp3'; // --- Version Info --- const SCRIPT_VERSION = '0.9.9-global-mute'; // <<< UPDATED version number // --- UI Constants --- const MIN_WINDOW_WIDTH = 300; const MIN_WINDOW_HEIGHT = 200; const DEFAULT_THEME_COLOR = '#0b5a9c'; const GLOBAL_THEME_STORAGE_KEY = 'dmm_theme_color_v1'; const CONVERSATION_COLORS_STORAGE_KEY = 'dmm_conversation_colors_v2'; // Notification Colors const UNREAD_NOTIFICATION_COLOR = '#cca300'; const UNREAD_TEXT_COLOR = '#101010'; const UNREAD_BORDER_COLOR = '#b0891a'; // Sidebar Mute Highlight Color const SIDEBAR_MUTED_COLOR = '#ff6666'; // A noticeable red/pink // --- Global Sound Mute State --- <<< NEW >>> let isGloballyMuted = false; const GLOBAL_SOUND_MUTE_STORAGE_KEY = 'dmm_global_sound_mute_v1'; const GLOBAL_MUTE_BUTTON_ID = 'dmm-global-mute-button'; // --- Mute Constants & Storage (v3 - Timed Mutes + Selected Duration) --- const MUTED_CONVERSATIONS_STORAGE_KEY_V3 = 'dmm_muted_conversations_v3'; // New key for new format const MUTE_DURATIONS = { // milliseconds, null for forever, 0 for unmute UNMUTE: 0, TWO_MINUTES: 2 * 60 * 1000, FIFTEEN_MINUTES: 15 * 60 * 1000, ONE_HOUR: 60 * 60 * 1000, FOREVER: null }; // --- Global Sound Mute Utilities --- <<< NEW >>> function loadGlobalMuteState() { try { const storedValue = localStorage.getItem(GLOBAL_SOUND_MUTE_STORAGE_KEY); isGloballyMuted = storedValue === 'true'; // localStorage stores strings // console.log(`DMM Global Mute: Initial state loaded: ${isGloballyMuted}`); } catch (e) { console.error("DMM Global Mute: Failed to load state from localStorage.", e); isGloballyMuted = false; // Default to unmuted on error } } function saveGlobalMuteState() { try { localStorage.setItem(GLOBAL_SOUND_MUTE_STORAGE_KEY, String(isGloballyMuted)); // console.log(`DMM Global Mute: State saved: ${isGloballyMuted}`); } catch (e) { console.error("DMM Global Mute: Failed to save state to localStorage.", e); } } function updateGlobalMuteButtonAppearance() { const button = document.getElementById(GLOBAL_MUTE_BUTTON_ID); if (button) { if (isGloballyMuted) { button.textContent = '🔇'; // Muted icon button.title = 'Activer les sons du script DMM'; button.style.textDecoration = 'line-through'; button.style.opacity = '0.7'; button.style.fontSize = '1.5em'; // 50% larger } else { button.textContent = '🔈'; // Unmuted icon button.title = 'Couper les sons du script DMM'; button.style.textDecoration = 'none'; button.style.opacity = '1'; button.style.fontSize = '1.5em'; // 50% larger } } } async function createGlobalMuteButton() { try { const newsDiv = await waitForElement('.news', 5000); // Wait for the news div if (!newsDiv || document.getElementById(GLOBAL_MUTE_BUTTON_ID)) { if (!newsDiv) console.warn("DMM Global Mute: '.news' div not found to attach button."); return; // Don't add if already exists or target not found } const muteButton = document.createElement('span'); // Using span for inline flow muteButton.id = GLOBAL_MUTE_BUTTON_ID; muteButton.style.cursor = 'pointer'; muteButton.style.marginLeft = '10px'; // Space after news link muteButton.style.fontSize = '1em'; // Adjust size if needed muteButton.style.verticalAlign = 'middle'; // Align with text muteButton.style.zIndex = '9999999999'; // Very high z-index as requested muteButton.style.position = 'relative'; // Needed for z-index to reliably apply vs static elements muteButton.addEventListener('click', () => { isGloballyMuted = !isGloballyMuted; saveGlobalMuteState(); updateGlobalMuteButtonAppearance(); console.log(`DMM Global Mute: Toggled to ${isGloballyMuted}`); }); // Insert the button after the news div newsDiv.insertAdjacentElement('afterend', muteButton); // Set initial appearance based on loaded state updateGlobalMuteButtonAppearance(); console.log("DMM Global Mute: Button created and attached."); } catch (error) { console.error("DMM Global Mute: Error creating or attaching global mute button:", error); } } // --- End Global Sound Mute Utilities --- function getMutedData() { try { // Prioritize V3, fallback to V2 for migration (optional, simpler to just use V3) let stored = localStorage.getItem(MUTED_CONVERSATIONS_STORAGE_KEY_V3); if (!stored) { // Optional: Add migration logic here if needed from V2 // For simplicity, we'll just start fresh with V3 stored = '{}'; // Start with empty V3 data // console.log("DMM Mute: Initializing V3 mute storage."); // Less verbose } const parsed = JSON.parse(stored); // Basic validation: ensure it's an object return (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) ? parsed : {}; } catch (e) { console.error("DMM: Failed to parse muted conversation data (v3) from localStorage.", e); return {}; // Return empty object on error } } function saveMutedData(muteDataObject) { if (typeof muteDataObject !== 'object' || muteDataObject === null || Array.isArray(muteDataObject)) { console.error("DMM SaveMutedData: Attempted to save non-object:", muteDataObject); return; } try { localStorage.setItem(MUTED_CONVERSATIONS_STORAGE_KEY_V3, JSON.stringify(muteDataObject)); } catch (e) { console.error("DMM: Failed to save muted conversation data (v3) to localStorage.", e); } } // Checks if a conversation is *currently* muted, cleans up expired entries AND updates sidebar UI function isConversationMuted(conversationId) { const idStr = String(conversationId); if (!idStr) return false; let mutedData = getMutedData(); const entry = mutedData[idStr]; let isCurrentlyMuted = false; let dataNeedsSaving = false; let needsSidebarUpdate = false; // Flag if (entry && typeof entry === 'object') { const endTime = entry.muteEndTime; if (endTime === null) { // Permanent mute isCurrentlyMuted = true; } else if (typeof endTime === 'number' && endTime > 0) { if (Date.now() < endTime) { // Timed mute still active isCurrentlyMuted = true; } else { // Mute expired! Clean up. // console.log(`%cDMM Mute Check: Mute expired for ${idStr}. Removing entry.`, "color: gray;"); // Less verbose delete mutedData[idStr]; dataNeedsSaving = true; isCurrentlyMuted = false; needsSidebarUpdate = true; // Trigger sidebar update on expiry } } else { // Invalid entry (missing endTime or invalid type), clean up console.warn(`DMM Mute Check: Found invalid mute entry format for ${idStr}. Removing.`, entry); delete mutedData[idStr]; dataNeedsSaving = true; isCurrentlyMuted = false; needsSidebarUpdate = true; // Trigger sidebar update on cleanup } } else { // No entry or invalid entry type if (entry) { // If entry existed but was invalid type console.warn(`DMM Mute Check: Found invalid mute entry type for ${idStr}. Removing.`, entry); delete mutedData[idStr]; dataNeedsSaving = true; needsSidebarUpdate = true; // Trigger sidebar update on cleanup } isCurrentlyMuted = false; } if (dataNeedsSaving) { saveMutedData(mutedData); } if (needsSidebarUpdate) { // NEW: Update sidebar if status changed due to expiry/cleanup updateSidebarMuteStatus(idStr); } return isCurrentlyMuted; } // Gets the current mute end time (null for permanent, 0 if not muted/expired, timestamp otherwise) function getConversationMuteEndTime(conversationId) { const idStr = String(conversationId); if (!idStr) return 0; // Treat as not muted let mutedData = getMutedData(); const entry = mutedData[idStr]; if (entry && typeof entry === 'object') { const endTime = entry.muteEndTime; if (endTime === null) { return null; // Permanent } else if (typeof endTime === 'number' && endTime > 0) { if (Date.now() < endTime) { return endTime; // Active timed mute } else { // Expired, trigger potential cleanup and return 0 isConversationMuted(idStr); // Trigger cleanup side-effect (which now also updates sidebar) return 0; // Treat as not muted } } } // No valid entry or expired return 0; // Treat as not muted } // Gets the originally selected mute duration (used for checkmarks) function getConversationMuteSelectedDuration(conversationId) { const idStr = String(conversationId); if (!idStr) return MUTE_DURATIONS.UNMUTE; // Treat as unmuted const mutedData = getMutedData(); const entry = mutedData[idStr]; // First, check if it's *currently* muted at all if (!isConversationMuted(idStr)) { return MUTE_DURATIONS.UNMUTE; // Return Unmute duration if not currently muted } // If muted, retrieve the stored selected duration if (entry && typeof entry === 'object' && (typeof entry.selectedDuration === 'number' || entry.selectedDuration === null)) { return entry.selectedDuration; } // Fallback if data is somehow inconsistent (shouldn't happen with proper saving) console.warn(`DMM getSelectedDuration: Mute entry exists for ${idStr} but selectedDuration is missing/invalid.`, entry); // Try to infer based on endTime const endTime = entry?.muteEndTime; if (endTime === null) return MUTE_DURATIONS.FOREVER; // Cannot reliably infer timed duration, default to Unmute status display return MUTE_DURATIONS.UNMUTE; } // Sets mute status AND updates sidebar UI function setConversationMuted(conversationId, durationMs) { const idStr = String(conversationId); if (!idStr) return; let mutedData = getMutedData(); let needsSave = false; const wasPreviouslyMuted = isConversationMuted(idStr); // Check *before* changing if (durationMs === MUTE_DURATIONS.UNMUTE) { // Unmute if (mutedData[idStr]) { delete mutedData[idStr]; needsSave = true; console.log(`%cDMM Mute Set: Unmuted conversation ${idStr}.`, "color: green;"); } } else if (durationMs === MUTE_DURATIONS.FOREVER) { // Mute forever if (!mutedData[idStr] || mutedData[idStr]?.muteEndTime !== null || mutedData[idStr]?.selectedDuration !== durationMs) { mutedData[idStr] = { muteEndTime: null, selectedDuration: MUTE_DURATIONS.FOREVER }; // Store duration needsSave = true; console.log(`%cDMM Mute Set: Muted conversation ${idStr} FOREVER.`, "color: orange;"); } } else if (typeof durationMs === 'number' && durationMs > 0) { // Timed mute const endTime = Date.now() + durationMs; // Update only if end time or selected duration changes if (!mutedData[idStr] || mutedData[idStr]?.muteEndTime !== endTime || mutedData[idStr]?.selectedDuration !== durationMs) { mutedData[idStr] = { muteEndTime: endTime, selectedDuration: durationMs }; // Store duration needsSave = true; const durationMinutes = durationMs / (60 * 1000); console.log(`%cDMM Mute Set: Muted conversation ${idStr} for ${durationMinutes} minutes (until ${new Date(endTime).toLocaleTimeString()}).`, "color: orange;"); } } else { console.warn(`DMM Mute Set: Invalid duration provided for ${idStr}:`, durationMs); } if (needsSave) { saveMutedData(mutedData); } const isNowMuted = isConversationMuted(idStr); // Check *after* changing // --- Trigger UI Updates --- // 1. Update DMM Window (Header, Menu Checkmarks/Timers, Theme) const convData = ACTIVE_CONVERSATIONS[idStr]; if (convData?.customWindow && document.body.contains(convData.customWindow)) { updateHeaderMuteStatus(convData.customWindow, idStr); updateMuteOptionsUI(convData.customWindow, idStr); // <<< This now updates timer too applyCurrentTheme(convData.customWindow, idStr); } // 2. Update Sidebar List Item (if status changed) if (wasPreviouslyMuted !== isNowMuted || needsSave) { // Update if status flipped or if save happened (covers initial mute) updateSidebarMuteStatus(idStr); } } // --- End Mute Utilities (v3 + Sidebar Update Trigger) --- // --- Sidebar Mute UI Update Functions --- /** * Updates the visual style of a conversation item in the main message list (#liste_messages) * based on its current mute status. * @param {string} conversationId The ID of the conversation. */ function updateSidebarMuteStatus(conversationId) { const listItem = document.getElementById(`message_${conversationId}`); if (!listItem) { // console.log(`DMM updateSidebarMuteStatus: LI #message_${conversationId} not found in DOM.`); return; // Element not visible (e.g., different folder) or doesn't exist } const titleElement = listItem.querySelector('.message_titre'); if (!titleElement) { console.warn(`DMM updateSidebarMuteStatus: .message_titre not found within #message_${conversationId}`); return; } const currentlyMuted = isConversationMuted(conversationId); // Use the function that handles expiry checks if (currentlyMuted) { listItem.classList.add('dmm-muted-sidebar-item'); } else { listItem.classList.remove('dmm-muted-sidebar-item'); } } /** * Scans all visible message list items in the sidebar and updates their mute status highlighting. * Should be called on initial load and when the list content changes significantly. */ function scanAndUpdateSidebarMutes() { const messageListItems = document.querySelectorAll('#liste_messages li.message[id^="message_"]'); // console.log(`%cDMM scanAndUpdateSidebarMutes: Found ${messageListItems.length} items to check.`, "color: gray"); messageListItems.forEach(item => { const conversationId = item.id.replace('message_', ''); if (conversationId) { updateSidebarMuteStatus(conversationId); } }); } // --- End Sidebar Mute UI Update Functions --- // --- Mute UI Update Functions --- /** * Updates the mute status display in the chat window header. * @param {HTMLElement} chatWindow - The custom chat window element. * @param {string} conversationId - The ID of the conversation. */ function updateHeaderMuteStatus(chatWindow, conversationId) { if (!chatWindow || !document.body.contains(chatWindow)) return; const muteStatusDisplay = chatWindow.querySelector('.custom-chat-head .mute-status-display'); if (!muteStatusDisplay) return; const endTime = getConversationMuteEndTime(conversationId); // null (forever), 0 (unmuted), or timestamp if (endTime === null) { // Permanent mute muteStatusDisplay.textContent = '🔈 Muted'; muteStatusDisplay.style.display = 'inline-block'; muteStatusDisplay.title = 'Cette conversation est muette de façon permanente.'; } else if (endTime > 0) { // Timed mute active (endTime is a future timestamp) const now = Date.now(); if (endTime > now) { const remainingSeconds = Math.round((endTime - now) / 1000); const remainingMinutes = Math.ceil(remainingSeconds / 60); if (remainingMinutes > 1) { muteStatusDisplay.textContent = `🔈 ${remainingMinutes} min`; muteStatusDisplay.title = `Muet pour encore ${remainingMinutes} minutes (jusqu'à ${new Date(endTime).toLocaleTimeString()}).`; } else if (remainingSeconds > 0) { muteStatusDisplay.textContent = '🔈 <1 min'; muteStatusDisplay.title = `Muet pour moins d'une minute (jusqu'à ${new Date(endTime).toLocaleTimeString()}).`; } else { // Should technically be caught by getConversationMuteEndTime returning 0, but safe fallback muteStatusDisplay.style.display = 'none'; muteStatusDisplay.textContent = ''; muteStatusDisplay.title = ''; } muteStatusDisplay.style.display = 'inline-block'; } else { // Mute just expired, hide display (isConversationMuted will handle cleanup later) muteStatusDisplay.style.display = 'none'; muteStatusDisplay.textContent = ''; muteStatusDisplay.title = ''; } } else { // Not muted (endTime is 0 or invalid) muteStatusDisplay.style.display = 'none'; muteStatusDisplay.textContent = ''; muteStatusDisplay.title = ''; } } /** * Updates the checkmarks, styles, and timer display for mute options in the menu. * @param {HTMLElement} chatWindow - The custom chat window element containing the menu. * @param {string} conversationId - The ID of the conversation. */ function updateMuteOptionsUI(chatWindow, conversationId) { // <<< MODIFIED >>> if (!chatWindow || !document.body.contains(chatWindow)) return; const muteOptionsContainer = chatWindow.querySelector('.more-opts-menu .mute-options-container'); if (!muteOptionsContainer) return; const currentEndTime = getConversationMuteEndTime(conversationId); // null (forever), 0 (unmuted), or timestamp const currentlySelectedDuration = getConversationMuteSelectedDuration(conversationId); // 0, null, or duration ms const isTimedMuteActive = typeof currentEndTime === 'number' && currentEndTime > 0; muteOptionsContainer.querySelectorAll('.mute-option-item').forEach(item => { const checkmark = item.querySelector('.checkmark'); const textSpan = item.querySelector('.item-text'); // Get the text span if (!checkmark || !textSpan) return; const itemDurationStr = item.dataset.duration; let itemDuration; if (itemDurationStr === 'null') itemDuration = null; else itemDuration = parseInt(itemDurationStr, 10); // --- Restore Original Label --- // Store original label if not already stored if (!item.dataset.originalLabel) { item.dataset.originalLabel = textSpan.textContent; } // Always reset to original label before adding timer or checkmark styling textSpan.textContent = item.dataset.originalLabel; // --- Reset styles --- checkmark.style.display = 'none'; item.style.fontWeight = 'normal'; // Reset font weight // --- Check if this item matches the currently active selection --- if (itemDuration === currentlySelectedDuration) { checkmark.style.display = 'inline'; // Show checkmark item.style.fontWeight = 'bold'; // Optional: make selected bold // --- Add Timer Display if this is the ACTIVE TIMED mute --- if (isTimedMuteActive && itemDuration === currentlySelectedDuration && typeof itemDuration === 'number' && itemDuration > 0) { const remainingMs = currentEndTime - Date.now(); const formattedTime = formatRemainingTime(remainingMs); if (formattedTime) { // Append timer to the (restored) original label textSpan.textContent += ` (${formattedTime})`; item.title = `${item.dataset.originalLabel} (Fin: ${new Date(currentEndTime).toLocaleTimeString()})`; // Update title too } else { // Mute expired just now? Reset title item.title = item.dataset.originalLabel; } } else { // Reset title if it's not the active timed mute item.title = item.dataset.originalLabel; } } else { // Reset title if it's not selected at all item.title = item.dataset.originalLabel; } }); // --- End forEach item --- // Dim "Unmute" if already unmuted const unmuteItem = muteOptionsContainer.querySelector('.mute-option-item[data-duration="0"]'); if (unmuteItem) { const isCurrentlyUnmuted = (currentEndTime === 0); unmuteItem.style.opacity = isCurrentlyUnmuted ? '0.6' : '1'; unmuteItem.style.cursor = isCurrentlyUnmuted ? 'default' : 'pointer'; // Ensure checkmark and bold are correctly applied if Unmute is the "selected" state if (isCurrentlyUnmuted && currentlySelectedDuration === MUTE_DURATIONS.UNMUTE) { const unmuteCheckmark = unmuteItem.querySelector('.checkmark'); if (unmuteCheckmark) unmuteCheckmark.style.display = 'inline'; unmuteItem.style.fontWeight = 'bold'; // Also bold if selected } } } // --- End updateMuteOptionsUI --- // --- End Mute UI Update Functions --- // --- Observers --- let mainObserver = null; // Observes body for added original message windows let sidebarObserver = null; // Observes the #liste_messages content UL for changes let sidebarScanDebounceTimer = null; // Timer for debouncing sidebar scans // --- Utility Functions --- function getMyCharacterName() { const nameElement = document.getElementById('txt_pseudo'); if (nameElement) return nameElement.textContent.trim(); console.error("DMM: #txt_pseudo not found?"); return null; } function waitForElement(selector, timeout = WAIT_FOR_ELEMENT_TIMEOUT, container = document) { return new Promise((resolve, reject) => { const startTime = Date.now(); const interval = setInterval(() => { try { const element = container.querySelector(selector); // Check visibility more robustly if (element && document.body.contains(element) && element.offsetParent !== null && getComputedStyle(element).visibility !== 'hidden' && getComputedStyle(element).display !== 'none') { clearInterval(interval); resolve(element); } else if (Date.now() - startTime > timeout) { clearInterval(interval); reject(new Error(`Element ${selector} not found or not visible within ${timeout}ms`)); } } catch (e) { clearInterval(interval); console.error(`DMM waitForElement: Error during querySelector for "${selector}"`, e); reject(new Error(`Error finding element ${selector}: ${e.message}`)); } }, 50); }); } /** * Formats remaining milliseconds into a user-friendly string. * @param {number} ms - Milliseconds remaining. * @returns {string} Formatted time string (e.g., "15 min left", "<1 min left"). */ function formatRemainingTime(ms) { // <<< NEW HELPER >>> if (ms <= 0) return ""; // No time left or invalid input const totalSeconds = Math.round(ms / 1000); const minutes = Math.floor(totalSeconds / 60); // const seconds = totalSeconds % 60; // Not using seconds for now if (minutes >= 1) { return `${minutes} min`; } else if (totalSeconds > 0) { return "<1 min"; } else { return ""; // Should already be caught by ms <= 0, but safe fallback } } function bringWindowToFront(window) { // Get all DMM windows const allWindows = document.querySelectorAll('.custom-chat-window'); let maxZ = 999999; // Base z-index // Find highest current z-index allWindows.forEach(w => { const z = parseInt(getComputedStyle(w).zIndex) || 0; maxZ = Math.max(maxZ, z); }); // Set the clicked window higher than all others window.style.zIndex = (maxZ + 1).toString(); } // --- Theme Color Management --- function getSavedGlobalThemeColor() { return localStorage.getItem(GLOBAL_THEME_STORAGE_KEY) || DEFAULT_THEME_COLOR; } function getConversationColors() { try { const stored = localStorage.getItem(CONVERSATION_COLORS_STORAGE_KEY); return stored ? JSON.parse(stored) : {}; } catch (e) { console.error("DMM: Failed to parse conversation colors from localStorage.", e); return {}; } } function saveConversationColors(allColors) { try { localStorage.setItem(CONVERSATION_COLORS_STORAGE_KEY, JSON.stringify(allColors)); } catch (e) { console.error("DMM: Failed to save conversation colors to localStorage.", e); } } function getConversationSetting(conversationId) { const allColors = getConversationColors(); const setting = allColors[conversationId]; if (setting && typeof setting === 'object' && typeof setting.enabled === 'boolean' && typeof setting.color === 'string') { return setting; } return { enabled: false, color: DEFAULT_THEME_COLOR }; } function setConversationSetting(conversationId, setting) { if (typeof setting !== 'object' || typeof setting.enabled !== 'boolean' || typeof setting.color !== 'string') { console.warn(`DMM: Invalid setting provided for conversation ${conversationId}:`, setting); return; } const allColors = getConversationColors(); allColors[conversationId] = setting; saveConversationColors(allColors); } function applyCurrentTheme(chatWindow, conversationId) { if (!chatWindow || !conversationId) return; const specificSetting = getConversationSetting(conversationId); const globalColor = getSavedGlobalThemeColor(); const effectiveColor = specificSetting.enabled ? specificSetting.color : globalColor; // Apply standard theme colors chatWindow.style.setProperty('--dmm-primary-color', effectiveColor); chatWindow.style.setProperty('--dmm-header-bg', `color-mix(in srgb, ${effectiveColor} 70%, #080808)`); chatWindow.style.setProperty('--dmm-button-hover-bg', `color-mix(in srgb, ${effectiveColor} 85%, #ffffff)`); chatWindow.style.setProperty('--dmm-bubble-timestamp', `color-mix(in srgb, ${effectiveColor} 40%, #ffffff)`); chatWindow.style.setProperty('--dmm-border-color', effectiveColor); chatWindow.style.setProperty('--dmm-menu-hover-bg', effectiveColor); chatWindow.style.setProperty('--dmm-resize-border', `color-mix(in srgb, ${effectiveColor} 50%, #667788)`); // Re-apply notification style if needed (theme change shouldn't clear it) const convData = ACTIVE_CONVERSATIONS[conversationId]; const isMuted = isConversationMuted(conversationId); if (convData?.hasUnreadNotification && !isMuted) { chatWindow.classList.add('has-unread-notification'); } else { chatWindow.classList.remove('has-unread-notification'); } } function saveGlobalThemeColor(newColor) { localStorage.setItem(GLOBAL_THEME_STORAGE_KEY, newColor); for (const [convId, convData] of Object.entries(ACTIVE_CONVERSATIONS)) { if (convData.customWindow && document.body.contains(convData.customWindow)) { applyCurrentTheme(convData.customWindow, convId); } } } // --- Styles --- function addChatStyles() { GM_addStyle(` /* Styles pour la fenêtre de chat personnalisée */ .custom-chat-window { --dmm-primary-color: ${DEFAULT_THEME_COLOR}; /* Fallback */ --dmm-header-bg: color-mix(in srgb, var(--dmm-primary-color) 70%, #080808); --dmm-button-hover-bg: color-mix(in srgb, var(--dmm-primary-color) 85%, #ffffff); --dmm-bubble-timestamp: color-mix(in srgb, var(--dmm-primary-color) 40%, #ffffff); --dmm-border-color: var(--dmm-primary-color); --dmm-menu-hover-bg: var(--dmm-primary-color); --dmm-resize-border: color-mix(in srgb, var(--dmm-primary-color) 50%, #667788); position: fixed; z-index: 999999; width: 450px; height: 600px; max-height: 95vh; max-width: 95vw; min-width: ${MIN_WINDOW_WIDTH}px; min-height: ${MIN_WINDOW_HEIGHT}px; border: 1px solid var(--dmm-border-color); background-color: #101010; color: #e0e0e0; border-radius: 5px; box-shadow: 0 0 15px color-mix(in srgb, var(--dmm-primary-color) 30%, transparent); display: flex; flex-direction: column; top: 100px; left: calc(50% - 225px); overflow: hidden; transition: max-height 0.3s ease-out, border-color 0.3s ease-in-out, box-shadow 0.3s ease-in-out; /* Added transitions */ } .custom-chat-head { background-color: var(--dmm-header-bg); color: #fff; padding: 6px 10px; font-weight: bold; border-bottom: 1px solid var(--dmm-border-color); cursor: move; display: flex; justify-content: space-between; align-items: center; border-radius: 5px 5px 0 0; flex-shrink: 0; position: relative; transition: background-color 0.3s ease-in-out, color 0.3s ease-in-out, border-bottom-color 0.3s ease-in-out; /* Add transition for smooth color change */ } /* Style for Unread Notification */ .custom-chat-window.has-unread-notification { border-color: ${UNREAD_BORDER_COLOR}; /* Change main border */ box-shadow: 0 0 15px color-mix(in srgb, ${UNREAD_NOTIFICATION_COLOR} 40%, transparent); /* Change shadow color */ } .custom-chat-window.has-unread-notification .custom-chat-head { background-color: ${UNREAD_NOTIFICATION_COLOR}; color: ${UNREAD_TEXT_COLOR}; border-bottom-color: ${UNREAD_BORDER_COLOR}; } .custom-chat-window.has-unread-notification .custom-chat-head .title { color: ${UNREAD_TEXT_COLOR}; /* Ensure title text is dark */ } .custom-chat-window.has-unread-notification .custom-chat-head .controls span { color: #333; /* Darker control icons on yellow */ } .custom-chat-window.has-unread-notification .custom-chat-head .controls span:hover { color: #000; /* Black on hover */ } /* End Unread Notification */ .custom-chat-head .title { flex-grow: 1; padding-right: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* Styles for Header Mute Status */ .custom-chat-head .mute-status-display { font-size: 0.9em; color: #ffcc66; /* Light orange/yellow */ margin-left: 10px; /* Space after title */ font-weight: normal; display: none; /* Hidden by default */ vertical-align: middle; /* Align with title text */ white-space: nowrap; /* Prevent wrapping */ } /* Unread notification state override for mute status */ .custom-chat-window.has-unread-notification .custom-chat-head .mute-status-display { color: var(--dmm-primary-color); /* Use theme color on yellow bg for contrast */ } .custom-chat-head .controls { display: flex; align-items: center; flex-shrink: 0; white-space: nowrap; } .custom-chat-head .controls span { cursor: pointer; padding: 0 5px; font-size: 1.2em; font-family: Arial, sans-serif; font-weight: bold; user-select: none; line-height: 1; transition: color 0.2s ease; } .custom-chat-head .controls span:hover { color: #ffdddd; } .custom-chat-content { flex-grow: 1; overflow-y: auto; padding: 10px; background-color: #1a1a1a; display: flex; flex-direction: column; gap: 5px; transition: opacity 0.2s ease-out; } .custom-chat-content.loading::after { content: "Chargement des messages..."; display: block; text-align: center; padding: 20px; color: #ccc; font-style: italic; } .load-more-container { text-align: center; padding: 8px; border-bottom: 1px solid #2a2a2a; margin-bottom: 5px; flex-shrink: 0; } .load-more-link { color: #87ceeb; cursor: pointer; text-decoration: underline; font-size: 0.9em; } .load-more-link:hover { color: #aaeebb; } .load-more-link.loading { color: #aaa; cursor: default; text-decoration: none; } .load-more-link.loading::before { content: "Chargement... "; } .chat-bubble { max-width: 80%; padding: 8px 14px; border-radius: 18px; margin-bottom: 4px; line-height: 1.4; word-wrap: break-word; position: relative; } .bubble-content { white-space: pre-wrap; } .my-bubble { background-color: var(--dmm-primary-color); color: #ffffff; align-self: flex-end; border-bottom-right-radius: 5px; transition: background-color 0.3s ease-in-out; } .their-bubble { background-color: #3a3a3a; color: #e0e0e0; align-self: flex-start; border-bottom-left-radius: 5px; } .bubble-sender-name { font-size: 0.8em; font-weight: bold; color: #87ceeb; margin-bottom: 3px; } .bubble-timestamp { font-size: 0.7em; color: #a0a0a0; display: block; text-align: right; margin-top: 4px; clear: both; } .my-bubble .bubble-timestamp { color: var(--dmm-bubble-timestamp); transition: color 0.3s ease-in-out;} .custom-chat-reply { padding: 8px; border-top: 1px solid var(--dmm-border-color); background-color: #101010; display: flex; gap: 5px; flex-shrink: 0; transition: opacity 0.2s ease-out, border-color 0.3s ease-in-out; } .custom-chat-reply textarea { flex-grow: 1; height: 50px; min-height: 30px; max-height: 150px; padding: 5px; border: 1px solid #333; background-color: #222; color: #ddd; resize: vertical; font-family: inherit; font-size: 0.9em; } .custom-chat-reply button { padding: 10px 15px; background-color: var(--dmm-primary-color); color: white; border: none; border-radius: 3px; cursor: pointer; font-weight: bold; align-self: center; transition: background-color 0.2s ease; } .custom-chat-reply button:hover { background-color: var(--dmm-button-hover-bg); } .custom-chat-reply button:disabled { background-color: #555; cursor: not-allowed; } .hidden-original-databox { position: absolute !important; top: -9999px !important; left: -9999px !important; opacity: 0 !important; pointer-events: none !important; z-index: -1 !important; width: 1px !important; height: 1px !important; overflow: hidden !important; } .custom-chat-window.collapsed { max-height: 35px !important; min-height: 35px !important; height: 35px !important; } .custom-chat-window.collapsed .custom-chat-content, .custom-chat-window.collapsed .custom-chat-reply, .custom-chat-window.collapsed .participants-panel, .custom-chat-window.collapsed .color-picker-panel, .custom-chat-window.collapsed .more-opts-menu, .custom-chat-window.collapsed .resize-handle, .custom-chat-window.collapsed .mute-status-display { display: none; } /* Hide mute status when collapsed */ .custom-chat-window.collapsed .custom-chat-head { border-radius: 5px; } .custom-chat-head .controls .theme-btn { padding: 0 8px; font-size: 1.1em; margin-right: 5px; font-family: 'Segoe UI Symbol', Arial, sans-serif; } .custom-chat-head .controls .participants-btn { padding: 0 8px; font-size: 1.2em; margin-right: 5px; font-family: 'Segoe UI Symbol', Arial, sans-serif; } .custom-chat-head .controls .more-opts-btn { padding: 0 8px; font-size: 1.4em; font-weight: bold; margin-right: 5px; font-family: 'Segoe UI Symbol', Arial, sans-serif; } .custom-chat-window .more-opts-menu { position: absolute; top: 35px; right: 5px; background-color: #2a2a2a; border: 1px solid var(--dmm-border-color); border-radius: 4px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); z-index: 1000001; min-width: 150px; padding: 5px 0; display: none; transition: border-color 0.3s ease-in-out; } .custom-chat-window .color-picker-panel { position: absolute; top: 35px; right: 5px; background-color: #2a2a2a; border: 1px solid var(--dmm-border-color); border-radius: 4px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); z-index: 1000001; padding: 10px; display: none; text-align: center; transition: border-color 0.3s ease-in-out; } .custom-chat-window .color-picker-panel label { display: block; margin-bottom: 5px; font-size: 0.9em; color: #ccc; } .custom-chat-window .color-picker-panel input[type="color"] { cursor: pointer; border: 1px solid #555; width: 50px; height: 30px; padding: 0; background-color: #333; } .custom-chat-window .color-picker-panel .reset-color-btn { cursor: pointer; margin-left: 8px; padding-bottom: 15px; font-size: 1em; vertical-align: middle; opacity: 0.8; } .custom-chat-window .color-picker-panel .reset-color-btn:hover { opacity: 1; } .custom-chat-window .more-opts-menu .menu-item { padding: 8px 15px; color: #e0e0e0; cursor: pointer; font-size: 0.9em; white-space: nowrap; } .custom-chat-window .more-opts-menu .menu-item:hover { background-color: var(--dmm-menu-hover-bg); color: #ffffff; } .custom-chat-window .more-opts-menu hr { border: none; border-top: 1px solid #444; margin: 5px 0; } /* Style for settings items (color) */ .custom-chat-window .more-opts-menu .convo-settings-item { display: flex; align-items: center; justify-content: space-between; padding: 6px 15px; font-size: 0.9em; color: #ccc; } .custom-chat-window .more-opts-menu .convo-settings-item label { margin-right: 8px; white-space: nowrap; cursor: pointer; } .custom-chat-window .more-opts-menu .convo-settings-item input[type="checkbox"] { margin-right: 5px; cursor: pointer; vertical-align: middle; } .custom-chat-window .more-opts-menu .convo-settings-item input[type="color"] { cursor: pointer; border: 1px solid #555; width: 35px; height: 20px; padding: 0; background-color: #333; vertical-align: middle; } .custom-chat-window .more-opts-menu .convo-settings-item input[type="color"]:disabled { cursor: not-allowed; opacity: 0.5; } /* Mute options styling (with checkmark) */ .custom-chat-window .more-opts-menu .mute-option-item { display: flex; /* Use flexbox for easy alignment */ align-items: center; /* Reuse base menu-item padding etc. */ } .custom-chat-window .more-opts-menu .mute-option-item .checkmark { display: none; /* Hidden by default */ margin-right: 8px; /* Space between checkmark and text */ color: #66ff66; /* Green checkmark */ font-weight: bold; font-size: 1.1em; line-height: 1; /* Ensure alignment */ } .custom-chat-window .more-opts-menu .mute-option-item .item-text { flex-grow: 1; /* Allow text to take remaining space */ /* Style for timer text within the label */ color: #e0e0e0; /* Ensure consistent color */ } /* Style for the timer part specifically if needed (e.g., slightly dimmer) */ .custom-chat-window .more-opts-menu .mute-option-item .item-text span { /* Example: Make timer slightly dimmer */ /* color: #bbb; */ /* font-style: italic; */ } /* Participants Panel */ .participants-panel { position: absolute; top: 0; right: -250px; width: 250px; height: 100%; background-color: rgba(31, 31, 31, 0.95); border-left: 1px solid var(--dmm-border-color); box-shadow: -2px 0 5px rgba(0, 0, 0, 0.4); z-index: 999998; display: flex; flex-direction: column; transition: right 0.3s ease-in-out, border-color 0.3s ease-in-out; overflow: hidden; } .participants-panel.active { right: 0; } .participants-panel-header { padding: 8px 12px; font-weight: bold; color: #fff; background-color: var(--dmm-header-bg); border-bottom: 1px solid var(--dmm-border-color); flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; transition: background-color 0.3s ease-in-out, border-color 0.3s ease-in-out; } .participants-panel-header .close-panel-btn { font-size: 1.2em; cursor: pointer; padding: 0 5px; } .participants-panel-list { padding: 10px; overflow-y: auto; flex-grow: 1; color: #ccc; font-size: 0.9em; line-height: 1.4; } .participants-panel-list p { margin: 0; padding: 0; word-break: break-word; } .resize-handle { position: absolute; bottom: 0; right: 0; width: 15px; height: 15px; background-color: transparent; border-bottom: 2px solid var(--dmm-resize-border); border-right: 2px solid var(--dmm-resize-border); cursor: nwse-resize; z-index: 1000000; transition: border-color 0.3s ease-in-out; } .custom-chat-window.has-unread-notification .resize-handle { /* Optional: Change resize handle color too */ border-color: color-mix(in srgb, ${UNREAD_BORDER_COLOR} 50%, #667788); } /* Sidebar Mute Highlight */ #liste_messages li.message.dmm-muted-sidebar-item .message_titre { color: ${SIDEBAR_MUTED_COLOR} !important; /* Use important to override potential inline styles */ font-weight: bold; /* Optional: make it bolder */ } #liste_messages li.message.dmm-muted-sidebar-item:hover .message_titre { /* Optional: Style on hover if needed */ text-decoration: line-through; } /* Ensure normal color when class is removed */ #liste_messages li.message:not(.dmm-muted-sidebar-item) .message_titre { color: inherit !important; /* Revert to default color */ font-weight: normal; text-decoration: none; } `); } // --- Dragging/Resizing --- function makeDraggable(element) { let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0; const h = element.querySelector(".custom-chat-head"); if (h) { h.onmousedown = dragMouseDown; } else { element.onmousedown = dragMouseDown; } function dragMouseDown(e) { // Prevent drag if clicking on controls, menus, panels, or resize handle if (e.target.closest('.controls span') || e.target.closest('.more-opts-menu') || e.target.closest('.participants-panel') || e.target.closest('.color-picker-panel') || e.target.classList.contains('resize-handle')) return; e = e || window.event; pos3 = e.clientX; pos4 = e.clientY; document.onmouseup = closeDragElement; document.onmousemove = elementDrag; } function elementDrag(e) { e = e || window.event; e.preventDefault(); pos1 = pos3 - e.clientX; pos2 = pos4 - e.clientY; pos3 = e.clientX; pos4 = e.clientY; element.style.top = Math.max(0, (element.offsetTop - pos2)) + "px"; element.style.left = (element.offsetLeft - pos1) + "px"; } function closeDragElement() { document.onmouseup = null; document.onmousemove = null; } } function makeResizable(element, handle) { let startX, startY, startWidth, startHeight; handle.addEventListener('mousedown', function(e) { e.preventDefault(); startX = e.clientX; startY = e.clientY; const computedStyle = document.defaultView.getComputedStyle(element); startWidth = parseInt(computedStyle.width, 10); startHeight = parseInt(computedStyle.height, 10); document.addEventListener('mousemove', doDrag, false); document.addEventListener('mouseup', stopDrag, false); }, false); function doDrag(e) { let newWidth = startWidth + e.clientX - startX; let newHeight = startHeight + e.clientY - startY; newWidth = Math.max(MIN_WINDOW_WIDTH, newWidth); newHeight = Math.max(MIN_WINDOW_HEIGHT, newHeight); newWidth = Math.min(window.innerWidth - element.offsetLeft - 5, newWidth); // Prevent dragging off-screen right newHeight = Math.min(window.innerHeight - element.offsetTop - 5, newHeight); // Prevent dragging off-screen bottom element.style.width = newWidth + 'px'; element.style.height = newHeight + 'px'; } function stopDrag() { document.removeEventListener('mousemove', doDrag, false); document.removeEventListener('mouseup', stopDrag, false); } } // --- Message Parsing and Fetching --- function parseMessageElement(element) { const id = element.id.replace('convers_', ''); // Used for *content fetching*, not the LI ID const timestamp = element.querySelector('.ligne1')?.textContent.trim(); const senderLine = element.querySelector('.ligne2')?.textContent.trim(); const senderMatch = senderLine?.match(/Message de (.*)/); const sender = senderMatch ? senderMatch[1] : '?'; if (!id || !timestamp || !senderLine) { // console.warn("DMM parseMessageElement: Failed to parse element", element); return null; } return { id, timestamp, sender }; // 'id' here is the internal message_id for content } function fetchMessageContent(messageId, conversationId, callback) { GM_xmlhttpRequest({ method: "GET", url: `https://www.dreadcast.net/Menu/Messaging/action=ReadMessage&id_message=${messageId}&id_conversation=${conversationId}`, timeout: 15000, onload: function(r) { if (r.status === 200 && r.responseText) { try { let p = new DOMParser(), x = p.parseFromString(r.responseText, "text/xml"), m = x.querySelector("message"); const content = m ? (m.textContent || m.innerHTML).trim() : "Erreur: Contenu message vide"; callback(content); } catch (e) { console.error(`%cDMM fetchMessageContent[${conversationId}]: Parse XML error for msg ${messageId}`, "color: red", e); callback("Erreur: Parse XML"); } } else { console.warn(`%cDMM fetchMessageContent[${conversationId}]: Load error for msg ${messageId}, Status: ${r.status}`, "color: red"); callback(`Erreur: Load ${r.status}`); } }, onerror: function(e) { console.error(`%cDMM fetchMessageContent[${conversationId}]: Network error for msg ${messageId}`, "color: red", e); callback("Erreur: Réseau"); }, ontimeout: function() { console.warn(`%cDMM fetchMessageContent[${conversationId}]: Timeout for msg ${messageId}`, "color: red"); callback("Erreur: Timeout"); } }); } async function parseAndFetchInitialMessages(originalWindow, conversationId) { const logPrefix = `DMM PFIM (${conversationId}):`; const defaultResult = { messages: [], participants: [], totalMessages: 0, latestId: null, oldestId: null, allLoaded: true }; const conversationZone = originalWindow.querySelector('.zone_conversation'); if (!conversationZone) { console.warn(`${logPrefix} No .zone_conversation found.`); return defaultResult; } let participants = []; try { const participantsTitleDiv = Array.from(conversationZone.querySelectorAll('div')).find(div => div.textContent.includes('Participants')); if (participantsTitleDiv) { let currentElement = participantsTitleDiv.nextElementSibling; let participantsString = ''; // Find the <p> tag containing participant names, skipping over potential <br> or other tags. while(currentElement && currentElement.tagName !== 'P') { currentElement = currentElement.nextElementSibling; } // Check if we found the P tag and make sure it doesn't contain a message link (edge case) if (currentElement && currentElement.tagName === 'P' && !currentElement.querySelector('.link.conversation')) { participantsString = currentElement.textContent.trim(); } if (participantsString) { participants = participantsString.split(',').map(name => name.trim()).filter(name => name.length > 0); } // else { console.warn(`${logPrefix} Participants list <p> tag not found or empty near title.`); } } // else { console.warn(`${logPrefix} Participants title div not found.`); } } catch (e) { console.warn(`${logPrefix} Failed to parse participants list`, e); } // Find elements with 'convers_' ID inside the zone_conversation to parse message details const allElements = Array.from(conversationZone.querySelectorAll('.link.conversation[id^="convers_"]')); const total = allElements.length; if (total === 0) { defaultResult.participants = participants; return defaultResult; } const fetchElements = allElements.slice(0, INITIAL_LOAD_COUNT); let fetchedData = []; let fetchPromises = []; for (const el of fetchElements) { const parsed = parseMessageElement(el); // Parse elements with 'convers_' ID if (parsed) { let msgData = { ...parsed, content: null }; // msgData.id is the message_id (from convers_ID) fetchedData.push(msgData); fetchPromises.push(new Promise((resolve) => { fetchMessageContent(msgData.id, conversationId, (content) => { // Use msgData.id here const targetMsg = fetchedData.find(m => m.id === msgData.id); if (targetMsg) targetMsg.content = content; resolve(); }); })); } } try { if (fetchPromises.length > 0) await Promise.all(fetchPromises); fetchedData.reverse(); // Display oldest first within the initial batch const latestId = fetchedData.length > 0 ? fetchedData[fetchedData.length - 1].id : null; const oldestId = fetchedData.length > 0 ? fetchedData[0].id : null; const allLoaded = total <= INITIAL_LOAD_COUNT; const result = { messages: fetchedData, participants: participants, totalMessages: total, latestId: latestId, oldestId: oldestId, allLoaded: allLoaded }; return result; } catch (error) { console.error(`${logPrefix} Error during Promise.all or processing:`, error); defaultResult.participants = participants; return defaultResult; } } // --- UI Building and Manipulation --- /** * Adds a message bubble to the chat window. * @param {object} msgData - The message data { id, timestamp, sender, content }. id is message_id. * @param {HTMLElement} container - The DOM element to add the bubble to (.custom-chat-content). * @param {string} conversationId - The ID of the conversation. * @param {boolean} [prepend=false] - True if the bubble should be added to the top (loading older). * @param {boolean} [isInitialLoad=false] - True if this bubble is being added during the initial window build. * @returns {string|null} The ID of the added message, or null if not added. */ function addBubble(msgData, container, conversationId, prepend = false, isInitialLoad = false) { if (!MY_NAME || !container) { console.warn(`DMM addBubble: MY_NAME (${MY_NAME}) or container invalid. Cannot add bubble.`, msgData); return null; } const isMine = msgData.sender === MY_NAME; if (msgData.id) { const existingById = container.querySelector(`.chat-bubble[data-message-id="${msgData.id}"]`); if (existingById) { // console.log(`%cDMM addBubble: Preventing duplicate add for ID ${msgData.id}`, "color: gray"); return null; // Return null to indicate it wasn't added } } const bubble = document.createElement('div'); bubble.classList.add('chat-bubble'); if (msgData.id) bubble.dataset.messageId = msgData.id; // Store message_id bubble.classList.add(isMine ? 'my-bubble' : 'their-bubble'); if (!isMine) { const n = document.createElement('div'); n.classList.add('bubble-sender-name'); n.textContent = msgData.sender; bubble.appendChild(n); } const c = document.createElement('div'); c.classList.add('bubble-content'); const tempDiv = document.createElement('div'); tempDiv.textContent = msgData.content || "..."; // Set text content safely c.innerHTML = tempDiv.innerHTML.replace(/\n/g, '<br>'); // Convert newlines to <br> bubble.appendChild(c); if (msgData.timestamp) { const t = document.createElement('span'); t.classList.add('bubble-timestamp'); t.textContent = msgData.timestamp; bubble.appendChild(t); } const isScrolledToBottom = Math.abs(container.scrollHeight - container.clientHeight - container.scrollTop) < 50; const shouldScrollDown = !prepend && (isScrolledToBottom || isMine); if (prepend) { const loadMoreElem = container.querySelector('.load-more-container'); if (loadMoreElem) { loadMoreElem.insertAdjacentElement('afterend', bubble); } else { container.insertBefore(bubble, container.firstChild); } } else { container.appendChild(bubble); } // --- Play Sound & Set Notification State --- // if (!isMine && !prepend && !isInitialLoad) { const isConvoMuted = isConversationMuted(conversationId); // Conversation specific mute // Check BOTH conversation mute AND global mute <<< MODIFIED >>> if (!isConvoMuted && !isGloballyMuted) { // Sound try { const audio = new Audio(NOTIFICATION_SOUND_URL); audio.play().catch(e => { console.warn("DMM: Open window audio playback failed (interaction might be required for sound):", e.name, e.message); }); } catch (e) { console.error("DMM: Error creating or playing open window notification sound:", e); } // Visual Notification State (only if conversation is not muted) const convData = ACTIVE_CONVERSATIONS[conversationId]; const chatWindow = container.closest('.custom-chat-window'); if (convData && chatWindow && !chatWindow.classList.contains('has-unread-notification')) { convData.hasUnreadNotification = true; chatWindow.classList.add('has-unread-notification'); } } else if (isConvoMuted) { // Still handle visual notification if window is open and conversation is muted (no sound) const convData = ACTIVE_CONVERSATIONS[conversationId]; const chatWindow = container.closest('.custom-chat-window'); if (convData && chatWindow && !chatWindow.classList.contains('has-unread-notification')) { // We might still want to visually indicate a new message arrived, even if muted. // If you DON'T want the yellow highlight for muted convos, remove this part. // For now, let's keep it, as the yellow indicates 'unread' visually. convData.hasUnreadNotification = true; chatWindow.classList.add('has-unread-notification'); } // console.log(`%cDMM addBubble: Skipped sound for MUTED conversation ${conversationId}`, "color: gray;"); } else if (isGloballyMuted) { // Globally muted - No sound, but still handle visual notification if window is open const convData = ACTIVE_CONVERSATIONS[conversationId]; const chatWindow = container.closest('.custom-chat-window'); if (convData && chatWindow && !chatWindow.classList.contains('has-unread-notification')) { convData.hasUnreadNotification = true; chatWindow.classList.add('has-unread-notification'); } // console.log(`%cDMM addBubble: Skipped sound due to GLOBAL mute for conversation ${conversationId}`, "color: gray;"); } } if (shouldScrollDown) { requestAnimationFrame(() => { if (container && container.isConnected) { container.scrollTop = container.scrollHeight; } }); } return msgData.id; // Return the message_id of the added bubble } // --- END of addBubble function --- function buildInitialChatUI(messages, container, conversationId, allMessagesLoaded) { container.innerHTML = ''; container.classList.remove('loading'); if (!MY_NAME) { container.innerHTML = "<p style='color:red;'>Erreur: Nom utilisateur non trouvé.</p>"; return { latestId: null, oldestId: null }; } if (!allMessagesLoaded) { addLoadMoreLink(container, conversationId); } let firstId = null; // Oldest message_id in this batch let lastId = null; // Latest message_id in this batch messages.forEach(msg => { // Pass 'true' for the new isInitialLoad parameter const addedId = addBubble(msg, container, conversationId, false, true); // isInitialLoad=true here if (addedId) { // addedId is the message_id const addedIdNum = parseInt(addedId); if (!firstId || (addedIdNum < parseInt(firstId))) { firstId = addedId; } if (!lastId || (addedIdNum > parseInt(lastId))) { lastId = addedId; } } }); // Scroll to bottom after initial build setTimeout(() => { if (container && container.isConnected) container.scrollTop = container.scrollHeight; }, 100); return { latestId: lastId, oldestId: firstId }; // Return message_ids } function addLoadMoreLink(container, conversationId) { if (container.querySelector('.load-more-container')) return; // Avoid adding multiple links const loadMoreContainer = document.createElement('div'); loadMoreContainer.classList.add('load-more-container'); const loadMoreLink = document.createElement('a'); loadMoreLink.classList.add('load-more-link'); loadMoreLink.textContent = 'Afficher les messages précédents'; loadMoreLink.href = '#'; loadMoreLink.onclick = (e) => { e.preventDefault(); const cData = ACTIVE_CONVERSATIONS[conversationId]; if (cData && !cData.isLoadingOlder) { loadOlderMessages(conversationId, loadMoreLink); } }; loadMoreContainer.appendChild(loadMoreLink); container.insertBefore(loadMoreContainer, container.firstChild); } async function loadOlderMessages(conversationId, linkElement) { const cData = ACTIVE_CONVERSATIONS[conversationId]; const container = cData?.customWindow?.querySelector('.custom-chat-content'); if (!cData || !container || cData.isLoadingOlder || cData.allMessagesLoaded) { if (cData?.allMessagesLoaded && linkElement?.parentElement) linkElement.parentElement.remove(); // Clean up link if already loaded return; } cData.isLoadingOlder = true; linkElement.classList.add('loading'); linkElement.textContent = ''; // Clear text while loading try { // Fetch the full conversation page HTML to get all message elements const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: `https://www.dreadcast.net/Menu/Messaging/action=OpenMessage&id_conversation=${conversationId}`, timeout: 15000, onload: (res) => { if (res.status === 200 && res.responseText) resolve(res.responseText); else reject(`HTTP Status ${res.status}`); }, onerror: (err) => reject("Network Error"), ontimeout: () => reject("Timeout") }); }); const parser = new DOMParser(); const doc = parser.parseFromString(response, 'text/html'); const messageList = doc.querySelector('.zone_conversation'); if (!messageList) throw new Error("Could not find .zone_conversation in older messages response."); // Get all message links (convers_...) from the fetched HTML const allElements = Array.from(messageList.querySelectorAll('.link.conversation[id^="convers_"]')); const currentOldestId = cData.oldestMessageId; // This is a message_id let olderElementsToLoad = []; // Find the index of the *element* corresponding to the current oldest message ID const currentOldestIndex = allElements.findIndex(el => el.id.replace('convers_', '') === currentOldestId); if (currentOldestIndex !== -1 && currentOldestId) { // Find elements *before* the current oldest one in the full list (since list is newest first) const startIndex = currentOldestIndex + 1; const endIndex = Math.min(startIndex + LOAD_MORE_COUNT, allElements.length); olderElementsToLoad = allElements.slice(startIndex, endIndex); // console.log(`DMM LoadOlder: Found current oldest index ${currentOldestIndex}. Slicing from ${startIndex} to ${endIndex} (total ${allElements.length})`); } else if (currentOldestId) { console.warn(`DMM LoadOlder: Could not find index for current oldest message ID ${currentOldestId}. List might have changed?`); linkElement.textContent = 'Erreur index'; linkElement.style.color = '#aaa'; linkElement.onclick = (e) => e.preventDefault(); cData.isLoadingOlder = false; // Unlock return; } else { // This case should ideally not happen if initial load worked, but handle it. console.warn(`DMM LoadOlder: Cannot load older - current oldest message ID is null.`); cData.allMessagesLoaded = true; // Assume loaded if we have no starting point if(linkElement.parentElement) linkElement.parentElement.remove(); cData.isLoadingOlder = false; // Unlock return; } if (olderElementsToLoad.length > 0) { let fetchedData = []; let fetchPromises = []; for (const el of olderElementsToLoad) { const parsed = parseMessageElement(el); // Parse the 'convers_' element // Ensure we don't re-add a bubble that might already exist somehow (defensive check) if (parsed && !container.querySelector(`.chat-bubble[data-message-id="${parsed.id}"]`)) { let msgData = { ...parsed, content: null }; // msgData.id is message_id fetchedData.push(msgData); fetchPromises.push(new Promise((resolve) => { fetchMessageContent(msgData.id, conversationId, (content) => { // Use message_id const targetMsg = fetchedData.find(m => m.id === msgData.id); if (targetMsg) targetMsg.content = content; resolve(); }); })); } } if (fetchPromises.length > 0) await Promise.all(fetchPromises); fetchedData.reverse(); // Reverse so oldest fetched appears first (top) in the UI const oldScrollHeight = container.scrollHeight; const oldScrollTop = container.scrollTop; let newOldestId = cData.oldestMessageId; // Start with current oldest message_id fetchedData.forEach(msg => { // Pass 'false' for the isInitialLoad parameter when loading older const prependedId = addBubble(msg, container, conversationId, true, false); // prepend=true, isInitialLoad=false; prependedId is message_id // Update the overall oldest message ID if the prepended one is older if (prependedId && (!newOldestId || (parseInt(prependedId) < parseInt(newOldestId)))) { newOldestId = prependedId; } }); // Restore scroll position relative to the old top content if(fetchedData.length > 0) { const newScrollHeight = container.scrollHeight; container.scrollTop = oldScrollTop + (newScrollHeight - oldScrollHeight); } cData.oldestMessageId = newOldestId; // Update the tracked oldest message ID // Check if we've reached the end of the conversation history const endReached = (currentOldestIndex + 1 + olderElementsToLoad.length) >= allElements.length; if (endReached || olderElementsToLoad.length < LOAD_MORE_COUNT) { cData.allMessagesLoaded = true; if (linkElement.parentElement) linkElement.parentElement.remove(); // Remove the link } } else if (!cData.allMessagesLoaded && currentOldestId) { // If no elements were found to load, but we thought there were more // console.log(`DMM LoadOlder: No older elements found after index ${currentOldestIndex}. Marking as fully loaded.`); cData.allMessagesLoaded = true; if (linkElement.parentElement) linkElement.parentElement.remove(); } } catch (error) { console.error(`DMM: Error loading older messages for ${conversationId}:`, error); linkElement.textContent = 'Erreur chargement'; linkElement.style.color = 'red'; linkElement.style.cursor = 'default'; linkElement.onclick = (e) => e.preventDefault(); // Prevent retries on error } finally { cData.isLoadingOlder = false; // Restore link text if still loading and no error occurred and not fully loaded if (!cData.allMessagesLoaded && linkElement.classList.contains('loading') && !linkElement.textContent.includes('Erreur')) { linkElement.classList.remove('loading'); linkElement.textContent = 'Afficher les messages précédents'; } else if (cData.allMessagesLoaded && linkElement.parentElement) { // Ensure link is removed if finally block confirms all loaded linkElement.parentElement.remove(); } } } let closeChatWindow = (conversationId, options = { removeOriginal: true }) => { // Ensure timer clearing happens *before* data deletion const cData = ACTIVE_CONVERSATIONS[conversationId]; // Get data first if (cData?.muteTimerIntervalId) { clearInterval(cData.muteTimerIntervalId); // console.log(`%cDMM Mute Timer: Cleared interval ${cData.muteTimerIntervalId} on window close for ${conversationId}`, "color: gray"); } const customWindowId = `custom-chat-${conversationId}`; const chatWindow = document.getElementById(customWindowId); if (chatWindow) { chatWindow.remove(); } if (cData) { // Check if data existed (might have already been partially cleaned) const oRef = cData.originalWindow; // Only remove original if requested AND it wasn't specifically revealed for 'invite' action const shouldRemove = options.removeOriginal && oRef?.dataset.modernized !== 'revealed_for_invite'; if (shouldRemove && oRef?.parentNode) { try { oRef.remove(); } catch (e) { console.warn(`DMM: Error removing original window ${conversationId} on close:`, e); } } delete ACTIVE_CONVERSATIONS[conversationId]; // Delete data *after* using it // console.log(`DMM: Closed window and deleted data for conversation ${conversationId}. Original removed: ${shouldRemove}`); } }; function populateConversationSettingsUI(conversationId, checkbox, colorInput) { const setting = getConversationSetting(conversationId); checkbox.checked = setting.enabled; colorInput.value = setting.enabled ? setting.color : getSavedGlobalThemeColor(); // Show global if disabled colorInput.disabled = !setting.enabled; } function createCustomWindow(conversationId, otherParticipantName_UNUSED, initialResult, originalWindowRef) { const { messages, participants, totalMessages, latestId: initialLatestId, oldestId: initialOldestId, allLoaded } = initialResult; const windowId = `custom-chat-${conversationId}`; if (document.getElementById(windowId)) { // If DMM window already exists, just update the original ref and hide the new one. const existingData = ACTIVE_CONVERSATIONS[conversationId]; if (existingData) { existingData.originalWindow = originalWindowRef; } if (originalWindowRef?.parentNode) { originalWindowRef.classList.add('hidden-original-databox'); // Hide the duplicate original originalWindowRef.dataset.modernized = 'replaced'; // Mark as handled } console.warn(`DMM: createCustomWindow called for existing ID ${conversationId}. Updated original ref.`); const existingWindow = document.getElementById(windowId); if(existingWindow) { bringWindowToFront(existingWindow); // Use new function instead of direct z-index const txtArea = existingWindow.querySelector('.custom-chat-reply textarea'); if (txtArea) txtArea.focus(); } return; // Don't create a new window } // --- Create Window Structure --- const chatWindow = document.createElement('div'); chatWindow.id = windowId; chatWindow.classList.add('custom-chat-window'); chatWindow.style.zIndex = '999999'; // Set initial z-index applyCurrentTheme(chatWindow, conversationId); // Add click handler to bring window to front chatWindow.addEventListener('mousedown', function(e) { // Don't change z-index if clicking close button or menu items if (!e.target.closest('.controls span') && !e.target.closest('.more-opts-menu') && !e.target.closest('.participants-panel') && !e.target.closest('.color-picker-panel')) { bringWindowToFront(chatWindow); } }); const head = document.createElement('div'); head.classList.add('custom-chat-head'); let actualTitleText = `Conversation ${conversationId}`; const originalTitleElement = originalWindowRef?.querySelector('.head .title'); if (originalTitleElement) { actualTitleText = originalTitleElement.textContent.trim(); } else { actualTitleText = `Messages ${conversationId}`; } const title = document.createElement('span'); title.classList.add('title'); title.textContent = actualTitleText; title.title = actualTitleText; // Add Mute Status Display const muteStatusDisplay = document.createElement('span'); muteStatusDisplay.classList.add('mute-status-display'); title.insertAdjacentElement('afterend', muteStatusDisplay); // Insert after title const controls = document.createElement('div'); controls.classList.add('controls'); const themeBtn = document.createElement('span'); themeBtn.classList.add('theme-btn'); themeBtn.innerHTML = '🎨'; themeBtn.title = 'Changer la couleur GLOBALE du thème'; controls.appendChild(themeBtn); const participantsBtn = document.createElement('span'); participantsBtn.classList.add('participants-btn'); participantsBtn.innerHTML = '👥'; participantsBtn.title = 'Afficher les participants'; controls.appendChild(participantsBtn); const moreOptsBtn = document.createElement('span'); moreOptsBtn.classList.add('more-opts-btn'); moreOptsBtn.innerHTML = '⋮'; moreOptsBtn.title = "Plus d'options"; controls.appendChild(moreOptsBtn); const closeBtn = document.createElement('span'); closeBtn.innerHTML = '×'; closeBtn.title = 'Fermer'; controls.appendChild(closeBtn); head.appendChild(title); head.appendChild(controls); chatWindow.appendChild(head); const content = document.createElement('div'); content.classList.add('custom-chat-content', 'loading'); chatWindow.appendChild(content); const replyDiv = document.createElement('div'); replyDiv.classList.add('custom-chat-reply'); const textarea = document.createElement('textarea'); textarea.placeholder = 'Écrire un message...'; textarea.setAttribute('aria-label', 'Message reply input'); const sendButton = document.createElement('button'); sendButton.textContent = 'Envoyer'; replyDiv.appendChild(textarea); replyDiv.appendChild(sendButton); chatWindow.appendChild(replyDiv); // --- More Options Menu Structure --- const moreOptionsMenu = document.createElement('div'); moreOptionsMenu.classList.add('more-opts-menu'); const inviteItem = document.createElement('div'); inviteItem.classList.add('menu-item'); inviteItem.textContent = 'Inviter'; inviteItem.dataset.action = 'invite'; moreOptionsMenu.appendChild(inviteItem); const markUnreadItem = document.createElement('div'); markUnreadItem.classList.add('menu-item'); markUnreadItem.textContent = 'Marquer non lu'; markUnreadItem.dataset.action = 'mark_unread'; moreOptionsMenu.appendChild(markUnreadItem); const deleteItem = document.createElement('div'); deleteItem.classList.add('menu-item'); deleteItem.textContent = 'Supprimer'; deleteItem.dataset.action = 'delete'; moreOptionsMenu.appendChild(deleteItem); const settingsHr = document.createElement('hr'); moreOptionsMenu.appendChild(settingsHr); // Specific Color Setting const convoSettingsWrapper = document.createElement('div'); convoSettingsWrapper.classList.add('convo-settings-item'); const specificColorLabel = document.createElement('label'); specificColorLabel.textContent = 'Couleur Spécifique:'; specificColorLabel.htmlFor = `dmm-specific-cb-${conversationId}`; const specificColorCheckbox = document.createElement('input'); specificColorCheckbox.type = 'checkbox'; specificColorCheckbox.id = `dmm-specific-cb-${conversationId}`; specificColorCheckbox.title = 'Activer une couleur unique pour cette conversation'; const specificColorInput = document.createElement('input'); specificColorInput.type = 'color'; specificColorInput.id = `dmm-specific-color-${conversationId}`; specificColorInput.title = 'Choisir la couleur spécifique pour cette conversation'; convoSettingsWrapper.appendChild(specificColorLabel); convoSettingsWrapper.appendChild(specificColorCheckbox); convoSettingsWrapper.appendChild(specificColorInput); moreOptionsMenu.appendChild(convoSettingsWrapper); // --- Mute Options --- const muteOptionsTitle = document.createElement('div'); muteOptionsTitle.textContent = 'Mute Options:'; muteOptionsTitle.style.padding = '6px 15px 3px'; // Style as a title muteOptionsTitle.style.fontSize = '0.8em'; muteOptionsTitle.style.color = '#aaa'; muteOptionsTitle.style.fontWeight = 'bold'; moreOptionsMenu.appendChild(muteOptionsTitle); const muteOptionsContainer = document.createElement('div'); muteOptionsContainer.classList.add('mute-options-container'); // Add a class for styling/selection moreOptionsMenu.appendChild(muteOptionsContainer); // Define options const muteChoices = [ { label: 'Unmute', duration: MUTE_DURATIONS.UNMUTE }, { label: 'Mute 2 min', duration: MUTE_DURATIONS.TWO_MINUTES }, { label: 'Mute 15 min', duration: MUTE_DURATIONS.FIFTEEN_MINUTES }, { label: 'Mute 1 hour', duration: MUTE_DURATIONS.ONE_HOUR }, { label: 'Mute Forever', duration: MUTE_DURATIONS.FOREVER } ]; // Create mute option items with checkmarks muteChoices.forEach(choice => { const item = document.createElement('div'); item.classList.add('menu-item', 'mute-option-item'); // Add class for click listener const checkmarkSpan = document.createElement('span'); checkmarkSpan.classList.add('checkmark'); checkmarkSpan.innerHTML = '✓'; item.appendChild(checkmarkSpan); const textSpan = document.createElement('span'); textSpan.classList.add('item-text'); textSpan.textContent = choice.label; // Set initial label item.appendChild(textSpan); item.dataset.duration = choice.duration === null ? 'null' : String(choice.duration); // Store duration item.dataset.originalLabel = choice.label; // Store original label for timer reset item.title = `${choice.label}`; // Initial title muteOptionsContainer.appendChild(item); }); // --- Fin Mute Options --- chatWindow.appendChild(moreOptionsMenu); // Add the completed menu // --- Other Panels --- const participantsPanel = document.createElement('div'); participantsPanel.classList.add('participants-panel'); const panelHeader = document.createElement('div'); panelHeader.classList.add('participants-panel-header'); panelHeader.textContent = 'Participants'; const closePanelBtn = document.createElement('span'); closePanelBtn.classList.add('close-panel-btn'); closePanelBtn.innerHTML = '×'; closePanelBtn.title = 'Fermer'; panelHeader.appendChild(closePanelBtn); participantsPanel.appendChild(panelHeader); const participantsListDiv = document.createElement('div'); participantsListDiv.classList.add('participants-panel-list'); participantsPanel.appendChild(participantsListDiv); chatWindow.appendChild(participantsPanel); const colorPickerPanel = document.createElement('div'); colorPickerPanel.classList.add('color-picker-panel'); const colorLabel = document.createElement('label'); colorLabel.textContent = 'Couleur Globale :'; colorPickerPanel.appendChild(colorLabel); const colorInput = document.createElement('input'); colorInput.type = 'color'; colorInput.value = getSavedGlobalThemeColor(); const resetColorBtn = document.createElement('span'); // Add reset button resetColorBtn.textContent = '❌'; resetColorBtn.title = 'Rétablir la couleur par défaut'; resetColorBtn.classList.add('reset-color-btn'); colorPickerPanel.appendChild(colorInput); colorPickerPanel.appendChild(resetColorBtn); // Add reset button chatWindow.appendChild(colorPickerPanel); const resizeHandle = document.createElement('div'); resizeHandle.classList.add('resize-handle'); resizeHandle.title = 'Redimensionner'; chatWindow.appendChild(resizeHandle); // --- Event Listeners & State --- let clickOutsideMenuHandler = null; let clickOutsidePanelHandler = null; let clickOutsideColorPickerHandler = null; // Function and Listeners to Clear Notification const clearNotification = () => { const convData = ACTIVE_CONVERSATIONS[conversationId]; if (convData && convData.hasUnreadNotification) { const isMuted = isConversationMuted(conversationId); if (!isMuted) { convData.hasUnreadNotification = false; chatWindow.classList.remove('has-unread-notification'); // console.log(`%cDMM: Cleared unread notification for ${conversationId} via interaction.`, "color: green"); } else { convData.hasUnreadNotification = false; // Still clear the flag even if muted // console.log(`%cDMM: Cleared hasUnreadNotification flag for MUTED conversation ${conversationId} via interaction.`, "color: gray"); } } }; chatWindow.addEventListener('mousedown', clearNotification, true); chatWindow.addEventListener('focusin', clearNotification); const closeThisChatWindow = (options = { removeOriginal: true }) => { chatWindow.removeEventListener('mousedown', clearNotification, true); chatWindow.removeEventListener('focusin', clearNotification); // Call original close function - this handles interval clearing and data deletion now closeChatWindow(conversationId, options); // Cleanup click handlers if (clickOutsideMenuHandler) { document.removeEventListener('click', clickOutsideMenuHandler, true); clickOutsideMenuHandler = null; } if (clickOutsidePanelHandler) { document.removeEventListener('click', clickOutsidePanelHandler, true); clickOutsidePanelHandler = null; } if (clickOutsideColorPickerHandler) { document.removeEventListener('click', clickOutsideColorPickerHandler, true); clickOutsideColorPickerHandler = null; } }; const closeOtherPopups = (except) => { if (except !== 'menu' && moreOptionsMenu.style.display === 'block') { moreOptionsMenu.style.display = 'none'; if (clickOutsideMenuHandler) document.removeEventListener('click', clickOutsideMenuHandler, true); clickOutsideMenuHandler = null; } if (except !== 'panel' && participantsPanel.classList.contains('active')) { participantsPanel.classList.remove('active'); if (clickOutsidePanelHandler) document.removeEventListener('click', clickOutsidePanelHandler, true); clickOutsidePanelHandler = null; } if (except !== 'color' && colorPickerPanel.style.display === 'block') { colorPickerPanel.style.display = 'none'; if (clickOutsideColorPickerHandler) document.removeEventListener('click', clickOutsideColorPickerHandler, true); clickOutsideColorPickerHandler = null; } }; closeBtn.onclick = () => closeThisChatWindow({ removeOriginal: true }); sendButton.onclick = () => { // No changes needed here for mute highlight const messageText = textarea.value.trim(); const cData = ACTIVE_CONVERSATIONS[conversationId]; if (!messageText || sendButton.disabled) { if (!messageText) textarea.focus(); return; } if (!cData) { console.error(`DMM SendError[${conversationId}]: Conversation data not found!`); alert("Erreur critique DMM: Données de conversation manquantes."); return; } const currentOriginalWindow = cData.originalWindow; if (!currentOriginalWindow || !document.body.contains(currentOriginalWindow) || !currentOriginalWindow.querySelector('.zone_reponse textarea[name=nm_texte]') || !currentOriginalWindow.querySelector('.zone_reponse .btnTxt[onclick*="sendMessage"]')) { console.error(`DMM SendError[${conversationId}]: Original window reference lost, detached, or incomplete. Refresh might be needed.`); alert("Erreur DMM: Référence à la fenêtre originale perdue ou invalide. La page pourrait nécessiter un rafraîchissement ou la réouverture de la conversation."); sendButton.disabled = true; sendButton.textContent = 'Erreur Orig.'; return; } sendButton.disabled = true; const originalButtonText = sendButton.textContent; sendButton.textContent = 'Envoi...'; let sendAttemptError = null; let originalClickSuccess = false; const originalTextarea = currentOriginalWindow.querySelector('.zone_reponse textarea[name=nm_texte]'); const originalSendButton = currentOriginalWindow.querySelector('.zone_reponse .btnTxt[onclick*="sendMessage"]'); if (originalTextarea && originalSendButton) { try { originalTextarea.value = messageText; originalSendButton.click(); originalClickSuccess = true; textarea.value = ''; } catch (e) { sendAttemptError = e; console.error(`%cDMM SendError[${conversationId}]: Error clicking original send button.`, "color: red", e); sendButton.textContent = 'Erreur Envoi'; } } else { sendAttemptError = new Error("Could not find original send elements just before use."); console.error(`%cDMM SendError[${conversationId}]: ${sendAttemptError.message}`, "color: red"); sendButton.textContent = 'Erreur Config'; } if (originalClickSuccess) { GM_xmlhttpRequest({ method: "GET", url: `https://www.dreadcast.net/Menu/Messaging/action=OpenMessage&id_conversation=${conversationId}`, timeout: 10000, onload: function(response) { const currentSendButton = document.querySelector(`#custom-chat-${conversationId} .custom-chat-reply button`); if (response.status === 200 && response.responseText) { try { const latestCData = ACTIVE_CONVERSATIONS[conversationId]; if (latestCData && latestCData.customWindow && document.body.contains(latestCData.customWindow)) { handleOpenMessageResponse(conversationId, response.responseText); } if (currentSendButton) { currentSendButton.textContent = originalButtonText; currentSendButton.disabled = false; } } catch (handlerError) { console.error(`%cDMM SendError[${conversationId}]: Error in handleOpenMessageResponse after immediate fetch.`, "color: red", handlerError); if (currentSendButton) { currentSendButton.textContent = 'Erreur MàJ'; setTimeout(() => { const btn = document.querySelector(`#custom-chat-${conversationId} .custom-chat-reply button`); if (btn) { btn.disabled = false; btn.textContent = originalButtonText; } }, 2000); } } } else { console.warn(`%cDMM Send[${conversationId}]: Immediate OpenMessage failed. Status: ${response.status}. UI will update later via /Check.`, "color: orange"); sendAttemptError = new Error(`Immediate OpenMessage fetch failed with status ${response.status}`); if (currentSendButton) { currentSendButton.textContent = 'MàJ différée'; setTimeout(() => { const btn = document.querySelector(`#custom-chat-${conversationId} .custom-chat-reply button`); if (btn) { btn.disabled = false; btn.textContent = originalButtonText; } }, 2500); } } }, onerror: function(error) { console.error(`%cDMM SendError[${conversationId}]: Network error during immediate OpenMessage fetch.`, "color: red", error); sendAttemptError = new Error("Network error during immediate update fetch."); const currentSendButton = document.querySelector(`#custom-chat-${conversationId} .custom-chat-reply button`); if (currentSendButton) { currentSendButton.textContent = 'Erreur Réseau MàJ'; setTimeout(() => { const btn = document.querySelector(`#custom-chat-${conversationId} .custom-chat-reply button`); if (btn) { btn.disabled = false; btn.textContent = originalButtonText; } }, 2500); } }, ontimeout: function() { console.warn(`%cDMM Send[${conversationId}]: Timeout during immediate OpenMessage fetch. UI will update later.`, "color: orange"); sendAttemptError = new Error("Timeout during immediate update fetch."); const currentSendButton = document.querySelector(`#custom-chat-${conversationId} .custom-chat-reply button`); if (currentSendButton) { currentSendButton.textContent = 'Timeout MàJ'; setTimeout(() => { const btn = document.querySelector(`#custom-chat-${conversationId} .custom-chat-reply button`); if (btn) { btn.disabled = false; btn.textContent = originalButtonText; } }, 2500); } } }); } else { setTimeout(() => { const currentSendButton = document.querySelector(`#custom-chat-${conversationId} .custom-chat-reply button`); if (currentSendButton) { currentSendButton.disabled = false; currentSendButton.textContent = originalButtonText; } if (sendAttemptError && !originalClickSuccess) { alert(`Erreur DMM: Échec de l'envoi initial du message.\n${sendAttemptError.message}`); } }, 1500); } }; textarea.addEventListener('keypress', function(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendButton.click(); } }); // --- Other UI Handlers (Popups, Theme, Participants, Options, Resize, Drag) --- clickOutsideColorPickerHandler = (event) => { if (!colorPickerPanel.contains(event.target) && !themeBtn.contains(event.target)) { closeOtherPopups('none'); } }; themeBtn.addEventListener('click', (event) => { event.stopPropagation(); const isDisplayed = colorPickerPanel.style.display === 'block'; closeOtherPopups(isDisplayed ? 'none' : 'color'); colorInput.value = getSavedGlobalThemeColor(); colorPickerPanel.style.display = isDisplayed ? 'none' : 'block'; if (!isDisplayed) { setTimeout(() => { document.addEventListener('click', clickOutsideColorPickerHandler, true); }, 0); } else { if (clickOutsideColorPickerHandler) { document.removeEventListener('click', clickOutsideColorPickerHandler, true); clickOutsideColorPickerHandler = null; } } }); colorInput.addEventListener('input', (event) => { saveGlobalThemeColor(event.target.value); }); resetColorBtn.addEventListener('click', () => { colorInput.value = DEFAULT_THEME_COLOR; saveGlobalThemeColor(DEFAULT_THEME_COLOR); }); clickOutsideMenuHandler = (event) => { if (!moreOptionsMenu.contains(event.target) && !moreOptsBtn.contains(event.target)) { closeOtherPopups('none'); } }; moreOptsBtn.addEventListener('click', (event) => { event.stopPropagation(); const isDisplayed = moreOptionsMenu.style.display === 'block'; closeOtherPopups(isDisplayed ? 'none' : 'menu'); if (!isDisplayed) { // Update BOTH settings and mute UI when menu opens populateConversationSettingsUI(conversationId, specificColorCheckbox, specificColorInput); updateMuteOptionsUI(chatWindow, conversationId); // Pass chatWindow reference - THIS NOW UPDATES TIMER TOO } moreOptionsMenu.style.display = isDisplayed ? 'none' : 'block'; if (!isDisplayed) { setTimeout(() => { document.addEventListener('click', clickOutsideMenuHandler, true); }, 0); } else { if (clickOutsideMenuHandler) { document.removeEventListener('click', clickOutsideMenuHandler, true); clickOutsideMenuHandler = null; } } }); moreOptionsMenu.addEventListener('click', (event) => { // Handle menu item clicks const menuItem = event.target.closest('.menu-item:not(.convo-settings-item):not(.mute-option-item)'); // Exclude settings & mute items const settingsItem = event.target.closest('.convo-settings-item'); const muteOptionItem = event.target.closest('.mute-option-item'); // Get mute item if (menuItem) { // Handle standard actions (invite, delete, etc.) const action = menuItem.dataset.action; closeOtherPopups('none'); try { const cData = ACTIVE_CONVERSATIONS[conversationId]; const currentOriginalWindow = cData?.originalWindow; const messagerie = unsafeWindow?.nav?.getMessagerie(); if (!messagerie && (action === 'mark_unread' || action === 'delete')) { console.error("DMM Error: unsafeWindow.nav.getMessagerie() is not accessible!"); alert("Erreur: Fonctionnalité de messagerie non trouvée."); return; } if (action === 'invite') { if (currentOriginalWindow && document.body.contains(currentOriginalWindow) && typeof unsafeWindow?.$ === 'function') { try { const originalWindowId = `#${currentOriginalWindow.id}`; currentOriginalWindow.classList.remove('hidden-original-databox'); currentOriginalWindow.style.opacity = ''; currentOriginalWindow.style.pointerEvents = ''; currentOriginalWindow.style.zIndex = '9999'; currentOriginalWindow.style.top = ''; currentOriginalWindow.style.left = ''; currentOriginalWindow.dataset.modernized = 'revealed_for_invite'; unsafeWindow.$(`${originalWindowId} .contenu`).animate({height: '140px'}, 'fast'); const $zoneReponse = unsafeWindow.$(`${originalWindowId} .zone_reponse`); $zoneReponse.slideDown('fast'); unsafeWindow.$(`${originalWindowId} .autres_actions`).addClass('small'); $zoneReponse.find('.cible').slideDown('fast'); closeThisChatWindow({ removeOriginal: false }); setTimeout(() => { const targetTextarea = currentOriginalWindow.querySelector('.zone_reponse textarea[name="nm_texte"]'); if(targetTextarea) targetTextarea.focus(); }, 400); } catch (e) { console.error(`DMM: Error executing Invite action jQuery...`, e); alert("Erreur lors de la préparation de l'invitation."); closeThisChatWindow({ removeOriginal: true }); } } else { console.error("DMM Error: Cannot perform Invite - original window ref missing or jQuery not available."); alert("Erreur: Impossible d'exécuter l'action 'Inviter'."); closeThisChatWindow({ removeOriginal: true }); } } else if (action === 'mark_unread') { messagerie.notReadMessage(conversationId); setTimeout(() => closeThisChatWindow({ removeOriginal: true }), 100); } else if (action === 'delete') { messagerie.deleteMessage(conversationId); setTimeout(() => closeThisChatWindow({ removeOriginal: true }), 100); } } catch (e) { console.error(`DMM Error executing action '${action}'...`, e); alert(`Une erreur est survenue lors de l'action ${action}`); } } else if (muteOptionItem) { // Handle Mute Option Click const durationStr = muteOptionItem.dataset.duration; let durationMs; if (durationStr === 'null') durationMs = null; else durationMs = parseInt(durationStr, 10); if (typeof durationMs === 'number' || durationMs === null) { // Don't allow clicking "Unmute" if already unmuted const currentEndTime = getConversationMuteEndTime(conversationId); if (durationMs === MUTE_DURATIONS.UNMUTE && currentEndTime === 0) { // Already unmuted, do nothing } else { setConversationMuted(conversationId, durationMs); // This now handles all UI updates (window+sidebar) // closeOtherPopups('none'); // Optionally close menu } } else { console.warn("DMM: Invalid duration found on mute option item:", durationStr); } } else if (settingsItem) { // Handle Color Settings Click // Check if the click was directly on an input or its label if (event.target.tagName === 'INPUT' && (event.target.type === 'checkbox' || event.target.type === 'color')) { // Let the specific input's event listener handle it } else if (event.target.tagName === 'LABEL') { const inputId = event.target.htmlFor; const inputElement = document.getElementById(inputId); if (inputElement && inputElement.type === 'checkbox') { inputElement.click(); } } } }); specificColorCheckbox.addEventListener('change', (event) => { const isEnabled = event.target.checked; const currentColor = specificColorInput.value; specificColorInput.disabled = !isEnabled; setConversationSetting(conversationId, { enabled: isEnabled, color: currentColor }); applyCurrentTheme(chatWindow, conversationId); }); specificColorInput.addEventListener('input', (event) => { const newColor = event.target.value; if (!specificColorInput.disabled) { setConversationSetting(conversationId, { enabled: true, color: newColor }); applyCurrentTheme(chatWindow, conversationId); } }); clickOutsidePanelHandler = (event) => { if (!participantsPanel.contains(event.target) && !participantsBtn.contains(event.target)) { closeOtherPopups('none'); } }; participantsBtn.addEventListener('click', (event) => { event.stopPropagation(); const cData = ACTIVE_CONVERSATIONS[conversationId]; if (!cData) return; const isActive = participantsPanel.classList.contains('active'); closeOtherPopups(isActive ? 'none' : 'panel'); const listHtml = cData.participants.length > 0 ? cData.participants.join('<br>') : '<p style="color:#888;font-style:italic;">Aucun participant trouvé.</p>'; participantsListDiv.innerHTML = listHtml; participantsPanel.classList.toggle('active'); if (!isActive) { setTimeout(() => { document.addEventListener('click', clickOutsidePanelHandler, true); }, 0); } else { if (clickOutsidePanelHandler) { document.removeEventListener('click', clickOutsidePanelHandler, true); clickOutsidePanelHandler = null; } } }); closePanelBtn.addEventListener('click', () => { closeOtherPopups('none'); }); head.addEventListener('dblclick', (e) => { if (e.target.closest('.controls span') || e.target.closest('.participants-panel') || e.target.closest('.color-picker-panel') || e.target.closest('.more-opts-menu') || e.target.classList.contains('resize-handle') || e.target.classList.contains('mute-status-display')) return; const isCollapsed = chatWindow.classList.toggle('collapsed'); if (isCollapsed) { closeOtherPopups('none'); } }); // --- Add to DOM and make interactive --- document.body.appendChild(chatWindow); makeDraggable(chatWindow); makeResizable(chatWindow, resizeHandle); // --- Build initial content --- const { latestId, oldestId } = buildInitialChatUI(messages, content, conversationId, allLoaded); // --- Setup Mute Timer Interval --- const muteTimerIntervalId = setInterval(() => { const currentConvData = ACTIVE_CONVERSATIONS[conversationId]; // Check if window still exists before updating if (currentConvData?.customWindow && document.body.contains(currentConvData.customWindow)) { // Update header inside the DMM window updateHeaderMuteStatus(currentConvData.customWindow, conversationId); // Check if mute expired using the main function (which updates sidebar if needed) isConversationMuted(conversationId); // Update menu timer display ONLY IF menu is currently open const currentMenu = currentConvData.customWindow.querySelector('.more-opts-menu'); if (currentMenu && currentMenu.style.display === 'block') { updateMuteOptionsUI(currentConvData.customWindow, conversationId); } } else { // Window is gone, clear the interval (check existence before clearing) const intervalIdToClear = ACTIVE_CONVERSATIONS[conversationId]?.muteTimerIntervalId; if (intervalIdToClear) { clearInterval(intervalIdToClear); if (ACTIVE_CONVERSATIONS[conversationId]) { // Check again before deleting prop delete ACTIVE_CONVERSATIONS[conversationId].muteTimerIntervalId; } // console.log(`%cDMM Mute Timer: Cleared interval ${intervalIdToClear} for closed/missing window ${conversationId}`, "color: gray"); } } }, 20000); // Update every 20 seconds // --- Store conversation data --- ACTIVE_CONVERSATIONS[conversationId] = { customWindow: chatWindow, originalWindow: originalWindowRef, latestMessageId: latestId ?? initialLatestId, // message_id oldestMessageId: oldestId ?? initialOldestId, // message_id allMessagesLoaded: allLoaded, isLoadingOlder: false, participants: participants, hasUnreadNotification: false, muteTimerIntervalId: muteTimerIntervalId // Store the interval ID }; // --- Initial Mute State UI Update --- updateHeaderMuteStatus(chatWindow, conversationId); updateMuteOptionsUI(chatWindow, conversationId); // Initial checkmark/timer update // updateSidebarMuteStatus(conversationId); // No need to call here, scanAndUpdateSidebarMutes runs on init/update // --- Final focus --- setTimeout(() => textarea.focus(), 200); } // --- END of createCustomWindow function --- // --- Click Simulation Functions --- function simulateClick(element) { return new Promise(resolve => { if (!element || !document.body.contains(element)) { console.warn("DMM simulateClick (.click()): Null or detached element"); resolve(false); return; } try { element.click(); resolve(true); } catch (e) { console.error(`DMM simulateClick (.click()): Error calling .click() on`, element, e); resolve(false); } }); } async function initiateDoubleClick(selector, container = document) { const logPrefix = `DMM initiateDoubleClick (.click() x2) (${selector}):`; let element = null; try { element = container.querySelector(selector); if (!element || !document.body.contains(element)) { console.warn(`${logPrefix} Initial element not found or not in document.`); return false; } element.click(); // First click await new Promise(r => setTimeout(r, REFIND_DELAY)); element = null; // Reset before re-find element = container.querySelector(selector); // RE-FIND ELEMENT if (!element || !document.body.contains(element)) { console.warn(`${logPrefix} Element could not be re-found after delay.`); return false; } element.click(); // Second click // console.log(`%c${logPrefix} Double .click() sequence completed.`, "color: green"); return true; } catch (error) { console.error(`${logPrefix} Error during double .click() simulation:`, error); return false; } } // --- Core Logic Handlers --- async function handleNewMessageEvent(conversationId, folderId) { const MAX_ATTEMPTS = 5; const RETRY_DELAY = 100; const logPrefix = `DMM /Check Sim Handler [${conversationId}/${folderId}]:`; // const conversationData = ACTIVE_CONVERSATIONS[conversationId]; // Keep for potential future use, but check isn't needed here anymore let menuWasOpenedByScriptOnSuccessfulAttempt = false; let overallSuccess = false; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { // console.log(`%c${logPrefix} Attempt ${attempt}/${MAX_ATTEMPTS}...`, "color: #4682B4; font-weight: bold;"); let currentAttemptMenuOpened = false; try { // --- Step 1: Ensure Main Message Menu is Visible --- const messageListContainer = document.getElementById('liste_messages'); const mainMenuButton = document.getElementById('display_messagerie'); const isListVisibleCheck = () => messageListContainer && document.body.contains(messageListContainer) && messageListContainer.offsetParent !== null && getComputedStyle(messageListContainer).visibility !== 'hidden' && getComputedStyle(messageListContainer).display !== 'none'; if (!isListVisibleCheck()) { if (!mainMenuButton) { console.error(`${logPrefix} Attempt ${attempt}: Message list not visible and #display_messagerie not found! Aborting attempts.`); return; } // console.log(`%c${logPrefix} Attempt ${attempt}: Main message list not visible. Clicking #display_messagerie...`, "color: #4682B4"); currentAttemptMenuOpened = true; await new Promise(r => setTimeout(r, UI_CLICK_DELAY)); const click1Success = await simulateClick(mainMenuButton); if (!click1Success) { console.warn(`${logPrefix} Attempt ${attempt}: Failed to simulate click on #display_messagerie. Retrying...`); await new Promise(r => setTimeout(r, RETRY_DELAY)); continue; } await new Promise(r => setTimeout(r, UI_WAIT_DELAY)); try { await waitForElement('#liste_messages'); // console.log(`%c${logPrefix} Attempt ${attempt}: Main message list should now be visible.`, "color: #4682B4"); } catch (waitError) { console.warn(`${logPrefix} Attempt ${attempt}: Main message list (#liste_messages) did not become visible after click. Retrying...`, waitError?.message || waitError); await new Promise(r => setTimeout(r, RETRY_DELAY)); continue; } } // --- Step 2: Ensure Correct Folder List is Visible --- const folderListULSelector = '#liste_messages ul#folder_list'; let folderListUL = document.querySelector(folderListULSelector); const isFolderListVisibleCheck = () => folderListUL && folderListUL.offsetParent !== null && getComputedStyle(folderListUL).display !== 'none'; if (!isFolderListVisibleCheck() && folderListUL) { folderListUL.style.display = 'block'; // Force visible if needed await new Promise(r => setTimeout(r, UI_WAIT_DELAY / 2)); } else if (!folderListUL) { console.error(`${logPrefix} Attempt ${attempt}: Folder list UL element ('${folderListULSelector}') not found! Retrying...`); await new Promise(r => setTimeout(r, RETRY_DELAY)); continue; } // --- Step 3: Click Target Folder LI --- folderListUL = document.querySelector(folderListULSelector); // Re-select const targetFolderLiSelector = `li#folder_${folderId}`; if (!folderListUL || !document.body.contains(folderListUL)) { console.warn(`${logPrefix} Attempt ${attempt}: Cannot find folder list UL ('${folderListULSelector}') or it's detached before clicking folder LI. Retrying...`); await new Promise(r => setTimeout(r, RETRY_DELAY)); continue; } let targetFolderLi = null; try { targetFolderLi = await waitForElement(targetFolderLiSelector, WAIT_FOR_ELEMENT_TIMEOUT, folderListUL); } catch (findError) { console.warn(`${logPrefix} Attempt ${attempt}: Failed to find target folder LI '${targetFolderLiSelector}'. Retrying...`, findError?.message || findError); await new Promise(r => setTimeout(r, RETRY_DELAY)); continue; } // Click the folder ONLY if it's not already the active one const currentFolderSpan = document.querySelector('#current_folder'); const currentFolderId = currentFolderSpan?.dataset?.id; let folderClicked = false; if(currentFolderId !== folderId) { // console.log(`%c${logPrefix} Attempt ${attempt}: Found folder LI ${folderId}. Clicking...`, "color: #4682B4"); await new Promise(r => setTimeout(r, UI_CLICK_DELAY)); const click3Success = await simulateClick(targetFolderLi); if(!click3Success) { console.warn(`${logPrefix} Attempt ${attempt}: Failed to simulate click on target folder LI ${folderId}. Retrying...`); await new Promise(r => setTimeout(r, RETRY_DELAY)); continue; } folderClicked = true; // Wait longer if we clicked the folder, as it triggers an XHR load await new Promise(r => setTimeout(r, UI_WAIT_DELAY * 1.5)); // Increased wait } // else { console.log(`%c${logPrefix} Attempt ${attempt}: Target folder ${folderId} already selected.`, "color: #4682B4"); } // --- Step 4: Find and Double-Click the Message LI --- // Target the specific message LI within the main list container const messageListContentUL = document.querySelector('#liste_messages .content ul'); if (!messageListContentUL || !document.body.contains(messageListContentUL)) { console.warn(`${logPrefix} Attempt ${attempt}: Cannot find #liste_messages .content ul container before message LI click. Retrying...`); await new Promise(r => setTimeout(r, RETRY_DELAY)); continue; } const targetMessageSelector = `li#message_${conversationId}`; // console.log(`%c${logPrefix} Attempt ${attempt}: Waiting for message LI '${targetMessageSelector}' within .content ul...`, "color: #4682B4"); let targetMessageLi = null; try { // Wait longer if the folder was just clicked targetMessageLi = await waitForElement(targetMessageSelector, WAIT_FOR_ELEMENT_TIMEOUT * (folderClicked ? 3 : 2) , messageListContentUL); } catch (findMsgError) { console.warn(`${logPrefix} Attempt ${attempt}: Failed to find target message LI '${targetMessageSelector}' after folder interaction. Retrying...`, findMsgError?.message || findMsgError); await new Promise(r => setTimeout(r, RETRY_DELAY)); continue; } // console.log(`%c${logPrefix} Attempt ${attempt}: Found message LI ${conversationId}. Initiating double-click...`, "color: #008080; font-weight: bold;"); // Use the messageListContentUL as the container for the double-click simulation const success = await initiateDoubleClick(targetMessageSelector, messageListContentUL); if (success) { console.log(`%c${logPrefix} Attempt ${attempt} SUCCEEDED. Double-click initiated for ${targetMessageSelector}.`, "color: green; font-weight: bold;"); overallSuccess = true; menuWasOpenedByScriptOnSuccessfulAttempt = currentAttemptMenuOpened; break; // Exit the retry loop } else { console.warn(`${logPrefix} Attempt ${attempt}: Double-click simulation FAILED for ${targetMessageSelector}. Retrying...`); await new Promise(r => setTimeout(r, RETRY_DELAY)); continue; } } catch (error) { console.error(`${logPrefix} Attempt ${attempt}: Error during UI simulation steps. Retrying...`, error); await new Promise(r => setTimeout(r, RETRY_DELAY)); continue; } } // --- END of for loop (attempts) --- if (!overallSuccess) { console.error(`${logPrefix} All ${MAX_ATTEMPTS} attempts failed to complete the UI simulation.`); } // --- Auto-close Menu --- if (overallSuccess && menuWasOpenedByScriptOnSuccessfulAttempt) { // console.log(`%c${logPrefix} FINALLY: Successful attempt opened the menu. Waiting briefly before attempting to close...`, "color: #4682B4"); await new Promise(r => setTimeout(r, 150)); // Short delay const finalMenuButton = document.getElementById('display_messagerie'); const finalList = document.getElementById('liste_messages'); const finalIsListVisibleCheck = () => finalList && document.body.contains(finalList) && finalList.offsetParent !== null && getComputedStyle(finalList).visibility !== 'hidden' && getComputedStyle(finalList).display !== 'none'; if (finalMenuButton && finalIsListVisibleCheck()) { // console.log(`%c${logPrefix} FINALLY: List is still visible, attempting close click.`, "color: #4682B4"); await simulateClick(finalMenuButton); // console.log(`%c${logPrefix} FINALLY: Clicked #display_messagerie to close menu.`, "color: #4682B4"); } // else: Menu already closed or button missing } // console.log(`%c${logPrefix} Processing finished. Overall Success: ${overallSuccess}`, "color: #4682B4; font-weight: bold;"); } // --- END of handleNewMessageEvent --- function handleOpenMessageResponse(conversationId, responseText) { const conversationData = ACTIVE_CONVERSATIONS[conversationId]; const isConvoMuted = isConversationMuted(conversationId); // Check conversation-specific mute status // Check if window exists OR if it's a user override scenario (where window *should* exist soon) const dmmWindowExists = conversationData && conversationData.customWindow && document.body.contains(conversationData.customWindow); const isUserOverride = openingMutedOverride === conversationId; // Check if this ID is being overridden // If conversation muted AND the DMM window doesn't exist AND it's NOT a user override, suppress the update. if (isConvoMuted && !dmmWindowExists && !isUserOverride) { // console.log(`%cDMM OpenMessage Handler: Suppressing update for MUTED conversation ${conversationId} (no window, no override).`, "color: gray;"); return; } // If window doesn't exist and it's *not* a user override case waiting for the window, abort. if (!dmmWindowExists && !isUserOverride) { // console.log(`%cDMM OpenMessage Handler: DMM window for ${conversationId} not active/attached (and not a pending override). Aborting UI update.`, "color: gray"); return; } // At this point, either the window exists, or we expect it to exist shortly due to user override. const customContentArea = conversationData?.customWindow?.querySelector('.custom-chat-content'); // If the window doesn't exist *yet* due to override, customContentArea will be null. This is handled below. if (!isUserOverride && !customContentArea) { console.warn(`%cDMM OpenMessage Handler[${conversationId}]: Content area not found for UI update (window exists but content area missing?).`, "color: red"); return; } const currentLatestKnownId = conversationData?.latestMessageId; // This is message_id try { const parser = new DOMParser(); const doc = parser.parseFromString(responseText, 'text/html'); const latestConvList = doc.querySelector('.zone_conversation'); if (!latestConvList) { console.warn(`%cDMM OpenMessage Handler[${conversationId}]: Could not find .zone_conversation in intercepted response. Cannot update messages.`, "color: orange"); return; } // Parse elements with 'convers_' ID from the response const serverElements = Array.from(latestConvList.querySelectorAll('.link.conversation[id^="convers_"]')); if (serverElements.length === 0) { /*console.log(`%cDMM OpenMessage Handler[${conversationId}]: No message elements found in response.`, "color: gray");*/ return; } let elementsToProcess = []; let highestServerId = null; // Highest message_id from server let highestServerIdNum = 0; serverElements.forEach(el => { const parsed = parseMessageElement(el); // Get { id, timestamp, sender } where id is message_id if (parsed) { const elIdNum = parseInt(parsed.id); if (!highestServerId || elIdNum > highestServerIdNum) { highestServerId = parsed.id; highestServerIdNum = elIdNum; } // Check against existing content area *only if it exists* const alreadyExists = customContentArea ? customContentArea.querySelector(`.chat-bubble[data-message-id="${parsed.id}"]`) : false; // Compare message_id numbers const isNewer = !currentLatestKnownId || elIdNum > parseInt(currentLatestKnownId); if (!alreadyExists && isNewer) { elementsToProcess.push(el); } // Store the element to fetch content later } }); // console.log(`%cDMM OpenMessage Handler[${conversationId}]: Server highest MsgID: ${highestServerId}. Client latest MsgID: ${currentLatestKnownId ?? 'None'}. Found ${elementsToProcess.length} potential new message(s) to add. Muted: ${isConvoMuted}, Override Pending: ${isUserOverride}`, "color: blue"); if (elementsToProcess.length > 0) { elementsToProcess.reverse(); // Process oldest new message first let newlyProcessedRealIds = []; // Store message_ids added let fetchPromises = elementsToProcess.map(element => { return new Promise(async (resolve) => { const parsed = parseMessageElement(element); // Parse again to get message_id etc. if (parsed) { // Wait briefly if it's an override, allowing createCustomWindow to potentially finish first if (isUserOverride) await new Promise(r => setTimeout(r, 50)); // Re-check DMM window/content area status right before fetching content const finalConvDataCheck = ACTIVE_CONVERSATIONS[conversationId]; const finalContentAreaCheck = finalConvDataCheck?.customWindow?.querySelector('.custom-chat-content'); // If window/content area still doesn't exist (even after potential override delay), skip adding bubble if (!finalConvDataCheck || !finalConvDataCheck.customWindow || !document.body.contains(finalConvDataCheck.customWindow) || !finalContentAreaCheck) { // console.log(`%cDMM OpenMessage Handler [${conversationId}]: Window/Content disappeared before fetching msg ${parsed.id}. Aborting add.`, "color: orange"); resolve(); return; } // Final duplicate check within the confirmed content area using message_id if (finalContentAreaCheck.querySelector(`.chat-bubble[data-message-id="${parsed.id}"]`)) { // console.log(`%cDMM OpenMessage Handler [${conversationId}]: Duplicate msg ${parsed.id} detected before addBubble. Skipping.`, "color: gray"); resolve(); return; } fetchMessageContent(parsed.id, conversationId, (content) => { // Use message_id to fetch // Check window status *again* after async fetch returns const finalConvData = ACTIVE_CONVERSATIONS[conversationId]; if (!finalConvData || !finalConvData.customWindow || !document.body.contains(finalConvData.customWindow)) { resolve(); return; } const finalContentArea = finalConvData.customWindow.querySelector('.custom-chat-content'); if (!finalContentArea) { resolve(); return; } // Check duplicate again *after* fetch, *before* adding if (finalContentArea.querySelector(`.chat-bubble[data-message-id="${parsed.id}"]`)) { // console.log(`%cDMM OpenMessage Handler [${conversationId}]: Duplicate msg ${parsed.id} detected AFTER fetch. Skipping add.`, "color: gray"); resolve(); return; } const msgData = { ...parsed, content: content }; const processedId = addBubble(msgData, finalContentArea, conversationId, false, false); // isInitialLoad=false here; processedId is message_id if (processedId) newlyProcessedRealIds.push(processedId); resolve(); }); } else { resolve(); } }); }); // End map Promise.all(fetchPromises).then(() => { // Final check after all bubbles *should* have been added const postProcessConvData = ACTIVE_CONVERSATIONS[conversationId]; if (!postProcessConvData || !postProcessConvData.customWindow || !document.body.contains(postProcessConvData.customWindow)) { return; } let overallLatestId = postProcessConvData.latestMessageId; // message_id let didUpdateLatest = false; try { newlyProcessedRealIds.forEach(processedId => { // processedId is message_id if (!processedId) return; const processedIdNum = parseInt(processedId); const currentLatestNum = overallLatestId ? parseInt(overallLatestId) : 0; if (processedIdNum > currentLatestNum) { overallLatestId = processedId; didUpdateLatest = true; } }); if (didUpdateLatest) { postProcessConvData.latestMessageId = overallLatestId; // console.log(`%cDMM OpenMessage Handler[${conversationId}]: Successfully processed ${newlyProcessedRealIds.length} message(s). Updated latest message ID to ${postProcessConvData.latestMessageId}`, "color: green"); } } catch (idUpdateError) { console.error(`%cDMM OpenMessage Handler[${conversationId}]: Error updating latestMessageId after processing!`, "color: red", idUpdateError); } }).catch(error => { console.error(`%cDMM OpenMessage Handler[${conversationId}]: Error in Promise.all for new message fetches:`, "color: red", error); }); } else if (highestServerId && currentLatestKnownId && parseInt(highestServerId) > parseInt(currentLatestKnownId)) { // Update latest ID even if no new bubbles were added (they might have been added by another means or filtered) const convDataForIdUpdate = ACTIVE_CONVERSATIONS[conversationId]; if (convDataForIdUpdate) { // Ensure data still exists // console.log(`%cDMM OpenMessage Handler[${conversationId}]: Server latest message ID ${highestServerId} > Client ${currentLatestKnownId}, but no new elements added. Updating client latest message ID.`, "color: blue"); convDataForIdUpdate.latestMessageId = highestServerId; } } } catch (e) { console.error(`%cDMM OpenMessage Handler[${conversationId}]: General error processing intercepted response:`, "color: red", e); } } // --- END of handleOpenMessageResponse --- // --- Click Handling on Sidebar --- function handleMessageListClick(event) { if (!event.isTrusted) return; // Ignore simulated clicks const messageLi = event.target.closest('li.message[id^="message_"]'); if (!messageLi) return; // Click wasn't on a message LI element const conversationId = messageLi.id.replace('message_', ''); // Mute Handling Logic const isMuted = isConversationMuted(conversationId); if (isMuted) { console.log(`%cDMM ClickIntercept: User click detected on sidebar for MUTED convo ${conversationId}. OVERRIDING mute for this open.`, "color: #8A2BE2;"); // Purple // Set the override flag openingMutedOverride = conversationId; // Clear previous timer if any if (openingMutedOverrideTimer) clearTimeout(openingMutedOverrideTimer); // Set a timer to clear the flag shortly after, in case observer is slow or DC action fails openingMutedOverrideTimer = setTimeout(() => { if (openingMutedOverride === conversationId) { // Ensure it wasn't changed by another click // console.log(`%cDMM ClickIntercept: Clearing mute override flag for ${conversationId} after timeout.`, "color: gray;"); openingMutedOverride = null; } openingMutedOverrideTimer = null; }, 500); // 500ms should be enough for DC to add the window and observer to react // *** DO NOT preventDefault() or stopPropagation() here! *** // Let the original Dreadcast click handler proceed to open the db_message window. // The mainObserverCallback will handle the override. return; // Stop further processing *within this DMM handler* } // End Mute Handling // --- If NOT muted, proceed with existing logic --- const customWindowId = `custom-chat-${conversationId}`; const existingData = ACTIVE_CONVERSATIONS[conversationId]; const customWindowElement = document.getElementById(customWindowId); if (existingData && customWindowElement && existingData.customWindow === customWindowElement && document.body.contains(customWindowElement)) { // console.log(`%cDMM ClickIntercept: User click detected on sidebar for OPEN (and not muted) DMM convo ${conversationId}. DMM PREVENTING default Dreadcast action.`, "color: blue"); event.preventDefault(); // Stop Dreadcast from opening its own window event.stopPropagation(); // Stop event from bubbling further bringWindowToFront(customWindowElement); // Use new function to bring window to front if (customWindowElement.classList.contains('collapsed')) { customWindowElement.classList.remove('collapsed'); } // Uncollapse const textarea = customWindowElement.querySelector('.custom-chat-reply textarea'); if (textarea) setTimeout(() => textarea.focus(), 50); // Focus after a tiny delay return; // Stop further processing by this handler } // If an original window was previously revealed for 'invite' and is still in the DOM... const revealedOriginal = document.querySelector(`#db_message_${conversationId}[data-modernized="revealed_for_invite"]`); if (revealedOriginal && document.body.contains(revealedOriginal)) { // console.log(`DMM ClickIntercept: User click on message_${conversationId} while original was revealed. Hiding original before allowing default action.`); revealedOriginal.classList.add('hidden-original-databox'); // Re-hide the original revealedOriginal.dataset.modernized = ''; // Clear the state } // else: No DMM window open, or original not revealed. Allow default Dreadcast action. } // --- END of handleMessageListClick --- function setupClickListener() { const stableParent = document.getElementById('liste_messages'); if (stableParent) { stableParent.removeEventListener('click', handleMessageListClick, true); // Remove first stableParent.addEventListener('click', handleMessageListClick, true); // Add listener (capture phase) console.log("DMM: Sidebar click listener attached (capture phase)."); } else { console.error("DMM: CRITICAL - #liste_messages not found for click listener. Retrying..."); setTimeout(setupClickListener, 2000); } } // --- Main Observer Callback (Detects added original windows) --- const mainObserverCallback = async (mutationsList) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { for (const node of mutation.addedNodes) { // Check if the added node is an original Dreadcast message window if (node.nodeType === Node.ELEMENT_NODE && node.id?.startsWith('db_message_')) { const originalWindow = node; const conversationId = originalWindow.id.replace('db_message_', ''); // Mute Check Logic const isMuted = isConversationMuted(conversationId); let skipMuteRemoval = false; // Flag to allow modernization even if muted if (isMuted) { // Check if this opening was triggered by user override click if (openingMutedOverride === conversationId) { console.log(`%cDMM MainObserver: Muted window ${conversationId} detected, but user override flag is set. Allowing modernization.`, "color: #8A2BE2;"); skipMuteRemoval = true; // Allow modernization openingMutedOverride = null; // Consume the flag if(openingMutedOverrideTimer) clearTimeout(openingMutedOverrideTimer); // Clear timer early openingMutedOverrideTimer = null; } else { // Muted and NOT a user override click (e.g., from /Check simulation) // Check if it hasn't ALREADY been marked/removed to avoid loops if (originalWindow.dataset.modernized !== 'muted_removed') { console.log(`%cDMM MainObserver: Detected original window ${originalWindow.id} for MUTED conversation (no override). Removing it immediately.`, "color: #DAA520;"); originalWindow.dataset.modernized = 'muted_removed'; // Mark state FIRST try { // Add an extra check to ensure it's still in the DOM if (originalWindow.parentNode) { originalWindow.remove(); } else { // console.log(`%cDMM MainObserver: Muted window ${originalWindow.id} was already removed from DOM before explicit removal.`, "color: gray;"); } } catch (removeError) { console.warn(`DMM MainObserver: Error removing muted original window ${originalWindow.id}:`, removeError); } } continue; // <<< IMPORTANT: Stop processing this node further if muted and not overridden } } // End Mute Check Logic // Skip if already handled/marked (unless overridden) if (['processing', 'replaced', 'error', 'revealed_for_invite', 'muted_removed'].includes(originalWindow.dataset.modernized) && !skipMuteRemoval) { // console.log(`DMM MainObserver: Skipping node ${originalWindow.id} with state: ${originalWindow.dataset.modernized}`); continue; } // --- Proceed with modernization if not muted OR if mute was overridden --- const existingDMMData = ACTIVE_CONVERSATIONS[conversationId]; const existingDMMWindow = document.getElementById(`custom-chat-${conversationId}`); if (existingDMMData && existingDMMWindow && document.body.contains(existingDMMWindow)) { // Update reference and hide original if DMM window already exists // console.log(`DMM MainObserver: Detected original window ${originalWindow.id} for existing DMM window. Updating reference and hiding original.`); existingDMMData.originalWindow = originalWindow; originalWindow.classList.add('hidden-original-databox'); originalWindow.dataset.modernized = 'replaced'; } else { // This is a NEW conversation window to modernize originalWindow.dataset.modernized = 'processing'; // console.log(`%cDMM MainObserver: Detected new original window ${originalWindow.id} (Muted: ${isMuted}, Overridden: ${skipMuteRemoval}). Modernizing...`, "color: purple"); originalWindow.classList.add('hidden-original-databox'); // Hide it if (!MY_NAME) MY_NAME = getMyCharacterName(); if (!MY_NAME) { console.error("DMM CRITICAL: Failed to get character name when modernizing window!"); alert("Erreur critique DMM: Impossible d'obtenir le nom du personnage. Impossible d'ouvrir la fenêtre de message DMM."); originalWindow.classList.remove('hidden-original-databox'); originalWindow.style.opacity = '0.7'; originalWindow.style.border = '2px dashed red'; originalWindow.style.pointerEvents = 'auto'; originalWindow.dataset.modernized = 'error'; continue; // Stop processing this node } // Fetch initial messages and create the DMM window try { const initialResult = await parseAndFetchInitialMessages(originalWindow, conversationId); if (!initialResult || typeof initialResult !== 'object') { throw new Error(`parseAndFetchInitialMessages returned invalid result for ${conversationId}`); } createCustomWindow(conversationId, null, initialResult, originalWindow); originalWindow.dataset.modernized = 'replaced'; // Mark as replaced *after* successful creation // console.log(`%cDMM MainObserver: Successfully modernized ${originalWindow.id}`, "color: purple"); } catch (error) { console.error(`DMM MainObserver: Failed to process and modernize conversation ${conversationId}:`, error); alert(`DMM Erreur: Impossible de charger la conversation ${conversationId}. La fenêtre originale reste visible (avec bordure rouge).`); delete ACTIVE_CONVERSATIONS[conversationId]; // Clean up potentially partial data originalWindow.classList.remove('hidden-original-databox'); originalWindow.style.opacity = '0.7'; originalWindow.style.border = '2px dashed red'; originalWindow.style.pointerEvents = 'auto'; originalWindow.dataset.modernized = 'error'; } } } // Fin if node.id startsWith db_message_ } // Fin boucle addedNodes } // Fin if mutation.type childList } // Fin boucle mutationsList }; // --- END of mainObserverCallback --- // --- Sidebar Observer Callback (Detects changes in message list UL) --- const sidebarObserverCallback = (mutationsList) => { let listChanged = false; let contentChanged = false; for (const mutation of mutationsList) { // Check for both content UL changes and folder switches if (mutation.type === 'childList') { // Check added/removed nodes const changedNodes = [...mutation.addedNodes, ...mutation.removedNodes]; for (const node of changedNodes) { if (node.nodeType === Node.ELEMENT_NODE) { // Check for direct message items if (node.matches?.('li.message[id^="message_"]')) { listChanged = true; break; } // Check for content container changes (folder switch) if (node.matches?.('.content')) { contentChanged = true; break; } // Check for UL replacement if (node.tagName === 'UL') { contentChanged = true; break; } } } } if (listChanged || contentChanged) break; } // Handle the changes if (listChanged || contentChanged) { // Clear any pending scan if (sidebarScanDebounceTimer) { clearTimeout(sidebarScanDebounceTimer); } // Set different delays based on change type const delay = contentChanged ? 500 : 300; // Longer delay for folder switches sidebarScanDebounceTimer = setTimeout(() => { scanAndUpdateSidebarMutes(); sidebarScanDebounceTimer = null; }, delay); } }; // --- Script Initialization --- console.log(`DMM: Dreadcast Dynamic Messages script v${SCRIPT_VERSION} starting...`); addChatStyles(); async function initializeScript() { // Make async to await element const essentialElements = [ document.body, document.getElementById('zone_messagerie'), document.getElementById('txt_pseudo'), document.getElementById('liste_messages') // Ensure message list exists for listener/observer ]; if (essentialElements.every(el => el)) { console.log("DMM: Essential elements found."); if (!MY_NAME) MY_NAME = getMyCharacterName(); if (!MY_NAME) { console.error("DMM: Failed to get character name on init! Aborting."); return; } // --- Load Global Mute State --- <<< NEW >>> loadGlobalMuteState(); // --- Create Global Mute Button --- <<< NEW >>> createGlobalMuteButton(); // Async, will attach when ready // --- Setup XHR Wrapper --- // (Logic for /Check and OpenMessage remains the same) if (!unsafeWindow._original_XMLHttpRequest_open && !unsafeWindow._original_XMLHttpRequest_send) { console.log("DMM: Applying XMLHttpRequest wrappers for /Check and OpenMessage interception..."); const origOpen = unsafeWindow.XMLHttpRequest.prototype.open; const origSend = unsafeWindow.XMLHttpRequest.prototype.send; let requestCounter = 0; unsafeWindow.XMLHttpRequest.prototype.open = function(method, url) { this._requestMethod = method; this._requestURL = url; this._requestId = requestCounter++; // console.log(`DMM XHR Open [${this._requestId}]: ${method} ${url}`); return origOpen.apply(this, arguments); }; unsafeWindow.XMLHttpRequest.prototype.send = function() { const xhr = this; const reqId = xhr._requestId; const targetUrl = xhr._requestURL; const dmmReadyStateHandler = function() { const rsUrl = xhr._requestURL; // Use the stored URL if (xhr.readyState === 4) { // console.log(`DMM XHR Done [${reqId}]: State 4 for ${rsUrl}, Status: ${xhr.status}`); const currentStatus = xhr.status; let currentResponseText = null; try { currentResponseText = xhr.responseText; } catch(e) { /* Ignore responseText access errors */ } // --- Handle /Check --- if (rsUrl && typeof rsUrl === 'string' && rsUrl.includes('/Check')) { if (currentStatus === 200 && currentResponseText) { let match = null; try { // Regex to find nouveau_message event match = currentResponseText.match(/<evenement\s+type="nouveau_message">.*?<folder_(\d+)\s+quantite="\d+"\s+id_conversation="(\d+)"\s*\/?>.*?<\/evenement>/s); } catch (regexError) { console.error('DMM: Error during /Check regex matching!', regexError); } if (match && match[1] && match[2]) { const folderId = match[1]; const conversationId = match[2]; const isConvoMuted = isConversationMuted(conversationId); // Conversation specific mute // console.log(`%cDMM XHR /Check Match [${reqId}]: nouveau_message! Convo: ${conversationId}, Folder: ${folderId}, Muted: ${isConvoMuted}`, "color: orange"); const conversationData = ACTIVE_CONVERSATIONS[conversationId]; const isWindowOpen = conversationData && conversationData.customWindow && document.body.contains(conversationData.customWindow); if (isWindowOpen) { // DMM Window IS OPEN: Always trigger simulation to update content. // console.log(`%cDMM /Check: Window for ${conversationId} is OPEN. Triggering UI update simulation. (ConvoMuted: ${isConvoMuted}, GlobalMuted: ${isGloballyMuted})`, "color: blue"); try { setTimeout(() => handleNewMessageEvent(conversationId, folderId), 0); } catch (e) { console.error(`DMM ERROR: Exception queueing handleNewMessageEvent for OPEN window ${conversationId}`, e); } } else { // DMM Window IS CLOSED: if (isConvoMuted) { // CONVO MUTED and CLOSED: Trigger simulation to mark as read silently. NO sound. // console.log(`%cDMM /Check: Window for ${conversationId} is CLOSED & CONVO MUTED. Triggering SILENT UI simulation.`, "color: #DAA520;"); try { setTimeout(() => handleNewMessageEvent(conversationId, folderId), 0); } catch (e) { console.error(`DMM ERROR: Exception queueing handleNewMessageEvent for CONVO MUTED/CLOSED window ${conversationId}`, e); } } else if (isGloballyMuted) { // GLOBALLY MUTED and CLOSED: Trigger simulation to mark as read silently. NO sound. // console.log(`%cDMM /Check: Window for ${conversationId} is CLOSED & GLOBALLY MUTED. Triggering SILENT UI simulation.`, "color: #DAA520;"); try { setTimeout(() => handleNewMessageEvent(conversationId, folderId), 0); } catch (e) { console.error(`DMM ERROR: Exception queueing handleNewMessageEvent for GLOBALLY MUTED/CLOSED window ${conversationId}`, e); } } else { // NOT muted (neither convo nor global) and CLOSED: Play "unopened" sound. DO NOT simulate click. // console.log(`%cDMM /Check: Window for ${conversationId} is CLOSED & NOT muted (convo or global). Playing UNOPENED notification sound.`, "color: #FF8C00"); try { const audio = new Audio(UNOPENED_NOTIFICATION_SOUND_URL); audio.play().catch(e => { console.warn(`DMM: Unopened notification sound playback failed:`, e.name, e.message); }); } catch (e) { console.error("DMM: Error creating/playing unopened notification sound:", e); } // No handleNewMessageEvent call here. Let user click. } } // <<< NEW >>> Update sidebar highlighting potentially needed after /Check handles marking read // Use a small delay to allow potential UI updates from handleNewMessageEvent to settle setTimeout(() => updateSidebarMuteStatus(conversationId), 150); } // end if match } // end if status 200 } // end if /Check // --- Handle OpenMessage --- else if (rsUrl && typeof rsUrl === 'string' && rsUrl.includes('action=OpenMessage&id_conversation=')) { if (currentStatus === 200 && currentResponseText) { const conversationIdMatch = rsUrl.match(/id_conversation=(\d+)/); if (conversationIdMatch && conversationIdMatch[1]) { const conversationId = conversationIdMatch[1]; // console.log(`%cDMM XHR OpenMessage Resp [${reqId}]: Intercepted successful response for ${conversationId}. Processing...`, "color: #8A2BE2"); try { setTimeout(() => handleOpenMessageResponse(conversationId, currentResponseText), 0); } catch (e) { console.error(`DMM ERROR: Exception queueing handleOpenMessageResponse for ${conversationId}`, e); } } } else { /* console.warn(`DMM XHR OpenMessage Resp [${reqId}]: Intercepted non-200 status: ${currentStatus} for ${rsUrl}`); */ } } try { xhr.removeEventListener('readystatechange', dmmReadyStateHandler); } catch(removeError) { /* ignore */ } } // end if readyState 4 }; // --- END of dmmReadyStateHandler --- try { xhr.addEventListener('readystatechange', dmmReadyStateHandler); } catch(addListenerError) { console.error(`DMM: FAILED to add readystatechange listener for ${targetUrl}!`, addListenerError); } // Ensure originals are stored correctly if multiple XHRs happen quickly if(!unsafeWindow._original_XMLHttpRequest_open) unsafeWindow._original_XMLHttpRequest_open = origOpen; if(!unsafeWindow._original_XMLHttpRequest_send) unsafeWindow._original_XMLHttpRequest_send = origSend; return origSend.apply(this, arguments); }; // --- END of send override --- console.log("DMM: XHR wrappers applied."); } else { console.log("DMM: XHR wrappers appear to be already applied. Skipping re-application."); } // --- End XHR Wrapper Setup --- // Initialize Main Observer if (!mainObserver) { mainObserver = new MutationObserver(mainObserverCallback); mainObserver.observe(document.body, { childList: true, subtree: true }); console.log("DMM: Main observer initialized."); } // Initialize Sidebar Observer (modified to be more thorough) if (!sidebarObserver) { // Try to observe the entire message list container for better coverage const messageList = document.getElementById('liste_messages'); if (messageList) { sidebarObserver = new MutationObserver(sidebarObserverCallback); sidebarObserver.observe(messageList, { childList: true, subtree: true, attributes: false, characterData: false }); console.log("DMM: Enhanced sidebar observer initialized."); // Initial scan scanAndUpdateSidebarMutes(); } else { console.error("DMM: Could not find #liste_messages to observe sidebar changes!"); } } // Setup Click Listener for the sidebar setupClickListener(); // Initial scan of sidebar items for mute status scanAndUpdateSidebarMutes(); console.log("DMM: Initialization complete."); } else { console.log("DMM: Waiting for essential elements... Retrying init."); const missing = essentialElements.map((el, i) => el ? '' : ['body', '#zone_messagerie', '#txt_pseudo', '#liste_messages'][i]).filter(Boolean); console.log("DMM: Missing elements:", missing.join(', ')); setTimeout(initializeScript, 500); // Retry } } // --- END of initializeScript --- // --- Start Initialization --- if (document.readyState === 'loading') { window.addEventListener('DOMContentLoaded', initializeScript); } else { initializeScript(); // DOM is already ready } // --- Cleanup on page unload --- window.addEventListener('beforeunload', () => { console.log("DMM: Unloading script and cleaning up..."); if (mainObserver) mainObserver.disconnect(); mainObserver = null; if (sidebarObserver) sidebarObserver.disconnect(); sidebarObserver = null; if (sidebarScanDebounceTimer) clearTimeout(sidebarScanDebounceTimer); sidebarScanDebounceTimer = null; if (openingMutedOverrideTimer) clearTimeout(openingMutedOverrideTimer); openingMutedOverrideTimer = null; // Clear all mute timers BEFORE deleting conversation data Object.keys(ACTIVE_CONVERSATIONS).forEach(convId => { const cData = ACTIVE_CONVERSATIONS[convId]; if(cData?.muteTimerIntervalId) { clearInterval(cData.muteTimerIntervalId); } }); // Now close windows and delete data Object.keys(ACTIVE_CONVERSATIONS).forEach(convId => { try { closeChatWindow(convId, { removeOriginal: true }); } catch(e) { console.warn("DMM: Error during unload window cleanup for convId:", convId, e); } }); // Explicitly clear the object just in case closeChatWindow had issues for (let key in ACTIVE_CONVERSATIONS) { delete ACTIVE_CONVERSATIONS[key]; } const listenerTarget = document.getElementById('liste_messages'); if (listenerTarget) { try { listenerTarget.removeEventListener('click', handleMessageListClick, true); } catch(e){} } // <<< NEW >>> Remove global mute button listener if needed (though often not strictly necessary on unload) const globalMuteButton = document.getElementById(GLOBAL_MUTE_BUTTON_ID); // Basic check: if it still exists, remove it (or its listener) if(globalMuteButton && globalMuteButton.parentNode) { try { globalMuteButton.remove(); } catch(e) {} } try { const win = unsafeWindow; if (win._original_XMLHttpRequest_open) { win.XMLHttpRequest.prototype.open = win._original_XMLHttpRequest_open; delete win._original_XMLHttpRequest_open; } if (win._original_XMLHttpRequest_send) { win.XMLHttpRequest.prototype.send = win._original_XMLHttpRequest_send; delete win._original_XMLHttpRequest_send; } } catch (e) { console.error("DMM: Error restoring original XMLHttpRequest methods:", e); } console.log("DMM: Cleanup complete."); }); })();