// 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 = '

Extension Debug

'; if (!tracker) { 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 += '
'; Array.from(senders).slice(0, 10).forEach(sender => { html += `- ${sender}
`; }); html += '
'; // Show reaction stats let totalReactions = 0; tracker.reactionData.forEach(data => { data.reactions.forEach(reactorMap => { reactorMap.forEach(count => totalReactions += count); }); }); 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 = `

Quick Stats

Chat: ${this.escapeHtml(chatName)}
Messages: ${stats.totalMessages}
Reactions: ${stats.totalReactions}
Participants: ${stats.participants}
Data Quality: ${stats.dataQuality}%
?? Open Full Dashboard
`; document.body.appendChild(popup); // Intercept dashboard link and open via background script try { const dashLink2 = document.getElementById('qs-dash'); if (dashLink2) { dashLink2.addEventListener('click', (e) => { e.preventDefault(); try { chrome.runtime.sendMessage({ type: 'OPEN_DASHBOARD' }); } catch { window.open(dashLink2.href, '_blank'); } }); } } catch {} // Post-render tweaks to improve reliability/visibility try { popup.id = 'whatsapp-analyzer-quick-stats'; popup.style.zIndex = '2147483647'; const closeBtn = popup.querySelector('button'); if (closeBtn) closeBtn.addEventListener('click', () => popup.remove()); const dashLink = popup.querySelector('a'); if (dashLink) { try { if (!(chrome && chrome.runtime && chrome.runtime.getURL)) { dashLink.href = '#'; } dashLink.textContent = '📊 Open Full Dashboard'; } catch {} } if (!stats.totalMessages) { const container = popup.querySelector('div[style*="line-height"]'); if (container) { const notice = document.createElement('div'); notice.style.cssText = 'margin-top:6px;color:#777;'; notice.textContent = 'No data yet. Scroll your chat to load messages.'; container.appendChild(notice); } } } catch {} // Auto-remove after 10 seconds setTimeout(() => { if (popup.parentNode) { popup.remove(); } }, 10000); } catch (error) { console.error("What's That!?: Error showing quick stats:", error); } } // More robust quick stats popup renderer (used by the status pill) renderQuickStatsPopup() { try { const existing = document.getElementById('whatsapp-analyzer-quick-stats'); if (existing) existing.remove(); const popup = document.createElement('div'); popup.id = 'whatsapp-analyzer-quick-stats'; popup.style.cssText = 'position:fixed;bottom:80px;right:20px;background:#fff;border:2px solid #25d366;border-radius:12px;padding:16px;box-shadow:0 4px 20px rgba(0,0,0,.3);z-index:2147483647;min-width:260px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;color:#222;'; const stats = this.calculateQuickStats(); const chatName = this.currentChat ? this.currentChat.name : 'Unknown'; const linkUrl = (chrome && chrome.runtime && chrome.runtime.getURL) ? chrome.runtime.getURL('dashboard.html') : '#'; popup.innerHTML = ''+ '
'+ '

Quick Stats

'+ ''+ '
'+ '
'+ '
Chat: '+ this.escapeHtml(chatName) +'
'+ '
Messages: '+ stats.totalMessages +'
'+ '
Reactions: '+ stats.totalReactions +'
'+ '
Participants: '+ stats.participants +'
'+ '
Data Quality: '+ stats.dataQuality +'%
'+ (stats.totalMessages ? '' : '
No data yet. Scroll your chat to load messages.
')+ '
'+ '📊 Open Full Dashboard'+ '
'+ '
'; document.body.appendChild(popup); const closeBtn = document.getElementById('qs-close'); if (closeBtn) closeBtn.addEventListener('click', () => popup.remove()); setTimeout(() => { if (popup.parentNode) popup.remove(); }, 10000); } catch (e) { console.error("What's That!?: Error rendering quick stats:", e); } } calculateQuickStats() { // Safety check for reactionData if (!this.reactionData) { return { totalMessages: 0, totalReactions: 0, participants: 0, dataQuality: 0 }; } const totalMessages = this.reactionData.size; let totalReactions = 0; const participants = new Set(); let messagesWithReactions = 0; this.reactionData.forEach((messageData) => { if (messageData && messageData.sender) { participants.add(messageData.sender); } if (messageData && messageData.reactions) { // Check if reactions exist (handle both Map and plain objects) const hasReactions = messageData.reactions instanceof Map ? messageData.reactions.size > 0 : Object.keys(messageData.reactions).length > 0; if (hasReactions) { messagesWithReactions++; // Handle both Map and plain object reactions if (messageData.reactions instanceof Map) { messageData.reactions.forEach(reactors => { if (reactors && typeof reactors.forEach === 'function') { // Handle Map objects reactors.forEach(count => totalReactions += count); } else if (reactors && typeof reactors === 'object') { // Handle plain objects (serialized Maps) if (reactors instanceof Map) { reactors.forEach(count => totalReactions += count); } else { Object.values(reactors).forEach(count => totalReactions += count); } } }); } else { // Handle plain object reactions Object.values(messageData.reactions).forEach(reactors => { if (reactors && typeof reactors.forEach === 'function') { // Handle Map objects reactors.forEach(count => totalReactions += count); } else if (reactors && typeof reactors === 'object') { // Handle plain objects (serialized Maps) if (reactors instanceof Map) { reactors.forEach(count => totalReactions += count); } else { Object.values(reactors).forEach(count => totalReactions += count); } } }); } } } }); const dataQuality = totalMessages > 0 ? Math.round((messagesWithReactions / totalMessages) * 100) : 0; return { totalMessages, totalReactions, participants: participants.size, dataQuality }; } startDataQualityMonitoring() { // Monitor data quality and provide feedback setInterval(() => { try { const stats = this.calculateQuickStats(); if (stats && stats.dataQuality < 10 && stats.totalMessages > 10) { console.warn("What's That!?: Low data quality detected. Consider scrolling through more messages."); } if (stats && stats.totalMessages > 0 && stats.totalReactions === 0) { console.warn("What's That!?: No reactions detected. Make sure reactions are being used in the chat."); } } catch (error) { console.error("What's That!?: Error in data quality monitoring:", error); } }, 30000); // Check every 30 seconds } startPeriodicRefresh() { // Periodically refresh data to catch new messages setInterval(() => { try { if (document.visibilityState === 'visible') { this.scanMessages(); } } catch (error) { console.error("What's That!?: Error in periodic refresh:", error); } }, 10000); // Refresh every 10 seconds when page is visible } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Enhanced sender name extraction with multiple strategies extractSenderName(messageElement, prePlainText) { let sender = null; // Strategy 1: Extract from data-pre-plain-text attribute if (prePlainText) { // Pattern: [time, date] Sender: const match1 = prePlainText.match(/\[.*?\]\s([^:]+):/); if (match1 && match1[1]) { sender = match1[1].trim(); console.log(`What's That!?: Extracted sender from prePlainText: "${sender}"`); } // Pattern: Sender: (without brackets) if (!sender) { const match2 = prePlainText.match(/^([^:]+):/); if (match2 && match2[1]) { sender = match2[1].trim(); console.log(`What's That!?: Extracted sender from prePlainText (no brackets): "${sender}"`); } } } // Strategy 2: Extract from aria-label if (!sender) { const ariaLabel = messageElement.getAttribute('aria-label') || ''; if (ariaLabel) { // Pattern: "Message from Sender" or "Sender: message" const match1 = ariaLabel.match(/Message from\s+([^,]+)/i); if (match1 && match1[1]) { sender = match1[1].trim(); console.log(`What's That!?: Extracted sender from aria-label (from): "${sender}"`); } if (!sender) { const match2 = ariaLabel.match(/^([^:]+):/); if (match2 && match2[1]) { sender = match2[1].trim(); console.log(`What's That!?: Extracted sender from aria-label: "${sender}"`); } } } } // Strategy 3: Extract from title attribute if (!sender) { const title = messageElement.getAttribute('title') || ''; if (title) { const match = title.match(/^([^:]+):/); if (match && match[1]) { sender = match[1].trim(); console.log(`What's That!?: Extracted sender from title: "${sender}"`); } } } // Strategy 4: Look for sender in parent elements if (!sender) { let parent = messageElement.parentElement; let attempts = 0; while (parent && attempts < 5) { const parentAriaLabel = parent.getAttribute('aria-label') || ''; const parentTitle = parent.getAttribute('title') || ''; const parentText = parent.textContent || ''; // Check aria-label if (parentAriaLabel) { const match = parentAriaLabel.match(/Message from\s+([^,]+)/i); if (match && match[1]) { sender = match[1].trim(); console.log(`What's That!?: Extracted sender from parent aria-label: "${sender}"`); break; } } // Check title if (!sender && parentTitle) { const match = parentTitle.match(/^([^:]+):/); if (match && match[1]) { sender = match[1].trim(); console.log(`What's That!?: Extracted sender from parent title: "${sender}"`); break; } } // Check for sender patterns in text content if (!sender && parentText) { const match = parentText.match(/^([^:]+):/); if (match && match[1] && match[1].length < 50) { // Reasonable name length sender = match[1].trim(); console.log(`What's That!?: Extracted sender from parent text: "${sender}"`); break; } } parent = parent.parentElement; attempts++; } } // Strategy 5: Look for sender in sibling elements if (!sender) { const siblings = messageElement.parentElement?.children || []; for (const sibling of siblings) { const siblingText = sibling.textContent || ''; const match = siblingText.match(/^([^:]+):/); if (match && match[1] && match[1].length < 50) { sender = match[1].trim(); console.log(`What's That!?: Extracted sender from sibling: "${sender}"`); break; } } } // Strategy 6: Phone number fallback if (!sender) { const phoneMatch = prePlainText.match(/\[.*?\]\s(\+?[1-9]\d{6,14}):/); if (phoneMatch && phoneMatch[1]) { const potentialPhone = phoneMatch[1].trim(); if (this.isValidPhoneNumber(potentialPhone)) { sender = this.sanitizePhone(potentialPhone); console.log(`What's That!?: Using phone number as sender: ${sender} from "${prePlainText}"`); } } } // Clean up the sender name if (sender) { sender = this.cleanSenderName(sender); console.log(`What's That!?: Final sender name: "${sender}"`); } return sender; } // Clean and validate sender names cleanSenderName(name) { if (!name) return null; let cleaned = name.trim(); // If it looks like a phone number, keep full number try { if (/^[\d\s().\-+]+$/.test(cleaned)) { const phone = this.sanitizePhone(cleaned.replace(/[^\d+]/g, '')); if (phone) return phone; } } catch {} // Remove common prefixes/suffixes cleaned = cleaned.replace(/^(Message from|From|Sent by|By)\s+/i, ''); cleaned = cleaned.replace(/\s+(sent|message|chat)$/i, ''); // Remove timestamps and dates cleaned = cleaned.replace(/^\d{1,2}[:\.]\d{2}(\s*[AP]M)?\s*/i, ''); cleaned = cleaned.replace(/^\d{1,2}\/\d{1,2}\/\d{2,4}\s*/i, ''); // Remove brackets and parentheses content cleaned = cleaned.replace(/\[.*?\]/g, ''); // Keep parentheses unless they are known UI noise; phone numbers handled earlier cleaned = cleaned.replace(/\((?:\s*reply\s*|\s*edited\s*).*?\)/ig, ''); // Clean up whitespace cleaned = cleaned.replace(/\s+/g, ' ').trim(); // Validate name length and content if (cleaned.length < 1 || cleaned.length > 50) return null; if (/^[\d\s\-\(\)]+$/.test(cleaned)) return null; // Only numbers and symbols if (/^(you|me|unknown|sender)$/i.test(cleaned)) return null; // Generic names return cleaned; } slugify(s) { return String(s).toLowerCase().normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') .slice(0, 80) || 'unknown'; } // Extract actual message timestamp from WhatsApp DOM extractMessageTimestamp(messageElement, prePlainText) { try { // Strategy 1: Extract from data-pre-plain-text attribute if (prePlainText) { // Pattern: [time, date] Sender: or [time] Sender: const timestampMatch = prePlainText.match(/\[([^\]]+)\]/); if (timestampMatch && timestampMatch[1]) { const timestampStr = timestampMatch[1].trim(); const parsedTime = this.parseWhatsAppTimestamp(timestampStr); if (parsedTime) { console.log(`What's That!?: Extracted timestamp from prePlainText: "${timestampStr}" -> ${parsedTime}`); return parsedTime; } } } // Strategy 2: Look for timestamp in aria-label const ariaLabel = messageElement.getAttribute('aria-label') || ''; if (ariaLabel) { const timestampMatch = ariaLabel.match(/\[([^\]]+)\]/); if (timestampMatch && timestampMatch[1]) { const timestampStr = timestampMatch[1].trim(); const parsedTime = this.parseWhatsAppTimestamp(timestampStr); if (parsedTime) { console.log(`What's That!?: Extracted timestamp from aria-label: "${timestampStr}" -> ${parsedTime}`); return parsedTime; } } } // Strategy 3: Look for timestamp in title attribute const title = messageElement.getAttribute('title') || ''; if (title) { const timestampMatch = title.match(/\[([^\]]+)\]/); if (timestampMatch && timestampMatch[1]) { const timestampStr = timestampMatch[1].trim(); const parsedTime = this.parseWhatsAppTimestamp(timestampStr); if (parsedTime) { console.log(`What's That!?: Extracted timestamp from title: "${timestampStr}" -> ${parsedTime}`); return parsedTime; } } } // Strategy 4: Look for timestamp in parent elements let parent = messageElement.parentElement; let attempts = 0; while (parent && attempts < 5) { const parentAriaLabel = parent.getAttribute('aria-label') || ''; const parentTitle = parent.getAttribute('title') || ''; for (const text of [parentAriaLabel, parentTitle]) { if (text) { const timestampMatch = text.match(/\[([^\]]+)\]/); if (timestampMatch && timestampMatch[1]) { const timestampStr = timestampMatch[1].trim(); const parsedTime = this.parseWhatsAppTimestamp(timestampStr); if (parsedTime) { console.log(`What's That!?: Extracted timestamp from parent: "${timestampStr}" -> ${parsedTime}`); return parsedTime; } } } } parent = parent.parentElement; attempts++; } // Strategy 5: Look for timestamp elements in the DOM const timestampSelectors = [ '[data-testid*="time"]', '[data-testid*="timestamp"]', '[class*="time"]', '[class*="timestamp"]', 'time', '.time', '.timestamp' ]; for (const selector of timestampSelectors) { try { const timeElements = messageElement.querySelectorAll(selector); for (const timeEl of timeElements) { const text = timeEl.textContent || timeEl.getAttribute('datetime') || ''; if (text) { const parsedTime = this.parseWhatsAppTimestamp(text); if (parsedTime) { console.log(`What's That!?: Extracted timestamp from DOM element: "${text}" -> ${parsedTime}`); return parsedTime; } } } } catch (e) { // ignore } } console.log("What's That!?: Could not extract timestamp, using current time"); return Date.now(); } catch (error) { console.error("What's That!?: Error extracting timestamp:", error); return Date.now(); } } // Parse WhatsApp timestamp formats parseWhatsAppTimestamp(timestampStr) { try { if (!timestampStr) return null; const now = new Date(); const currentYear = now.getFullYear(); // Pattern 1: "14:30" or "2:30 PM" const timeOnlyMatch = timestampStr.match(/^(\d{1,2}):(\d{2})(?:\s*(AM|PM))?$/i); if (timeOnlyMatch) { let hours = parseInt(timeOnlyMatch[1]); const minutes = parseInt(timeOnlyMatch[2]); const ampm = timeOnlyMatch[3]; if (ampm) { if (ampm.toUpperCase() === 'PM' && hours !== 12) hours += 12; if (ampm.toUpperCase() === 'AM' && hours === 12) hours = 0; } const date = new Date(currentYear, now.getMonth(), now.getDate(), hours, minutes); return date.getTime(); } // Pattern 2: "14:30, 12/25/23" or "2:30 PM, 12/25/2023" const timeDateMatch = timestampStr.match(/^(\d{1,2}):(\d{2})(?:\s*(AM|PM))?,\s*(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/i); if (timeDateMatch) { let hours = parseInt(timeDateMatch[1]); const minutes = parseInt(timeDateMatch[2]); const ampm = timeDateMatch[3]; const month = parseInt(timeDateMatch[4]); const day = parseInt(timeDateMatch[5]); let year = parseInt(timeDateMatch[6]); if (year < 100) year += 2000; // Convert 2-digit year to 4-digit if (ampm) { if (ampm.toUpperCase() === 'PM' && hours !== 12) hours += 12; if (ampm.toUpperCase() === 'AM' && hours === 12) hours = 0; } const date = new Date(year, month - 1, day, hours, minutes); return date.getTime(); } // Pattern 3: "12/25/23, 14:30" or "12/25/2023, 2:30 PM" const dateTimeMatch = timestampStr.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4}),\s*(\d{1,2}):(\d{2})(?:\s*(AM|PM))?$/i); if (dateTimeMatch) { const month = parseInt(dateTimeMatch[1]); const day = parseInt(dateTimeMatch[2]); let year = parseInt(dateTimeMatch[3]); let hours = parseInt(dateTimeMatch[4]); const minutes = parseInt(dateTimeMatch[5]); const ampm = dateTimeMatch[6]; if (year < 100) year += 2000; // Convert 2-digit year to 4-digit if (ampm) { if (ampm.toUpperCase() === 'PM' && hours !== 12) hours += 12; if (ampm.toUpperCase() === 'AM' && hours === 12) hours = 0; } const date = new Date(year, month - 1, day, hours, minutes); return date.getTime(); } // Pattern 4: "Yesterday, 14:30" or "Today, 2:30 PM" const relativeMatch = timestampStr.match(/^(Yesterday|Today),\s*(\d{1,2}):(\d{2})(?:\s*(AM|PM))?$/i); if (relativeMatch) { const relative = relativeMatch[1].toLowerCase(); let hours = parseInt(relativeMatch[2]); const minutes = parseInt(relativeMatch[3]); const ampm = relativeMatch[4]; if (ampm) { if (ampm.toUpperCase() === 'PM' && hours !== 12) hours += 12; if (ampm.toUpperCase() === 'AM' && hours === 12) hours = 0; } const date = new Date(); if (relative === 'yesterday') { date.setDate(date.getDate() - 1); } date.setHours(hours, minutes, 0, 0); return date.getTime(); } // Pattern 5: Try to parse as ISO date or other standard formats const parsedDate = new Date(timestampStr); if (!isNaN(parsedDate.getTime())) { return parsedDate.getTime(); } return null; } catch (error) { console.error('WhatsApp Reaction Analyzer: Error parsing timestamp:', error); return null; } } } // Initialize and expose globally for debugging console.log('About to initialize WhatsAppReactionTracker...'); let tracker = null; try { tracker = new WhatsAppReactionTracker(); window.tracker = tracker; console.log('✅ Tracker initialized successfully!'); } catch (error) { console.error('❌ Error initializing tracker:', error); console.error('Stack trace:', error.stack); } // Listen for messages from popup chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { console.log('Message received:', request.type); if (!tracker) { console.error('Tracker not initialized!'); sendResponse({ error: 'Tracker not initialized' }); return true; } if (request.type === 'GET_AVAILABLE_CHATS') { const chats = tracker.getAvailableChats(); sendResponse({ chats: chats }); } else if (request.type === 'GET_REACTION_DATA') { tracker.sendDataToBackground(); sendResponse({ success: true }); } else if (request.type === 'START_BACKFILL') { (async () => { const res = await tracker.startBackfill(request.options || {}); sendResponse(res); })(); return true; } return true; // Keep the message channel open }); // (Removed stray top-level debug block that caused syntax errors) // Enhanced debug function for sender extraction window.debugSenderExtraction = function() { if (!tracker) { console.error('❌ Tracker is not initialized!'); return; } console.log('=== SENDER EXTRACTION DEBUG ==='); const messages = document.querySelectorAll('[data-pre-plain-text]'); console.log(`Found ${messages.length} messages to analyze`); Array.from(messages).slice(0, 10).forEach((msg, index) => { console.log(`\n--- Message ${index + 1} ---`); const prePlainText = msg.getAttribute('data-pre-plain-text') || ''; const ariaLabel = msg.getAttribute('aria-label') || ''; const title = msg.getAttribute('title') || ''; const textContent = msg.textContent || ''; console.log('prePlainText:', prePlainText); console.log('ariaLabel:', ariaLabel); console.log('title:', title); console.log('textContent (first 100 chars):', textContent.substring(0, 100)); const extractedSender = tracker.extractSenderName(msg, prePlainText); console.log('Extracted sender:', extractedSender); const extractedTimestamp = tracker.extractMessageTimestamp(msg, prePlainText); console.log('Extracted timestamp:', extractedTimestamp, new Date(extractedTimestamp)); // Show parent elements let parent = msg.parentElement; let level = 0; while (parent && level < 3) { console.log(`Parent ${level}:`, { tagName: parent.tagName, className: parent.className, ariaLabel: parent.getAttribute('aria-label') || '', title: parent.getAttribute('title') || '', textContent: parent.textContent?.substring(0, 50) || '' }); parent = parent.parentElement; level++; } }); console.log('\n=== CURRENT SENDERS ==='); const senders = new Set(); tracker.reactionData.forEach(data => senders.add(data.sender)); console.log('Unique senders found:', Array.from(senders)); }; // Debug function for timestamp extraction window.debugTimestampExtraction = function() { if (!tracker) { console.error('❌ Tracker is not initialized!'); return; } console.log('=== TIMESTAMP EXTRACTION DEBUG ==='); const messages = document.querySelectorAll('[data-pre-plain-text]'); console.log(`Found ${messages.length} messages to analyze`); Array.from(messages).slice(0, 10).forEach((msg, index) => { console.log(`\n--- Message ${index + 1} ---`); const prePlainText = msg.getAttribute('data-pre-plain-text') || ''; const ariaLabel = msg.getAttribute('aria-label') || ''; const title = msg.getAttribute('title') || ''; console.log('prePlainText:', prePlainText); console.log('ariaLabel:', ariaLabel); console.log('title:', title); const extractedTimestamp = tracker.extractMessageTimestamp(msg, prePlainText); const timestampDate = new Date(extractedTimestamp); console.log('Extracted timestamp:', extractedTimestamp); console.log('Parsed date:', timestampDate.toLocaleString()); console.log('Hour:', timestampDate.getHours()); console.log('Day of week:', timestampDate.getDay(), '(' + ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][timestampDate.getDay()] + ')'); }); }; // Debug functions window.debugExtension = function() { if (!tracker) { console.error('❌ Tracker is not initialized!'); return; } console.log('=== EXTENSION DEBUG ==='); console.log('Tracker instance:', tracker); console.log('Messages tracked:', tracker.reactionData.size); console.log('Available chats:', tracker.getAvailableChats()); console.log('\nMessage details:'); tracker.reactionData.forEach((data, id) => { console.log(` ${data.sender}`); }); }; window.debugReactionDetection = function() { console.log('=== REACTION DETECTION DEBUG ==='); const messages = document.querySelectorAll('[data-pre-plain-text]'); console.log(`Found ${messages.length} messages to analyze`); Array.from(messages).slice(0, 3).forEach((msg, index) => { console.log(`\n--- Message ${index + 1} ---`); // Check for various reaction-related elements const reactionSelectors = [ '[data-testid*="reaction"]', '[data-testid*="reaction-"]', 'button[aria-label*="reaction"]', 'div[role="button"][aria-label*="reaction"]', '.reaction-button', '[class*="reaction-button"]', '[aria-label*="reacted"]', '[title*="reacted"]', '[class*="reaction"]', '[class*="emoji"]', 'span[role="img"]', 'div[role="img"]' ]; reactionSelectors.forEach(selector => { try { const elements = msg.querySelectorAll(selector); if (elements.length > 0) { console.log(`Found ${elements.length} elements with selector: ${selector}`); elements.forEach((el, i) => { console.log(` Element ${i + 1}:`, { tagName: el.tagName, className: el.className, ariaLabel: el.getAttribute('aria-label'), title: el.getAttribute('title'), textContent: el.textContent?.substring(0, 50), dataTestId: el.getAttribute('data-testid') }); }); } } catch (e) { // ignore } }); // Also check parent elements let parent = msg.parentElement; let level = 0; while (parent && level < 3) { const parentReactions = parent.querySelectorAll('[data-testid*="reaction"], [aria-label*="reaction"], [class*="reaction"]'); if (parentReactions.length > 0) { console.log(`Found ${parentReactions.length} reactions in parent level ${level}`); parentReactions.forEach((el, i) => { console.log(` Parent reaction ${i + 1}:`, { tagName: el.tagName, className: el.className, ariaLabel: el.getAttribute('aria-label'), textContent: el.textContent?.substring(0, 50) }); }); } parent = parent.parentElement; level++; } }); }; console.log("What's That!?: Setup complete!"); console.log('Run window.debugExtension() to see status'); console.log('Run window.debugSenderExtraction() to debug sender extraction'); console.log('Run window.debugTimestampExtraction() to debug timestamp extraction'); console.log('Run window.debugReactionDetection() to debug reaction detection'); // Fix syntax error by removing misplaced code if (typeof window !== 'undefined') { // Clean up any misplaced code }