// ==UserScript==
// @name Hide Spoiler/NSFW Titles on Reddit
// @namespace https://greasyfork.org/en/users/1438773-thezealot
// @version 2.0.2
// @description Hides titles of posts marked as spoilers and/or NSFW on Reddit. Includes a toggle button to show/hide titles, and settings to 1). choose between blur and censor bar styles and 2). hide spoiler and/or NSFW titles. Settings are persistent across sessions. Script mostly made using AI.
// @author TheZealot
// @license MIT
// @icon https://cdn-icons-png.flaticon.com/512/11695/11695651.png
// @supportURL https://greasyfork.org/en/users/1438773-thezealot
// @include https://www.reddit.com/*
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
(function () {
'use strict';
// ==================== CONFIGURATION ====================
// Cached DOM selectors for better performance
const SELECTORS = {
POST: 'shreddit-post[spoiler], shreddit-post[nsfw]',
POST_TITLE: '[id^="post-title-t3_"]',
SEARCH_POST: 'div[data-testid="search-post-unit"]',
SEARCH_TITLE: 'a[data-testid="post-title-text"]',
SPOILER_MARKER: '.text-category-spoiler',
NSFW_MARKER: '.text-category-nsfw',
RELATED_POSTS: 'ul.list-none.p-0.m-0 li',
RELATED_TITLE: '.i18n-list-item-post-title',
HEADER: [
'.items-center.flex.h-header-large',
'header',
'div[data-testid="header-container"]'
]
};
// Icons for the toggle button
const ICONS = {
ON: 'https://cdn-icons-png.flaticon.com/512/11860/11860394.png',
OFF: 'https://cdn-icons-png.flaticon.com/512/11860/11860256.png'
};
// Classes for styling
const CLASSES = {
BLUR: 'btr-blurred',
CENSOR: 'btr-censor-bar',
BUTTON: 'btr-toggle-btn',
MODAL: 'btr-settings-modal',
NO_TRANSITION: 'btr-no-transition',
BACKDROP: 'btr-modal-backdrop' // Added class for modal backdrop
};
// ==================== STATE MANAGEMENT ====================
// Retrieve last saved settings from GM_getValue (persistent across page loads)
let state = {
coverEnabled: GM_getValue('coverEnabled', true),
useBlurMode: GM_getValue('useBlurMode', true),
hideSpoilers: GM_getValue('hideSpoilers', true),
hideNsfw: GM_getValue('hideNsfw', false),
isStyleTransitionDisabled: false,
blurIntensity: GM_getValue('blurIntensity', 4), // Added setting for blur intensity
lastSettingsUpdate: Date.now() // Track when settings were last changed
};
function updateFilterSettings(newSpoilerSetting, newNsfwSetting) {
const previousSpoilerSetting = state.hideSpoilers;
const previousNsfwSetting = state.hideNsfw;
state.hideSpoilers = newSpoilerSetting;
state.hideNsfw = newNsfwSetting;
state.lastSettingsUpdate = Date.now();
GM_setValue('hideSpoilers', state.hideSpoilers);
GM_setValue('hideNsfw', state.hideNsfw);
// Force removal of concealment if either setting was turned off
if (previousSpoilerSetting && !newSpoilerSetting) {
removeSpoilerConcealment();
}
if (previousNsfwSetting && !newNsfwSetting) {
removeNsfwConcealment();
}
// If both settings are now disabled, do an additional cleanup
if (!newSpoilerSetting && !newNsfwSetting) {
// Extra thorough removal of all concealment classes from any element
document.querySelectorAll(`.${CLASSES.BLUR}, .${CLASSES.CENSOR}`).forEach(el => {
el.classList.remove(CLASSES.BLUR, CLASSES.CENSOR);
});
}
// Update UI
updateToggleButton();
updateSpoilerTitles();
}
// Cache for DOM elements
let cache = {
button: null,
modal: null,
styleElement: null,
postFeed: null,
sidebar: null,
searchResults: null,
modalBackdrop: null // Added cache for modal backdrop
};
// ==================== UI SETUP ====================
/**
* Injects all CSS styles at once for better performance
*/
function setupStyles() {
const style = document.createElement('style');
style.textContent = `
:root {
--btr-censor-bar-color: black; /* Default color for censor bar in light mode */
--btr-primary-color: #ff4500; /* Reddit's primary orange color */
--btr-primary-hover: #ff6a33; /* Lighter shade for hover state */
--btr-primary-active: #cc3700; /* Darker shade for active state */
--btr-blur-intensity: ${state.blurIntensity}px; /* Configurable blur intensity */
}
/* Apply effects only to spoiler and NSFW titles */
shreddit-post[spoiler] [id^="post-title-t3_"],
shreddit-post[nsfw] [id^="post-title-t3_"],
.text-category-spoiler + h3,
.text-category-spoiler + a,
.text-category-nsfw + h3,
.text-category-nsfw + a,
div[data-testid="search-post-unit"] a[data-testid="post-title-text"] {
transition: filter 0.3s ease, opacity 0.3s ease;
position: relative;
white-space: normal !important;
overflow-wrap: anywhere !important;
display: inline !important;
will-change: filter, opacity; /* GPU optimization hint */
}
/* Disable transitions when switching styles in settings */
.${CLASSES.NO_TRANSITION} {
transition: none !important;
}
/* Blur effect for spoiler and NSFW titles */
.${CLASSES.BLUR} {
filter: blur(var(--btr-blur-intensity)) !important;
white-space: normal !important;
overflow-wrap: anywhere !important;
}
/* Blur hover effect - apply only to the element being hovered */
.${CLASSES.BLUR}:hover {
filter: none !important;
transition: filter 0.3s ease !important;
}
/* Censor bar effect for spoiler and NSFW titles */
.${CLASSES.CENSOR} {
display: inline !important;
width: 100% !important;
position: relative;
white-space: normal !important;
overflow-wrap: anywhere !important;
background-color: var(--btr-censor-bar-color) !important;
color: transparent !important;
transition: background-color 0.3s ease, color 0.3s ease !important;
}
/* Censor bar hover effect */
.${CLASSES.CENSOR}:hover {
background-color: transparent !important;
color: inherit !important;
}
/* Censor bar hover effect removal */
.${CLASSES.CENSOR}::after {
display: none;
}
/* Fix for search results - remove parent hover detection, only apply to direct hover */
div[data-testid="search-post-unit"] a[data-testid="post-title-text"].${CLASSES.BLUR}:hover,
div[data-testid="search-post-unit"] a[data-testid="post-title-text"].${CLASSES.CENSOR}:hover::after {
pointer-events: auto !important;
}
/* Remove the parent hover triggering - only direct hover should work */
div[data-testid="search-post-unit"]:hover a[data-testid="post-title-text"].${CLASSES.BLUR}:not(:hover) {
filter: blur(var(--btr-blur-intensity)) !important;
}
div[data-testid="search-post-unit"]:hover a[data-testid="post-title-text"].${CLASSES.CENSOR}:not(:hover)::after {
opacity: 1 !important;
}
/* Toggle button styles with enhanced hover and active states */
.${CLASSES.BUTTON} {
position: relative;
cursor: pointer;
margin-left: 8px;
z-index: 1000;
width: 36px;
height: 36px;
display: flex;
justify-content: center;
align-items: center;
background-size: 75%;
background-repeat: no-repeat;
background-position: center;
border: none;
border-radius: 50%;
background-color: var(--btr-primary-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
will-change: transform, background-color, box-shadow;
}
/* Enhanced hover state */
.${CLASSES.BUTTON}:not(:disabled):hover {
background-color: var(--btr-primary-hover);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
/* Enhanced active/pressed state */
.${CLASSES.BUTTON}:not(:disabled):active {
background-color: var(--btr-primary-active);
transform: translateY(1px) scale(0.95);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Disabled state */
.${CLASSES.BUTTON}:disabled {
background-color: #3D494E;
opacity: 0.7;
cursor: not-allowed;
}
/* Modal backdrop */
.${CLASSES.BACKDROP} {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1999;
opacity: 0;
transition: opacity 0.3s ease;
backdrop-filter: blur(2px);
display: none;
pointer-events: none;
}
.${CLASSES.BACKDROP}.active {
opacity: 1;
display: block;
pointer-events: auto;
}
/* Settings modal styles with improved animation */
.${CLASSES.MODAL} {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.9);
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
z-index: 2000;
display: none;
min-width: 340px;
text-align: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.${CLASSES.MODAL}.active {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
/* Dark mode support for modal */
:root.theme-dark .${CLASSES.MODAL} {
background: #1a1a1b;
color: #d7dadc;
border: 1px solid #343536;
}
.${CLASSES.MODAL} h3 {
font-size: 20px;
font-weight: bold;
color: var(--btr-primary-color);
margin-bottom: 10px;
}
.btr-settings-container {
display: flex;
justify-content: space-between;
column-gap: 12px;
margin: 0 auto;
max-width: 300px;
}
.btr-settings-column {
display: flex;
flex-direction: column;
gap: 10px;
text-align: left;
}
.btr-settings-column h4 {
font-size: 16px;
font-weight: bold;
color: #444;
text-decoration: underline;
margin-bottom: 6px;
}
:root.theme-dark .btr-settings-column h4 {
color: #d7dadc;
}
.${CLASSES.MODAL} label {
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
color: #666;
margin: 4px 0;
}
:root.theme-dark .${CLASSES.MODAL} label {
color: #c5c6c7;
}
/* Improved slider styles */
.btr-range-slider {
width: 100%;
margin: 8px 0;
display: flex;
flex-direction: column;
}
.btr-range-slider label {
margin-bottom: 5px;
}
.btr-range-slider-value {
margin-top: 5px;
text-align: center;
font-weight: bold;
color: var(--btr-primary-color);
}
/* Firefox-specific fix for settings modal UI */
@-moz-document url-prefix() {
/* Make the columns perfectly equal and aligned */
.btr-settings-container {
display: flex;
justify-content: space-between;
width: 100%;
max-width: 300px;
margin: 0 auto;
}
/* Ensure consistent column widths */
.btr-settings-column {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0 5px;
min-height: 160px;
}
/* Center the column headings */
.btr-settings-column h4 {
text-align: left;
width: 100%;
margin: 0 0 10px 0;
}
/* Align radio buttons and checkboxes */
.btr-option {
display: flex;
align-items: center;
width: 100%;
margin: 4px 0;
}
/* Create a consistent container for inputs */
.btr-input-wrapper {
display: inline-block;
width: 16px;
height: 16px;
flex-shrink: 0;
position: relative;
margin-right: 8px;
}
/* Position the actual input elements */
.btr-input-wrapper input[type="radio"],
.btr-input-wrapper input[type="checkbox"] {
position: absolute;
left: 0;
top: 0;
margin: 0;
padding: 0;
}
/* Ensure label text is aligned */
.btr-label-text {
display: inline-block;
vertical-align: middle;
line-height: 16px;
}
/* Consistent slider spacing */
.btr-range-slider {
width: 100%;
margin: 15px 0;
}
/* Maintain consistent spacing when blur intensity is hidden */
.btr-range-slider[style*="display: none"] {
display: block !important;
visibility: hidden;
pointer-events: none;
height: 0;
margin: 0;
padding: 0;
}
/* Fix specific slider elements */
#btr-blur-intensity {
width: 100%;
margin: 8px 0;
}
}
/* Improved buttons */
.btr-button {
background-color: var(--btr-primary-color);
color: white;
border: none;
padding: 8px 16px;
cursor: pointer;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.btr-button:hover {
background-color: var(--btr-primary-hover);
transform: translateY(-1px);
}
.btr-button:active {
background-color: var(--btr-primary-active);
transform: translateY(1px);
}
.btr-buttons-container {
margin-top: 20px;
display: flex;
justify-content: center;
gap: 10px;
}
/* Reset button style */
.btr-reset-btn {
background-color: #666;
}
.btr-reset-btn:hover {
background-color: #777;
}
.btr-reset-btn:active {
background-color: #555;
}
`;
document.head.appendChild(style);
cache.styleElement = style;
}
/**
* Updates the censor bar color to match Reddit's theme (light/dark mode)
*/
function updateCensorBarColor() {
const isDarkMode = document.documentElement.classList.contains('theme-dark');
const censorbarColor = isDarkMode ? 'white' : 'black';
document.documentElement.style.setProperty('--btr-censor-bar-color', censorbarColor);
}
/**
* Creates or updates the toggle button in Reddit's header
*/
function setupToggleButton() {
// Check if button already exists in DOM
const existingButton = document.querySelector(`.${CLASSES.BUTTON}`);
if (existingButton) {
cache.button = existingButton;
updateToggleButton();
return;
}
// Create new button
cache.button = document.createElement('button');
cache.button.classList.add(CLASSES.BUTTON);
cache.button.setAttribute('aria-label', 'Toggle Spoiler/NSFW Title Hiding');
cache.button.style.backgroundImage = `url('${state.coverEnabled ? ICONS.ON : ICONS.OFF}')`;
// Add click event listener
cache.button.addEventListener('click', function() {
// Only toggle if at least one filter is enabled
const atLeastOneFilterEnabled = state.hideSpoilers || state.hideNsfw;
if (atLeastOneFilterEnabled) {
state.coverEnabled = !state.coverEnabled;
GM_setValue('coverEnabled', state.coverEnabled);
// Add a subtle animation effect when toggling
this.style.transform = 'scale(0.8)';
setTimeout(() => {
this.style.transform = '';
updateToggleButton();
updateSpoilerTitles();
}, 150);
} else {
// Provide visual feedback that click does nothing when filters are disabled
this.style.transform = 'scale(0.95)';
setTimeout(() => {
this.style.transform = '';
}, 100);
}
});
// Add right-click event listener for settings
cache.button.addEventListener('contextmenu', function(e) {
e.preventDefault(); // Prevent context menu
showModal();
return false;
});
// Try to insert button in header
let headerFound = false;
for (const selector of SELECTORS.HEADER) {
const headerContainer = document.querySelector(selector);
if (headerContainer) {
headerContainer.appendChild(cache.button);
headerFound = true;
break;
}
}
// Fallback if header not found
if (!headerFound) {
console.warn('Header container not found. Creating floating button.');
cache.button.style.position = 'fixed';
cache.button.style.bottom = '20px';
cache.button.style.right = '20px';
cache.button.style.zIndex = '9999';
document.body.appendChild(cache.button);
}
updateToggleButton();
}
/**
* Updates the toggle button's appearance and tooltip based on current settings
*/
/**
* Updates the toggle button's appearance and tooltip based on current settings
*/
function updateToggleButton() {
if (!cache.button) return;
// Check if at least one filter is enabled
const atLeastOneFilterEnabled = state.hideSpoilers || state.hideNsfw;
// Update button's appearance and functionality
if (atLeastOneFilterEnabled) {
cache.button.disabled = false;
cache.button.style.backgroundColor = 'var(--btr-primary-color)';
cache.button.style.opacity = '1';
// Update tooltip based on the state of the checkboxes
if (state.hideSpoilers && !state.hideNsfw) {
cache.button.title = state.coverEnabled ? 'Show Spoilers' : 'Hide Spoilers';
} else if (!state.hideSpoilers && state.hideNsfw) {
cache.button.title = state.coverEnabled ? 'Show NSFW' : 'Hide NSFW';
} else if (state.hideSpoilers && state.hideNsfw) {
cache.button.title = state.coverEnabled ? 'Show Spoilers/NSFW' : 'Hide Spoilers/NSFW';
}
} else {
// Instead of disabling the button, we'll just make left-click do nothing
cache.button.disabled = false; // Keep it enabled for right-click
cache.button.style.backgroundColor = '#666'; // A more interactive gray color
cache.button.title = 'No filters selected (Right-click for settings)';
cache.button.style.opacity = '0.8'; // Slightly more visible than before
}
// Update button icon
cache.button.style.backgroundImage = `url('${state.coverEnabled ? ICONS.ON : ICONS.OFF}')`;
}
/**
* Creates settings modal with improved UI and functionality
*/
function createSettingsModal() {
if (document.querySelector(`.${CLASSES.MODAL}`)) return;
// Create modal backdrop (initially hidden)
const backdrop = document.createElement('div');
backdrop.classList.add(CLASSES.BACKDROP);
backdrop.addEventListener('click', hideModal);
backdrop.style.display = 'none'; // Initially hidden
document.body.appendChild(backdrop);
cache.modalBackdrop = backdrop;
// Create modal
const modal = document.createElement('div');
modal.classList.add(CLASSES.MODAL);
modal.innerHTML = `
<h3>Reddit Spoiler/NSFW Title Settings</h3>
<div class="btr-settings-container">
<div class="btr-settings-column">
<h4>Style</h4>
<label><input type="radio" name="btr-mode" value="blur"> Blurred Titles</label>
<label><input type="radio" name="btr-mode" value="censor-bar"> Censor Bar</label>
<div class="btr-range-slider">
<label for="btr-blur-intensity">Blur Intensity</label>
<input type="range" id="btr-blur-intensity" min="1" max="10" value="${state.blurIntensity}">
<div class="btr-range-slider-value" id="btr-blur-value">${state.blurIntensity}px</div>
</div>
</div>
<div class="btr-settings-column">
<h4>Filters</h4>
<label><input type="checkbox" id="btr-hide-spoilers"> Hide Spoilers</label>
<label><input type="checkbox" id="btr-hide-nsfw"> Hide NSFW</label>
</div>
</div>
<div class="btr-buttons-container">
<button id="btr-reset-settings" class="btr-button btr-reset-btn">Reset</button>
<button id="btr-close-settings" class="btr-button">Save & Close</button>
</div>
`;
document.body.appendChild(modal);
cache.modal = modal;
// Set initial values
const blurOption = modal.querySelector('input[value="blur"]');
const censorOption = modal.querySelector('input[value="censor-bar"]');
const spoilersOption = modal.querySelector('#btr-hide-spoilers');
const nsfwOption = modal.querySelector('#btr-hide-nsfw');
const blurIntensitySlider = modal.querySelector('#btr-blur-intensity');
const blurValueDisplay = modal.querySelector('#btr-blur-value');
if (blurOption && censorOption) {
blurOption.checked = state.useBlurMode;
censorOption.checked = !state.useBlurMode;
modal.querySelectorAll('input[name="btr-mode"]').forEach(input => {
input.addEventListener('change', (e) => {
// Check if we're switching from censor bar to blur
const switchingFromCensorToBlur = !state.useBlurMode && e.target.value === 'blur';
// Update state
state.useBlurMode = e.target.value === 'blur';
GM_setValue('useBlurMode', state.useBlurMode);
// Toggle blur intensity control visibility
document.querySelector('.btr-range-slider').style.display =
state.useBlurMode ? 'flex' : 'none';
// Disable transitions when switching from censor to blur
if (switchingFromCensorToBlur) {
disableTransitions();
}
// Update the titles with the new style
updateSpoilerTitles();
// If we disabled transitions, re-enable them after a short delay
if (switchingFromCensorToBlur) {
setTimeout(enableTransitions, 50);
}
});
});
// Set initial display of blur intensity control
document.querySelector('.btr-range-slider').style.display =
state.useBlurMode ? 'flex' : 'none';
}
if (spoilersOption) {
spoilersOption.checked = state.hideSpoilers;
spoilersOption.addEventListener('change', (e) => {
const wasEnabled = state.hideSpoilers;
state.hideSpoilers = e.target.checked;
GM_setValue('hideSpoilers', state.hideSpoilers);
if (wasEnabled && !state.hideSpoilers) {
// Do an aggressive removal of all blurred/censored elements
removeSpoilerConcealment();
document.querySelectorAll(`.${CLASSES.BLUR}, .${CLASSES.CENSOR}`).forEach(el => {
el.classList.remove(CLASSES.BLUR, CLASSES.CENSOR);
});
}
updateSpoilerTitles();
updateToggleButton();
});
}
if (nsfwOption) {
nsfwOption.checked = state.hideNsfw;
nsfwOption.addEventListener('change', (e) => {
const wasEnabled = state.hideNsfw;
state.hideNsfw = e.target.checked;
GM_setValue('hideNsfw', state.hideNsfw);
if (wasEnabled && !state.hideNsfw) {
// Do an aggressive removal of all blurred/censored elements
removeNsfwConcealment();
document.querySelectorAll(`.${CLASSES.BLUR}, .${CLASSES.CENSOR}`).forEach(el => {
el.classList.remove(CLASSES.BLUR, CLASSES.CENSOR);
});
}
updateSpoilerTitles();
updateToggleButton();
});
}
// Blur intensity slider
if (blurIntensitySlider && blurValueDisplay) {
blurIntensitySlider.addEventListener('input', (e) => {
const value = parseInt(e.target.value);
state.blurIntensity = value;
blurValueDisplay.textContent = `${value}px`;
document.documentElement.style.setProperty('--btr-blur-intensity', `${value}px`);
// Debounce saving to avoid excessive writes
clearTimeout(blurIntensitySlider.saveTimeout);
blurIntensitySlider.saveTimeout = setTimeout(() => {
GM_setValue('blurIntensity', value);
updateSpoilerTitles();
}, 300);
});
}
// Close button event listener
const closeButton = document.getElementById('btr-close-settings');
if (closeButton) {
closeButton.addEventListener('click', hideModal);
}
// Reset button event listener
const resetButton = document.getElementById('btr-reset-settings');
if (resetButton) {
resetButton.addEventListener('click', resetSettings);
}
// Right click on toggle button opens settings
cache.button.addEventListener('contextmenu', function(e) {
e.preventDefault(); // Prevent context menu
showModal();
return false;
});
// Add keyboard event listener to close modal on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('active')) {
hideModal();
}
});
}
/**
* Shows the settings modal with animation
*/
function showModal() {
if (!cache.modal || !cache.modalBackdrop) return;
// First make elements visible but not active
cache.modal.style.display = 'block';
cache.modalBackdrop.style.display = 'block';
// Trigger reflow to enable animation
void cache.modal.offsetWidth;
// Then add active class for animation
cache.modal.classList.add('active');
cache.modalBackdrop.classList.add('active');
}
/**
* Hides the settings modal with animation
*/
function hideModal() {
if (!cache.modal || !cache.modalBackdrop) return;
// Remove active classes first (starts animation)
cache.modal.classList.remove('active');
cache.modalBackdrop.classList.remove('active');
// Wait for animation to complete before hiding completely
setTimeout(() => {
cache.modal.style.display = 'none';
cache.modalBackdrop.style.display = 'none';
}, 300);
}
/**
* Resets all settings to defaults
*/
function resetSettings() {
// Default values
const defaults = {
coverEnabled: true,
useBlurMode: true,
hideSpoilers: true,
hideNsfw: false,
blurIntensity: 4
};
// Update state with defaults
Object.assign(state, defaults);
// Save to storage
GM_setValue('coverEnabled', defaults.coverEnabled);
GM_setValue('useBlurMode', defaults.useBlurMode);
GM_setValue('hideSpoilers', defaults.hideSpoilers);
GM_setValue('hideNsfw', defaults.hideNsfw);
GM_setValue('blurIntensity', defaults.blurIntensity);
// Update UI elements
const modal = cache.modal;
if (modal) {
modal.querySelector('input[value="blur"]').checked = defaults.useBlurMode;
modal.querySelector('input[value="censor-bar"]').checked = !defaults.useBlurMode;
modal.querySelector('#btr-hide-spoilers').checked = defaults.hideSpoilers;
modal.querySelector('#btr-hide-nsfw').checked = defaults.hideNsfw;
const blurSlider = modal.querySelector('#btr-blur-intensity');
const blurValue = modal.querySelector('#btr-blur-value');
if (blurSlider && blurValue) {
blurSlider.value = defaults.blurIntensity;
blurValue.textContent = `${defaults.blurIntensity}px`;
}
document.querySelector('.btr-range-slider').style.display = 'flex';
}
// Update CSS variable
document.documentElement.style.setProperty('--btr-blur-intensity', `${defaults.blurIntensity}px`);
// Update page content
updateToggleButton();
updateSpoilerTitles();
// Feedback animation
const resetBtn = document.getElementById('btr-reset-settings');
if (resetBtn) {
resetBtn.textContent = 'Done!';
setTimeout(() => {
resetBtn.textContent = 'Reset';
}, 1000);
}
}
/**
* Temporarily disables transitions for all title elements
*/
function disableTransitions() {
// Get all elements that might have transitions
const elements = document.querySelectorAll(
`${SELECTORS.POST_TITLE}, ${SELECTORS.SEARCH_TITLE}, ${SELECTORS.RELATED_TITLE}`
);
// Add no-transition class to all elements
elements.forEach(el => {
el.classList.add(CLASSES.NO_TRANSITION);
});
state.isStyleTransitionDisabled = true;
}
/**
* Re-enables transitions for all title elements
*/
function enableTransitions() {
// Get all elements with no-transition class
const elements = document.querySelectorAll(`.${CLASSES.NO_TRANSITION}`);
// Remove no-transition class from all elements
elements.forEach(el => {
el.classList.remove(CLASSES.NO_TRANSITION);
});
state.isStyleTransitionDisabled = false;
}
// ==================== CONTENT PROCESSING ====================
function updateSpoilerTitles() {
// Skip processing if no filter is active
if (!state.hideSpoilers && !state.hideNsfw) {
// Make sure to remove any existing effects when both filters are disabled
removeSpoilerConcealment();
removeNsfwConcealment();
return;
}
// Process standard posts (spoilers and NSFW)
document.querySelectorAll(SELECTORS.POST).forEach(post => {
const title = post.querySelector(SELECTORS.POST_TITLE);
if (!title) return;
// Determine if title should be hidden
const isSpoiler = post.hasAttribute('spoiler') && state.hideSpoilers;
const isNsfw = post.hasAttribute('nsfw') && state.hideNsfw;
const shouldHide = state.coverEnabled && (isSpoiler || isNsfw);
// Skip processing if hovering - prevents flickering
if (title.matches(':hover')) return;
// Update classes efficiently by removing both first
title.classList.remove(CLASSES.BLUR, CLASSES.CENSOR);
// Apply appropriate effect if needed
if (shouldHide) {
title.classList.add(state.useBlurMode ? CLASSES.BLUR : CLASSES.CENSOR);
}
});
// Process search results (spoilers and NSFW)
document.querySelectorAll(SELECTORS.SEARCH_POST).forEach(postContainer => {
const spoilerMarker = postContainer.querySelector(SELECTORS.SPOILER_MARKER);
const nsfwMarker = postContainer.querySelector(SELECTORS.NSFW_MARKER);
const titleLink = postContainer.querySelector(SELECTORS.SEARCH_TITLE);
if (!titleLink) return;
// Skip processing ONLY if directly hovering over the title - fixes hover issue
if (titleLink.matches(':hover')) return;
// Determine if title should be hidden
const isSpoiler = spoilerMarker && state.hideSpoilers;
const isNsfw = nsfwMarker && state.hideNsfw;
const shouldHide = state.coverEnabled && (isSpoiler || isNsfw);
// Update classes efficiently
titleLink.classList.remove(CLASSES.BLUR, CLASSES.CENSOR);
if (shouldHide) {
titleLink.classList.add(state.useBlurMode ? CLASSES.BLUR : CLASSES.CENSOR);
}
});
// Process related posts in sidebar (spoilers and NSFW)
document.querySelectorAll(SELECTORS.RELATED_POSTS).forEach(post => {
const spoilerMarker = post.querySelector(SELECTORS.SPOILER_MARKER);
const nsfwMarker = post.querySelector(SELECTORS.NSFW_MARKER);
const title = post.querySelector(SELECTORS.RELATED_TITLE);
if (!title) return;
// Skip processing if hovering - prevents flickering
if (title.matches(':hover')) return;
// Determine if title should be hidden
const isSpoiler = spoilerMarker && state.hideSpoilers;
const isNsfw = nsfwMarker && state.hideNsfw;
const shouldHide = state.coverEnabled && (isSpoiler || isNsfw);
// Update classes efficiently
title.classList.remove(CLASSES.BLUR, CLASSES.CENSOR);
if (shouldHide) {
title.classList.add(state.useBlurMode ? CLASSES.BLUR : CLASSES.CENSOR);
}
});
}
/**
* Explicitly removes concealment effects from all spoiler titles
* when the hideSpoilers setting is toggled off
*/
function removeSpoilerConcealment() {
// Bundle all selector operations for better performance
// Add a more generic selector to catch all elements with concealment classes
const selectors = [
`shreddit-post[spoiler] ${SELECTORS.POST_TITLE}`,
`${SELECTORS.SEARCH_POST} ${SELECTORS.SPOILER_MARKER} ~ ${SELECTORS.SEARCH_TITLE}`,
`${SELECTORS.RELATED_POSTS} ${SELECTORS.SPOILER_MARKER} ~ ${SELECTORS.RELATED_TITLE}`,
// More direct selectors for any element with the classes
`.${CLASSES.BLUR}, .${CLASSES.CENSOR}`
];
// Process all matching elements
document.querySelectorAll(selectors.join(', ')).forEach(element => {
element.classList.remove(CLASSES.BLUR, CLASSES.CENSOR);
});
}
/**
* Explicitly removes concealment effects from all NSFW titles
* when the hideNsfw setting is toggled off
*/
function removeNsfwConcealment() {
// Bundle all selector operations for better performance
// Add a more generic selector to catch all elements with concealment classes
const selectors = [
`shreddit-post[nsfw] ${SELECTORS.POST_TITLE}`,
`${SELECTORS.SEARCH_POST} ${SELECTORS.SPOILER_MARKER} ~ ${SELECTORS.SEARCH_TITLE}`,
`${SELECTORS.RELATED_POSTS} ${SELECTORS.SPOILER_MARKER} ~ ${SELECTORS.RELATED_TITLE}`,
// More direct selectors for any element with the classes
`.${CLASSES.BLUR}, .${CLASSES.CENSOR}`
];
// Process all matching elements
document.querySelectorAll(selectors.join(', ')).forEach(element => {
element.classList.remove(CLASSES.BLUR, CLASSES.CENSOR);
});
}
// ==================== OBSERVERS ====================
/**
* Optimized function to handle all observation setup for better performance
*/
function setupObservers() {
// 1. Theme changes observer (light/dark mode)
const themeObserver = new MutationObserver(() => {
updateCensorBarColor();
updateSpoilerTitles();
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
// 2. Header changes observer (for button persistence)
const headerObserver = new MutationObserver(throttle(() => {
if (!document.contains(cache.button)) {
setupToggleButton();
}
}, 500));
headerObserver.observe(document.body, {
childList: true,
subtree: true
});
// 3. Content changes observer (for updating spoiler/NSFW titles)
// Find the main content container
cache.postFeed = document.querySelector('[data-scroller-first]') ||
document.querySelector('div[data-testid="post-container"]') ||
document.body;
cache.sidebar = document.querySelector('ul.list-none.p-0.m-0');
cache.searchResults = document.querySelector('div[data-testid="search-results-container"]');
// Create a single content observer for all relevant areas
const contentObserver = new MutationObserver(throttle(() => {
updateSpoilerTitles();
}, 300));
// Observe the main content area
contentObserver.observe(cache.postFeed, {
childList: true,
subtree: true
});
// Observe sidebar if present
if (cache.sidebar) {
contentObserver.observe(cache.sidebar, {
childList: true,
subtree: true
});
}
// Observe search results if present
if (cache.searchResults) {
contentObserver.observe(cache.searchResults, {
childList: true,
subtree: true
});
}
// 4. Dynamic content observer (for sidebar and search results that appear later)
const dynamicContentObserver = new MutationObserver(throttle((mutations) => {
// Check for new sidebar or search results
const newSidebar = !cache.sidebar && document.querySelector('ul.list-none.p-0.m-0');
const newSearchResults = !cache.searchResults && document.querySelector('div[data-testid="search-results-container"]');
if (newSidebar) {
cache.sidebar = newSidebar;
contentObserver.observe(cache.sidebar, {
childList: true,
subtree: true
});
}
if (newSearchResults) {
cache.searchResults = newSearchResults;
contentObserver.observe(cache.searchResults, {
childList: true,
subtree: true
});
}
// Run update after detecting new content areas
if (newSidebar || newSearchResults) {
updateSpoilerTitles();
}
}, 500));
dynamicContentObserver.observe(document.body, {
childList: true,
subtree: true
});
}
/**
* Improved throttle function with leading and trailing options
*/
function throttle(func, wait, options = {}) {
let context, args, result;
let timeout = null;
let previous = 0;
const later = function() {
previous = options.leading === false ? 0 : Date.now();
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function() {
const now = Date.now();
if (!previous && options.leading === false) previous = now;
const remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
return result;
};
}
/**
* Handles navigation events (page changes within Reddit SPA)
*/
function setupNavigationHandlers() {
// Handle pushState and replaceState
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function() {
originalPushState.apply(this, arguments);
handleNavigation();
};
history.replaceState = function() {
originalReplaceState.apply(this, arguments);
handleNavigation();
};
// Handle popstate event (back/forward browser navigation)
window.addEventListener('popstate', handleNavigation);
// Store last URL to detect actual navigation
let lastUrl = location.href;
function handleNavigation() {
// Check if URL actually changed (avoid unnecessary processing)
if (lastUrl === location.href) return;
lastUrl = location.href;
// Slight delay to allow Reddit to update the DOM
setTimeout(() => {
// Re-check sidebar and search results
cache.sidebar = document.querySelector('ul.list-none.p-0.m-0');
cache.searchResults = document.querySelector('div[data-testid="search-results-container"]');
// Ensure button is present after navigation
setupToggleButton();
// Force clean all spoiler/NSFW concealment
removeSpoilerConcealment();
removeNsfwConcealment();
// Determine if we need a full reset based on settings changes
const pageLoadTime = Date.parse(performance.getEntriesByType("navigation")[0]?.startTime || Date.now());
const settingsChangedSincePageLoad = state.lastSettingsUpdate > pageLoadTime;
if (settingsChangedSincePageLoad) {
// Force clean and reapply
removeSpoilerConcealment();
removeNsfwConcealment();
}
// Always update for consistency
updateSpoilerTitles();
}, 300);
}
}
// ==================== INITIALIZATION ====================
function init() {
// Setup UI components
setupStyles();
updateCensorBarColor();
setupToggleButton();
createSettingsModal();
// Register menu command
GM_registerMenuCommand('Reddit Spoiler/NSFW Settings', showModal);
// Setup observers and event handlers
setupObservers();
setupNavigationHandlers();
// Initial update
updateSpoilerTitles();
console.log('Reddit Spoiler/NSFW Hider initialized');
}
// Start the script after a slight delay to ensure Reddit is fully loaded
setTimeout(init, 100);
})();