// What's That!? - Content Script console.log("What's That!?: Content script loaded!"); console.log('Document URL:', document.URL); console.log('Document readyState:', document.readyState); // Create a debug panel that doesn't require opening DevTools window.showDebugPanel = function() { // Remove existing panel if any const existing = document.getElementById('whatsapp-analyzer-debug'); if (existing) existing.remove(); const panel = document.createElement('div'); panel.id = 'whatsapp-analyzer-debug'; panel.style.cssText = ` position: fixed; top: 10px; right: 10px; width: 350px; max-height: 80vh; background: white; border: 2px solid #25d366; border-radius: 8px; padding: 16px; z-index: 999999; overflow-y: auto; box-shadow: 0 4px 20px rgba(0,0,0,0.3); font-family: monospace; font-size: 11px; `; const tracker = window.tracker; let html = '
âÂÂÂÅ’ Tracker not initialized!
'; } else { html += `✅ Tracker active
`; html += `Messages tracked: ${tracker.reactionData.size}
`; const chats = tracker.getAvailableChats(); html += `Chats found: ${chats.length}
`; // Show unique senders const senders = new Set(); tracker.reactionData.forEach(data => senders.add(data.sender)); html += `Unique senders: ${senders.size}
`; html += 'Total reactions: ${totalReactions}
`; } html += ``; panel.innerHTML = html; document.body.appendChild(panel); }; console.log('💡 Tip: Run window.showDebugPanel() to see stats without opening DevTools!'); class WhatsAppReactionTracker { constructor() { this.reactionData = new Map(); this.currentChat = { id: 'unknown', name: 'Unknown Chat' }; this._scanTimer = null; this.init(); } init() { console.log("What's That!?: Initializing..."); // Wait for WhatsApp to be ready with multiple checks this.waitForWhatsApp(); } waitForWhatsApp(attempts = 0) { // Check if extension context is still valid if (!chrome || !chrome.runtime || !chrome.runtime.id) { console.log("What's That!?: Extension context invalidated, stopping"); return; } console.log(`What's That!?: Checking if WhatsApp is ready (attempt ${attempts + 1}/10)...`); const main = document.querySelector('#main'); const messages = document.querySelectorAll('[data-pre-plain-text]'); console.log(` - #main found: ${!!main}`); console.log(` - Messages found: ${messages.length}`); if (main && messages.length > 0) { console.log("What's That!?: WhatsApp is ready!"); this.scanMessages(); this.setupObserver(); this.setupRealTimeMonitoring(); } else if (attempts < 10) { setTimeout(() => this.waitForWhatsApp(attempts + 1), 2000); } else { console.log("What's That!?: Timeout waiting for WhatsApp. Manual scan may be needed."); console.log('Run: window.tracker.scanMessages() to scan manually'); } } scanMessages() { console.log("What's That!?: Scanning messages..."); const messages = document.querySelectorAll('[data-pre-plain-text]'); console.log(`Found ${messages.length} messages`); // Track unique senders for mock data const uniqueSenders = new Set(); // Capture current chat info once per scan this.currentChat = this.getCurrentChatInfo(); messages.forEach((msg, index) => { const prePlainText = msg.getAttribute('data-pre-plain-text') || ''; console.log(`Message ${index + 1}: "${prePlainText}"`); // Enhanced sender extraction with multiple strategies let sender = this.extractSenderName(msg, prePlainText); if (!sender) sender = 'Unknown'; if (sender) { uniqueSenders.add(sender); const messageId = this.generateMessageId(msg, prePlainText, sender, this.currentChat.id); // Extract reactions from a broader root (message row/container), not just the inner copyable node const reactionRoot = msg.closest('[role="row"]') || msg.closest('[data-testid*="msg-container"]') || msg.parentElement || msg; const reactions = this.extractReactionsRobust(reactionRoot, sender); // Extract reply target (who this message replies to), if any const replyTo = this.extractReplyTo(msg, sender); // Extract actual message timestamp const messageTimestamp = this.extractMessageTimestamp(msg, prePlainText); // Compute message length (approximate) from the message row let messageLength = 0; try { const messageText = (reactionRoot && reactionRoot.innerText) ? reactionRoot.innerText.replace(/\s+/g, ' ').trim() : ''; messageLength = messageText.length; } catch {} // TEMPORARY: Add mock reactions to demonstrate the analytics // Remove this once real reactions are detected if (false && reactions.size === 0 && Math.random() > 0.5) { const allSenders = Array.from(uniqueSenders); if (allSenders.length > 1) { // Pick a random person to react const reactor = allSenders[Math.floor(Math.random() * allSenders.length)]; if (reactor !== sender) { const emojis = ['ðŸ‘ÂÂÂ', 'â¤ï¸ÂÂÂ', '😂', 'ðŸâ€ÂÂÂ¥', 'ðŸ‘ÂÂÂ']; const emoji = emojis[Math.floor(Math.random() * emojis.length)]; reactions.set(emoji, new Map([[reactor, 1]])); console.log(` - [MOCK] Added reaction: ${emoji} from ${reactor}`); } } } this.reactionData.set(messageId, { sender: sender, reactions: reactions, timestamp: messageTimestamp, // Actual message timestamp chatId: this.currentChat.id, chatName: this.currentChat.name, replyTo: replyTo || null, messageLength: messageLength }); console.log(` - Extracted sender: ${sender}, Reactions: ${reactions.size}`); } }); console.log(`Total messages tracked: ${this.reactionData.size}`); this.sendDataToBackground(); } extractReactions(messageElement, messageSender) { const reactions = new Map(); // Look for reaction elements in the message // WhatsApp shows reactions in various ways - let's look for common patterns // 1. Look for elements with reaction-related classes or attributes const reactionSelectors = [ '[data-testid*="reaction"]', '[class*="reaction"]', '[aria-label*="reaction"]', 'span[title*="reacted"]', 'div[title*="reacted"]' ]; reactionSelectors.forEach(selector => { const reactionElements = messageElement.querySelectorAll(selector); reactionElements.forEach(el => { const ariaLabel = el.getAttribute('aria-label') || ''; const title = el.getAttribute('title') || ''; const text = el.textContent || ''; // Try to parse reaction information // Common patterns: "John reacted with ðŸ‘ÂÂÂ", "👠John, Mary" const combined = `${ariaLabel} ${title} ${text}`; // Extract emoji const emojiMatch = combined.match(/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu); if (emojiMatch) { const emoji = emojiMatch[0]; // Extract who reacted const nameMatch = combined.match(/([A-Z][a-z]+(?:\s[A-Z][a-z]+)*)\s(?:reacted|with)/); if (nameMatch) { const reactor = nameMatch[1].trim(); if (!reactions.has(emoji)) { reactions.set(emoji, new Map()); } reactions.get(emoji).set(reactor, 1); } } }); }); // 2. Look for reaction counts in the message's parent container // Sometimes reactions are shown as a summary like "2" with emojis const parent = messageElement.parentElement; if (parent) { const countElements = parent.querySelectorAll('div[title], span[title]'); countElements.forEach(el => { const title = el.getAttribute('title'); if (title && title.length < 10 && /^\d+$/.test(title)) { // This might be a reaction count console.log(` Found potential reaction count: ${title}`); } }); } return reactions; } setupObserver() { const mainContainer = document.querySelector('#main'); if (!mainContainer) { console.log("What's That!?: No main container found"); return; } console.log("What's That!?: Setting up observer..."); const observer = new MutationObserver(() => { // Check if extension is still valid before scanning if (!chrome || !chrome.runtime || !chrome.runtime.id) { console.log("What's That!?: Extension invalidated, stopping observer"); observer.disconnect(); return; } this.scheduleScan(); }); observer.observe(mainContainer, { childList: true, subtree: true }); } scheduleScan() { try { if (this._scanTimer) clearTimeout(this._scanTimer); this._scanTimer = setTimeout(() => { const previousCount = this.reactionData.size; this.scanMessages(); // If new messages were detected, send data to background if (this.reactionData.size > previousCount) { console.log(`What's That!?: ${this.reactionData.size - previousCount} new messages detected, sending to background`); this.sendDataToBackground(); } this._scanTimer = null; }, 500); } catch (e) { this.scanMessages(); } } sendDataToBackground() { try { const dataToSend = {}; this.reactionData.forEach((value, key) => { const reactionsObj = Object.fromEntries( Array.from(value.reactions.entries()).map(([emoji, reactors]) => [ emoji, Object.fromEntries(reactors) ]) ); dataToSend[key] = { sender: value.sender, reactions: reactionsObj, timestamp: value.timestamp, chatId: value.chatId, chatName: value.chatName, replyTo: value.replyTo || null, messageLength: typeof value.messageLength === 'number' ? value.messageLength : 0 }; }); console.log(`Sending ${Object.keys(dataToSend).length} messages to background`); // Check if chrome.runtime is available if (!chrome || !chrome.runtime || !chrome.runtime.id) { console.log('Chrome runtime not available - extension may be reloading'); return; } chrome.runtime.sendMessage({ type: 'REACTION_DATA_UPDATE', data: dataToSend }, (response) => { if (chrome.runtime.lastError) { console.log('Error sending data:', chrome.runtime.lastError.message); } }); } catch (error) { console.log('Error in sendDataToBackground:', error.message); } } // Controlled backfill: scroll up in steps to load older messages async startBackfill(options = {}) { const { stepDelayMs = 1000, steps = 50, pauseOnActivity = true } = options; try { const container = document.querySelector('#main [role="application"], #main [data-testid*="conversation-panel"], #main'); let performed = 0; const isUserActive = () => { // Heuristic: if mouse is down or keys pressed recently, treat as active return false; }; while (performed < steps) { if (pauseOnActivity && isUserActive()) break; // Attempt to scroll the messages list upward const scrollable = document.querySelector('#main ._ajyl, #main [role="main"], #main'); if (scrollable && typeof scrollable.scrollBy === 'function') { scrollable.scrollBy({ top: -800, behavior: 'auto' }); } else { window.scrollBy(0, -800); } // Let DOM load, then rescan await new Promise(r => setTimeout(r, stepDelayMs)); this.scanMessages(); performed++; } return { performed }; } catch (e) { return { error: String(e && e.message || e) }; } } getAvailableChats() { console.log('=== GET AVAILABLE CHATS ==='); console.log('Total messages tracked:', this.reactionData.size); const chatMap = new Map(); this.reactionData.forEach((messageData) => { if (messageData.chatId) { chatMap.set(messageData.chatId, messageData.chatName || messageData.chatId); } }); const chats = Array.from(chatMap.entries()).map(([id, name]) => ({ id, name, type: 'tracked' })); console.log(`Final chats array: ${chats.length}`, chats); return chats; } // Enhanced reaction extraction with multiple detection strategies extractReactionsRobust(messageElement, messageSender) { // Ensure we search in the outer message container so we don’t miss reaction chips const searchRoot = messageElement.closest('[role="row"]') || messageElement.closest('[data-testid*="msg-container"]') || messageElement; const reactions = new Map(); const debugInfo = { strategies: [], found: 0 }; // Strategy 0: Generic reaction chips in modern WhatsApp const genericChips = this.findReactionChipsGeneric(searchRoot); if (genericChips.length > 0) { debugInfo.strategies.push('generic-chips'); genericChips.forEach(chip => { const chipData = this.extractFromReactionChip(chip, messageSender); chipData.forEach((reactors, emoji) => { if (!reactions.has(emoji)) reactions.set(emoji, new Map()); reactors.forEach((count, reactor) => { const current = reactions.get(emoji).get(reactor) || 0; reactions.get(emoji).set(reactor, current + count); }); }); }); } // Strategy 1: Look for WhatsApp's reaction buttons/indicators const reactionButtons = this.findReactionButtons(searchRoot); if (reactionButtons.length > 0) { debugInfo.strategies.push('reaction-buttons'); reactionButtons.forEach(button => { const buttonReactions = this.extractFromReactionButton(button, messageSender); buttonReactions.forEach((reactors, emoji) => { if (!reactions.has(emoji)) reactions.set(emoji, new Map()); reactors.forEach((count, reactor) => { const current = reactions.get(emoji).get(reactor) || 0; reactions.get(emoji).set(reactor, current + count); }); }); }); } // Strategy 2: Look for reaction tooltips and hover states const tooltipReactions = this.findReactionTooltips(searchRoot); if (tooltipReactions.length > 0) { debugInfo.strategies.push('tooltips'); tooltipReactions.forEach(tooltip => { const tooltipData = this.extractFromTooltip(tooltip, messageSender); tooltipData.forEach((reactors, emoji) => { if (!reactions.has(emoji)) reactions.set(emoji, new Map()); reactors.forEach((count, reactor) => { const current = reactions.get(emoji).get(reactor) || 0; reactions.get(emoji).set(reactor, current + count); }); }); }); } // Strategy 3: Look for reaction counts and emoji indicators const countReactions = this.findReactionCounts(searchRoot); if (countReactions.length > 0) { debugInfo.strategies.push('counts'); countReactions.forEach(countEl => { const countData = this.extractFromCountElement(countEl, messageSender); countData.forEach((reactors, emoji) => { if (!reactions.has(emoji)) reactions.set(emoji, new Map()); reactors.forEach((count, reactor) => { const current = reactions.get(emoji).get(reactor) || 0; reactions.get(emoji).set(reactor, current + count); }); }); }); } // Strategy 4: Look for emoji-only reactions in message context const emojiReactions = this.findEmojiReactions(searchRoot); if (emojiReactions.length > 0) { debugInfo.strategies.push('emoji-only'); emojiReactions.forEach(emojiEl => { const emojiData = this.extractFromEmojiElement(emojiEl, messageSender); emojiData.forEach((reactors, emoji) => { if (!reactions.has(emoji)) reactions.set(emoji, new Map()); reactors.forEach((count, reactor) => { const current = reactions.get(emoji).get(reactor) || 0; reactions.get(emoji).set(reactor, current + count); }); }); }); } // Strategy 5: Modern WhatsApp reaction detection const modernReactions = this.findModernReactions(searchRoot); if (modernReactions.length > 0) { debugInfo.strategies.push('modern-detection'); modernReactions.forEach(reactionEl => { const reactionData = this.extractFromModernReaction(reactionEl, messageSender); reactionData.forEach((reactors, emoji) => { if (!reactions.has(emoji)) reactions.set(emoji, new Map()); reactors.forEach((count, reactor) => { const current = reactions.get(emoji).get(reactor) || 0; reactions.get(emoji).set(reactor, current + count); }); }); }); } debugInfo.found = reactions.size; if (debugInfo.found > 0) { console.log(`What's That!?: Found ${debugInfo.found} reactions using strategies:`, debugInfo.strategies); } else { console.log(`What's That!?: No reactions found for message from ${messageSender}`); } return reactions; } // Strategy 0 helper: likely reaction chip containers findReactionChipsGeneric(messageElement) { const selectors = [ '[data-testid="msg-reactions"]', '[data-testid^="msg-reactions"]', '[data-testid*="reactions"]', '[data-testid*="reaction-emoji"]', '[data-testid*="reactions-emoji"]', '[class*="reactions"]', '[class*="msg-reaction"]', '[aria-label*="Reacted"]', // English UI '[aria-label*="reaction"]' ]; const out = []; selectors.forEach(sel => { try { messageElement.querySelectorAll(sel).forEach(el => out.push(el)); } catch {} }); return out; } // Strategy 0 extractor: parse a chip container extractFromReactionChip(el, messageSender) { const reactions = new Map(); try { const aria = el.getAttribute('aria-label') || ''; const title = el.getAttribute('title') || ''; const text = el.textContent || ''; const combined = `${aria} ${title} ${text}`; // Emojis present in the chip cluster const emojis = this.extractEmojisFromText(combined); // Try to get reactors; default to Unknown when missing const candidates = [combined]; let p = el.parentElement; let steps = 0; while (p && steps < 2) { candidates.push(`${p.getAttribute('aria-label') || ''} ${p.getAttribute('title') || ''} ${p.textContent || ''}`); p = p.parentElement; steps++; } const reactorSet = this._extractReactorsFromCandidates(candidates, messageSender); const reactors = Array.from(reactorSet); const finalReactors = reactors.length ? reactors : ['Unknown']; emojis.forEach(emoji => { reactions.set(emoji, new Map()); finalReactors.forEach(r => reactions.get(emoji).set(r, 1)); }); } catch {} return reactions; } // Strategy 1: Find reaction buttons findReactionButtons(messageElement) { const selectors = [ // Modern WhatsApp selectors '[data-testid*="reaction"]', '[data-testid*="reaction-"]', '[data-testid*="msg-reaction"]', '[data-testid*="reaction-button"]', // Button-based reactions 'button[aria-label*="reaction"]', 'button[aria-label*="reacted"]', 'div[role="button"][aria-label*="reaction"]', 'div[role="button"][aria-label*="reacted"]', // Class-based selectors '.reaction-button', '[class*="reaction-button"]', '[class*="reaction"]', '[class*="emoji-reaction"]', // Generic emoji containers 'span[role="img"]', 'div[role="img"]', '[data-emoji]', // WhatsApp specific patterns '[aria-label*="👍"]', '[aria-label*="❤️"]', '[aria-label*="😂"]', '[aria-label*="😮"]', '[aria-label*="😢"]', '[aria-label*="🙏"]' ]; const buttons = []; selectors.forEach(selector => { try { const elements = messageElement.querySelectorAll(selector); elements.forEach(el => buttons.push(el)); } catch (e) { // ignore } }); return buttons; } // Strategy 2: Find reaction tooltips findReactionTooltips(messageElement) { const selectors = [ '[title*="reacted"]', '[aria-label*="reacted"]', '[title*="Reacted by"]', '[aria-label*="Reacted by"]', '[title*="reaction"]', '[aria-label*="reaction"]' ]; const tooltips = []; selectors.forEach(selector => { try { const elements = messageElement.querySelectorAll(selector); elements.forEach(el => { const title = el.getAttribute('title') || ''; const ariaLabel = el.getAttribute('aria-label') || ''; if (title.includes('reacted') || ariaLabel.includes('reacted') || title.includes('reaction') || ariaLabel.includes('reaction')) { tooltips.push(el); } }); } catch (e) { // ignore } }); return tooltips; } // Strategy 3: Find reaction counts findReactionCounts(messageElement) { const selectors = [ 'span[title]', 'div[title]', '[data-testid*="count"]', '[data-testid*="reaction-count"]', '[class*="count"]', '[class*="reaction-count"]', '[class*="number"]' ]; const counts = []; selectors.forEach(selector => { try { const elements = messageElement.querySelectorAll(selector); elements.forEach(el => { const title = el.getAttribute('title') || ''; const text = el.textContent || ''; const ariaLabel = el.getAttribute('aria-label') || ''; // Look for numeric counts that might be reaction counts if (/^\d+$/.test(text.trim()) && text.length <= 3) { counts.push(el); } // Also check for reaction-related attributes if (title.includes('reaction') || ariaLabel.includes('reaction') || title.includes('reacted') || ariaLabel.includes('reacted')) { counts.push(el); } }); } catch (e) { // ignore } }); return counts; } // Strategy 4: Find emoji reactions findEmojiReactions(messageElement) { const emojiRegex = /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu; const elements = messageElement.querySelectorAll('*'); const emojiElements = []; elements.forEach(el => { const text = el.textContent || ''; const ariaLabel = el.getAttribute('aria-label') || ''; const title = el.getAttribute('title') || ''; // Check if element contains emojis and is likely a reaction if (emojiRegex.test(text) && text.length <= 10) { // Short text with emojis emojiElements.push(el); } // Also check aria-label and title for emoji reactions if (emojiRegex.test(ariaLabel) || emojiRegex.test(title)) { emojiElements.push(el); } }); return emojiElements; } // Strategy 5: Modern WhatsApp reaction detection findModernReactions(messageElement) { const selectors = [ // Latest WhatsApp reaction selectors '[data-testid*="reaction"]', '[data-testid*="msg-reaction"]', '[data-testid*="reaction-button"]', '[data-testid*="reaction-count"]', // Modern emoji reaction patterns 'span[role="img"][aria-label*="👍"]', 'span[role="img"][aria-label*="❤️"]', 'span[role="img"][aria-label*="😂"]', 'span[role="img"][aria-label*="😮"]', 'span[role="img"][aria-label*="😢"]', 'span[role="img"][aria-label*="🙏"]', // Generic emoji containers with reactions 'div[role="img"]', 'span[role="img"]', '[data-emoji]', // Class-based modern selectors '[class*="reaction"]', '[class*="emoji-reaction"]', '[class*="msg-reaction"]' ]; const reactions = []; selectors.forEach(selector => { try { const elements = messageElement.querySelectorAll(selector); elements.forEach(el => { const ariaLabel = el.getAttribute('aria-label') || ''; const title = el.getAttribute('title') || ''; const text = el.textContent || ''; // Check if this looks like a reaction element if (ariaLabel.includes('reacted') || ariaLabel.includes('reaction') || title.includes('reacted') || title.includes('reaction') || /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(text) || /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(ariaLabel)) { reactions.push(el); } }); } catch (e) { // ignore } }); return reactions; } // Extract reactions from modern reaction elements extractFromModernReaction(reactionEl, messageSender) { const reactions = new Map(); const ariaLabel = reactionEl.getAttribute('aria-label') || ''; const title = reactionEl.getAttribute('title') || ''; const text = reactionEl.textContent || ''; const combined = `${ariaLabel} ${title} ${text}`; // Extract emojis from the element const emojis = this.extractEmojisFromText(combined); // Extract reactor names using multiple candidate strings const candidates = [combined]; let p = reactionEl.parentElement; let steps = 0; while (p && steps < 2) { candidates.push(`${p.getAttribute('aria-label') || ''} ${p.getAttribute('title') || ''} ${p.textContent || ''}`); p = p.parentElement; steps++; } const reactors = Array.from(this._extractReactorsFromCandidates(candidates, messageSender)); // If no reactors found, try to extract from parent elements let finalReactors = reactors; if (finalReactors.length === 0) { let parent = reactionEl.parentElement; let attempts = 0; while (parent && attempts < 3) { const parentText = parent.textContent || ''; const parentAriaLabel = parent.getAttribute('aria-label') || ''; const parentTitle = parent.getAttribute('title') || ''; const parentCombined = `${parentAriaLabel} ${parentTitle} ${parentText}`; const parentReactors = this.extractReactorsFromText(parentCombined, messageSender); if (parentReactors.length > 0) { finalReactors = parentReactors; break; } parent = parent.parentElement; attempts++; } } // If still no reactors, use "Unknown" if (finalReactors.length === 0) { finalReactors = ['Unknown']; } // Create reaction entries emojis.forEach(emoji => { reactions.set(emoji, new Map()); finalReactors.forEach(reactor => { reactions.get(emoji).set(reactor, 1); }); }); return reactions; } // Extract reactions from reaction button extractFromReactionButton(button, messageSender) { const reactions = new Map(); const ariaLabel = button.getAttribute('aria-label') || ''; const title = button.getAttribute('title') || ''; const text = button.textContent || ''; const combined = `${ariaLabel} ${title} ${text}`; const emojis = this.extractEmojisFromText(combined); const reactorsSet = this._extractReactorsFromCandidates([combined], messageSender); const reactors = reactorsSet.size ? Array.from(reactorsSet) : ['Unknown']; emojis.forEach(emoji => { if (!reactions.has(emoji)) reactions.set(emoji, new Map()); reactors.forEach(reactor => { reactions.get(emoji).set(reactor, 1); }); }); return reactions; } // Extract reactions from tooltip extractFromTooltip(tooltip, messageSender) { const reactions = new Map(); const ariaLabel = tooltip.getAttribute('aria-label') || ''; const title = tooltip.getAttribute('title') || ''; const combined = `${ariaLabel} ${title}`; const emojis = this.extractEmojisFromText(combined); const reactorsSet = this._extractReactorsFromCandidates([combined], messageSender); const reactors = reactorsSet.size ? Array.from(reactorsSet) : ['Unknown']; emojis.forEach(emoji => { if (!reactions.has(emoji)) reactions.set(emoji, new Map()); reactors.forEach(reactor => { reactions.get(emoji).set(reactor, 1); }); }); return reactions; } // Extract reactions from count element extractFromCountElement(countEl, messageSender) { const reactions = new Map(); const title = countEl.getAttribute('title') || ''; const text = countEl.textContent || ''; // Look for patterns like "👍 3" or "❤️ 2" const countMatch = text.match(/^(\d+)$/); if (countMatch) { const count = parseInt(countMatch[1]); // Try to find associated emoji from parent or sibling elements const parent = countEl.parentElement; if (parent) { const emojis = this.extractEmojisFromText(parent.textContent || ''); emojis.forEach(emoji => { reactions.set(emoji, new Map([['Unknown', count]])); }); } } return reactions; } // Extract reactions from emoji element extractFromEmojiElement(emojiEl, messageSender) { const reactions = new Map(); const text = emojiEl.textContent || ''; const emojis = this.extractEmojisFromText(text); emojis.forEach(emoji => { reactions.set(emoji, new Map([['Unknown', 1]])); }); return reactions; } // Helper: Extract emojis from text extractEmojisFromText(text) { const emojiRegex = /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu; return Array.from(new Set(text.match(emojiRegex) || [])); } // Helper: Extract reactor names from text extractReactorsFromText(text, messageSender) { const reactors = new Set(); const known = this.getKnownSenders(); // Pattern 1: "John reacted with 👍" const reactedPattern = /([A-Z][a-z]+(?:\s[A-Z][a-z]+)*)\s+reacted\s+with/i; const reactedMatch = text.match(reactedPattern); if (reactedMatch) { const name = this.cleanDisplayName(reactedMatch[1], messageSender, known); if (name) reactors.add(name); } // Pattern 2: "Reacted by John, Mary" const reactedByPattern = /Reacted by\s+([^,]+(?:,\s*[^,]+)*)/i; const reactedByMatch = text.match(reactedByPattern); if (reactedByMatch) { const names = reactedByMatch[1].split(',').map(n => n.trim()); names.forEach(name => { const cleaned = this.cleanDisplayName(name, messageSender, known); if (cleaned) reactors.add(cleaned); }); } // Pattern 3: Look for known names in the text known.forEach(name => { if (name !== messageSender && text.includes(name)) { reactors.add(name); } }); return Array.from(reactors); } // Extract reply target (original author) from a reply/quoted header within the message bubble extractReplyTo(messageElement, messageSender) { try { const searchRoot = messageElement.closest('[role="row"]') || messageElement.closest('div') || messageElement; // Structural detection: quoted/reply header often contains the original name as first line const structural = searchRoot.querySelector('[data-testid*="msg-context"], [data-testid*="quoted-message"], [data-testid*="msg-quote"]'); if (structural) { const nameEl = structural.querySelector('span[dir="auto"], strong[dir="auto"], div[dir="auto"]'); const candidate = nameEl && (nameEl.textContent || '').trim(); if (candidate && candidate !== messageSender && candidate.length <= 60) { return candidate; } } const replySelectors = [ '[data-testid*="quoted"]', '[data-testid*="msg-context"]', '[aria-label*="replied"]', '[aria-label*="reply"]', '[title*="replied"]', '[title*="reply"]', '[class*="quoted"]', '[class*="reply"]' ]; const candidates = []; replySelectors.forEach(sel => { searchRoot.querySelectorAll(sel).forEach(el => { const s = `${el.getAttribute('aria-label') || ''} ${el.getAttribute('title') || ''} ${el.textContent || ''}` .replace(/\s+/g, ' ').trim(); if (s) candidates.push(s); }); }); const known = this.getKnownSenders(); const fromPatterns = [/replied to\s+([^:–\-\,]+)/i, /reply to\s+([^:–\-\,]+)/i, /in reply to\s+([^:–\-\,]+)/i]; for (const str of candidates) { for (const rx of fromPatterns) { const m = str.match(rx); if (m && m[1]) { const name = m[1].trim(); if (name && name !== messageSender) return name; } } // Fallback: pick the first known sender mentioned for (const name of known) { if (name !== messageSender && str.includes(name)) return name; } // Final fallback: extract phone number if present const phone = this.extractPhoneNumber(str); if (phone && phone !== messageSender) return phone; } } catch (e) {} return null; } getKnownSenders() { const set = new Set(); try { this.reactionData.forEach(v => { if (v && v.sender) set.add(v.sender); }); } catch (e) {} return set; } // Phone extraction helpers _extractReactorsFromCandidates(candidates, messageSender) { try { const known = this.getKnownSenders(); const splitTokens = (s) => String(s).split(/,|\band\b|·|•/i).map(t => t.trim()).filter(Boolean); const clean = (raw) => this.cleanDisplayName(raw, messageSender, known); const reactorSet = new Set(); candidates.forEach(str => { if (!str) return; let base = str; const rb = String(str).match(/Reacted by\s+(.+)/i); if (rb && rb[1]) base = rb[1]; base = base.replace(/^[\p{Extended_Pictographic}\s,]+/gu, ''); if (rb && rb[1]) { splitTokens(base).forEach(tok => { const n = clean(tok); if (n && n !== messageSender) reactorSet.add(n); }); } const m = String(str).match(/([^,]+?)\s+reacted\b/i); if (m && m[1]) { const n2 = clean(m[1]); if (n2 && n2 !== messageSender) reactorSet.add(n2); } }); // Fallback: if nothing found, look for any known sender names in the strings if (reactorSet.size === 0 && known && known.size) { candidates.forEach(str => { if (!str) return; known.forEach(name => { if (name !== messageSender && str.includes(name)) reactorSet.add(name); }); }); } return reactorSet; } catch { return new Set(); } } cleanDisplayName(raw, messageSender, knownSenders) { if (!raw) return null; let s = String(raw); // Preserve full phone numbers (keep area code) before stripping characters try { if (/^[\d\s().\-+]+$/.test(s)) { const cleanedDigits = s.replace(/[^\d+]/g, ''); const phone0 = this.sanitizePhone(cleanedDigits); if (phone0) return phone0; } } catch {} // Remove emojis s = s.replace(/[\p{Extended_Pictographic}]/gu, ' '); // Drop common UI noise words s = s.replace(/\b(view|views|reaction|reactions|reacted|with|others?|more|see\s+fewer|see\s+more|total|in\s+total|reply|replied|message|messages)\b/gi, ' '); // Remove parentheses content and punctuation s = s.replace(/\(.*?\)/g, ' ').replace(/[|•·.,;:()\[\]{}<>]/g, ' '); s = s.replace(/\s+/g, ' ').trim(); if (!s) return null; // Ignore 'you'/'me' if (/^you$/i.test(s) || /^me$/i.test(s)) return null; // Ignore times and pure numbers if (/(?:^|\s)(am|pm)\b/i.test(s) || /^\d+$/.test(s)) return null; // Only check for phone numbers if the string looks like it could be one // (contains only digits, +, spaces, parentheses, dots, hyphens) if (/^[\d\s().\-+]+$/.test(s)) { const phone = this.extractPhoneNumber(s); if (phone) return phone; } // Basic content check: must contain a letter if (!/[\p{L}]/u.test(s)) return null; // Cap length if (s.length > 60) s = s.slice(0, 60); // Avoid exact self if (messageSender && s === messageSender) return null; return s; } extractPhoneNumber(s) { try { if (!s) return null; // Allow spaces, dashes, and parentheses by cleaning first const cleanedRaw = String(s).replace(/[^\d+]/g, ''); // More restrictive pattern: 7-15 digits, with optional + prefix // Must start with + or digit 1-9 (not 0) const m = cleanedRaw.match(/^(\+?[1-9]\d{6,14})$/); if (!m) return null; const phone = m[1]; // Additional validation if (!this.isValidPhoneNumber(phone)) return null; return this.sanitizePhone(phone); } catch { return null; } } isValidPhoneNumber(phone) { try { // Remove all non-digits except + const cleaned = phone.replace(/[^\d+]/g, ''); // Must be 7-15 digits total if (cleaned.length < 7 || cleaned.length > 15) return false; // Must start with + or digit 1-9 (not 0) if (!/^(\+[1-9]|[1-9])/.test(cleaned)) return false; // If it starts with +, the rest should be digits if (cleaned.startsWith('+')) { const digits = cleaned.slice(1); if (!/^\d+$/.test(digits)) return false; // International numbers should be 7-14 digits after + if (digits.length < 7 || digits.length > 14) return false; } // If it doesn't start with +, it should be all digits if (!cleaned.startsWith('+')) { if (!/^\d+$/.test(cleaned)) return false; } return true; } catch { return false; } } sanitizePhone(s) { try { let cleaned = String(s).replace(/[^\d+]/g, ''); // Handle 00 prefix conversion to + if (cleaned.startsWith('00')) { cleaned = '+' + cleaned.slice(2); } // Ensure leading + for international numbers (10+ digits) if (!cleaned.startsWith('+') && cleaned.length >= 10) { cleaned = '+' + cleaned; } // Final validation if (!this.isValidPhoneNumber(cleaned)) return null; return cleaned; } catch { return null; } } // Helpers: stable IDs and chat detection generateMessageId(messageElement, prePlainText, sender, chatId) { const text = (messageElement.innerText || '').replace(/\s+/g, ' ').trim(); const base = `${chatId}|${prePlainText || ''}|${sender || ''}|${text}`; return 'msg_' + this.hashString(base); } hashString(str) { let h = 5381; for (let i = 0; i < str.length; i++) { h = ((h << 5) + h) + str.charCodeAt(i); h = h & 0xffffffff; } return (h >>> 0).toString(36); } getCurrentChatInfo() { const bad = ['Menu', 'New chat', 'Search', 'Type a message', 'Attach', 'Profile', 'Get the app', 'video', 'call']; const headerTitle = document.querySelector('#main header [title]'); let name = headerTitle && headerTitle.getAttribute('title'); if (name && name.length < 100 && !bad.some(b => name.includes(b))) { return { id: this.slugify(name), name }; } const selected = document.querySelector('[aria-selected="true"] [title]'); name = selected && selected.getAttribute('title'); if (name && name.length < 100 && !bad.some(b => name.includes(b))) { return { id: this.slugify(name), name }; } return { id: 'unknown', name: 'Unknown Chat' }; } // Enhanced monitoring and user feedback setupRealTimeMonitoring() { // Add visual indicators to show the extension is working this.addStatusIndicator(); // Monitor data quality in real-time this.startDataQualityMonitoring(); // Add periodic data refresh this.startPeriodicRefresh(); } addStatusIndicator() { // Create a subtle status indicator on WhatsApp Web const indicator = document.createElement('div'); indicator.id = 'whatsapp-analyzer-status'; indicator.style.cssText = ` position: fixed; top: 80px; left: 12px; background: rgba(37, 211, 102, 0.9); color: white; padding: 8px 12px; border-radius: 20px; font-size: 11px; font-weight: 600; z-index: 999999; box-shadow: 0 2px 10px rgba(0,0,0,0.2); transition: all 0.3s ease; cursor: pointer; `; indicator.textContent = '📊 Analyzing...'; indicator.title = "What's That!? - Click for stats"; // Add click handler to show quick stats (robust renderer) indicator.addEventListener('click', () => { try { this.renderQuickStatsPopup(); } catch (e) { try { this.showQuickStats(); } catch {} } }); document.body.appendChild(indicator); // Reposition right under the left sidebar header (Meta AI icon area) const reposition = () => { try { const sideHeader = document.querySelector('#side header') || document.querySelector('#side [role="toolbar"]') || document.querySelector('#side'); if (sideHeader) { const rect = sideHeader.getBoundingClientRect(); indicator.style.top = `${Math.round(rect.bottom + 12)}px`; indicator.style.left = '12px'; } } catch {} }; reposition(); window.addEventListener('resize', reposition, { passive: true }); setTimeout(reposition, 750); // Update indicator periodically setInterval(() => { this.updateStatusIndicator(indicator); }, 5000); } updateStatusIndicator(indicator) { try { const messageCount = this.reactionData ? this.reactionData.size : 0; const chatName = this.currentChat ? this.currentChat.name : 'Unknown'; if (messageCount === 0) { indicator.textContent = '📊 No data yet'; indicator.style.background = 'rgba(255, 193, 7, 0.9)'; } else { indicator.textContent = `📊 ${messageCount} msgs`; indicator.style.background = 'rgba(37, 211, 102, 0.9)'; } indicator.title = `WhatsApp Reaction Analyzer\nChat: ${chatName}\nMessages: ${messageCount}\nClick for detailed stats`; } catch (error) { console.error('WhatsApp Reaction Analyzer: Error updating status indicator:', error); } } showQuickStats() { try { // Create a quick stats popup const popup = document.createElement('div'); popup.style.cssText = ` position: fixed; bottom: 80px; right: 20px; background: white; border: 2px solid #25d366; border-radius: 12px; padding: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); z-index: 999999; min-width: 250px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; `; const stats = this.calculateQuickStats(); const chatName = this.currentChat ? this.currentChat.name : 'Unknown'; popup.innerHTML = `