// ==UserScript== // @name Twitter Viewer + // @namespace https://github.com/ceeprus // @version 1.30 // @description Adds a themed, icon-labelled panel to X/Twitter profiles that hides pinned posts, replies, quote retweets, retweets, plain posts, media posts and text-only posts (each with a live counter), plus compact-media, hide-post-text, hide-media and hide-engagement-bar toggles, a "show only checked" invert mode, a fast-retweet toggle (skips the Quote menu) and a toggle to hide "Post Video" from the video right-click menu. Settings persist, and the panel can be minimised. Matches your X theme and accent colour. // @author Cee // @icon https://www.google.com/s2/favicons?sz=64&domain=x.com // @match https://x.com/* // @match https://twitter.com/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @license MIT // @downloadURL https://raw.githubusercontent.com/ceeprus/userscript/main/twitter/viewerplus.user.js // @updateURL https://raw.githubusercontent.com/ceeprus/userscript/main/twitter/viewerplus.user.js // ==/UserScript== /* * Panel above the "You might like" card on X/Twitter profiles: whole-tweet filters (with counters), * per-tweet modifiers, persistent settings, minimise and reset. Re-applies on every timeline change * since X recycles tweet nodes. Relies on some hashed X classes and English UI strings. */ (function () { 'use strict'; const BOX_ID = 'cee-vp-box'; const BODY_ID = 'cee-vp-body'; const TITLE_ID = 'cee-vp-title'; const TITLE_TEXT = 'Twitter Viewer +'; const STORE_KEY = 'cee-vp-state'; // X sets its font per-element, not on a parent, so apply Chirp explicitly (else serif). const FONT_STACK = 'TwitterChirp, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; // Leaf media nodes, for detection and the media modifiers. const MEDIA_SEL = '[data-testid="tweetPhoto"], [data-testid="videoComponent"], ' + '[data-testid="card.wrapper"], [data-testid="card.layoutLarge.media"]'; const COMPACT_MEDIA_MAX = '120px'; // Whole-tweet filters (shown with a live hidden/total counter). const FILTERS = [ { key: 'pinned', label: 'Hide Pinned post' }, { key: 'replies', label: 'Hide replies' }, { key: 'quotes', label: 'Hide quote retweets' }, { key: 'retweets', label: 'Hide retweets' }, { key: 'posts', label: 'Hide just posts' }, { key: 'mediaposts', label: 'Hide media posts' }, { key: 'textonly', label: 'Hide text-only posts' }, ]; // Per-tweet modifiers (act on parts of each tweet, not whole-tweet hiding). const MODIFIERS = [ { key: 'compact', label: 'Compact posts' }, { key: 'hideText', label: 'Hide post text' }, { key: 'hideMedia', label: 'Hide media' }, { key: 'hideEngagement', label: 'Hide engagement bar' }, ]; // Row icons (24x24 paths). X's own glyphs where it has them. const ICONS = { pinned: 'M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z', replies: 'M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z', quotes: 'M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z', retweets: 'M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z', posts: 'M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM14 17H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z', mediaposts: 'M3 5.5C3 4.119 4.119 3 5.5 3h13C19.881 3 21 4.119 21 5.5v13c0 1.381-1.119 2.5-2.5 2.5h-13C4.119 21 3 19.881 3 18.5v-13zM5.5 5c-.276 0-.5.224-.5.5v9.086l3-3 3 3 5-5 3 3V5.5c0-.276-.224-.5-.5-.5h-13zM19 15.414l-3-3-5 5-3-3-3 3V18.5c0 .276.224.5.5.5h13c.276 0 .5-.224.5-.5v-3.086zM9.75 7C8.784 7 8 7.784 8 8.75s.784 1.75 1.75 1.75 1.75-.784 1.75-1.75S10.716 7 9.75 7z', textonly: 'M14 17H4v2h10v-2zm6-8H4v2h16V9zM4 15h16v-2H4v2zM4 5v2h16V5H4z', compact: 'M7.41 18.59L8.83 20 12 16.83 15.17 20l1.41-1.41L12 14l-4.59 4.59zm9.18-13.18L15.17 4 12 7.17 8.83 4 7.41 5.41 12 10l4.59-4.59z', hideText: 'M14 17H4v2h10v-2zm6-8H4v2h16V9zM4 15h16v-2H4v2zM4 5v2h16V5H4z', hideMedia: 'M3 5.5C3 4.119 4.119 3 5.5 3h13C19.881 3 21 4.119 21 5.5v13c0 1.381-1.119 2.5-2.5 2.5h-13C4.119 21 3 19.881 3 18.5v-13zM5.5 5c-.276 0-.5.224-.5.5v9.086l3-3 3 3 5-5 3 3V5.5c0-.276-.224-.5-.5-.5h-13zM19 15.414l-3-3-5 5-3-3-3 3V18.5c0 .276.224.5.5.5h13c.276 0 .5-.224.5-.5v-3.086zM9.75 7C8.784 7 8 7.784 8 8.75s.784 1.75 1.75 1.75 1.75-.784 1.75-1.75S10.716 7 9.75 7z', hideEngagement: 'M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z', invert: 'M16 17.01V10h-2v7.01h-3L15 21l4-3.99h-3zM9 3L5 6.99h3V14h2V6.99h3L9 3z', fastRetweet: 'M7 2v11h3v9l7-12h-4l4-8z', hidePostVideo: 'M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z', }; // Toggle state + cumulative id sets so counters survive node recycling. The sets are capped so // a marathon scroll can't grow them without bound (oldest ids drop once over the limit). const ID_CAP = 4000; const state = {}; const seen = {}; // ids ever classified into a filter const hiddenIds = {}; // ids actually hidden while a filter was on FILTERS.forEach(f => { state[f.key] = false; seen[f.key] = new Set(); hiddenIds[f.key] = new Set(); }); MODIFIERS.forEach(m => { state[m.key] = false; }); state.invert = false; // when on, filters become "show only checked" instead of "hide checked" state.fastRetweet = false; // when on, the repost button confirms instantly, skipping the Quote menu state.hidePostVideo = false; // when on, removes "Post Video" from the video right-click menu // Add to a Set, evicting the oldest entry if it would exceed ID_CAP (insertion order = age). function addCapped(set, id) { if (set.has(id)) return; if (set.size >= ID_CAP) set.delete(set.values().next().value); set.add(id); } let collapsed = false; // panel minimised const groupCollapsed = { filters: false, display: false, tools: false }; // each section's minimise state let activeProfile = null; // handle of the profile the counters currently belong to let textWasActive = false; // whether hide-post-text ran last pass (so we can undo "Show more") let filtersWereActive = false; // ran the tweet-hiding pass last time (so we can restore once) let modsWereActive = false; // ran the modifier pass last time (so we can restore once) /* -------------------------------- persistence -------------------------------- */ function loadState() { let saved = null; try { if (typeof GM_getValue === 'function') saved = GM_getValue(STORE_KEY, null); } catch (e) {} if (!saved || typeof saved !== 'object') return; if (saved.toggles) Object.keys(state).forEach(k => { if (typeof saved.toggles[k] === 'boolean') state[k] = saved.toggles[k]; }); if (typeof saved.collapsed === 'boolean') collapsed = saved.collapsed; if (saved.groups && typeof saved.groups === 'object') Object.keys(groupCollapsed).forEach(k => { if (typeof saved.groups[k] === 'boolean') groupCollapsed[k] = saved.groups[k]; }); } function saveState() { try { if (typeof GM_setValue !== 'function') return; const toggles = {}; Object.keys(state).forEach(k => { toggles[k] = state[k]; }); GM_setValue(STORE_KEY, { toggles, collapsed, groups: groupCollapsed }); } catch (e) {} } /* ------------------------------- locate the sidebar ------------------------------- */ function findSection() { const aside = document.querySelector('aside[aria-label="Who to follow"]'); if (aside) return aside; // Other named sidebar modules — single-post pages lead with "Relevant people". const wanted = ['you might like', 'who to follow', 'relevant people']; const headings = document.querySelectorAll( 'aside[role="complementary"] [role="heading"], [data-testid="sidebarColumn"] [role="heading"]'); for (const h of headings) { if (wanted.includes(h.textContent.trim().toLowerCase())) return h.closest('aside') || h.parentElement; } return null; } function findHeadingTextEl(section) { const heading = section.querySelector('[role="heading"]'); return heading ? (heading.querySelector('span') || heading) : null; } // The rounded sidebar card (first ancestor with a real border-radius). function findCard(aside) { let el = aside; for (let i = 0; i < 6 && el && el !== document.body; i++) { if ((parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0) >= 12) return el; el = el.parentElement; } return aside.parentElement ? (aside.parentElement.parentElement || aside.parentElement) : aside; } /* --------------------------------- build the panel --------------------------------- */ function buildBox(referenceCard, headingTextEl) { const ref = getComputedStyle(referenceCard); const box = document.createElement('div'); box.id = BOX_ID; Object.assign(box.style, { backgroundColor: ref.backgroundColor, border: ref.borderTopWidth + ' solid ' + ref.borderTopColor, borderRadius: ref.borderTopLeftRadius, fontFamily: FONT_STACK, marginBottom: '16px', padding: '12px 16px', boxSizing: 'border-box', }); box.appendChild(buildHeader(headingTextEl)); const body = document.createElement('div'); body.id = BODY_ID; if (collapsed) body.style.display = 'none'; const tools = [ { key: 'invert', label: 'Show only checked' }, { key: 'fastRetweet', label: 'Fast retweet' }, { key: 'hidePostVideo', label: 'Hide Post Video' }, ]; body.appendChild(buildGroup('Filters', 'filters', headingTextEl, FILTERS, true)); body.appendChild(buildSeparator(ref)); body.appendChild(buildGroup('Display', 'display', headingTextEl, MODIFIERS, false)); body.appendChild(buildSeparator(ref)); body.appendChild(buildGroup('Tools', 'tools', headingTextEl, tools, false)); box.appendChild(body); return box; } function buildSeparator(ref) { const sep = document.createElement('div'); Object.assign(sep.style, { borderTop: '1px solid ' + ref.borderTopColor, margin: '12px 0' }); return sep; } // A labelled, individually collapsible section of toggle rows. function buildGroup(title, key, headingTextEl, items, withCounter) { const wrap = document.createElement('div'); const rows = document.createElement('div'); Object.assign(rows.style, { display: 'flex', flexDirection: 'column', gap: '10px' }); if (groupCollapsed[key]) rows.style.display = 'none'; items.forEach(it => rows.appendChild(buildRow(it, headingTextEl, withCounter))); const head = document.createElement('div'); head.setAttribute('role', 'button'); head.tabIndex = 0; Object.assign(head.style, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer', marginBottom: groupCollapsed[key] ? '0' : '10px', userSelect: 'none', }); const label = document.createElement('span'); label.textContent = title; Object.assign(label.style, { fontFamily: FONT_STACK, fontSize: '12px', fontWeight: '700', color: '#71767b', letterSpacing: '.04em', textTransform: 'uppercase', }); const chev = document.createElement('span'); Object.assign(chev.style, { flex: '0 0 auto', display: 'flex', alignItems: 'center', color: '#71767b' }); chev.innerHTML = ''; const svg = chev.firstChild; const applyChev = () => { svg.style.transform = groupCollapsed[key] ? 'rotate(-90deg)' : 'rotate(0deg)'; }; applyChev(); const toggle = (e) => { e.preventDefault(); e.stopPropagation(); groupCollapsed[key] = !groupCollapsed[key]; rows.style.display = groupCollapsed[key] ? 'none' : 'flex'; head.style.marginBottom = groupCollapsed[key] ? '0' : '10px'; applyChev(); saveState(); }; head.addEventListener('click', toggle); head.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') toggle(e); }); head.append(label, chev); wrap.append(head, rows); return wrap; } // Header: title + (reset, minimise) controls on the right. function buildHeader(headingTextEl) { const header = document.createElement('div'); Object.assign(header.style, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: collapsed ? '0' : '12px', }); const title = document.createElement('div'); title.id = TITLE_ID; title.textContent = TITLE_TEXT; Object.assign(title.style, { cursor: 'default', userSelect: 'text', WebkitUserSelect: 'text', minWidth: '0' }); if (headingTextEl) { const h = getComputedStyle(headingTextEl); Object.assign(title.style, { fontFamily: FONT_STACK, fontSize: h.fontSize, fontWeight: h.fontWeight, fontStyle: h.fontStyle, lineHeight: h.lineHeight, letterSpacing: h.letterSpacing, color: h.color, }); } const controls = document.createElement('div'); Object.assign(controls.style, { flex: '0 0 auto', display: 'flex', alignItems: 'center' }); controls.append(buildReset(), buildMinimize(header)); header.append(title, controls); return header; } function buildMinimize(header) { const btn = document.createElement('div'); btn.setAttribute('role', 'button'); btn.setAttribute('aria-label', 'Minimise panel'); btn.tabIndex = 0; Object.assign(btn.style, { flex: '0 0 auto', marginLeft: '8px', padding: '2px', cursor: 'pointer', color: '#71767b', display: 'flex', alignItems: 'center', }); btn.innerHTML = ''; const svg = btn.firstChild; const apply = () => { svg.style.transform = collapsed ? 'rotate(-90deg)' : 'rotate(0deg)'; }; apply(); const toggle = (e) => { e.preventDefault(); e.stopPropagation(); collapsed = !collapsed; apply(); const body = document.getElementById(BODY_ID); if (body) body.style.display = collapsed ? 'none' : ''; header.style.marginBottom = collapsed ? '0' : '12px'; saveState(); }; btn.addEventListener('click', toggle); btn.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') toggle(e); }); return btn; } // 18px row icon, or null if no glyph for the key. function buildIcon(key, color) { if (!ICONS[key]) return null; const span = document.createElement('span'); Object.assign(span.style, { flex: '0 0 auto', display: 'inline-flex', alignItems: 'center' }); span.innerHTML = ''; return span; } // One toggle row: icon + bold label (+ optional counter) on the left, checkbox on the right. function buildRow(item, headingTextEl, withCounter) { const themedColor = headingTextEl ? getComputedStyle(headingTextEl).color : '#e7e9ea'; const row = document.createElement('label'); Object.assign(row.style, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', cursor: 'pointer', }); const left = document.createElement('span'); Object.assign(left.style, { display: 'inline-flex', alignItems: 'center', gap: '8px', minWidth: '0' }); const icon = buildIcon(item.key, themedColor); if (icon) left.append(icon); const text = document.createElement('span'); text.textContent = item.label; Object.assign(text.style, { fontFamily: FONT_STACK, fontSize: '15px', fontWeight: '700', lineHeight: '20px', color: themedColor, userSelect: 'text', WebkitUserSelect: 'text', }); left.append(text); if (withCounter) { const counter = document.createElement('span'); counter.dataset.counter = item.key; counter.textContent = '(0/0)'; Object.assign(counter.style, { fontSize: '13px', color: '#71767b', whiteSpace: 'nowrap' }); left.append(counter); } row.append(left, buildCheckbox(item.key)); return row; } // "Reset all" as a refresh-icon button (sits left of the minimise chevron in the header). function buildReset() { const btn = document.createElement('div'); btn.setAttribute('role', 'button'); btn.setAttribute('aria-label', 'Reset all'); btn.title = 'Reset all'; btn.tabIndex = 0; Object.assign(btn.style, { flex: '0 0 auto', marginLeft: '8px', padding: '2px', cursor: 'pointer', color: '#71767b', display: 'flex', alignItems: 'center', }); btn.innerHTML = ''; btn.addEventListener('mouseenter', () => { btn.style.color = '#e7e9ea'; }); btn.addEventListener('mouseleave', () => { btn.style.color = '#71767b'; }); const run = (e) => { e.preventDefault(); e.stopPropagation(); resetAll(); }; btn.addEventListener('click', run); btn.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') run(e); }); return btn; } function resetAll() { Object.keys(state).forEach(k => { state[k] = false; }); FILTERS.forEach(f => hiddenIds[f.key].clear()); saveState(); const box = document.getElementById(BOX_ID); if (box) box.remove(); ensureBox(); applyFilters(); } // Checkmark colour for an accent (black on light accents, else white). function contrastOn(color) { let r, g, b; const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (rgb) { r = +rgb[1]; g = +rgb[2]; b = +rgb[3]; } else { const h = color.replace('#', '').trim(); if (h.length < 6) return '#fff'; r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16); } return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6 ? '#0f1419' : '#fff'; } // The user's X accent: Control Panel for Twitter's CSS var, else an accent-coloured link. function accentColors() { let bg = ''; try { bg = getComputedStyle(document.body).getPropertyValue('--cpft-theme').trim(); } catch (e) {} if (!bg) { const link = document.querySelector('[data-testid="tweetText"] a[href], a[href*="/hashtag/"]'); if (link) bg = getComputedStyle(link).color || ''; } if (!bg) bg = '#1d9bf0'; return { bg, fg: contrastOn(bg) }; } // X-style square checkbox; checked fill uses the live accent colour. function buildCheckbox(key) { const box = document.createElement('div'); box.setAttribute('role', 'checkbox'); box.tabIndex = 0; Object.assign(box.style, { flex: '0 0 auto', width: '20px', height: '20px', marginLeft: '12px', borderRadius: '4px', boxSizing: 'border-box', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'background-color .15s ease, border-color .15s ease', }); const render = () => { const accent = accentColors(); box.setAttribute('aria-checked', String(state[key])); box.style.border = '2px solid ' + (state[key] ? accent.bg : '#536471'); box.style.backgroundColor = state[key] ? accent.bg : 'transparent'; box.innerHTML = state[key] ? '' : ''; }; render(); const toggle = (e) => { e.preventDefault(); e.stopPropagation(); state[key] = !state[key]; if (!state[key] && hiddenIds[key]) hiddenIds[key].clear(); // filters only; modifiers have none render(); applyFilters(); saveState(); }; box.addEventListener('click', toggle); box.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') toggle(e); }); return box; } /* -------------------------------- classification -------------------------------- */ // Profile handle from the URL, or null when not on a profile feed. function getProfileOwner() { const seg = location.pathname.split('/').filter(Boolean); if (!seg.length) return null; const reserved = new Set(['home', 'explore', 'notifications', 'messages', 'settings', 'i', 'search', 'compose', 'hashtag', 'bookmarks', 'lists', 'jobs', 'communities', 'tos', 'privacy', 'about']); if (reserved.has(seg[0].toLowerCase())) return null; if (seg.includes('status')) return null; // single-tweet page, not a feed return seg[0]; } // Author handle + status id from a tweet's permalink (handles relative and absolute hrefs). function permalinkInfo(article) { const link = article.querySelector('a[href*="/status/"]'); if (!link) return { author: null, id: null }; const m = (link.getAttribute('href') || '').match(/(?:https?:\/\/[^/]+)?\/([^/]+)\/status\/(\d+)/); return m ? { author: m[1].toLowerCase(), id: m[2] } : { author: null, id: null }; } // Classify a cell (may be a multi-tweet conversation) -> { id, cats } or null. function classify(cell) { const articles = Array.from(cell.querySelectorAll('[data-testid="tweet"]')); if (!articles.length) return null; const owner = (activeProfile || '').toLowerCase(); const infos = articles.map(a => { const { author, id } = permalinkInfo(a); const ctx = a.querySelector('[data-testid="socialContext"]'); const s = ctx ? ctx.textContent.toLowerCase() : ''; // The quoted-tweet wrapper, so its nested text/avatars don't leak into this tweet's signals. const quoteEl = a.querySelector('div[role="link"]'); const hasQuote = !!(quoteEl && quoteEl.querySelector('[data-testid="User-Name"]')); // "Replying to" that belongs to THIS tweet — not to a reply shown inside the quoted tweet. let replyingTo = false; for (const d of a.querySelectorAll('div[dir="ltr"]')) { if (hasQuote && quoteEl.contains(d)) continue; if (/^\s*replying to/i.test(d.textContent)) { replyingTo = true; break; } } return { author, id, replyingTo, pinned: /pinned/.test(s), retweet: /repost|retweet/.test(s), quote: hasQuote, // Thread-connector line: anyLine = in a thread; upLine = the reply itself. anyLine: !!a.querySelector('.r-1bnu78o.r-f8sm7e.r-m5arl1'), upLine: !!a.querySelector('.r-1bnu78o.r-f8sm7e.r-m5arl1.r-1p0dtai'), }; }); const last = infos[infos.length - 1]; const multi = articles.length >= 2; const otherAuthor = !!owner && infos.some(x => x.author && x.author !== owner); // Explicit "reposted" context is a definitive retweet, even when the reposted tweet is itself // a reply or quote — the owner's action is the repost, so it outranks reply/quote/thread. const isRetweet = infos.some(x => x.retweet); // Reply: thread line, "Replying to", or in-cell convo with another author. Reposts are excluded // (a repost is the owner's repost, not their reply). const isReply = !isRetweet && (infos.some(x => x.upLine || x.replyingTo) || (multi && otherAuthor)); // A reply's parent context: hidden with replies but not counted. const isThreadPart = !isRetweet && (infos.some(x => x.anyLine) || isReply); const replyContext = isThreadPart && !isReply; const isPinned = infos.some(x => x.pinned); const isQuote = !isRetweet && !isThreadPart && last.quote; const isPost = !isPinned && !isRetweet && !isQuote && !isThreadPart; // Independent of type: has media? const hasMedia = !!cell.querySelector(MEDIA_SEL); let id = null; for (let i = infos.length - 1; i >= 0; i--) { if (infos[i].id) { id = infos[i].id; break; } } return { id, replyContext, cats: { pinned: isPinned, replies: isReply, quotes: isQuote, retweets: isRetweet, posts: isPost, mediaposts: hasMedia, textonly: !hasMedia, }, }; } // A reply-expander cell (conversation link, no tweet). function isReplyExpander(cell) { if (cell.querySelector('[data-testid="tweet"]')) return false; if (cell.querySelector('a[href*="/i/status/"]')) return true; return /show\s+(?:more|additional|this)\b[^]*?(?:repl|thread)/i.test(cell.textContent || ''); } /* ----------------------------- per-tweet modifiers ----------------------------- */ // Media height is reserved by a padding-bottom spacer, so resize that container, not the leaf. function aspectContainer(mediaEl) { let el = mediaEl; for (let i = 0; i < 10 && el && el.parentElement; i++) { const parent = el.parentElement; if (parent.matches && parent.matches('[data-testid="tweet"], article')) break; if (parent.querySelector(':scope > div[style*="padding-bottom"]')) return parent; el = parent; } return null; } function applyTweetMods() { const modsActive = state.compact || state.hideText || state.hideMedia || state.hideEngagement; if (!modsActive && !modsWereActive) return; // nothing to apply, nothing left to restore document.querySelectorAll('[data-testid="tweet"]').forEach(tweet => { // Text tweet.querySelectorAll('[data-testid="tweetText"]').forEach(el => { el.style.display = state.hideText ? 'none' : ''; }); // "Show more" expander sits beside tweetText, so hide/restore it too. if (state.hideText || textWasActive) { tweet.querySelectorAll('span').forEach(sp => { if (sp.textContent.trim() !== 'Show more') return; let node = sp; while (node.parentElement && node.parentElement !== tweet && node.parentElement.textContent.trim() === 'Show more') node = node.parentElement; node.style.display = state.hideText ? 'none' : ''; }); } // Media (photos, videos, link-card embeds) tweet.querySelectorAll(MEDIA_SEL).forEach(el => { const target = aspectContainer(el) || el; if (target !== el) { el.style.maxHeight = ''; el.style.overflow = ''; } // size the container if (state.hideMedia) { target.style.display = 'none'; target.style.maxHeight = ''; target.style.overflow = ''; } else if (state.compact) { target.style.display = ''; target.style.maxHeight = COMPACT_MEDIA_MAX; target.style.overflow = 'hidden'; } else { target.style.display = ''; target.style.maxHeight = ''; target.style.overflow = ''; } }); // Engagement bar (the reply / repost / like / views action row). tweet.querySelectorAll('[data-testid="reply"]').forEach(btn => { const bar = btn.closest('[role="group"]'); if (bar) bar.style.display = state.hideEngagement ? 'none' : ''; }); }); textWasActive = state.hideText; modsWereActive = modsActive; } /* --------------------------------- apply / counters --------------------------------- */ function applyFilters() { // Reset cumulative counts when switching to a different profile. const owner = getProfileOwner(); if (owner !== activeProfile) { activeProfile = owner; FILTERS.forEach(f => { seen[f.key].clear(); hiddenIds[f.key].clear(); }); } // Cheap and rare: strip "Post Video" from the video right-click menu. document.querySelectorAll('[data-testid="postVideoContextMenuItem"]').forEach(el => { el.style.display = state.hidePostVideo ? 'none' : ''; }); const box = document.getElementById(BOX_ID); const anyFilterOn = !!box && FILTERS.some(f => state[f.key]); // Only scan/classify cells while filtering is engaged, plus one pass to restore when it stops. if (anyFilterOn || filtersWereActive) { document.querySelectorAll('[data-testid="cellInnerDiv"]').forEach(cell => { const info = classify(cell); if (!info) { // Non-tweet cell: "Show more replies" expanders track the replies filter. let hideCell = false; if (isReplyExpander(cell)) { hideCell = state.invert ? (anyFilterOn && !state.replies) : state.replies; } cell.style.display = hideCell ? 'none' : ''; return; } let matched = false; FILTERS.forEach(f => { if (!info.cats[f.key]) return; if (info.id) addCapped(seen[f.key], info.id); if (state[f.key]) { matched = true; if (info.id) addCapped(hiddenIds[f.key], info.id); } }); // A reply's parent context counts with the replies filter. if (info.replyContext && state.replies) matched = true; const hide = state.invert ? (anyFilterOn && !matched) : matched; cell.style.display = hide ? 'none' : ''; }); if (box) { FILTERS.forEach(f => { const el = box.querySelector('[data-counter="' + f.key + '"]'); if (!el) return; el.textContent = '(' + (state[f.key] ? hiddenIds[f.key].size : 0) + '/' + seen[f.key].size + ')'; }); } filtersWereActive = anyFilterOn; } applyTweetMods(); } /* ------------------------------------ wiring ------------------------------------ */ function ensureBox() { if (document.getElementById(BOX_ID)) return; const section = findSection(); if (!section) return; const card = findCard(section); if (!card || !card.parentNode) return; card.parentNode.insertBefore(buildBox(card, findHeadingTextEl(section)), card); } // Fast retweet: after the repost button is clicked, auto-confirm the repost when X's menu opens, // skipping the Repost/Quote chooser. Observes only for a short window after each click. function setupFastRetweet() { let timer = null; const obs = new MutationObserver(() => { const confirm = document.querySelector('[data-testid="retweetConfirm"], [data-testid="unretweetConfirm"]'); if (confirm) { stop(); confirm.click(); } }); function stop() { obs.disconnect(); if (timer) { clearTimeout(timer); timer = null; } } document.addEventListener('click', (e) => { if (!state.fastRetweet) return; const t = e.target; if (!t || !t.closest || !t.closest('[data-testid="retweet"], [data-testid="unretweet"]')) return; obs.observe(document.body, { childList: true, subtree: true }); if (timer) clearTimeout(timer); timer = setTimeout(stop, 1500); // give up if the menu never appears }, true); } let scheduled = false; function schedule() { if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; ensureBox(); applyFilters(); }); } loadState(); setupFastRetweet(); new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true }); schedule(); })();