1019 lines
38 KiB
JavaScript
1019 lines
38 KiB
JavaScript
// What's That!? - Background Script
|
|
class ReactionDataProcessor {
|
|
constructor() {
|
|
this.reactionData = new Map();
|
|
this.setupMessageListener();
|
|
this.setupActionClick();
|
|
}
|
|
|
|
setupMessageListener() {
|
|
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
|
// Keep channel open for async responses when needed
|
|
let willRespondAsync = false;
|
|
switch (request.type) {
|
|
case 'REACTION_DATA_UPDATE':
|
|
this.updateReactionData(request.data);
|
|
break;
|
|
case 'DUMP_CORPUS': {
|
|
try {
|
|
const corpus = this.dumpCorpus();
|
|
sendResponse({ corpus });
|
|
} catch (e) {
|
|
sendResponse({ error: String(e && e.message || e) });
|
|
}
|
|
break;
|
|
}
|
|
case 'IMPORT_CORPUS': {
|
|
try {
|
|
const { corpus } = request;
|
|
if (corpus && typeof corpus === 'object') {
|
|
this.importCorpus(corpus);
|
|
sendResponse({ success: true, total: this.reactionData.size });
|
|
} else {
|
|
sendResponse({ error: 'Invalid corpus' });
|
|
}
|
|
} catch (e) {
|
|
sendResponse({ error: String(e && e.message || e) });
|
|
}
|
|
break;
|
|
}
|
|
case 'OPEN_DASHBOARD': {
|
|
try {
|
|
const url = chrome.runtime.getURL('dashboard.html');
|
|
chrome.tabs.create({ url });
|
|
sendResponse({ success: true });
|
|
} catch (e) {
|
|
sendResponse({ error: String(e && e.message || e) });
|
|
}
|
|
break;
|
|
}
|
|
case 'GET_STATS':
|
|
const stats = this.calculateStats();
|
|
sendResponse({ stats: this.serializeStats(stats) });
|
|
break;
|
|
case 'GET_STATS_FOR_CHAT':
|
|
// Chat-specific filtering is not wired yet since content data lacks chat IDs.
|
|
const chatStats = this.calculateStats({ chatId: request.chatId });
|
|
sendResponse({ stats: this.serializeStats(chatStats) });
|
|
break;
|
|
case 'GET_STORED_CHATS': {
|
|
const chats = this.getStoredChats();
|
|
sendResponse({ chats });
|
|
break;
|
|
}
|
|
case 'GET_AVAILABLE_CHATS': {
|
|
willRespondAsync = true;
|
|
this.forwardToActiveTab(request, sendResponse);
|
|
break;
|
|
}
|
|
case 'START_BACKFILL': {
|
|
willRespondAsync = true;
|
|
this.forwardToActiveTab(request, sendResponse);
|
|
break;
|
|
}
|
|
case 'GET_REACTION_DATA': {
|
|
willRespondAsync = true;
|
|
this.forwardToActiveTab(request, sendResponse);
|
|
break;
|
|
}
|
|
case 'CLEAR_DATA':
|
|
this.clearData();
|
|
sendResponse({ success: true });
|
|
break;
|
|
}
|
|
return willRespondAsync;
|
|
});
|
|
}
|
|
|
|
setupActionClick() {
|
|
if (chrome.action && chrome.action.onClicked) {
|
|
chrome.action.onClicked.addListener(() => {
|
|
chrome.tabs.create({ url: chrome.runtime.getURL('dashboard.html') });
|
|
});
|
|
}
|
|
}
|
|
|
|
forwardToActiveTab(message, sendResponse) {
|
|
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
|
const tab = tabs && tabs[0];
|
|
if (!tab || !tab.id) {
|
|
// Fallback to stored chats if asking for chats
|
|
if (message.type === 'GET_AVAILABLE_CHATS') {
|
|
const chats = this.getStoredChats();
|
|
sendResponse({ chats });
|
|
} else {
|
|
sendResponse({ error: 'No active tab' });
|
|
}
|
|
return;
|
|
}
|
|
chrome.tabs.sendMessage(tab.id, message, (response) => {
|
|
const lastErr = chrome.runtime.lastError;
|
|
if (lastErr) {
|
|
if (message.type === 'GET_AVAILABLE_CHATS') {
|
|
// Fallback: return chats from storage
|
|
const chats = this.getStoredChats();
|
|
sendResponse({ chats });
|
|
} else {
|
|
sendResponse({ error: lastErr.message });
|
|
}
|
|
} else {
|
|
sendResponse(response);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
updateReactionData(newData) {
|
|
console.log('Background: Received data update with', Object.keys(newData).length, 'messages');
|
|
|
|
// Merge new data with existing data
|
|
Object.entries(newData).forEach(([messageId, messageData]) => {
|
|
if (!this.reactionData.has(messageId)) {
|
|
this.reactionData.set(messageId, {
|
|
sender: messageData.sender,
|
|
reactions: new Map(),
|
|
timestamp: messageData.timestamp,
|
|
chatId: messageData.chatId || 'unknown',
|
|
chatName: messageData.chatName || 'Unknown Chat',
|
|
replyTo: messageData.replyTo || null,
|
|
messageLength: typeof messageData.messageLength === 'number' ? messageData.messageLength : 0
|
|
});
|
|
}
|
|
|
|
const existingData = this.reactionData.get(messageId);
|
|
existingData.chatId = messageData.chatId || existingData.chatId || 'unknown';
|
|
existingData.chatName = messageData.chatName || existingData.chatName || 'Unknown Chat';
|
|
existingData.replyTo = messageData.replyTo || existingData.replyTo || null;
|
|
if (typeof messageData.messageLength === 'number') existingData.messageLength = messageData.messageLength;
|
|
|
|
// Update reactions
|
|
Object.entries(messageData.reactions).forEach(([emoji, reactors]) => {
|
|
if (!existingData.reactions.has(emoji)) {
|
|
existingData.reactions.set(emoji, new Map());
|
|
}
|
|
|
|
const emojiReactions = existingData.reactions.get(emoji);
|
|
const pairs = reactors instanceof Map
|
|
? Array.from(reactors.entries())
|
|
: Object.entries(reactors || {});
|
|
pairs.forEach(([reactor, count]) => {
|
|
emojiReactions.set(reactor, count);
|
|
});
|
|
});
|
|
});
|
|
|
|
console.log('Background: Total messages stored:', this.reactionData.size);
|
|
|
|
// Store in Chrome storage
|
|
this.saveToStorage();
|
|
}
|
|
|
|
calculateStats(options = {}) {
|
|
const { chatId } = options;
|
|
console.log('Background: Calculating stats from', this.reactionData.size, 'messages', chatId ? `(chat: ${chatId})` : '');
|
|
|
|
const stats = {
|
|
bySender: new Map(), // sender -> Map(reactor -> totalReactions)
|
|
byReactor: new Map(), // reactor -> Map(sender -> totalReactions)
|
|
topReactions: new Map(), // sender -> Map(reactor -> count)
|
|
totalMessages: 0,
|
|
totalReactions: 0,
|
|
// Advanced analytics
|
|
messageCount: new Map(), // sender -> total messages sent
|
|
reactionRates: new Map(), // sender -> reactions received per message
|
|
relationships: [], // Array of {from, to, strength, likelihood}
|
|
topPairs: [], // Top reactor-reactee pairs
|
|
reactionMatrix: new Map(), // sender -> reactor -> {reactions, messages, rate}
|
|
// Bias/selectivity analytics (reactions)
|
|
messageShare: new Map(), // sender -> share of messages in chat
|
|
biasedReactors: [],
|
|
selectivity: [], // Array of {reactor, target, reactions, focus, lift, selectivity}
|
|
// Enhanced analytics
|
|
temporalAnalysis: {
|
|
hourlyActivity: new Map(), // hour -> {messages, reactions}
|
|
dailyActivity: new Map(), // date -> {messages, reactions}
|
|
weeklyPatterns: new Map(), // dayOfWeek -> {messages, reactions}
|
|
activityTrends: [], // Array of {date, messages, reactions, participants}
|
|
peakHours: [],
|
|
peakDays: []
|
|
},
|
|
engagementMetrics: {
|
|
responseTime: new Map(), // sender -> avg response time
|
|
conversationThreads: [], // Array of {participants, messages, duration}
|
|
activeParticipants: new Set(),
|
|
lurkers: new Set(),
|
|
influencers: new Map(), // person -> influence score
|
|
networkDensity: 0,
|
|
clusteringCoefficient: 0
|
|
},
|
|
contentAnalysis: {
|
|
emojiUsage: new Map(), // emoji -> count
|
|
reactionTypes: new Map(), // reaction type -> count
|
|
messageLengths: new Map(), // sender -> avg message length
|
|
replyChains: [], // Array of {chainId, participants, length}
|
|
topicClusters: [] // Array of {participants, frequency}
|
|
},
|
|
dataQuality: {
|
|
extractionRate: 0, // percentage of messages with reactions
|
|
completenessScore: 0, // overall data completeness
|
|
confidenceLevel: 0, // confidence in analytics
|
|
lastUpdated: Date.now(),
|
|
sampleSize: 0
|
|
}
|
|
};
|
|
|
|
// Filter messages by chat when requested
|
|
const iterMessages = [];
|
|
this.reactionData.forEach((messageData, messageId) => {
|
|
if (!chatId || messageData.chatId === chatId) {
|
|
iterMessages.push([messageId, messageData]);
|
|
}
|
|
});
|
|
|
|
iterMessages.forEach(([messageId, messageData]) => {
|
|
const sender = messageData.sender;
|
|
|
|
if (!stats.bySender.has(sender)) {
|
|
stats.bySender.set(sender, new Map());
|
|
}
|
|
|
|
const senderStats = stats.bySender.get(sender);
|
|
|
|
// We care about who reacted to whom, not which emoji.
|
|
// Count each reactor at most once per message, aggregated across emojis.
|
|
const uniqueReactors = new Set();
|
|
if (messageData.reactions && typeof messageData.reactions.forEach === 'function') {
|
|
messageData.reactions.forEach((reactors /* Map or Object */, _emoji) => {
|
|
if (reactors && typeof reactors.forEach === 'function') {
|
|
reactors.forEach((_count, reactor) => uniqueReactors.add(reactor));
|
|
} else if (reactors && typeof reactors === 'object') {
|
|
Object.keys(reactors).forEach(reactor => uniqueReactors.add(reactor));
|
|
}
|
|
});
|
|
}
|
|
|
|
uniqueReactors.forEach((reactor) => {
|
|
// Update sender stats (reactor -> +1 per message)
|
|
senderStats.set(reactor, (senderStats.get(reactor) || 0) + 1);
|
|
|
|
// Update reactor stats (sender -> +1 per message)
|
|
if (!stats.byReactor.has(reactor)) {
|
|
stats.byReactor.set(reactor, new Map());
|
|
}
|
|
const reactorStats = stats.byReactor.get(reactor);
|
|
reactorStats.set(sender, (reactorStats.get(sender) || 0) + 1);
|
|
|
|
stats.totalReactions += 1; // total unique reactor->message interactions
|
|
});
|
|
});
|
|
|
|
// Set total messages to filtered set size
|
|
stats.totalMessages = iterMessages.length;
|
|
|
|
// Coverage window (earliest/latest timestamps in the filtered set)
|
|
let minTs = Number.POSITIVE_INFINITY;
|
|
let maxTs = 0;
|
|
iterMessages.forEach(([_, md]) => {
|
|
const t = md.timestamp || 0;
|
|
if (t && t < minTs) minTs = t;
|
|
if (t && t > maxTs) maxTs = t;
|
|
});
|
|
if (!isFinite(minTs)) minTs = 0;
|
|
stats.dataQuality.coverageStartTs = minTs;
|
|
stats.dataQuality.coverageEndTs = maxTs;
|
|
|
|
// Message shares per sender (baseline)
|
|
iterMessages.forEach(([_, messageData]) => {
|
|
const s = messageData.sender;
|
|
stats.messageCount.set(s, (stats.messageCount.get(s) || 0) + 1);
|
|
});
|
|
stats.messageCount.forEach((cnt, s) => {
|
|
stats.messageShare.set(s, (cnt || 0) / (stats.totalMessages || 1));
|
|
});
|
|
|
|
// Calculate reaction rates (reactions per message)
|
|
stats.bySender.forEach((reactors, sender) => {
|
|
const totalReactionsReceived = Array.from(reactors.values()).reduce((sum, count) => sum + count, 0);
|
|
const messagesCount = stats.messageCount.get(sender) || 1;
|
|
stats.reactionRates.set(sender, totalReactionsReceived / messagesCount);
|
|
});
|
|
|
|
// Calculate relationships and likelihood
|
|
stats.bySender.forEach((reactors, sender) => {
|
|
const senderMessages = stats.messageCount.get(sender) || 1;
|
|
|
|
reactors.forEach((reactions, reactor) => {
|
|
// Calculate likelihood: reactions / sender's total messages
|
|
const likelihood = reactions / senderMessages;
|
|
|
|
// Get reactor's total activity
|
|
const reactorTotalReactions = stats.byReactor.get(reactor)
|
|
? Array.from(stats.byReactor.get(reactor).values()).reduce((sum, count) => sum + count, 0)
|
|
: 0;
|
|
|
|
// Calculate relative strength: how much this reactor focuses on this sender
|
|
const focus = reactorTotalReactions > 0 ? reactions / reactorTotalReactions : 0;
|
|
|
|
// Combined strength score
|
|
const strength = (likelihood + focus) / 2;
|
|
|
|
stats.relationships.push({
|
|
from: reactor, // Who is reacting
|
|
to: sender, // Who is being reacted to
|
|
reactions: reactions,
|
|
likelihood: likelihood, // Probability of reaction per message
|
|
focus: focus, // What % of reactor's reactions go to this person
|
|
strength: strength,
|
|
messagesReactedTo: reactions,
|
|
totalMessagesBy: senderMessages
|
|
});
|
|
});
|
|
});
|
|
|
|
// Sort relationships by strength
|
|
stats.relationships.sort((a, b) => b.strength - a.strength);
|
|
|
|
// Get top 10 strongest pairs
|
|
stats.topPairs = stats.relationships.slice(0, 10);
|
|
|
|
// Calculate top reactions for each sender
|
|
stats.bySender.forEach((reactors, sender) => {
|
|
const sortedReactors = Array.from(reactors.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5); // Top 5 reactors per sender
|
|
|
|
stats.topReactions.set(sender, new Map(sortedReactors));
|
|
});
|
|
|
|
console.log('Background: Calculated relationships:', stats.relationships.length);
|
|
console.log('Background: Top pairs:', stats.topPairs);
|
|
|
|
// Compute selectivity/bias metrics
|
|
// - Focus: share of a reactor's reactions going to a specific target
|
|
// - Lift: focus divided by target's share of messages (baseline)
|
|
// - Selectivity score: focus * lift (penalizes low-focus or low-lift pairs)
|
|
const selectivity = [];
|
|
const biasedReactors = [];
|
|
|
|
// Pre-compute per-reactor totals
|
|
const reactorTotals = new Map();
|
|
stats.byReactor.forEach((senders, reactor) => {
|
|
const tot = Array.from(senders.values()).reduce((s, n) => s + n, 0);
|
|
reactorTotals.set(reactor, tot);
|
|
});
|
|
|
|
// Pairs with scores
|
|
stats.byReactor.forEach((senders, reactor) => {
|
|
const rTot = reactorTotals.get(reactor) || 0;
|
|
if (!rTot) return;
|
|
const pairScores = [];
|
|
let hhi = 0;
|
|
senders.forEach((count, target) => {
|
|
const focus = count / rTot;
|
|
hhi += focus * focus;
|
|
const targetMsgShare = stats.messageShare.get(target) || 0.00001;
|
|
const lift = focus / targetMsgShare;
|
|
const sel = focus * lift;
|
|
const entry = { reactor, target, reactions: count, focus, lift, selectivity: sel, targetMessageShare: targetMsgShare };
|
|
pairScores.push(entry);
|
|
// Include all pairs; UI can sort and you can eyeball noise
|
|
selectivity.push(entry);
|
|
});
|
|
// Reactor-level bias index: concentration (HHI) reported as 0-1
|
|
pairScores.sort((a,b) => b.selectivity - a.selectivity);
|
|
biasedReactors.push({
|
|
reactor,
|
|
biasIndex: hhi, // 0..1, higher = more concentrated
|
|
totalReactions: rTot,
|
|
topTargets: pairScores.slice(0, 5)
|
|
});
|
|
});
|
|
|
|
selectivity.sort((a,b) => b.selectivity - a.selectivity);
|
|
|
|
// --- Reply-based bias metrics (responds bias) ---
|
|
const respondsByReplier = new Map(); // replier -> Map(target -> count)
|
|
const respondsByTarget = new Map(); // target -> Map(replier -> count)
|
|
iterMessages.forEach(([_, md]) => {
|
|
if (!md.replyTo) return;
|
|
const replier = md.sender;
|
|
const target = md.replyTo;
|
|
if (!respondsByReplier.has(replier)) respondsByReplier.set(replier, new Map());
|
|
if (!respondsByTarget.has(target)) respondsByTarget.set(target, new Map());
|
|
respondsByReplier.get(replier).set(target, (respondsByReplier.get(replier).get(target) || 0) + 1);
|
|
respondsByTarget.get(target).set(replier, (respondsByTarget.get(target).get(replier) || 0) + 1);
|
|
});
|
|
|
|
// Reactor-like totals but for repliers
|
|
const replierTotals = new Map();
|
|
respondsByReplier.forEach((targets, replier) => {
|
|
const tot = Array.from(targets.values()).reduce((s, n) => s + n, 0);
|
|
replierTotals.set(replier, tot);
|
|
});
|
|
|
|
const respondSelectivity = [];
|
|
const biasedResponders = [];
|
|
respondsByReplier.forEach((targets, replier) => {
|
|
const rTot = replierTotals.get(replier) || 0;
|
|
if (!rTot) return;
|
|
const pairScores = [];
|
|
let hhi = 0;
|
|
targets.forEach((count, target) => {
|
|
const focus = count / rTot;
|
|
hhi += focus * focus;
|
|
const targetMsgShare = stats.messageShare.get(target) || 0.00001;
|
|
const lift = focus / targetMsgShare;
|
|
const sel = focus * lift;
|
|
const entry = { replier, target, replies: count, focus, lift, selectivity: sel, targetMessageShare: targetMsgShare };
|
|
pairScores.push(entry);
|
|
respondSelectivity.push(entry);
|
|
});
|
|
pairScores.sort((a,b) => b.selectivity - a.selectivity);
|
|
biasedResponders.push({
|
|
replier,
|
|
biasIndex: hhi,
|
|
totalReplies: rTot,
|
|
topTargets: pairScores.slice(0, 5)
|
|
});
|
|
});
|
|
respondSelectivity.sort((a,b) => b.selectivity - a.selectivity);
|
|
|
|
// --- Simple combined relationships (emoji-agnostic, reactions + replies) ---
|
|
// For each person P, compute:
|
|
// - mostOutgoing: who P most reacts/replies to (combine byReactor[P] + respondsByReplier[P])
|
|
// - mostIncoming: who most reacts/replies to P (combine bySender[P] + respondsByTarget[P])
|
|
const people = new Set();
|
|
stats.bySender.forEach((_, sender) => people.add(sender));
|
|
stats.byReactor.forEach((_, reactor) => people.add(reactor));
|
|
respondsByReplier.forEach((_, rep) => people.add(rep));
|
|
respondsByTarget.forEach((_, tgt) => people.add(tgt));
|
|
// Ensure we include participants even if no interactions captured yet
|
|
stats.messageCount.forEach((_, participant) => people.add(participant));
|
|
|
|
const simpleRelationships = [];
|
|
people.forEach(person => {
|
|
// Outgoing
|
|
const outMap = new Map();
|
|
const reactOut = stats.byReactor.get(person);
|
|
if (reactOut) {
|
|
reactOut.forEach((cnt, target) => outMap.set(target, (outMap.get(target) || 0) + cnt));
|
|
}
|
|
const replyOut = respondsByReplier.get(person);
|
|
if (replyOut) {
|
|
replyOut.forEach((cnt, target) => outMap.set(target, (outMap.get(target) || 0) + cnt));
|
|
}
|
|
|
|
// Incoming
|
|
const inMap = new Map();
|
|
const reactIn = stats.bySender.get(person);
|
|
if (reactIn) {
|
|
reactIn.forEach((cnt, from) => inMap.set(from, (inMap.get(from) || 0) + cnt));
|
|
}
|
|
const replyIn = respondsByTarget.get(person);
|
|
if (replyIn) {
|
|
replyIn.forEach((cnt, from) => inMap.set(from, (inMap.get(from) || 0) + cnt));
|
|
}
|
|
|
|
const mostOutgoing = Array.from(outMap.entries()).sort((a,b)=>b[1]-a[1])[0] || null;
|
|
const mostIncoming = Array.from(inMap.entries()).sort((a,b)=>b[1]-a[1])[0] || null;
|
|
const outgoingTotal = Array.from(outMap.values()).reduce((s,n)=>s+n,0);
|
|
const incomingTotal = Array.from(inMap.values()).reduce((s,n)=>s+n,0);
|
|
|
|
simpleRelationships.push({
|
|
person,
|
|
mostOutgoing: mostOutgoing ? { target: mostOutgoing[0], count: mostOutgoing[1] } : null,
|
|
mostIncoming: mostIncoming ? { from: mostIncoming[0], count: mostIncoming[1] } : null,
|
|
totals: { outgoing: outgoingTotal, incoming: incomingTotal }
|
|
});
|
|
});
|
|
simpleRelationships.sort((a,b)=> (b.totals.outgoing + b.totals.incoming) - (a.totals.outgoing + a.totals.incoming));
|
|
|
|
// Enhanced Analytics Calculations
|
|
|
|
// 1. Temporal Analysis
|
|
this.calculateTemporalAnalysis(iterMessages, stats);
|
|
|
|
// 2. Engagement Metrics
|
|
this.calculateEngagementMetrics(iterMessages, stats);
|
|
|
|
// 3. Content Analysis
|
|
this.calculateContentAnalysis(iterMessages, stats);
|
|
|
|
// 4. Data Quality Assessment
|
|
this.calculateDataQuality(iterMessages, stats);
|
|
|
|
return {
|
|
messageShare: Object.fromEntries(stats.messageShare),
|
|
biasedReactors,
|
|
selectivity,
|
|
// reply bias outputs
|
|
respondsByReplier: Object.fromEntries(
|
|
Array.from(respondsByReplier.entries()).map(([rep, targets]) => [rep, Object.fromEntries(targets)])
|
|
),
|
|
respondsByTarget: Object.fromEntries(
|
|
Array.from(respondsByTarget.entries()).map(([tgt, reps]) => [tgt, Object.fromEntries(reps)])
|
|
),
|
|
biasedResponders,
|
|
respondSelectivity,
|
|
simpleRelationships,
|
|
chatId: chatId || null,
|
|
bySender: Object.fromEntries(
|
|
Array.from(stats.bySender.entries()).map(([sender, reactors]) => [
|
|
sender,
|
|
Object.fromEntries(reactors)
|
|
])
|
|
),
|
|
byReactor: Object.fromEntries(
|
|
Array.from(stats.byReactor.entries()).map(([reactor, senders]) => [
|
|
reactor,
|
|
Object.fromEntries(senders)
|
|
])
|
|
),
|
|
topReactions: Object.fromEntries(
|
|
Array.from(stats.topReactions.entries()).map(([sender, reactors]) => [
|
|
sender,
|
|
Object.fromEntries(reactors)
|
|
])
|
|
),
|
|
totalMessages: stats.totalMessages,
|
|
totalReactions: stats.totalReactions,
|
|
messageCount: Object.fromEntries(stats.messageCount),
|
|
reactionRates: Object.fromEntries(stats.reactionRates),
|
|
relationships: stats.relationships,
|
|
topPairs: stats.topPairs,
|
|
// Enhanced analytics
|
|
temporalAnalysis: {
|
|
hourlyActivity: Object.fromEntries(stats.temporalAnalysis.hourlyActivity),
|
|
dailyActivity: Object.fromEntries(stats.temporalAnalysis.dailyActivity),
|
|
weeklyPatterns: Object.fromEntries(stats.temporalAnalysis.weeklyPatterns),
|
|
activityTrends: stats.temporalAnalysis.activityTrends,
|
|
peakHours: stats.temporalAnalysis.peakHours,
|
|
peakDays: stats.temporalAnalysis.peakDays
|
|
},
|
|
engagementMetrics: {
|
|
responseTime: Object.fromEntries(stats.engagementMetrics.responseTime),
|
|
conversationThreads: stats.engagementMetrics.conversationThreads,
|
|
activeParticipants: Array.from(stats.engagementMetrics.activeParticipants),
|
|
lurkers: Array.from(stats.engagementMetrics.lurkers),
|
|
influencers: Object.fromEntries(stats.engagementMetrics.influencers),
|
|
networkDensity: stats.engagementMetrics.networkDensity,
|
|
clusteringCoefficient: stats.engagementMetrics.clusteringCoefficient
|
|
},
|
|
contentAnalysis: {
|
|
emojiUsage: Object.fromEntries(stats.contentAnalysis.emojiUsage),
|
|
reactionTypes: Object.fromEntries(stats.contentAnalysis.reactionTypes),
|
|
messageLengths: Object.fromEntries(stats.contentAnalysis.messageLengths),
|
|
replyChains: stats.contentAnalysis.replyChains,
|
|
topicClusters: stats.contentAnalysis.topicClusters
|
|
},
|
|
dataQuality: stats.dataQuality
|
|
};
|
|
}
|
|
|
|
// Enhanced Analytics Methods
|
|
|
|
calculateTemporalAnalysis(iterMessages, stats) {
|
|
const hourlyActivity = new Map();
|
|
const dailyActivity = new Map();
|
|
const weeklyPatterns = new Map();
|
|
const activityTrends = [];
|
|
|
|
// Initialize hourly activity (0-23)
|
|
for (let i = 0; i < 24; i++) {
|
|
hourlyActivity.set(i, { messages: 0, reactions: 0 });
|
|
}
|
|
|
|
// Initialize weekly patterns (0-6, Sunday-Saturday)
|
|
for (let i = 0; i < 7; i++) {
|
|
weeklyPatterns.set(i, { messages: 0, reactions: 0 });
|
|
}
|
|
|
|
iterMessages.forEach(([messageId, messageData]) => {
|
|
const timestamp = messageData.timestamp;
|
|
const date = new Date(timestamp);
|
|
const hour = date.getHours();
|
|
const dayOfWeek = date.getDay();
|
|
const dateKey = date.toISOString().split('T')[0];
|
|
|
|
// Hourly activity
|
|
const hourlyData = hourlyActivity.get(hour);
|
|
hourlyData.messages++;
|
|
|
|
// Count reactions for this message
|
|
let messageReactions = 0;
|
|
if (messageData.reactions) {
|
|
messageData.reactions.forEach(reactors => {
|
|
if (reactors && typeof reactors.forEach === 'function') {
|
|
reactors.forEach(count => messageReactions += count);
|
|
} else if (reactors && typeof reactors === 'object') {
|
|
Object.values(reactors).forEach(count => messageReactions += count);
|
|
}
|
|
});
|
|
}
|
|
hourlyData.reactions += messageReactions;
|
|
|
|
// Daily activity
|
|
if (!dailyActivity.has(dateKey)) {
|
|
dailyActivity.set(dateKey, { messages: 0, reactions: 0, participants: new Set() });
|
|
}
|
|
const dailyData = dailyActivity.get(dateKey);
|
|
dailyData.messages++;
|
|
dailyData.reactions += messageReactions;
|
|
dailyData.participants.add(messageData.sender);
|
|
|
|
// Weekly patterns
|
|
const weeklyData = weeklyPatterns.get(dayOfWeek);
|
|
weeklyData.messages++;
|
|
weeklyData.reactions += messageReactions;
|
|
});
|
|
|
|
// Calculate activity trends (daily aggregation)
|
|
const sortedDates = Array.from(dailyActivity.keys()).sort();
|
|
sortedDates.forEach(dateKey => {
|
|
const dailyData = dailyActivity.get(dateKey);
|
|
activityTrends.push({
|
|
date: dateKey,
|
|
messages: dailyData.messages,
|
|
reactions: dailyData.reactions,
|
|
participants: dailyData.participants.size
|
|
});
|
|
});
|
|
|
|
// Find peak hours and days
|
|
const peakHours = Array.from(hourlyActivity.entries())
|
|
.sort((a, b) => b[1].messages - a[1].messages)
|
|
.slice(0, 3)
|
|
.map(([hour, data]) => ({ hour, messages: data.messages, reactions: data.reactions }));
|
|
|
|
const peakDays = Array.from(weeklyPatterns.entries())
|
|
.sort((a, b) => b[1].messages - a[1].messages)
|
|
.slice(0, 3)
|
|
.map(([day, data]) => ({
|
|
day,
|
|
dayName: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][day],
|
|
messages: data.messages,
|
|
reactions: data.reactions
|
|
}));
|
|
|
|
stats.temporalAnalysis.hourlyActivity = hourlyActivity;
|
|
stats.temporalAnalysis.dailyActivity = dailyActivity;
|
|
stats.temporalAnalysis.weeklyPatterns = weeklyPatterns;
|
|
stats.temporalAnalysis.activityTrends = activityTrends;
|
|
stats.temporalAnalysis.peakHours = peakHours;
|
|
stats.temporalAnalysis.peakDays = peakDays;
|
|
}
|
|
|
|
calculateEngagementMetrics(iterMessages, stats) {
|
|
const responseTime = new Map();
|
|
const conversationThreads = [];
|
|
const activeParticipants = new Set();
|
|
const lurkers = new Set();
|
|
const influencers = new Map();
|
|
|
|
// Track all participants
|
|
const allParticipants = new Set();
|
|
iterMessages.forEach(([messageId, messageData]) => {
|
|
allParticipants.add(messageData.sender);
|
|
});
|
|
|
|
// Calculate response times and conversation threads
|
|
const messageTimestamps = new Map();
|
|
iterMessages.forEach(([messageId, messageData]) => {
|
|
messageTimestamps.set(messageId, messageData.timestamp);
|
|
});
|
|
|
|
// Identify active participants (those who send messages frequently)
|
|
const messageCounts = new Map();
|
|
iterMessages.forEach(([messageId, messageData]) => {
|
|
const count = messageCounts.get(messageData.sender) || 0;
|
|
messageCounts.set(messageData.sender, count + 1);
|
|
});
|
|
|
|
// Filter out "Unknown" senders for better analytics
|
|
const validParticipants = Array.from(allParticipants).filter(p => p !== 'Unknown');
|
|
|
|
if (validParticipants.length > 0) {
|
|
const avgMessagesPerPerson = iterMessages.length / validParticipants.length;
|
|
const activeThreshold = Math.max(avgMessagesPerPerson * 0.3, 1); // At least 1 message, or 30% of average
|
|
|
|
validParticipants.forEach(participant => {
|
|
const messageCount = messageCounts.get(participant) || 0;
|
|
if (messageCount >= activeThreshold) {
|
|
activeParticipants.add(participant);
|
|
} else {
|
|
lurkers.add(participant);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Calculate influence details per participant (total reactions, per-message rate)
|
|
const influencerDetails = [];
|
|
validParticipants.forEach(participant => {
|
|
let totalReactionsReceived = 0;
|
|
const totalMessages = messageCounts.get(participant) || 0;
|
|
|
|
iterMessages.forEach(([_, messageData]) => {
|
|
if (messageData.sender === participant && messageData.reactions) {
|
|
messageData.reactions.forEach(reactors => {
|
|
if (reactors && typeof reactors.forEach === 'function') {
|
|
reactors.forEach(count => { totalReactionsReceived += count; });
|
|
} else if (reactors && typeof reactors === 'object') {
|
|
Object.values(reactors).forEach(count => { totalReactionsReceived += count; });
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
const perMessage = totalMessages > 0 ? (totalReactionsReceived / totalMessages) : 0;
|
|
influencerDetails.push({
|
|
participant,
|
|
totalReactions: totalReactionsReceived,
|
|
totalMessages,
|
|
perMessage
|
|
});
|
|
});
|
|
|
|
// Normalize into a 0..100 influence score combining rate and volume
|
|
const maxPerMsg = Math.max(0.00001, ...influencerDetails.map(d => d.perMessage));
|
|
const maxTotal = Math.max(1, ...influencerDetails.map(d => d.totalReactions));
|
|
influencerDetails.forEach(d => {
|
|
const perMsgNorm = d.perMessage / maxPerMsg; // 0..1
|
|
const totalNorm = d.totalReactions / maxTotal; // 0..1
|
|
const score = Math.round(100 * (0.6 * perMsgNorm + 0.4 * totalNorm));
|
|
influencers.set(d.participant, {
|
|
score,
|
|
totalReactions: d.totalReactions,
|
|
perMessage: d.perMessage,
|
|
totalMessages: d.totalMessages
|
|
});
|
|
});
|
|
|
|
// Calculate network density (proportion of possible connections that exist)
|
|
const totalPossibleConnections = allParticipants.size * (allParticipants.size - 1);
|
|
let actualConnections = 0;
|
|
|
|
allParticipants.forEach(personA => {
|
|
allParticipants.forEach(personB => {
|
|
if (personA !== personB) {
|
|
// Check if there's any interaction between A and B
|
|
let hasInteraction = false;
|
|
iterMessages.forEach(([messageId, messageData]) => {
|
|
if (messageData.sender === personA && messageData.reactions) {
|
|
messageData.reactions.forEach(reactors => {
|
|
if (reactors && typeof reactors.has === 'function') {
|
|
if (reactors.has(personB)) hasInteraction = true;
|
|
} else if (reactors && typeof reactors === 'object') {
|
|
if (reactors[personB]) hasInteraction = true;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
if (hasInteraction) actualConnections++;
|
|
}
|
|
});
|
|
});
|
|
|
|
const networkDensity = totalPossibleConnections > 0 ? actualConnections / totalPossibleConnections : 0;
|
|
|
|
stats.engagementMetrics.responseTime = responseTime;
|
|
stats.engagementMetrics.conversationThreads = conversationThreads;
|
|
stats.engagementMetrics.activeParticipants = activeParticipants;
|
|
stats.engagementMetrics.lurkers = lurkers;
|
|
stats.engagementMetrics.influencers = influencers;
|
|
stats.engagementMetrics.networkDensity = networkDensity;
|
|
stats.engagementMetrics.clusteringCoefficient = 0; // Simplified for now
|
|
}
|
|
|
|
calculateContentAnalysis(iterMessages, stats) {
|
|
const emojiUsage = new Map();
|
|
const reactionTypes = new Map();
|
|
const messageLengths = new Map();
|
|
const replyChains = [];
|
|
|
|
// Track message lengths per sender
|
|
const messageLengthCounts = new Map();
|
|
const messageLengthTotals = new Map();
|
|
|
|
iterMessages.forEach(([messageId, messageData]) => {
|
|
const sender = messageData.sender;
|
|
|
|
// Count message lengths using per-message length (if available)
|
|
const length = typeof messageData.messageLength === 'number' ? messageData.messageLength : 0;
|
|
const count = messageLengthCounts.get(sender) || 0;
|
|
const total = messageLengthTotals.get(sender) || 0;
|
|
messageLengthCounts.set(sender, count + 1);
|
|
messageLengthTotals.set(sender, total + length);
|
|
|
|
// Analyze reactions
|
|
if (messageData.reactions) {
|
|
messageData.reactions.forEach((reactors, emoji) => {
|
|
// Count emoji usage
|
|
const emojiCount = emojiUsage.get(emoji) || 0;
|
|
emojiUsage.set(emoji, emojiCount + 1);
|
|
|
|
// Count reaction types
|
|
const reactionType = this.categorizeReaction(emoji);
|
|
const typeCount = reactionTypes.get(reactionType) || 0;
|
|
reactionTypes.set(reactionType, typeCount + 1);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Calculate average message lengths
|
|
messageLengthCounts.forEach((count, sender) => {
|
|
const total = messageLengthTotals.get(sender) || 0;
|
|
const avgLength = count > 0 ? (total / count) : 0;
|
|
messageLengths.set(sender, avgLength);
|
|
});
|
|
|
|
stats.contentAnalysis.emojiUsage = emojiUsage;
|
|
stats.contentAnalysis.reactionTypes = reactionTypes;
|
|
stats.contentAnalysis.messageLengths = messageLengths;
|
|
stats.contentAnalysis.replyChains = replyChains;
|
|
stats.contentAnalysis.topicClusters = []; // Simplified for now
|
|
}
|
|
|
|
calculateDataQuality(iterMessages, stats) {
|
|
const totalMessages = iterMessages.length;
|
|
let messagesWithReactions = 0;
|
|
let totalReactions = 0;
|
|
|
|
iterMessages.forEach(([messageId, messageData]) => {
|
|
if (messageData.reactions && messageData.reactions.size > 0) {
|
|
messagesWithReactions++;
|
|
messageData.reactions.forEach(reactors => {
|
|
if (reactors && typeof reactors.forEach === 'function') {
|
|
reactors.forEach(count => totalReactions += count);
|
|
} else if (reactors && typeof reactors === 'object') {
|
|
Object.values(reactors).forEach(count => totalReactions += count);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
const extractionRate = totalMessages > 0 ? (messagesWithReactions / totalMessages) * 100 : 0;
|
|
const completenessScore = Math.min(extractionRate * 1.2, 100); // Boost score slightly
|
|
const confidenceLevel = Math.min(completenessScore * 0.9, 95); // Slightly lower than completeness
|
|
|
|
stats.dataQuality.extractionRate = extractionRate;
|
|
stats.dataQuality.completenessScore = completenessScore;
|
|
stats.dataQuality.confidenceLevel = confidenceLevel;
|
|
stats.dataQuality.lastUpdated = Date.now();
|
|
stats.dataQuality.sampleSize = totalMessages;
|
|
}
|
|
|
|
categorizeReaction(emoji) {
|
|
// Categorize emojis into reaction types
|
|
const positiveEmojis = ['👍', '❤️', '😂', '😊', '🎉', '👏', '🔥', '💯', '😍', '🥰'];
|
|
const negativeEmojis = ['👎', '😢', '😡', '😠', '😞', '😔'];
|
|
const neutralEmojis = ['😮', '🤔', '😐', '😑', '🙄'];
|
|
|
|
if (positiveEmojis.includes(emoji)) return 'positive';
|
|
if (negativeEmojis.includes(emoji)) return 'negative';
|
|
if (neutralEmojis.includes(emoji)) return 'neutral';
|
|
return 'other';
|
|
}
|
|
|
|
async saveToStorage() {
|
|
try {
|
|
const dataToStore = {};
|
|
this.reactionData.forEach((value, key) => {
|
|
dataToStore[key] = {
|
|
sender: value.sender,
|
|
reactions: Object.fromEntries(
|
|
Array.from(value.reactions.entries()).map(([emoji, reactors]) => [
|
|
emoji,
|
|
Object.fromEntries(reactors)
|
|
])
|
|
),
|
|
timestamp: value.timestamp,
|
|
chatId: value.chatId || 'unknown',
|
|
chatName: value.chatName || 'Unknown Chat',
|
|
messageLength: typeof value.messageLength === 'number' ? value.messageLength : 0
|
|
};
|
|
});
|
|
|
|
await chrome.storage.local.set({ reactionData: dataToStore });
|
|
} catch (error) {
|
|
console.error('Error saving reaction data:', error);
|
|
}
|
|
}
|
|
|
|
async loadFromStorage() {
|
|
try {
|
|
const result = await chrome.storage.local.get(['reactionData']);
|
|
if (result.reactionData) {
|
|
Object.entries(result.reactionData).forEach(([messageId, messageData]) => {
|
|
this.reactionData.set(messageId, {
|
|
sender: messageData.sender,
|
|
reactions: new Map(
|
|
Object.entries(messageData.reactions).map(([emoji, reactors]) => [
|
|
emoji,
|
|
new Map(Object.entries(reactors))
|
|
])
|
|
),
|
|
timestamp: messageData.timestamp,
|
|
chatId: messageData.chatId || 'unknown',
|
|
chatName: messageData.chatName || 'Unknown Chat',
|
|
messageLength: typeof messageData.messageLength === 'number' ? messageData.messageLength : 0
|
|
});
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading reaction data:', error);
|
|
}
|
|
}
|
|
|
|
clearData() {
|
|
this.reactionData.clear();
|
|
chrome.storage.local.remove(['reactionData']);
|
|
}
|
|
|
|
dumpCorpus() {
|
|
const out = {};
|
|
this.reactionData.forEach((msg, id) => {
|
|
out[id] = {
|
|
sender: msg.sender,
|
|
reactions: Object.fromEntries(
|
|
Array.from(msg.reactions.entries()).map(([emoji, reactors]) => [emoji, Object.fromEntries(reactors)])
|
|
),
|
|
timestamp: msg.timestamp,
|
|
chatId: msg.chatId || 'unknown',
|
|
chatName: msg.chatName || 'Unknown Chat',
|
|
messageLength: typeof msg.messageLength === 'number' ? msg.messageLength : 0,
|
|
replyTo: msg.replyTo || null
|
|
};
|
|
});
|
|
return out;
|
|
}
|
|
|
|
importCorpus(corpus) {
|
|
try {
|
|
Object.entries(corpus).forEach(([id, md]) => {
|
|
if (!this.reactionData.has(id)) {
|
|
this.reactionData.set(id, {
|
|
sender: md.sender,
|
|
reactions: new Map(),
|
|
timestamp: md.timestamp,
|
|
chatId: md.chatId || 'unknown',
|
|
chatName: md.chatName || 'Unknown Chat',
|
|
replyTo: md.replyTo || null,
|
|
messageLength: typeof md.messageLength === 'number' ? md.messageLength : 0
|
|
});
|
|
}
|
|
const existing = this.reactionData.get(id);
|
|
existing.sender = md.sender || existing.sender;
|
|
existing.timestamp = md.timestamp || existing.timestamp;
|
|
existing.chatId = md.chatId || existing.chatId;
|
|
existing.chatName = md.chatName || existing.chatName;
|
|
existing.replyTo = md.replyTo || existing.replyTo || null;
|
|
if (typeof md.messageLength === 'number') existing.messageLength = md.messageLength;
|
|
const reactions = md.reactions || {};
|
|
Object.entries(reactions).forEach(([emoji, reactors]) => {
|
|
if (!existing.reactions.has(emoji)) existing.reactions.set(emoji, new Map());
|
|
const map = existing.reactions.get(emoji);
|
|
Object.entries(reactors || {}).forEach(([reactor, count]) => {
|
|
map.set(reactor, count);
|
|
});
|
|
});
|
|
});
|
|
this.saveToStorage();
|
|
} catch (e) {
|
|
console.error('Error importing corpus:', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
getStoredChats() {
|
|
const map = new Map();
|
|
this.reactionData.forEach((msg) => {
|
|
if (msg.chatId) {
|
|
map.set(msg.chatId, msg.chatName || msg.chatId);
|
|
}
|
|
});
|
|
return Array.from(map.entries()).map(([id, name], index) => ({ id, name, type: 'stored' }));
|
|
}
|
|
|
|
// Serialize stats object to convert Maps to plain objects for message passing
|
|
serializeStats(stats) {
|
|
if (!stats) return null;
|
|
|
|
const serialized = JSON.parse(JSON.stringify(stats, (key, value) => {
|
|
if (value instanceof Map) {
|
|
return Object.fromEntries(value);
|
|
} else if (value instanceof Set) {
|
|
return Array.from(value);
|
|
}
|
|
return value;
|
|
}));
|
|
|
|
return serialized;
|
|
}
|
|
}
|
|
|
|
// Initialize the processor
|
|
const processor = new ReactionDataProcessor();
|
|
|
|
// Load existing data on startup
|
|
processor.loadFromStorage();
|