// ==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.");
});
})();