/** * @name Hide Blue Checks * @description Collapses posts from X Premium ("blue check") accounts into a one-line stub you can click to show, locally (nothing is muted on X itself). Optionally also hides legacy gold/grey org-verified accounts. Never hides you, accounts you follow (default on; with that on, a post whose follow status is not known yet stays visible), or handles on your allowlist, and hides nothing until your own account is known. Posts are checked again when X's responses bring the missing details. Alt+Shift+H edits settings. * @version 1.2.1 * @author wayBetter * @permissions storage, ui, account, users, commands, network:none */ // i18n:begin // This plugin's English words. Translations live in the app's catalogs (plugin..*, // docs/TRANSLATING.md); a plugin of your own can also pass { en: {...}, tr: {...} } to register. const MESSAGES = { "en": { "settingsCommand": "Hide Blue Checks settings…", "stub": "Post hidden by Hide Blue Checks. Click to show.", "title": "Hide Blue Checks", "message": "Choose what to collapse. Nothing is muted on X itself: hidden posts leave a one-line stub you can click.", "hideBlue": "Hide X Premium (blue check) posts", "hideBusiness": "Also hide Business-verified accounts", "hideBusinessHint": "Gold checks: companies and organizations.", "hideGovernment": "Also hide Government-verified accounts", "hideGovernmentHint": "Grey checks.", "keepFollowed": "Never hide accounts you follow", "keepFollowedHint": "A post stays visible until X says whether you follow its author.", "hideQuotes": "Also check a post's quoted author", "allowlist": "Always show (one @handle per line)", "cancel": "Cancel", "save": "Save", "saveFailed": "Could not save (storage unavailable).", "saved": "Hide Blue Checks: settings saved" } }; // i18n:end /** The words in the app's language (api.i18n); English before start() and outside the app. */ let i18n = null; const t = (key, params = {}) => (i18n ? i18n.t(key, params) : String(MESSAGES.en[key] ?? key) .replace(/\{(\w+), plural, one \{([^{}]*)\} other \{([^{}]*)\}\}/g, (m, name, one, other) => (Number(params[name]) === 1 ? one : other).replace('#', String(params[name]))) .replace(/\{(\w+)\}/g, (m, name) => (name in params ? String(params[name]) : m))); module.exports = class HideBlueChecks { static DEFAULTS = { hideBlue: true, hideBusiness: false, hideGovernment: false, keepFollowed: true, hideQuotes: false, allowlist: [] }; /** * Pure decision, no DOM: whether a post by `ctx` should be hidden under `settings`. `ctx.blue` / * `ctx.verifiedType` / `ctx.following` are `null` when not yet observed this session — unknown * never hides, since X's responses report absence, not a guaranteed "no". `ctx.viewer` is the * signed-in handle; while it is unknown (null) nothing is hidden, since the post could be yours. */ static shouldHide(settings, ctx) { if (!ctx.viewer) return false; const handle = String(ctx.handle || '').toLowerCase(); if (String(ctx.viewer).toLowerCase() === handle) return false; if ((settings.allowlist || []).some(h => String(h).toLowerCase() === handle)) return false; // With "never hide accounts you follow" on, only a known "not following" can hide. if (settings.keepFollowed && ctx.following !== false) return false; const isBlue = ctx.blue === true; const isBusiness = ctx.verifiedType === 'Business'; const isGovernment = ctx.verifiedType === 'Government'; return !!((settings.hideBlue && isBlue) || (settings.hideBusiness && isBusiness) || (settings.hideGovernment && isGovernment)); } /** Whether a later response could change the decision for `user` (null = never seen). */ static isUnknown(settings, user) { if (!user) return true; return user.blue === null || user.verifiedType === null || (!!settings.keepFollowed && user.following === null); } /** Parses the allowlist editor text (one @handle per line) into lowercase handles. */ static parseAllowlist(source) { return [...new Set(String(source || '').split('\n') .map(line => line.trim().replace(/^@/, '').toLowerCase()) .filter(line => /^[a-z0-9_]{1,15}$/.test(line)))]; } start() { const api = window.wayBetter.api; this.scope = api.scope(); i18n = api.i18n || null; if (i18n) i18n.register(MESSAGES); if (i18n) this.scope.defer(i18n.onChange(() => { for (const stub of document.querySelectorAll('.bt-hbc-stub')) stub.textContent = t('stub'); })); this.api = api; this.store = api.storage('hide-blue-checks'); this.load(); this.scope.css('hide-blue-checks', ` article[data-bt-hidden-check] { display: none !important; } .bt-hbc-stub { padding: 10px 16px; font: 400 13px/1.3 var(--wb-font, system-ui, sans-serif); color: var(--wb-muted, inherit); opacity: .75; cursor: pointer; border-bottom: 1px solid var(--wb-border, rgba(127,127,127,.2)); } .bt-hbc-stub:hover { opacity: 1; } `); // Posts decided while their author (or the viewer) was unknown, re-checked when X's responses // bring that user: article -> lowercase handles it depends on ([] = any update). this.pending = new Map(); this.dirty = new Set(); this.recheckFrame = 0; // Stub id -> { article, stub }; stubs carry `data-bt-stub-for`, their post `data-bt-hbc-id`. this.stubs = new Map(); this.stubSeq = 0; this.scope.observe('article[data-testid="tweet"]', article => this.check(article)); if (typeof api.users.onUpdate === 'function') { this.scope.defer(api.users.onUpdate(handles => this.onUsersUpdate(handles))); } this.scope.defer(() => { if (this.recheckFrame) cancelAnimationFrame(this.recheckFrame); this.recheckFrame = 0; }); this.scope.on(document, 'keydown', event => { if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyH') { if (api.isTypingTarget(document.activeElement)) return; event.preventDefault(); event.stopPropagation(); this.openEditor(); } }, true); // Ctrl+K: the settings dialog (runs here, in this tab). if (this.scope.commands) { this.scope.commands.register({ id: 'settings', title: t('settingsCommand'), keywords: 'verified blue check premium allowlist hide alt shift h', run: () => this.openEditor() }); } } stop() { this.scope.dispose(); this.unhideAll(); } load() { const saved = this.store.get('settings', {}); this.settings = { ...HideBlueChecks.DEFAULTS, ...saved, allowlist: Array.isArray(saved.allowlist) ? saved.allowlist : HideBlueChecks.DEFAULTS.allowlist, }; } unhideAll() { for (const stub of document.querySelectorAll('.bt-hbc-stub')) stub.remove(); this.stubs?.clear(); this.pending?.clear(); for (const article of document.querySelectorAll('article[data-bt-hidden-check], article[data-bt-hidden-check-shown]')) { article.removeAttribute('data-bt-hidden-check'); article.removeAttribute('data-bt-hidden-check-shown'); } } /** The handle whose quote card is embedded in this post, or null when there is none. */ quotedHandle(article) { const link = article.querySelector('div[role="link"] a[href^="/"][href*="/status/"]'); const match = /^\/([A-Za-z0-9_]{1,15})\/status\//.exec(link?.getAttribute('href') || ''); return match ? match[1] : null; } /** `{ hit, unknown }` for a post by `handle`; `unknown`: a later response may change the answer. */ decide(handle, viewer) { if (!handle) return { hit: false, unknown: false }; const user = this.api.users.get(handle); const hit = HideBlueChecks.shouldHide(this.settings, { handle, blue: user?.blue ?? null, verifiedType: user?.verifiedType ?? null, following: user?.following ?? null, viewer, }); return { hit, unknown: !hit && (!viewer || HideBlueChecks.isUnknown(this.settings, user)) }; } onUsersUpdate(handles) { if (!this.pending.size) return; for (const handle of handles) this.dirty.add(handle); // Coalesced: one re-check per animation frame, however many responses arrive. if (!this.recheckFrame) this.recheckFrame = requestAnimationFrame(() => this.recheck()); } recheck() { this.recheckFrame = 0; const dirty = this.dirty; this.dirty = new Set(); for (const [article, handles] of [...this.pending]) { if (!article.isConnected) { this.pending.delete(article); continue; } if (handles.length && !handles.some(handle => dirty.has(handle))) continue; this.pending.delete(article); this.check(article); } } /** Removes stubs whose post is no longer on the page (X recycles timeline cells). */ sweepStubs() { for (const [id, entry] of this.stubs) { if (entry.article.isConnected && entry.stub.isConnected) continue; entry.stub.remove(); this.stubs.delete(id); } } check(article) { this.sweepStubs(); if (article.hasAttribute('data-bt-hidden-check-shown')) return; const id = article.getAttribute('data-bt-hbc-id'); if (id && this.stubs.has(id) && article.hasAttribute('data-bt-hidden-check')) return; const tweet = this.api.tweet(article); if (!tweet || !article.parentNode) return; const account = this.api.account(); const viewer = account ? account.handle : null; const quoted = this.settings.hideQuotes ? this.quotedHandle(article) : null; const own = this.decide(tweet.handle, viewer); const quote = quoted ? this.decide(quoted, viewer) : { hit: false, unknown: false }; if (!own.hit && !quote.hit) { article.removeAttribute('data-bt-hidden-check'); if (own.unknown || quote.unknown) { this.pending.set(article, viewer ? [tweet.handle, quoted].filter(Boolean).map(h => h.toLowerCase()) : []); } return; } this.pending.delete(article); article.setAttribute('data-bt-hidden-check', ''); const stubId = id || String(++this.stubSeq); article.setAttribute('data-bt-hbc-id', stubId); const stub = document.createElement('div'); stub.className = 'bt-hbc-stub'; stub.setAttribute('data-bt-stub-for', stubId); stub.textContent = t('stub'); stub.addEventListener('click', event => { event.stopPropagation(); article.removeAttribute('data-bt-hidden-check'); article.setAttribute('data-bt-hidden-check-shown', ''); stub.remove(); this.stubs.delete(stubId); }); article.before(stub); this.stubs.set(stubId, { article, stub }); } /** The settings dialog, a themed dialog from the UI kit (closed by `scope.dispose()` on stop). */ openEditor() { if (this.editor?.open) return; const ui = this.scope.ui; const h = ui.h; const checks = {}; const check = (key, label, hint) => { checks[key] = h('input', { type: 'checkbox', checked: !!this.settings[key] }); return h('label', { class: 'wb-check' }, checks[key], h('span', null, label, hint ? h('small', null, hint) : null)); }; const area = h('textarea', { class: 'wb-mono', rows: 4, spellcheck: false, placeholder: '@handle' }); area.value = (this.settings.allowlist || []).map(handle => `@${handle}`).join('\n'); this.editor = ui.dialog({ title: t('title'), message: t('message'), content: body => body.append( check('hideBlue', t('hideBlue')), check('hideBusiness', t('hideBusiness'), t('hideBusinessHint')), check('hideGovernment', t('hideGovernment'), t('hideGovernmentHint')), check('keepFollowed', t('keepFollowed'), t('keepFollowedHint')), check('hideQuotes', t('hideQuotes')), h('div', { class: 'wb-divider' }), h('label', { class: 'wb-field' }, h('span', { class: 'wb-label' }, t('allowlist')), area), ), actions: [ { label: t('cancel') }, { label: t('save'), tone: 'primary', default: true, onClick: handle => { const settings = { hideBlue: checks.hideBlue.checked, hideBusiness: checks.hideBusiness.checked, hideGovernment: checks.hideGovernment.checked, keepFollowed: checks.keepFollowed.checked, hideQuotes: checks.hideQuotes.checked, allowlist: HideBlueChecks.parseAllowlist(area.value), }; if (!this.store.set('settings', settings)) { handle.setError(t('saveFailed')); return false; } this.load(); this.unhideAll(); for (const article of document.querySelectorAll('article[data-testid="tweet"]')) this.check(article); ui.toast(t('saved'), { tone: 'success' }); }, }, ], }); } };