// ==UserScript==
// @name 8chan Lightweight Extended Suite
// @namespace https://greasyfork.org/en/scripts/533173
// @version 2.1
// @description Spoiler revealer for 8chan with nested replies
// @author impregnator
// @match https://8chan.moe/*
// @match https://8chan.se/*
// @match https://8chan.cc/*
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// Function to process images and replace spoiler placeholders with thumbnails
function processImages(images, isCatalog = false) {
images.forEach(img => {
// Check if the image is a spoiler placeholder (custom or default)
if (img.src.includes('custom.spoiler') || img.src.includes('spoiler.png')) {
let fullFileUrl;
if (isCatalog) {
// Catalog: Get the href from the parent <a class="linkThumb">
const link = img.closest('a.linkThumb');
if (link) {
// Construct the thumbnail URL based on the thread URL
fullFileUrl = link.href;
const threadMatch = fullFileUrl.match(/\/([a-z0-9]+)\/res\/([0-9]+)\.html$/i);
if (threadMatch && threadMatch[1] && threadMatch[2]) {
const board = threadMatch[1];
const threadId = threadMatch[2];
// Fetch the thread page to find the actual image URL
fetchThreadImage(board, threadId).then(thumbnailUrl => {
if (thumbnailUrl) {
img.src = thumbnailUrl;
}
});
}
}
} else {
// Thread: Get the parent <a> element containing the full-sized file URL
const link = img.closest('a.imgLink');
if (link) {
// Extract the full-sized file URL
fullFileUrl = link.href;
// Extract the file hash (everything after /.media/ up to the extension)
const fileHash = fullFileUrl.match(/\/\.media\/([a-f0-9]+)\.[a-z0-9]+$/i);
if (fileHash && fileHash[1]) {
// Construct the thumbnail URL using the current domain
const thumbnailUrl = `${window.location.origin}/.media/t_${fileHash[1]}`;
// Replace the spoiler image with the thumbnail
img.src = thumbnailUrl;
}
}
}
}
});
}
// Function to fetch the thread page and extract the thumbnail URL
async function fetchThreadImage(board, threadId) {
try {
const response = await fetch(`https://${window.location.host}/${board}/res/${threadId}.html`);
const text = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
// Find the first image in the thread's OP post
const imgLink = doc.querySelector('.uploadCell a.imgLink');
if (imgLink) {
const fullFileUrl = imgLink.href;
const fileHash = fullFileUrl.match(/\/\.media\/([a-f0-9]+)\.[a-z0-9]+$/i);
if (fileHash && fileHash[1]) {
return `${window.location.origin}/.media/t_${fileHash[1]}`;
}
}
return null;
} catch (error) {
console.error('Error fetching thread image:', error);
return null;
}
}
// Process existing images on page load
const isCatalogPage = window.location.pathname.includes('catalog.html');
if (isCatalogPage) {
const initialCatalogImages = document.querySelectorAll('.catalogCell a.linkThumb img');
processImages(initialCatalogImages, true);
} else {
const initialThreadImages = document.querySelectorAll('.uploadCell img');
processImages(initialThreadImages, false);
}
// Set up MutationObserver to handle dynamically added posts
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.addedNodes.length) {
// Check each added node for new images
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (isCatalogPage) {
const newCatalogImages = node.querySelectorAll('.catalogCell a.linkThumb img');
processImages(newCatalogImages, true);
} else {
const newThreadImages = node.querySelectorAll('.uploadCell img');
processImages(newThreadImages, false);
}
}
});
}
});
});
// Observe changes to the document body, including child nodes and subtrees
observer.observe(document.body, {
childList: true,
subtree: true
});
})();
//Opening all posts from the catalog in a new tag section
// Add click event listener to catalog thumbnail images
document.addEventListener('click', function(e) {
// Check if the clicked element is an image inside a catalog cell
if (e.target.tagName === 'IMG' && e.target.closest('.catalogCell')) {
// Find the parent link with class 'linkThumb'
const link = e.target.closest('.linkThumb');
if (link) {
// Prevent default link behavior
e.preventDefault();
// Open the thread in a new tab
window.open(link.href, '_blank');
}
}
});
//Automatically redirect to catalog section
// Redirect to catalog if on a board's main page, excluding overboard pages
(function() {
const currentPath = window.location.pathname;
// Check if the path matches a board's main page (e.g., /v/, /a/) but not overboard pages
if (currentPath.match(/^\/[a-zA-Z0-9]+\/$/) && !currentPath.match(/^\/(sfw|overboard)\/$/)) {
// Redirect to the catalog page
window.location.replace(currentPath + 'catalog.html');
}
})();
//Text spoiler revealer
// Automatically reveal all spoilers
(function() {
// Find all elements with class 'spoiler'
const spoilers = document.querySelectorAll('span.spoiler');
spoilers.forEach(spoiler => {
// Override default spoiler styles to make text visible
spoiler.style.background = 'none';
spoiler.style.color = 'inherit';
spoiler.style.textShadow = 'none';
});
})();
//Inline reply chains
(function() {
'use strict';
console.log('Userscript is running');
// Add CSS for visual nesting
const style = document.createElement('style');
style.innerHTML = `
.inlineQuote .replyPreview {
margin-left: 20px;
border-left: 1px solid #ccc;
padding-left: 10px;
}
.closeInline {
color: #ff0000;
cursor: pointer;
margin-left: 5px;
font-weight: bold;
}
`;
document.head.appendChild(style);
// Wait for tooltips to initialize
window.addEventListener('load', function() {
if (!window.tooltips) {
console.error('tooltips module not found');
return;
}
console.log('tooltips module found');
// Ensure Inline Replies is enabled
if (!tooltips.inlineReplies) {
console.log('Enabling Inline Replies');
localStorage.setItem('inlineReplies', 'true');
tooltips.inlineReplies = true;
// Check and update the checkbox, retrying if not yet loaded
const enableCheckbox = () => {
const inlineCheckbox = document.getElementById('settings-SW5saW5lIFJlcGxpZX');
if (inlineCheckbox) {
inlineCheckbox.checked = true;
console.log('Inline Replies checkbox checked');
return true;
}
console.warn('Inline Replies checkbox not found, retrying...');
return false;
};
// Try immediately
if (!enableCheckbox()) {
// Retry every 500ms up to 5 seconds
let attempts = 0;
const maxAttempts = 10;
const interval = setInterval(() => {
if (enableCheckbox() || attempts >= maxAttempts) {
clearInterval(interval);
if (attempts >= maxAttempts) {
console.error('Failed to find Inline Replies checkbox after retries');
}
}
attempts++;
}, 500);
}
} else {
console.log('Inline Replies already enabled');
}
// Override addLoadedTooltip to ensure replyPreview exists
const originalAddLoadedTooltip = tooltips.addLoadedTooltip;
tooltips.addLoadedTooltip = function(htmlContents, tooltip, quoteUrl, replyId, isInline) {
console.log('addLoadedTooltip called for:', quoteUrl);
originalAddLoadedTooltip.apply(this, arguments);
if (isInline) {
let replyPreview = htmlContents.querySelector('.replyPreview');
if (!replyPreview) {
replyPreview = document.createElement('div');
replyPreview.className = 'replyPreview';
htmlContents.appendChild(replyPreview);
}
}
};
// Override addInlineClick for nested replies, excluding post number links
tooltips.addInlineClick = function(quote, innerPost, isBacklink, quoteTarget, sourceId) {
// Skip post number links (href starts with #q)
if (quote.href.includes('#q')) {
console.log('Skipping post number link:', quote.href);
return;
}
// Remove existing listeners by cloning
const newQuote = quote.cloneNode(true);
quote.parentNode.replaceChild(newQuote, quote);
quote = newQuote;
// Reapply hover events to preserve preview functionality
tooltips.addHoverEvents(quote, innerPost, quoteTarget, sourceId);
console.log('Hover events reapplied for:', quoteTarget.quoteUrl);
// Add click handler
quote.addEventListener('click', function(e) {
console.log('linkQuote clicked:', quoteTarget.quoteUrl);
if (!tooltips.inlineReplies) {
console.log('inlineReplies disabled');
return;
}
e.preventDefault();
e.stopPropagation(); // Prevent site handlers
// Find or create replyPreview
let replyPreview = innerPost.querySelector('.replyPreview');
if (!replyPreview) {
replyPreview = document.createElement('div');
replyPreview.className = 'replyPreview';
innerPost.appendChild(replyPreview);
}
// Check for duplicates or loading
if (tooltips.loadingPreviews[quoteTarget.quoteUrl] ||
tooltips.quoteAlreadyAdded(quoteTarget.quoteUrl, innerPost)) {
console.log('Duplicate or loading:', quoteTarget.quoteUrl);
return;
}
// Create and load inline post
const placeHolder = document.createElement('div');
placeHolder.style.whiteSpace = 'normal';
placeHolder.className = 'inlineQuote';
tooltips.loadTooltip(placeHolder, quoteTarget.quoteUrl, sourceId, true);
// Verify post loaded
if (!placeHolder.querySelector('.linkSelf')) {
console.log('Failed to load post:', quoteTarget.quoteUrl);
return;
}
// Add close button
const close = document.createElement('a');
close.innerText = 'X';
close.className = 'closeInline';
close.onclick = () => placeHolder.remove();
placeHolder.querySelector('.postInfo').prepend(close);
// Process quotes in the new inline post
Array.from(placeHolder.querySelectorAll('.linkQuote'))
.forEach(a => tooltips.processQuote(a, false, true));
if (tooltips.bottomBacklinks) {
const alts = placeHolder.querySelector('.altBacklinks');
if (alts && alts.firstChild) {
Array.from(alts.firstChild.children)
.forEach(a => tooltips.processQuote(a, true));
}
}
// Append to replyPreview
replyPreview.appendChild(placeHolder);
console.log('Inline post appended:', quoteTarget.quoteUrl);
tooltips.removeIfExists();
}, true); // Use capture phase
};
// Reprocess all existing linkQuote and backlink elements, excluding post numbers
console.log('Reprocessing linkQuote elements');
const quotes = document.querySelectorAll('.linkQuote, .panelBacklinks a');
quotes.forEach(quote => {
const innerPost = quote.closest('.innerPost, .innerOP');
if (!innerPost) {
console.log('No innerPost found for quote:', quote.href);
return;
}
// Skip post number links
if (quote.href.includes('#q')) {
console.log('Skipping post number link:', quote.href);
return;
}
const isBacklink = quote.parentElement.classList.contains('panelBacklinks') ||
quote.parentElement.classList.contains('altBacklinks');
const quoteTarget = api.parsePostLink(quote.href);
const sourceId = api.parsePostLink(innerPost.querySelector('.linkSelf').href).post;
tooltips.addInlineClick(quote, innerPost, isBacklink, quoteTarget, sourceId);
});
// Observe for dynamically added posts
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType !== 1) return;
const newQuotes = node.querySelectorAll('.linkQuote, .panelBacklinks a');
newQuotes.forEach(quote => {
if (quote.dataset.processed || quote.href.includes('#q')) {
if (quote.href.includes('#q')) {
console.log('Skipping post number link:', quote.href);
}
return;
}
quote.dataset.processed = 'true';
const innerPost = quote.closest('.innerPost, .innerOP');
if (!innerPost) return;
const isBacklink = quote.parentElement.classList.contains('panelBacklinks') ||
quote.parentElement.classList.contains('altBacklinks');
const quoteTarget = api.parsePostLink(quote.href);
const sourceId = api.parsePostLink(innerPost.querySelector('.linkSelf').href).post;
tooltips.addInlineClick(quote, innerPost, isBacklink, quoteTarget, sourceId);
});
});
});
});
observer.observe(document.querySelector('.divPosts') || document.body, {
childList: true,
subtree: true
});
console.log('MutationObserver set up');
});
})();