Improve x.com reading by toggling side columns, ads, and media defaults.
// ==UserScript==
// @name X Reading Enhancer
// @name:zh-CN X 阅读增强
// @namespace https://github.com/local/x-reading-enhancer
// @version 0.4.1
// @description Improve x.com reading by toggling side columns, ads, and media defaults.
// @description:zh-CN 优化 x.com / twitter.com 阅读体验:隐藏左右栏、广告,控制媒体默认展示,并提供快捷键和可拖动悬浮面板。
// @author Codex
// @match https://x.com/*
// @match https://twitter.com/*
// @license MIT
// @supportURL https://github.com/Skylerliutian/x-reading-enhancer/issues
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-start
// ==/UserScript==
(function () {
"use strict";
const STORAGE_PREFIX = "x-reading-enhancer.";
const DEFAULTS = {
leftVisible: true,
rightVisible: true,
hideAds: true,
mediaVisible: true,
panelOpen: false,
panelPosition: null,
};
const DRAG_MARGIN = 8;
const DRAG_CLICK_TOLERANCE = 4;
const PANEL_OFFSET = 22;
const SHORTCUTS = {
A: "hideAds",
L: "leftVisible",
M: "mediaVisible",
R: "rightVisible",
};
const AD_PATTERNS = [
/^ad$/i,
/^promoted$/i,
/^promoted by\b/i,
/^sponsored$/i,
/^sponsored by\b/i,
/^广告$/,
/^廣告$/,
/^推广$/,
/^推廣$/,
/^赞助$/,
/^贊助$/,
/^プロモーション$/,
/^広告$/,
/^プロモーション広告$/,
/^gesponsert$/i,
/^anzeige$/i,
/^sponsorise$/i,
/^sponsorise par\b/i,
/^promocionado$/i,
/^promovido$/i,
/^promosso$/i,
/^annuncio$/i,
/^reklama$/i,
/^реклама$/i,
];
const MEDIA_SELECTORS = [
'[data-testid="tweetPhoto"]',
'[data-testid="videoPlayer"]',
'[data-testid="videoComponent"]',
'[data-testid="gifPlayer"]',
'[data-testid="playButton"]',
'a[href*="/photo/"]',
'a[href*="/video/"]',
'img[src*="pbs.twimg.com/media"]',
'img[src*="pbs.twimg.com/amplify_video_thumb"]',
"video",
].join(",");
const MEDIA_BLOCK_SELECTORS = [
'[data-testid="tweetPhoto"]',
'[data-testid="videoPlayer"]',
'[data-testid="videoComponent"]',
'[data-testid="gifPlayer"]',
'a[href*="/photo/"]',
'a[href*="/video/"]',
].join(",");
const settings = loadSettings();
let observer = null;
let scanQueued = false;
let mediaToggleListenerInstalled = false;
installStyles();
applyRootClasses();
onReady(() => {
ensurePanel();
setupMediaToggleListener();
scanPage();
startObserver();
});
function loadSettings() {
return Object.fromEntries(
Object.entries(DEFAULTS).map(([key, fallback]) => [key, readSetting(key, fallback)]),
);
}
function readSetting(key, fallback) {
const storageKey = STORAGE_PREFIX + key;
try {
if (typeof GM_getValue === "function") {
return GM_getValue(storageKey, fallback);
}
} catch (_) {
// Fall through to localStorage.
}
try {
const stored = window.localStorage.getItem(storageKey);
return stored == null ? fallback : JSON.parse(stored);
} catch (_) {
return fallback;
}
}
function writeSetting(key, value) {
settings[key] = value;
const storageKey = STORAGE_PREFIX + key;
try {
if (typeof GM_setValue === "function") {
GM_setValue(storageKey, value);
return;
}
} catch (_) {
// Fall through to localStorage.
}
try {
window.localStorage.setItem(storageKey, JSON.stringify(value));
} catch (_) {
// Ignore storage failures; current-page controls still work.
}
}
function installStyles() {
const css = `
:root.xre-hide-left header[role="banner"] {
visibility: hidden !important;
}
:root.xre-hide-right [data-testid="sidebarColumn"],
:root.xre-hide-right aside[role="complementary"] {
visibility: hidden !important;
}
:root.xre-hide-media article:not([data-xre-media-expanded="true"]) [data-testid="tweetPhoto"],
:root.xre-hide-media article:not([data-xre-media-expanded="true"]) [data-testid="videoPlayer"],
:root.xre-hide-media article:not([data-xre-media-expanded="true"]) [data-testid="videoComponent"],
:root.xre-hide-media article:not([data-xre-media-expanded="true"]) [data-testid="gifPlayer"],
:root.xre-hide-media article:not([data-xre-media-expanded="true"]) [data-testid="playButton"],
:root.xre-hide-media article:not([data-xre-media-expanded="true"]) a[href*="/photo/"],
:root.xre-hide-media article:not([data-xre-media-expanded="true"]) a[href*="/video/"],
:root.xre-hide-media article:not([data-xre-media-expanded="true"]) img[src*="pbs.twimg.com/media"],
:root.xre-hide-media article:not([data-xre-media-expanded="true"]) img[src*="pbs.twimg.com/amplify_video_thumb"],
:root.xre-hide-media article:not([data-xre-media-expanded="true"]) video {
display: none !important;
}
.xre-media-toggle {
display: none;
width: 100%;
min-height: 42px;
margin: 10px 0 2px;
padding: 10px 12px;
border: 1px solid rgb(47, 51, 54);
border-radius: 8px;
background: rgba(29, 155, 240, 0.1);
color: #1d9bf0;
cursor: pointer;
font: inherit;
font-weight: 700;
text-align: center;
}
.xre-media-toggle:hover,
.xre-media-toggle:focus-visible {
background: rgba(29, 155, 240, 0.18);
outline: 2px solid rgba(29, 155, 240, 0.5);
outline-offset: 2px;
}
:root.xre-hide-media article[data-xre-has-media="true"]:not([data-xre-media-expanded="true"]) .xre-media-toggle {
display: block;
}
:root:not(.xre-hide-media) .xre-media-toggle {
display: none !important;
}
article[data-xre-ad-hidden="true"] {
display: none !important;
}
#xre-root {
--xre-panel-offset: ${PANEL_OFFSET}px;
position: fixed;
right: 18px;
bottom: 18px;
width: 55px;
height: 55px;
z-index: 2147483647;
color-scheme: dark;
color: #eff3f4;
scrollbar-color: rgb(62, 65, 68) rgb(22, 24, 28);
font-family: TwitterChirp, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.35;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
#xre-root.xre-positioned {
right: auto !important;
bottom: auto !important;
}
#xre-root,
#xre-root * {
box-sizing: border-box;
}
#xre-root .xre-fab {
display: flex;
flex-basis: auto;
flex-direction: column;
flex-shrink: 0;
align-self: flex-end;
align-items: center;
justify-content: center;
width: 55px;
height: 55px;
min-width: 0;
min-height: 0;
margin: 0;
padding: 0;
border: 0 solid black;
border-width: 1px;
border-color: rgb(75, 78, 82);
border-radius: 16px;
background-color: rgba(0, 0, 0, 0.65);
color: inherit;
color-scheme: dark;
box-shadow:
rgba(255, 255, 255, 0.2) 0 0 15px,
rgba(255, 255, 255, 0.15) 0 0 3px 1px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-sizing: border-box;
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: 800;
list-style: none;
outline-style: none;
pointer-events: auto !important;
text-align: inherit;
text-decoration: none;
transition-duration: 0.2s;
transition-property: background-color, box-shadow;
z-index: 0;
touch-action: none;
}
#xre-root .xre-fab:hover,
#xre-root .xre-fab:focus-visible {
background-color: rgba(22, 24, 28, 0.75);
box-shadow:
rgba(255, 255, 255, 0.28) 0 0 18px,
rgba(255, 255, 255, 0.18) 0 0 4px 1px;
}
#xre-root .xre-fab-icon {
display: block;
width: 27px;
height: 27px;
color: #eff3f4;
pointer-events: none;
}
#xre-root .xre-visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
#xre-root .xre-panel {
position: absolute;
right: var(--xre-panel-offset);
bottom: var(--xre-panel-offset);
width: 238px;
padding: 14px;
border: 1px solid rgba(239, 243, 244, 0.18);
border-radius: 8px;
background: rgba(0, 0, 0, 0.94);
box-shadow: 0 10px 36px rgba(0, 0, 0, 0.45);
}
#xre-root.xre-panel-below .xre-panel {
top: var(--xre-panel-offset);
right: var(--xre-panel-offset);
bottom: auto;
}
#xre-root.xre-panel-right .xre-panel {
right: auto;
left: var(--xre-panel-offset);
bottom: var(--xre-panel-offset);
}
#xre-root.xre-panel-right.xre-panel-below .xre-panel {
right: auto;
left: var(--xre-panel-offset);
top: var(--xre-panel-offset);
bottom: auto;
}
#xre-root .xre-panel[hidden] {
display: none !important;
}
#xre-root .xre-title {
margin: 0 0 10px;
color: #eff3f4;
font-size: 15px;
font-weight: 800;
text-align: center;
cursor: grab;
user-select: none;
}
#xre-root.xre-dragging,
#xre-root.xre-dragging * {
cursor: grabbing !important;
user-select: none !important;
}
#xre-root .xre-row {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) 24px;
align-items: center;
min-height: 34px;
gap: 12px;
color: #eff3f4;
cursor: pointer;
user-select: none;
}
#xre-root .xre-row + .xre-row {
border-top: 1px solid rgba(239, 243, 244, 0.12);
}
#xre-root .xre-row > span {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
grid-column: 2;
min-width: 0;
text-align: center;
overflow-wrap: anywhere;
}
#xre-root .xre-shortcut {
color: #8b98a5;
font-size: 11px;
font-weight: 500;
line-height: 1.1;
}
#xre-root .xre-row input {
grid-column: 3;
justify-self: end;
width: 18px;
height: 18px;
margin: 0;
accent-color: #1d9bf0;
}
@media (max-width: 720px) {
#xre-root {
right: 12px;
bottom: 12px;
}
#xre-root .xre-panel {
width: min(238px, calc(100vw - 24px));
}
}
`;
try {
if (typeof GM_addStyle === "function") {
GM_addStyle(css);
return;
}
} catch (_) {
// Fall through to a style element.
}
const style = document.createElement("style");
style.id = "xre-style";
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
}
function applyRootClasses() {
const root = document.documentElement;
root.classList.toggle("xre-hide-left", !settings.leftVisible);
root.classList.toggle("xre-hide-right", !settings.rightVisible);
root.classList.toggle("xre-hide-media", !settings.mediaVisible);
}
function onReady(callback) {
if (document.body) {
callback();
return;
}
document.addEventListener("DOMContentLoaded", callback, { once: true });
}
function ensurePanel() {
if (!document.body || document.getElementById("xre-root")) {
return;
}
const root = document.createElement("div");
root.id = "xre-root";
root.innerHTML = `
<button class="xre-fab" type="button" aria-label="X" aria-controls="xre-panel" aria-expanded="false" title="X">
<svg class="xre-fab-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817-5.97 6.817H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"></path>
</svg>
<span class="xre-visually-hidden">X</span>
</button>
<section class="xre-panel" id="xre-panel" aria-label="X 阅读增强控制面板">
<div class="xre-title">阅读控制</div>
${renderToggle("leftVisible", "显示左侧栏", "Shift+L")}
${renderToggle("rightVisible", "显示右侧栏", "Shift+R")}
${renderToggle("hideAds", "隐藏广告", "Shift+A")}
${renderToggle("mediaVisible", "默认显示媒体", "Shift+M")}
</section>
`;
document.body.appendChild(root);
const fab = root.querySelector(".xre-fab");
const panel = root.querySelector(".xre-panel");
const title = root.querySelector(".xre-title");
let suppressFabClickUntil = 0;
fab.addEventListener("click", (event) => {
event.stopPropagation();
if (Date.now() < suppressFabClickUntil) {
return;
}
writeSetting("panelOpen", !settings.panelOpen);
updatePanel(root);
});
root.addEventListener("change", (event) => {
const input = event.target;
if (!input || !input.matches || !input.matches("input[data-xre-key]")) {
return;
}
writeSetting(input.dataset.xreKey, input.checked);
applyRootClasses();
scanPage();
updatePanel(root);
});
document.addEventListener("keydown", (event) => {
if (handleShortcut(event, root)) {
return;
}
if (event.key !== "Escape" || panel.hidden) {
return;
}
writeSetting("panelOpen", false);
updatePanel(root);
fab.focus();
}, true);
document.addEventListener("click", (event) => {
if (panel.hidden || root.contains(event.target)) {
return;
}
writeSetting("panelOpen", false);
updatePanel(root);
}, true);
applyPanelPosition(root);
setupPanelDrag(root, [fab, title], (handle) => {
if (handle === fab) {
suppressFabClickUntil = Date.now() + 250;
}
});
updatePanel(root);
}
function renderToggle(key, label, shortcut) {
return `
<label class="xre-row" title="快捷键:${shortcut}">
<span>
<span>${label}</span>
<span class="xre-shortcut">${shortcut}</span>
</span>
<input type="checkbox" data-xre-key="${key}">
</label>
`;
}
function handleShortcut(event, root) {
if (
!event.shiftKey ||
event.ctrlKey ||
event.altKey ||
event.metaKey ||
shouldIgnoreShortcutTarget(event.target)
) {
return false;
}
const settingKey = SHORTCUTS[String(event.key || "").toUpperCase()];
if (!settingKey) {
return false;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
writeSetting(settingKey, !settings[settingKey]);
applyRootClasses();
scanPage();
updatePanel(root);
return true;
}
function shouldIgnoreShortcutTarget(target) {
if (!target || !target.closest) {
return false;
}
return Boolean(
target.closest(
'input, textarea, select, [contenteditable="true"], [contenteditable=""], [role="textbox"]',
),
);
}
function setupMediaToggleListener() {
if (mediaToggleListenerInstalled) {
return;
}
mediaToggleListenerInstalled = true;
document.addEventListener("click", (event) => {
const button = event.target?.closest?.(".xre-media-toggle");
if (!button) {
return;
}
const article = button.closest('article[data-testid="tweet"], article[role="article"]');
if (!article) {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
const expanded = article.dataset.xreMediaExpanded === "true";
if (expanded) {
delete article.dataset.xreMediaExpanded;
} else {
article.dataset.xreMediaExpanded = "true";
}
updateMediaToggleText(article);
}, true);
}
function updatePanel(root) {
const fab = root.querySelector(".xre-fab");
const panel = root.querySelector(".xre-panel");
if (fab && panel) {
panel.hidden = !settings.panelOpen;
fab.setAttribute("aria-expanded", String(settings.panelOpen));
}
root.querySelectorAll("input[data-xre-key]").forEach((input) => {
input.checked = Boolean(settings[input.dataset.xreKey]);
});
updatePanelPlacement(root);
}
function setupPanelDrag(root, handles, onDragEnd) {
handles.filter(Boolean).forEach((handle) => {
handle.addEventListener("pointerdown", (event) => {
if (event.button !== 0) {
return;
}
const startRect = root.getBoundingClientRect();
const state = {
handle,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startLeft: startRect.left,
startTop: startRect.top,
moved: false,
};
const onPointerMove = (moveEvent) => {
if (moveEvent.pointerId !== state.pointerId) {
return;
}
const dx = moveEvent.clientX - state.startX;
const dy = moveEvent.clientY - state.startY;
if (Math.hypot(dx, dy) > DRAG_CLICK_TOLERANCE) {
state.moved = true;
}
setPanelPosition(root, clampPanelPosition(root, state.startLeft + dx, state.startTop + dy));
};
const onPointerUp = (upEvent) => {
if (upEvent.pointerId !== state.pointerId) {
return;
}
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
document.removeEventListener("pointercancel", onPointerUp);
root.classList.remove("xre-dragging");
if (state.moved) {
const rect = root.getBoundingClientRect();
const position = clampPanelPosition(root, rect.left, rect.top);
setPanelPosition(root, position);
writeSetting("panelPosition", position);
onDragEnd(state.handle);
}
};
event.preventDefault();
root.classList.add("xre-dragging");
handle.setPointerCapture?.(event.pointerId);
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
document.addEventListener("pointercancel", onPointerUp);
});
});
window.addEventListener("resize", () => {
const rootElement = document.getElementById("xre-root");
if (!rootElement) {
return;
}
if (isValidPosition(settings.panelPosition)) {
const position = clampPanelPosition(rootElement, settings.panelPosition.left, settings.panelPosition.top);
setPanelPosition(rootElement, position);
writeSetting("panelPosition", position);
}
updatePanelPlacement(rootElement);
});
}
function applyPanelPosition(root) {
if (!isValidPosition(settings.panelPosition)) {
updatePanelPlacement(root);
return;
}
setPanelPosition(root, clampPanelPosition(root, settings.panelPosition.left, settings.panelPosition.top));
}
function isValidPosition(position) {
return (
position &&
Number.isFinite(position.left) &&
Number.isFinite(position.top)
);
}
function clampPanelPosition(root, left, top) {
const rect = root.getBoundingClientRect();
const width = rect.width || 55;
const height = rect.height || 55;
const maxLeft = Math.max(DRAG_MARGIN, window.innerWidth - width - DRAG_MARGIN);
const maxTop = Math.max(DRAG_MARGIN, window.innerHeight - height - DRAG_MARGIN);
return {
left: Math.round(Math.min(Math.max(DRAG_MARGIN, left), maxLeft)),
top: Math.round(Math.min(Math.max(DRAG_MARGIN, top), maxTop)),
};
}
function setPanelPosition(root, position) {
root.classList.add("xre-positioned");
root.style.left = `${position.left}px`;
root.style.top = `${position.top}px`;
root.style.right = "auto";
root.style.bottom = "auto";
updatePanelPlacement(root);
}
function updatePanelPlacement(root) {
const panel = root.querySelector(".xre-panel");
if (!panel) {
return;
}
const rootRect = root.getBoundingClientRect();
const panelWidth = panel.offsetWidth || 238;
const panelHeight = panel.offsetHeight || 210;
const needsRightExpand = rootRect.right < panelWidth + PANEL_OFFSET + DRAG_MARGIN;
const needsBelow = rootRect.top < panelHeight + PANEL_OFFSET + DRAG_MARGIN;
root.classList.toggle("xre-panel-right", needsRightExpand);
root.classList.toggle("xre-panel-below", needsBelow);
}
function startObserver() {
if (observer || !document.body) {
return;
}
observer = new MutationObserver(queueScan);
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
function queueScan() {
if (scanQueued) {
return;
}
scanQueued = true;
window.requestAnimationFrame(() => {
scanQueued = false;
ensurePanel();
scanPage();
});
}
function scanPage() {
applyRootClasses();
const articles = document.querySelectorAll('article[data-testid="tweet"], article[role="article"]');
articles.forEach((article) => {
syncArticleMediaToggle(article);
if (settings.hideAds && isPromotedArticle(article)) {
article.dataset.xreAdHidden = "true";
} else {
delete article.dataset.xreAdHidden;
}
});
}
function syncArticleMediaToggle(article) {
const hasMedia = Boolean(article.querySelector(MEDIA_SELECTORS));
const button = article.querySelector(".xre-media-toggle");
if (!hasMedia) {
delete article.dataset.xreHasMedia;
delete article.dataset.xreMediaExpanded;
button?.remove();
return;
}
article.dataset.xreHasMedia = "true";
if (!button) {
insertMediaToggle(article);
} else {
updateMediaToggleText(article);
}
}
function insertMediaToggle(article) {
const button = document.createElement("button");
button.className = "xre-media-toggle";
button.type = "button";
updateMediaToggleText(article, button);
const mediaBlock = findMediaBlock(article);
if (mediaBlock?.parentElement) {
mediaBlock.parentElement.insertBefore(button, mediaBlock);
return;
}
article.appendChild(button);
}
function findMediaBlock(article) {
const block = article.querySelector(MEDIA_BLOCK_SELECTORS);
if (block) {
return block;
}
const media = article.querySelector(MEDIA_SELECTORS);
return media?.closest?.(MEDIA_BLOCK_SELECTORS) || media;
}
function updateMediaToggleText(article, button = article.querySelector(".xre-media-toggle")) {
if (!button) {
return;
}
button.textContent = article.dataset.xreMediaExpanded === "true"
? "隐藏图片/视频"
: "显示图片/视频";
}
function isPromotedArticle(article) {
if (article.querySelector('[data-testid="placementTracking"]')) {
return true;
}
const labelNodes = article.querySelectorAll('span, div[dir="auto"], [aria-label]');
for (const node of labelNodes) {
if (node.closest('[data-testid="tweetText"]')) {
continue;
}
if (isPromotedText(node.textContent) || isPromotedText(node.getAttribute("aria-label"))) {
return true;
}
}
return false;
}
function isPromotedText(value) {
const text = normalizeText(value);
if (!text || text.length > 64) {
return false;
}
return AD_PATTERNS.some((pattern) => pattern.test(text));
}
function normalizeText(value) {
return String(value || "")
.replace(/\s+/g, " ")
.replace(/[\u00a0\u200b-\u200d\ufeff]/g, "")
.trim();
}
})();