Mask non-current chatbot conversation titles with hover reveal and per-title unlocks.
// ==UserScript==
// @name ChatVeil
// @namespace https://github.com/local/chatveil
// @version 0.1.0
// @description Mask non-current chatbot conversation titles with hover reveal and per-title unlocks.
// @author Wonster
// @license MIT
// @match https://chatgpt.com/*
// @match https://*.chatgpt.com/*
// @match https://claude.ai/*
// @match https://*.claude.ai/*
// @match https://qwen.ai/*
// @match https://*.qwen.ai/*
// @match https://chat.qwen.ai/*
// @match https://minimax.io/*
// @match https://*.minimax.io/*
// @match https://agent.minimax.com/*
// @match https://agent.minimaxi.com/*
// @match https://hailuo.ai/*
// @match https://*.hailuo.ai/*
// @match https://hailuoai.video/*
// @match https://*.hailuoai.video/*
// @match https://chat.deepseek.com/*
// @match https://kimi.moonshot.cn/*
// @match https://doubao.com/*
// @match https://*.doubao.com/*
// @match https://yuanbao.tencent.com/*
// @match https://tongyi.com/*
// @match https://*.tongyi.com/*
// @match https://tongyi.aliyun.com/*
// @match https://yiyan.baidu.com/*
// @match https://gemini.google.com/*
// @match https://copilot.microsoft.com/*
// @match https://perplexity.ai/*
// @match https://*.perplexity.ai/*
// @match https://poe.com/*
// @match https://*.poe.com/*
// @match https://chat.mistral.ai/*
// @match https://grok.com/*
// @match https://*.grok.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @run-at document-idle
// ==/UserScript==
(function() {
//#region src/core/constants.ts
var SETTINGS_KEY = "chatVeilSettings";
var DATA_PREFIX = "chatveil";
var STYLE_ID = "chatveil-mask-style";
var KNOWN_HOSTS = [
"chatgpt.com",
"claude.ai",
"qwen.ai",
"chat.qwen.ai",
"minimax.io",
"chat.minimax.io",
"agent.minimax.com",
"agent.minimaxi.com",
"hailuo.ai",
"hailuoai.video",
"chat.deepseek.com",
"kimi.moonshot.cn",
"doubao.com",
"www.doubao.com",
"yuanbao.tencent.com",
"tongyi.com",
"www.tongyi.com",
"tongyi.aliyun.com",
"yiyan.baidu.com",
"gemini.google.com",
"copilot.microsoft.com",
"perplexity.ai",
"poe.com",
"chat.mistral.ai",
"grok.com"
];
var DEFAULT_SETTINGS = {
version: 1,
enabledHosts: {},
customHosts: [],
maskStyle: "skeleton",
customMaskChar: "•",
showAllUntilByHost: {},
unlockedKeysByHost: {},
syncEnabled: false
};
Array.from(new Set(KNOWN_HOSTS.flatMap((host) => [`https://${host}/*`, `https://*.${host}/*`])));
//#endregion
//#region src/core/adapters.ts
var titleSelector = [
"[data-testid*=\"conversation\"]",
"[data-testid*=\"history\"]",
"[data-testid*=\"chat\"]",
"[title]",
"span",
"p",
"div"
].join(",");
var rowActionSelector = [
"button",
"[role=\"button\"]",
"[aria-haspopup=\"menu\"]",
"[aria-label*=\"menu\" i]",
"[aria-label*=\"more\" i]",
"[aria-label*=\"option\" i]",
"[aria-label*=\"action\" i]",
"[aria-label*=\"conversation\" i]",
"[aria-label*=\"chat\" i]",
"[aria-label*=\"更多\" i]",
"[data-testid*=\"menu\" i]",
"[data-testid*=\"more\" i]",
"[data-testid*=\"option\" i]",
"[data-testid*=\"action\" i]"
].join(",");
function normalizedText(element) {
return element?.textContent?.replace(/\s+/g, " ").trim() ?? "";
}
function normalizeComparableText(text) {
return text.replace(/\s+/g, "").trim().toLowerCase();
}
function shouldIgnoreTitleText(text) {
const normalized = normalizeComparableText(text);
if (!normalized) return true;
if (/^(19|20)\d{2}([-/.]?\d{1,2})?$/.test(normalized)) return true;
return [
"newchat",
"newconversation",
"newthread",
"settings",
"logout",
"login",
"upgrade",
"help",
"search",
"newtask",
"explore",
"discover",
"apps",
"tools",
"plugins",
"community",
"chats",
"projects",
"newproject",
"artifacts",
"code",
"coder",
"customize",
"recents",
"today",
"yesterday",
"allchats",
"allconversations",
"slides",
"docs",
"deepresearch",
"websites",
"sheets",
"agentswarm",
"kimicode",
"kimiclaw",
"chathistory",
"mimoclaw",
"mimochat",
"maxhermes",
"maxclaw",
"skills",
"新对话",
"新建对话",
"开启新对话",
"新建任务",
"ai创作",
"更多",
"设置",
"登录",
"退出登录",
"帮助",
"搜索",
"发现",
"探索",
"应用",
"插件",
"工具",
"社区",
"项目",
"新项目",
"所有对话",
"今天",
"昨天",
"资产",
"专家",
"探索专家",
"任务记录",
"新会话",
"历史",
"历史记录",
"skills技能",
"minimax实验室"
].includes(normalized);
}
function cleanPath(pathname) {
return pathname.replace(/\/+$/g, "") || "/";
}
function parseHref(href, base) {
if (!href) return void 0;
try {
return new URL(href, base);
} catch {
return;
}
}
function hostMatches(hostname, host) {
const clean = hostname.toLowerCase();
return clean === host || clean.endsWith(`.${host}`);
}
function isElement(value) {
return value !== null && typeof value === "object" && "nodeType" in value && value.nodeType === 1;
}
function closestLinkActionRow(link) {
let current = link.parentElement;
for (let depth = 0; current && depth < 6; depth += 1, current = current.parentElement) {
if (current === current.ownerDocument.body || current === current.ownerDocument.documentElement) break;
const textLength = normalizedText(current).length;
if (textLength < 2 || textLength > 260) continue;
if (current.querySelectorAll("a[href], [role=\"link\"]").length !== 1) continue;
if (!current.querySelector(rowActionSelector)) continue;
return current;
}
}
function closestRow(element) {
const link = element.closest("a[href], [role=\"link\"]") ?? element;
const actionRow = closestLinkActionRow(link);
if (actionRow) return actionRow;
const container = Array.from(link.closest("body")?.querySelectorAll("li, [role=\"treeitem\"], [role=\"listitem\"], [data-testid], [data-qa], [aria-label*=\"conversation\" i], [aria-label*=\"chat\" i], [class*=\"conversation\"], [class*=\"history-item\"], [class*=\"chat-item\"], [class*=\"thread-item\"], [class*=\"session-item\"]") ?? []).filter((container) => container.contains(link)).sort((left, right) => {
return normalizedText(left).length - normalizedText(right).length;
}).find((candidate) => {
const textLength = normalizedText(candidate).length;
return textLength >= 2 && textLength <= 260 && Boolean(candidate.querySelector("a[href], [role=\"link\"], button, [role=\"button\"]"));
}) ?? link.closest("li, [role=\"treeitem\"], [role=\"listitem\"], [data-testid], [data-qa]");
return container?.querySelector("a[href], [role=\"link\"], button, [role=\"button\"]") ? container : link;
}
function chooseTitleElement(anchor) {
const titleAttrElement = anchor.querySelector("[title]");
if (titleAttrElement && normalizedText(titleAttrElement).length > 0 && !hasNonTitleChild(titleAttrElement)) return titleAttrElement;
const candidate = Array.from(anchor.querySelectorAll(titleSelector)).filter((candidate) => {
const controlAncestor = candidate.closest("button, [role=\"button\"]");
if (controlAncestor && controlAncestor !== anchor) return false;
if (candidate.closest("svg")) return false;
if (isActionElement(candidate)) return false;
if (isMediaElement(candidate)) return false;
if (hasNonTitleChild(candidate)) return false;
const text = normalizedText(candidate);
return text.length >= 2 && text.length <= 180;
}).sort((left, right) => {
const leftText = normalizedText(left);
const rightText = normalizedText(right);
return leftText.length - rightText.length;
})[0];
if (candidate) return candidate;
return candidate ?? ensureTextOnlyElement(anchor);
}
function isMediaElement(element) {
const className = typeof element.className === "string" ? element.className : "";
return [
"IMG",
"SVG",
"CANVAS",
"PICTURE",
"VIDEO"
].includes(element.tagName) || element.getAttribute("role") === "img" || /\b(icon|avatar)\b/i.test(className);
}
function hasMediaChild(element) {
return Boolean(element.querySelector("img, svg, canvas, picture, video, [role=\"img\"], .icon, [class*=\"icon\"], [class*=\"avatar\"]"));
}
function isActionElement(element) {
return element.matches(rowActionSelector);
}
function hasActionChild(element) {
return Boolean(element.querySelector(rowActionSelector));
}
function hasNonTitleChild(element) {
return hasMediaChild(element) || hasActionChild(element);
}
function isProfileOrAccountRow(row) {
const marker = [
row.className,
row.id,
row.getAttribute("data-testid"),
row.getAttribute("data-qa"),
row.getAttribute("aria-label")
].join(" ").toLowerCase();
const text = normalizedText(row).toLowerCase();
return /\b(user|profile|account|avatar|workspace|sidebar-user|footer)\b/.test(marker) || Boolean(row.closest("footer, [class*=\"footer\" i], [class*=\"account\" i], [class*=\"profile\" i], [class*=\"user\" i]")) || hasMediaChild(row) && /\b(pro|free|team|account|profile|个人|账号)\b/i.test(text);
}
function ensureTextOnlyElement(row, forceWrapper = false) {
const textNode = row.ownerDocument.createTreeWalker(row, NodeFilter.SHOW_TEXT, { acceptNode(node) {
const text = node.textContent?.replace(/\s+/g, " ").trim() ?? "";
if (text.length < 2 || text.length > 180) return NodeFilter.FILTER_REJECT;
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
const controlAncestor = parent.closest("button, [role=\"button\"]");
if (!forceWrapper && controlAncestor && controlAncestor !== row) return NodeFilter.FILTER_REJECT;
if (parent.closest("svg, [aria-hidden=\"true\"], [role=\"img\"], script, style")) return NodeFilter.FILTER_REJECT;
if (isActionElement(parent)) return NodeFilter.FILTER_REJECT;
if (hasNonTitleChild(parent) && normalizedText(parent) === text) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
} }).nextNode();
if (!textNode?.parentNode) return row;
const parent = textNode.parentElement;
if (!forceWrapper && parent && parent.childNodes.length === 1 && !hasNonTitleChild(parent)) return parent;
const wrapper = row.ownerDocument.createElement("span");
wrapper.dataset.chatveilTextWrapper = "true";
textNode.parentNode.insertBefore(wrapper, textNode);
wrapper.append(textNode);
return wrapper;
}
function hasActiveMarker(element) {
const marker = [
element.getAttribute("aria-current"),
element.getAttribute("data-active"),
element.getAttribute("data-current"),
element.getAttribute("data-selected"),
element.className
].join(" ").toLowerCase();
return /\b(page|true|active|current|selected)\b/.test(marker);
}
function isCurrentByHref(item, location) {
if (hasActiveMarker(item.row) || hasActiveMarker(item.title)) return true;
const url = parseHref(item.href, location.href);
if (!url || url.origin !== location.origin) return false;
return cleanPath(url.pathname) === cleanPath(location.pathname);
}
function stableKeyFromHref(item, location, index) {
const href = parseHref(item.href, location.href);
if (href) return `${href.origin}${cleanPath(href.pathname)}`;
const text = normalizedText(item.title).toLowerCase();
return `${location.origin}${cleanPath(location.pathname)}#${item.source ?? "item"}:${index}:${text}`;
}
function stableKeyFromDeepSeek(item, location, index) {
const id = item.row.getAttribute("data-conversation-id") ?? item.row.getAttribute("data-chat-id") ?? item.row.getAttribute("data-session-id") ?? item.row.getAttribute("data-id");
if (id) return `${location.origin}/deepseek/${id}`;
return stableKeyFromHref(item, location, index);
}
function itemsFromAnchors(root, predicate, source) {
return itemsFromAnchorsInContainers([root], root, predicate, source);
}
function itemsFromAnchorsInContainers(containers, root, predicate, source) {
const base = root.ownerDocument?.baseURI ?? globalThis.location?.href ?? "https://example.test/";
const seenRows = /* @__PURE__ */ new Set();
const seenAnchors = /* @__PURE__ */ new Set();
const output = [];
const anchors = containers.flatMap((container) => Array.from(container.querySelectorAll("a[href]")));
for (const anchor of anchors) {
if (seenAnchors.has(anchor)) continue;
seenAnchors.add(anchor);
const url = parseHref(anchor.getAttribute("href") ?? anchor.href, base);
if (!url || !predicate(url, anchor)) continue;
const title = chooseTitleElement(anchor);
const text = normalizedText(title);
if (text.length < 2 || text.length > 180) continue;
if (shouldIgnoreTitleText(text)) continue;
const row = closestRow(anchor);
if (seenRows.has(row)) continue;
seenRows.add(row);
output.push({
row,
title,
href: url.href,
source
});
}
return output;
}
function itemFromGenericLink(anchor, source) {
const title = chooseTitleElement(anchor);
const text = normalizedText(title);
if (text.length < 2 || text.length > 180) return void 0;
if (shouldIgnoreTitleText(text)) return void 0;
return {
row: closestRow(anchor),
title,
href: anchor.href || anchor.getAttribute("href") || void 0,
source
};
}
function isLikelyChatHistoryUrl(url) {
return /^\/(a\/)?chat(\/s)?\/[^/]+/.test(url.pathname) || /^\/(c|conversation|thread|session|chatroom)\/[^/]+/.test(url.pathname);
}
function historyContainers(root) {
return Array.from(root.querySelectorAll("aside, nav, [role=\"navigation\"], [data-sidebar], [class*=\"sidebar\"], [class*=\"history\"], [class*=\"conversation\"], [class*=\"chat-list\"], [class*=\"thread-list\"], [class*=\"session-list\"], [class*=\"task-list\"], [class*=\"record-list\"]"));
}
function isSidebarLikeContainer(element) {
return Boolean(element.closest("aside, nav, [role=\"navigation\"], [data-sidebar], [class*=\"sidebar\"]"));
}
function sidebarHistoryContainers(root) {
return historyContainers(root).filter(isSidebarLikeContainer);
}
function isLeftSidebarRow(element) {
const rect = element.getBoundingClientRect();
if (rect.width <= 0 && rect.height <= 0) return true;
const viewportWidth = element.ownerDocument.defaultView?.innerWidth ?? 0;
if (viewportWidth <= 0) return true;
const maxRight = Math.min(760, viewportWidth * .5);
const maxWidth = Math.min(620, viewportWidth * .45);
return rect.left >= -8 && rect.left <= Math.min(520, viewportWidth * .4) && rect.right <= maxRight && rect.width <= maxWidth;
}
function itemsFromSidebarAnchors(root, predicate, source) {
return itemsFromAnchorsInContainers(sidebarHistoryContainers(root), root, predicate, source);
}
function itemFromClickableRow(row, source) {
const title = chooseTitleElement(row);
const text = normalizedText(title);
if (text.length < 2 || text.length > 180) return void 0;
if (shouldIgnoreTitleText(text)) return void 0;
return {
row,
title,
href: row.querySelector("a[href]")?.href ?? void 0,
source
};
}
function itemsFromClickableRows(root, source) {
const seenRows = /* @__PURE__ */ new Set();
const output = [];
for (const container of historyContainers(root)) {
if (!isElement(container)) continue;
const candidates = Array.from(container.querySelectorAll([
"[data-conversation-id]",
"[data-chat-id]",
"[data-session-id]",
"[class*=\"conversation-item\"]",
"[class*=\"history-item\"]",
"[class*=\"chat-item\"]",
"[class*=\"thread-item\"]",
"[class*=\"session-item\"]",
"li[role=\"button\"]",
"[role=\"listitem\"][tabindex]",
"div[tabindex]"
].join(",")));
for (const row of candidates) {
if (row.querySelector("input, textarea")) continue;
const className = typeof row.className === "string" ? row.className : "";
if (!(row.hasAttribute("data-conversation-id") || row.hasAttribute("data-chat-id") || row.hasAttribute("data-session-id") || /conversation|history|chat-item|thread|session|task|record/i.test(className))) continue;
const item = itemFromClickableRow(row, source);
if (!item || seenRows.has(item.row)) continue;
seenRows.add(item.row);
output.push(item);
}
}
return output;
}
function isLikelyPlainHistoryRow(row, text) {
if (row.querySelector("input, textarea, select")) return false;
if (isProfileOrAccountRow(row)) return false;
if (row.matches("aside, nav, section, main, header, footer")) return false;
if (row.querySelectorAll("a[href], button, [role=\"button\"]").length > 1) return false;
if (/^(history|recents|chat history|all chats|all conversations|today|yesterday|任务记录|历史|历史记录|所有对话|今天|昨天)$/i.test(text)) return false;
if (/^(19|20)\d{2}([-/.]\d{1,2})?$/.test(text.trim())) return false;
if (Array.from(row.children).filter((child) => child instanceof HTMLElement).filter((child) => !isActionElement(child) && !isMediaElement(child) && !hasMediaChild(child)).map((child) => normalizedText(child)).filter((childText) => childText.length >= 2).length >= 2) return false;
return true;
}
function itemsFromLabeledPlainRows(root, source, labelPattern) {
const output = [];
const seenTexts = /* @__PURE__ */ new Set();
const containers = sidebarHistoryContainers(root);
for (const container of containers) {
const candidates = Array.from(container.querySelectorAll("button, div, li, [role=\"button\"], [role=\"listitem\"], [data-testid], [data-qa], [class*=\"item\"], [class*=\"row\"], [class*=\"task\"], [class*=\"record\"]"));
const label = candidates.find((candidate) => labelPattern.test(normalizedText(candidate)));
if (!label) continue;
if (containers.some((other) => other !== container && container.contains(other) && other.contains(label))) continue;
for (const row of candidates) {
if (!(label.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_FOLLOWING)) continue;
if (source === "minimax" && !isLeftSidebarRow(row)) continue;
const text = normalizedText(row);
if (text.length < 2 || text.length > 180) continue;
if (shouldIgnoreTitleText(text)) continue;
if (!isLikelyPlainHistoryRow(row, text)) continue;
const key = normalizeComparableText(text);
if (seenTexts.has(key)) continue;
const item = itemFromClickableRow(row, source);
if (!item) continue;
seenTexts.add(key);
output.push(item);
}
}
return output;
}
var chatGptAdapter = {
id: "chatgpt",
name: "ChatGPT",
matches(location) {
return hostMatches(location.hostname, "chatgpt.com");
},
findHistoryItems(root) {
return itemsFromAnchors(root, (url) => url.hostname.endsWith("chatgpt.com") && /^\/(c|g)\/[^/]+/.test(url.pathname), "chatgpt");
},
isCurrent: isCurrentByHref,
getStableKey: stableKeyFromHref
};
var claudeAdapter = {
id: "claude",
name: "Claude",
matches(location) {
return hostMatches(location.hostname, "claude.ai");
},
findHistoryItems(root) {
return itemsFromAnchors(root, (url) => url.hostname.endsWith("claude.ai") && /^\/chat\/[^/]+/.test(url.pathname), "claude");
},
isCurrent: isCurrentByHref,
getStableKey: stableKeyFromHref
};
var qwenAdapter = {
id: "qwen",
name: "Qwen",
matches(location) {
return hostMatches(location.hostname, "qwen.ai");
},
findHistoryItems(root) {
const anchorItems = itemsFromAnchors(root, (url) => url.hostname.endsWith("qwen.ai") && /^\/(chat|c)\/[^/]+/.test(url.pathname), "qwen");
const seenRows = new Set(anchorItems.map((item) => item.row));
const rowItems = itemsFromLabeledPlainRows(root, "qwen", /^(all chats|all conversations|today|yesterday|所有对话|今天|昨天|(19|20)\d{2}([-/.]\d{1,2})?)$/i).filter((item) => !seenRows.has(item.row));
return [...anchorItems, ...rowItems];
},
isCurrent: isCurrentByHref,
getStableKey: stableKeyFromHref
};
var deepSeekAdapter = {
id: "deepseek",
name: "DeepSeek",
matches(location) {
return hostMatches(location.hostname, "chat.deepseek.com");
},
findHistoryItems(root) {
const anchorItems = itemsFromAnchors(root, (url) => url.hostname.endsWith("chat.deepseek.com") && isLikelyChatHistoryUrl(url), "deepseek");
const seenRows = new Set(anchorItems.map((item) => item.row));
const rowItems = itemsFromClickableRows(root, "deepseek").filter((item) => !seenRows.has(item.row));
return [...anchorItems, ...rowItems];
},
isCurrent: isCurrentByHref,
getStableKey: stableKeyFromDeepSeek
};
var minimaxAdapter = {
id: "minimax",
name: "MiniMax",
matches(location) {
return hostMatches(location.hostname, "minimax.io") || hostMatches(location.hostname, "agent.minimax.com") || hostMatches(location.hostname, "agent.minimaxi.com");
},
findHistoryItems(root) {
const anchorItems = itemsFromSidebarAnchors(root, (url) => (url.hostname.endsWith("minimax.io") || url.hostname.endsWith("minimax.com") || url.hostname.endsWith("minimaxi.com")) && (/^\/(chat|conversation|thread|task)\b/.test(url.pathname) || url.searchParams.has("id")), "minimax").filter((item) => isLeftSidebarRow(item.row));
const seenRows = new Set(anchorItems.map((item) => item.row));
const rowItems = itemsFromLabeledPlainRows(root, "minimax", /^(任务记录|task records?\b|task history\b|history\b)/i).filter((item) => !seenRows.has(item.row));
return [...anchorItems, ...rowItems];
},
isCurrent: isCurrentByHref,
getStableKey: stableKeyFromHref
};
var genericSidebarAdapter = {
id: "generic",
name: "Generic chatbot sidebar",
matches() {
return true;
},
findHistoryItems(root) {
const containers = historyContainers(root);
const seenRows = /* @__PURE__ */ new Set();
const output = [];
for (const container of containers) {
if (!isElement(container)) continue;
for (const anchor of Array.from(container.querySelectorAll("a[href]"))) {
const parsed = parseHref(anchor.getAttribute("href") ?? anchor.href, root.ownerDocument?.baseURI ?? globalThis.location?.href ?? "https://example.test/");
if (parsed && !isLikelyChatHistoryUrl(parsed) && !/\/(chat|conversation|thread|session)\b/.test(parsed.pathname)) continue;
const item = itemFromGenericLink(anchor, "generic");
if (!item || seenRows.has(item.row)) continue;
seenRows.add(item.row);
output.push(item);
}
}
for (const item of itemsFromClickableRows(root, "generic")) {
if (seenRows.has(item.row)) continue;
seenRows.add(item.row);
output.push(item);
}
return output;
},
isCurrent: isCurrentByHref,
getStableKey: stableKeyFromHref
};
var defaultAdapters = [
chatGptAdapter,
claudeAdapter,
qwenAdapter,
deepSeekAdapter,
minimaxAdapter,
genericSidebarAdapter
];
function selectAdapter(adapters, location) {
return adapters.find((adapter) => adapter.id !== "generic" && adapter.matches(location)) ?? genericSidebarAdapter;
}
//#endregion
//#region src/core/hash.ts
var encoder = new TextEncoder();
function toHex(buffer) {
return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function fallbackHash(input) {
let hash = 2166136261;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 16777619) >>> 0;
}
return hash.toString(16).padStart(8, "0");
}
async function hashStableKey(input) {
const subtle = globalThis.crypto?.subtle;
if (!subtle) return `sha256:${fallbackHash(input)}`;
return `sha256:${toHex(await subtle.digest("SHA-256", encoder.encode(input)))}`;
}
//#endregion
//#region src/core/settings.ts
var maskStyles = [
"skeleton",
"characters",
"blur"
];
function isRecord(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function cleanHost(host) {
return host.trim().toLowerCase().replace(/^\.+|\.+$/g, "");
}
function isKnownHost(hostname) {
const host = cleanHost(hostname);
return KNOWN_HOSTS.some((knownHost) => host === knownHost || host.endsWith(`.${knownHost}`));
}
function normalizeHostList(value) {
if (!Array.isArray(value)) return [];
return Array.from(new Set(value.filter((host) => typeof host === "string").map(cleanHost).filter(Boolean))).sort();
}
function normalizeBooleanRecord(value) {
if (!isRecord(value)) return {};
const output = {};
for (const [host, enabled] of Object.entries(value)) {
const clean = cleanHost(host);
if (!clean || typeof enabled !== "boolean") continue;
output[clean] = enabled;
}
return output;
}
function normalizeNumberRecord(value) {
if (!isRecord(value)) return {};
const output = {};
for (const [host, timestamp] of Object.entries(value)) {
const clean = cleanHost(host);
if (!clean || typeof timestamp !== "number" || !Number.isFinite(timestamp)) continue;
output[clean] = Math.max(0, timestamp);
}
return output;
}
function normalizeStringArrayRecord(value) {
if (!isRecord(value)) return {};
const output = {};
for (const [host, keys] of Object.entries(value)) {
const clean = cleanHost(host);
if (!clean || !Array.isArray(keys)) continue;
output[clean] = Array.from(new Set(keys.filter((key) => typeof key === "string" && key.startsWith("sha256:")))).sort();
}
return output;
}
function normalizeSettings(value) {
if (!isRecord(value)) return { ...DEFAULT_SETTINGS };
const maskStyle = maskStyles.includes(value.maskStyle) ? value.maskStyle : DEFAULT_SETTINGS.maskStyle;
const customMaskChar = typeof value.customMaskChar === "string" && value.customMaskChar.trim() ? Array.from(value.customMaskChar.trim()).slice(0, 120).join("") : DEFAULT_SETTINGS.customMaskChar;
return {
version: 1,
enabledHosts: normalizeBooleanRecord(value.enabledHosts),
customHosts: normalizeHostList(value.customHosts),
maskStyle,
customMaskChar,
showAllUntilByHost: normalizeNumberRecord(value.showAllUntilByHost),
unlockedKeysByHost: normalizeStringArrayRecord(value.unlockedKeysByHost),
syncEnabled: typeof value.syncEnabled === "boolean" ? value.syncEnabled : DEFAULT_SETTINGS.syncEnabled
};
}
function getSiteStatus(settings, hostname) {
const host = cleanHost(hostname);
const known = isKnownHost(host);
const custom = settings.customHosts.includes(host);
return {
host,
known,
custom,
enabled: settings.enabledHosts[host] ?? (known || custom)
};
}
function canRunOnHost(settings, hostname) {
return getSiteStatus(settings, hostname).enabled;
}
function updateHostEnabled(settings, hostname, enabled) {
const host = cleanHost(hostname);
return normalizeSettings({
...settings,
enabledHosts: {
...settings.enabledHosts,
[host]: enabled
}
});
}
function addCustomHost(settings, hostname) {
const host = cleanHost(hostname);
return normalizeSettings({
...settings,
customHosts: [...settings.customHosts, host],
enabledHosts: {
...settings.enabledHosts,
[host]: true
}
});
}
function removeCustomHost(settings, hostname) {
const host = cleanHost(hostname);
const { [host]: _removedEnabled, ...enabledHosts } = settings.enabledHosts;
const { [host]: _removedShowAll, ...showAllUntilByHost } = settings.showAllUntilByHost;
const { [host]: _removedUnlocked, ...unlockedKeysByHost } = settings.unlockedKeysByHost;
return normalizeSettings({
...settings,
customHosts: settings.customHosts.filter((customHost) => customHost !== host),
enabledHosts,
showAllUntilByHost,
unlockedKeysByHost
});
}
function setUnlockedKey(settings, hostname, key, unlocked) {
const host = cleanHost(hostname);
const current = new Set(settings.unlockedKeysByHost[host] ?? []);
if (unlocked) current.add(key);
else current.delete(key);
return normalizeSettings({
...settings,
unlockedKeysByHost: {
...settings.unlockedKeysByHost,
[host]: Array.from(current)
}
});
}
//#endregion
//#region src/core/engine.ts
var MENU_ACTION_LABEL = "Pin title unmasked";
var MENU_UNPIN_LABEL = "Unpin title mask";
var MENU_CONTEXT_TTL_MS = 3e4;
var MENU_ITEM_TEXT_PATTERN = /^(select|share|rename|edit name|edit chat name|move to project|add to project|move|pin chat|pin to top|pin|unpin|star|unstar|favorite|archive|delete|remove|remove from recents|duplicate|copy|copy link|download|export|选择|分享|重命名|编辑名称|编辑对话名|移动|移至项目|置顶|固定|取消置顶|归档|存档|删除|移除|复制|复制链接|下载)$/i;
var MENU_CONTAINER_TEXT_PATTERN = /(select|share|rename|edit name|edit chat name|pin chat|pin to top|star|favorite|archive|delete|remove|move to project|add to project|copy|download|选择|分享|重命名|编辑名称|编辑对话名|置顶|固定|归档|删除|移除|移动|移至项目|复制|下载)/i;
var MENU_ITEM_SELECTOR = "[role=\"menuitem\"], [role=\"option\"], button, [role=\"button\"], li, div";
var MENU_CONTROL_SELECTOR = "button, [role=\"button\"], [aria-haspopup=\"menu\"], [data-testid*=\"menu\" i], [data-testid*=\"more\" i], [data-testid*=\"option\" i], [data-testid*=\"action\" i], [aria-label*=\"menu\" i], [aria-label*=\"more\" i], [aria-label*=\"option\" i], [aria-label*=\"action\" i], [aria-label*=\"overflow\" i], [aria-label*=\"conversation\" i], [aria-label*=\"chat\" i], [aria-label*=\"更多\" i]";
function normalizeMenuText(text) {
return text.replace(/[\uE000-\uF8FF]/g, "").replace(/\s+/g, " ").trim();
}
function createVisibilityIcon(document, visible) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
const eye = document.createElementNS("http://www.w3.org/2000/svg", "path");
eye.setAttribute("d", "M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z");
const pupil = document.createElementNS("http://www.w3.org/2000/svg", "circle");
pupil.setAttribute("cx", "12");
pupil.setAttribute("cy", "12");
pupil.setAttribute("r", "2.5");
svg.append(eye, pupil);
if (!visible) {
const slash = document.createElementNS("http://www.w3.org/2000/svg", "path");
slash.setAttribute("d", "M4 4l16 16");
svg.append(slash);
}
return svg;
}
function isIconOnlyMenuControl(control) {
const text = (control.textContent ?? "").replace(/\s+/g, "").trim();
const rect = control.getBoundingClientRect();
const compact = rect.width === 0 || rect.height === 0 || rect.width <= 48 && rect.height <= 48;
const className = typeof control.className === "string" ? control.className : "";
return text.length <= 2 && compact && (Boolean(control.querySelector("svg")) || /inline-flex|items-center|justify-center|icon/i.test(className));
}
function isLikelyMenuControl(control) {
const marker = [
control.textContent,
control.getAttribute("aria-label"),
control.getAttribute("title"),
control.getAttribute("data-testid"),
control.getAttribute("aria-haspopup")
].join(" ").replace(/\s+/g, " ").trim().toLowerCase();
return /(\.\.\.|…|⋯|⋮|︙|···|more|menu|option|action|overflow|conversation|chat|更多|操作|菜单)/i.test(marker);
}
function isJsdomDocument(document) {
return /jsdom/i.test(document.defaultView?.navigator.userAgent ?? "");
}
function ownerDocument(root) {
return root.nodeType === Node.DOCUMENT_NODE ? root : root.ownerDocument;
}
function documentRoot(root) {
return root.nodeType === Node.DOCUMENT_NODE ? root : root;
}
function setDataset(element, name, value) {
element.dataset[`${DATA_PREFIX}${name}`] = value;
}
function deleteDataset(element, name) {
delete element.dataset[`${DATA_PREFIX}${name}`];
}
function maskTextFor(text, pattern) {
const patternCharacters = Array.from(pattern.trim()).slice(0, 120);
const fallbackPattern = patternCharacters.length > 0 ? patternCharacters : ["•"];
const targetCount = Math.min(120, Math.max(4, Array.from(text.trim()).length));
const output = [];
while (output.length < targetCount) output.push(...fallbackPattern);
return output.slice(0, targetCount).join("");
}
function buildStyle() {
return `
[data-${DATA_PREFIX}-title="true"] {
position: relative !important;
isolation: isolate !important;
}
[data-${DATA_PREFIX}-title="true"][data-${DATA_PREFIX}-state="masked"] {
color: transparent !important;
text-shadow: none !important;
-webkit-text-fill-color: transparent !important;
caret-color: transparent !important;
text-overflow: clip !important;
}
[data-${DATA_PREFIX}-title="true"][data-${DATA_PREFIX}-state="masked"]::after {
content: "";
position: absolute !important;
left: 0 !important;
right: 0 !important;
top: 50% !important;
height: 0.8em !important;
min-width: 3.5em !important;
transform: translateY(-50%) !important;
border-radius: 999px !important;
background: linear-gradient(90deg, rgba(148, 163, 184, 0.38), rgba(203, 213, 225, 0.56), rgba(148, 163, 184, 0.38)) !important;
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.16) !important;
pointer-events: none !important;
z-index: 1 !important;
}
[data-${DATA_PREFIX}-title="true"][data-${DATA_PREFIX}-state="masked"][data-${DATA_PREFIX}-style="characters"]::after {
content: attr(data-${DATA_PREFIX}-mask-text) !important;
top: 0 !important;
height: auto !important;
min-width: 0 !important;
transform: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
color: rgba(71, 85, 105, 0.78) !important;
-webkit-text-fill-color: rgba(71, 85, 105, 0.78) !important;
opacity: 0.72 !important;
letter-spacing: 0 !important;
}
[data-${DATA_PREFIX}-title="true"][data-${DATA_PREFIX}-state="masked"][data-${DATA_PREFIX}-style="blur"] {
color: inherit !important;
-webkit-text-fill-color: currentColor !important;
filter: blur(5px) !important;
}
[data-${DATA_PREFIX}-title="true"][data-${DATA_PREFIX}-state="masked"][data-${DATA_PREFIX}-style="blur"]::after {
display: none !important;
}
[data-${DATA_PREFIX}-title="true"][data-${DATA_PREFIX}-state="visible"] {
filter: none !important;
}
[data-${DATA_PREFIX}-title-clip="true"][data-${DATA_PREFIX}-clip-state="masked"] {
color: transparent !important;
-webkit-text-fill-color: transparent !important;
text-overflow: clip !important;
}
[data-${DATA_PREFIX}-menu-action="true"] {
display: flex !important;
align-items: center !important;
gap: 12px !important;
width: 100% !important;
min-height: 40px !important;
border: 0 !important;
border-radius: 6px !important;
padding: 8px 12px !important;
color: inherit !important;
background: transparent !important;
font: inherit !important;
text-align: left !important;
cursor: pointer !important;
}
[data-${DATA_PREFIX}-menu-action="true"]:hover,
[data-${DATA_PREFIX}-menu-action="true"]:focus-visible {
background: rgba(148, 163, 184, 0.14) !important;
outline: none !important;
}
[data-${DATA_PREFIX}-menu-action-icon="true"] {
display: inline-flex !important;
width: 20px !important;
flex: 0 0 20px !important;
align-items: center !important;
justify-content: center !important;
color: inherit !important;
}
[data-${DATA_PREFIX}-menu-action-icon="true"] svg {
width: 18px !important;
height: 18px !important;
display: block !important;
}
`;
}
var MaskEngine = class {
root;
document;
location;
storage;
adapters;
clock;
onError;
rowBindings = /* @__PURE__ */ new WeakMap();
boundRows = /* @__PURE__ */ new WeakSet();
activeRevealKeys = /* @__PURE__ */ new Set();
documentMenuListener = (event) => {
this.rememberMenuRowFromDocument(event);
};
lastMenuRow;
lastMenuRowAt = 0;
observer;
settings;
refreshQueued = false;
stopped = false;
constructor(options) {
this.root = options.root;
this.document = ownerDocument(options.root);
this.location = options.location;
this.storage = options.storage;
this.adapters = options.adapters ?? defaultAdapters;
this.clock = options.clock ?? (() => Date.now());
this.onError = options.onError;
}
async start() {
try {
this.stopped = false;
this.injectStyle();
this.settings = await this.storage.getSettings();
await this.rescan();
if (this.stopped) return;
this.observer = new MutationObserver((records) => {
if (this.shouldRefreshForMutations(records)) this.queueRefresh();
});
this.observer.observe(documentRoot(this.root), {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
"href",
"aria-current",
"data-active",
"data-current",
"data-selected",
"class"
],
attributeOldValue: true
});
this.document.addEventListener("pointerdown", this.documentMenuListener, true);
this.document.addEventListener("click", this.documentMenuListener, true);
this.document.addEventListener("contextmenu", this.documentMenuListener, true);
} catch (error) {
this.handleError(error);
}
}
stop() {
this.stopped = true;
this.observer?.disconnect();
this.observer = void 0;
this.document.removeEventListener("pointerdown", this.documentMenuListener, true);
this.document.removeEventListener("click", this.documentMenuListener, true);
this.document.removeEventListener("contextmenu", this.documentMenuListener, true);
this.clearMarkedTitles();
}
async refresh() {
if (this.stopped) return;
try {
this.settings = await this.storage.getSettings();
await this.rescan();
} catch (error) {
this.handleError(error);
}
}
async setSettings(settings) {
if (this.stopped) return;
try {
this.settings = settings;
await this.storage.setSettings(settings);
await this.rescan();
} catch (error) {
this.handleError(error);
}
}
queueRefresh() {
if (this.stopped || this.refreshQueued) return;
this.refreshQueued = true;
this.document.defaultView?.setTimeout(() => {
if (this.stopped) return;
this.refreshQueued = false;
this.refresh();
}, 60);
}
shouldRefreshForMutations(records) {
const stateClassPattern = /\b(active|current|selected|open|expanded)\b/i;
const elementCtor = this.document.defaultView?.HTMLElement;
for (const record of records) {
if (record.type === "childList") {
if ([...Array.from(record.addedNodes), ...Array.from(record.removedNodes)].some((node) => !elementCtor || !(node instanceof elementCtor) || !node.matches(`[data-chatveil-menu-action="true"]`))) return true;
continue;
}
if (record.type !== "attributes") continue;
if (record.attributeName !== "class") return true;
const target = record.target;
if (!elementCtor || !(target instanceof elementCtor)) return true;
const before = record.oldValue ?? "";
const after = typeof target.className === "string" ? target.className : "";
if (stateClassPattern.test(before) || stateClassPattern.test(after)) return true;
if (target.closest(`[data-chatveil-row="true"], [data-chatveil-title="true"]`)) continue;
return true;
}
return false;
}
injectStyle() {
if (this.document.getElementById("chatveil-mask-style")) return;
const style = this.document.createElement("style");
style.id = STYLE_ID;
style.textContent = buildStyle();
this.document.documentElement.append(style);
}
clearMarkedTitles() {
for (const element of Array.from(this.document.querySelectorAll(`[data-${DATA_PREFIX}-title="true"]`))) {
deleteDataset(element, "Title");
deleteDataset(element, "State");
deleteDataset(element, "Style");
deleteDataset(element, "MaskText");
deleteDataset(element, "Current");
this.clearTitleClip(element);
}
for (const element of Array.from(this.document.querySelectorAll(`[data-${DATA_PREFIX}-row="true"]`))) {
deleteDataset(element, "Row");
deleteDataset(element, "Key");
}
}
async rescan() {
if (this.stopped) return;
const settings = this.settings ?? await this.storage.getSettings();
this.settings = settings;
const status = getSiteStatus(settings, this.location.hostname);
if (!status.enabled || !canRunOnHost(settings, this.location.hostname)) {
this.clearMarkedTitles();
return;
}
const adapter = selectAdapter(this.adapters, this.location);
if (!adapter) {
this.clearMarkedTitles();
return;
}
const now = this.clock();
const temporaryShowAll = (settings.showAllUntilByHost[status.host] ?? 0) > now;
const unlocked = new Set(settings.unlockedKeysByHost[status.host] ?? []);
const seenTitles = /* @__PURE__ */ new Set();
const items = adapter.findHistoryItems(documentRoot(this.root));
await Promise.all(items.map(async (item, index) => {
const rawKey = adapter.getStableKey(item, this.location, index);
const hashedKey = await hashStableKey(`${status.host}:${adapter.id}:${rawKey}`);
const isCurrent = adapter.isCurrent(item, this.location);
const isVisible = isCurrent || temporaryShowAll || unlocked.has(hashedKey) || this.activeRevealKeys.has(hashedKey);
this.applyMaskState(item, settings, hashedKey, isCurrent, isVisible);
seenTitles.add(item.title);
}));
for (const element of Array.from(this.document.querySelectorAll(`[data-${DATA_PREFIX}-title="true"]`))) if (!seenTitles.has(element)) {
deleteDataset(element, "Title");
deleteDataset(element, "State");
deleteDataset(element, "Style");
deleteDataset(element, "MaskText");
deleteDataset(element, "Current");
this.clearTitleClip(element);
}
this.injectMenuAction(status.host);
}
applyMaskState(item, settings, hashedKey, isCurrent, isVisible) {
setDataset(item.row, "Row", "true");
setDataset(item.row, "Key", hashedKey);
setDataset(item.title, "Title", "true");
setDataset(item.title, "Current", String(isCurrent));
setDataset(item.title, "State", isVisible ? "visible" : "masked");
setDataset(item.title, "Style", settings.maskStyle);
setDataset(item.title, "MaskText", maskTextFor(item.title.textContent ?? "", settings.customMaskChar));
this.applyTitleClipState(item.title, isVisible ? "visible" : "masked");
this.rowBindings.set(item.row, {
host: this.location.hostname.toLowerCase(),
hashedKey,
title: item.title
});
this.bindRow(item.row);
}
bindRow(row) {
if (this.boundRows.has(row)) return;
this.boundRows.add(row);
row.addEventListener("pointerenter", () => {
this.revealRow(row);
});
row.addEventListener("pointerleave", () => {
this.maskRow(row);
});
row.addEventListener("focusin", () => {
this.revealRow(row);
});
row.addEventListener("focusout", () => {
this.maskRow(row);
});
row.addEventListener("pointerdown", (event) => {
this.rememberMenuRow(row, event);
}, true);
row.addEventListener("click", (event) => {
this.rememberMenuRow(row, event);
}, true);
row.addEventListener("contextmenu", () => {
this.rememberMenuRowFor(row);
});
row.addEventListener("click", (event) => {
if (event.detail !== 3) return;
event.preventDefault();
event.stopPropagation();
this.toggleUnlock(row);
});
}
revealRow(row) {
if (this.stopped) return;
const binding = this.rowBindings.get(row);
if (!binding) return;
this.activeRevealKeys.add(binding.hashedKey);
this.setBoundTitleState(binding, "visible");
}
maskRow(row) {
if (this.stopped) return;
const binding = this.rowBindings.get(row);
if (!binding) return;
this.activeRevealKeys.delete(binding.hashedKey);
this.setBoundTitleState(binding, this.shouldKeepBoundTitleVisible(binding) ? "visible" : "masked");
}
setBoundTitleState(binding, state) {
if (!this.document.documentElement.contains(binding.title)) return;
setDataset(binding.title, "State", state);
this.applyTitleClipState(binding.title, state);
}
shouldKeepBoundTitleVisible(binding) {
if (binding.title.dataset[`chatveilCurrent`] === "true") return true;
if (this.activeRevealKeys.has(binding.hashedKey)) return true;
const settings = this.settings;
if (!settings) return false;
if ((settings.showAllUntilByHost[binding.host] ?? 0) > this.clock()) return true;
return new Set(settings.unlockedKeysByHost[binding.host] ?? []).has(binding.hashedKey);
}
applyTitleClipState(title, state) {
const parent = title.parentElement;
if (!parent || !this.isTitleClipParent(title, parent)) {
this.clearTitleClip(title);
return;
}
setDataset(parent, "TitleClip", "true");
setDataset(parent, "ClipState", state);
}
clearTitleClip(title) {
const parent = title.parentElement;
if (!parent || parent.dataset[`chatveilTitleClip`] !== "true") return;
deleteDataset(parent, "TitleClip");
deleteDataset(parent, "ClipState");
}
isTitleClipParent(title, parent) {
if (title.dataset.chatveilTextWrapper !== "true") return false;
if (parent.querySelector("button, [role=\"button\"], img, svg, canvas, picture, video, [role=\"img\"]")) return false;
const className = typeof parent.className === "string" ? parent.className : "";
return /\b(flex-?1|min-w-0|w-full|truncate|text-ellipsis|line-clamp|overflow-hidden)\b/i.test(className);
}
async toggleUnlock(row) {
if (this.stopped) return;
const binding = this.rowBindings.get(row);
const settings = this.settings;
if (!binding || !settings) return;
const host = binding.host;
const shouldUnlock = !new Set(settings.unlockedKeysByHost[host] ?? []).has(binding.hashedKey);
const next = setUnlockedKey(settings, host, binding.hashedKey, shouldUnlock);
await this.setSettings(next);
}
rememberMenuRow(row, event) {
const target = event.target;
if (!(target instanceof Element)) return;
const control = target.closest(MENU_CONTROL_SELECTOR);
if (!control || !row.contains(control) || !isLikelyMenuControl(control) && !isIconOnlyMenuControl(control)) return;
this.rememberMenuRowFor(row);
}
rememberMenuRowFromDocument(event) {
if (this.stopped) return;
const target = event.target;
if (!(target instanceof Element)) return;
const control = target.closest(MENU_CONTROL_SELECTOR);
if (!control || !isLikelyMenuControl(control) && !isIconOnlyMenuControl(control)) return;
const row = this.findRowForMenuControl(control);
if (row) this.rememberMenuRowFor(row);
}
rememberMenuRowFor(row) {
this.lastMenuRow = row;
this.lastMenuRowAt = this.clock();
this.queueMenuRefresh();
}
queueMenuRefresh() {
this.queueRefresh();
for (const delay of [90, 240]) this.document.defaultView?.setTimeout(() => {
if (!this.stopped) this.queueRefresh();
}, delay);
}
findRowForMenuControl(control) {
const ancestor = control.closest(`[data-${DATA_PREFIX}-row="true"]`);
if (ancestor && this.rowBindings.has(ancestor)) return ancestor;
const rows = Array.from(this.document.querySelectorAll(`[data-${DATA_PREFIX}-row="true"]`)).filter((row) => this.rowBindings.has(row));
if (rows.length === 0) return void 0;
const controlRect = control.getBoundingClientRect();
const controlCenterY = controlRect.top + controlRect.height / 2;
const controlCenterX = controlRect.left + controlRect.width / 2;
let best;
for (const row of rows) {
const rowRect = row.getBoundingClientRect();
const rowCenterY = rowRect.top + rowRect.height / 2;
const rowCenterX = rowRect.left + rowRect.width / 2;
const verticalDistance = controlCenterY >= rowRect.top - 12 && controlCenterY <= rowRect.bottom + 12 ? 0 : Math.abs(controlCenterY - rowCenterY);
if (verticalDistance > 64 && rowRect.height > 0) continue;
const horizontalDistance = Math.min(Math.abs(controlRect.left - rowRect.left), Math.abs(controlRect.left - rowRect.right), Math.abs(controlRect.right - rowRect.right), Math.abs(controlCenterX - rowCenterX));
const score = verticalDistance * 10 + horizontalDistance;
if (!best || score < best.score) best = {
row,
score
};
}
return best?.row;
}
injectMenuAction(host) {
const menus = this.findMenus();
if (host === "z.ai" || host.endsWith(".z.ai")) {
this.cleanupMenuActions([]);
return;
}
const menu = this.chooseActionMenu(menus);
if (!menu) {
this.cleanupMenuActions([]);
return;
}
const row = this.resolveMenuRow(menu);
if (!row) {
this.cleanupMenuActions(menus);
return;
}
const binding = this.rowBindings.get(row);
const settings = this.settings;
if (!binding || !settings) {
this.cleanupMenuActions(menus);
return;
}
this.cleanupMenuActions([menu]);
const unlocked = new Set(settings.unlockedKeysByHost[host] ?? []).has(binding.hashedKey);
const actions = this.directMenuActions(menu);
const existing = actions.find((action) => action instanceof HTMLButtonElement);
for (const action of actions) if (action !== existing) action.remove();
if (existing) {
this.updateMenuAction(existing, row, unlocked);
this.positionMenuAction(menu, existing);
return;
}
const action = this.createMenuAction(row, unlocked);
this.positionMenuAction(menu, action);
}
resolveMenuRow(menu) {
const row = this.lastMenuRow;
if (row && this.document.documentElement.contains(row) && this.rowBindings.has(row) && this.clock() - this.lastMenuRowAt <= MENU_CONTEXT_TTL_MS) return row;
return this.findNearestRowForMenu(menu);
}
findNearestRowForMenu(menu) {
const rows = Array.from(this.document.querySelectorAll(`[data-${DATA_PREFIX}-row="true"]`)).filter((row) => this.rowBindings.has(row));
if (rows.length === 0) return void 0;
const menuRect = menu.getBoundingClientRect();
if (menuRect.width <= 0 && menuRect.height <= 0) return void 0;
const menuAnchorY = menuRect.top;
const menuAnchorX = menuRect.left;
let best;
for (const row of rows) {
const rowRect = row.getBoundingClientRect();
if (rowRect.width <= 0 && rowRect.height <= 0) continue;
const rowCenterY = rowRect.top + rowRect.height / 2;
const verticalDistance = Math.abs(menuAnchorY - rowCenterY);
if (verticalDistance > 140) continue;
const horizontalDistance = Math.min(Math.abs(menuAnchorX - rowRect.right), Math.abs(menuAnchorX - rowRect.left), Math.abs(menuAnchorX - (rowRect.left + rowRect.width / 2)));
const score = verticalDistance * 10 + horizontalDistance;
if (!best || score < best.score) best = {
row,
score
};
}
return best?.row;
}
createMenuAction(row, unlocked) {
const action = this.document.createElement("button");
action.type = "button";
action.setAttribute("role", "menuitem");
setDataset(action, "MenuAction", "true");
const icon = this.document.createElement("span");
setDataset(icon, "MenuActionIcon", "true");
const label = this.document.createElement("span");
action.append(icon, label);
this.updateMenuAction(action, row, unlocked);
return action;
}
updateMenuAction(action, row, unlocked) {
action.querySelector(`[data-${DATA_PREFIX}-menu-action-icon="true"]`)?.replaceChildren(createVisibilityIcon(this.document, !unlocked));
const label = action.querySelector("span:last-child");
if (label) label.textContent = unlocked ? MENU_UNPIN_LABEL : MENU_ACTION_LABEL;
action.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
const binding = this.rowBindings.get(row);
const settings = this.settings;
if (!binding || !settings) return;
const isUnlocked = new Set(settings.unlockedKeysByHost[binding.host] ?? []).has(binding.hashedKey);
this.updateMenuAction(action, row, !isUnlocked);
this.toggleUnlock(row).then(() => {
if (!this.stopped) this.injectMenuAction(binding.host);
}).catch((error) => {
this.handleError(error);
});
};
}
findMenus() {
const menus = new Set(this.findPopoverMenuBodies());
const items = Array.from(this.document.querySelectorAll(MENU_ITEM_SELECTOR)).filter((element) => {
if (element.closest(`[data-chatveil-menu-action="true"]`)) return false;
return MENU_ITEM_TEXT_PATTERN.test(normalizeMenuText(element.textContent ?? ""));
});
for (const item of items) {
const menu = this.findMenuContainer(item);
if (menu) menus.add(menu);
}
return Array.from(menus);
}
findPopoverMenuBodies() {
const popovers = Array.from(this.document.querySelectorAll("[class*=\"z-popover\"], [class*=\"popover\" i], [data-radix-popper-content-wrapper], [data-floating-ui-portal], [data-headlessui-portal]"));
const menus = [];
for (const popover of popovers) {
const candidates = [popover, ...Array.from(popover.querySelectorAll("[role=\"menu\"], [class*=\"overflow-y-auto\"], [class*=\"min-h-0\"], [class*=\"rounded\"], [class*=\"p-1\"]"))];
for (const candidate of candidates) {
if (menus.includes(candidate)) continue;
if (this.hasDirectMenuShape(candidate) && this.isEligibleMenuContainer(candidate)) menus.push(candidate);
}
}
return menus;
}
hasDirectMenuShape(menu) {
const directItems = Array.from(menu.children).filter((child) => {
if (!(child instanceof HTMLElement)) return false;
if (child.matches(`[data-chatveil-menu-action="true"]`)) return false;
return child.matches("[role=\"menuitem\"], [role=\"option\"], button, [role=\"button\"]");
});
if (directItems.length < 2) return false;
return directItems.some((item) => MENU_ITEM_TEXT_PATTERN.test(normalizeMenuText(item.textContent ?? ""))) && MENU_CONTAINER_TEXT_PATTERN.test(menu.textContent ?? "");
}
chooseActionMenu(menus) {
return menus.find((menu) => Boolean(this.findMenuItemByText(menu, /^(pin chat|pin to top|pin|置顶|固定)$/i))) ?? menus.find((menu) => Boolean(this.findMenuItemByText(menu, /^(delete|remove|删除|移除)$/i))) ?? menus[0];
}
directMenuActions(menu) {
return Array.from(menu.children).filter((child) => child instanceof HTMLElement && child.matches(`[data-chatveil-menu-action="true"]`));
}
positionMenuAction(menu, action) {
const pinItem = this.findMenuItemByText(menu, /^(pin chat|pin to top|pin|置顶|固定)$/i);
const destructiveItem = this.findMenuItemByText(menu, /^(delete|remove|删除|移除)$/i);
const insertBefore = pinItem ?? destructiveItem;
if (insertBefore?.parentElement === menu) {
if (action.nextElementSibling !== insertBefore) menu.insertBefore(action, insertBefore);
return;
}
if (action.parentElement !== menu || action.nextElementSibling) menu.append(action);
}
findMenuContainer(item) {
let current = item.parentElement;
for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) {
if (current === this.document.body || current === this.document.documentElement) break;
if (current.matches("[role=\"menuitem\"], [role=\"option\"], button, [role=\"button\"], li")) continue;
if (current.matches(`[data-chatveil-menu-action="true"]`) || current.closest(`[data-chatveil-menu-action="true"]`)) continue;
const text = current.textContent ?? "";
if ((Array.from(current.querySelectorAll(MENU_ITEM_SELECTOR)).filter((element) => !element.closest(`[data-chatveil-menu-action="true"]`) && MENU_ITEM_TEXT_PATTERN.test(normalizeMenuText(element.textContent ?? ""))).length >= 2 && MENU_CONTAINER_TEXT_PATTERN.test(text) || current.getAttribute("role") === "menu") && this.isEligibleMenuContainer(current)) return current;
}
}
isEligibleMenuContainer(menu) {
if (!this.document.documentElement.contains(menu)) return false;
if (menu.matches("[role=\"menuitem\"], [role=\"option\"], button, [role=\"button\"], li")) return false;
if (menu.closest(`[data-chatveil-title="true"], [data-chatveil-row="true"]`)) return false;
const style = this.document.defaultView?.getComputedStyle(menu);
if (style && (style.display === "none" || style.visibility === "hidden" || style.opacity === "0")) return false;
if (isJsdomDocument(this.document)) return true;
if (!this.hasPopupMenuSignal(menu)) return false;
const rect = menu.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;
if (rect.width > 720 || rect.height > 900) return false;
const viewportWidth = this.document.defaultView?.innerWidth ?? 0;
const viewportHeight = this.document.defaultView?.innerHeight ?? 0;
if (viewportWidth > 0 && rect.width > viewportWidth * .8) return false;
if (viewportHeight > 0 && rect.height > viewportHeight * .8) return false;
return true;
}
hasPopupMenuSignal(menu) {
const popupSelector = [
"[role=\"menu\"]",
"[role=\"listbox\"]",
"[data-radix-menu-content]",
"[data-radix-popper-content-wrapper]",
"[data-headlessui-portal]",
"[data-floating-ui-portal]",
"[data-popper-placement]",
"[data-side][data-align]",
"[class*=\"popover\" i]",
"[class*=\"dropdown\" i]",
"[class*=\"floating\" i]",
"[class*=\"portal\" i]"
].join(",");
if (menu.matches(popupSelector) || menu.closest(popupSelector)) return true;
const marker = [
menu.id,
menu.className,
menu.getAttribute("data-testid"),
menu.getAttribute("data-radix-menu-content"),
menu.getAttribute("data-popper-placement")
].join(" ").toLowerCase();
if (/\b(popover|dropdown|menu|popup|portal|floating|radix|headlessui|tooltip|tippy)\b/.test(marker)) return true;
let current = menu;
for (let depth = 0; current && depth < 4; depth += 1, current = current.parentElement) {
if (current === this.document.body || current === this.document.documentElement) break;
const style = this.document.defaultView?.getComputedStyle(current);
if (!style) continue;
const zIndex = Number.parseInt(style.zIndex, 10);
const hasElevatedLayer = Number.isFinite(zIndex) && zIndex >= 10;
const hasPopupPosition = style.position === "fixed" || style.position === "absolute";
const hasPopupPaint = style.boxShadow !== "none" || style.transform !== "none";
if (hasPopupPosition && (hasElevatedLayer || hasPopupPaint)) return true;
}
return false;
}
cleanupMenuActions(validMenus) {
const menus = new Set(validMenus);
for (const action of Array.from(this.document.querySelectorAll(`[data-${DATA_PREFIX}-menu-action="true"]`))) {
const menu = validMenus.find((candidate) => action.parentElement === candidate);
if (!menu || !menus.has(menu)) action.remove();
}
for (const menu of validMenus) {
const actions = this.directMenuActions(menu);
const keep = actions[0];
for (const action of actions.slice(1)) action.remove();
if (keep) this.positionMenuAction(menu, keep);
}
}
findMenuItemByText(menu, pattern) {
return Array.from(menu.children).find((child) => {
return child instanceof HTMLElement && pattern.test(normalizeMenuText(child.textContent ?? ""));
});
}
handleError(error) {
this.onError?.(error);
}
};
//#endregion
//#region src/userscript/main.ts
var PANEL_ID = "chatveil-userscript-panel";
function escapeAttribute(value) {
return value.replaceAll("&", "&").replaceAll("\"", """).replaceAll("<", "<").replaceAll(">", ">");
}
var userscriptStorage = {
async getSettings() {
return normalizeSettings(GM_getValue(SETTINGS_KEY, void 0));
},
async setSettings(settings) {
GM_setValue(SETTINGS_KEY, normalizeSettings(settings));
}
};
var engine = new MaskEngine({
root: document,
location: window.location,
storage: userscriptStorage
});
function renderPanel(settings) {
const site = getSiteStatus(settings, window.location.hostname);
const panel = document.createElement("form");
panel.id = PANEL_ID;
panel.innerHTML = `
<style>
#${PANEL_ID} {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 2147483647;
display: grid;
gap: 8px;
width: 236px;
padding: 12px;
border: 1px solid rgba(100, 116, 139, 0.32);
border-radius: 8px;
color: #172033;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.22);
font: 13px/1.35 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#${PANEL_ID} label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
#${PANEL_ID} select,
#${PANEL_ID} input[type="number"],
#${PANEL_ID} input[type="text"] {
width: 112px;
}
#${PANEL_ID} button {
min-height: 30px;
}
#${PANEL_ID} .row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
@media (prefers-color-scheme: dark) {
#${PANEL_ID} {
color: #e6eef7;
background: rgba(17, 24, 39, 0.96);
}
}
</style>
<strong>ChatVeil</strong>
<label>Enabled <input name="enabled" type="checkbox" ${site.enabled ? "checked" : ""}></label>
<label>
Style
<select name="style">
<option value="skeleton" ${settings.maskStyle === "skeleton" ? "selected" : ""}>Skeleton</option>
<option value="characters" ${settings.maskStyle === "characters" ? "selected" : ""}>Characters</option>
<option value="blur" ${settings.maskStyle === "blur" ? "selected" : ""}>Blur</option>
</select>
</label>
<label>Character <input name="char" type="text" maxlength="120" value="${escapeAttribute(settings.customMaskChar)}"></label>
<div class="row">
<button name="show" type="button">Show 10m</button>
<button name="reset" type="button">Reset</button>
</div>
<button name="close" type="button">Close</button>
`;
return panel;
}
async function showPanel() {
document.getElementById(PANEL_ID)?.remove();
const settings = await userscriptStorage.getSettings();
const panel = renderPanel(settings);
document.body.append(panel);
panel.addEventListener("change", async (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement)) return;
const form = new FormData(panel);
const enabled = form.get("enabled") === "on";
const next = normalizeSettings({
...settings,
maskStyle: form.get("style"),
customMaskChar: form.get("char")
});
const withHost = enabled ? addCustomHost(next, window.location.hostname) : updateHostEnabled(removeCustomHost(next, window.location.hostname), window.location.hostname, false);
await userscriptStorage.setSettings(withHost);
await engine.refresh();
});
panel.addEventListener("click", async (event) => {
const target = event.target;
if (!(target instanceof HTMLButtonElement)) return;
const current = await userscriptStorage.getSettings();
if (target.name === "close") {
panel.remove();
return;
}
if (target.name === "show") await userscriptStorage.setSettings(normalizeSettings({
...current,
showAllUntilByHost: {
...current.showAllUntilByHost,
[window.location.hostname]: Date.now() + 600 * 1e3
}
}));
if (target.name === "reset") await userscriptStorage.setSettings(normalizeSettings({
...current,
unlockedKeysByHost: {
...current.unlockedKeysByHost,
[window.location.hostname]: []
}
}));
await engine.refresh();
});
}
GM_registerMenuCommand("Open ChatVeil controls", () => {
showPanel();
});
engine.start();
//#endregion
})();
//# sourceMappingURL=chatveil.user.js.map