// ==UserScript== // @name Bluesky Content Manager // @namespace https://greasyfork.org/en/users/567951-stuart-saddler // @version 4.08 // @description Filter Bluesky by meaning with optional OpenRouter AI topics, preview and reveal controls, plus keyword, account, allowlist, and alt-text filters. // @author Stuart Saddler // @license MIT // @match https://bsky.app/* // @icon https://i.ibb.co/YySpmDk/Bluesky-Content-Manager.png // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM.getValue // @grant GM.setValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_addValueChangeListener // @connect public.api.bsky.app // @connect openrouter.ai // @connect cdn.bsky.app // @run-at document-start // @downloadURL https://update.greasyfork.org/scripts/518614/Bluesky%20Content%20Manager.user.js // @updateURL https://update.greasyfork.org/scripts/518614/Bluesky%20Content%20Manager.meta.js // ==/UserScript== "use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; (function () { 'use strict'; /*** AI TOPIC FILTER — no page code or post instructions are executed ***/ const AI_KEY = 'bcmaiOpenRouterKeyV1', AI_CACHE = 'bcmaiAIResultsV1', AI_USAGE = 'bcmaiUsageV1', AI_KEEPS = 'bcmaiKeepsV1', AI_PACING = 'bcmaiDecisionPacingV1'; const AI_MIN_GAP = 250; const AI_POLICY = 6; const AI_MODEL = '~liquid/d1-ultra-latest', AI_RESERVE = .005, AI_AGE = 7 * 86400000; const NEWS_FEED = 'at://did:plc:kkf4naxqmweop7dv4l2iqqf5/app.bsky.feed.generator/verified-news'; const MAX_AI_TOPIC_LENGTH = 1000; const aiDefaults = { enabled: false, mode: 'preview', scope: 'news', analyzeImages: false, topics: [], budget: .10, requestLimit: 200 }; /*** CONFIG & GLOBALS ***/ const SETTINGS_KEY = 'bcmSettingsV1', CACHE_KEY = 'bcmBlockedCacheV1'; const SETTING_LABELS = { filteredTerms: 'blocklist', manualWhitelistTerms: 'allowlist', autoWhitelistEnabled: 'followed-account', altTextEnforcementEnabled: 'alt-text' }; const CACHE_LIMIT = 5000, CACHE_AGE = 30 * 86400000, FOLLOW_AGE = 5 * 60000, PAGE_SIZE = 50; const THREAD_POST_SELECTOR = '[data-testid^="postThreadItem-by-"]'; const KNOWN_POST_SELECTOR = '[data-testid="post"], [data-testid^="feedItem-by-"], [data-testid="postThreadItem"], ' + THREAD_POST_SELECTOR; const POST_SELECTOR = KNOWN_POST_SELECTOR + ', article'; const QUOTE_SELECTOR = '[data-testid="quotePost"], [data-testid="quotePostEmbed"], [data-testid="quotedPost"], a[href*="/post/"][title^="Post by "], a[href*="/post/"][aria-label^="Post by "], [role="link"][aria-label^="Post by "]'; const POST_CANDIDATES = `${POST_SELECTOR}, div[role="link"]`; let settings = { filteredTerms: [], manualWhitelistTerms: [], autoWhitelistEnabled: false, altTextEnforcementEnabled: false, ai: Object.assign(Object.assign({}, aiDefaults), { topics: [] }) }; let filteredTerms = [], manualWhitelistTerms = [], autoWhitelistEnabled = false, altTextEnforcementEnabled = false; let whitelistedUsers = new Set(), blockedAccountSet = new Set(), allowedAccountSet = new Set(); const patterns = new Map(), blockedPosts = new Map(), blockedCache = new Map(), pendingCache = new Map(), pendingPosts = new Set(), issues = new Map(); const nodeIds = new WeakMap(); let processedPosts = new WeakMap(), postRuns = new WeakMap(), nodeSequence = 0; let menuCommandId = null, menuTimer = null, queueTimer = null, cacheTimer = null, flushing = false; let decisionContext = '', contextInput = '', contextEpoch = 0, contextReady = Promise.resolve(); let currentUserDid = '', followAccount = null, followEnabled = null, followReady = true; let followState = 'disabled', followEpoch = 0, followsUpdatedAt = 0, followsStartedAt = 0; let siteState = {}, lastSiteStorage = null, lastPath = '', settingsRaw = null, settingsLoaded = false; let settingsReloadEpoch = 0; let configDialog = null, updateDialogStatus = () => { }, updateHistory = () => { }; /*** CSS ***/ GM_addStyle(` .bcm-filtered{display:none!important} .bcm-filter-allow{grid-column:1/-1} .bcm-quick-menu,.bcm-quick-feedback{position:fixed;z-index:2147483647;color:white;background:#172b3a;border:1px solid #8ba3b5;border-radius:8px;padding:8px;font:14px/1.4 system-ui,sans-serif} .bcm-quick-menu{display:grid;gap:4px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow:auto} .bcm-quick-menu button,.bcm-quick-feedback button{color:inherit;background:#243e50;border:1px solid #8ba3b5;border-radius:5px;padding:6px 10px;cursor:pointer;font:inherit;text-align:left;overflow-wrap:anywhere} .bcm-quick-menu button:focus-visible,.bcm-quick-feedback button:focus-visible{outline:2px solid white;outline-offset:-3px} .bcm-quick-feedback{bottom:12px;left:12px;max-width:min(380px,calc(100vw - 24px));max-height:50vh;overflow:auto;overflow-wrap:anywhere} .bcm-quick-feedback div{padding:4px}.bcm-quick-feedback button{margin-inline-start:8px} .bcm-dialog{--bcm-bg:#fff;--bcm-fg:#17232e;--bcm-muted:#52616e;--bcm-field:#fbfcfd;--bcm-panel:#f7f9fb;--bcm-line:#d6dee6;--bcm-border:#7b8b99;--bcm-accent:#075b9c;--bcm-on-accent:#fff;--bcm-danger:#a51d25;--bcm-info:#edf5fc;--bcm-match:#faf2e5;--bcm-on-match:#694519;color-scheme:light;background:var(--bcm-bg);color:var(--bcm-fg);box-sizing:border-box;width:min(760px,calc(100% - 24px));max-width:none;max-height:calc(100vh - 24px);max-height:calc(100dvh - 24px);padding:0;border:1px solid var(--bcm-line);border-radius:14px;box-shadow:0 16px 44px #0004;font:14px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif;overflow:hidden} .bcm-dialog[data-bcm-theme="dark"]{--bcm-bg:#182028;--bcm-fg:#eef4f8;--bcm-muted:#b7c4ce;--bcm-field:#1c2731;--bcm-panel:#202a34;--bcm-line:#3f4e5c;--bcm-border:#8092a5;--bcm-accent:#9ad0ff;--bcm-on-accent:#10263a;--bcm-danger:#ffb0b5;--bcm-info:#22384a;--bcm-match:#382d20;--bcm-on-match:#f5cf96;color-scheme:dark} .bcm-dialog::backdrop{background:#0009}.bcm-dialog *{box-sizing:border-box}.bcm-dialog [hidden]{display:none!important} .bcm-shell{display:flex;flex-direction:column;max-height:calc(100vh - 26px);max-height:calc(100dvh - 26px)} .bcm-head,.bcm-actions{flex:none;padding:15px 22px;display:flex;align-items:center;justify-content:space-between;gap:12px} .bcm-head p{margin:0;color:var(--bcm-muted);font-size:.875rem} .bcm-dialog h2,.bcm-dialog h3{color:var(--bcm-fg);margin:0;font-family:inherit}.bcm-dialog h2{font-size:1.5rem;line-height:1.2}.bcm-dialog h3{font-size:1rem;line-height:1.35} .bcm-body{overflow:auto;padding:19px 22px;min-height:0;overscroll-behavior:contain} .bcm-nav{flex:none;display:flex;flex-wrap:wrap;gap:4px;padding:0 12px;border-bottom:1px solid var(--bcm-line)} .bcm-dialog .bcm-nav button{border:0;border-radius:0;box-shadow:inset 0 -2px transparent;background:transparent;color:var(--bcm-muted);padding:8px 10px} .bcm-dialog .bcm-nav button[aria-selected="true"]{color:var(--bcm-accent);font-weight:700;box-shadow:inset 0 -2px var(--bcm-accent)} .bcm-group{margin-top:18px;padding-top:16px;border-top:1px solid var(--bcm-line)} .bcm-editors{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.bcm-editor{min-width:0} .bcm-dialog label,.bcm-dialog legend,.bcm-dialog summary{font-weight:650} .bcm-field-title{display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap} .bcm-dialog textarea,.bcm-dialog input:not([type="checkbox"]):not([type="radio"]):not([type="file"]){display:block;width:100%;min-width:0;min-height:38px;color:var(--bcm-fg);background:var(--bcm-field);border:1px solid var(--bcm-border);border-radius:7px;padding:8px 10px;font:inherit;caret-color:var(--bcm-accent)} .bcm-dialog select{display:block;width:100%;min-height:38px;color:var(--bcm-fg);background:var(--bcm-field);border:1px solid var(--bcm-border);border-radius:7px;padding:8px 10px;font:inherit} .bcm-dialog textarea{min-height:128px;resize:vertical;line-height:1.55}.bcm-dialog #bcm-ai-topics{min-height:100px} .bcm-dialog input::placeholder{color:var(--bcm-muted);opacity:1} .bcm-help{font-size:.875rem;color:var(--bcm-muted);margin:5px 0 9px;overflow-wrap:anywhere}.bcm-field-title .bcm-help{margin:0} .bcm-dialog button{min-height:38px;padding:7px 11px;border:1px solid var(--bcm-border);border-radius:7px;font:inherit;font-weight:600;color:var(--bcm-fg);background:var(--bcm-bg);cursor:pointer} .bcm-dialog button:hover:not(:disabled){border-color:var(--bcm-accent);background:var(--bcm-info)}.bcm-dialog button:disabled{opacity:.62;cursor:default} .bcm-dialog button.bcm-primary,.bcm-dialog button.bcm-primary:hover:not(:disabled){background:var(--bcm-accent);color:var(--bcm-on-accent);border-color:transparent;font-weight:650} #bcm-title:focus{outline:none} .bcm-dialog :focus-visible{outline:2px solid var(--bcm-accent);outline-offset:2px}.bcm-dialog .bcm-nav :focus-visible{outline-offset:-3px}.bcm-dialog ::selection{background:var(--bcm-accent);color:var(--bcm-on-accent)} .bcm-dialog a{color:var(--bcm-accent);text-underline-offset:3px} .bcm-row,.bcm-find,.bcm-pager{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.bcm-find{flex-wrap:nowrap;margin-top:8px}.bcm-find button,.bcm-actions button{flex:none} .bcm-status{margin:0;color:var(--bcm-muted);font-size:.875rem;white-space:pre-line;overflow-wrap:anywhere}.bcm-status[data-error="true"]{color:var(--bcm-danger)} .bcm-actions{border-top:1px solid var(--bcm-line);background:var(--bcm-panel)}.bcm-actions .bcm-row{flex:none}.bcm-actions .bcm-status{max-height:4.5em;overflow:auto} .bcm-result,.bcm-info{padding:7px 10px;border-radius:6px;background:var(--bcm-panel)}.bcm-result{margin-top:8px}.bcm-result[data-blocked="true"]{background:var(--bcm-match);color:var(--bcm-on-match)}.bcm-info{background:var(--bcm-info);color:var(--bcm-fg);margin:8px 0 14px} .bcm-dialog fieldset{border:0;padding:0;margin:12px 0 0;min-width:0}.bcm-dialog legend{padding:0} .bcm-dialog .bcm-check{display:flex;align-items:center;gap:8px;min-height:36px}.bcm-check input{accent-color:var(--bcm-accent);margin:0}.bcm-check .bcm-help{margin:0;font-weight:400} .bcm-dialog summary{padding:4px 0;cursor:pointer}.bcm-dialog details[open] summary{margin-bottom:8px} .bcm-history-tabs{margin:10px 0 14px}.bcm-dialog .bcm-history-tabs button[aria-selected="true"]{color:var(--bcm-accent);background:var(--bcm-info);border-color:var(--bcm-accent)} .bcm-history{list-style:none;padding:0;margin:10px 0}.bcm-history li{border-bottom:1px solid var(--bcm-line);padding:10px 0;overflow-wrap:anywhere}.bcm-history p{margin:4px 0}.bcm-pager{justify-content:space-between} .bcm-editor-pages{margin-top:8px}.bcm-editor-pages span{flex-basis:100%;order:-1} .bcm-notice{position:fixed;bottom:16px;right:16px;z-index:1000000;max-width:min(380px,calc(100% - 32px));padding:12px 16px;border:1px solid #8ba3b5;border-radius:8px;background:#172b3a;color:white;font:14px/1.4 system-ui,sans-serif;cursor:pointer} @supports (grid-template-rows:subgrid){ @media (width > 560px){ #bcm-panel-filters .bcm-editors{row-gap:0} #bcm-panel-filters .bcm-editor{display:grid;grid-template-rows:subgrid;grid-row:span 5} } } @media(max-width:560px){.bcm-editors{grid-template-columns:1fr}.bcm-head,.bcm-actions,.bcm-body{padding:13px 15px}.bcm-actions{flex-wrap:wrap}.bcm-actions .bcm-row{margin-left:auto}.bcm-nav{padding-inline:5px}} @media(max-width:390px){.bcm-nav{display:grid;grid-template-columns:1fr 1fr;gap:0}.bcm-dialog .bcm-nav button{text-align:left}} @media(pointer:coarse){.bcm-dialog button,.bcm-dialog .bcm-check{min-height:44px}.bcm-dialog textarea,.bcm-dialog input:not([type="checkbox"]):not([type="radio"]):not([type="file"]){min-height:44px;font-size:16px}} `); GM_addStyle(`.bcm-ai-hidden{display:none!important}html .bcm-load-paused{position:fixed!important;top:calc(100vh + 96px)!important;left:0!important;transform:none!important}.bcm-ai-panel{box-sizing:border-box;margin:8px;padding:12px;background:#eaf4ff;color:#172b3a;border:1px solid #718899;border-radius:6px;font:14px/1.5 system-ui;overflow-wrap:anywhere}.bcm-ai-panel button{margin:6px 8px 0 0;min-height:40px;padding:8px;background:#fff;color:#172b3a;border:1px solid #718899;border-radius:6px;font:inherit;cursor:pointer}.bcm-ai-panel button:focus-visible{outline:3px solid #0067b4;outline-offset:2px}@media(prefers-color-scheme:dark){.bcm-ai-panel{background:#1e3040;color:#edf4f9}.bcm-ai-panel button{background:#15202b;color:#edf4f9}}`); /*** SETTINGS & MATCHING ***/ function normalizeUsername(value) { return value.toLowerCase().replace(/[\u200B-\u200F\u202A-\u202F]/g, '').trim(); } // Unified Filter Backup v1. Keep this block identical in both standalone scripts. const BACKUP_FILE_BYTES = 10 * 1024 * 1024, FILTER_TEXT_BYTES = 8 * 1024 * 1024; function normalizeFilterList(values, label) { if (!Array.isArray(values)) throw new Error(label + ' must be a list of text entries.'); const result = [], seen = new Set(); for (const value of values) { if (typeof value !== 'string') throw new Error(label + ' contains a non-text entry.'); const term = value.trim().toLowerCase(); if (/[\r\n]/.test(term)) throw new Error(label + ' entries must each fit on one line. Split multiline entries before importing.'); if (term.length > 10000) throw new Error(label + ' entries must be at most 10,000 characters (UTF-16 units).'); if (term && !seen.has(term)) { seen.add(term); result.push(term); if (result.length > 100000) throw new Error(label + ' must contain at most 100,000 unique entries.'); } } return result; } function validateSharedFilters(blocklist, allowlist) { const shared = { blocklist: normalizeFilterList(blocklist, 'Blocklist'), allowlist: normalizeFilterList(allowlist, 'Allowlist') }; const bytes = new TextEncoder().encode(shared.blocklist.join('\n')).length + new TextEncoder().encode(shared.allowlist.join('\n')).length; if (bytes > FILTER_TEXT_BYTES) throw new Error('Combined normalized filter text must be at most 8 MiB of UTF-8 text.'); return shared; } function backupObject(value, label) { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(label + ' must be an object.'); } function backupFields(value, names, label) { backupObject(value, label); for (const key of Object.keys(value)) { if (names.indexOf(key) === -1) throw new Error(label + ' contains an unsupported field: ' + key + '. Remove unknown fields and credentials before importing.'); } } const redditFilterKeys = ['subreddits', 'users', 'allowSubreddits', 'allowUsers', 'legacySubreddits']; function redditName(value, user) { const name = String(value || '').trim().toLowerCase(); return (user ? /^[a-z0-9_-]{1,20}$/ : /^[a-z0-9_]{1,21}$/).test(name) ? name : ''; } function validateRedditFilters(value) { backupFields(value, redditFilterKeys, 'Reddit filters'); const result = {}; for (const key of redditFilterKeys) { const user = key === 'users' || key === 'allowUsers'; result[key] = normalizeFilterList(Object.prototype.hasOwnProperty.call(value, key) ? value[key] : [], key); if (result[key].some(name => !redditName(name, user))) throw new Error('Invalid Reddit name in ' + key + '.'); } return result; } const blueskyFilterKeys = ['blockedAccounts', 'allowedAccounts']; function blueskyAccount(value, legacy) { if (typeof value !== 'string') return ''; const raw = value.trim().toLowerCase(), name = raw.replace(/^@/, ''); const handle = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/; const valid = name.length <= 253 && (handle.test(name) || /^did:plc:[a-z2-7]{24}$/.test(name) || (name.startsWith('did:web:') && handle.test(name.slice(8)))); return valid || (legacy && /^@[a-z0-9._:-]+$/.test(raw)) ? '@' + name : ''; } function validateBlueskyFilters(value) { backupFields(value, blueskyFilterKeys, 'Bluesky filters'); const filters = {}; for (const key of blueskyFilterKeys) { const entries = normalizeFilterList(Object.prototype.hasOwnProperty.call(value, key) ? value[key] : [], key); filters[key] = Array.from(new Set(entries.map(term => blueskyAccount(term, key === 'allowedAccounts')))); if (filters[key].includes('')) throw new Error('Use a Bluesky handle or DID in ' + key + '.'); } return filters; } function preservePlatformSettings(value) { if (value === undefined) return {}; backupFields(value, ['bluesky', 'reddit'], 'Platforms'); const result = {}; for (const platform of Object.keys(value)) { const options = value[platform], copy = {}; backupFields(options, platform === 'bluesky' ? ['autoWhitelistEnabled', 'altTextEnforcementEnabled', 'ai', 'filters'] : ['ai', 'filters'], platform + ' settings'); if (Object.prototype.hasOwnProperty.call(options, 'filters')) copy.filters = platform === 'reddit' ? validateRedditFilters(options.filters) : validateBlueskyFilters(options.filters); for (const key of ['autoWhitelistEnabled', 'altTextEnforcementEnabled']) { if (Object.prototype.hasOwnProperty.call(options, key)) { if (typeof options[key] !== 'boolean') throw new Error(platform + ' ' + key + ' must be true or false.'); copy[key] = options[key]; } } if (Object.prototype.hasOwnProperty.call(options, 'ai')) { const ai = options.ai; backupFields(ai, ['enabled', 'mode', 'scope', 'analyzeImages', 'topics', 'budget', 'requestLimit'], platform + ' AI settings'); if ((platform === 'reddit' && ai.scope !== 'all') || (platform === 'bluesky' && ['news', 'all'].indexOf(ai.scope) === -1)) throw new Error(platform + ' AI scope is invalid.'); // Validate with the local schema, then retain the other platform's scope. copy.ai = validateAI(Object.assign({ topics: [] }, ai, { scope: 'all' })); copy.ai.scope = ai.scope; } result[platform] = copy; } return result; } function mergePlatformSettings(current, incoming) { const result = preservePlatformSettings(current), next = preservePlatformSettings(incoming); for (const platform of Object.keys(next)) result[platform] = Object.assign({}, result[platform] || {}, next[platform]); return result; } function sharedAITopics(shared, platforms) { let topics; if (Object.prototype.hasOwnProperty.call(shared, 'aiTopics')) topics = shared.aiTopics; else { for (const platform of ['bluesky', 'reddit']) { if (platforms[platform] && platforms[platform].ai) { if (!topics) topics = []; topics = topics.concat(platforms[platform].ai.topics); } } if (topics) topics = [...new Set(topics)]; } if (topics === undefined) return undefined; if (!Array.isArray(topics) || topics.some(topic => typeof topic !== 'string' || topic.length > MAX_AI_TOPIC_LENGTH)) throw new Error('Shared AI prompts must be text entries of at most 1,000 characters each.'); topics = [...new Set(topics.map(topic => topic.trim()).filter(Boolean))]; if (topics.length > 20) throw new Error('Shared AI prompts must contain at most 20 topics. Combine the older platform-specific topics before importing.'); return topics; } function parseBackup(text) { if (new TextEncoder().encode(text).length > BACKUP_FILE_BYTES) throw new Error('Settings files must be at most 10 MiB.'); let value; try { value = JSON.parse(text); } catch (error) { throw new Error('The file is not valid JSON.'); } backupObject(value, 'Backup'); if (value.version !== 1) throw new Error('Unsupported backup version. This script supports version 1.'); let shared, platforms = {}, source = ''; if (value.format === 'unified-content-filter-backup') { backupObject(value.shared, 'Shared filters'); shared = validateSharedFilters(value.shared.blocklist, value.shared.allowlist); // Older version-1 readers ignore top-level additions. Keep the extension out of platforms on disk. if (value.platforms && value.platforms.reddit && Object.prototype.hasOwnProperty.call(value.platforms.reddit, 'filters')) throw new Error('Reddit filters must use the top-level redditFilters field.'); if (value.platforms && value.platforms.bluesky && Object.prototype.hasOwnProperty.call(value.platforms.bluesky, 'filters')) throw new Error('Bluesky filters must use the top-level blueskyFilters field.'); platforms = preservePlatformSettings(value.platforms); if (Object.prototype.hasOwnProperty.call(value, 'redditFilters')) platforms.reddit = Object.assign({}, platforms.reddit, { filters: validateRedditFilters(value.redditFilters) }); if (Object.prototype.hasOwnProperty.call(value, 'blueskyFilters')) platforms.bluesky = Object.assign({}, platforms.bluesky, { filters: validateBlueskyFilters(value.blueskyFilters) }); } else if (value.format === 'bluesky-content-manager-settings') { source = 'bluesky'; backupObject(value.settings, 'Bluesky settings'); shared = validateSharedFilters(value.settings.filteredTerms, value.settings.manualWhitelistTerms); platforms.bluesky = {}; for (const key of ['autoWhitelistEnabled', 'altTextEnforcementEnabled', 'ai']) { if (Object.prototype.hasOwnProperty.call(value.settings, key)) platforms.bluesky[key] = value.settings[key]; } } else if (['reddit-advanced-content-filter-settings', 'reddit-advanced-content-filter-ai-settings'].indexOf(value.format) !== -1) { source = 'reddit'; shared = validateSharedFilters(value.blocklist, value.whitelist); platforms.reddit = {}; if (Object.prototype.hasOwnProperty.call(value, 'ai')) platforms.reddit.ai = value.ai; } else throw new Error('Unsupported backup format. Choose a unified, Bluesky, or Reddit settings backup.'); platforms = preservePlatformSettings(platforms); const topics = sharedAITopics(value.format === 'unified-content-filter-backup' ? value.shared : {}, platforms); if (topics !== undefined) shared.aiTopics = topics; return { shared, platforms, source }; } function createUnifiedBackup(shared, platforms) { const value = { format: 'unified-content-filter-backup', version: 1, exportedAt: new Date().toISOString(), shared: validateSharedFilters(shared.blocklist, shared.allowlist), platforms: preservePlatformSettings(platforms) }; value.shared.aiTopics = sharedAITopics(shared, value.platforms) || []; for (const platform of Object.keys(value.platforms)) { if (value.platforms[platform].ai) delete value.platforms[platform].ai.topics; } if (value.platforms.reddit && value.platforms.reddit.filters) { value.redditFilters = value.platforms.reddit.filters; delete value.platforms.reddit.filters; } if (value.platforms.bluesky && value.platforms.bluesky.filters) { value.blueskyFilters = value.platforms.bluesky.filters; delete value.platforms.bluesky.filters; } if (new TextEncoder().encode(JSON.stringify(value)).length > BACKUP_FILE_BYTES) throw new Error('The JSON backup exceeds 10 MiB. Shorten the lists before exporting.'); return value; } // End Unified Filter Backup v1. // Keep native textarea layout bounded while retaining the complete unsaved draft. function createPagedEditor(editor, previous, next, label, bar) { let pages = [''], page = 0; const offset = () => pages.slice(0, page).reduce((sum, text) => sum + text.length + 1, 0); const getText = () => { pages[page] = editor.value; return pages.join('\n'); }; function refresh() { bar.hidden = pages.length === 1; label.textContent = 'Page ' + (page + 1) + ' of ' + pages.length + '. Edits affect this page; Find, Save and Export use all pages.'; previous.disabled = editor.disabled || page === 0; next.disabled = editor.disabled || page === pages.length - 1; } function show(start = 0, end = start) { editor.value = pages[page]; editor.setSelectionRange(start, end); editor.scrollTop = 0; refresh(); } function setText(text, caret = 0) { pages = []; let lines = [], size = 0; for (const line of text.split('\n')) { if (lines.length && (lines.length >= 1000 || size + line.length + 1 > 65536)) { pages.push(lines.join('\n')); lines = []; size = 0; } lines.push(line); size += line.length + 1; } pages.push(lines.join('\n')); page = 0; while (page < pages.length - 1 && caret > pages[page].length) { caret -= pages[page].length + 1; page++; } show(caret); } function capture(event) { if (event && event.isComposing) return; if (editor.value.length > 65536 || editor.value.split('\n').length > 1000) { const caret = offset() + editor.selectionEnd; setText(getText(), caret); } else { pages[page] = editor.value; refresh(); } } editor.addEventListener('input', capture); editor.addEventListener('compositionend', capture); previous.addEventListener('click', () => { getText(); page = Math.max(0, page - 1); show(); editor.focus(); }); next.addEventListener('click', () => { getText(); page = Math.min(pages.length - 1, page + 1); show(); editor.focus(); }); function findNext(query) { const raw = getText(), text = raw.toLowerCase(); const start = raw.slice(0, offset() + editor.selectionEnd).toLowerCase().length; query = query.toLowerCase(); if (!query) return; let index = text.indexOf(query, start); if (index < 0) index = text.indexOf(query); if (index < 0) return; let end = index + query.length; if (raw.length !== text.length) { let original = 0, folded = 0, mappedStart = 0, mappedEnd = 0; for (const character of raw) { const nextFolded = folded + character.toLowerCase().length; if (folded <= index && index < nextFolded) mappedStart = original; original += character.length; if (folded < end && end <= nextFolded) { mappedEnd = original; break; } folded = nextFolded; } index = mappedStart; end = mappedEnd; } const length = end - index; page = 0; while (page < pages.length - 1 && index > pages[page].length) { index -= pages[page].length + 1; page++; } show(index, index + length); editor.focus(); editor.scrollTop = editor.value.slice(0, index).split('\n').length * parseFloat(getComputedStyle(editor).lineHeight); } return { getText, setText, findNext, refresh }; } function normalizeTerms(values) { return normalizeFilterList(values, 'Filter list'); } function exportBackup(value) { const platforms = preservePlatformSettings(value.backupPlatforms); platforms.bluesky = { ai: value.ai, autoWhitelistEnabled: value.autoWhitelistEnabled, altTextEnforcementEnabled: value.altTextEnforcementEnabled }; if (value.blueskyFilters) platforms.bluesky.filters = value.blueskyFilters; return createUnifiedBackup({ blocklist: value.filteredTerms, allowlist: value.manualWhitelistTerms, aiTopics: value.ai.topics }, platforms); } function validateSettings(value) { if (!value || typeof value !== 'object' || typeof value.autoWhitelistEnabled !== 'boolean' || typeof value.altTextEnforcementEnabled !== 'boolean') throw new Error('The settings file has invalid options.'); const shared = validateSharedFilters(value.filteredTerms, value.manualWhitelistTerms); return splitLegacyBluesky({ filteredTerms: shared.blocklist, manualWhitelistTerms: shared.allowlist, autoWhitelistEnabled: value.autoWhitelistEnabled, altTextEnforcementEnabled: value.altTextEnforcementEnabled, ai: validateAI(value.ai), backupPlatforms: preservePlatformSettings(value.backupPlatforms) }, value.blueskyFilters); } function splitLegacyBluesky(value, existing) { const filters = validateBlueskyFilters(existing === undefined ? {} : existing); if (existing === undefined) { for (const [list, accounts] of [['filteredTerms', 'blockedAccounts'], ['manualWhitelistTerms', 'allowedAccounts']]) { value[list] = value[list].filter(term => { const account = term.startsWith('@') && blueskyAccount( accounts === 'allowedAccounts' ? normalizeUsername(term) : term, accounts === 'allowedAccounts'); if (!account) return true; filters[accounts].push(account); return false; }); filters[accounts] = Array.from(new Set(filters[accounts])); } } return Object.assign(value, { blueskyFilters: filters }); } function blueskyBackupDraft(backup, current) { const value = { filteredTerms: backup.shared.blocklist, manualWhitelistTerms: backup.shared.allowlist }; const options = backup.platforms.bluesky || {}; if (options.filters) return splitLegacyBluesky(value, options.filters); if (backup.source === 'reddit' || (backup.platforms.reddit && backup.platforms.reddit.filters)) return splitLegacyBluesky(value, current); splitLegacyBluesky(value); if (!backup.source) { for (const key of blueskyFilterKeys) value.blueskyFilters[key] = Array.from(new Set(current[key].concat(value.blueskyFilters[key]))); } return splitLegacyBluesky(value, value.blueskyFilters); } function reportIssue(key, message) { issues.set(key, message); scheduleMenu(); updateDialogStatus(); } function clearIssue(key) { if (issues.delete(key)) scheduleMenu(); updateDialogStatus(); } function readStored(key, fallback) { try { return GM_getValue(key, fallback); } catch (e) { reportIssue(key, 'Some saved data could not be read. Existing settings were kept; open settings and try again.'); return fallback; } } function readSettings(raw) { try { if (!arguments.length) raw = GM_getValue(SETTINGS_KEY, null); } catch (e) { reportIssue(SETTINGS_KEY, 'Saved settings could not be read. The last valid settings were kept. Check userscript storage and try again.'); return { value: settings, raw: settingsRaw }; } if (raw !== null) { try { const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; if (parsed.version !== 1 || !parsed.settings || typeof parsed.settings !== 'object') throw new Error(); const value = Object.assign({}, settings); try { value.backupPlatforms = preservePlatformSettings(parsed.settings.backupPlatforms); } catch (error) { value.backupPlatforms = settings.backupPlatforms || {}; reportIssue('backup-platforms', 'Retained platform settings are invalid. Your active filters were recovered; export a backup before saving to repair this record.'); } try { value.ai = validateAI(parsed.settings.ai); } catch (e) { value.ai = Object.assign(Object.assign({}, aiDefaults), { topics: [] }); reportIssue('ai-settings', 'Saved AI settings are invalid. AI is off; review and save them again.'); } for (const key of ['filteredTerms', 'manualWhitelistTerms', 'autoWhitelistEnabled', 'altTextEnforcementEnabled']) { try { if (key.endsWith('Terms')) value[key] = normalizeTerms(parsed.settings[key]); else { if (typeof parsed.settings[key] !== 'boolean') throw new Error(); value[key] = parsed.settings[key]; } } catch (e) { reportIssue(key, `The saved ${SETTING_LABELS[key]} field is invalid. Other settings were recovered; its last valid value was kept. Export a backup before correcting it.`); } } try { validateSharedFilters(value.filteredTerms, value.manualWhitelistTerms); } catch (error) { value.filteredTerms = settings.filteredTerms; value.manualWhitelistTerms = settings.manualWhitelistTerms; reportIssue('settings', error.message + ' Saved data was not overwritten; the last valid filters were kept.'); } try { splitLegacyBluesky(value, parsed.settings.blueskyFilters); } catch (error) { value.blueskyFilters = settings.blueskyFilters || validateBlueskyFilters({}); reportIssue('account-filters', 'Saved account rules are invalid. Review and save a backup to repair them.'); } return { value, raw, quickEdits: readQuickEdits(parsed.quickEdits) }; } catch (e) { reportIssue('settings', 'Saved settings could not be read. Export a backup before saving.'); if (settingsLoaded) return { value: settings, raw }; } } const value = Object.assign({}, settings); for (const key of ['filteredTerms', 'manualWhitelistTerms']) { try { const stored = readStored(key, '[]'); value[key] = normalizeTerms(typeof stored === 'string' ? JSON.parse(stored) : stored); } catch (e) { reportIssue(key, `The saved ${key === 'filteredTerms' ? 'blocklist' : 'allowlist'} is invalid. Its original data has not been overwritten. Restore a backup or correct this list before saving.`); } } for (const key of ['autoWhitelistEnabled', 'altTextEnforcementEnabled']) { const stored = readStored(key, false); if (typeof stored === 'boolean') value[key] = stored; else reportIssue(key, `The saved ${SETTING_LABELS[key]} option is invalid. Its previous value was kept.`); } try { const stored = readStored('whitelistedUsers', '[]'); const legacy = normalizeTerms(typeof stored === 'string' ? JSON.parse(stored) : stored).map(user => user.startsWith('@') ? user : '@' + user); value.manualWhitelistTerms = normalizeTerms(value.manualWhitelistTerms.concat(legacy)); } catch (e) { reportIssue('whitelistedUsers', 'Some legacy allowed accounts could not be read. Their original data has not been overwritten.'); } try { validateSharedFilters(value.filteredTerms, value.manualWhitelistTerms); } catch (error) { value.filteredTerms = settings.filteredTerms; value.manualWhitelistTerms = settings.manualWhitelistTerms; reportIssue('settings', error.message + ' Saved data was not overwritten; the last valid filters were kept.'); } return { value: splitLegacyBluesky(value), raw, quickEdits: [] }; } const quickLists = ['filteredTerms', 'manualWhitelistTerms'].concat(blueskyFilterKeys); const filterStorageIssues = ['settings', SETTINGS_KEY, 'filteredTerms', 'manualWhitelistTerms', 'autoWhitelistEnabled', 'altTextEnforcementEnabled', 'whitelistedUsers', 'ai-settings', 'backup-platforms', 'account-filters']; function getFilterList(value, list) { return blueskyFilterKeys.includes(list) ? value.blueskyFilters[list] : value[list]; } function readQuickEdits(edits) { return Array.isArray(edits) ? edits.filter(edit => edit && typeof edit.id === 'string' && quickLists.includes(edit.list) && edit.until > Date.now() && Array.isArray(edit.changes) && edit.changes.length > 0 && edit.changes.length <= 8 && edit.changes.every(change => Array.isArray(change) && typeof change[0] === 'string' && typeof change[1] === 'boolean' && Number.isInteger(change[2]) && change[2] >= 0 && (change[3] === undefined || typeof change[3] === 'string'))).slice(-64) : []; } function withFilterSettingsLock(work) { if (!navigator.locks || typeof navigator.locks.request !== 'function') return Promise.reject(new Error('Saving filters requires a browser with Web Locks support.')); return navigator.locks.request('bcm-filter-settings-v1', work); } function currentFilterSnapshot() { return typeof GM !== 'undefined' && GM && typeof GM.getValue === 'function' ? GM.getValue(SETTINGS_KEY, null) : GM_getValue(SETTINGS_KEY, null); } function writeFilterSettings(value, edits) { return __awaiter(this, void 0, void 0, function* () { const snapshot = { version: 1, settings: value }; if (edits.length) snapshot.quickEdits = edits; const raw = JSON.stringify(snapshot); if (typeof GM !== 'undefined' && GM && typeof GM.setValue === 'function') yield GM.setValue(SETTINGS_KEY, raw); else yield GM_setValue(SETTINGS_KEY, raw); if ((yield currentFilterSnapshot()) !== raw) throw new Error('Storage verification failed. Export your draft and retry; saved data may be incomplete.'); }); } function persistFilterChange(list, targets, present, undoID) { return withFilterSettingsLock(() => __awaiter(this, void 0, void 0, function* () { const current = readSettings(yield currentFilterSnapshot()); if (!current.quickEdits || filterStorageIssues.some(key => issues.has(key))) throw new Error('Review and repair saved filters in Settings before using quick actions.'); const terms = getFilterList(current.value, list), lookup = new Set(terms); let changes; if (undoID) { const edit = current.quickEdits.find(record => record.id === undoID && record.list === list); if (!edit || edit.changes.some(([term, added]) => lookup.has(term) !== added)) return null; changes = edit.changes.map(([term, added, index, following]) => [term, !added, index, following]); } else if (present) { if (!targets.length || targets.some(term => lookup.has(term))) return null; changes = [[targets[0], true, terms.length]]; } else { const removed = new Set(targets); changes = []; terms.forEach((term, index) => { if (removed.has(term)) changes.push([term, false, index, terms[index + 1] || '']); }); if (!changes.length) return null; } const changed = new Set(changes.map(change => change[0])); const next = terms.filter(term => !changed.has(term)); for (const [term, add, index, following] of changes.slice().reverse()) { if (!add) continue; const neighbor = following ? next.indexOf(following) : -1; next.splice(neighbor < 0 ? index : neighbor, 0, term); } const value = Object.assign({}, current.value); if (blueskyFilterKeys.includes(list)) value.blueskyFilters = Object.assign({}, value.blueskyFilters, { [list]: next }); else value[list] = next; const edits = current.quickEdits.filter(edit => edit.list !== list || !edit.changes.some(change => changed.has(change[0]))); const id = undoID || Array.from(crypto.getRandomValues(new Uint32Array(4)), part => part.toString(16).padStart(8, '0')).join(''); if (!undoID) edits.push({ id, list, changes, until: Date.now() + 600000 }); yield writeFilterSettings(validateSettings(value), readQuickEdits(edits)); return undoID ? true : id; })); } function saveFilterDraft(baseRaw, value) { return withFilterSettingsLock(() => __awaiter(this, void 0, void 0, function* () { const raw = yield currentFilterSnapshot(); if (JSON.stringify(raw) !== JSON.stringify(baseRaw)) throw new Error('Settings changed in another tab. Export these edits, then close and reopen settings before saving.'); const current = readSettings(raw); const edits = (current.quickEdits || []).filter(edit => edit.changes.every(([term, present]) => getFilterList(value, edit.list).includes(term) === present)); yield writeFilterSettings(value, edits); })); } function reloadSavedFilters() { return __awaiter(this, void 0, void 0, function* () { const epoch = ++settingsReloadEpoch; try { const raw = yield currentFilterSnapshot(); if (epoch !== settingsReloadEpoch) return; clearIssue(SETTINGS_KEY); if (raw === settingsRaw && settingsLoaded) return; const loaded = readSettings(raw); installSettings(loaded.value); settingsRaw = loaded.raw; settingsLoaded = true; if (configDialog) configDialog.dispatchEvent(new Event('bcm-settings-changed')); yield refreshEnvironment(true); } catch (error) { if (epoch === settingsReloadEpoch) reportIssue(SETTINGS_KEY, 'Could not reload the saved filters. Check userscript storage and refresh the page.'); } }); } function initializeSettings() { const loaded = readSettings(); settingsRaw = loaded.raw; installSettings(loaded.value); settingsLoaded = true; } function installSettings(value) { const next = validateSettings(value); if (JSON.stringify(next) !== JSON.stringify(settings)) { contextEpoch++; contextInput = ""; } settings = next; resetAI(); ({ filteredTerms, manualWhitelistTerms, autoWhitelistEnabled, altTextEnforcementEnabled } = settings); patterns.clear(); allowedKeywordTerms = manualWhitelistTerms; blockedAccountSet = new Set(settings.blueskyFilters.blockedAccounts); allowedAccountSet = new Set(settings.blueskyFilters.allowedAccounts); compileTerms(filteredTerms); compileTerms(allowedKeywordTerms); } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function foldText(value) { return value.normalize('NFKC').replace(/[\u00AD\u200B-\u200F\u202A-\u202E\u2060\uFEFF]/g, '').toLowerCase(); } const phraseSeparators = new RegExp('[\\s\\p{Pd}_]+', 'gu'); function phraseText(value) { return value.replace(phraseSeparators, ' '); } function cleanText(value) { return phraseText(foldText(value)).trim(); } // A conservative candidate index; the existing matcher still decides every match. function indexText(text) { return text.toUpperCase().toLowerCase().replace(/\u03c2/g, '\u03c3'); } function buildMatchIndex(entries) { const index = new Map(); index.needles = []; entries.forEach((term, position) => { let entry = foldText(term).trim(); if (!entry) return; const variants = [phraseText(entry)]; if (/^[a-z]+$/.test(entry)) { if (/[^aeiou]y$/.test(entry)) variants.push(entry.slice(0, -1) + 'ies'); else if (/(s|x|z|ch|sh)$/.test(entry)) variants.push(entry + 'es'); else variants.push(entry + 's'); } index.needles[position] = variants.map(indexText); if (/^[a-z]+$/.test(entry) && /[^aeiou]y$/.test(entry)) entry = entry.slice(0, -1); const key = indexText(phraseText(entry)).slice(0, 3); if (!index.has(key)) index.set(key, []); index.get(key).push(position); }); return index; } function matchCandidates(text, phrases, index) { const found = new Set(), visited = new Set(); for (const value of [indexText(text), indexText(phrases)]) { for (let start = 0; start < value.length; start++) { for (let length = 1; length <= 3 && start + length <= value.length; length++) { const key = value.slice(start, start + length); if (visited.has(key)) continue; visited.add(key); const positions = index.get(key); if (positions) for (const position of positions) found.add(position); } } } const searchable = indexText(text), searchablePhrases = indexText(phrases); return Array.from(found).filter(position => index.needles[position].some(needle => searchable.indexOf(needle) !== -1 || searchablePhrases.indexOf(needle) !== -1)).sort((a, b) => a - b); } const matchIndexes = new WeakMap(); let allowedKeywordTerms = []; function compileTerms(terms) { if (!matchIndexes.has(terms)) matchIndexes.set(terms, buildMatchIndex(terms)); return terms; } function termToRegex(term) { if (!term || typeof term !== 'string') return null; if (!patterns.has(term)) { const entry = foldText(term).trim(), variants = [entry]; if (!entry) return null; if (/\s/.test(entry)) patterns.set(term, new RegExp(escapeRegExp(phraseText(entry)), 'iu')); else { // shortcut: regular English plurals only; enter irregular forms separately. if (/^[a-z]+$/.test(entry)) { if (/[^aeiou]y$/.test(entry)) variants.push(entry.slice(0, -1) + 'ies'); else if (/(s|x|z|ch|sh)$/.test(entry)) variants.push(entry + 'es'); else variants.push(entry + 's'); } patterns.set(term, new RegExp('(^|[^\\p{L}\\p{N}_])(' + variants.map(escapeRegExp).join('|') + ')(?=$|[^\\p{L}\\p{N}_])', 'iu')); } } return patterns.get(term); } function regexMatchAny(text, terms) { if (!text || !terms.length) return null; const folded = foldText(text), phrases = phraseText(folded); compileTerms(terms); for (const position of matchCandidates(folded, phrases, matchIndexes.get(terms))) { const term = terms[position]; const re = termToRegex(term); if (re && re.test(/\s/.test(foldText(term).trim()) ? phrases : folded)) return term; } return null; } function digest(value) { return __awaiter(this, void 0, void 0, function* () { const bytes = yield crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); return Array.from(new Uint8Array(bytes), byte => byte.toString(16).padStart(2, '0')).join(''); }); } function rebuildContext() { const value = JSON.stringify([2, settings, currentUserDid, autoWhitelistEnabled ? followReady : true, autoWhitelistEnabled ? [...whitelistedUsers].sort() : []]); if (value === contextInput) return contextReady; contextInput = value; const epoch = ++contextEpoch; contextReady = digest(value).then(context => { if (epoch !== contextEpoch) return; decisionContext = context; processedPosts = new WeakMap(); rescanPosts(); }); return contextReady; } /*** POST IDENTITY & CONTENT ***/ function parsePostURL(href) { try { const url = new URL(href, 'https://bsky.app'), match = url.pathname.match(/^\/profile\/([^/]+)\/post\/([^/]+)\/?$/); if (url.origin !== 'https://bsky.app' || !match) return null; const author = decodeURIComponent(match[1]), record = decodeURIComponent(match[2]); if (!/^[a-zA-Z0-9._:~-]+$/.test(author) || !/^[a-zA-Z0-9._:~-]+$/.test(record)) return null; return { url: `https://bsky.app/profile/${encodeURIComponent(author)}/post/${encodeURIComponent(record)}`, author: normalizeUsername('@' + author) }; } catch (e) { return null; } } function isPostContainer(node) { var _a; if (!(node === null || node === void 0 ? void 0 : node.matches) || node.closest('[data-bcm-ui]') || node.closest(QUOTE_SELECTOR)) return false; if (node.matches(POST_SELECTOR)) return true; return node.matches('div[role="link"]') && !((_a = node.parentElement) === null || _a === void 0 ? void 0 : _a.closest(POST_SELECTOR)) && !!node.querySelector('[data-testid="postText"],a[href*="/post/"]'); } function getPostContainer(node) { let current = (node === null || node === void 0 ? void 0 : node.nodeType) === 1 ? node : node === null || node === void 0 ? void 0 : node.parentElement; const quote = current === null || current === void 0 ? void 0 : current.closest(QUOTE_SELECTOR); if (quote) current = quote.parentElement; const explicit = current === null || current === void 0 ? void 0 : current.closest(POST_SELECTOR); if (explicit && isPostContainer(explicit)) return explicit; while (current && current !== document.body) { if (isPostContainer(current)) return current; current = current.parentElement; } return null; } function postTextSurface(element, post) { if (!element || element.closest(QUOTE_SELECTOR)) return null; const named = element.closest('[data-testid="postText"]'); if (named || !post.matches(THREAD_POST_SELECTOR)) return named; let surface = null; for (let node = element; node && node !== post; node = node.parentElement) if (node.hasAttribute('data-word-wrap')) surface = node; return surface; } function ownElement(element, post) { return !element.closest(QUOTE_SELECTOR) && !postTextSurface(element, post) && element.closest(POST_SELECTOR) === (post.matches(POST_SELECTOR) ? post : null); } function declaredPostAuthor(post) { const match = (post.getAttribute('data-testid') || '').match(/^(?:feedItem|postThreadItem)-by-(.+)$/); return match && match[1] !== 'handle.invalid' ? blueskyAccount(match[1]) : ''; } function getPostIdentity(post) { const own = post.matches('a[href]') && parsePostURL(post.getAttribute('href')); if (own) return own; const declared = declaredPostAuthor(post); for (const link of post.querySelectorAll('a[href*="/post/"]')) { if (!ownElement(link, post)) continue; const parsed = parsePostURL(link.getAttribute('href')); if (parsed && (!declared || parsed.author === declared)) return parsed; } return null; } function extractAuthorHandle(post) { const identity = getPostIdentity(post); if (identity) return identity.author; const declared = declaredPostAuthor(post); if (declared) return declared; const authors = new Set(); for (const link of post.querySelectorAll('a[href*="/profile/"]')) { if (!ownElement(link, post) || link.closest('[data-testid="postText"]')) continue; try { const url = new URL(link.getAttribute('href'), 'https://bsky.app'), match = url.origin === 'https://bsky.app' && url.pathname.match(/^\/profile\/([^/]+)\/?$/); if (match) authors.add(normalizeUsername('@' + decodeURIComponent(match[1]))); } catch (e) { } } return authors.size === 1 ? [...authors][0] : ''; } function getAuthorIdentities(post, identity) { const ids = new Set(identity ? [identity.author] : []); if (!identity) { const author = extractAuthorHandle(post); return author ? [author] : []; } const declared = declaredPostAuthor(post); if (declared) ids.add(declared); const permalink = Array.from(post.querySelectorAll('a[href*="/post/"]')).find(link => { const candidate = ownElement(link, post) && parsePostURL(link.getAttribute('href')); return candidate && candidate.url === identity.url; }); let scope = (permalink === null || permalink === void 0 ? void 0 : permalink.parentElement) || post; while (scope) { const links = [...scope.querySelectorAll('a[href*="/profile/"]')].filter(link => ownElement(link, post) && !link.closest('[data-testid="postText"]') && !parsePostURL(link.getAttribute('href'))); if (links.length) { // Use a single opposite-form alias in the permalink's own profile row. const profiles = new Set(); for (const link of links) { try { const url = new URL(link.getAttribute('href'), 'https://bsky.app'), match = url.origin === 'https://bsky.app' && url.pathname.match(/^\/profile\/([^/]+)\/?$/); if (match) profiles.add(normalizeUsername('@' + decodeURIComponent(match[1]))); } catch (e) { } } const aliases = Array.from(profiles).filter(profile => profile !== identity.author); if (aliases.length === 1 && aliases[0].startsWith('@did:') !== identity.author.startsWith('@did:') && (scope !== post || profiles.size === 1)) ids.add(aliases[0]); break; } if (scope === post) break; scope = scope.parentElement; } return [...ids]; } function shouldRequireAltText(img) { const src = img.getAttribute('src') || ''; if (src.includes('/avatar') || src.includes('/banner') || img.closest('[data-testid="avatar"]') || img.classList.contains('avatar')) return false; const width = img.naturalWidth || img.width || 0, height = img.naturalHeight || img.height || 0; return !(width > 0 && height > 0 && (width <= 48 || height <= 48)) && img.getAttribute('aria-hidden') !== 'true' && img.getAttribute('role') !== 'presentation'; } function isControl(element) { return !!element.closest('button,[role="button"],time,[data-bcm-ui]'); } function getPostEvidence(post) { const parts = []; for (const node of post.querySelectorAll('img[alt],img[aria-label],a[title],a[aria-label],video[title],[role="link"][aria-label]')) { if (node.tagName === 'IMG') { if (!aiImageElement(node)) continue; parts.push(node.alt || node.getAttribute('aria-label') || ''); continue; } if (isControl(node) || node.closest('[data-testid="avatar"],[data-bcm-ui]')) continue; const href = node.getAttribute('href'); if (href) { try { const url = new URL(href, 'https://bsky.app'); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.origin === 'https://bsky.app') continue; } catch (e) { continue; } } parts.push(node.getAttribute('title') || '', node.getAttribute('aria-label') || ''); } for (const link of post.querySelectorAll('a[href]')) { if (isControl(link) || link.closest('[data-testid="avatar"],[data-bcm-ui]')) continue; try { const url = new URL(link.getAttribute('href'), 'https://bsky.app'); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.origin === 'https://bsky.app') continue; parts.push(decodeURIComponent(url.pathname).replace(/[-_/]+/g, ' ')); } catch (e) { } } return parts.filter(text => text.trim()); } function getPostData(post) { const identity = getPostIdentity(post), author = (identity === null || identity === void 0 ? void 0 : identity.author) || extractAuthorHandle(post), authorIds = getAuthorIdentities(post, identity); const texts = [...post.querySelectorAll('[data-testid="postText"]')].map(element => element.textContent || ''); const images = [...post.querySelectorAll('img')].map(img => ({ text: aiImageElement(img) ? (img.alt || img.getAttribute('aria-label') || '') : '', alt: img.alt || '', src: img.getAttribute('src') || '', required: shouldRequireAltText(img) })); const names = [...post.querySelectorAll('a[href*="/profile/"]')].filter(element => !parsePostURL(element.getAttribute('href'))).map(element => element.textContent || '').join(' '); // Keep the production keyword checks for post labels separate from the narrower AI evidence. const labels = getPostEvidence(post).concat(Array.from(post.querySelectorAll('[aria-label],[title]')) .filter(node => node.tagName !== 'IMG' && !isControl(node) && !node.closest('[data-testid="avatar"]') && !(node.matches('a') && parsePostURL(node.getAttribute('href')))) .map(node => (node.getAttribute('aria-label') || '') + ' ' + (node.getAttribute('title') || ''))).join(' '); const parts = [], walker = document.createTreeWalker(post, NodeFilter.SHOW_TEXT); let node; while ((node = walker.nextNode())) { const parent = node.parentElement; if (!parent || isControl(parent)) continue; const link = parent.closest('a'); if (link && parsePostURL(link.getAttribute('href')) && !link.querySelector('[data-testid="postText"]')) continue; parts.push(node.nodeValue); } return { identity, author, authorIds, text: texts.join(' '), images, names, labels, general: parts.join(' ').replace(/\s+/g, ' ').trim() }; } function isWhitelisted(post, data = getPostData(post)) { return (data.authorIds || [data.author]).some(author => whitelistedUsers.has(author) || allowedAccountSet.has(author)); } function decidePost(post, data) { if (isWhitelisted(post, data)) return null; const combined = [data.general, data.text, data.names, data.labels, ...data.images.map(img => img.text)].join(' '); if (regexMatchAny(combined, allowedKeywordTerms)) return null; const account = (data.authorIds || [data.author]).find(author => blockedAccountSet.has(author)); if (account) return { reason: 'Blocked account', keyword: account }; if (altTextEnforcementEnabled && data.images.some(img => img.required && !img.alt.trim())) return { reason: 'Alt-text enforcement', keyword: 'missing alt-text' }; const fields = data.images.map(img => [img.text, 'Alt-text keyword']).concat([[data.names, 'Author name'], [data.text, 'Post text'], [data.labels, 'ARIA/TITLE attribute'], [data.general, 'General content']]); for (const [text, reason] of fields) { const keyword = regexMatchAny(text, filteredTerms); if (keyword) return { reason, keyword }; } return null; } function shouldProcessPage() { return !location.pathname.startsWith('/notifications'); } function recordBlockedPost(post, data, decision) { var _a, _b; if (!nodeIds.has(post)) nodeIds.set(post, ++nodeSequence); const provisional = 'node:' + nodeIds.get(post), id = ((_a = data.identity) === null || _a === void 0 ? void 0 : _a.url) || provisional, excerpt = (data.text || data.general || '[No text content]').trim(), previous = blockedPosts.get(id) || blockedPosts.get(provisional); if (data.identity) blockedPosts.delete(provisional); blockedPosts.set(id, Object.assign(Object.assign({ id, url: ((_b = data.identity) === null || _b === void 0 ? void 0 : _b.url) || '', author: data.author || '[Unknown author]', excerpt: excerpt.length > 240 ? excerpt.slice(0, 240) + '…' : excerpt }, decision), { authorIds: data.authorIds, timestamp: (previous === null || previous === void 0 ? void 0 : previous.timestamp) || new Date().toLocaleTimeString() })); scheduleMenu(); } function processPost(post) { return __awaiter(this, void 0, void 0, function* () { var _a; const container = getPostContainer(post); if (!container || !container.isConnected) return; const epoch = contextEpoch; yield contextReady; if (epoch !== contextEpoch) { enqueuePost(container); return; } if (!shouldProcessPage() || (autoWhitelistEnabled && !followReady)) { container.classList.remove('bcm-filtered'); clearAIPanel(container); return; } const context = decisionContext, data = getPostData(container), input = JSON.stringify([data.author, data.authorIds, (_a = data.identity) === null || _a === void 0 ? void 0 : _a.url, data.text, data.names, data.labels, data.general, data.images]), previous = processedPosts.get(container); if ((previous === null || previous === void 0 ? void 0 : previous.input) === input && previous.context === context) { container.classList.toggle('bcm-filtered', !!previous.decision); if (!previous.decision) void scheduleAI(container, data, context); return; } clearAIPanel(container); const run = (postRuns.get(container) || 0) + 1; postRuns.set(container, run); const fingerprint = data.identity ? yield digest(input) : ''; if (postRuns.get(container) !== run || epoch !== contextEpoch || context !== decisionContext || !container.isConnected) return; const key = data.identity ? `${context}|${data.identity.url}` : '', cached = key && blockedCache.get(key); const decision = (cached === null || cached === void 0 ? void 0 : cached.fingerprint) === fingerprint && Date.now() - cached.at < CACHE_AGE ? { reason: cached.reason, keyword: cached.keyword } : decidePost(container, data); processedPosts.set(container, { input, context, decision }); container.classList.toggle('bcm-filtered', !!decision); if (decision) { recordBlockedPost(container, data, decision); if (key && (!cached || cached.fingerprint !== fingerprint)) rememberDecision(key, Object.assign(Object.assign({ fingerprint }, decision), { at: Date.now() })); } else if (cached) blockedCache.delete(key); if (decision) clearAIPanel(container); else void scheduleAI(container, data, context); }); } /*** PERSISTENT DECISIONS ***/ function cacheEntries(value) { const parsed = typeof value === 'string' ? JSON.parse(value) : value; if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.entries)) throw new Error(); return parsed.entries.filter(entry => Array.isArray(entry) && typeof entry[0] === 'string' && entry[0].length < 1000 && entry[1] && /^[a-f0-9]{64}$/.test(entry[1].fingerprint) && typeof entry[1].reason === 'string' && typeof entry[1].keyword === 'string' && Number.isFinite(entry[1].at) && Date.now() - entry[1].at >= 0 && Date.now() - entry[1].at < CACHE_AGE).slice(-CACHE_LIMIT); } function loadDecisionCache() { try { for (const [key, value] of cacheEntries(readStored(CACHE_KEY, '{"version":1,"entries":[]}'))) blockedCache.set(key, value); } catch (e) { reportIssue('cache', 'Remembered blocks could not be loaded. Normal filtering still works; clear remembered decisions in settings to rebuild them.'); } } function rememberDecision(key, value) { blockedCache.delete(key); blockedCache.set(key, value); pendingCache.set(key, value); while (blockedCache.size > CACHE_LIMIT) blockedCache.delete(blockedCache.keys().next().value); if (!cacheTimer) cacheTimer = setTimeout(flushDecisionCache, 500); } function flushDecisionCache() { clearTimeout(cacheTimer); cacheTimer = null; if (!pendingCache.size) return; const additions = new Map(pendingCache); pendingCache.clear(); let merged; try { merged = new Map(cacheEntries(GM_getValue(CACHE_KEY, '{"version":1,"entries":[]}'))); } catch (e) { merged = new Map(); } for (const [key, value] of additions) { merged.delete(key); merged.set(key, value); } const entries = [...merged].sort((a, b) => a[1].at - b[1].at).slice(-CACHE_LIMIT); const failed = () => { for (const [key, value] of additions) if (!pendingCache.has(key)) pendingCache.set(key, value); reportIssue('cache-save', 'Remembered blocks could not be saved. Filtering still works; saving will be retried when more blocks are found or you leave the page.'); }; try { Promise.resolve(GM_setValue(CACHE_KEY, JSON.stringify({ version: 1, entries }))).then(() => { clearIssue('cache-save'); clearIssue('cache'); }, failed); } catch (e) { failed(); } } /*** OBSERVE + ROUTE WATCH ***/ function enqueuePost(post) { if (!(post === null || post === void 0 ? void 0 : post.isConnected)) return; pendingPosts.add(post); if (!queueTimer && !flushing) queueTimer = setTimeout(flushPosts, 0); } function flushPosts() { return __awaiter(this, void 0, void 0, function* () { queueTimer = null; if (flushing) return; flushing = true; const start = performance.now(); try { for (const post of pendingPosts) { pendingPosts.delete(post); try { yield processPost(post); } catch (e) { reportIssue('processing', 'A post could not be checked. Reload Bluesky if filtering does not resume.'); } if (performance.now() - start > 8) break; } } finally { flushing = false; if (pendingPosts.size && !queueTimer) queueTimer = setTimeout(flushPosts, 0); } }); } function collectPosts(node) { if ((node === null || node === void 0 ? void 0 : node.nodeType) !== 1 || node.closest('[data-bcm-ui]')) return; const parent = getPostContainer(node); if (parent) enqueuePost(parent); for (const post of node.querySelectorAll(POST_CANDIDATES)) if (isPostContainer(post)) enqueuePost(post); } function rescanPosts() { if (document.body) collectPosts(document.body); } function observePosts() { const observer = new MutationObserver(mutations => { for (const mutation of mutations) { const target = mutation.target.nodeType === 1 ? mutation.target : mutation.target.parentElement; if (!target || target.closest('[data-bcm-ui]')) continue; const post = getPostContainer(target); if (post) enqueuePost(post); for (const node of mutation.addedNodes) collectPosts(node); } }); observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['alt', 'aria-label', 'aria-hidden', 'title', 'src', 'srcset', 'poster', 'href', 'role', 'data-testid', 'width', 'height'] }); document.addEventListener('load', event => { var _a; if (((_a = event.target) === null || _a === void 0 ? void 0 : _a.tagName) === 'IMG') { const post = getPostContainer(event.target); if (post) enqueuePost(post); } }, true); return observer; } /*** ACTIVE ACCOUNT & PUBLIC FOLLOWS ***/ function getActiveAccount(value) { var _a; const account = (_a = value === null || value === void 0 ? void 0 : value.session) === null || _a === void 0 ? void 0 : _a.currentAccount, did = typeof account === 'string' ? account : account === null || account === void 0 ? void 0 : account.did; return typeof did === 'string' && /^did:[a-z]+:[^\s]+$/.test(did) ? did : ''; } function readSiteState() { try { const raw = localStorage.getItem('BSKY_STORAGE'); if (raw !== lastSiteStorage) { lastSiteStorage = raw; siteState = raw ? JSON.parse(raw) : {}; } } catch (e) { siteState = {}; } return siteState; } function followStorageKey() { return 'bcmFollowsV1.' + currentUserDid; } function readFollowCache() { try { const raw = readStored(followStorageKey(), null), value = typeof raw === 'string' ? JSON.parse(raw) : raw; if (!value || value.account !== currentUserDid || !Number.isFinite(value.at) || Date.now() - value.at < 0 || Date.now() - value.at > FOLLOW_AGE) return false; whitelistedUsers = new Set(normalizeTerms(value.users).map(normalizeUsername)); followsUpdatedAt = value.at; return true; } catch (e) { return false; } } function requestFollows(account, cursor) { const url = new URL('https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows'); url.searchParams.set('actor', account); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor); return new Promise((resolve, reject) => GM_xmlhttpRequest({ method: 'GET', url: url.href, anonymous: true, timeout: 15000, headers: { Accept: 'application/json' }, onload(response) { try { if (response.status !== 200) throw new Error(`Server returned ${response.status}.`); const data = JSON.parse(response.responseText); if (!Array.isArray(data.follows)) throw new Error('The server returned an invalid follows list.'); resolve(data); } catch (error) { reject(error); } }, onerror: () => reject(new Error('Network request failed.')), ontimeout: () => reject(new Error('The request timed out.')) })); } function autoWhitelistFollowedAccounts() { return __awaiter(this, void 0, void 0, function* () { if (!autoWhitelistEnabled || !currentUserDid) return; const account = currentUserDid, epoch = ++followEpoch; followState = 'loading'; followsStartedAt = Date.now(); updateDialogStatus(); try { const users = new Set(), cursors = new Set(); let cursor = ''; do { const data = yield requestFollows(account, cursor); if (epoch !== followEpoch || account !== currentUserDid || !autoWhitelistEnabled) return; for (const user of data.follows) for (const id of [user.handle, user.did]) if (typeof id === 'string' && id) users.add(normalizeUsername('@' + id)); cursor = data.cursor; if (cursor && (typeof cursor !== 'string' || cursors.has(cursor) || cursors.size >= 1000)) throw new Error('The server repeated or exceeded its follows pages.'); if (cursor) cursors.add(cursor); } while (cursor); whitelistedUsers = users; followReady = true; followState = 'ready'; followsUpdatedAt = Date.now(); clearIssue('follows'); try { yield GM_setValue(followStorageKey(), JSON.stringify({ account, at: followsUpdatedAt, users: [...users] })); } catch (e) { reportIssue('follow-save', 'Followed accounts were loaded, but their local snapshot could not be saved.'); } yield rebuildContext(); } catch (error) { if (epoch !== followEpoch || account !== currentUserDid) return; followState = 'error'; reportIssue('follows', `${followReady ? 'Using the last loaded followed accounts.' : 'Filtering is paused to protect followed accounts.'} Could not refresh follows. ${error.message} Use Retry in settings.`); if (!followReady) rescanPosts(); } updateDialogStatus(); scheduleMenu(); }); } function refreshEnvironment() { return __awaiter(this, arguments, void 0, function* (force = false) { const state = readSiteState(), account = getActiveAccount(state), changed = account !== currentUserDid || followEnabled !== autoWhitelistEnabled || followAccount !== account; if (changed) { currentUserDid = account; followAccount = account; followEnabled = autoWhitelistEnabled; followEpoch++; whitelistedUsers = new Set(); followsUpdatedAt = 0; clearIssue('follows'); followReady = !autoWhitelistEnabled; followState = autoWhitelistEnabled ? (account ? 'loading' : 'waiting') : 'disabled'; if (autoWhitelistEnabled && account) followReady = readFollowCache(); } if (changed || force) yield rebuildContext(); if (changed && autoWhitelistEnabled && account) void autoWhitelistFollowedAccounts(); refreshAIEnvironment(); const path = location.pathname; if (path !== lastPath) { lastPath = path; processedPosts = new WeakMap(); rescanPosts(); } if (configDialog) configDialog.dataset.bcmTheme = theme(); updateDialogStatus(); updateAIStatus(); }); } function theme() { const mode = readSiteState().colorMode; return mode === 'dark' || (mode !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; } /*** MENU UI ***/ function scheduleMenu() { if (!menuTimer) menuTimer = setTimeout(updateMenuCommand, 100); } function updateMenuCommand() { var _a; clearTimeout(menuTimer); menuTimer = null; if (menuCommandId !== null) GM_unregisterMenuCommand(menuCommandId); menuCommandId = GM_registerMenuCommand(`Configure Filters (${blockedPosts.size} blocked/hidden; ${aiMatches.size} AI matches; ${aiChecked.size} AI checked${((_a = settings.ai) === null || _a === void 0 ? void 0 : _a.enabled) && Date.now() < aiCooldown ? "; AI waiting" : ""}${issues.size ? '; needs attention' : ''})`, showConfigUI); if (configDialog === null || configDialog === void 0 ? void 0 : configDialog.open) { updateHistory(); updateDialogStatus(); } let notice = document.querySelector('.bcm-notice'); if (issues.size && document.body) { if (!notice) { notice = document.createElement('button'); notice.className = 'bcm-notice'; notice.dataset.bcmUi = ''; notice.addEventListener('click', showConfigUI); document.body.append(notice); } notice.textContent = 'Content Manager needs attention. Open settings for details.'; } else notice === null || notice === void 0 ? void 0 : notice.remove(); } function element(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } let quickMenu = null, quickAnchor = null, quickFeedback = null; function selectedPostKeyword(post, target, selection) { if (!selection || selection.isCollapsed || selection.rangeCount !== 1) return ''; const range = selection.getRangeAt(0); const surfaceAt = node => { const parent = node.nodeType === 1 ? node : node.parentElement; return postTextSurface(parent, post); }; const surface = surfaceAt(range.startContainer); if (!surface || !post.contains(surface) || !surface.contains(target) || surface !== surfaceAt(range.endContainer)) return ''; const keyword = selection.toString().trim().replace(/\s+/g, ' ').toLowerCase(); return keyword.length <= 10000 ? keyword : ''; } function quickTargets(authorIds, keyword) { const targets = []; if (keyword) targets.push({ terms: [keyword], label: '“' + (keyword.length > 80 ? keyword.slice(0, 79) + '…' : keyword) + '”', block: 'filteredTerms', allow: 'manualWhitelistTerms' }); const accounts = Array.from(new Set(authorIds.map(id => blueskyAccount(id)).filter(Boolean))); if (accounts.length) { const did = accounts.find(id => id.startsWith('@did:')), handle = accounts.find(id => !id.startsWith('@did:')); if (did) accounts.unshift(accounts.splice(accounts.indexOf(did), 1)[0]); targets.push({ terms: accounts, label: handle || did.slice(1), block: 'blockedAccounts', allow: 'allowedAccounts' }); } return targets; } function closeQuickMenu(restoreFocus) { if (!quickMenu) return; quickMenu.remove(); if (restoreFocus && quickAnchor && quickAnchor.isConnected) quickAnchor.focus({ preventScroll: true }); quickMenu = quickAnchor = null; } function showQuickFeedback(message, undo) { const parent = configDialog && configDialog.open ? configDialog : document.body; if (!quickFeedback || quickFeedback.parentNode !== parent) { quickFeedback = element('div', 'bcm-quick-feedback'); quickFeedback.dataset.bcmUi = ''; parent.append(quickFeedback); } const container = quickFeedback, entry = element('div'), status = element('span', '', message); status.setAttribute('role', 'status'); entry.append(status); function expire() { entry.remove(); if (!container.childElementCount) { container.remove(); if (quickFeedback === container) quickFeedback = null; } } let timer; const schedule = () => { clearTimeout(timer); timer = setTimeout(expire, 12000); }; const pause = () => clearTimeout(timer); for (const event of ['mouseenter', 'focusin']) entry.addEventListener(event, pause); for (const event of ['mouseleave', 'focusout']) entry.addEventListener(event, schedule); if (undo) { const button = element('button', '', 'Undo'); button.type = 'button'; button.addEventListener('click', () => { button.disabled = true; undo(); expire(); }); entry.append(button); } container.append(entry); schedule(); } function openQuickMenu(targets, x, y, anchor) { closeQuickMenu(false); const menu = quickMenu = element('div', 'bcm-quick-menu'); quickAnchor = anchor; menu.dataset.bcmUi = ''; menu.setAttribute('role', 'group'); menu.setAttribute('aria-label', 'Bluesky filters'); function addAction(label, handle) { const button = element('button', '', label); button.type = 'button'; button.addEventListener('click', () => { closeQuickMenu(false); handle(); }); menu.append(button); } for (const item of targets) { for (const list of [item.block, item.allow]) { const allow = list === item.allow, values = getFilterList(settings, list); const present = item.terms.some(term => values.includes(term)); const label = present ? (allow ? 'Remove from allowlist: ' : 'Unblock ') : (allow ? 'Allow ' : 'Block '); addAction(label + item.label, () => { persistFilterChange(list, item.terms, !present).then(id => { if (!id) { showQuickFeedback('This filter already changed. Open the menu again.'); return; } const message = present ? (allow ? 'Removed from allowlist: ' : 'Unblocked ') : (allow ? 'Allowed ' : 'Blocked '); void reloadSavedFilters(); showQuickFeedback(message + item.label, () => { persistFilterChange(list, [], false, id).then(result => { if (result) void reloadSavedFilters(); showQuickFeedback(result ? 'Undid filter change.' : 'This filter changed again or the Undo expired.'); }).catch(error => showQuickFeedback(error.message)); }); }).catch(error => showQuickFeedback(error.message)); }); } } if (!configDialog) addAction('Manage filters', showConfigUI); (configDialog && configDialog.open ? configDialog : document.body).append(menu); const rect = menu.getBoundingClientRect(); menu.style.left = Math.max(8, Math.min(x, innerWidth - rect.width - 8)) + 'px'; menu.style.top = Math.max(8, Math.min(y, innerHeight - rect.height - 8)) + 'px'; menu.firstElementChild.focus({ preventScroll: true }); } function postQuickTargets(target, link) { const post = getPostContainer(target); if (!post || !shouldProcessPage()) return []; const identity = getPostIdentity(post); if (!identity && !post.matches(KNOWN_POST_SELECTOR)) return []; const keyword = selectedPostKeyword(post, target, window.getSelection()); if (link && !keyword) { const clicked = ownElement(link, post) && parsePostURL(link.getAttribute('href')); if (!clicked || !identity || clicked.url !== identity.url) return []; } return quickTargets(getAuthorIdentities(post, identity), keyword); } function setupQuickFilters() { const controls = 'button,input,textarea,select,video,audio,[role="textbox"],[data-bcm-ui]'; document.addEventListener('contextmenu', event => { closeQuickMenu(false); const path = typeof event.composedPath === 'function' ? event.composedPath() : [event.target]; const target = path.find(node => node.nodeType === 1 && node.isConnected); if (!target || event.defaultPrevented || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return; const image = target.matches('img') && !target.closest('[data-testid="avatar"]'); if (path.some(node => node.nodeType === 1 && (node.isContentEditable || node.matches(controls + ',' + QUOTE_SELECTOR) || (!image && node.matches('[role="button"]:not(a[href])'))))) return; const link = path.find(node => node.nodeType === 1 && node.matches('a[href]')); const targets = postQuickTargets(target, image ? null : link); if (!targets.length) return; event.preventDefault(); event.stopPropagation(); const rect = target.getBoundingClientRect(); openQuickMenu(targets, event.clientX || rect.left, event.clientY || rect.bottom, document.activeElement); }, true); document.addEventListener('pointerdown', event => { if (quickMenu && !quickMenu.contains(event.target)) closeQuickMenu(false); }); document.addEventListener('keydown', event => { if (event.key === 'Escape' && quickMenu) { event.preventDefault(); event.stopPropagation(); closeQuickMenu(true); return; } if (event.defaultPrevented || event.key !== 'F10' || !event.altKey || !event.shiftKey || event.ctrlKey || event.metaKey) return; const target = event.target; if (!target || target.nodeType !== 1 || target.isContentEditable || target.closest(controls + ',' + QUOTE_SELECTOR)) return; const targets = postQuickTargets(target); if (!targets.length) return; event.preventDefault(); const rect = target.getBoundingClientRect(); openQuickMenu(targets, rect.left, rect.bottom, target); }, true); window.addEventListener('scroll', event => { if (quickMenu && event.target !== quickMenu) closeQuickMenu(false); }, { capture: true, passive: true }); window.addEventListener('resize', () => closeQuickMenu(false)); } function showConfigUI() { return __awaiter(this, void 0, void 0, function* () { if (configDialog) { if (configDialog.open) configDialog.focus(); else configDialog.addEventListener('close', showConfigUI, { once: true }); return; } yield refreshEnvironment(); createConfigUI(); }); } function allowlistText(value) { return value.manualWhitelistTerms.map(term => /^(?:[@\\]|did:)/.test(term) ? '\\' + term : term) .concat(value.blueskyFilters.allowedAccounts.map(term => term.startsWith('@did:') ? term.slice(1) : term)); } function readAllowlist(text) { const result = { manualWhitelistTerms: [], allowedAccounts: [] }; for (const line of text.split('\n')) { const term = line.trim().toLowerCase(); if (term.startsWith('\\')) result.manualWhitelistTerms.push(term.slice(1)); else if (term.startsWith('@') || term.startsWith('did:')) result.allowedAccounts.push(term); else result.manualWhitelistTerms.push(term); } return result; } function createConfigUI() { if (configDialog) return; const opener = document.activeElement, dialog = document.createElement('dialog'); configDialog = dialog; dialog.className = 'bcm-dialog'; dialog.dataset.bcmUi = ''; dialog.dataset.bcmTheme = theme(); dialog.setAttribute('aria-labelledby', 'bcm-title'); // Only static markup belongs here. Post content and saved values use textContent/value. dialog.innerHTML = `
Bluesky Content Manager
One keyword or phrase per line.
One handle or DID per line.
Use @handle or a DID for accounts. Plain text allows keywords. Use a backslash before a literal @ or did: prefix.
Try sample text against your draft lists. Account and image rules apply to real posts.
Preview labels the post. Hide removes it from the feed.
History clears when the page reloads.
Exports include draft lists and AI preferences and exclude your API key. Review imports before saving.
AI sends eligible feed content and your topics to OpenRouter and its providers. Only enable it for feeds you are comfortable sharing.
One topic per line.
Use Save API key to update the connection separately from your other settings.
Sends eligible pictures to the image provider and can add cost.
Limits reset at midnight UTC. Set a spending limit in OpenRouter for a firm cap.