Add initial VibeGuard browser extension

This commit is contained in:
Jordan Wages 2026-08-24 15:27:46 -05:00
commit 9c65a61ebb
43 changed files with 125275 additions and 2 deletions

41
src/content/filter.ts Normal file
View file

@ -0,0 +1,41 @@
import type { InferenceResult, Settings } from "../shared/types";
const HIDDEN = "data-vibeguard-hidden";
const ORIGINAL_DISPLAY = "data-vibeguard-display";
export function applyResult(element: Element, result: InferenceResult, settings: Settings): void {
const shouldFilter = result.label === "toxic" && result.probability >= settings.threshold;
if (!shouldFilter) { restore(element); return; }
element.setAttribute(HIDDEN, "true");
if (settings.filterMode === "hide") {
element.setAttribute(ORIGINAL_DISPLAY, (element as HTMLElement).style.display);
(element as HTMLElement).style.display = "none";
return;
}
const html = element as HTMLElement;
if (!html.dataset.vibeguardOriginalDisplay) html.dataset.vibeguardOriginalDisplay = html.style.display;
html.style.display = "none";
let placeholder = element.nextElementSibling;
if (!placeholder?.matches("[data-vibeguard-placeholder]")) {
placeholder = document.createElement("div");
placeholder.setAttribute("data-vibeguard-placeholder", "true");
element.insertAdjacentElement("afterend", placeholder);
}
placeholder.className = "vibeguard-placeholder";
placeholder.textContent = `Content hidden as toxic${settings.showScore ? ` (${Math.round(result.probability * 100)}%)` : ""}`;
const button = document.createElement("button");
button.type = "button";
button.textContent = "Show";
button.addEventListener("click", () => { restore(element); placeholder?.remove(); });
placeholder.append(" ", button);
}
export function restore(element: Element): void {
const html = element as HTMLElement;
html.style.display = html.dataset.vibeguardOriginalDisplay ?? "";
delete html.dataset.vibeguardOriginalDisplay;
element.removeAttribute(HIDDEN);
element.nextElementSibling?.matches("[data-vibeguard-placeholder]") && element.nextElementSibling.remove();
}

48
src/content/main.ts Normal file
View file

@ -0,0 +1,48 @@
import { createParser, siteForLocation } from "./parsers";
import { applyResult } from "./filter";
import { loadSettings } from "../shared/settings";
import { hashText } from "../shared/hash";
import { priorityFor } from "../inference/queue";
import type { InferenceRequest, RuntimeMessage, Settings } from "../shared/types";
const site = siteForLocation();
if (site) void start(site);
async function start(activeSite: NonNullable<typeof site>): Promise<void> {
let settings = await requestSettings();
if (!settings.enabledSites[activeSite]) return;
const parser = createParser(activeSite);
const navigationId = crypto.randomUUID();
const seen = new WeakSet<Element>();
const process = (posts: ReturnType<typeof parser.discover>): void => {
posts.forEach((post) => {
if (seen.has(post.element)) return;
seen.add(post.element);
const request: InferenceRequest = {
id: post.id, text: post.text, site: activeSite, navigationId,
priority: priorityFor(isVisible(post.element), document.visibilityState === "visible"),
requestId: crypto.randomUUID()
};
void chrome.runtime.sendMessage<RuntimeMessage, { type: "INFERENCE_RESULT"; result: Parameters<typeof applyResult>[1] }>( { type: "INFER", request })
.then((response) => { if (response?.result) applyResult(post.element, response.result, settings); });
});
};
process(parser.discover(document));
const stop = parser.observe(process);
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area === "local" && changes["vibeguard.settings"]?.newValue) settings = await loadSettings();
});
window.addEventListener("pagehide", () => { stop(); parser.dispose(); });
}
async function requestSettings(): Promise<Settings> {
const response = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });
return response?.settings ?? loadSettings();
}
function isVisible(element: Element): boolean {
const rect = element.getBoundingClientRect();
return rect.bottom >= 0 && rect.top <= window.innerHeight;
}

View file

@ -0,0 +1,40 @@
import type { IPostParser, NormalizedPost, SupportedSite } from "../../shared/types";
export abstract class SelectorParser implements IPostParser {
abstract readonly site: SupportedSite;
protected abstract readonly selectors: string[];
discover(root: Document | Element): NormalizedPost[] {
const nodes = root.querySelectorAll(this.selectors.join(","));
return Array.from(nodes).flatMap((element, index) => {
const text = this.extractText(element);
if (!text) return [];
const id = this.getId(element, index);
element.setAttribute("data-vibeguard-id", id);
return [{ id, text, element, site: this.site }];
});
}
observe(onPosts: (posts: NormalizedPost[]) => void): () => void {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) onPosts(this.discover(node as Element));
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}
dispose(): void {}
protected extractText(element: Element): string {
return (element.getAttribute("data-vibeguard-text") ?? element.textContent ?? "").replace(/\s+/g, " ").trim();
}
private getId(element: Element, index: number): string {
const nativeId = element.getAttribute("data-testid") ?? element.getAttribute("data-id") ?? element.id;
return `${this.site}:${nativeId || `${index}-${this.extractText(element).slice(0, 32)}`}`;
}
}

View file

@ -0,0 +1,6 @@
import { SelectorParser } from "./base";
export class FacebookParser extends SelectorParser {
readonly site = "facebook" as const;
protected readonly selectors = ["div[role='article']"];
}

View file

@ -0,0 +1,17 @@
import type { IPostParser, SupportedSite } from "../../shared/types";
import { FacebookParser } from "./facebook";
import { RedditParser } from "./reddit";
import { TwitterParser } from "./twitter";
export function siteForLocation(hostname = location.hostname): SupportedSite | undefined {
if (hostname === "reddit.com" || hostname.endsWith(".reddit.com")) return "reddit";
if (hostname === "x.com" || hostname.endsWith(".x.com") || hostname === "twitter.com" || hostname.endsWith(".twitter.com")) return "twitter";
if (hostname === "facebook.com" || hostname.endsWith(".facebook.com")) return "facebook";
return undefined;
}
export function createParser(site: SupportedSite): IPostParser {
if (site === "reddit") return new RedditParser();
if (site === "twitter") return new TwitterParser();
return new FacebookParser();
}

View file

@ -0,0 +1,6 @@
import { SelectorParser } from "./base";
export class RedditParser extends SelectorParser {
readonly site = "reddit" as const;
protected readonly selectors = ["shreddit-post", "shreddit-comment", "article[data-testid='post-container']", "div[data-testid='comment']"];
}

View file

@ -0,0 +1,6 @@
import { SelectorParser } from "./base";
export class TwitterParser extends SelectorParser {
readonly site = "twitter" as const;
protected readonly selectors = ["article[data-testid='tweet']"];
}