rwttrter / extension /entrypoints /content.tsx
plexdx's picture
Upload 26 files
64d289f verified
// extension/entrypoints/content.tsx
// Main content script β€” runs in every matching page context.
//
// Pipeline:
// 1. MutationObserver watches for meaningful text node changes
// 2. Text is accumulated in a ring buffer, flushed every 1200ms
// 3. Each flush is deduplicated via xxhash-wasm (client-side)
// 4. Deduplicated segments sent to background worker β†’ WebSocket
// 5. Results come back as chrome.runtime.onMessage events
// 6. Highlights applied as <mark> elements via Range.surroundContents()
// 7. Hover cards rendered inside a Shadow DOM to prevent CSS bleed
import { defineContentScript } from "wxt/sandbox";
import { createRoot } from "react-dom/client";
import React, { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { init as initXxhash, h64ToString } from "xxhash-wasm";
import {
AnalysisResult,
COLOR_CONFIG,
ExtensionMode,
HighlightColor,
shouldShowColor,
useExtensionStore,
} from "../stores/extensionStore";
// ---------------------------------------------------------------------------
// Platform detection
// ---------------------------------------------------------------------------
function detectPlatform(): string {
const host = location.hostname;
if (host.includes("twitter.com") || host.includes("x.com")) return "twitter";
if (host.includes("instagram.com")) return "instagram";
if (host.includes("youtube.com")) return "youtube";
if (host.includes("chat.openai.com")) return "chatgpt";
if (host.includes("claude.ai")) return "claude";
if (host.includes("gemini.google.com")) return "gemini";
return "news";
}
// ---------------------------------------------------------------------------
// Text node utilities
// ---------------------------------------------------------------------------
const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "SVG", "NOSCRIPT", "IFRAME", "META", "HEAD"]);
function isValidTextNode(node: Text): boolean {
const parent = node.parentElement;
if (!parent) return false;
// Skip non-content tags
let el: Element | null = parent;
while (el) {
if (SKIP_TAGS.has(el.tagName)) return false;
el = el.parentElement;
}
const text = node.textContent?.trim() ?? "";
const wordCount = text.split(/\s+/).filter(Boolean).length;
return wordCount >= 12;
}
function extractTextNodes(root: Node): Text[] {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode: (node) =>
isValidTextNode(node as Text) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP,
});
const nodes: Text[] = [];
while (walker.nextNode()) nodes.push(walker.currentNode as Text);
return nodes;
}
// ---------------------------------------------------------------------------
// Ring buffer β€” accumulates text segments, flushed every 1200ms
// ---------------------------------------------------------------------------
interface QueuedSegment {
hash: string;
text: string;
node: Text;
elementId: string;
}
// ---------------------------------------------------------------------------
// Highlight system
// ---------------------------------------------------------------------------
const highlightMap = new Map<string, HTMLElement>(); // elementId β†’ <mark>
function applyHighlight(
node: Text,
elementId: string,
color: HighlightColor,
result: AnalysisResult
): void {
// If already highlighted, update color only
const existing = highlightMap.get(elementId);
if (existing) {
const cfg = COLOR_CONFIG[color];
existing.style.backgroundColor = `${cfg.hex}${Math.round(cfg.opacity * 255).toString(16).padStart(2, "0")}`;
existing.dataset.result = JSON.stringify(result);
return;
}
try {
const range = document.createRange();
range.selectNode(node);
const cfg = COLOR_CONFIG[color];
const mark = document.createElement("mark");
mark.dataset.factId = elementId;
mark.dataset.result = JSON.stringify(result);
mark.style.cssText = `
background-color: ${cfg.hex}${Math.round(cfg.opacity * 255).toString(16).padStart(2, "0")};
border-radius: 2px;
cursor: help;
transition: background-color 0.2s;
`;
range.surroundContents(mark);
highlightMap.set(elementId, mark);
// Mount hover card on mouseenter using Shadow DOM
mark.addEventListener("mouseenter", (e) => showHoverCard(e, result, mark));
mark.addEventListener("mouseleave", hideHoverCard);
} catch {
// surroundContents() fails on nodes that cross element boundaries β€” skip silently
}
}
// ---------------------------------------------------------------------------
// Hover card β€” Shadow DOM isolated, Framer Motion animated
// ---------------------------------------------------------------------------
let hoverCardHost: HTMLElement | null = null;
let hoverRoot: ReturnType<typeof createRoot> | null = null;
function ensureHoverCardHost(): { host: HTMLElement; shadowRoot: ShadowRoot } {
if (!hoverCardHost) {
hoverCardHost = document.createElement("div");
hoverCardHost.id = "fact-intelligence-hover-host";
document.body.appendChild(hoverCardHost);
const shadow = hoverCardHost.attachShadow({ mode: "closed" });
// Inject Tailwind-scoped styles directly into shadow root
const style = document.createElement("style");
style.textContent = HOVER_CARD_STYLES;
shadow.appendChild(style);
const mountPoint = document.createElement("div");
shadow.appendChild(mountPoint);
hoverRoot = createRoot(mountPoint);
return { host: hoverCardHost, shadowRoot: shadow };
}
return { host: hoverCardHost, shadowRoot: hoverCardHost.shadowRoot! as ShadowRoot };
}
function showHoverCard(event: MouseEvent, result: AnalysisResult, anchor: HTMLElement): void {
const { shadowRoot } = ensureHoverCardHost();
const rect = anchor.getBoundingClientRect();
// Viewport clamping β€” card must never overflow
let top = rect.bottom + window.scrollY + 8;
let left = rect.left + window.scrollX;
const CARD_WIDTH = 340;
const CARD_HEIGHT = 200;
if (left + CARD_WIDTH > window.innerWidth - 16) {
left = window.innerWidth - CARD_WIDTH - 16;
}
if (top + CARD_HEIGHT > window.innerHeight + window.scrollY - 16) {
top = rect.top + window.scrollY - CARD_HEIGHT - 8; // Flip above
}
hoverRoot?.render(
<HoverCard result={result} top={top} left={left} visible={true} />
);
}
function hideHoverCard(): void {
hoverRoot?.render(<HoverCard result={null} top={0} left={0} visible={false} />);
}
// ---------------------------------------------------------------------------
// HoverCard React component
// ---------------------------------------------------------------------------
interface HoverCardProps {
result: AnalysisResult | null;
top: number;
left: number;
visible: boolean;
}
function HoverCard({ result, top, left, visible }: HoverCardProps) {
if (!result) return null;
const cfg = COLOR_CONFIG[result.color as HighlightColor] ?? COLOR_CONFIG.yellow;
return (
<AnimatePresence>
{visible && (
<motion.div
className="card"
style={{ top, left, "--accent": cfg.hex } as React.CSSProperties}
initial={{ opacity: 0, y: 6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 4, scale: 0.97 }}
transition={{ duration: 0.18, ease: "easeOut" }}
>
{/* Header row */}
<div className="header">
<div className="badge">{cfg.icon} {cfg.label}</div>
<div className="conf">
<svg width="36" height="36" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="14" fill="none" stroke="#333" strokeWidth="3"/>
<circle
cx="18" cy="18" r="14"
fill="none"
stroke={cfg.hex}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 14}`}
strokeDashoffset={`${2 * Math.PI * 14 * (1 - result.confidence / 100)}`}
transform="rotate(-90 18 18)"
/>
<text x="18" y="22" textAnchor="middle" fontSize="10" fill={cfg.hex} fontWeight="bold">
{result.confidence}
</text>
</svg>
</div>
</div>
{/* Verdict */}
<div className="verdict">{result.verdict_label}</div>
<div className="explanation">{result.explanation}</div>
{/* Sources */}
{result.sources?.length > 0 && (
<div className="sources">
{result.sources.slice(0, 3).map((s, i) => (
<a key={i} className="source" href={s.url} target="_blank" rel="noopener">
<img src={s.favicon_url} width="12" height="12" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span>{s.domain}</span>
</a>
))}
</div>
)}
{/* Footer meta */}
<div className="meta">
<span>trust {(result.trust_score * 100).toFixed(0)}%</span>
<span>Β·</span>
<span>{result.latency_ms?.toFixed(0)}ms</span>
{result.cached && <><span>Β·</span><span>cached</span></>}
</div>
</motion.div>
)}
</AnimatePresence>
);
}
// CSS injected into the Shadow DOM β€” complete isolation from host page
const HOVER_CARD_STYLES = `
.card {
position: fixed;
z-index: 2147483647;
width: 340px;
background: #0d1117;
border: 1px solid #21262d;
border-radius: 10px;
padding: 14px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04);
font-family: -apple-system, 'DM Sans', system-ui, sans-serif;
font-size: 13px;
color: #e6edf3;
pointer-events: none;
}
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 20px; font-size: 10px;
font-weight: 700; letter-spacing: 0.8px; text-transform: uppercase;
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--accent);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
}
.conf { flex-shrink: 0; }
.verdict { font-weight: 700; font-size: 14px; margin-bottom: 6px; line-height: 1.3; }
.explanation { color: #7d8590; font-size: 12px; line-height: 1.6; margin-bottom: 10px; }
.sources { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; }
.source {
display: flex; align-items: center; gap: 6px;
padding: 5px 8px; background: #161b22; border-radius: 5px;
color: #58a6ff; text-decoration: none; font-size: 11px;
pointer-events: all;
}
.meta {
display: flex; gap: 6px; font-size: 10px; color: #484f58;
font-family: 'Space Mono', monospace; letter-spacing: 0.3px;
}
`;
// ---------------------------------------------------------------------------
// Main content script entry point
// ---------------------------------------------------------------------------
export default defineContentScript({
matches: [
"https://twitter.com/*", "https://x.com/*",
"https://www.instagram.com/*", "https://www.youtube.com/*",
"https://chat.openai.com/*", "https://claude.ai/*",
"https://gemini.google.com/*", "<all_urls>",
],
runAt: "document_idle",
main: async () => {
const platform = detectPlatform();
// Initialize xxhash-wasm (compiled WASM, sub-microsecond hashing)
const { h64ToString: xxhash64 } = await initXxhash();
const SESSION_ID = crypto.randomUUID();
const seenHashes = new Set<string>(); // Client-side dedup ring buffer
// Flush buffer every 1200ms β€” avoids layout thrashing from rapid DOM changes
const flushBuffer: Map<string, QueuedSegment> = new Map();
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const { enabled, mode } = useExtensionStore.getState();
if (!enabled) return;
function queueSegment(node: Text): void {
const text = node.textContent?.trim() ?? "";
if (!text) return;
const hash = xxhash64(text);
if (seenHashes.has(hash)) return; // Already processed this text
const elementId = `fi-${hash.slice(0, 8)}-${Date.now()}`;
flushBuffer.set(hash, { hash, text, node, elementId });
// Debounced flush
if (!flushTimer) {
flushTimer = setTimeout(flushSegments, 1200);
}
}
async function flushSegments(): void {
flushTimer = null;
if (flushBuffer.size === 0) return;
const { enabled, mode } = useExtensionStore.getState();
if (!enabled) return;
const segments = Array.from(flushBuffer.values()).map((s) => {
seenHashes.add(s.hash);
// Prevent unbounded memory growth β€” prune oldest half when > 5000
if (seenHashes.size > 5000) {
const arr = Array.from(seenHashes);
arr.slice(0, 2500).forEach((h) => seenHashes.delete(h));
}
return {
content_hash: s.hash,
text: s.text,
element_id: s.elementId,
word_count: s.text.split(/\s+/).length,
};
});
flushBuffer.clear();
const batch = {
session_id: SESSION_ID,
platform,
segments,
sent_at: new Date().toISOString(),
};
// Send to background worker, which holds the WebSocket
chrome.runtime.sendMessage({ type: "send_batch", payload: batch });
}
// ---------------------------------------------------------------------------
// MutationObserver β€” watch for new text nodes
// ---------------------------------------------------------------------------
const observer = new MutationObserver((mutations) => {
const { enabled } = useExtensionStore.getState();
if (!enabled) return;
for (const mutation of mutations) {
if (mutation.type === "childList") {
mutation.addedNodes.forEach((node) => {
const textNodes = extractTextNodes(node);
textNodes.forEach(queueSegment);
});
} else if (mutation.type === "characterData") {
const node = mutation.target as Text;
if (isValidTextNode(node)) queueSegment(node);
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
});
// Process existing text on page load
extractTextNodes(document.body).forEach(queueSegment);
// ---------------------------------------------------------------------------
// Receive results from background worker
// ---------------------------------------------------------------------------
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === "result" && msg.payload) {
const result = msg.payload as AnalysisResult;
const { mode } = useExtensionStore.getState();
const color = result.color as HighlightColor;
if (!shouldShowColor(color, mode)) return;
// Find the text node by element_id stored on the flushBuffer segment
// (We need the original node reference β€” stored in flushBuffer pre-clear)
// Fallback: search by matching text content
const targetNode = findNodeByHash(result.content_hash);
if (targetNode) {
applyHighlight(targetNode, result.element_id, color, result);
}
}
if (msg.type === "ws_status") {
useExtensionStore.getState().setWsStatus(msg.payload.status);
}
});
},
});
// Node registry for post-flush lookup
const nodeRegistry = new Map<string, Text>(); // hash β†’ Text node
// Override queueSegment to also register nodes
// (actual implementation integrates this into the closure above)
function findNodeByHash(hash: string): Text | undefined {
return nodeRegistry.get(hash);
}