Greasy Fork is available in English.
Enhanced features for IQRPG including notifications and alerts
// ==UserScript== // @name IQRPG Enhanced // @namespace https://iqrpg.com/ // @version 2.2.8 // @description Enhanced features for IQRPG including notifications and alerts // @author Sanjin // @license MIT // @match https://iqrpg.com/game.html // @match https://www.iqrpg.com/game.html // @match http://iqrpg.com/game.html // @match http://www.iqrpg.com/game.html // @grant none // @run-at document-start // @require https://cdn.jsdelivr.net/npm/[email protected]/lib/emoji.min.js // ==/UserScript== (function() { 'use strict'; // ============================================ // Configuration & State Management // ============================================ const DEFAULT_CONFIG = { notifications: { globalEvents: { sound: true, desktop: true }, clan: { sound: true, desktop: true, watchtower: true, clanChatGlobals: true }, actionBonus: { sound: true, desktop: true }, bossSpawn: { sound: true, desktop: true }, tradeAlert: { sound: true, desktop: true, sellingKeywords: [], buyingKeywords: [] }, gatheringEvents: { woodcutting: { sound: true, desktop: true }, quarrying: { sound: true, desktop: true }, mining: { sound: true, desktop: true } }, message: { sound: true, desktop: true }, autos: { sound: true, desktop: true, threshold: 100, // Alert when autos reach this number repeatCount: 1, // Number of times to repeat alert while under threshold (1 = no repeat) repeatInterval: 1 // Seconds between repeat alerts (0 = immediate repeats, no delay) }, potions: { sound: true, desktop: true, threshold: 100, // Alert when potions reach this number repeatCount: 1, // Number of times to repeat alert while under threshold (1 = no repeat) repeatInterval: 1 // Seconds between repeat alerts (0 = immediate repeats, no delay) }, dungeon: { sound: true, desktop: true, onlyWhenAllKeysComplete: false // Only notify when all dungeon keys are completed }, mastery: { sound: true, desktop: true }, land: { sound: true, desktop: true }, skills: { sound: true, desktop: true }, itemDrop: { sound: true, desktop: true, itemKeywords: [] }, abyssBattles: { sound: true, desktop: true }, marketSale: { sound: true, desktop: true }, itemReceived: { sound: true, desktop: true }, itemSent: { sound: true, desktop: true } }, sounds: { globalEvent: 'https://audio.jukehost.co.uk/qyoNau6faKvNTt2NVyZ3Mcr8WDw1ueiv', actionBonus: 'https://audio.jukehost.co.uk/wHRlgKNZdfDnXfLsoTqDjcluHENngS4b', bossSpawn: 'https://audio.jukehost.co.uk/zL9Qk16xdxOKyJfMDUPwliTfsKAVJW6n', tradeAlert: 'https://audio.jukehost.co.uk/kbWqZVtOxyOB3Whq0o5Em6LLOGJjP2CY', gatheringEvent: 'https://audio.jukehost.co.uk/yuTJytEhB55P1iFzAVwgX7wU4sr1h7cp', message: 'https://audio.jukehost.co.uk/s3Nil94O25qt8bKUZp3CrL3z5YzIS1OE', autos: 'https://audio.jukehost.co.uk/WKlTn6GvA0e3UGCAvW3IwaJj7vT7VBmL', dungeon: 'https://audio.jukehost.co.uk/ccIvfx6WghmSymNDuZEeuGFFpMS84CY5', mastery: 'https://audio.jukehost.co.uk/9DW0A6lxLQtstNHZwuiNoGcJciEJ5rdh', land: 'https://audio.jukehost.co.uk/ccIvfx6WghmSymNDuZEeuGFFpMS84CY5', skills: 'https://audio.jukehost.co.uk/9DW0A6lxLQtstNHZwuiNoGcJciEJ5rdh', clanWatchtower: 'https://audio.jukehost.co.uk/wf7tdKTnzx1Kb0wQHqLDab9pfhEHk130', clanGlobals: 'https://audio.jukehost.co.uk/qyoNau6faKvNTt2NVyZ3Mcr8WDw1ueiv', itemDrop: 'https://audio.jukehost.co.uk/mlm4l3BkKXDT54NaogHDmt9buhGO4Sa3', abyssBattles: 'https://audio.jukehost.co.uk/8WLa35FnrtIYNbBVhoiVdHKeFyLjwp0n', potions: 'https://audio.jukehost.co.uk/DsgIJBKLrrZHdx7R3XE1kirUcMJKaDjo', marketSale: 'https://audio.jukehost.co.uk/kllcpuOyzKQUIMNdwO1YlmGZTcGLeWkM', itemReceived: 'https://audio.jukehost.co.uk/mlm4l3BkKXDT54NaogHDmt9buhGO4Sa3', itemSent: 'https://audio.jukehost.co.uk/mlm4l3BkKXDT54NaogHDmt9buhGO4Sa3', goldReceived: 'https://audio.jukehost.co.uk/AG7SUW9D6RwJfu2XJco92BYalCLYYmO2', goldSent: 'https://audio.jukehost.co.uk/AG7SUW9D6RwJfu2XJco92BYalCLYYmO2', volume: 1.0 // Volume level (0.0 to 1.0) }, gui: { enabled: true }, features: { images: { enabled: false // Image linking and modals }, youtube: { enabled: false // YouTube linking and modals }, emojis: { enabled: false // Emoji rendering and autocomplete } } }; const CONFIG = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); // Constants const CONSTANTS = { DELAYS: { BUTTON_CREATION: 100, RETRY_SHORT: 500, NOTIFICATION_AUTO_CLOSE: 10000, SAVE_FEEDBACK: 2000 }, STRINGS: { PREMIUM_STORE: 'premium store', SOUND_GLOBAL: 'globalEvent', SOUND_ACTION_BONUS: 'actionBonus', SOUND_BOSS_SPAWN: 'bossSpawn', SOUND_TRADE_ALERT: 'tradeAlert', SOUND_GATHERING_EVENT: 'gatheringEvent', SOUND_MESSAGE: 'message', SOUND_AUTOS: 'autos', SOUND_DUNGEON: 'dungeon', SOUND_MASTERY: 'mastery', SOUND_LAND: 'land', SOUND_SKILLS: 'skills', SOUND_CLAN_WATCHTOWER: 'clanWatchtower', SOUND_CLAN_GLOBALS: 'clanGlobals', SOUND_ITEM_DROP: 'itemDrop', SOUND_ABYSS_BATTLES: 'abyssBattles', SOUND_POTIONS: 'potions', SOUND_MARKET_SALE: 'marketSale', SOUND_ITEM_RECEIVED: 'itemReceived', SOUND_ITEM_SENT: 'itemSent', SOUND_GOLD_RECEIVED: 'goldReceived', SOUND_GOLD_SENT: 'goldSent', MESSAGE_TYPE_MSG: 'msg', MESSAGE_TYPE_ACTION_BONUS: 'actionBonus', DATA_TYPE_GLOBAL: 'global', DATA_TYPE_EVENT_GLOBAL: 'eventGlobal', DATA_TYPE_CLAN_GLOBAL: 'clanGlobal', DATA_TYPE_PM_FROM: 'pm-from', DATA_TYPE_NOTIFICATION: 'notification', CHANNEL_TRADE: 'trade', CHANNEL_CLAN_PREFIX: 'clan-', CHANNEL_NOTIFICATIONS: 'notifications', MESSAGE_TYPE_NOTIFICATION: 'notification' }, // Patterns to detect selling/buying intent in trade messages (case-insensitive) TRADE_PATTERNS: { SELLING: ['selling', 'wts', 'sell', 's>', '{s}'], BUYING: ['buying', 'wtb', 'buy', 'b>', '{b}'] }, BOSS_SPAWN_PATTERNS: { DEMON_HORN: ['demon horn', 'bosses start appearing'] }, // Mapping of item keys to their proper display names ITEM_NAME_MAP: { // Only dungeon keys need special names - all other items will be auto-converted 'dungeon_key_1': 'Goblin Cave Key', 'dungeon_key_2': 'Mountain Pass Key', 'dungeon_key_3': 'Desolate Tombs Key', 'dungeon_key_4': 'Dragonkin Lair Key', 'dungeon_key_5': 'Sunken Ruins Key', 'dungeon_key_6': 'Abandoned Tower Key', 'dungeon_key_7': 'Haunted Cells Key', 'dungeon_key_8': 'Hall of Dragons Key', 'dungeon_key_9': 'The Vault Key', 'dungeon_key_10': 'The Treasury Key' }, SELECTORS: { FIXED_TOP: '.fixed-top', SECTION_3: '.section-3' } }; // Helper function to preload all sounds function preloadAllSounds() { AudioManager.preloadSound(CONFIG.sounds.globalEvent, CONSTANTS.STRINGS.SOUND_GLOBAL); AudioManager.preloadSound(CONFIG.sounds.actionBonus, CONSTANTS.STRINGS.SOUND_ACTION_BONUS); AudioManager.preloadSound(CONFIG.sounds.bossSpawn, CONSTANTS.STRINGS.SOUND_BOSS_SPAWN); AudioManager.preloadSound(CONFIG.sounds.tradeAlert, CONSTANTS.STRINGS.SOUND_TRADE_ALERT); AudioManager.preloadSound(CONFIG.sounds.gatheringEvent, CONSTANTS.STRINGS.SOUND_GATHERING_EVENT); AudioManager.preloadSound(CONFIG.sounds.message, CONSTANTS.STRINGS.SOUND_MESSAGE); AudioManager.preloadSound(CONFIG.sounds.autos, CONSTANTS.STRINGS.SOUND_AUTOS); AudioManager.preloadSound(CONFIG.sounds.dungeon, CONSTANTS.STRINGS.SOUND_DUNGEON); AudioManager.preloadSound(CONFIG.sounds.mastery, CONSTANTS.STRINGS.SOUND_MASTERY); AudioManager.preloadSound(CONFIG.sounds.land, CONSTANTS.STRINGS.SOUND_LAND); AudioManager.preloadSound(CONFIG.sounds.skills, CONSTANTS.STRINGS.SOUND_SKILLS); AudioManager.preloadSound(CONFIG.sounds.clanWatchtower, CONSTANTS.STRINGS.SOUND_CLAN_WATCHTOWER); AudioManager.preloadSound(CONFIG.sounds.clanGlobals, CONSTANTS.STRINGS.SOUND_CLAN_GLOBALS); AudioManager.preloadSound(CONFIG.sounds.itemDrop, CONSTANTS.STRINGS.SOUND_ITEM_DROP); AudioManager.preloadSound(CONFIG.sounds.abyssBattles, CONSTANTS.STRINGS.SOUND_ABYSS_BATTLES); AudioManager.preloadSound(CONFIG.sounds.potions, CONSTANTS.STRINGS.SOUND_POTIONS); AudioManager.preloadSound(CONFIG.sounds.marketSale, CONSTANTS.STRINGS.SOUND_MARKET_SALE); AudioManager.preloadSound(CONFIG.sounds.itemReceived, CONSTANTS.STRINGS.SOUND_ITEM_RECEIVED); AudioManager.preloadSound(CONFIG.sounds.itemSent, CONSTANTS.STRINGS.SOUND_ITEM_SENT); AudioManager.preloadSound(CONFIG.sounds.goldReceived, CONSTANTS.STRINGS.SOUND_GOLD_RECEIVED); AudioManager.preloadSound(CONFIG.sounds.goldSent, CONSTANTS.STRINGS.SOUND_GOLD_SENT); } // Load saved configuration function loadConfig() { let saved = null; try { const savedStr = localStorage.getItem('iqrpg_enhanced_config'); if (savedStr) { saved = JSON.parse(savedStr); } } catch (e) { // Silently fail - use default config } if (saved) { // Deep merge instead of shallow assign CONFIG.notifications.globalEvents = { ...CONFIG.notifications.globalEvents, ...(saved.notifications?.globalEvents || {}) }; CONFIG.notifications.clan = { ...CONFIG.notifications.clan, ...(saved.notifications?.clan || {}) }; CONFIG.notifications.actionBonus = { ...CONFIG.notifications.actionBonus, ...(saved.notifications?.actionBonus || {}) }; CONFIG.notifications.bossSpawn = { ...CONFIG.notifications.bossSpawn, ...(saved.notifications?.bossSpawn || {}) }; CONFIG.notifications.tradeAlert = { ...CONFIG.notifications.tradeAlert, ...(saved.notifications?.tradeAlert || {}) }; // Handle gathering events with backward compatibility if (saved.notifications?.gatheringEvents) { // Check if old format exists (top-level sound/desktop) if (saved.notifications.gatheringEvents.sound !== undefined && !saved.notifications.gatheringEvents.woodcutting) { // Old format detected - migrate to new structure const oldConfig = saved.notifications.gatheringEvents; CONFIG.notifications.gatheringEvents.woodcutting = { sound: oldConfig.sound, desktop: oldConfig.desktop }; CONFIG.notifications.gatheringEvents.quarrying = { sound: oldConfig.sound, desktop: oldConfig.desktop }; CONFIG.notifications.gatheringEvents.mining = { sound: oldConfig.sound, desktop: oldConfig.desktop }; } else { // New format - merge each event type CONFIG.notifications.gatheringEvents.woodcutting = { ...CONFIG.notifications.gatheringEvents.woodcutting, ...(saved.notifications.gatheringEvents.woodcutting || {}) }; CONFIG.notifications.gatheringEvents.quarrying = { ...CONFIG.notifications.gatheringEvents.quarrying, ...(saved.notifications.gatheringEvents.quarrying || {}) }; CONFIG.notifications.gatheringEvents.mining = { ...CONFIG.notifications.gatheringEvents.mining, ...(saved.notifications.gatheringEvents.mining || {}) }; } } CONFIG.notifications.message = { ...CONFIG.notifications.message, ...(saved.notifications?.message || {}) }; CONFIG.notifications.autos = { ...CONFIG.notifications.autos, ...(saved.notifications?.autos || {}) }; CONFIG.notifications.dungeon = { ...CONFIG.notifications.dungeon, ...(saved.notifications?.dungeon || {}) }; CONFIG.notifications.mastery = { ...CONFIG.notifications.mastery, ...(saved.notifications?.mastery || {}) }; CONFIG.notifications.land = { ...CONFIG.notifications.land, ...(saved.notifications?.land || {}) }; CONFIG.notifications.skills = { ...CONFIG.notifications.skills, ...(saved.notifications?.skills || {}) }; CONFIG.notifications.itemDrop = { ...CONFIG.notifications.itemDrop, ...(saved.notifications?.itemDrop || {}) }; CONFIG.notifications.abyssBattles = { ...CONFIG.notifications.abyssBattles, ...(saved.notifications?.abyssBattles || {}) }; CONFIG.notifications.potions = { ...CONFIG.notifications.potions, ...(saved.notifications?.potions || {}) }; CONFIG.notifications.marketSale = { ...CONFIG.notifications.marketSale, ...(saved.notifications?.marketSale || {}) }; CONFIG.notifications.itemReceived = { ...CONFIG.notifications.itemReceived, ...(saved.notifications?.itemReceived || {}) }; CONFIG.notifications.itemSent = { ...CONFIG.notifications.itemSent, ...(saved.notifications?.itemSent || {}) }; CONFIG.sounds = { ...CONFIG.sounds, ...(saved.sounds || {}) }; // Ensure gold sounds exist if (!CONFIG.sounds.goldReceived) { CONFIG.sounds.goldReceived = DEFAULT_CONFIG.sounds.goldReceived; } if (!CONFIG.sounds.goldSent) { CONFIG.sounds.goldSent = DEFAULT_CONFIG.sounds.goldSent; } CONFIG.gui = { ...CONFIG.gui, ...(saved.gui || {}) }; CONFIG.features = { ...CONFIG.features, ...(saved.features || {}) }; // Ensure nested properties are merged correctly if (saved.features) { CONFIG.features.images = { ...CONFIG.features.images, ...(saved.features.images || {}) }; CONFIG.features.youtube = { ...CONFIG.features.youtube, ...(saved.features.youtube || {}) }; CONFIG.features.emojis = { ...CONFIG.features.emojis, ...(saved.features.emojis || {}) }; } } } // Save configuration function saveConfig() { try { localStorage.setItem('iqrpg_enhanced_config', JSON.stringify(CONFIG)); } catch (e) { // Silently fail } } // Initialize config loadConfig(); // ============================================ // Audio Management // ============================================ const AudioManager = { audioCache: new Map(), // Preload and cache audio files preloadSound(url, name) { if (!url || url === 'default') return null; if (this.audioCache.has(name)) { return this.audioCache.get(name); } try { const audio = new Audio(url); audio.preload = 'auto'; this.audioCache.set(name, audio); return audio; } catch (e) { return null; } }, // Play a sound playSound(name, url = null) { const volume = CONFIG.sounds.volume !== undefined ? CONFIG.sounds.volume : 1.0; if (url) { const audio = this.preloadSound(url, name); if (audio) { audio.volume = Math.max(0, Math.min(1, volume)); // Clamp between 0 and 1 audio.currentTime = 0; audio.play().catch(() => { // Silently fail }); } } else if (this.audioCache.has(name)) { const audio = this.audioCache.get(name); audio.volume = Math.max(0, Math.min(1, volume)); // Clamp between 0 and 1 audio.currentTime = 0; audio.play().catch(() => { // Silently fail }); } } }; // Preload default sounds preloadAllSounds(); // ============================================ // Notification System // ============================================ // Helper function to convert item key to display name // e.g., "undead_heart" -> "Undead Heart", "vial_of_orc_blood" -> "Vial Of Orc Blood" // Also cleans up gem names: "Gem Diamond" -> "Diamond" function formatItemName(itemKey) { if (!itemKey) return itemKey; // Split by underscore, capitalize first letter of each word, join with spaces let formatted = itemKey .split('_') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' '); // Clean up gem names: remove "Gem " prefix // "Gem Diamond" -> "Diamond", "Gem Ruby" -> "Ruby", etc. if (formatted.startsWith('Gem ')) { formatted = formatted.substring(4); // Remove "Gem " prefix } return formatted; } // Helper function to clean game messages (remove HTML tags and game item formatting codes) function cleanGameMessage(msg) { if (!msg) return ''; // Remove HTML tags let cleaned = msg.replace(/<[^>]*>/g, ''); // Remove game item formatting codes: [item:name] -> name cleaned = cleaned.replace(/\[item:([^\]]+)\]/g, (match, itemKey) => { // First check if this item key has a special mapping (only dungeon keys) const properName = CONSTANTS.ITEM_NAME_MAP[itemKey]; if (properName) { return properName; } // Auto-convert the item key to a display name // e.g., "undead_heart" -> "Undead Heart", "runic_leather" -> "Runic Leather" return formatItemName(itemKey); }); // Remove game item formatting codes: [--type: {...}--] // Parse JSON to extract item details, especially for trinkets cleaned = cleaned.replace(/\[--([^:]+):([\s\S]*?)--\]/g, (match, itemType, jsonData) => { // Try to parse the JSON data try { const data = JSON.parse(jsonData.trim()); // Shared rarity color mapping (used by both trinkets and jewels) // rarity: 1 = White, 2 = Green, 3 = Blue, 4 = Yellow, 5 = Orange, 6 = Red, 7 = Light Blue const rarityColorMap = { 1: 'White', 2: 'Green', 3: 'Blue', 4: 'Yellow', 5: 'Orange', 6: 'Red', 7: 'Cyan' }; // Handle trinkets specifically if (itemType === 'trinket' && data.type !== undefined && data.tier !== undefined) { // type: 1 = Battling Trinket, 2 = Gathering Trinket const trinketType = data.type === 1 ? 'Battling' : data.type === 2 ? 'Gathering' : 'Trinket'; let result = `${trinketType} Trinket (T${data.tier})`; // Add rarity color if available if (data.rarity !== undefined) { const rarityColor = rarityColorMap[data.rarity] || `T${data.rarity}`; result += ` (${rarityColor})`; } return result; } // Handle jewels specifically if (itemType === 'jewel' && data.gemType !== undefined && data.rarity !== undefined) { // gemType: 1 = Sapphire, 2 = Ruby, 3 = Emerald, 4 = Diamond const gemTypeMap = { 1: 'Sapphire', 2: 'Ruby', 3: 'Emerald', 4: 'Diamond' }; const gemName = gemTypeMap[data.gemType] || 'Jewel'; const rarityColor = rarityColorMap[data.rarity] || `T${data.rarity}`; return `${gemName} Jewel (${rarityColor})`; } // For other item types, try to extract name from JSON if available if (data.name) { return data.name; } else if (data.itemName) { return data.itemName; } else if (data.title) { return data.title; } } catch (e) { // JSON parsing failed, fall through to default handling } // Fallback: return the item type if we can't parse or construct a name return itemType || ''; }); // Remove game item formatting codes: [name] -> name // This catches any remaining bracket patterns that weren't handled above cleaned = cleaned.replace(/\[([^\]]+)\]/g, (match, itemKey) => { // First check if this is a direct key in the map (dungeon keys) let properName = CONSTANTS.ITEM_NAME_MAP[itemKey]; if (properName) { return properName; } // If it looks like an item key (contains underscores), auto-convert it if (itemKey.includes('_')) { return formatItemName(itemKey); } // If it's already a display name (no underscores), return as-is return itemKey; }); // Clean up any extra whitespace cleaned = cleaned.replace(/\s+/g, ' ').trim(); return cleaned; } // Helper function to convert emoji to data URL icon function emojiToIcon(emoji) { try { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const size = 128; // Icon size canvas.width = size; canvas.height = size; // Set font size to render emoji large ctx.font = `${size * 0.8}px Arial`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // Draw emoji on canvas ctx.fillText(emoji, size / 2, size / 2); // Convert to data URL return canvas.toDataURL('image/png'); } catch (e) { return null; } } const NotificationManager = { // Request notification permission async requestPermission() { if ('Notification' in window && Notification.permission === 'default') { await Notification.requestPermission(); } }, // Show desktop notification showDesktopNotification(title, message, icon = null) { if (!('Notification' in window)) { return; } if (Notification.permission === 'granted') { const options = { body: message, icon: icon || null, badge: icon || null, tag: 'iqrpg-enhanced', requireInteraction: false }; const notification = new Notification(title, options); // Auto-close after delay setTimeout(() => { notification.close(); }, CONSTANTS.DELAYS.NOTIFICATION_AUTO_CLOSE); // Handle click to focus window notification.onclick = () => { window.focus(); notification.close(); }; } else if (Notification.permission === 'default') { this.requestPermission(); } }, // Unified notification method notify(title, message, options = {}) { const { sound = false, desktop = false, soundName = null, soundUrl = null, emoji = null } = options; // Play sound if enabled if (sound && soundName) { AudioManager.playSound(soundName, soundUrl); } // Show desktop notification if enabled if (desktop) { // Convert emoji to icon if provided const icon = emoji ? emojiToIcon(emoji) : null; this.showDesktopNotification(title, message, icon); } } }; // Request notification permission on load if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { NotificationManager.requestPermission(); }); } else { NotificationManager.requestPermission(); } // ============================================ // WebSocket Interception // ============================================ const WebSocketInterceptor = { originalWebSocket: null, interceptedSockets: new Set(), init() { try { // Intercept WebSocket constructor this.originalWebSocket = window.WebSocket; const self = this; window.WebSocket = function(url, protocols) { const ws = new self.originalWebSocket(url, protocols); self.interceptSocket(ws); return ws; }; // Copy static properties Object.setPrototypeOf(window.WebSocket, self.originalWebSocket); Object.defineProperty(window.WebSocket, 'CONNECTING', { value: self.originalWebSocket.CONNECTING, writable: false }); Object.defineProperty(window.WebSocket, 'OPEN', { value: self.originalWebSocket.OPEN, writable: false }); Object.defineProperty(window.WebSocket, 'CLOSING', { value: self.originalWebSocket.CLOSING, writable: false }); Object.defineProperty(window.WebSocket, 'CLOSED', { value: self.originalWebSocket.CLOSED, writable: false }); } catch (e) { // Silently fail } }, interceptSocket(ws) { if (this.interceptedSockets.has(ws)) return; this.interceptedSockets.add(ws); // Intercept messages const originalAddEventListener = ws.addEventListener.bind(ws); ws.addEventListener = function(type, listener, options) { if (type === 'message') { const wrappedListener = function(event) { // Call original listener first listener(event); // Capture and log message let messageData; try { messageData = JSON.parse(event.data); } catch (e) { messageData = typeof event.data === 'object' && event.data !== null && !Array.isArray(event.data) ? event.data : { raw: event.data, _parseError: true }; } // Process message for notifications if (messageData && typeof messageData === 'object') { MessageProcessor.process(messageData); } }; return originalAddEventListener(type, wrappedListener, options); } return originalAddEventListener(type, listener, options); }; // Also intercept onmessage property Object.defineProperty(ws, 'onmessage', { get: function() { return this._onmessage; }, set: function(listener) { this._onmessage = listener; if (listener) { ws.addEventListener('message', listener); } }, configurable: true }); } }; // ============================================ // Emoji Converter // ============================================ const EmojiConverter = { emoji: null, observer: null, processedMessages: new WeakSet(), initialized: false, timeout: null, emojiPattern: /:[\w+-]+:/, // Compile regex once availableShortcodes: [], // Store discovered shortcodes for autocomplete init() { // Prevent multiple initializations if (this.initialized) return; // Check if emojis feature is enabled if (!CONFIG.features.emojis.enabled) { return; } // Check if EmojiConvertor is available (loaded via @require) const EmojiConvertorClass = typeof EmojiConvertor !== 'undefined' ? EmojiConvertor : (typeof window !== 'undefined' && window.EmojiConvertor) ? window.EmojiConvertor : null; if (!EmojiConvertorClass) { console.error('[IQRPG Enhanced] EmojiConvertor not available - @require may have failed'); return; } // Initialize emoji converter this.emoji = new EmojiConvertorClass(); this.emoji.replace_mode = 'unified'; this.emoji.allow_native = true; this.initialized = true; // Discover and log supported shortcodes this.discoverShortcodes(); this.startObserving(); }, convert(text) { if (!CONFIG.features.emojis.enabled || !this.emoji || !text || typeof text !== 'string') return text; return this.emoji.replace_colons(text); }, discoverShortcodes() { if (!this.emoji) { console.error('[IQRPG Enhanced] Emoji converter not initialized'); return; } let shortcodes = []; // The emoji map is at this.emoji.map.colons // The keys are shortcode names without colons (e.g., "100", "umbrella_with_rain_drops") if (this.emoji.map && this.emoji.map.colons) { const colonsMap = this.emoji.map.colons; // Get all keys and wrap them with colons shortcodes = Object.keys(colonsMap).map(key => `:${key}:`); } // Store for autocomplete this.availableShortcodes = shortcodes; return shortcodes; }, startObserving() { // Disconnect existing observer if any (prevent duplicates) if (this.observer) { this.observer.disconnect(); this.observer = null; } // Clear any pending timeout if (this.timeout) { clearTimeout(this.timeout); this.timeout = null; } // Use MutationObserver to watch for new chat messages this.observer = new MutationObserver((mutations) => { // Debounce to avoid processing during rapid DOM changes if (this.timeout) { clearTimeout(this.timeout); } this.timeout = setTimeout(() => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === 1) { // Element node this.processMessageNode(node); } }); }); this.timeout = null; // Clear reference after execution }, 100); // Small delay to let chat finish rendering }); // Observe the chat container const chatContainer = document.querySelector('.chat-content'); if (chatContainer) { this.observer.observe(chatContainer, { childList: true, subtree: true }); // Process existing messages this.processExistingMessages(chatContainer); } else { // Retry if chat container not found yet setTimeout(() => this.startObserving(), 1000); } }, processMessageNode(node) { // Skip if already processed if (this.processedMessages.has(node)) return; // Skip if this is an input, form, or interactive element if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA' || node.tagName === 'FORM' || node.tagName === 'BUTTON' || node.isContentEditable || node.contentEditable === 'true') { return; } // Skip if inside a form or input if (node.closest('form') || node.closest('input') || node.closest('textarea') || node.closest('[contenteditable="true"]')) { return; } // Skip if this is the chat container itself if (node.classList && node.classList.contains('chat-content')) { return; } // Process any node that has text content with emoji shortcodes if (node.textContent && node.nodeType === 1) { const text = node.textContent; // Check if text contains emoji shortcodes (use pre-compiled regex) if (this.emojiPattern.test(text)) { // Mark as processed before conversion to avoid infinite loops this.processedMessages.add(node); // Convert emojis in the node's HTML this.convertEmojisInNode(node); } } }, convertEmojisInNode(node) { // Check if emojis feature is enabled if (!CONFIG.features.emojis.enabled) { return; } // Get the innerHTML, convert emojis, then set it back const originalHTML = node.innerHTML; if (!originalHTML) return; // Convert emoji shortcodes to Unicode const convertedHTML = this.convert(originalHTML); // Only update if something changed if (convertedHTML !== originalHTML) { node.innerHTML = convertedHTML; } }, processExistingMessages(container) { // Process all existing messages in the chat const walker = document.createTreeWalker( container, NodeFilter.SHOW_ELEMENT, { acceptNode: (node) => { // Skip inputs, forms, etc. if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA' || node.tagName === 'FORM' || node.tagName === 'BUTTON' || node.isContentEditable || node.contentEditable === 'true') { return NodeFilter.FILTER_REJECT; } if (node.closest('form') || node.closest('input') || node.closest('textarea') || node.closest('[contenteditable="true"]')) { return NodeFilter.FILTER_REJECT; } // Accept nodes with text that might contain emojis (use pre-compiled regex) if (node.textContent && this.emojiPattern.test(node.textContent)) { return NodeFilter.FILTER_ACCEPT; } return NodeFilter.FILTER_SKIP; } } ); let node; while (node = walker.nextNode()) { if (!this.processedMessages.has(node)) { this.processedMessages.add(node); this.convertEmojisInNode(node); } } }, cleanup() { // Clear timeout if (this.timeout) { clearTimeout(this.timeout); this.timeout = null; } // Disconnect observer if (this.observer) { this.observer.disconnect(); this.observer = null; } // Reset state this.processedMessages = new WeakSet(); this.initialized = false; } }; // ============================================ // Emoji Autocomplete (Discord-style) // ============================================ const EmojiAutocomplete = { inputField: null, suggestionBox: null, currentSuggestions: [], selectedIndex: -1, isActive: false, shortcodes: [], handleInputHandler: null, handleKeyDownHandler: null, clickOutsideHandler: null, initialized: false, init() { // Check if emojis feature is enabled if (!CONFIG.features.emojis.enabled) { return; } // Prevent multiple initializations if (this.initialized) { // If already initialized, just ensure input field is still valid if (!this.inputField || !document.body.contains(this.inputField)) { // Input field was removed, re-initialize this.cleanup(); this.initialized = false; } else { // Already initialized and working, just return return; } } // Wait for chat input to be available this.findInputField(); // Retry if not found if (!this.inputField) { setTimeout(() => this.init(), 1000); return; } // Create suggestion box (only if it doesn't exist) if (!this.suggestionBox || !document.body.contains(this.suggestionBox)) { this.createSuggestionBox(); } // Attach event listeners (only if not already attached) if (!this.handleInputHandler) { this.attachListeners(); } // Get available shortcodes from EmojiConverter if (EmojiConverter.availableShortcodes && EmojiConverter.availableShortcodes.length > 0) { this.shortcodes = EmojiConverter.availableShortcodes; } else { // Wait a bit for EmojiConverter to discover shortcodes setTimeout(() => { this.shortcodes = EmojiConverter.availableShortcodes || []; }, 500); } this.initialized = true; }, findInputField() { // Try more specific selectors first, then generic ones const selectors = [ 'input[type="text"]', 'textarea', 'input.chat', 'textarea.chat', '.chat-input', '#chat-input', '[contenteditable="true"]', 'input[placeholder*="chat" i]', 'input[placeholder*="message" i]', 'textarea[placeholder*="chat" i]', 'textarea[placeholder*="message" i]' ]; for (const selector of selectors) { const fields = document.querySelectorAll(selector); for (const field of fields) { // Prefer visible, enabled fields if (field.offsetParent !== null && !field.disabled && (field.tagName === 'INPUT' || field.tagName === 'TEXTAREA' || field.isContentEditable)) { this.inputField = field; return; } } } }, createSuggestionBox() { // Create popup container this.suggestionBox = document.createElement('div'); this.suggestionBox.id = 'iqrpg-emoji-autocomplete'; this.suggestionBox.style.cssText = ` position: absolute; background: rgb(30, 30, 30); border: 1px solid rgb(68, 68, 68); border-radius: 4px; max-height: 200px; overflow-y: auto; display: none; z-index: 10000; box-shadow: 0 2px 10px rgba(0,0,0,0.3); font-family: Verdana, Arial, sans-serif; font-size: 14px; `; document.body.appendChild(this.suggestionBox); }, attachListeners() { // Store handler references for cleanup this.handleInputHandler = (e) => this.handleInput(e); this.handleKeyDownHandler = (e) => this.handleKeyDown(e); // Listen for input changes and keydown (works for both INPUT/TEXTAREA and contenteditable) this.inputField.addEventListener('input', this.handleInputHandler); // Use capture phase to intercept before game's handlers this.inputField.addEventListener('keydown', this.handleKeyDownHandler, true); // Hide suggestions when clicking outside this.clickOutsideHandler = (e) => { if (!this.suggestionBox.contains(e.target) && e.target !== this.inputField) { this.hideSuggestions(); } }; document.addEventListener('click', this.clickOutsideHandler); }, handleInput(e) { const text = this.getInputValue(); const cursorPos = this.getCursorPosition(); // Get text before cursor const beforeCursor = text.substring(0, cursorPos); // Check if user is typing a shortcode // Match pattern: :word or :word: (partial or complete shortcode) const partialMatch = beforeCursor.match(/:([\w+-]+)$/); const completeMatch = beforeCursor.match(/:([\w+-]+):$/); if (completeMatch) { // User typed a complete shortcode (e.g., ":heart:") const fullShortcode = `:${completeMatch[1]}:`; this.showSuggestionsForCompleteShortcode(fullShortcode, completeMatch.index); } else if (partialMatch && partialMatch[1].length > 0) { // User is typing a partial shortcode (e.g., ":hear") const query = partialMatch[1].toLowerCase(); this.showSuggestions(query, partialMatch.index); } else { this.hideSuggestions(); } }, getCursorPosition() { if (this.inputField.tagName === 'INPUT' || this.inputField.tagName === 'TEXTAREA') { return this.inputField.selectionStart || this.inputField.value.length; } else { // For contenteditable const selection = window.getSelection(); if (selection.rangeCount > 0) { const range = selection.getRangeAt(0); return range.startOffset; } return (this.inputField.textContent || '').length; } }, handleKeyDown(e) { if (!this.isActive) return; switch(e.key) { case 'ArrowDown': e.preventDefault(); e.stopPropagation(); this.selectedIndex = Math.min(this.selectedIndex + 1, this.currentSuggestions.length - 1); this.updateSelection(); break; case 'ArrowUp': e.preventDefault(); e.stopPropagation(); // Prevent going below 0 - wrap around to last item instead if (this.selectedIndex <= 0) { this.selectedIndex = this.currentSuggestions.length - 1; } else { this.selectedIndex = this.selectedIndex - 1; } this.updateSelection(); break; case 'Enter': case 'Tab': // Always prevent default when autocomplete is active to prevent form submission e.preventDefault(); e.stopPropagation(); if (this.selectedIndex >= 0 && this.selectedIndex < this.currentSuggestions.length) { this.selectSuggestion(this.selectedIndex); } else { // If no valid selection, just hide suggestions this.hideSuggestions(); } break; case 'Escape': e.preventDefault(); e.stopPropagation(); this.hideSuggestions(); break; } }, getShortcodes() { if (!this.shortcodes || this.shortcodes.length === 0) { // Try to get shortcodes again if (EmojiConverter.availableShortcodes && EmojiConverter.availableShortcodes.length > 0) { this.shortcodes = EmojiConverter.availableShortcodes; } else { // Retry discovery EmojiConverter.discoverShortcodes(); this.shortcodes = EmojiConverter.availableShortcodes || []; } } return this.shortcodes; }, showSuggestionsForCompleteShortcode(fullShortcode, cursorPos) { const shortcodes = this.getShortcodes(); if (shortcodes.length === 0) { this.hideSuggestions(); return; } // Check if the complete shortcode is valid and get the actual shortcode from list const normalizedShortcode = fullShortcode.toLowerCase(); const actualShortcode = shortcodes.find(sc => sc.toLowerCase() === normalizedShortcode); if (actualShortcode) { // Show just this one emoji using the actual shortcode from the list this.currentSuggestions = [actualShortcode]; this.selectedIndex = 0; this.isActive = true; // Render suggestions this.renderSuggestions([actualShortcode]); // Position the suggestion box this.positionSuggestionBox(cursorPos); } else { // Invalid shortcode, hide suggestions this.hideSuggestions(); } }, showSuggestions(query, cursorPos) { // Require at least one character in query if (!query || query.length === 0) { this.hideSuggestions(); return; } const shortcodes = this.getShortcodes(); if (shortcodes.length === 0) { this.hideSuggestions(); return; } // Filter shortcodes that match the query const matching = shortcodes.filter(sc => { if (typeof sc !== 'string' || !sc.startsWith(':') || !sc.endsWith(':')) { return false; // Skip invalid entries } const shortcodeName = sc.replace(/:/g, '').toLowerCase(); return shortcodeName.startsWith(query); }); // Sort: exact matches first, then prefix matches matching.sort((a, b) => { const aName = a.replace(/:/g, '').toLowerCase(); const bName = b.replace(/:/g, '').toLowerCase(); const aExact = aName === query; const bExact = bName === query; if (aExact && !bExact) return -1; if (!aExact && bExact) return 1; return 0; }); // Limit to 20 suggestions for performance const limitedMatching = matching.slice(0, 20); if (limitedMatching.length === 0) { this.hideSuggestions(); return; } this.currentSuggestions = limitedMatching; this.selectedIndex = 0; this.isActive = true; // Render suggestions this.renderSuggestions(limitedMatching); // Position the suggestion box this.positionSuggestionBox(cursorPos); }, renderSuggestions(suggestions) { this.suggestionBox.innerHTML = ''; suggestions.forEach((shortcode, index) => { const item = document.createElement('div'); item.className = 'iqrpg-emoji-suggestion'; item.style.cssText = ` padding: 8px 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; ${index === this.selectedIndex ? 'background: rgb(34, 136, 34);' : ''} `; // Convert shortcode to emoji for display const emojiChar = EmojiConverter.convert(shortcode); item.innerHTML = ` <span style="font-size: 20px;">${emojiChar}</span> <span style="color: rgb(204, 204, 204);">${shortcode}</span> `; item.addEventListener('mouseenter', () => { this.selectedIndex = index; this.updateSelection(); }); item.addEventListener('click', () => { this.selectSuggestion(index); }); this.suggestionBox.appendChild(item); }); this.suggestionBox.style.display = 'block'; this.updateSelection(); }, updateSelection() { const items = this.suggestionBox.querySelectorAll('.iqrpg-emoji-suggestion'); items.forEach((item, index) => { item.style.background = index === this.selectedIndex ? 'rgb(34, 136, 34)' : 'transparent'; }); // Scroll selected item into view if (this.selectedIndex >= 0 && items[this.selectedIndex]) { items[this.selectedIndex].scrollIntoView({ block: 'nearest' }); } }, selectSuggestion(index) { if (index < 0 || index >= this.currentSuggestions.length) return; const shortcode = this.currentSuggestions[index]; const emojiChar = EmojiConverter.convert(shortcode); // Get current text and cursor position const text = this.getInputValue(); const cursorPos = this.getCursorPosition(); const beforeCursor = text.substring(0, cursorPos); // Find the shortcode being typed (match partial or complete) const partialMatch = beforeCursor.match(/:([\w+-]+)$/); const completeMatch = beforeCursor.match(/:([\w+-]+):$/); const match = completeMatch || partialMatch; if (match) { // Replace the shortcode with emoji const before = text.substring(0, match.index); const after = text.substring(cursorPos); const newText = before + emojiChar + after; this.setInputValue(newText); // Move cursor after inserted emoji const newCursorPos = before.length + emojiChar.length; this.setCursorPosition(newCursorPos); } this.hideSuggestions(); }, getInputValue() { if (this.inputField.tagName === 'INPUT' || this.inputField.tagName === 'TEXTAREA') { return this.inputField.value; } else { return this.inputField.textContent || this.inputField.innerText; } }, setInputValue(value) { if (this.inputField.tagName === 'INPUT' || this.inputField.tagName === 'TEXTAREA') { this.inputField.value = value; this.inputField.dispatchEvent(new Event('input', { bubbles: true })); } else { this.inputField.textContent = value; } }, setCursorPosition(pos) { if (this.inputField.tagName === 'INPUT' || this.inputField.tagName === 'TEXTAREA') { this.inputField.setSelectionRange(pos, pos); } else { // For contenteditable, set cursor position const range = document.createRange(); const sel = window.getSelection(); if (this.inputField.childNodes.length > 0) { const textNode = this.inputField.childNodes[0]; const maxPos = textNode.textContent ? textNode.textContent.length : 0; range.setStart(textNode, Math.min(pos, maxPos)); } else { range.setStart(this.inputField, 0); } range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } }, positionSuggestionBox(cursorPos) { // Get input field position const rect = this.inputField.getBoundingClientRect(); this.suggestionBox.style.top = (rect.bottom + window.scrollY + 5) + 'px'; this.suggestionBox.style.left = (rect.left + window.scrollX) + 'px'; this.suggestionBox.style.width = Math.min(300, rect.width) + 'px'; }, hideSuggestions() { this.suggestionBox.style.display = 'none'; this.isActive = false; this.selectedIndex = -1; this.currentSuggestions = []; }, cleanup() { // Remove event listeners if (this.inputField) { if (this.handleInputHandler) { this.inputField.removeEventListener('input', this.handleInputHandler); } if (this.handleKeyDownHandler) { this.inputField.removeEventListener('keydown', this.handleKeyDownHandler, true); } } if (this.clickOutsideHandler) { document.removeEventListener('click', this.clickOutsideHandler); this.clickOutsideHandler = null; } // Remove DOM element if (this.suggestionBox && this.suggestionBox.parentNode) { this.suggestionBox.parentNode.removeChild(this.suggestionBox); } // Reset state this.inputField = null; this.suggestionBox = null; this.currentSuggestions = []; this.selectedIndex = -1; this.isActive = false; this.handleInputHandler = null; this.handleKeyDownHandler = null; this.initialized = false; } }; // ============================================ // Message Processing // ============================================ // Track last action bonus to avoid duplicate notifications let lastActionBonus = null; const MessageProcessor = { process(message) { if (!message || typeof message !== 'object') return; // Handle different message types switch (message.type) { case CONSTANTS.STRINGS.MESSAGE_TYPE_MSG: this.handleMessageType(message); break; case CONSTANTS.STRINGS.MESSAGE_TYPE_ACTION_BONUS: this.handleActionBonus(message); break; case CONSTANTS.STRINGS.MESSAGE_TYPE_NOTIFICATION: this.handleNotificationType(message); break; default: // Unhandled message types - silently ignore break; } }, handleMessageType(message) { if (!message.data || !message.data.type) { return; } // Handle global events (crafting notifications) if (message.data.type === CONSTANTS.STRINGS.DATA_TYPE_GLOBAL) { const config = CONFIG.notifications.globalEvents; if (config.sound || config.desktop) { const rawMsg = message.data.msg || 'A global event occurred'; const cleanMsg = cleanGameMessage(rawMsg); NotificationManager.notify( 'Global', cleanMsg, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_GLOBAL, soundUrl: CONFIG.sounds.globalEvent, emoji: '🎉' } ); } } // Handle eventGlobal messages (gathering events and boss spawns) if (message.data.type === CONSTANTS.STRINGS.DATA_TYPE_EVENT_GLOBAL) { const rawMsg = message.data.msg || ''; // Clean message (remove HTML tags, game formatting codes, normalize whitespace) const cleanMsg = cleanGameMessage(rawMsg); const msgLower = cleanMsg.toLowerCase(); // Check if this is a boss spawn message (demon horn indicates multiple bosses spawning) const isDemonHorn = CONSTANTS.BOSS_SPAWN_PATTERNS.DEMON_HORN.some(pattern => msgLower.includes(pattern) ); if (isDemonHorn) { // Always use bossSpawn config and sound const config = CONFIG.notifications.bossSpawn; if (config.sound || config.desktop) { NotificationManager.notify( 'Boss Event', cleanMsg, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_BOSS_SPAWN, soundUrl: CONFIG.sounds.bossSpawn, emoji: '👹' } ); } } else { // Gathering event (woodcutting, quarrying, mining) // Determine specific event type and emoji let eventType; let eventEmoji; let eventConfigKey = null; if (msgLower.includes('spirit tree') || msgLower.includes('forest')) { eventType = 'Woodcutting Event'; eventEmoji = '🌲'; eventConfigKey = 'woodcutting'; } else if (msgLower.includes('sinkhole') || msgLower.includes('ground shakes')) { eventType = 'Quarrying Event'; eventEmoji = '🪨'; eventConfigKey = 'quarrying'; } else if (msgLower.includes('meteorite')) { eventType = 'Mining Event'; eventEmoji = '☄️'; eventConfigKey = 'mining'; } // Only notify if this specific event type is enabled if (eventConfigKey) { const config = CONFIG.notifications.gatheringEvents[eventConfigKey]; if (config && (config.sound || config.desktop)) { NotificationManager.notify( eventType, cleanMsg, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_GATHERING_EVENT, soundUrl: CONFIG.sounds.gatheringEvent, emoji: eventEmoji } ); } } } } // Handle private messages if (message.data.type === CONSTANTS.STRINGS.DATA_TYPE_PM_FROM) { const config = CONFIG.notifications.message; if (config.sound || config.desktop) { const rawMsg = message.data.msg || ''; const cleanMsg = cleanGameMessage(rawMsg); const username = message.data.username || 'Unknown'; NotificationManager.notify( `Message from ${username}`, cleanMsg, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_MESSAGE, soundUrl: CONFIG.sounds.message, emoji: '💬' } ); } } // Handle trade channel messages if (message.channel === CONSTANTS.STRINGS.CHANNEL_TRADE && message.data.type === CONSTANTS.STRINGS.MESSAGE_TYPE_MSG) { this.handleTradeMessage(message); } // Handle clan chat global messages if (message.channel && message.channel.startsWith(CONSTANTS.STRINGS.CHANNEL_CLAN_PREFIX) && message.data.type === CONSTANTS.STRINGS.DATA_TYPE_CLAN_GLOBAL) { const config = CONFIG.notifications.clan; const rawMsg = message.data.msg || ''; const cleanMsg = cleanGameMessage(rawMsg); // Check if this is a watchtower event const isWatchtower = rawMsg.toLowerCase().includes('watchtower'); if (isWatchtower) { // Handle watchtower events if (config.watchtower && (config.sound || config.desktop)) { NotificationManager.notify( 'Clan Watchtower', cleanMsg, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_CLAN_WATCHTOWER, soundUrl: CONFIG.sounds.clanWatchtower, emoji: '🐉' } ); } } else { // Handle other clan global events if (config.clanChatGlobals && (config.sound || config.desktop)) { NotificationManager.notify( 'Clan Global', cleanMsg, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_CLAN_GLOBALS, soundUrl: CONFIG.sounds.clanGlobals, emoji: '🎉' } ); } } } }, handleTradeMessage(message) { const config = CONFIG.notifications.tradeAlert; if (!config.sound && !config.desktop) return; const msgText = (message.data.msg || '').toLowerCase(); const username = message.data.username || 'Unknown'; // Normalize patterns to lowercase for case-insensitive matching const sellingPatterns = CONSTANTS.TRADE_PATTERNS.SELLING.map(p => p.toLowerCase()); const buyingPatterns = CONSTANTS.TRADE_PATTERNS.BUYING.map(p => p.toLowerCase()); // Check if message contains selling indicators (case-insensitive) const isSelling = sellingPatterns.some(pattern => msgText.includes(pattern)); // Check if message contains buying indicators (case-insensitive) const isBuying = buyingPatterns.some(pattern => msgText.includes(pattern)); // Get keywords to check based on message type let keywordsToCheck = []; let tradeType = ''; if (isSelling && config.sellingKeywords && config.sellingKeywords.length > 0) { keywordsToCheck = config.sellingKeywords; tradeType = 'Selling'; } else if (isBuying && config.buyingKeywords && config.buyingKeywords.length > 0) { keywordsToCheck = config.buyingKeywords; tradeType = 'Buying'; } if (keywordsToCheck.length === 0) return; // Check if any of the keywords match (case-insensitive) const matchedKeywords = keywordsToCheck.filter(keyword => { // Normalize keyword to lowercase for case-insensitive matching const kw = (keyword || '').toLowerCase().trim(); return kw && msgText.includes(kw); }); if (matchedKeywords.length > 0) { const rawMsg = message.data.msg || ''; const cleanMsg = cleanGameMessage(rawMsg); const notificationMsg = `${username}: ${cleanMsg}`; NotificationManager.notify( 'Trade Alert', notificationMsg, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_TRADE_ALERT, soundUrl: CONFIG.sounds.tradeAlert, emoji: '💱' } ); } }, handleActionBonus(message) { const config = CONFIG.notifications.actionBonus; if ((config.sound || config.desktop) && message.data && message.data.actionBonus) { const actionBonus = message.data.actionBonus; // Only notify if the action bonus has actually changed (not on initial load) if (lastActionBonus !== null && actionBonus !== lastActionBonus) { const msg = `The action bonus is now active whilst ${actionBonus}`; NotificationManager.notify( 'Action Bonus Changed', msg, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_ACTION_BONUS, soundUrl: CONFIG.sounds.actionBonus, emoji: '✨' } ); } // Always update the tracked value lastActionBonus = actionBonus; } }, handleNotificationType(message) { // Check if this is a market sale notification if (message.channel === CONSTANTS.STRINGS.CHANNEL_NOTIFICATIONS && message.data && message.data.type === CONSTANTS.STRINGS.DATA_TYPE_NOTIFICATION && message.data.msg) { const rawMsg = message.data.msg || ''; const msgLower = rawMsg.toLowerCase(); // Check if message contains "sold" to identify market sales if (msgLower.includes('sold') || msgLower.includes('you have sold')) { // Parse and track the sale const saleData = MarketDataTracker.parseSaleMessage(rawMsg); if (saleData) { MarketDataTracker.recordSale(saleData); } const config = CONFIG.notifications.marketSale; if (config.sound || config.desktop) { const cleanMsg = cleanGameMessage(rawMsg); NotificationManager.notify( 'Market Sale', cleanMsg, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_MARKET_SALE, soundUrl: CONFIG.sounds.marketSale, emoji: '🪙' } ); } } // Check if message contains "received" to identify item receipts else if (msgLower.includes('received') || msgLower.includes('have received') || msgLower.includes('got')) { // Parse and track the received item const itemData = MarketDataTracker.parseReceivedItem(rawMsg); if (itemData) { MarketDataTracker.recordReceivedItem(itemData); const config = CONFIG.notifications.itemReceived; if (config.sound || config.desktop) { const cleanMsg = cleanGameMessage(rawMsg); // Check if this is Gold const isGold = itemData.itemName === 'Gold'; const title = isGold ? 'Gold Received' : 'Item Received'; const emoji = isGold ? '🪙' : '📦'; const soundName = isGold ? CONSTANTS.STRINGS.SOUND_GOLD_RECEIVED : CONSTANTS.STRINGS.SOUND_ITEM_RECEIVED; const soundUrl = isGold ? CONFIG.sounds.goldReceived : CONFIG.sounds.itemReceived; NotificationManager.notify( title, cleanMsg, { sound: config.sound, desktop: config.desktop, soundName: soundName, soundUrl: soundUrl, emoji: emoji } ); } } } } } }; // ============================================ // DOM Observation for UI-Based Events // ============================================ const DOMMonitor = { checkInterval: null, lastAutosCount: null, autosRepeatCount: 0, isUnderThreshold: false, lastAutosNotificationTime: null, lastDungeonText: null, pendingDungeonText: null, lastMasteryLevel: null, lastMasteryName: null, lastRaidTimer: null, skillLevels: new Map(), processedItemDrops: new Set(), lastAbyssBattlesCount: null, lastPotionCounts: new Map(), potionRepeatCounts: new Map(), potionUnderThreshold: new Map(), lastPotionNotificationTimes: new Map(), // Cached DOM element references for performance cachedElements: { autoElement: null, gameGrid: null, leftSidebar: null, logDiv: null, raidContainer: null, mainGameSection: null, effectsPanel: null }, // MutationObserver instances for instant notifications autosObserver: null, potionsObserver: null, // Debounce timeouts autosCheckTimeout: null, potionsCheckTimeout: null, // Retry timeouts for observer setup autosSetupRetryTimeout: null, potionsSetupRetryTimeout: null, init() { // Wait for DOM to be ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { this.setupObservers(); this.startObserving(); }); } else { this.setupObservers(); this.startObserving(); } }, setupObservers() { // Set up MutationObserver for autos (instant notifications) this.setupAutosObserver(); // Set up MutationObserver for potions (instant notifications) this.setupPotionsObserver(); }, setupAutosObserver() { const config = CONFIG.notifications.autos; if (!config.sound && !config.desktop) return; // Try to find the autos element // Validate cached element is still in DOM before using it let autoElement = this.cachedElements.autoElement; if (autoElement && !document.contains(autoElement)) { // Cached element is no longer in DOM, clear it this.cachedElements.autoElement = null; autoElement = null; } // If no valid cached element, query for it if (!autoElement) { autoElement = document.querySelector('.action-timer__text'); } if (!autoElement) { // Clear any existing retry timeout to prevent accumulation if (this.autosSetupRetryTimeout) { clearTimeout(this.autosSetupRetryTimeout); } // Retry after a delay if element not found yet this.autosSetupRetryTimeout = setTimeout(() => { this.autosSetupRetryTimeout = null; this.setupAutosObserver(); }, 1000); return; } // Clear retry timeout if element was found if (this.autosSetupRetryTimeout) { clearTimeout(this.autosSetupRetryTimeout); this.autosSetupRetryTimeout = null; } // Cache the element this.cachedElements.autoElement = autoElement; // Disconnect existing observer if any if (this.autosObserver) { this.autosObserver.disconnect(); } // Create new observer this.autosObserver = new MutationObserver(() => { // Debounce to avoid too many checks clearTimeout(this.autosCheckTimeout); this.autosCheckTimeout = setTimeout(() => { this.checkForAutos(); }, 100); }); // Observe the autos element for changes this.autosObserver.observe(autoElement, { childList: true, subtree: true, characterData: true }); }, setupPotionsObserver() { const config = CONFIG.notifications.potions; if (!config.sound && !config.desktop) return; // Try to find the Effects panel - look for main-section that contains potions // The Effects panel is typically in the right sidebar // Validate cached panel is still in DOM before using it let effectsPanel = this.cachedElements.effectsPanel; if (effectsPanel && !document.contains(effectsPanel)) { // Cached panel is no longer in DOM, clear it this.cachedElements.effectsPanel = null; effectsPanel = null; } if (!effectsPanel) { // Look for main-section that contains "Effects" or potion-related content const mainSections = document.querySelectorAll('.main-section'); for (const section of mainSections) { const header = section.querySelector('.main-section__header'); if (header && (header.textContent || '').toLowerCase().includes('effect')) { effectsPanel = section.querySelector('.main-section__body'); if (effectsPanel) break; } } } if (!effectsPanel) { // Clear any existing retry timeout to prevent accumulation if (this.potionsSetupRetryTimeout) { clearTimeout(this.potionsSetupRetryTimeout); } // Retry after a delay if panel not found yet this.potionsSetupRetryTimeout = setTimeout(() => { this.potionsSetupRetryTimeout = null; this.setupPotionsObserver(); }, 1000); return; } // Clear retry timeout if panel was found if (this.potionsSetupRetryTimeout) { clearTimeout(this.potionsSetupRetryTimeout); this.potionsSetupRetryTimeout = null; } // Cache the element this.cachedElements.effectsPanel = effectsPanel; // Disconnect existing observer if any if (this.potionsObserver) { this.potionsObserver.disconnect(); } // Create new observer this.potionsObserver = new MutationObserver(() => { // Debounce to avoid too many checks clearTimeout(this.potionsCheckTimeout); this.potionsCheckTimeout = setTimeout(() => { this.checkForPotionThreshold(); }, 100); }); // Observe the effects panel for changes this.potionsObserver.observe(effectsPanel, { childList: true, subtree: true, characterData: true }); }, startObserving() { // Initialize processed items immediately before starting the interval // This prevents notifications for items that were already in the log before page load this.initializeProcessedItemDrops(); // Check periodically for less frequent changes // Autos and potions are now handled by MutationObserver for instant notifications // But we'll also check them as fallback if observers aren't set up this.checkInterval = setInterval(() => { // Fallback checks for autos and potions if observers aren't working if (!this.autosObserver || !this.cachedElements.autoElement) { this.checkForAutos(); } if (!this.potionsObserver || !this.cachedElements.effectsPanel) { this.checkForPotionThreshold(); } // Regular polling checks this.checkForDungeonCompletion(); this.checkForMasteryLevelIncrease(); this.checkForLandCompletion(); this.checkForSkillLevelIncrease(); this.checkForItemDrops(); this.checkForAbyssBattlesCompletion(); }, 1000); // Check every second }, checkForAutos() { const config = CONFIG.notifications.autos; if (!config.sound && !config.desktop) return; // Use cached element or try to find it let autoElement = this.cachedElements.autoElement; const wasElementCached = !!autoElement; const wasElementInDOM = wasElementCached && document.contains(autoElement); if (!autoElement || !wasElementInDOM) { autoElement = document.querySelector('.action-timer__text'); if (!autoElement) { this.cachedElements.autoElement = null; // Disconnect observer if element disappeared if (this.autosObserver) { this.autosObserver.disconnect(); this.autosObserver = null; } // Try to re-setup observer if element disappeared if (!this.autosSetupRetryTimeout) { this.autosSetupRetryTimeout = setTimeout(() => { this.autosSetupRetryTimeout = null; this.setupAutosObserver(); }, 1000); } return; } // Element found - update cache const elementChanged = this.cachedElements.autoElement !== autoElement; this.cachedElements.autoElement = autoElement; // If element changed or observer doesn't exist, reconnect observer if (elementChanged || !this.autosObserver) { // Disconnect existing observer if it exists if (this.autosObserver) { this.autosObserver.disconnect(); this.autosObserver = null; } // Reconnect observer to the new element // Create new observer with same callback this.autosObserver = new MutationObserver(() => { // Debounce to avoid too many checks clearTimeout(this.autosCheckTimeout); this.autosCheckTimeout = setTimeout(() => { this.checkForAutos(); }, 100); }); // Observe the new element this.autosObserver.observe(autoElement, { childList: true, subtree: true, characterData: true }); } } const text = autoElement.textContent || ''; const match = text.match(/Autos Remaining:\s*(\d+)/i); if (match) { const currentAutos = parseInt(match[1], 10); const threshold = config.threshold || 0; const repeatCount = config.repeatCount || 1; const wasUnderThreshold = this.isUnderThreshold; const isCurrentlyUnder = currentAutos <= threshold; // Check if we've crossed the threshold (went from above to at/below threshold) if (this.lastAutosCount !== null && this.lastAutosCount > threshold && currentAutos <= threshold) { // Just crossed threshold - trigger first notification this.autosRepeatCount = 1; this.isUnderThreshold = true; this.lastAutosNotificationTime = Date.now(); NotificationManager.notify( 'Autos Alert', text.trim(), { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_AUTOS, soundUrl: CONFIG.sounds.autos, emoji: '🪫' } ); } // Check if we're still under threshold and need to repeat else if (isCurrentlyUnder && wasUnderThreshold && this.autosRepeatCount < repeatCount) { const repeatInterval = config.repeatInterval !== undefined ? config.repeatInterval : 0; // Default to 0 (immediate) if not set const now = Date.now(); const timeSinceLastNotification = this.lastAutosNotificationTime ? (now - this.lastAutosNotificationTime) / 1000 : Infinity; // If no previous notification, allow it // Check if enough time has passed (or interval is 0 for immediate repeats) if (repeatInterval === 0 || timeSinceLastNotification >= repeatInterval) { // Still under threshold and haven't reached repeat limit this.autosRepeatCount++; this.lastAutosNotificationTime = now; NotificationManager.notify( 'Autos Alert', text.trim(), { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_AUTOS, soundUrl: CONFIG.sounds.autos, emoji: '🪫' } ); } } // Check if we've gone back above threshold else if (wasUnderThreshold && !isCurrentlyUnder) { // Reset repeat counter and notification time when we go back above threshold this.autosRepeatCount = 0; this.isUnderThreshold = false; this.lastAutosNotificationTime = null; } // Update state if we're currently under threshold else if (isCurrentlyUnder) { this.isUnderThreshold = true; } this.lastAutosCount = currentAutos; } }, checkForDungeonCompletion() { const config = CONFIG.notifications.dungeon; if (!config.sound && !config.desktop) return; const dungeonChest = document.querySelector('.d_chest'); // If chest exists, track it but don't notify yet if we need to check progress if (dungeonChest) { // Find the green text paragraph with the completion message const greenText = dungeonChest.querySelector('.green-text'); if (!greenText) return; const text = greenText.textContent || ''; // Only proceed if this is a new dungeon completion (different text) if (text && text !== this.lastDungeonText) { // If we need to check for all keys complete, wait for progress text to update if (config.onlyWhenAllKeysComplete) { // Store the text and check progress when chest disappears this.pendingDungeonText = text; return; } // Otherwise, notify immediately this.sendDungeonNotification(text, config); } } // When chest disappears, check progress text if we have a pending notification else if (this.pendingDungeonText && config.onlyWhenAllKeysComplete) { // Query .progress__text directly const progressText = document.querySelector('.progress__text'); if (progressText) { const progressTextContent = (progressText.textContent || '').trim().toLowerCase(); // If it contains "dungeoneering", user has more keys - don't notify if (progressTextContent.includes('dungeoneering')) { // Still has keys left, skip notification this.pendingDungeonText = null; return; } // Otherwise, all keys are completed - send notification this.sendDungeonNotification(this.pendingDungeonText, config); this.pendingDungeonText = null; } else { // Progress text not found, clear pending this.pendingDungeonText = null; } } else { // Reset tracking when chest disappears and no pending notification this.lastDungeonText = null; this.pendingDungeonText = null; } }, sendDungeonNotification(text, config) { this.lastDungeonText = text; NotificationManager.notify( 'Dungeon Complete', text.trim(), { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_DUNGEON, soundUrl: CONFIG.sounds.dungeon, emoji: '🗝️' } ); }, checkForMasteryLevelIncrease() { const config = CONFIG.notifications.mastery; if (!config.sound && !config.desktop) return; try { // Use cached elements or find them let gameGrid = this.cachedElements.gameGrid; if (!gameGrid || !document.contains(gameGrid)) { gameGrid = document.querySelector('.game-grid'); if (!gameGrid) { this.cachedElements.gameGrid = null; this.cachedElements.leftSidebar = null; return; } this.cachedElements.gameGrid = gameGrid; } let leftSidebar = this.cachedElements.leftSidebar; if (!leftSidebar || !document.contains(leftSidebar)) { leftSidebar = gameGrid.children[0]; // First div (left sidebar) if (!leftSidebar) { this.cachedElements.leftSidebar = null; return; } this.cachedElements.leftSidebar = leftSidebar; } // Find all .main-section divs in left sidebar const mainSections = leftSidebar.querySelectorAll('.main-section'); if (mainSections.length < 2) return; // Need at least 2 sections const masteriesPanel = mainSections[1]; // Second .main-section (masteries panel) if (!masteriesPanel) return; // Find .main-section__body const body = masteriesPanel.querySelector('.main-section__body'); if (!body) return; // Find the unnamed div (first child of body) const unnamedDiv = body.children[0]; if (!unnamedDiv) return; // Find all .relative.clickable divs (all masteries) const masteryDivs = unnamedDiv.querySelectorAll('.relative.clickable'); if (!masteryDivs || masteryDivs.length === 0) return; // Find the active mastery (the one that's green/selected) // The active one should have a .flex.space-between with span.activeText and span.green-text let activeMastery = null; let masteryName = null; let masteryLevel = null; for (const masteryDiv of masteryDivs) { const flexDiv = masteryDiv.querySelector('.flex.space-between'); if (!flexDiv) continue; const activeText = flexDiv.querySelector('span.activeText'); const greenText = flexDiv.querySelector('span.green-text'); // If both exist, this is likely the active mastery if (activeText && greenText) { activeMastery = masteryDiv; masteryName = (activeText.textContent || '').trim(); const levelText = (greenText.textContent || '').trim(); // Parse level number directly (green-text is just a number) masteryLevel = parseInt(levelText, 10); // Validate that we got a valid number if (isNaN(masteryLevel)) { continue; // Skip this mastery if level is not a valid number } break; } } // If we found an active mastery with valid data if (activeMastery && masteryName && masteryLevel !== null && !isNaN(masteryLevel)) { // Check if this is a new mastery or level increase if (this.lastMasteryName !== masteryName) { // User switched to a different mastery - reset tracking this.lastMasteryName = masteryName; this.lastMasteryLevel = masteryLevel; return; } // Same mastery - check if level increased if (this.lastMasteryLevel !== null && masteryLevel > this.lastMasteryLevel) { // Level increased! NotificationManager.notify( 'Mastery Level Up!', `${masteryName} reached level ${masteryLevel}`, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_MASTERY, soundUrl: CONFIG.sounds.mastery, emoji: '⭐' } ); } // Update tracking this.lastMasteryName = masteryName; this.lastMasteryLevel = masteryLevel; } } catch (error) { // Silently fail if DOM structure changes or element not found } }, checkForLandCompletion() { const config = CONFIG.notifications.land; if (!config.sound && !config.desktop) return; try { // Use cached raid container if available and still in DOM let raidContainer = this.cachedElements.raidContainer; if (!raidContainer || !document.contains(raidContainer)) { // Try to find it with a more targeted approach // Look for main-section containers that might contain raid info const possibleContainers = document.querySelectorAll('.main-section, .sidebar-section'); for (const container of possibleContainers) { if (container.textContent && container.textContent.includes('Raid:')) { raidContainer = container.querySelector('.flex.space-between'); if (raidContainer) break; } } // Fallback: query all flex containers if targeted approach didn't work if (!raidContainer) { const flexContainers = document.querySelectorAll('.flex.space-between'); for (const container of flexContainers) { const text = container.textContent || ''; if (text.includes('Raid:')) { raidContainer = container; break; } } } // Cache the found container this.cachedElements.raidContainer = raidContainer; } if (!raidContainer) return; // Find the div containing "Raid:" text (first child div) const raidTextDiv = Array.from(raidContainer.children).find(child => { return child.tagName === 'DIV' && (child.textContent || '').includes('Raid:'); }); if (!raidTextDiv) return; // Extract raid name from the span inside the raid text div const raidNameSpan = raidTextDiv.querySelector('span'); const raidName = raidNameSpan ? (raidNameSpan.textContent || '').trim() : ''; if (!raidName) return; // Find the status div - it should be the second child div (not the raid text div) const statusDiv = Array.from(raidContainer.children).find(child => { return child.tagName === 'DIV' && child !== raidTextDiv && child.textContent && child.textContent.trim(); }); if (!statusDiv) return; // Get the text from the <a> tag inside the status div, or fallback to div text const statusLink = statusDiv.querySelector('a'); const statusText = (statusLink ? statusLink.textContent : statusDiv.textContent || '').trim(); // Check if status changed to "Returned" if (statusText.toLowerCase() === 'returned') { // Only notify when transitioning from NOT "returned" to "returned" if (this.lastRaidTimer !== 'returned') { NotificationManager.notify( 'Land', `Raid: ${raidName} Returned!`, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_LAND, soundUrl: CONFIG.sounds.land, emoji: '🏰' } ); } // Update tracking this.lastRaidTimer = 'returned'; } else { // Status is not "Returned" - update tracking this.lastRaidTimer = statusText; } } catch (error) { // Clear cache on error this.cachedElements.raidContainer = null; } }, checkForSkillLevelIncrease() { const config = CONFIG.notifications.skills; if (!config.sound && !config.desktop) return; try { // Use direct selector approach - find all skill-bar divs const skillBars = document.querySelectorAll('.skill-bar'); if (!skillBars || skillBars.length === 0) return; // Process each skill-bar for (const skillBar of skillBars) { // Find the progress__text element (try both variations) const progressText = skillBar.querySelector('.progress__text') || skillBar.querySelector('.progress_text'); if (!progressText) continue; const text = (progressText.textContent || '').trim(); // Parse text like "Battling (120)" to extract skill name and level // Match pattern: "Skill Name (level)" const match = text.match(/^(.+?)\s*\((\d+)\)$/); if (!match || match.length < 3) continue; const skillName = match[1].trim(); const currentLevel = parseInt(match[2], 10); if (!skillName || isNaN(currentLevel)) continue; // Check if we've seen this skill before const previousLevel = this.skillLevels.get(skillName); if (previousLevel !== undefined) { // Skill exists in tracking - check if level increased if (currentLevel > previousLevel) { // Level increased! NotificationManager.notify( 'Skills', `Skill leveled up! ${skillName} (${currentLevel})`, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_SKILLS, soundUrl: CONFIG.sounds.skills, emoji: '⏫' } ); } } // Update tracking (always update to current level) this.skillLevels.set(skillName, currentLevel); } } catch (error) { // Silently fail if DOM structure changes or element not found } }, initializeProcessedItemDrops() { const config = CONFIG.notifications.itemDrop; if (!config.itemKeywords || config.itemKeywords.length === 0) return; const validKeywords = config.itemKeywords.filter(kw => kw && typeof kw === 'string' && kw.trim().length > 0); if (validKeywords.length === 0) return; try { const logDiv = document.querySelector('#log-div') || document.querySelector('.log-div'); if (!logDiv) return; const itemDivs = logDiv.querySelectorAll('.item.clickable'); for (const itemDiv of itemDivs) { const itemNamePara = itemDiv.querySelector('p'); if (!itemNamePara) continue; let itemName = (itemNamePara.textContent || '').trim(); itemName = itemName.replace(/^\[|\]$/g, '').trim(); if (!itemName) continue; const itemNameLower = itemName.toLowerCase(); const matchedKeyword = validKeywords.find(keyword => { const kw = (keyword || '').toLowerCase().trim(); return kw && itemNameLower.includes(kw); }); if (!matchedKeyword) continue; // Extract amount and timestamp to create unique ID let amount = '1'; let timestamp = ''; let container = itemDiv.parentElement; while (container && container !== logDiv) { const spans = container.querySelectorAll('span'); for (const span of spans) { const spanText = (span.textContent || '').trim(); if (spanText.match(/\+?\d+/)) { const amountMatch = spanText.match(/\+?(\d+)/); if (amountMatch && amountMatch[1]) { amount = amountMatch[1]; } } // Look specifically for timestamp pattern [HH:MM:SS] or HH:MM:SS if (!timestamp) { const timeMatch = spanText.match(/\[?(\d{1,2}:\d{2}:\d{2})\]?/); if (timeMatch) { timestamp = timeMatch[1]; // Extract just the time part (HH:MM:SS) } } } if (amount !== '1' && timestamp) break; container = container.parentElement; } // Mark current items as processed using unique ID const uniqueId = `${itemNameLower}|${amount}|${timestamp}`; this.processedItemDrops.add(uniqueId); } } catch (e) { // Silently fail if DOM structure changes } }, checkForItemDrops() { const config = CONFIG.notifications.itemDrop; if (!config.sound && !config.desktop) return; if (!config.itemKeywords || config.itemKeywords.length === 0) return; // Filter out any empty or whitespace-only keywords const validKeywords = config.itemKeywords.filter(kw => kw && typeof kw === 'string' && kw.trim().length > 0); if (validKeywords.length === 0) return; try { // Use cached logDiv or find it let logDiv = this.cachedElements.logDiv; if (!logDiv || !document.contains(logDiv)) { logDiv = document.querySelector('#log-div') || document.querySelector('.log-div'); if (!logDiv) { this.cachedElements.logDiv = null; return; } this.cachedElements.logDiv = logDiv; } // Find all item clickable divs within the log const itemDivs = logDiv.querySelectorAll('.item.clickable'); if (!itemDivs || itemDivs.length === 0) return; // Process each item drop entry for (const itemDiv of itemDivs) { // Find the paragraph containing the item name const itemNamePara = itemDiv.querySelector('p'); if (!itemNamePara) continue; // Extract item name (remove brackets if present) let itemName = (itemNamePara.textContent || '').trim(); itemName = itemName.replace(/^\[|\]$/g, '').trim(); if (!itemName) continue; // Check if this item matches any tracked keyword (case-insensitive) const itemNameLower = itemName.toLowerCase(); const matchedKeyword = validKeywords.find(keyword => { const kw = (keyword || '').toLowerCase().trim(); return kw && itemNameLower.includes(kw); }); if (!matchedKeyword) continue; // Find the amount and timestamp - look for it in the log entry container // The structure is: log entry container -> timestamp span -> amount span -> item div let amount = '1'; // Default to 1 if not found let timestamp = ''; // Track timestamp for unique identification // Try to find the log entry container (parent of item div, or parent's parent) let container = itemDiv.parentElement; while (container && container !== logDiv) { // Look for spans that might contain the amount or timestamp const spans = container.querySelectorAll('span'); for (const span of spans) { const spanText = (span.textContent || '').trim(); // Check if this span contains a number pattern like "+1 " or "+5 " if (spanText.match(/\+?\d+/)) { const amountMatch = spanText.match(/\+?(\d+)/); if (amountMatch && amountMatch[1]) { amount = amountMatch[1]; } } // Look specifically for timestamp pattern [HH:MM:SS] or HH:MM:SS if (!timestamp) { const timeMatch = spanText.match(/\[?(\d{1,2}:\d{2}:\d{2})\]?/); if (timeMatch) { timestamp = timeMatch[1]; // Extract just the time part (HH:MM:SS) } } } if (amount !== '1' && timestamp) break; // Found both, exit loop // Move up to parent container container = container.parentElement; } // Create unique identifier: item name + amount + timestamp // This prevents duplicate notifications even if DOM elements are recreated const uniqueId = `${itemNameLower}|${amount}|${timestamp}`; // Skip if already processed if (this.processedItemDrops.has(uniqueId)) continue; // Mark as processed this.processedItemDrops.add(uniqueId); // Send notification NotificationManager.notify( 'Item Log', `Found ${amount}x ${itemName}`, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_ITEM_DROP, soundUrl: CONFIG.sounds.itemDrop, emoji: '💰' } ); } } catch (error) { // Clear cache on error this.cachedElements.logDiv = null; } }, checkForAbyssBattlesCompletion() { const config = CONFIG.notifications.abyssBattles; if (!config.sound && !config.desktop) return; try { // Use cached mainGameSection or find it let mainGameSection = this.cachedElements.mainGameSection; if (!mainGameSection || !document.contains(mainGameSection)) { mainGameSection = document.querySelector('.main-game-section'); if (!mainGameSection) { this.cachedElements.mainGameSection = null; return; } this.cachedElements.mainGameSection = mainGameSection; } // Find all divs with the specific classes const battleDivs = mainGameSection.querySelectorAll('.margin-top-small.grey-text'); let battlesDiv = null; // Find the one containing " Battles Remaining" for (const div of battleDivs) { const text = div.textContent || ''; if (text.includes('Battles Remaining')) { battlesDiv = div; break; } } // If battlesDiv doesn't exist, we're not in abyss battles section // Reset tracking and don't assume completion if (!battlesDiv) { this.lastAbyssBattlesCount = null; return; } // Find the span with class "green-text" inside it const battlesSpan = battlesDiv.querySelector('span.green-text'); if (!battlesSpan) { // If span doesn't exist but div does, reset tracking this.lastAbyssBattlesCount = null; return; } const battlesText = (battlesSpan.textContent || '').trim(); const battlesCount = parseInt(battlesText, 10); // Check if count is valid if (isNaN(battlesCount)) { // If text is not a number, reset tracking this.lastAbyssBattlesCount = null; return; } // Detect when battles reach 0 (completed) // Only notify when transitioning from > 0 to 0 if (battlesCount === 0) { // Only notify if we haven't already notified for this completion // and we were previously tracking a count > 0 if (this.lastAbyssBattlesCount !== null && this.lastAbyssBattlesCount > 0) { NotificationManager.notify( 'Abyss Battles', 'All Abyss Battles Completed!', { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_ABYSS_BATTLES, soundUrl: CONFIG.sounds.abyssBattles, emoji: '🌀' } ); } } // Update tracking (always update, even if 0, to prevent duplicate notifications) this.lastAbyssBattlesCount = battlesCount; } catch (error) { // Silently fail if DOM structure changes or element not found this.lastAbyssBattlesCount = null; } }, checkForPotionThreshold() { const config = CONFIG.notifications.potions; if (!config.sound && !config.desktop) return; try { // Use cached effects panel if available, otherwise query all flex divs let allFlexDivs; const effectsPanel = this.cachedElements.effectsPanel; if (effectsPanel && document.contains(effectsPanel)) { // Query only within the effects panel for better performance allFlexDivs = effectsPanel.querySelectorAll('.flex.space-between'); } else { // Fallback: query all flex divs (less efficient but works) allFlexDivs = document.querySelectorAll('.flex.space-between'); } if (!allFlexDivs || allFlexDivs.length === 0) return; const threshold = config.threshold || 0; const repeatCount = config.repeatCount || 1; // Process each flex div to find potion entries for (const flexDiv of allFlexDivs) { // Check if this div contains a potion (has an item clickable with a potion name and a green-text span) const itemDiv = flexDiv.querySelector('.item.clickable'); if (!itemDiv) continue; const potionNamePara = itemDiv.querySelector('p'); if (!potionNamePara) continue; // Extract potion name (remove brackets if present) let potionName = (potionNamePara.textContent || '').trim(); potionName = potionName.replace(/^\[|\]$/g, '').trim(); if (!potionName) continue; // Check if this is actually a potion (contains "Potion" in the name) if (!potionName.toLowerCase().includes('potion')) continue; // Find the green-text span with the count const countSpan = flexDiv.querySelector('span.green-text'); if (!countSpan) continue; // Parse the count (remove commas) const countText = (countSpan.textContent || '').trim().replace(/,/g, ''); const currentCount = parseInt(countText, 10); if (isNaN(currentCount)) continue; // Get previous count for this potion const previousCount = this.lastPotionCounts.get(potionName); const wasUnderThreshold = this.potionUnderThreshold.get(potionName) || false; const isCurrentlyUnder = currentCount <= threshold; // Check if we've crossed the threshold (went from above to at/below threshold) if (previousCount !== undefined && previousCount > threshold && currentCount <= threshold) { // Just crossed threshold - trigger first notification this.potionRepeatCounts.set(potionName, 1); this.potionUnderThreshold.set(potionName, true); this.lastPotionNotificationTimes.set(potionName, Date.now()); NotificationManager.notify( 'Potion Alert', `${potionName}: ${countText.toLocaleString()} remaining`, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_POTIONS, soundUrl: CONFIG.sounds.potions, emoji: '🧪' } ); } // Check if we're still under threshold and need to repeat else if (isCurrentlyUnder && wasUnderThreshold) { const currentRepeatCount = this.potionRepeatCounts.get(potionName) || 0; if (currentRepeatCount < repeatCount) { const repeatInterval = config.repeatInterval !== undefined ? config.repeatInterval : 0; // Default to 0 (immediate) if not set const now = Date.now(); const lastNotificationTime = this.lastPotionNotificationTimes.get(potionName); const timeSinceLastNotification = lastNotificationTime ? (now - lastNotificationTime) / 1000 : Infinity; // If no previous notification, allow it // Check if enough time has passed (or interval is 0 for immediate repeats) if (repeatInterval === 0 || timeSinceLastNotification >= repeatInterval) { // Still under threshold and haven't reached repeat limit this.potionRepeatCounts.set(potionName, currentRepeatCount + 1); this.lastPotionNotificationTimes.set(potionName, now); NotificationManager.notify( 'Potion Alert', `${potionName}: ${countText.toLocaleString()} remaining`, { sound: config.sound, desktop: config.desktop, soundName: CONSTANTS.STRINGS.SOUND_POTIONS, soundUrl: CONFIG.sounds.potions, emoji: '🧪' } ); } } } // Check if we've gone back above threshold else if (wasUnderThreshold && !isCurrentlyUnder) { // Reset repeat counter and notification time when we go back above threshold this.potionRepeatCounts.set(potionName, 0); this.potionUnderThreshold.set(potionName, false); this.lastPotionNotificationTimes.delete(potionName); } // Update state if we're currently under threshold else if (isCurrentlyUnder) { this.potionUnderThreshold.set(potionName, true); } // Update tracking this.lastPotionCounts.set(potionName, currentCount); } // Clean up tracking for potions that no longer exist const currentPotionNames = new Set(); for (const flexDiv of allFlexDivs) { const itemDiv = flexDiv.querySelector('.item.clickable'); if (!itemDiv) continue; const potionNamePara = itemDiv.querySelector('p'); if (!potionNamePara) continue; let potionName = (potionNamePara.textContent || '').trim(); potionName = potionName.replace(/^\[|\]$/g, '').trim(); if (potionName && potionName.toLowerCase().includes('potion')) { currentPotionNames.add(potionName); } } // Remove tracking for potions that are no longer active for (const [potionName] of this.lastPotionCounts) { if (!currentPotionNames.has(potionName)) { this.lastPotionCounts.delete(potionName); this.potionRepeatCounts.delete(potionName); this.potionUnderThreshold.delete(potionName); this.lastPotionNotificationTimes.delete(potionName); } } } catch (error) { // Clear cache on error this.cachedElements.effectsPanel = null; } }, cleanup() { // Clear interval if (this.checkInterval) { clearInterval(this.checkInterval); this.checkInterval = null; } // Disconnect observers if (this.autosObserver) { this.autosObserver.disconnect(); this.autosObserver = null; } if (this.potionsObserver) { this.potionsObserver.disconnect(); this.potionsObserver = null; } // Clear timeouts if (this.autosCheckTimeout) { clearTimeout(this.autosCheckTimeout); this.autosCheckTimeout = null; } if (this.potionsCheckTimeout) { clearTimeout(this.potionsCheckTimeout); this.potionsCheckTimeout = null; } // Clear retry timeouts if (this.autosSetupRetryTimeout) { clearTimeout(this.autosSetupRetryTimeout); this.autosSetupRetryTimeout = null; } if (this.potionsSetupRetryTimeout) { clearTimeout(this.potionsSetupRetryTimeout); this.potionsSetupRetryTimeout = null; } // Clear cached elements this.cachedElements = { autoElement: null, gameGrid: null, leftSidebar: null, logDiv: null, raidContainer: null, mainGameSection: null, effectsPanel: null }; } }; // ============================================ // Image Modal Manager // ============================================ const ImageModalManager = { modalOverlay: null, modal: null, image: null, videoFrame: null, initialized: false, escHandler: null, observer: null, processedNodes: new WeakSet(), // Track processed nodes to avoid reprocessing init() { const wasInitialized = this.initialized; // Check if images or YouTube features are enabled if (!CONFIG.features.images.enabled && !CONFIG.features.youtube.enabled) { return; } // If already initialized, just process existing messages if (wasInitialized) { this.processExistingMessages(); return; } // Wait for DOM to be ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => this.startObserving()); } else { this.startObserving(); } this.initialized = true; }, startObserving() { // Use MutationObserver with debouncing to avoid interfering with chat let timeout; this.observer = new MutationObserver((mutations) => { // Debounce processing to avoid interfering with rapid DOM changes clearTimeout(timeout); timeout = setTimeout(() => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === 1) { // Element node // Process new nodes for plain text image and video URLs this.processNodeForImageUrls(node); } }); }); }, 200); // Small delay to let chat finish its DOM manipulation }); // Observe the chat container const chatContainer = document.querySelector('.chat-content'); if (chatContainer) { this.observer.observe(chatContainer, { childList: true, subtree: true }); // Process existing messages this.processNodeForImageUrls(chatContainer); } else { // Retry if chat container not found yet setTimeout(() => this.startObserving(), 1000); } }, processExistingMessages() { const chatContainer = document.querySelector('.chat-content'); if (chatContainer) { // Clear processed nodes to allow re-processing this.processedNodes = new WeakSet(); // Process all existing messages this.processNodeForImageUrls(chatContainer); } }, removeLinks() { // Remove all image and video links, converting them back to plain text const chatContainer = document.querySelector('.chat-content'); if (!chatContainer) return; // Find all link spans const imageLinks = chatContainer.querySelectorAll('.iqrpg-image-link'); const videoLinks = chatContainer.querySelectorAll('.iqrpg-video-link'); // Convert image links back to plain text imageLinks.forEach(link => { const url = link.getAttribute('data-url') || link.textContent; const textNode = document.createTextNode(url); link.parentNode.replaceChild(textNode, link); }); // Convert video links back to plain text videoLinks.forEach(link => { const url = link.getAttribute('data-url') || link.textContent; const textNode = document.createTextNode(url); link.parentNode.replaceChild(textNode, link); }); // Clear processed nodes to allow re-processing if feature is re-enabled this.processedNodes = new WeakSet(); }, processNodeForImageUrls(node) { // Skip if already processed if (this.processedNodes.has(node)) return; // Skip if this is an input, form, or interactive element if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA' || node.tagName === 'FORM' || node.tagName === 'BUTTON' || node.isContentEditable || node.contentEditable === 'true') { return; } // Skip if inside a form or input if (node.closest('form') || node.closest('input') || node.closest('textarea') || node.closest('[contenteditable="true"]')) { return; } // Skip if this is the chat container itself (we want to process its children) if (node.classList && node.classList.contains('chat-content')) { // Process all child nodes instead if (node.children) { Array.from(node.children).forEach(child => { this.processNodeForImageUrls(child); }); } return; } // Process any node that has text content and is likely a message // Be more flexible - process any element with text that contains URLs if (node.textContent && node.nodeType === 1) { const text = node.textContent; const urlPattern = /(https?:\/\/[^\s<>"']+)/gi; const urlMatches = [...text.matchAll(urlPattern)]; if (urlMatches && urlMatches.length > 0) { // Clean URLs - remove trailing punctuation and whitespace const urls = urlMatches.map(match => { let url = match[0]; // Remove trailing punctuation and whitespace (but keep query params and fragments) url = url.replace(/[.,;:!?)\]\}>\s-]+$/, '').trim(); return url; }); const uniqueUrls = [...new Set(urls)]; // Filter for image and video URLs const imageUrls = uniqueUrls.filter(url => { return CONFIG.features.images.enabled && this.isImageUrl(url); }); const videoUrls = uniqueUrls.filter(url => { return CONFIG.features.youtube.enabled && this.isVideoUrl(url); }); if (imageUrls.length > 0 || videoUrls.length > 0) { // Mark node as processed this.processedNodes.add(node); // Convert text URLs to clickable links this.convertTextUrlsToClickable(node, imageUrls, videoUrls); } } } }, convertTextUrlsToClickable(node, imageUrls, videoUrls = []) { // Use TreeWalker to find and replace text nodes containing URLs const allMediaUrls = [...imageUrls, ...videoUrls]; const walker = document.createTreeWalker( node, NodeFilter.SHOW_TEXT, { acceptNode: (textNode) => { // Only process text nodes that contain our image or video URLs const text = textNode.textContent; const hasMediaUrl = allMediaUrls.some(url => text.includes(url)); return hasMediaUrl ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } }, false ); const textNodesToReplace = []; let textNode; while (textNode = walker.nextNode()) { textNodesToReplace.push(textNode); } // Process text nodes in reverse order to maintain indices textNodesToReplace.reverse().forEach((textNode) => { const text = textNode.textContent; const urlPattern = /(https?:\/\/[^\s<>"']+)/gi; const parts = []; let lastIndex = 0; let match; // Split text by URLs while ((match = urlPattern.exec(text)) !== null) { let url = match[0]; // Clean URL - remove trailing punctuation and whitespace const cleanedUrl = url.replace(/[.,;:!?)\]\}>\s-]+$/, '').trim(); const index = match.index; // Add text before URL if (index > lastIndex) { const beforeText = text.substring(lastIndex, index); if (beforeText) { parts.push({ type: 'text', content: beforeText }); } } // Add URL (check if cleaned URL is in our image/video arrays) if (imageUrls.includes(cleanedUrl)) { parts.push({ type: 'imageUrl', content: cleanedUrl }); } else if (videoUrls.includes(cleanedUrl)) { parts.push({ type: 'videoUrl', content: cleanedUrl }); } else { // Not a media URL, keep original as text parts.push({ type: 'text', content: match[0] }); } lastIndex = index + match[0].length; } // Add remaining text if (lastIndex < text.length) { const remainingText = text.substring(lastIndex); if (remainingText) { parts.push({ type: 'text', content: remainingText }); } } // If we found URLs to replace, create new elements if (parts.some(p => p.type === 'imageUrl' || p.type === 'videoUrl')) { const fragment = document.createDocumentFragment(); parts.forEach((part) => { if (part.type === 'imageUrl' || part.type === 'videoUrl') { // Create clickable span for image or video URL const linkSpan = document.createElement('span'); linkSpan.textContent = part.content; linkSpan.className = part.type === 'imageUrl' ? 'iqrpg-image-link' : 'iqrpg-video-link'; linkSpan.style.cssText = ` color: #4CAF50; text-decoration: underline; cursor: pointer; user-select: text; `; linkSpan.setAttribute('data-url', part.content); linkSpan.setAttribute('data-type', part.type === 'imageUrl' ? 'image' : 'video'); // Add click handler linkSpan.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // Check if this is a direct image link (has file extension) if (part.type === 'imageUrl' && this.isDirectImageUrl(part.content)) { // Direct image link - open in modal this.openModal(part.content, 'image'); } else if (part.type === 'imageUrl') { // Non-direct image link - open in new tab window.open(part.content, '_blank', 'noopener,noreferrer'); } else { // Video link - open in modal (existing behavior) this.openModal(part.content, 'video'); } }, true); // Add hover effect linkSpan.addEventListener('mouseenter', () => { linkSpan.style.color = '#66BB6A'; }); linkSpan.addEventListener('mouseleave', () => { linkSpan.style.color = '#4CAF50'; }); fragment.appendChild(linkSpan); } else { // Add plain text fragment.appendChild(document.createTextNode(part.content)); } }); // Replace the text node with the fragment const parent = textNode.parentNode; if (parent) { parent.replaceChild(fragment, textNode); } } }); }, isDirectImageUrl(url) { if (!url) return false; // Clean URL - remove trailing punctuation and whitespace const cleanUrl = url.replace(/[.,;:!?)\]\}>\s-]+$/, '').trim(); // Check for common image file extensions const imageExtensions = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?|$)/i; return imageExtensions.test(cleanUrl); }, isImageUrl(url) { if (!url) return false; // Clean URL - remove trailing punctuation and whitespace const cleanUrl = url.replace(/[.,;:!?)\]\}>\s-]+$/, '').trim(); // Check for common image file extensions const imageExtensions = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?|$)/i; if (imageExtensions.test(cleanUrl)) return true; // Check for common image hosting services const imageHosts = [ /imgur\.com/i, /giphy\.com/i, /tenor\.com/i, /gfycat\.com/i, /i\.redd\.it/i, /i\.imgur\.com/i, /media\.giphy\.com/i, /cdn\.discordapp\.com/i, /discord\.com\/attachments/i, /prnt\.sc/i, // PrintScreen/ShareX screenshot hosting /prntscr\.com/i, // Alternative PrintScreen domain /lightshot\.app/i // Lightshot screenshot hosting ]; return imageHosts.some(pattern => pattern.test(cleanUrl)); }, isVideoUrl(url) { if (!url) return false; // Clean URL - remove trailing punctuation and whitespace const cleanUrl = url.replace(/[.,;:!?)\]\}>\s-]+$/, '').trim(); // Check for YouTube URL patterns (including Shorts) const youtubePatterns = [ /youtube\.com\/watch\?v=([\w-]+)/i, /youtu\.be\/([\w-]+)/i, /youtube\.com\/embed\/([\w-]+)/i, /youtube\.com\/v\/([\w-]+)/i, /youtube\.com\/shorts\/([\w-]+)/i // YouTube Shorts ]; return youtubePatterns.some(pattern => pattern.test(cleanUrl)); }, getYouTubeEmbedUrl(url) { if (!url) return null; // Check if it's a Short const isShort = /youtube\.com\/shorts\/([\w-]+)/i.test(url); // Extract video ID from various YouTube URL formats let videoId = null; const patterns = [ { regex: /youtube\.com\/watch\?v=([\w-]+)/i, index: 1 }, { regex: /youtu\.be\/([\w-]+)/i, index: 1 }, { regex: /youtube\.com\/embed\/([\w-]+)/i, index: 1 }, { regex: /youtube\.com\/v\/([\w-]+)/i, index: 1 }, { regex: /youtube\.com\/shorts\/([\w-]+)/i, index: 1 } // Shorts pattern ]; for (const { regex, index } of patterns) { const match = url.match(regex); if (match) { videoId = match[index]; break; } } if (videoId) { return { embedUrl: `https://www.youtube.com/embed/${videoId}`, isShort: isShort || /youtube\.com\/shorts\//i.test(url) // Double-check }; } return null; }, getDirectImageUrl(url) { if (!url) return url; // Clean URL first const cleanUrl = url.replace(/[.,;:!?)\]\}>\s-]+$/, '').trim(); // For imgur gallery/album links, try to convert to direct image // https://imgur.com/gallery/[id] -> https://i.imgur.com/[id].jpg const imgurGalleryMatch = cleanUrl.match(/https?:\/\/(?:www\.)?imgur\.com\/(?:gallery\/|a\/)?([a-zA-Z0-9]+)/i); if (imgurGalleryMatch && !cleanUrl.includes('/i.imgur.com/')) { const id = imgurGalleryMatch[1]; // Try .jpg first (most common), fallback handled by browser return `https://i.imgur.com/${id}.jpg`; } // Return original URL if no conversion needed // prnt.sc and tenor.com URLs need page extraction, so return as-is return cleanUrl; }, async extractDirectImageUrlFromPage(pageUrl) { // Extract direct image URL from prnt.sc or tenor.com pages // Returns a promise that resolves to the direct URL or null return new Promise((resolve) => { // Create a hidden iframe to load the page const iframe = document.createElement('iframe'); iframe.style.display = 'none'; iframe.style.width = '0'; iframe.style.height = '0'; iframe.src = pageUrl; let resolved = false; const timeout = setTimeout(() => { if (!resolved) { resolved = true; if (iframe.parentNode) { document.body.removeChild(iframe); } resolve(null); } }, 3000); // 3 second timeout for faster fallback iframe.onload = () => { try { // Try to access iframe content (may fail due to same-origin policy) const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; // For prnt.sc: Look for image.prntscr.com URL if (/prnt\.sc|prntscr\.com/i.test(pageUrl)) { // Look for the image element or meta tags const img = iframeDoc.querySelector('img[src*="image.prntscr.com"]'); if (img && img.src) { const directUrl = img.src; if (!resolved) { resolved = true; clearTimeout(timeout); if (iframe.parentNode) { document.body.removeChild(iframe); } resolve(directUrl); return; } } // Try meta tags const ogImage = iframeDoc.querySelector('meta[property="og:image"]'); if (ogImage && ogImage.content && ogImage.content.includes('image.prntscr.com')) { if (!resolved) { resolved = true; clearTimeout(timeout); if (iframe.parentNode) { document.body.removeChild(iframe); } resolve(ogImage.content); return; } } // Try to find in page source - look for image.prntscr.com pattern const pageHtml = iframeDoc.documentElement.outerHTML; const imageUrlMatch = pageHtml.match(/https?:\/\/image\.prntscr\.com\/image\/[a-zA-Z0-9_-]+\.(png|jpg|jpeg|gif)/i); if (imageUrlMatch) { if (!resolved) { resolved = true; clearTimeout(timeout); if (iframe.parentNode) { document.body.removeChild(iframe); } resolve(imageUrlMatch[0]); return; } } } // For tenor.com: Look for media.tenor.com or media1.tenor.com URL if (/tenor\.com\/view/i.test(pageUrl)) { // Look for meta tags with og:image const ogImage = iframeDoc.querySelector('meta[property="og:image"]'); if (ogImage && ogImage.content) { const directUrl = ogImage.content; if (directUrl.includes('media') && directUrl.includes('tenor.com')) { if (!resolved) { resolved = true; clearTimeout(timeout); if (iframe.parentNode) { document.body.removeChild(iframe); } resolve(directUrl); return; } } } // Try JSON-LD structured data const jsonLd = iframeDoc.querySelector('script[type="application/ld+json"]'); if (jsonLd) { try { const data = JSON.parse(jsonLd.textContent); if (data.contentUrl || (data.image && data.image.contentUrl)) { const directUrl = data.contentUrl || data.image.contentUrl; if (directUrl.includes('media') && directUrl.includes('tenor.com')) { if (!resolved) { resolved = true; clearTimeout(timeout); if (iframe.parentNode) { document.body.removeChild(iframe); } resolve(directUrl); return; } } } } catch (e) { // JSON parse error, continue } } // Try to find in page source - look for media.tenor.com or media1.tenor.com pattern const pageHtml = iframeDoc.documentElement.outerHTML; const mediaUrlMatch = pageHtml.match(/https?:\/\/media\d?\.tenor\.com\/[^"'\s<>]+\.(gif|webp|mp4)/i); if (mediaUrlMatch) { if (!resolved) { resolved = true; clearTimeout(timeout); if (iframe.parentNode) { document.body.removeChild(iframe); } resolve(mediaUrlMatch[0]); return; } } } // If we get here, extraction failed if (!resolved) { resolved = true; clearTimeout(timeout); if (iframe.parentNode) { document.body.removeChild(iframe); } resolve(null); } } catch (e) { // Cross-origin error or other issue - this is expected for most cases if (!resolved) { resolved = true; clearTimeout(timeout); if (iframe.parentNode) { document.body.removeChild(iframe); } resolve(null); } } }; iframe.onerror = () => { if (!resolved) { resolved = true; clearTimeout(timeout); if (iframe.parentNode) { document.body.removeChild(iframe); } resolve(null); } }; document.body.appendChild(iframe); }); }, openModal(url, type = 'image') { // Create overlay if it doesn't exist if (!this.modalOverlay) { this.createModal(); } if (type === 'video') { // Handle video (YouTube) const videoInfo = this.getYouTubeEmbedUrl(url); if (videoInfo && this.videoFrame) { this.videoFrame.src = videoInfo.embedUrl; // Set aspect ratio based on video type if (videoInfo.isShort) { // YouTube Shorts: 9:16 (portrait) this.videoFrame.style.aspectRatio = '9 / 16'; this.videoFrame.style.width = 'min(85vw, 405px)'; // 85% viewport width, capped at 405px this.videoFrame.style.maxWidth = '405px'; this.videoFrame.style.maxHeight = '720px'; } else { // Regular YouTube videos: 16:9 (landscape) this.videoFrame.style.aspectRatio = '16 / 9'; this.videoFrame.style.width = 'min(85vw, 1280px)'; // 85% viewport width, capped at 1280px this.videoFrame.style.maxWidth = '1280px'; this.videoFrame.style.maxHeight = '720px'; } this.videoFrame.style.display = 'block'; if (this.image) this.image.style.display = 'none'; } } else { // Handle image - convert to direct image URL if needed const isTenorViewUrl = /tenor\.com\/view\//i.test(url); const isPrntScUrl = /prnt\.sc\//i.test(url) || /prntscr\.com\//i.test(url); const isImgurGallery = /imgur\.com\/(?:gallery|a)\//i.test(url); if (this.image) { if (isTenorViewUrl || isPrntScUrl) { // For tenor.com/view and prnt.sc URLs, extract direct URL first this.image.style.display = 'block'; this.image.alt = 'Loading...'; this.image.src = ''; // Clear previous image // Remove any existing error message first const existingError = this.modal.querySelector('.iqrpg-image-error-message'); if (existingError) { existingError.remove(); } this.extractDirectImageUrlFromPage(url).then((directUrl) => { if (directUrl) { this.image.src = directUrl; this.image.alt = 'Image'; this.image.style.display = 'block'; if (this.videoFrame) this.videoFrame.style.display = 'none'; // Remove any existing error message (in case it was shown from a previous attempt) const existingError = this.modal.querySelector('.iqrpg-image-error-message'); if (existingError) { existingError.remove(); } } else { // Extraction failed - show user-friendly error message with link // Don't try iframe as these sites block it with X-Frame-Options this.image.style.display = 'none'; if (this.videoFrame) this.videoFrame.style.display = 'none'; // Create and show error message with link const errorMsg = document.createElement('div'); errorMsg.className = 'iqrpg-image-error-message'; errorMsg.style.cssText = ` color: white; text-align: center; padding: 40px 20px; max-width: 500px; margin: 0 auto; `; const errorText = document.createElement('p'); errorText.textContent = 'Unable to extract direct image URL. The image may be available on the source page.'; errorText.style.cssText = 'margin: 0 0 20px 0; font-size: 18px; line-height: 1.5;'; const linkBtn = document.createElement('a'); linkBtn.href = url; linkBtn.target = '_blank'; linkBtn.rel = 'noopener noreferrer'; linkBtn.textContent = 'Open in new tab'; linkBtn.style.cssText = ` display: inline-block; padding: 12px 24px; background: rgb(34, 136, 34); color: white; text-decoration: none; border-radius: 6px; font-size: 16px; transition: background 0.2s ease; cursor: pointer; `; linkBtn.onmouseover = () => linkBtn.style.background = 'rgb(34, 102, 34)'; linkBtn.onmouseout = () => linkBtn.style.background = 'rgb(34, 136, 34)'; errorMsg.appendChild(errorText); errorMsg.appendChild(linkBtn); // Insert error message into modal this.modal.appendChild(errorMsg); } }); } else { // For other URLs, use direct conversion let directImageUrl = this.getDirectImageUrl(url); // Set up error handler for URLs that might need extension fallback let extensionAttempts = 0; const errorHandler = () => { if (extensionAttempts < 4 && this.image && isImgurGallery) { const baseUrl = directImageUrl.replace(/\.(png|jpg|jpeg|gif|webp)$/i, ''); const extensions = ['.jpg', '.png', '.gif', '.webp']; if (extensionAttempts < extensions.length) { this.image.src = baseUrl + extensions[extensionAttempts]; extensionAttempts++; } } }; this.image.onerror = errorHandler; this.image.src = directImageUrl; this.image.alt = 'Image'; this.image.style.display = 'block'; } } if (this.videoFrame && !isTenorViewUrl && !isPrntScUrl) { this.videoFrame.style.display = 'none'; } } // Show modal if (this.modalOverlay) { this.modalOverlay.classList.add('active'); document.body.style.overflow = 'hidden'; } }, createModal() { // Create overlay this.modalOverlay = document.createElement('div'); this.modalOverlay.className = 'iqrpg-image-modal-overlay'; this.modalOverlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.9); z-index: 10000; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.3s ease; cursor: pointer; pointer-events: none; `; // Create modal container this.modal = document.createElement('div'); this.modal.className = 'iqrpg-image-modal'; this.modal.style.cssText = ` position: relative; max-width: 90%; max-height: 90%; cursor: default; pointer-events: auto; `; // Create close button const closeBtn = document.createElement('button'); closeBtn.innerHTML = '×'; closeBtn.className = 'iqrpg-image-modal-close'; closeBtn.style.cssText = ` position: absolute; top: -40px; right: 0; background: rgba(255, 255, 255, 0.2); border: none; color: white; font-size: 32px; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.2s ease; pointer-events: auto; `; closeBtn.onmouseover = () => closeBtn.style.background = 'rgba(255, 255, 255, 0.3)'; closeBtn.onmouseout = () => closeBtn.style.background = 'rgba(255, 255, 255, 0.2)'; // Create image element this.image = document.createElement('img'); this.image.style.cssText = ` max-width: 100%; max-height: 90vh; object-fit: contain; border-radius: 8px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); display: block; `; // Create video iframe element this.videoFrame = document.createElement('iframe'); this.videoFrame.style.cssText = ` aspect-ratio: 16 / 9; /* Default to regular video ratio */ border: none; border-radius: 8px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); display: none; `; this.videoFrame.setAttribute('allowfullscreen', 'true'); this.videoFrame.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'); // Assemble modal this.modal.appendChild(closeBtn); this.modal.appendChild(this.image); this.modal.appendChild(this.videoFrame); this.modalOverlay.appendChild(this.modal); document.body.appendChild(this.modalOverlay); // Add CSS for active state if (!document.getElementById('iqrpg-image-modal-styles')) { const style = document.createElement('style'); style.id = 'iqrpg-image-modal-styles'; style.textContent = ` .iqrpg-image-modal-overlay.active { opacity: 1 !important; pointer-events: auto !important; } .iqrpg-image-link:hover, .iqrpg-video-link:hover { color: #66BB6A !important; } `; document.head.appendChild(style); } // Event listeners closeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.closeModal(); }); this.modalOverlay.addEventListener('click', (e) => { if (e.target === this.modalOverlay) { this.closeModal(); } }); // ESC key to close this.escHandler = (e) => { if (e.key === 'Escape' && this.modalOverlay.classList.contains('active')) { this.closeModal(); } }; document.addEventListener('keydown', this.escHandler); }, closeModal() { if (this.modalOverlay) { this.modalOverlay.classList.remove('active'); document.body.style.overflow = ''; // Remove image source to free memory if (this.image) { this.image.src = ''; this.image.style.display = 'block'; } // Remove video iframe source to stop playback and free memory if (this.videoFrame) { this.videoFrame.src = ''; this.videoFrame.style.display = 'none'; } // Remove any error messages if (this.modal) { const errorMsg = this.modal.querySelector('.iqrpg-image-error-message'); if (errorMsg) { errorMsg.remove(); } } } }, cleanup() { if (this.observer) { this.observer.disconnect(); this.observer = null; } if (this.escHandler) { document.removeEventListener('keydown', this.escHandler); this.escHandler = null; } if (this.modalOverlay) { this.modalOverlay.remove(); this.modalOverlay = null; this.modal = null; this.image = null; this.videoFrame = null; } document.body.style.overflow = ''; } }; // ============================================ // Market Data Tracker // ============================================ const MarketDataTracker = { storageKey: 'iqrpg_market_data', // Parse market sale message to extract data parseSaleMessage(rawMsg) { // Example: "You have sold 3 [item:spider_egg] for 6,986,664 [item:gold]. You paid 349,334 [item:gold] tax." // Extract quantity, item name, gold value, and tax // Match pattern: "sold X [item:item_name] for Y [item:gold]. You paid Z [item:gold] tax." const salePattern = /sold\s+(\d+)\s+\[item:([^\]]+)\]\s+for\s+([\d,]+)\s+\[item:gold\]\.\s+You\s+paid\s+([\d,]+)\s+\[item:gold\]\s+tax/i; const match = rawMsg.match(salePattern); if (!match) return null; const quantity = parseInt(match[1].replace(/,/g, ''), 10); const itemKey = match[2]; const goldValue = parseInt(match[3].replace(/,/g, ''), 10); const tax = parseInt(match[4].replace(/,/g, ''), 10); // Get proper item name using existing formatItemName function const itemName = CONSTANTS.ITEM_NAME_MAP[itemKey] || formatItemName(itemKey); return { itemName: itemName, itemKey: itemKey, quantity: quantity, goldValue: goldValue, tax: tax, netGold: goldValue - tax, // Net gold after tax timestamp: Date.now() }; }, // Record a sale recordSale(saleData) { if (!saleData) return; const data = this.loadData(); // Initialize item entry if it doesn't exist if (!data.items[saleData.itemName]) { data.items[saleData.itemName] = { quantity: 0, totalGold: 0, totalTax: 0, totalNetGold: 0 }; } // Update stats (use net gold for totals) data.items[saleData.itemName].quantity += saleData.quantity; data.items[saleData.itemName].totalGold += saleData.goldValue; // Keep gross for reference data.items[saleData.itemName].totalTax += saleData.tax; data.items[saleData.itemName].totalNetGold += saleData.netGold; // Net after tax data.totalGold += saleData.netGold; // Total net gold earned data.totalTax += saleData.tax; // Total taxes paid // Save to localStorage this.saveData(data); }, // Load data from localStorage loadData() { try { const stored = localStorage.getItem(this.storageKey); if (stored) { const data = JSON.parse(stored); // Migrate old data structure if needed if (data.totalTax === undefined) { data.totalTax = 0; // Recalculate if we have items if (data.items) { Object.values(data.items).forEach(item => { if (item.totalTax === undefined) { item.totalTax = 0; item.totalNetGold = item.totalGold || 0; } }); } } // Initialize receivedItems if it doesn't exist if (data.receivedItems === undefined) { data.receivedItems = {}; } else { // Migrate old receivedItems structure to new one with transactions Object.keys(data.receivedItems).forEach(itemName => { const item = data.receivedItems[itemName]; if (item && !item.transactions) { // Old structure: just { quantity: X } // Convert to new structure with empty transactions array data.receivedItems[itemName] = { quantity: item.quantity || 0, transactions: [] }; } }); } // Initialize gold tracking if it doesn't exist if (data.totalGoldReceived === undefined) { data.totalGoldReceived = 0; } if (data.totalTaxFromReceived === undefined) { data.totalTaxFromReceived = 0; } // Initialize sentItems if it doesn't exist if (data.sentItems === undefined) { data.sentItems = {}; } else { // Migrate old sentItems structure to new one with transactions Object.keys(data.sentItems).forEach(itemName => { const item = data.sentItems[itemName]; if (item && !item.transactions) { // Old structure: just { quantity: X } // Convert to new structure with empty transactions array data.sentItems[itemName] = { quantity: item.quantity || 0, transactions: [] }; } }); } return data; } } catch (e) { // Silently fail } // Return default structure return { items: {}, totalGold: 0, totalTax: 0, totalGoldReceived: 0, // Gross gold received (no tax deduction) totalTaxFromReceived: 0, // Taxes paid on received gold receivedItems: {}, sentItems: {} }; }, // Save data to localStorage saveData(data) { if (!data || typeof data !== 'object') { console.warn('[IQRPG Enhanced] Invalid data structure in saveData()'); return; } // Validate required structure const requiredFields = ['items', 'totalGold', 'totalTax', 'totalGoldReceived', 'totalTaxFromReceived', 'receivedItems', 'sentItems']; for (const field of requiredFields) { if (!(field in data)) { console.warn(`[IQRPG Enhanced] Missing required field '${field}' in saveData()`); return; } } // Validate numeric fields const numericFields = ['totalGold', 'totalTax', 'totalGoldReceived', 'totalTaxFromReceived']; for (const field of numericFields) { if (typeof data[field] !== 'number' || isNaN(data[field])) { console.warn(`[IQRPG Enhanced] Invalid numeric value for '${field}' in saveData()`); return; } } try { localStorage.setItem(this.storageKey, JSON.stringify(data)); } catch (e) { console.error('[IQRPG Enhanced] Failed to save market data:', e); } }, // Get all stats getStats() { return this.loadData(); }, // Helper function to aggregate transactions by item + person aggregateTransactions(itemsData, personKey) { const aggregated = new Map(); Object.entries(itemsData).forEach(([itemName, stats]) => { if (stats.transactions && stats.transactions.length > 0) { stats.transactions.forEach(trans => { const person = trans[personKey] || 'Unknown'; const key = `${itemName}|${person}`; if (aggregated.has(key)) { // Sum the amounts aggregated.get(key).amount += trans.quantity; } else { // Create new entry aggregated.set(key, { item: itemName, amount: trans.quantity, person: person }); } }); } }); return Array.from(aggregated.values()); }, // Clear all data clearData() { try { localStorage.removeItem(this.storageKey); } catch (e) { // Silently fail } }, // Parse received item message to extract data parseReceivedItem(rawMsg) { // Example: "You received 5 [item:spider_egg]" // Or: "You have received 10 [item:gold] from playerName" // Or: "You received 5,000,000 [item:gold] from Blackknight. You paid 250,000 [item:gold] tax." // First check if this is a gold receipt with tax information const goldWithTaxPattern = /received\s+([\d,]+)\s+\[item:gold\]\s+from\s+([^\s.]+)\.\s+You\s+paid\s+([\d,]+)\s+\[item:gold\]\s+tax/i; const goldWithTaxMatch = rawMsg.match(goldWithTaxPattern); if (goldWithTaxMatch) { const goldAmount = parseInt(goldWithTaxMatch[1].replace(/,/g, ''), 10); const from = goldWithTaxMatch[2].trim(); const tax = parseInt(goldWithTaxMatch[3].replace(/,/g, ''), 10); return { itemName: 'Gold', itemKey: 'gold', quantity: goldAmount, from: from, goldValue: goldAmount, // Gross gold received tax: tax, timestamp: Date.now() }; } // Regular patterns for other items or gold without tax info const patterns = [ /received\s+(\d+)\s+\[item:([^\]]+)\](?:\s+from\s+([^\s.]+))?/i, /have\s+received\s+(\d+)\s+\[item:([^\]]+)\](?:\s+from\s+([^\s.]+))?/i, /got\s+(\d+)\s+\[item:([^\]]+)\](?:\s+from\s+([^\s.]+))?/i ]; for (const pattern of patterns) { const match = rawMsg.match(pattern); if (match) { const quantity = parseInt(match[1].replace(/,/g, ''), 10); const itemKey = match[2]; const from = match[3] ? match[3].trim() : 'Unknown'; // Get proper item name using existing formatItemName function const itemName = CONSTANTS.ITEM_NAME_MAP[itemKey] || formatItemName(itemKey); return { itemName: itemName, itemKey: itemKey, quantity: quantity, from: from, timestamp: Date.now() }; } } return null; }, // Record a received item recordReceivedItem(itemData) { if (!itemData) return; const data = this.loadData(); // Initialize receivedItems if it doesn't exist if (!data.receivedItems) { data.receivedItems = {}; } // Initialize item entry if it doesn't exist if (!data.receivedItems[itemData.itemName]) { data.receivedItems[itemData.itemName] = { quantity: 0, transactions: [] // Store individual transactions }; } // Add transaction const transaction = { quantity: itemData.quantity, from: itemData.from || 'Unknown', // Sender name timestamp: itemData.timestamp || Date.now() }; // If this is gold with tax info, include gold value and tax in transaction if (itemData.itemKey === 'gold' && itemData.goldValue !== undefined) { transaction.goldValue = itemData.goldValue; transaction.tax = itemData.tax || 0; } data.receivedItems[itemData.itemName].transactions.push(transaction); // Update total quantity data.receivedItems[itemData.itemName].quantity += itemData.quantity; // If this is gold with tax info, track gold totals and tax if (itemData.itemKey === 'gold' && itemData.goldValue !== undefined) { // Initialize gold tracking if it doesn't exist if (data.totalGoldReceived === undefined) { data.totalGoldReceived = 0; } if (data.totalTaxFromReceived === undefined) { data.totalTaxFromReceived = 0; } // Track gross gold received (no tax deduction) data.totalGoldReceived += itemData.goldValue; // Track tax paid on received gold data.totalTaxFromReceived += (itemData.tax || 0); } // Save to localStorage this.saveData(data); }, // Get sorted received items by quantity (descending) getSortedReceivedItems() { const data = this.loadData(); if (!data.receivedItems) return []; const items = Object.entries(data.receivedItems).map(([name, stats]) => ({ name, quantity: stats.quantity, transactions: stats.transactions || [] // Include transactions })); return items.sort((a, b) => b.quantity - a.quantity); }, // Parse sent item from DOM notification element parseSentItemFromDOM(notificationElement) { // Parse notification HTML structure // Example: "You sent 5000000 [Gold] to krm." const contentDiv = notificationElement.querySelector('.notification__content'); if (!contentDiv) return null; const text = contentDiv.textContent || ''; // Check if this is a "sent" notification if (!text.toLowerCase().includes('you sent')) return null; // Extract quantity - pattern: "You sent 5000000" const quantityMatch = text.match(/you\s+sent\s+([\d,]+)/i); if (!quantityMatch) return null; const quantity = parseInt(quantityMatch[1].replace(/,/g, ''), 10); // Extract item name from the <p> tag inside .item.clickable // The item is in: <div class="item clickable"><p class="gold-text">[Gold]</p> const itemPara = notificationElement.querySelector('.item.clickable p'); let itemName = null; if (itemPara) { // Get text like "[Gold]" and extract "Gold" const itemText = itemPara.textContent || ''; const bracketMatch = itemText.match(/\[([^\]]+)\]/); if (bracketMatch) { itemName = bracketMatch[1].trim(); } } // Fallback: try to extract from the full text if itemPara not found if (!itemName) { const bracketMatch = text.match(/\[([^\]]+)\]/); if (bracketMatch) { itemName = bracketMatch[1].trim(); } } if (!itemName) return null; // Format item name using existing formatItemName function for consistency const formattedItemName = CONSTANTS.ITEM_NAME_MAP[itemName.toLowerCase()] || formatItemName(itemName); // Extract recipient - pattern: "to krm." const recipientMatch = text.match(/to\s+([^.]+)\.?/i); const recipient = recipientMatch ? recipientMatch[1].trim() : 'Unknown'; return { itemName: formattedItemName, quantity: quantity, recipient: recipient, timestamp: Date.now() }; }, // Record a sent item recordSentItem(itemData) { if (!itemData) return; const data = this.loadData(); // Initialize sentItems if it doesn't exist if (!data.sentItems) { data.sentItems = {}; } // Initialize item entry if it doesn't exist if (!data.sentItems[itemData.itemName]) { data.sentItems[itemData.itemName] = { quantity: 0, transactions: [] // Store individual transactions }; } // Add transaction data.sentItems[itemData.itemName].transactions.push({ quantity: itemData.quantity, to: itemData.recipient || 'Unknown', // Recipient name timestamp: itemData.timestamp || Date.now() }); // Update total quantity data.sentItems[itemData.itemName].quantity += itemData.quantity; // Save to localStorage this.saveData(data); }, // Get sorted sent items by quantity (descending) getSortedSentItems() { const data = this.loadData(); if (!data.sentItems) return []; const items = Object.entries(data.sentItems).map(([name, stats]) => ({ name, quantity: stats.quantity, transactions: stats.transactions || [] // Include transactions })); return items.sort((a, b) => b.quantity - a.quantity); }, // Flatten received items into rows for table display getReceivedItemsRows(data = null) { const itemsData = data || this.loadData(); if (!itemsData.receivedItems) return []; return this.aggregateTransactions(itemsData.receivedItems, 'from'); }, // Flatten sent items into rows for table display getSentItemsRows(data = null) { const itemsData = data || this.loadData(); if (!itemsData.sentItems) return []; return this.aggregateTransactions(itemsData.sentItems, 'to'); }, // Calculate total gold sent (optimized to only process Gold transactions) getTotalGoldSent(data = null) { const itemsData = data || this.loadData(); if (!itemsData.sentItems || !itemsData.sentItems.Gold) return 0; const goldStats = itemsData.sentItems.Gold; if (!goldStats.transactions || goldStats.transactions.length === 0) return 0; return goldStats.transactions.reduce((total, trans) => total + trans.quantity, 0); }, // Get market sales as rows for table display getMarketSalesRows(data = null) { const itemsData = data || this.loadData(); if (!itemsData.items) return []; const rows = []; Object.entries(itemsData.items).forEach(([itemName, stats]) => { rows.push({ item: itemName, quantity: stats.quantity || 0, gold: stats.totalNetGold || 0, // Net gold after tax tax: stats.totalTax || 0 }); }); return rows; } }; // ============================================ // Item Sent Observer // ============================================ const ItemSentObserver = { observer: null, processedNotifications: new WeakSet(), initialized: false, init() { // Prevent multiple initializations if (this.initialized) return; // Check if item sent notifications are enabled if (!CONFIG.notifications.itemSent.sound && !CONFIG.notifications.itemSent.desktop) { return; } this.initialized = true; this.startObserving(); }, startObserving() { // Disconnect existing observer if any if (this.observer) { this.observer.disconnect(); this.observer = null; } // Use MutationObserver to watch for notification divs this.observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === 1 && node.classList && node.classList.contains('notification')) { // Check if this is a sent item notification this.processNotification(node); } }); }); }); // Observe document body for notification divs (they appear outside .game) this.observer.observe(document.body, { childList: true, subtree: true }); }, processNotification(notificationElement) { // Skip if already processed if (this.processedNotifications.has(notificationElement)) return; // Check if this is a "sent" notification const contentDiv = notificationElement.querySelector('.notification__content'); if (!contentDiv) return; const text = (contentDiv.textContent || '').toLowerCase(); if (!text.includes('you sent')) return; // Mark as processed this.processedNotifications.add(notificationElement); // Parse and track the sent item const itemData = MarketDataTracker.parseSentItemFromDOM(notificationElement); if (itemData) { MarketDataTracker.recordSentItem(itemData); const config = CONFIG.notifications.itemSent; if (config.sound || config.desktop) { // Get clean message for notification const cleanText = contentDiv.textContent || ''; const cleanMsg = cleanGameMessage(cleanText); // Check if this is Gold const isGold = itemData.itemName === 'Gold'; const title = isGold ? 'Gold Sent' : 'Item Sent'; const emoji = isGold ? '🪙' : '📤'; const soundName = isGold ? CONSTANTS.STRINGS.SOUND_GOLD_SENT : CONSTANTS.STRINGS.SOUND_ITEM_SENT; const soundUrl = isGold ? CONFIG.sounds.goldSent : CONFIG.sounds.itemSent; NotificationManager.notify( title, cleanMsg, { sound: config.sound, desktop: config.desktop, soundName: soundName, soundUrl: soundUrl, emoji: emoji } ); } } }, cleanup() { if (this.observer) { this.observer.disconnect(); this.observer = null; } this.processedNotifications = new WeakSet(); this.initialized = false; } }; // ============================================ // GUI Manager - Settings Button & Modal // ============================================ const GUIManager = { initialized: false, receivedTableSort: null, // { column: 'item', direction: 'asc' } sentTableSort: null, // { column: 'item', direction: 'asc' } salesTableSort: null, // { column: 'item', direction: 'asc' } // Section name mapping for navigation sectionNames: { volume: 'Volume', autos: 'Autos', globalEvents: 'Globals', bossSpawn: 'Bosses', gatheringEvents: 'Gathering', actionBonus: 'Action Bonus', clan: 'Clan', dungeon: 'Dungeon', land: 'Raid', mastery: 'Mastery', skills: 'Skills', marketSale: 'Market', tradeAlert: 'Trade', itemDrop: 'Log', message: 'Message', abyssBattles: 'Abyss', potions: 'Potions', images: 'Img', youtube: 'YouTube', emojis: 'Emojis' }, settingsButton: null, modal: null, modalOverlay: null, escKeyHandler: null, init() { if (this.initialized || !CONFIG.gui.enabled) return; this.initialized = true; // Wait for DOM to be ready const initGUI = () => { this.injectStyles(); // Delay button creation slightly to ensure header content is loaded setTimeout(() => { this.createSettingsButton(); }, CONSTANTS.DELAYS.BUTTON_CREATION); this.createModal(); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initGUI); } else { initGUI(); } }, injectStyles() { if (document.getElementById('iqrpg-enhanced-styles')) return; const style = document.createElement('style'); style.id = 'iqrpg-enhanced-styles'; style.textContent = ` /* Settings Button */ .iqrpg-settings-btn { width: 32px; height: 32px; background: transparent; border: none; border-radius: 0px; cursor: pointer; box-shadow: none; display: inline-flex; align-items: center; justify-content: center; transition: all 0.3s ease; font-size: 16px; color: rgb(10, 10, 10); padding: 0; margin-right: 8px; vertical-align: middle; } .iqrpg-settings-btn:hover { transform: scale(1.05); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); } .iqrpg-settings-btn:active { transform: scale(0.98); } /* Modal Overlay */ .iqrpg-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7); z-index: 2000; display: none; align-items: center; justify-content: center; backdrop-filter: blur(2px); } .iqrpg-modal-overlay.active { display: flex; } /* Modal */ .iqrpg-modal { background: rgb(10, 10, 10); border-radius: 0px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); width: 90%; max-width: 600px; max-height: 90vh; display: flex; flex-direction: column; z-index: 2001; position: relative; border: 1px solid rgb(51, 51, 51); } /* Modal Header */ .iqrpg-modal-header { padding: 20px; border-bottom: 2px solid rgb(51, 51, 51); display: flex; justify-content: space-between; align-items: center; background: linear-gradient(135deg, rgb(34, 102, 34) 0%, rgb(34, 136, 34) 100%); border-radius: 0px; flex-shrink: 0; } .iqrpg-modal-title { margin: 0; color: white; font-size: 24px; font-weight: bold; font-family: Verdana, Arial, sans-serif; } .iqrpg-modal-close { background: rgba(255, 255, 255, 0.2); border: none; color: white; width: 32px; height: 32px; border-radius: 50%; cursor: pointer; font-size: 20px; display: flex; align-items: center; justify-content: center; transition: all 0.2s; } .iqrpg-modal-close:hover { background: rgba(255, 255, 255, 0.3); transform: rotate(90deg); } /* Modal Navigation Bar */ .iqrpg-modal-nav { position: sticky; top: 0; z-index: 10; background: rgba(0, 0, 0, 0); border-bottom: 2px solid rgb(68, 68, 68); padding: 6px 8px; min-height: 60px; flex-shrink: 0; } .iqrpg-nav-buttons { display: flex; flex-wrap: wrap; gap: 3px; align-items: center; justify-content: center; } .iqrpg-nav-btn { padding: 3px 6px; background: rgb(10, 10, 10); border: 1px solid rgb(102, 102, 102); border-radius: 0px; color: rgb(204, 204, 204); cursor: pointer; font-size: 11px; font-weight: 400; font-family: Verdana, Arial, sans-serif; transition: all 0.2s; white-space: nowrap; flex: 0 0 auto; min-height: 26px; display: inline-block; } .iqrpg-nav-btn:hover { background: rgb(51, 51, 51); border-color: rgb(34, 136, 34); color: rgb(34, 136, 34); } .iqrpg-nav-btn:active { transform: scale(0.95); } /* Modal Body */ .iqrpg-modal-body { overflow-y: auto; flex: 1; min-height: 0; padding: 20px; padding-bottom: 80px; /* Space for sticky footer */ } /* Modal Footer */ .iqrpg-modal-footer { position: sticky; bottom: 0; z-index: 10; background: rgba(0, 0, 0, 0); border-top: 2px solid rgb(51, 51, 51); padding: 15px 20px; border-radius: 0px; } /* Section */ .iqrpg-section { margin-bottom: 2px; padding: 0px; background: rgb(10, 10, 10); border-radius: 0px; border: 1px solid rgb(51, 51, 51); } .iqrpg-section-header { display: flex; align-items: center; margin-bottom: 15px; padding: 20px 20px 0 20px; } .iqrpg-section-title { margin: 0; color: rgb(204, 204, 204); font-size: 18px; font-weight: 400; font-family: Verdana, Arial, sans-serif; } .iqrpg-section-content { overflow: hidden; padding: 0 20px 20px 20px; } /* Toggle Switch */ .iqrpg-toggle-group { display: flex; align-items: center; justify-content: space-between; margin-bottom: 15px; padding: 10px; background: rgb(10, 10, 10); border-radius: 0px; border: 1px solid rgb(51, 51, 51); } .iqrpg-toggle-label { color: rgb(204, 204, 204); font-size: 14px; font-family: Verdana, Arial, sans-serif; flex: 1; margin: 0px 15px 0px 0px; } .iqrpg-toggle-switch { position: relative; width: 50px; height: 26px; background: rgb(102, 102, 102); border-radius: 13px; cursor: pointer; transition: background 0.3s; } .iqrpg-toggle-switch.active { background: rgb(34, 136, 34); } .iqrpg-toggle-switch::after { content: ''; position: absolute; width: 20px; height: 20px; background: white; border-radius: 50%; top: 3px; left: 3px; transition: left 0.3s; } .iqrpg-toggle-switch.active::after { left: 27px; } /* Input Group */ .iqrpg-input-group { margin-bottom: 15px; } .iqrpg-input-label { display: block; color: rgb(204, 204, 204); font-size: 14px; font-family: Verdana, Arial, sans-serif; margin: 0px 15px 5px 0px; } .iqrpg-input { width: 100%; padding: 10px; background: rgb(20, 20, 20); border: 1px solid rgb(51, 51, 51); border-radius: 0px; color: rgb(255, 255, 255); font-size: 14px; font-weight: 400; font-family: Arial; box-sizing: border-box; } .iqrpg-input:focus { outline: none; border-color: rgb(34, 136, 34); } /* Volume Slider */ .iqrpg-volume-group { display: flex; align-items: center; justify-content: space-between; margin-bottom: 15px; padding: 10px; background: rgb(10, 10, 10); border-radius: 0px; border: 1px solid rgb(51, 51, 51); } .iqrpg-volume-label { color: rgb(204, 204, 204); font-size: 14px; font-family: Verdana, Arial, sans-serif; flex: 1; margin-right: 15px; } .iqrpg-volume-slider { flex: 2; height: 6px; background: rgb(51, 51, 51); border-radius: 3px; outline: none; -webkit-appearance: none; appearance: none; margin: 0; padding: 0; } .iqrpg-volume-slider::-webkit-slider-runnable-track { width: 100%; height: 6px; background: linear-gradient(to right, rgb(34, 136, 34) 0%, rgb(34, 136, 34) var(--volume-percent, 0%), rgb(51, 51, 51) var(--volume-percent, 0%), rgb(51, 51, 51) 100%); border-radius: 3px; } .iqrpg-volume-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 18px; height: 18px; background: rgb(34, 136, 34); border-radius: 50%; cursor: pointer; transition: background 0.2s; margin-top: -6px; position: relative; z-index: 1; } .iqrpg-volume-slider::-webkit-slider-thumb:hover { background: rgb(34, 102, 34); } .iqrpg-volume-slider::-moz-range-track { width: 100%; height: 6px; background: rgb(51, 51, 51); border-radius: 3px; border: none; } .iqrpg-volume-slider::-moz-range-progress { background: rgb(34, 136, 34); height: 6px; border-radius: 3px 0 0 3px; } .iqrpg-volume-slider::-moz-range-thumb { width: 18px; height: 18px; background: rgb(34, 136, 34); border-radius: 50%; cursor: pointer; border: none; transition: background 0.2s; } .iqrpg-volume-slider::-moz-range-thumb:hover { background: rgb(34, 102, 34); } .iqrpg-volume-value { color: rgb(34, 136, 34); font-size: 14px; font-weight: bold; font-family: Verdana, Arial, sans-serif; min-width: 45px; text-align: right; margin-left: 15px; } /* Buttons */ .iqrpg-button-group { display: flex; gap: 10px; margin-top: 20px; } .iqrpg-button { flex: 1; padding: 3px 6px; border: 1px solid rgb(51, 51, 51); border-radius: 0px; cursor: pointer; font-size: 13.33px; font-weight: 400; font-family: Arial; transition: all 0.3s; } .iqrpg-button-primary { background: linear-gradient(135deg, rgb(34, 102, 34) 0%, rgb(34, 136, 34) 100%); color: white; } .iqrpg-button-primary:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(34, 136, 34, 0.4); } .iqrpg-button-secondary { background: rgb(20, 20, 20); color: rgb(204, 204, 204); } .iqrpg-button-secondary:hover { background: rgb(51, 51, 51); } /* Market Data Side Panel */ .iqrpg-market-data-panel { position: fixed; top: 0; right: -400px; width: 400px; height: 100vh; background: rgb(10, 10, 10); border-left: 2px solid rgb(51, 51, 51); z-index: 2002; transition: right 0.3s ease; overflow-y: auto; box-shadow: -5px 0 20px rgba(0, 0, 0, 0.5); } .iqrpg-market-data-panel.active { right: 0; } .iqrpg-market-data-panel-header { padding: 20px; border-bottom: 2px solid rgb(51, 51, 51); background: linear-gradient(135deg, rgb(34, 102, 34) 0%, rgb(34, 136, 34) 100%); display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; z-index: 10; } .iqrpg-market-data-panel-title { margin: 0; color: white; font-size: 20px; font-weight: bold; font-family: Verdana, Arial, sans-serif; } .iqrpg-market-data-panel-close { background: rgba(255, 255, 255, 0.2); border: none; color: white; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; font-size: 18px; display: flex; align-items: center; justify-content: center; transition: all 0.2s; } .iqrpg-market-data-panel-close:hover { background: rgba(255, 255, 255, 0.3); } .iqrpg-market-data-content { padding: 20px; } .iqrpg-market-data-summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px; } .iqrpg-market-data-summary { margin-bottom: 0; padding: 15px; background: rgb(30, 30, 30); border: 1px solid rgb(51, 51, 51); border-radius: 0px; display: flex; flex-direction: column; justify-content: space-between; min-height: 80px; } .iqrpg-market-data-summary-title { color: rgb(204, 204, 204); font-size: 12px; font-weight: bold; font-family: Verdana, Arial, sans-serif; margin-bottom: 10px; margin-top: 0; line-height: 1.4; } .iqrpg-market-data-summary-value { color: rgb(34, 136, 34); font-size: 11px; font-weight: bold; font-family: Verdana, Arial, sans-serif; margin: 0; line-height: 1.2; text-align: left; } .iqrpg-market-data-items { margin-top: 20px; } .iqrpg-market-data-item { padding: 12px; margin-bottom: 8px; background: rgb(30, 30, 30); border: 1px solid rgb(51, 51, 51); border-radius: 0px; } .iqrpg-market-data-item-name { color: rgb(204, 204, 204); font-size: 14px; font-weight: bold; font-family: Verdana, Arial, sans-serif; margin-bottom: 8px; } .iqrpg-market-data-item-stats { display: flex; justify-content: space-between; font-size: 12px; font-family: Verdana, Arial, sans-serif; color: rgb(136, 136, 136); } .iqrpg-market-data-item-gold { color: rgb(34, 136, 34); font-weight: bold; } /* Market Data Table Styles */ .iqrpg-market-data-table { width: 100%; border-collapse: collapse; margin-top: 0; font-family: Verdana, Arial, sans-serif; font-size: 12px; } .iqrpg-market-data-table th { background: rgb(20, 20, 20); color: rgb(204, 204, 204); padding: 10px; text-align: left; border: 1px solid rgb(51, 51, 51); cursor: pointer; user-select: none; position: sticky; top: 0; z-index: 5; } .iqrpg-market-data-table th:hover { background: rgb(30, 30, 30); } .iqrpg-market-data-table th.sort-asc::after { content: ' ▲'; font-size: 10px; color: rgb(34, 136, 34); } .iqrpg-market-data-table th.sort-desc::after { content: ' ▼'; font-size: 10px; color: rgb(34, 136, 34); } .iqrpg-market-data-table td { padding: 8px 10px; border: 1px solid rgb(51, 51, 51); color: rgb(204, 204, 204); } .iqrpg-market-data-table tbody tr:nth-child(even) { background: rgb(15, 15, 15); } .iqrpg-market-data-table tbody tr:hover { background: rgb(25, 25, 25); } .iqrpg-market-data-table-container { max-height: 400px; overflow-y: auto; border: 1px solid rgb(51, 51, 51); margin-top: 0; } .iqrpg-market-data-filter { width: 100%; padding: 8px; background: rgb(20, 20, 20); border: 1px solid rgb(51, 51, 51); border-radius: 0px; color: rgb(255, 255, 255); font-size: 12px; font-family: Verdana, Arial, sans-serif; margin-bottom: 10px; box-sizing: border-box; } .iqrpg-market-data-filter:focus { outline: none; border-color: rgb(34, 136, 34); } `; document.head.appendChild(style); }, findButtonContainer() { // Find .fixed-top first (this is the actual header bar) const fixedTop = document.querySelector(CONSTANTS.SELECTORS.FIXED_TOP); if (!fixedTop) { // DOM not ready yet - return null to trigger retry return null; } // Find .section-3 inside .fixed-top (this is where Premium Store is) const section3 = fixedTop.querySelector(CONSTANTS.SELECTORS.SECTION_3); if (!section3) { // section-3 not ready yet - return null to trigger retry return null; } // Find the "Premium Store" element - it's in a <p> with an <a> tag // We want to insert before the <p> element that contains "Premium Store" let premiumStore = null; // First, try to find the <p> element containing "Premium Store" const paragraphs = section3.querySelectorAll('p'); for (const p of paragraphs) { const text = (p.textContent || p.innerText || '').trim().toLowerCase(); if (text.includes(CONSTANTS.STRINGS.PREMIUM_STORE)) { premiumStore = p; break; } } // If not found in <p>, try <a> tags and get their parent <p> if (!premiumStore) { const links = section3.querySelectorAll('a'); for (const a of links) { const text = (a.textContent || a.innerText || '').trim().toLowerCase(); if (text.includes(CONSTANTS.STRINGS.PREMIUM_STORE)) { // Find the parent <p> element let parent = a.parentElement; while (parent && parent !== section3 && parent.tagName !== 'P') { parent = parent.parentElement; } if (parent && parent.tagName === 'P') { premiumStore = parent; } else { premiumStore = a; } break; } } } // If Premium Store not found yet, return null to trigger retry if (!premiumStore) { return null; } return { premiumStore: premiumStore, insertParent: section3 }; }, createSettingsButton() { if (this.settingsButton) return; const button = document.createElement('button'); button.className = 'iqrpg-settings-btn'; button.innerHTML = '⚙️'; button.title = 'IQRPG Enhanced Settings'; button.setAttribute('aria-label', 'Open IQRPG Enhanced Settings'); button.addEventListener('click', (e) => { e.stopPropagation(); this.openModal(); }); // Try to create button with retry logic (DOM might not be fully loaded) let retryCount = 0; const maxRetries = 10; const tryCreateButton = () => { // Skip if already successfully placed if (this.settingsButton) return true; const container = this.findButtonContainer(); // Container not ready yet - retry if (!container) { retryCount++; if (retryCount < maxRetries) { setTimeout(tryCreateButton, CONSTANTS.DELAYS.RETRY_SHORT); } return false; } try { // Insert before Premium Store element in section-3 container.insertParent.insertBefore(button, container.premiumStore); this.settingsButton = button; return true; } catch (e) { retryCount++; if (retryCount < maxRetries) { setTimeout(tryCreateButton, CONSTANTS.DELAYS.RETRY_SHORT); } return false; } }; tryCreateButton(); }, createModal() { if (this.modal) return; // Overlay const overlay = document.createElement('div'); overlay.className = 'iqrpg-modal-overlay'; overlay.addEventListener('click', (e) => { if (e.target === overlay) { this.closeModal(); } }); // Modal const modal = document.createElement('div'); modal.className = 'iqrpg-modal'; // Header const header = document.createElement('div'); header.className = 'iqrpg-modal-header'; header.innerHTML = ` <h2 class="iqrpg-modal-title">IQRPG Enhanced Settings</h2> <button class="iqrpg-modal-close" aria-label="Close">×</button> `; const closeBtn = header.querySelector('.iqrpg-modal-close'); if (closeBtn) { closeBtn.addEventListener('click', () => this.closeModal()); } // Navigation Bar const navBar = document.createElement('div'); navBar.className = 'iqrpg-modal-nav'; navBar.innerHTML = ` <div class="iqrpg-nav-buttons"> ${Object.entries(this.sectionNames).map(([type, name]) => `<button class="iqrpg-nav-btn" data-section="${type}" title="${name}">${name}</button>` ).join('')} </div> `; // Add ESC key listener once in createModal this.escKeyHandler = (e) => { if (e.key === 'Escape' && this.modalOverlay && this.modalOverlay.classList.contains('active')) { this.closeModal(); } }; document.addEventListener('keydown', this.escKeyHandler); // Body const body = document.createElement('div'); body.className = 'iqrpg-modal-body'; body.innerHTML = this.generateSettingsHTML(); // Footer (sticky action buttons) const footer = document.createElement('div'); footer.className = 'iqrpg-modal-footer'; footer.innerHTML = ` <div class="iqrpg-button-group"> <button class="iqrpg-button iqrpg-button-primary" id="iqrpg-save-btn">Save Settings</button> <button class="iqrpg-button iqrpg-button-secondary" id="iqrpg-reset-btn">Reset to Defaults</button> <button class="iqrpg-button iqrpg-button-secondary" id="iqrpg-toggle-sound-btn">Toggle All Sound Alerts</button> <button class="iqrpg-button iqrpg-button-secondary" id="iqrpg-toggle-notifications-btn">Toggle All Notifications</button> <button class="iqrpg-button iqrpg-button-secondary" id="iqrpg-market-data-btn">Market Data</button> </div> `; modal.appendChild(header); modal.appendChild(navBar); modal.appendChild(body); modal.appendChild(footer); overlay.appendChild(modal); document.body.appendChild(overlay); this.modalOverlay = overlay; this.modal = modal; // Attach event listeners this.attachEventListeners(); }, generateNotificationSection(type, label) { const config = CONFIG.notifications[type]; // Map notification type to sound key const soundKeyMap = { 'globalEvents': CONSTANTS.STRINGS.SOUND_GLOBAL, 'actionBonus': CONSTANTS.STRINGS.SOUND_ACTION_BONUS, 'tradeAlert': CONSTANTS.STRINGS.SOUND_TRADE_ALERT, 'gatheringEvents': CONSTANTS.STRINGS.SOUND_GATHERING_EVENT, 'itemDrop': CONSTANTS.STRINGS.SOUND_ITEM_DROP, 'message': CONSTANTS.STRINGS.SOUND_MESSAGE, 'abyssBattles': CONSTANTS.STRINGS.SOUND_ABYSS_BATTLES, 'potions': CONSTANTS.STRINGS.SOUND_POTIONS, 'marketSale': CONSTANTS.STRINGS.SOUND_MARKET_SALE, 'itemReceived': CONSTANTS.STRINGS.SOUND_ITEM_RECEIVED, 'itemSent': CONSTANTS.STRINGS.SOUND_ITEM_SENT, 'goldReceived': CONSTANTS.STRINGS.SOUND_GOLD_RECEIVED, 'goldSent': CONSTANTS.STRINGS.SOUND_GOLD_SENT }; const soundKey = soundKeyMap[type] || type; return ` <div class="iqrpg-section" id="section-${type}" data-section-type="${type}"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">${label} Notifications</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}" data-setting="notifications.${type}.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}" data-setting="notifications.${type}.desktop"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${soundKey}" value="${CONFIG.sounds[soundKey] || ''}" placeholder="Leave empty for default"> </div> </div> </div> `; }, generateBossSection() { const config = CONFIG.notifications.bossSpawn; const bossSpawnSoundKey = CONSTANTS.STRINGS.SOUND_BOSS_SPAWN; return ` <div class="iqrpg-section" id="section-bossSpawn" data-section-type="bossSpawn"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Bosses Notifications</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}" data-setting="notifications.bossSpawn.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}" data-setting="notifications.bossSpawn.desktop"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Boss Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${bossSpawnSoundKey}" value="${CONFIG.sounds[bossSpawnSoundKey] || ''}" placeholder="Leave empty for default"> </div> </div> </div> `; }, generateClanSection() { const config = CONFIG.notifications.clan; const watchtowerSoundKey = CONSTANTS.STRINGS.SOUND_CLAN_WATCHTOWER; const globalsSoundKey = CONSTANTS.STRINGS.SOUND_CLAN_GLOBALS; return ` <div class="iqrpg-section" id="section-clan" data-section-type="clan"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Clan</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}" data-setting="notifications.clan.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}" data-setting="notifications.clan.desktop"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Watchtower</label> <div class="iqrpg-toggle-switch ${config.watchtower ? 'active' : ''}" data-setting="notifications.clan.watchtower"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Watchtower Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${watchtowerSoundKey}" value="${CONFIG.sounds[watchtowerSoundKey] || ''}" placeholder="Leave empty for default"> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Clan Chat Globals</label> <div class="iqrpg-toggle-switch ${config.clanChatGlobals ? 'active' : ''}" data-setting="notifications.clan.clanChatGlobals"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Clan Globals Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${globalsSoundKey}" value="${CONFIG.sounds[globalsSoundKey] || ''}" placeholder="Leave empty for default"> </div> </div> </div> `; }, generateItemDropSection() { const config = CONFIG.notifications.itemDrop; const soundKey = CONSTANTS.STRINGS.SOUND_ITEM_DROP; const itemKeywords = (config.itemKeywords || []).join(', '); return ` <div class="iqrpg-section" id="section-itemDrop" data-section-type="itemDrop"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Log (Item Drops)</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}" data-setting="notifications.itemDrop.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}" data-setting="notifications.itemDrop.desktop"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Item Keywords (comma-separated)</label> <input type="text" class="iqrpg-input" data-setting="notifications.itemDrop.itemKeywords" data-type="array" value="${itemKeywords}" placeholder="e.g. golden egg, diamond, malachite"> <small style="color: rgb(136, 136, 136); font-size: 12px; display: block; margin-top: 5px;"> Alert when these items are found in the log panel (case-insensitive) </small> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${soundKey}" value="${CONFIG.sounds[soundKey] || ''}" placeholder="Leave empty for default"> </div> </div> </div> `; }, generateGatheringEventsSection() { const woodcutting = CONFIG.notifications.gatheringEvents.woodcutting; const quarrying = CONFIG.notifications.gatheringEvents.quarrying; const mining = CONFIG.notifications.gatheringEvents.mining; const soundKey = CONSTANTS.STRINGS.SOUND_GATHERING_EVENT; return ` <div class="iqrpg-section" id="section-gatheringEvents" data-section-type="gatheringEvents"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Gathering</h3> </div> <div class="iqrpg-section-content"> <!-- Woodcutting Event --> <div style="margin-bottom: 20px; padding: 15px; background: rgb(10, 10, 10); border-radius: 0px; border: 1px solid rgb(51, 51, 51);"> <h4 style="margin: 0 0 10px 0; color: rgb(204, 204, 204); font-size: 16px;">🌲 Woodcutting Event</h4> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${woodcutting.sound ? 'active' : ''}" data-setting="notifications.gatheringEvents.woodcutting.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${woodcutting.desktop ? 'active' : ''}" data-setting="notifications.gatheringEvents.woodcutting.desktop"></div> </div> </div> <!-- Quarrying Event --> <div style="margin-bottom: 20px; padding: 15px; background: rgb(10, 10, 10); border-radius: 0px; border: 1px solid rgb(51, 51, 51);"> <h4 style="margin: 0 0 10px 0; color: rgb(204, 204, 204); font-size: 16px;">🪨 Quarrying Event</h4> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${quarrying.sound ? 'active' : ''}" data-setting="notifications.gatheringEvents.quarrying.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${quarrying.desktop ? 'active' : ''}" data-setting="notifications.gatheringEvents.quarrying.desktop"></div> </div> </div> <!-- Mining Event --> <div style="margin-bottom: 20px; padding: 15px; background: rgb(10, 10, 10); border-radius: 0px; border: 1px solid rgb(51, 51, 51);"> <h4 style="margin: 0 0 10px 0; color: rgb(204, 204, 204); font-size: 16px;">☄️ Mining Event</h4> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${mining.sound ? 'active' : ''}" data-setting="notifications.gatheringEvents.mining.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${mining.desktop ? 'active' : ''}" data-setting="notifications.gatheringEvents.mining.desktop"></div> </div> </div> <!-- Sound URL (shared for all gathering events) --> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${soundKey}" value="${CONFIG.sounds[soundKey] || ''}" placeholder="Leave empty for default"> </div> </div> </div> `; }, generateMarketSection() { const marketSaleConfig = CONFIG.notifications.marketSale; const itemReceivedConfig = CONFIG.notifications.itemReceived; const itemSentConfig = CONFIG.notifications.itemSent; const marketSaleSoundKey = CONSTANTS.STRINGS.SOUND_MARKET_SALE; const itemReceivedSoundKey = CONSTANTS.STRINGS.SOUND_ITEM_RECEIVED; const itemSentSoundKey = CONSTANTS.STRINGS.SOUND_ITEM_SENT; return ` <div class="iqrpg-section" id="section-marketSale" data-section-type="marketSale"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Market Notifications</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Market Sale - Sound Alerts</label> <div class="iqrpg-toggle-switch ${marketSaleConfig.sound ? 'active' : ''}" data-setting="notifications.marketSale.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Market Sale - Desktop Notifications</label> <div class="iqrpg-toggle-switch ${marketSaleConfig.desktop ? 'active' : ''}" data-setting="notifications.marketSale.desktop"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Market Sale Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${marketSaleSoundKey}" value="${CONFIG.sounds[marketSaleSoundKey] || ''}" placeholder="Leave empty for default"> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Item Received - Sound Alerts</label> <div class="iqrpg-toggle-switch ${itemReceivedConfig.sound ? 'active' : ''}" data-setting="notifications.itemReceived.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Item Received - Desktop Notifications</label> <div class="iqrpg-toggle-switch ${itemReceivedConfig.desktop ? 'active' : ''}" data-setting="notifications.itemReceived.desktop"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Item Received Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${itemReceivedSoundKey}" value="${CONFIG.sounds[itemReceivedSoundKey] || ''}" placeholder="Leave empty for default"> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Gold Received Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.goldReceived" value="${CONFIG.sounds.goldReceived || ''}" placeholder="Leave empty for default"> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Item Sent - Sound Alerts</label> <div class="iqrpg-toggle-switch ${itemSentConfig.sound ? 'active' : ''}" data-setting="notifications.itemSent.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Item Sent - Desktop Notifications</label> <div class="iqrpg-toggle-switch ${itemSentConfig.desktop ? 'active' : ''}" data-setting="notifications.itemSent.desktop"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Item Sent Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${itemSentSoundKey}" value="${CONFIG.sounds[itemSentSoundKey] || ''}" placeholder="Leave empty for default"> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Gold Sent Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.goldSent" value="${CONFIG.sounds.goldSent || ''}" placeholder="Leave empty for default"> </div> </div> </div> `; }, generateTradeAlertSection() { const config = CONFIG.notifications.tradeAlert; const soundKey = CONSTANTS.STRINGS.SOUND_TRADE_ALERT; const sellingKeywords = (config.sellingKeywords || []).join(', '); const buyingKeywords = (config.buyingKeywords || []).join(', '); return ` <div class="iqrpg-section" id="section-tradeAlert" data-section-type="tradeAlert"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Trade</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}" data-setting="notifications.tradeAlert.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}" data-setting="notifications.tradeAlert.desktop"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Selling Keywords (comma-separated)</label> <input type="text" class="iqrpg-input" data-setting="notifications.tradeAlert.sellingKeywords" data-type="array" value="${sellingKeywords}" placeholder="e.g. iq, mana, qs2"> <small style="color: rgb(136, 136, 136); font-size: 12px; display: block; margin-top: 5px;"> Alert when someone is selling these items (detects: selling, wts, sell, s>) </small> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Buying Keywords (comma-separated)</label> <input type="text" class="iqrpg-input" data-setting="notifications.tradeAlert.buyingKeywords" data-type="array" value="${buyingKeywords}" placeholder="e.g. iq, mana, qs2"> <small style="color: rgb(136, 136, 136); font-size: 12px; display: block; margin-top: 5px;"> Alert when someone is buying these items (detects: buying, wtb, buy, b>) </small> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${soundKey}" value="${CONFIG.sounds[soundKey] || ''}" placeholder="Leave empty for default"> </div> </div> </div> `; }, generateAutosSection() { const config = CONFIG.notifications.autos; const soundKey = CONSTANTS.STRINGS.SOUND_AUTOS; const threshold = config.threshold || 100; const repeatCount = config.repeatCount || 1; return ` <div class="iqrpg-section" id="section-autos" data-section-type="autos"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Autos Alert</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}" data-setting="notifications.autos.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}" data-setting="notifications.autos.desktop"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Alert Threshold</label> <input type="number" class="iqrpg-input" data-setting="notifications.autos.threshold" value="${threshold}" min="0" placeholder="100"> <small style="color: rgb(136, 136, 136); font-size: 12px; display: block; margin-top: 5px;"> Alert when autos remaining reaches this number or below </small> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Repeat Count</label> <input type="number" class="iqrpg-input" data-setting="notifications.autos.repeatCount" value="${repeatCount}" min="1" placeholder="1"> <small style="color: rgb(136, 136, 136); font-size: 12px; display: block; margin-top: 5px;"> Number of times to repeat alert while autos stay under threshold (1 = no repeat) </small> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Repeat Interval (seconds)</label> <input type="number" class="iqrpg-input" data-setting="notifications.autos.repeatInterval" value="${config.repeatInterval !== undefined ? config.repeatInterval : 1}" min="0" placeholder="1"> <small style="color: rgb(136, 136, 136); font-size: 12px; display: block; margin-top: 5px;"> Seconds between repeat alerts (0 = immediate repeats, no delay) </small> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${soundKey}" value="${CONFIG.sounds[soundKey] || ''}" placeholder="Leave empty for default"> </div> </div> </div> `; }, generatePotionSection() { const config = CONFIG.notifications.potions; const soundKey = CONSTANTS.STRINGS.SOUND_POTIONS; const threshold = config.threshold || 100; const repeatCount = config.repeatCount || 1; return ` <div class="iqrpg-section" id="section-potions" data-section-type="potions"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Potion Alert</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}" data-setting="notifications.potions.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}" data-setting="notifications.potions.desktop"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Alert Threshold</label> <input type="number" class="iqrpg-input" data-setting="notifications.potions.threshold" value="${threshold}" min="0" placeholder="100"> <small style="color: rgb(136, 136, 136); font-size: 12px; display: block; margin-top: 5px;"> Alert when potion remaining reaches this number or below </small> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Repeat Count</label> <input type="number" class="iqrpg-input" data-setting="notifications.potions.repeatCount" value="${repeatCount}" min="1" placeholder="1"> <small style="color: rgb(136, 136, 136); font-size: 12px; display: block; margin-top: 5px;"> Number of times to repeat alert while potion stays under threshold (1 = no repeat) </small> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Repeat Interval (seconds)</label> <input type="number" class="iqrpg-input" data-setting="notifications.potions.repeatInterval" value="${config.repeatInterval !== undefined ? config.repeatInterval : 1}" min="0" placeholder="1"> <small style="color: rgb(136, 136, 136); font-size: 12px; display: block; margin-top: 5px;"> Seconds between repeat alerts (0 = immediate repeats, no delay) </small> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${soundKey}" value="${CONFIG.sounds[soundKey] || ''}" placeholder="Leave empty for default"> </div> </div> </div> `; }, generateDungeonSection() { const config = CONFIG.notifications.dungeon; const soundKey = CONSTANTS.STRINGS.SOUND_DUNGEON; return ` <div class="iqrpg-section" id="section-dungeon" data-section-type="dungeon"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Dungeon Completion</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}" data-setting="notifications.dungeon.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}" data-setting="notifications.dungeon.desktop"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Only When All Keys Complete</label> <div class="iqrpg-toggle-switch ${config.onlyWhenAllKeysComplete ? 'active' : ''}" data-setting="notifications.dungeon.onlyWhenAllKeysComplete"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${soundKey}" value="${CONFIG.sounds[soundKey] || ''}" placeholder="Leave empty for default"> </div> </div> </div> `; }, generateMasterySection() { const config = CONFIG.notifications.mastery; const soundKey = CONSTANTS.STRINGS.SOUND_MASTERY; return ` <div class="iqrpg-section" id="section-mastery" data-section-type="mastery"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Mastery Level Increases</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}" data-setting="notifications.mastery.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}" data-setting="notifications.mastery.desktop"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${soundKey}" value="${CONFIG.sounds[soundKey] || ''}" placeholder="Leave empty for default"> </div> </div> </div> `; }, generateLandSection() { const config = CONFIG.notifications.land; const soundKey = CONSTANTS.STRINGS.SOUND_LAND; return ` <div class="iqrpg-section" id="section-land" data-section-type="land"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Raid Completion</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}" data-setting="notifications.land.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}" data-setting="notifications.land.desktop"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${soundKey}" value="${CONFIG.sounds[soundKey] || ''}" placeholder="Leave empty for default"> </div> </div> </div> `; }, generateSkillsSection() { const config = CONFIG.notifications.skills; const soundKey = CONSTANTS.STRINGS.SOUND_SKILLS; return ` <div class="iqrpg-section" id="section-skills" data-section-type="skills"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Skill Level Increases</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}" data-setting="notifications.skills.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}" data-setting="notifications.skills.desktop"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${soundKey}" value="${CONFIG.sounds[soundKey] || ''}" placeholder="Leave empty for default"> </div> </div> </div> `; }, generateAbyssBattlesSection() { const config = CONFIG.notifications.abyssBattles; const soundKey = CONSTANTS.STRINGS.SOUND_ABYSS_BATTLES; return ` <div class="iqrpg-section" id="section-abyssBattles" data-section-type="abyssBattles"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Abyss Battles Completion</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Sound Alerts</label> <div class="iqrpg-toggle-switch ${config.sound ? 'active' : ''}" data-setting="notifications.abyssBattles.sound"></div> </div> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Desktop Notifications</label> <div class="iqrpg-toggle-switch ${config.desktop ? 'active' : ''}" data-setting="notifications.abyssBattles.desktop"></div> </div> <div class="iqrpg-input-group"> <label class="iqrpg-input-label">Sound URL (optional)</label> <input type="text" class="iqrpg-input" data-setting="sounds.${soundKey}" value="${CONFIG.sounds[soundKey] || ''}" placeholder="Leave empty for default"> </div> </div> </div> `; }, generateImagesSection() { const config = CONFIG.features.images; return ` <div class="iqrpg-section" id="section-images" data-section-type="images"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Images</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Enable Image Linking & Modals</label> <div class="iqrpg-toggle-switch ${config.enabled ? 'active' : ''}" data-setting="features.images.enabled"></div> </div> <p class="iqrpg-section-description" style="color: rgb(136, 136, 136); font-size: 12px; margin-top: 10px;"> Automatically converts plain text image URLs in chat to clickable links. Click any link to view in a full-screen modal. </p> </div> </div> `; }, generateYouTubeSection() { const config = CONFIG.features.youtube; return ` <div class="iqrpg-section" id="section-youtube" data-section-type="youtube"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">YouTube</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Enable YouTube Linking & Modals</label> <div class="iqrpg-toggle-switch ${config.enabled ? 'active' : ''}" data-setting="features.youtube.enabled"></div> </div> <p class="iqrpg-section-description" style="color: rgb(136, 136, 136); font-size: 12px; margin-top: 10px;"> Automatically converts plain text YouTube URLs in chat to clickable links. Videos play directly in-game without leaving the page. </p> </div> </div> `; }, generateEmojisSection() { const config = CONFIG.features.emojis; return ` <div class="iqrpg-section" id="section-emojis" data-section-type="emojis"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Emojis</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-toggle-group"> <label class="iqrpg-toggle-label">Enable Emoji Rendering & Autocomplete</label> <div class="iqrpg-toggle-switch ${config.enabled ? 'active' : ''}" data-setting="features.emojis.enabled"></div> </div> <p class="iqrpg-section-description" style="color: rgb(136, 136, 136); font-size: 12px; margin-top: 10px;"> Converts emoji shortcodes (e.g., :heart:) to Unicode emojis in chat. Includes Discord-style autocomplete when typing emojis. <br><span style="color: #ffa500; font-weight: bold;">⚠ Requires a page refresh to take effect.</span> </p> </div> </div> `; }, generateSettingsHTML() { const volume = (CONFIG.sounds.volume !== undefined ? CONFIG.sounds.volume : 1.0) * 100; return ` <div class="iqrpg-section" id="section-volume" data-section-type="volume"> <div class="iqrpg-section-header"> <h3 class="iqrpg-section-title">Volume</h3> </div> <div class="iqrpg-section-content"> <div class="iqrpg-volume-group"> <label class="iqrpg-volume-label">Sound Volume</label> <input type="range" class="iqrpg-volume-slider" id="iqrpg-volume-slider" data-setting="sounds.volume" min="0" max="100" value="${volume}" step="1"> <span class="iqrpg-volume-value" id="iqrpg-volume-value">${Math.round(volume)}%</span> </div> </div> </div> ${this.generateAutosSection()} ${this.generateNotificationSection('globalEvents', 'Globals')} ${this.generateBossSection()} ${this.generateGatheringEventsSection()} ${this.generateNotificationSection('actionBonus', 'Action Bonus')} ${this.generateClanSection()} ${this.generateDungeonSection()} ${this.generateLandSection()} ${this.generateMasterySection()} ${this.generateSkillsSection()} ${this.generateMarketSection()} ${this.generateTradeAlertSection()} ${this.generateItemDropSection()} ${this.generateNotificationSection('message', 'Message')} ${this.generateAbyssBattlesSection()} ${this.generatePotionSection()} ${this.generateImagesSection()} ${this.generateYouTubeSection()} ${this.generateEmojisSection()} `; }, attachEventListeners() { if (!this.modal) return; // Use event delegation - attach listeners once to modal container // This works with dynamically regenerated HTML and prevents duplicate listeners // Navigation button click handlers this.modal.addEventListener('click', (e) => { if (e.target.classList.contains('iqrpg-nav-btn')) { const sectionType = e.target.getAttribute('data-section'); const targetSection = this.modal.querySelector(`#section-${sectionType}`); if (targetSection) { targetSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); e.target.style.background = 'rgb(34, 136, 34)'; setTimeout(() => { e.target.style.background = ''; }, 300); } } }); // Toggle switches this.modal.addEventListener('click', (e) => { if (e.target.classList.contains('iqrpg-toggle-switch')) { e.target.classList.toggle('active'); const setting = e.target.getAttribute('data-setting'); this.updateSetting(setting, e.target.classList.contains('active')); } }); // Volume slider this.modal.addEventListener('input', (e) => { if (e.target.id === 'iqrpg-volume-slider') { const volumeValue = this.modal.querySelector('#iqrpg-volume-value'); if (volumeValue) { const value = parseFloat(e.target.value); const volumePercent = Math.round(value); volumeValue.textContent = `${volumePercent}%`; const volumeDecimal = value / 100; this.updateSetting('sounds.volume', volumeDecimal); // Update the filled portion of the slider e.target.style.setProperty('--volume-percent', `${value}%`); } } }); // Input fields this.modal.addEventListener('change', (e) => { if (e.target.classList.contains('iqrpg-input')) { const setting = e.target.getAttribute('data-setting'); const dataType = e.target.getAttribute('data-type'); let value = e.target.value; if (e.target.type === 'number') { value = parseInt(value, 10) || 0; } if (dataType === 'array') { value = value.split(',') .map(v => v.trim()) .filter(v => v.length > 0); } this.updateSetting(setting, value); } }); // Save button this.modal.addEventListener('click', (e) => { if (e.target.id === 'iqrpg-save-btn') { this.saveSettings(); } }); // Reset button this.modal.addEventListener('click', (e) => { if (e.target.id === 'iqrpg-reset-btn') { if (confirm('Reset all settings to defaults? This cannot be undone.')) { this.resetSettings(); } } }); // Toggle all sound alerts button this.modal.addEventListener('click', (e) => { if (e.target.id === 'iqrpg-toggle-sound-btn') { this.toggleAllSoundAlerts(); } }); // Toggle all notifications button this.modal.addEventListener('click', (e) => { if (e.target.id === 'iqrpg-toggle-notifications-btn') { this.toggleAllDesktopNotifications(); } }); // Market Data button this.modal.addEventListener('click', (e) => { if (e.target.id === 'iqrpg-market-data-btn') { this.openMarketDataPanel(); } }); }, updateSetting(path, value) { const keys = path.split('.'); let obj = CONFIG; for (let i = 0; i < keys.length - 1; i++) { if (!obj[keys[i]]) obj[keys[i]] = {}; obj = obj[keys[i]]; } obj[keys[keys.length - 1]] = value; // Remove links when features are toggled off if (path === 'features.images.enabled' && value === false) { ImageModalManager.removeLinks(); } if (path === 'features.youtube.enabled' && value === false) { ImageModalManager.removeLinks(); } // Handle item sent observer initialization/deactivation if (path === 'notifications.itemSent.sound' || path === 'notifications.itemSent.desktop') { if (CONFIG.notifications.itemSent.sound || CONFIG.notifications.itemSent.desktop) { ItemSentObserver.init(); } else { ItemSentObserver.cleanup(); } } }, reloadAudio() { AudioManager.audioCache.clear(); preloadAllSounds(); }, saveSettings() { saveConfig(); // Handle image/YouTube features if (CONFIG.features.images.enabled || CONFIG.features.youtube.enabled) { // Re-initialize if enabled (processes existing messages) ImageModalManager.init(); } else { // Remove links if disabled ImageModalManager.removeLinks(); } // Emojis require page refresh (no dynamic initialization) // Note: We don't initialize emojis here as they require a page refresh // If item keywords exist, mark all current items in log as processed // This prevents alerts for items that were already in the log before keywords were set DOMMonitor.initializeProcessedItemDrops(); this.reloadAudio(); // Show feedback const saveBtn = this.modal?.querySelector('#iqrpg-save-btn'); if (saveBtn) { const originalText = saveBtn.textContent; saveBtn.textContent = 'Saved!'; saveBtn.style.background = '#4caf50'; setTimeout(() => { saveBtn.textContent = originalText; saveBtn.style.background = ''; }, CONSTANTS.DELAYS.SAVE_FEEDBACK); } }, resetSettings() { // Reset to defaults Object.assign(CONFIG, JSON.parse(JSON.stringify(DEFAULT_CONFIG))); saveConfig(); this.reloadAudio(); // Recreate modal to reflect new settings if (this.modalOverlay) { this.modalOverlay.remove(); } this.modal = null; this.modalOverlay = null; this.createModal(); }, toggleAllSoundAlerts() { // Get current state - check if all are enabled or disabled const allNotificationTypes = [ 'globalEvents', 'actionBonus', 'bossSpawn', 'tradeAlert', 'message', 'autos', 'potions', 'dungeon', 'mastery', 'land', 'skills', 'itemDrop', 'abyssBattles', 'clan', 'marketSale', 'itemReceived', 'itemSent' ]; // Check gathering events const gatheringTypes = ['woodcutting', 'quarrying', 'mining']; // Determine if we should enable or disable (if any are enabled, disable all; otherwise enable all) let anyEnabled = false; for (const type of allNotificationTypes) { if (CONFIG.notifications[type]?.sound) { anyEnabled = true; break; } } if (!anyEnabled) { for (const type of gatheringTypes) { if (CONFIG.notifications.gatheringEvents[type]?.sound) { anyEnabled = true; break; } } } const newValue = !anyEnabled; // Toggle all notification types for (const type of allNotificationTypes) { if (CONFIG.notifications[type]) { CONFIG.notifications[type].sound = newValue; } } // Toggle gathering events for (const type of gatheringTypes) { if (CONFIG.notifications.gatheringEvents[type]) { CONFIG.notifications.gatheringEvents[type].sound = newValue; } } // Update UI this.refreshSettingsUI(); }, toggleAllDesktopNotifications() { // Get current state - check if all are enabled or disabled const allNotificationTypes = [ 'globalEvents', 'actionBonus', 'bossSpawn', 'tradeAlert', 'message', 'autos', 'potions', 'dungeon', 'mastery', 'land', 'skills', 'itemDrop', 'abyssBattles', 'clan', 'marketSale', 'itemReceived', 'itemSent' ]; // Check gathering events const gatheringTypes = ['woodcutting', 'quarrying', 'mining']; // Determine if we should enable or disable (if any are enabled, disable all; otherwise enable all) let anyEnabled = false; for (const type of allNotificationTypes) { if (CONFIG.notifications[type]?.desktop) { anyEnabled = true; break; } } if (!anyEnabled) { for (const type of gatheringTypes) { if (CONFIG.notifications.gatheringEvents[type]?.desktop) { anyEnabled = true; break; } } } const newValue = !anyEnabled; // Toggle all notification types for (const type of allNotificationTypes) { if (CONFIG.notifications[type]) { CONFIG.notifications[type].desktop = newValue; } } // Toggle gathering events for (const type of gatheringTypes) { if (CONFIG.notifications.gatheringEvents[type]) { CONFIG.notifications.gatheringEvents[type].desktop = newValue; } } // Update UI this.refreshSettingsUI(); }, refreshSettingsUI() { // Regenerate settings HTML to reflect current config const body = this.modal.querySelector('.iqrpg-modal-body'); if (body) { body.innerHTML = this.generateSettingsHTML(); // Re-initialize volume slider CSS variable const volumeSlider = this.modal.querySelector('#iqrpg-volume-slider'); if (volumeSlider) { const currentVolume = parseFloat(volumeSlider.value); volumeSlider.style.setProperty('--volume-percent', `${currentVolume}%`); } } }, openModal() { if (this.modalOverlay) { this.modalOverlay.classList.add('active'); // Update modal content with current config const body = this.modal.querySelector('.iqrpg-modal-body'); body.innerHTML = this.generateSettingsHTML(); // Initialize volume slider CSS variable const volumeSlider = this.modal.querySelector('#iqrpg-volume-slider'); if (volumeSlider) { const currentVolume = parseFloat(volumeSlider.value); volumeSlider.style.setProperty('--volume-percent', `${currentVolume}%`); } // No need to re-attach listeners - event delegation handles dynamic content } }, createMarketDataPanel() { // Create panel if it doesn't exist if (document.getElementById('iqrpg-market-data-panel')) return; const panel = document.createElement('div'); panel.id = 'iqrpg-market-data-panel'; panel.className = 'iqrpg-market-data-panel'; // Header const header = document.createElement('div'); header.className = 'iqrpg-market-data-panel-header'; header.innerHTML = ` <h3 class="iqrpg-market-data-panel-title">Market Data</h3> <button class="iqrpg-market-data-panel-close" aria-label="Close">×</button> `; // Content const content = document.createElement('div'); content.className = 'iqrpg-market-data-content'; panel.appendChild(header); panel.appendChild(content); document.body.appendChild(panel); // Close button handler const closeBtn = header.querySelector('.iqrpg-market-data-panel-close'); closeBtn.addEventListener('click', () => { this.closeMarketDataPanel(); }); // ESC key handler const escHandler = (e) => { if (e.key === 'Escape' && panel.classList.contains('active')) { this.closeMarketDataPanel(); } }; document.addEventListener('keydown', escHandler); // Store handler for cleanup panel._escHandler = escHandler; }, openMarketDataPanel() { const panel = document.getElementById('iqrpg-market-data-panel'); if (!panel) { this.createMarketDataPanel(); // Wait for panel to be created, then open setTimeout(() => this.openMarketDataPanel(), 50); return; } // Update content this.updateMarketDataPanel(); // Show panel panel.classList.add('active'); // Prevent body scroll when panel is open document.body.style.overflow = 'hidden'; }, closeMarketDataPanel() { const panel = document.getElementById('iqrpg-market-data-panel'); if (panel) { panel.classList.remove('active'); document.body.style.overflow = ''; } }, updateMarketDataPanel() { const panel = document.getElementById('iqrpg-market-data-panel'); if (!panel) return; const content = panel.querySelector('.iqrpg-market-data-content'); if (!content) return; // Cache data to avoid multiple loadData() calls const stats = MarketDataTracker.getStats(); // Format gold with commas const formatGold = (gold) => gold.toLocaleString(); let html = ` <div class="iqrpg-market-data-summary-grid"> <div class="iqrpg-market-data-summary"> <div class="iqrpg-market-data-summary-title">Market Sales</div> <div class="iqrpg-market-data-summary-value">${formatGold(stats.totalGold || 0)}</div> </div> <div class="iqrpg-market-data-summary"> <div class="iqrpg-market-data-summary-title">Gold Received</div> <div class="iqrpg-market-data-summary-value">${formatGold(stats.totalGoldReceived || 0)}</div> </div> <div class="iqrpg-market-data-summary"> <div class="iqrpg-market-data-summary-title">Taxes Paid</div> <div class="iqrpg-market-data-summary-value" style="color: rgb(204, 68, 68);">${formatGold((stats.totalTax || 0) + (stats.totalTaxFromReceived || 0))}</div> </div> <div class="iqrpg-market-data-summary"> <div class="iqrpg-market-data-summary-title">Gold Sent</div> <div class="iqrpg-market-data-summary-value">${formatGold(MarketDataTracker.getTotalGoldSent(stats))}</div> </div> </div> <div style="margin-top: 30px; margin-bottom: 15px;"> <h4 style="color: rgb(204, 204, 204); font-size: 16px; font-weight: bold; font-family: Verdana, Arial, sans-serif; margin: 0 0 10px 0;">Market Sales</h4> <input type="text" class="iqrpg-market-data-filter" id="iqrpg-filter-sales" placeholder="Filter by item name..."> </div> <div class="iqrpg-market-data-table-container"> <table class="iqrpg-market-data-table" id="iqrpg-table-sales"> <thead> <tr> <th data-sort="item" data-table="sales">Item</th> <th data-sort="quantity" data-table="sales">Quantity</th> <th data-sort="gold" data-table="sales">Gold</th> <th data-sort="tax" data-table="sales">Tax</th> </tr> </thead> <tbody id="iqrpg-table-sales-body"> </tbody> </table> </div> <div style="margin-top: 30px; margin-bottom: 15px;"> <h4 style="color: rgb(204, 204, 204); font-size: 16px; font-weight: bold; font-family: Verdana, Arial, sans-serif; margin: 0 0 10px 0;">Items Received</h4> <input type="text" class="iqrpg-market-data-filter" id="iqrpg-filter-received" placeholder="Filter by item or person name..."> </div> <div class="iqrpg-market-data-table-container"> <table class="iqrpg-market-data-table" id="iqrpg-table-received"> <thead> <tr> <th data-sort="item" data-table="received">Item</th> <th data-sort="amount" data-table="received">Amount</th> <th data-sort="person" data-table="received">From</th> </tr> </thead> <tbody id="iqrpg-table-received-body"> </tbody> </table> </div> <div style="margin-top: 30px; margin-bottom: 15px;"> <h4 style="color: rgb(204, 204, 204); font-size: 16px; font-weight: bold; font-family: Verdana, Arial, sans-serif; margin: 0 0 10px 0;">Items Sent</h4> <input type="text" class="iqrpg-market-data-filter" id="iqrpg-filter-sent" placeholder="Filter by item or person name..."> </div> <div class="iqrpg-market-data-table-container"> <table class="iqrpg-market-data-table" id="iqrpg-table-sent"> <thead> <tr> <th data-sort="item" data-table="sent">Item</th> <th data-sort="amount" data-table="sent">Amount</th> <th data-sort="person" data-table="sent">To</th> </tr> </thead> <tbody id="iqrpg-table-sent-body"> </tbody> </table> </div> `; content.innerHTML = html; // Populate tables and attach event listeners this.populateSalesTable(); this.populateReceivedTable(); this.populateSentTable(); this.attachTableListeners(); }, populateSalesTable(filterText = '', data = null) { const tbody = document.getElementById('iqrpg-table-sales-body'); if (!tbody) return; let rows = MarketDataTracker.getMarketSalesRows(data); // Apply filter if (filterText) { const filter = filterText.toLowerCase(); rows = rows.filter(row => row.item.toLowerCase().includes(filter) ); } if (rows.length === 0) { tbody.innerHTML = ` <tr> <td colspan="4" style="text-align: center; color: rgb(136, 136, 136); padding: 20px;"> ${filterText ? 'No matching sales found.' : 'No market sales recorded yet.'} </td> </tr> `; return; } // Sort by current sort state (default: item name) const sortState = this.salesTableSort || { column: 'item', direction: 'asc' }; rows.sort((a, b) => { let aVal = a[sortState.column]; let bVal = b[sortState.column]; // Handle numeric sorting for quantity, gold, tax if (sortState.column === 'quantity' || sortState.column === 'gold' || sortState.column === 'tax') { return sortState.direction === 'asc' ? aVal - bVal : bVal - aVal; } // Handle string sorting aVal = String(aVal).toLowerCase(); bVal = String(bVal).toLowerCase(); if (sortState.direction === 'asc') { return aVal.localeCompare(bVal); } else { return bVal.localeCompare(aVal); } }); // Render rows tbody.innerHTML = rows.map(row => ` <tr> <td>${row.item}</td> <td style="text-align: right;">${row.quantity.toLocaleString()}</td> <td style="text-align: right; color: rgb(34, 136, 34);">${row.gold.toLocaleString()}</td> <td style="text-align: right; color: rgb(204, 68, 68);">${row.tax.toLocaleString()}</td> </tr> `).join(''); }, populateReceivedTable(filterText = '', data = null) { const tbody = document.getElementById('iqrpg-table-received-body'); if (!tbody) return; let rows = MarketDataTracker.getReceivedItemsRows(data); // Apply filter if (filterText) { const filter = filterText.toLowerCase(); rows = rows.filter(row => row.item.toLowerCase().includes(filter) || row.person.toLowerCase().includes(filter) ); } if (rows.length === 0) { tbody.innerHTML = ` <tr> <td colspan="3" style="text-align: center; color: rgb(136, 136, 136); padding: 20px;"> ${filterText ? 'No matching transactions found.' : 'No items received yet.'} </td> </tr> `; return; } // Sort by current sort state (default: item name) const sortState = this.receivedTableSort || { column: 'item', direction: 'asc' }; rows.sort((a, b) => { let aVal = a[sortState.column]; let bVal = b[sortState.column]; // Handle numeric sorting for amount if (sortState.column === 'amount') { return sortState.direction === 'asc' ? aVal - bVal : bVal - aVal; } // Handle string sorting aVal = String(aVal).toLowerCase(); bVal = String(bVal).toLowerCase(); if (sortState.direction === 'asc') { return aVal.localeCompare(bVal); } else { return bVal.localeCompare(aVal); } }); // Render rows tbody.innerHTML = rows.map(row => ` <tr> <td>${row.item}</td> <td style="text-align: right;">${row.amount.toLocaleString()}</td> <td>${row.person}</td> </tr> `).join(''); }, populateSentTable(filterText = '', data = null) { const tbody = document.getElementById('iqrpg-table-sent-body'); if (!tbody) return; let rows = MarketDataTracker.getSentItemsRows(data); // Apply filter if (filterText) { const filter = filterText.toLowerCase(); rows = rows.filter(row => row.item.toLowerCase().includes(filter) || row.person.toLowerCase().includes(filter) ); } if (rows.length === 0) { tbody.innerHTML = ` <tr> <td colspan="3" style="text-align: center; color: rgb(136, 136, 136); padding: 20px;"> ${filterText ? 'No matching transactions found.' : 'No items sent yet.'} </td> </tr> `; return; } // Sort by current sort state (default: item name) const sortState = this.sentTableSort || { column: 'item', direction: 'asc' }; rows.sort((a, b) => { let aVal = a[sortState.column]; let bVal = b[sortState.column]; // Handle numeric sorting for amount if (sortState.column === 'amount') { return sortState.direction === 'asc' ? aVal - bVal : bVal - aVal; } // Handle string sorting aVal = String(aVal).toLowerCase(); bVal = String(bVal).toLowerCase(); if (sortState.direction === 'asc') { return aVal.localeCompare(bVal); } else { return bVal.localeCompare(aVal); } }); // Render rows tbody.innerHTML = rows.map(row => ` <tr> <td>${row.item}</td> <td style="text-align: right;">${row.amount.toLocaleString()}</td> <td>${row.person}</td> </tr> `).join(''); }, attachTableListeners() { const panel = document.getElementById('iqrpg-market-data-panel'); if (!panel) return; // Initialize sort states if (!this.receivedTableSort) { this.receivedTableSort = { column: 'item', direction: 'asc' }; } if (!this.sentTableSort) { this.sentTableSort = { column: 'item', direction: 'asc' }; } if (!this.salesTableSort) { this.salesTableSort = { column: 'item', direction: 'asc' }; } // Column header click handlers for sorting panel.addEventListener('click', (e) => { if (e.target.tagName === 'TH' && e.target.hasAttribute('data-sort')) { const column = e.target.getAttribute('data-sort'); const tableType = e.target.getAttribute('data-table'); if (tableType === 'received') { // Toggle sort direction if same column, otherwise set to asc if (this.receivedTableSort.column === column) { this.receivedTableSort.direction = this.receivedTableSort.direction === 'asc' ? 'desc' : 'asc'; } else { this.receivedTableSort = { column, direction: 'asc' }; } // Update header classes const headers = panel.querySelectorAll('#iqrpg-table-received th'); headers.forEach(th => { th.classList.remove('sort-asc', 'sort-desc'); if (th.getAttribute('data-sort') === column) { th.classList.add(this.receivedTableSort.direction === 'asc' ? 'sort-asc' : 'sort-desc'); } }); this.populateReceivedTable(document.getElementById('iqrpg-filter-received')?.value || '', null); } else if (tableType === 'sent') { // Toggle sort direction if same column, otherwise set to asc if (this.sentTableSort.column === column) { this.sentTableSort.direction = this.sentTableSort.direction === 'asc' ? 'desc' : 'asc'; } else { this.sentTableSort = { column, direction: 'asc' }; } // Update header classes const headers = panel.querySelectorAll('#iqrpg-table-sent th'); headers.forEach(th => { th.classList.remove('sort-asc', 'sort-desc'); if (th.getAttribute('data-sort') === column) { th.classList.add(this.sentTableSort.direction === 'asc' ? 'sort-asc' : 'sort-desc'); } }); this.populateSentTable(document.getElementById('iqrpg-filter-sent')?.value || '', null); } else if (tableType === 'sales') { // Toggle sort direction if same column, otherwise set to asc if (this.salesTableSort.column === column) { this.salesTableSort.direction = this.salesTableSort.direction === 'asc' ? 'desc' : 'asc'; } else { this.salesTableSort = { column, direction: 'asc' }; } // Update header classes const headers = panel.querySelectorAll('#iqrpg-table-sales th'); headers.forEach(th => { th.classList.remove('sort-asc', 'sort-desc'); if (th.getAttribute('data-sort') === column) { th.classList.add(this.salesTableSort.direction === 'asc' ? 'sort-asc' : 'sort-desc'); } }); this.populateSalesTable(document.getElementById('iqrpg-filter-sales')?.value || ''); } } }); // Filter input handlers with debouncing (300ms delay) const receivedFilter = panel.querySelector('#iqrpg-filter-received'); const sentFilter = panel.querySelector('#iqrpg-filter-sent'); const salesFilter = panel.querySelector('#iqrpg-filter-sales'); // Debounce helper const debounce = (func, wait) => { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }; if (receivedFilter) { receivedFilter.addEventListener('input', debounce((e) => { this.populateReceivedTable(e.target.value); }, 300)); } if (sentFilter) { sentFilter.addEventListener('input', debounce((e) => { this.populateSentTable(e.target.value); }, 300)); } if (salesFilter) { salesFilter.addEventListener('input', debounce((e) => { this.populateSalesTable(e.target.value); }, 300)); } }, closeModal() { if (this.modalOverlay) { this.modalOverlay.classList.remove('active'); } // Also close market data panel if open this.closeMarketDataPanel(); }, cleanup() { if (this.escKeyHandler) { document.removeEventListener('keydown', this.escKeyHandler); this.escKeyHandler = null; } } }; // ============================================ // Initialization // ============================================ function init() { // Initialize WebSocket interception WebSocketInterceptor.init(); // Initialize GUI GUIManager.init(); // Initialize DOM monitoring DOMMonitor.init(); // Initialize image modal manager (only if enabled) if (CONFIG.features.images.enabled || CONFIG.features.youtube.enabled) { ImageModalManager.init(); } // Initialize emoji converter (only if enabled) if (CONFIG.features.emojis.enabled) { EmojiConverter.init(); EmojiAutocomplete.init(); } // Initialize item sent observer (only if enabled) if (CONFIG.notifications.itemSent.sound || CONFIG.notifications.itemSent.desktop) { ItemSentObserver.init(); } } // Start initialization init(); })();