// ==UserScript==
// @name 8chan Hiding Enhancer
// @namespace nipah-scripts-8chan
// @version 1.5.1
// @description Hides backlinks pointing to hidden posts, prevents hover tooltips and adds strikethrough to quotelinks, and adds recursive hiding/filtering options on 8chan.moe/se. Also adds unhiding options.
// @author nipah, Gemini
// @license MIT
// @match https://8chan.moe/*/res/*.html*
// @match https://8chan.se/*/res/*.html*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
const SCRIPT_NAME = 'Hiding Enhancer';
const BACKLINK_SELECTOR = '.panelBacklinks a, .altBacklinks a';
const QUOTE_LINK_SELECTOR = '.quoteLink';
const ALL_LINK_SELECTORS = `${BACKLINK_SELECTOR}, ${QUOTE_LINK_SELECTOR}`;
const POST_CONTAINER_SELECTOR = '.opCell, .postCell';
const INNER_POST_SELECTOR = '.innerOP, .innerPost'; // Selector for the inner content div
const THREAD_CONTAINER_SELECTOR = '#divThreads'; // Container for all posts in the thread
const HIDDEN_CLASS = 'hidden'; // Class added to the container when hidden by hiding.js
const HIDDEN_QUOTE_CLASS = 'hidden-quote'; // Class to add to quote links for hidden posts
const TOOLTIP_SELECTOR = '.quoteTooltip'; // Selector for the tooltip element
const HIDE_BUTTON_SELECTOR = '.hideButton'; // Selector for the hide menu button
const HIDE_MENU_SELECTOR = '.floatingList.extraMenu'; // Selector for the hide menu dropdown
const LABEL_ID_SELECTOR = '.labelId'; // Selector for the post ID label
const UNHIDE_BUTTON_SELECTOR = '.unhideButton'; // Selector for the site's unhide button
const log = (...args) => console.log(`[${SCRIPT_NAME}]`, ...args);
const warn = (...args) => console.warn(`[${SCRIPT_NAME}]`, ...args);
const error = (...args) => console.error(`[${SCRIPT_NAME}]`, ...args);
let debounceTimer = null;
const DEBOUNCE_DELAY = 250; // ms
/**
* Injects custom CSS styles into the document head.
*/
function addCustomStyles() {
const style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = `
.${HIDDEN_QUOTE_CLASS} {
text-decoration: line-through !important;
}
/* Style for the dynamically added menu items */
${HIDE_MENU_SELECTOR} li[data-action^="hide-recursive"],
${HIDE_MENU_SELECTOR} li[data-action^="filter-id-recursive"],
${HIDE_MENU_SELECTOR} li[data-action="show-id"],
${HIDE_MENU_SELECTOR} li[data-action="show-all"] {
cursor: pointer;
}
`;
document.head.appendChild(style);
log('Custom styles injected.');
}
/**
* Extracts the target post ID from a link's href attribute.
* Works for both backlinks and quote links.
* @param {HTMLAnchorElement} linkElement - The link <a> element.
* @returns {string|null} The target post ID as a string, or null if not found.
*/
function getTargetPostIdFromLink(linkElement) {
if (!linkElement || !linkElement.href) {
return null;
}
// Match the post number after the last '#'
const match = linkElement.href.match(/#(\d+)$/);
// Only return numeric post ID
return match ? match[1] : null;
}
/**
* Checks if a post is currently hidden based on its ID.
* @param {string} postId - The ID of the post to check.
* @returns {boolean} True if the post is hidden, false otherwise.
*/
function isPostHidden(postId) {
if (!postId) return false;
const postContainer = document.getElementById(postId);
if (!postContainer) return false;
// Check if the main container (.opCell or .postCell) is hidden (can happen with thread hiding)
if (postContainer.classList.contains(HIDDEN_CLASS)) {
return true;
}
// Check if the inner content container (.innerOP or .innerPost) is hidden (common for post hiding)
const innerContent = postContainer.querySelector(INNER_POST_SELECTOR);
return innerContent ? innerContent.classList.contains(HIDDEN_CLASS) : false;
}
/**
* Updates the visibility or style of a single link based on its target post's hidden status.
* Handles both backlinks and quote links.
* @param {HTMLAnchorElement} linkElement - The link <a> element to update.
*/
function updateLinkVisibility(linkElement) {
const targetPostId = getTargetPostIdFromLink(linkElement);
// Ensure it's a numeric post ID link
if (!targetPostId) return;
const hidden = isPostHidden(targetPostId);
if (linkElement.classList.contains('quoteLink')) {
// It's a quote link, apply strikethrough
if (hidden) {
linkElement.classList.add(HIDDEN_QUOTE_CLASS);
// // log(`Adding strikethrough to quote link ${linkElement.href} pointing to hidden post ${targetPostId}`);
} else {
linkElement.classList.remove(HIDDEN_QUOTE_CLASS);
// // log(`Removing strikethrough from quote link ${linkElement.href} pointing to visible post ${targetPostId}`);
}
} else {
// It's a backlink, hide/show the element
if (hidden) {
linkElement.style.display = 'none';
// // log(`Hiding backlink ${linkElement.href} pointing to hidden post ${targetPostId}`);
} else {
// Reset display.
linkElement.style.display = '';
// // log(`Showing backlink ${linkElement.href} pointing to visible post ${targetPostId}`);
}
}
}
/**
* Iterates through all relevant links (backlinks and quote links) on the page and updates their visibility/style.
*/
function updateAllLinks() {
log('Updating all link visibility/style...');
const links = document.querySelectorAll(ALL_LINK_SELECTORS);
links.forEach(updateLinkVisibility);
log(`Checked ${links.length} links.`);
}
/**
* Debounced version of updateAllLinks.
*/
function debouncedUpdateAllLinks() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(updateAllLinks, DEBOUNCE_DELAY);
}
/**
* Overrides the site's tooltips.loadTooltip function to prevent tooltips for hidden posts.
*/
function overrideLoadTooltip() {
// Check if tooltips object and loadTooltip function exist
if (typeof tooltips === 'undefined' || typeof tooltips.loadTooltip !== 'function') {
// Not ready, try again later
setTimeout(overrideLoadTooltip, 100);
return;
}
const originalLoadTooltip = tooltips.loadTooltip;
tooltips.loadTooltip = function(tooltip, quoteUrl, replyId, isInline) {
// Only intercept hover tooltips (isInline is false for hover tooltips)
if (!isInline) {
const matches = quoteUrl.match(/\/(\w+)\/res\/(\d+)\.html\#(\d+)/);
const targetPostId = matches ? matches[3] : null; // Post ID is the 3rd group
if (targetPostId && isPostHidden(targetPostId)) {
log(`Preventing hover tooltip for quote to hidden post ${targetPostId}`);
// Remove the tooltip element that was just created by the site's code
if (tooltip && tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
}
// Clear the site's internal reference if it points to the removed tooltip
// This is important so tooltips.removeIfExists doesn't try to remove it again
if (tooltips.currentTooltip === tooltip) {
tooltips.currentTooltip = null;
}
// Prevent the original function from running
return;
}
}
// If it's an inline quote OR the target post is not hidden, call the original function
originalLoadTooltip.apply(this, arguments);
};
log('tooltips.loadTooltip overridden to prevent tooltips for hidden posts.');
}
/**
* Implements the recursive hiding logic using the site's hiding.hidePost function.
* Hides a specific post and all its replies recursively.
* @param {string} startPostId - The ID of the post to start hiding from.
*/
function hidePostAndRepliesRecursivelyUserscript(startPostId) {
// Ensure site objects are available
if (typeof hiding === 'undefined' || typeof hiding.hidePost !== 'function' || typeof tooltips === 'undefined' || typeof tooltips.knownPosts === 'undefined') {
error('Site hiding or tooltips objects not available. Cannot perform recursive hide.');
return;
}
const boardUri = window.location.pathname.split('/')[1]; // Get boardUri from URL
function recursiveHide(currentPostId) {
const postElement = document.getElementById(currentPostId);
if (!postElement) {
// Post element not found (might be filtered or not loaded)
// log(`Post element ${currentPostId} not found.`);
return;
}
// Check if the post already has the site's unhide button, indicating it's already hidden
if (postElement.querySelector(UNHIDE_BUTTON_SELECTOR)) {
// log(`Post ${currentPostId} is already hidden (unhide button found). Skipping.`);
} else {
const linkSelf = postElement.querySelector('.linkSelf');
if (linkSelf) {
log(`Hiding post ${currentPostId}`);
// Call the site's hidePost function
hiding.hidePost(linkSelf);
} else {
warn(`Could not find .linkSelf for post ${currentPostId}. Cannot hide.`);
}
}
// Find replies using the site's tooltips.knownPosts structure
const knownPost = tooltips.knownPosts[boardUri]?.[currentPostId];
if (!knownPost || !knownPost.added || knownPost.added.length === 0) {
// log(`No known replies for post ${currentPostId}. Stopping recursion.`);
return; // No replies or post not found in knownPosts
}
// Recursively hide replies
knownPost.added.forEach((replyString) => {
const [replyBoard, replyId] = replyString.split('_');
// Only hide replies within the same board and thread
// The site's knownPosts structure seems to only track replies within the same thread anyway
if (replyBoard === boardUri) {
recursiveHide(replyId);
}
});
}
// Start the recursive hiding process
log(`Starting recursive hide from post ${startPostId}`);
recursiveHide(startPostId);
log(`Finished recursive hide from post ${startPostId}`);
// After hiding is done, trigger a link update to reflect changes
debouncedUpdateAllLinks();
}
/**
* Implements the recursive filtering logic.
* Adds an ID filter and recursively hides replies for all posts matching that ID.
* @param {string} targetId - The raw ID string (e.g., '0feed1') to filter by.
* @param {string} clickedPostId - The ID of the post whose menu was clicked (used for context/logging).
*/
function filterIdAndHideAllMatchingAndReplies(targetId, clickedPostId) {
// Ensure site objects are available
if (typeof settingsMenu === 'undefined' || typeof settingsMenu.createFilter !== 'function' || typeof hiding === 'undefined' || typeof hiding.hidePost !== 'function' || typeof tooltips === 'undefined' || typeof tooltips.knownPosts === 'undefined' || typeof hiding.buildPostFilterId !== 'function') {
error('Site settingsMenu, hiding, tooltips, or hiding.buildPostFilterId objects not available. Cannot perform recursive ID filter.');
return;
}
const boardUri = window.location.pathname.split('/')[1];
const threadId = window.location.pathname.split('/')[3].split('.')[0]; // Extract thread ID from URL
// Find the linkSelf element for the clicked post to pass to buildPostFilterId
const clickedPostElement = document.getElementById(clickedPostId);
let formattedFilterString = targetId; // Fallback to raw ID
if (clickedPostElement) {
const linkSelf = clickedPostElement.querySelector('.linkSelf');
if (linkSelf) {
// Use the site's function to get the formatted ID string
formattedFilterString = hiding.buildPostFilterId(linkSelf, targetId);
} else {
warn(`Could not find .linkSelf for clicked post ${clickedPostId}. Using raw ID for filter.`);
}
} else {
warn(`Could not find clicked post element ${clickedPostId}. Using raw ID for filter.`);
}
log(`Applying Filter ID++ for ID: ${targetId} (formatted as "${formattedFilterString}") triggered from post ${clickedPostId})`);
// 1. Add the ID filter using the site's function
// Type 4 is for filtering by ID
settingsMenu.createFilter(formattedFilterString, false, 4);
log(`Added filter for ID: ${formattedFilterString}`);
// Give the site's filter logic a moment to apply the 'hidden' class
// Then find all posts with this ID and recursively hide their replies
setTimeout(() => {
const allPosts = document.querySelectorAll(POST_CONTAINER_SELECTOR);
allPosts.forEach(postElement => {
const postIdLabel = postElement.querySelector(LABEL_ID_SELECTOR);
const currentPostId = postElement.id;
// Check if the post matches the target ID
if (postIdLabel && postIdLabel.textContent === targetId) {
log(`Found post ${currentPostId} matching ID ${targetId}. Recursively hiding its replies.`);
// Call the recursive hide function starting from this post.
// hidePostAndRepliesRecursivelyUserscript will handle hiding the post itself
// (if not already hidden by the filter) and its replies.
hidePostAndRepliesRecursivelyUserscript(currentPostId);
}
});
// After hiding is done, trigger a link update to reflect changes
// This is already handled by hidePostAndRepliesRecursivelyUserscript,
// but calling it again here after the loop ensures all changes are caught.
debouncedUpdateAllLinks();
}, DEBOUNCE_DELAY + 50); // Wait slightly longer than the debounce delay
}
/**
* Removes all filters associated with a specific raw ID from the site's settings.
* @param {string} targetId - The raw ID string (e.g., '0feed1') to remove filters for.
* @param {string} clickedPostId - The ID of the post whose menu was clicked (used for context/logging).
*/
function removeIdFilters(targetId, clickedPostId) {
// Ensure site objects are available
if (typeof settingsMenu === 'undefined' || typeof settingsMenu.loadedFilters === 'undefined' || typeof hiding === 'undefined' || typeof hiding.checkFilters !== 'function' || typeof hiding.buildPostFilterId !== 'function') {
error('Site settingsMenu, hiding, or hiding.buildPostFilterId objects not available. Cannot remove ID filters.');
return;
}
const boardUri = window.location.pathname.split('/')[1];
const threadId = window.location.pathname.split('/')[3].split('.')[0]; // Extract thread ID from URL
// Find the linkSelf element for the clicked post to pass to buildPostFilterId
const clickedPostElement = document.getElementById(clickedPostId);
let formattedFilterString = targetId; // Fallback to raw ID
if (clickedPostElement) {
const linkSelf = clickedPostElement.querySelector('.linkSelf');
if (linkSelf) {
// Use the site's function to get the formatted ID string
formattedFilterString = hiding.buildPostFilterId(linkSelf, targetId);
} else {
warn(`Could not find .linkSelf for clicked post ${clickedPostId}. Using raw ID for filter removal check.`);
}
} else {
warn(`Could not find clicked post element ${clickedPostId}. Using raw ID for filter removal check.`);
}
log(`Attempting to remove filters for ID: ${targetId} (formatted as "${formattedFilterString}") triggered from post ${clickedPostId})`);
// Filter out the matching filters
const initialFilterCount = settingsMenu.loadedFilters.length;
settingsMenu.loadedFilters = settingsMenu.loadedFilters.filter(filter => {
// Check if it's an ID filter (type 4 or 5) and if the filter content matches the formatted ID string
return !( (filter.type === 4 || filter.type === 5) && filter.filter === formattedFilterString );
});
const removedCount = initialFilterCount - settingsMenu.loadedFilters.length;
if (removedCount > 0) {
log(`Removed ${removedCount} filter(s) for ID: ${formattedFilterString}`);
// Update localStorage
localStorage.setItem('filterData', JSON.stringify(settingsMenu.loadedFilters));
// Trigger the site's filter update
hiding.checkFilters();
log('Triggered site filter update.');
} else {
log(`No filters found for ID: ${formattedFilterString} to remove.`);
}
// After removing filters, trigger a link update to reflect changes (posts might become visible)
debouncedUpdateAllLinks();
}
/**
* Removes all ID filters and manual hides for the current thread.
*/
function showAllInThread() {
// Ensure site objects are available
if (typeof settingsMenu === 'undefined' || typeof settingsMenu.loadedFilters === 'undefined' || typeof hiding === 'undefined' || typeof hiding.checkFilters !== 'function' || typeof hiding.buildPostFilterId !== 'function') {
error('Site settingsMenu, hiding, or hiding.buildPostFilterId objects not available. Cannot show all in thread.');
return;
}
const boardUri = window.location.pathname.split('/')[1];
const threadId = window.location.pathname.split('/')[3].split('.')[0]; // Extract thread ID from URL
log(`Attempting to show all posts in thread /${boardUri}/res/${threadId}.html`);
let filtersRemoved = 0;
let unhideButtonsClicked = 0;
// 1. Find and click all existing unhide buttons in the current thread
log('Searching for and clicking existing unhide buttons...');
const allPostsInThread = document.querySelectorAll(POST_CONTAINER_SELECTOR);
allPostsInThread.forEach(postElement => {
const postId = postElement.id;
if (!postId) return; // Skip if element has no ID
let unhideButton = null;
if (postId === threadId) {
// For the thread (OP), the button is the previous sibling
unhideButton = postElement.previousElementSibling;
if (!unhideButton || !unhideButton.matches(UNHIDE_BUTTON_SELECTOR)) {
unhideButton = null; // Reset if not found or doesn't match
}
} else {
// For regular posts, the button is inside the post container
unhideButton = postElement.querySelector(UNHIDE_BUTTON_SELECTOR);
}
if (unhideButton) {
log(`Clicking unhide button for ${postId}`);
unhideButton.click();
unhideButtonsClicked++;
}
});
log(`Clicked ${unhideButtonsClicked} unhide button(s).`);
// 2. Remove ID filters specific to this thread from settingsMenu
const initialFilterCount = settingsMenu.loadedFilters.length;
settingsMenu.loadedFilters = settingsMenu.loadedFilters.filter(filter => {
// Check if it's an ID filter (type 4 or 5) and if the filter content starts with the board-thread prefix
const isThreadIdFilter = (filter.type === 4 || filter.type === 5) && filter.filter.startsWith(`${boardUri}-${threadId}-`);
if (isThreadIdFilter) {
filtersRemoved++;
}
return !isThreadIdFilter;
});
if (filtersRemoved > 0) {
log(`Removed ${filtersRemoved} ID filter(s) specific to this thread.`);
// Update localStorage for filters
localStorage.setItem('filterData', JSON.stringify(settingsMenu.loadedFilters));
} else {
log('No ID filters specific to this thread found to remove.');
}
// 3. Trigger the site's filter update AFTER a short delay to allow button clicks to process
// and for the filter removal to take effect.
setTimeout(() => {
hiding.checkFilters();
log('Triggered site filter update after delay.');
// 5. Trigger userscript link update
debouncedUpdateAllLinks();
log('Finished "Show All" action.');
}, 100); // 100ms delay
}
/**
* Adds the custom "Hide post++", "Filter ID++", "Show ID", and "Show All" options to a hide button's menu when it appears.
* Uses a MutationObserver to detect when the menu is added to the button.
* @param {HTMLElement} hideButton - The hide button element.
*/
function addCustomHideMenuOptions(hideButton) {
// Create a new observer for each hide button
// This observer will stay active for the lifetime of the hideButton element
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
for (const addedNode of mutation.addedNodes) {
// Check if the added node is the menu we're looking for
if (addedNode.nodeType === Node.ELEMENT_NODE && addedNode.matches(HIDE_MENU_SELECTOR)) {
// Check if this menu is a child of the target hideButton
if (hideButton.contains(addedNode)) {
// Menu appeared, now add the custom options if they're not already there
const menuUl = addedNode.querySelector('ul');
if (menuUl) {
const postContainer = hideButton.closest(POST_CONTAINER_SELECTOR);
const postId = postContainer ? postContainer.id : null;
const isOP = postContainer ? postContainer.classList.contains('opCell') : false;
const postIdLabel = postContainer ? postContainer.querySelector(LABEL_ID_SELECTOR) : null;
const postIDText = postIdLabel ? postIdLabel.textContent : null;
// Find anchor points for insertion
const hidePostPlusItem = Array.from(menuUl.querySelectorAll('li')).find(li => li.textContent.trim() === 'Hide post+');
const filterIdPlusItem = Array.from(menuUl.querySelectorAll('li')).find(li => li.textContent.trim() === 'Filter ID+');
// Keep track of the last item we inserted after
let lastInsertedAfter = null;
// --- Add "Hide post++" ---
// Only add for reply posts and if it doesn't exist
if (!isOP && postId && !menuUl.querySelector('li[data-action="hide-recursive"]')) {
const hideRecursiveItem = document.createElement('li');
hideRecursiveItem.textContent = 'Hide post++';
hideRecursiveItem.dataset.action = 'hide-recursive';
hideRecursiveItem.addEventListener('click', (event) => {
log(`'Hide post++' clicked for post ${postId}`);
hidePostAndRepliesRecursivelyUserscript(postId);
});
// Insert after "Hide post+" if found
if (hidePostPlusItem) {
hidePostPlusItem.after(hideRecursiveItem);
lastInsertedAfter = hideRecursiveItem;
log(`Added 'Hide post++' option after 'Hide post+' for post ${postId}.`);
} else {
// Fallback: append to the end if "Hide post+" isn't found (shouldn't happen for replies)
menuUl.appendChild(hideRecursiveItem);
lastInsertedAfter = hideRecursiveItem;
warn(`'Hide post+' not found for post ${postId}. Appended 'Hide post++' to end.`);
}
}
// --- Add "Filter ID++" ---
// Only add if the post has an ID and it doesn't exist
if (postIDText && !menuUl.querySelector('li[data-action="filter-id-recursive"]')) {
const filterIdRecursiveItem = document.createElement('li');
filterIdRecursiveItem.textContent = 'Filter ID++';
filterIdRecursiveItem.dataset.action = 'filter-id-recursive';
filterIdRecursiveItem.addEventListener('click', (event) => {
filterIdAndHideAllMatchingAndReplies(postIDText, postId);
});
// Insert after "Filter ID+" if it exists, otherwise after the last item we added ("Hide post++")
if (filterIdPlusItem) {
filterIdPlusItem.after(filterIdRecursiveItem);
lastInsertedAfter = filterIdRecursiveItem;
log(`Added 'Filter ID++' option after 'Filter ID+' for post ${postId}.`);
} else if (lastInsertedAfter) { // If Hide post++ was added
lastInsertedAfter.after(filterIdRecursiveItem);
lastInsertedAfter = filterIdRecursiveItem;
warn(`'Filter ID+' not found for post ${postId}. Appended 'Filter ID++' after last added item.`);
} else {
// Fallback: append to the end if neither "Filter ID+" nor "Hide post++" were present/added
menuUl.appendChild(filterIdRecursiveItem);
lastInsertedAfter = filterIdRecursiveItem;
warn(`Neither 'Filter ID+' nor previous custom item found for post ${postId}. Appended 'Filter ID++' to end.`);
}
}
// --- Add "Show ID" ---
// Only add if the post has an ID and it doesn't exist
if (postIDText && !menuUl.querySelector('li[data-action="show-id"]')) {
const showIdItem = document.createElement('li');
showIdItem.textContent = 'Show ID';
showIdItem.dataset.action = 'show-id';
showIdItem.addEventListener('click', (event) => {
removeIdFilters(postIDText, postId);
// Simulate click outside to close menu via site's logic
setTimeout(() => document.body.click(), 0);
});
// Insert after the last item we added ("Filter ID++" or "Hide post++")
if (lastInsertedAfter) {
lastInsertedAfter.after(showIdItem);
lastInsertedAfter = showIdItem;
log(`Added 'Show ID' option after last added custom item for post ${postId}.`);
} else if (filterIdPlusItem) {
// Fallback if no custom items were added before this, but "Filter ID+" exists
filterIdPlusItem.after(showIdItem);
lastInsertedAfter = showIdItem;
warn(`No previous custom item found for post ${postId}. Appended 'Show ID' after 'Filter ID+'.`);
} else {
// Fallback: append to the end if nothing else was added/found
menuUl.appendChild(showIdItem);
lastInsertedAfter = showIdItem;
warn(`Neither previous custom item nor 'Filter ID+' found for post ${postId}. Appended 'Show ID' to end.`);
}
}
// --- Add "Show All" ---
// Add this option regardless of post type or ID, if it doesn't exist
if (!menuUl.querySelector('li[data-action="show-all"]')) {
const showAllItem = document.createElement('li');
showAllItem.textContent = 'Show All';
showAllItem.dataset.action = 'show-all';
showAllItem.addEventListener('click', (event) => {
log(`'Show All' clicked for post ${postId}`);
showAllInThread();
// Simulate click outside to close menu via site's logic
setTimeout(() => document.body.click(), 0);
});
// Insert after the last item we added ("Show ID", "Filter ID++", or "Hide post++")
if (lastInsertedAfter) {
lastInsertedAfter.after(showAllItem);
} else {
// Fallback: append to the end if no other custom items were added
menuUl.appendChild(showAllItem);
}
log(`Added 'Show All' option for post ${postId}.`);
}
} else {
warn('Could not find ul inside hide menu.');
}
}
}
}
}
}
});
// Start observing the hide button for added children (the menu appears as a child)
observer.observe(hideButton, { childList: true });
}
/**
* Finds all existing hide buttons on the page and attaches the menu observer logic.
*/
function addCustomHideOptionsToExistingButtons() {
const hideButtons = document.querySelectorAll(HIDE_BUTTON_SELECTOR);
hideButtons.forEach(addCustomHideMenuOptions);
log(`Attached menu observers to ${hideButtons.length} existing hide buttons.`);
}
// --- Initialization ---
log('Initializing...');
// Add custom CSS styles
addCustomStyles();
// Initial setup after a short delay to ensure site scripts are ready
setTimeout(() => {
updateAllLinks(); // Update links based on initial hidden posts
overrideLoadTooltip(); // Override tooltip function
addCustomHideOptionsToExistingButtons(); // Add menu options to posts already on the page
}, 500);
// Observe changes in the thread container to catch new posts or visibility changes
const threadContainer = document.querySelector(THREAD_CONTAINER_SELECTOR);
if (threadContainer) {
const observer = new MutationObserver((mutationsList) => {
let needsLinkUpdate = false;
for (const mutation of mutationsList) {
// Check for class changes on post containers (.opCell, .postCell) or their inner content (.innerOP, .innerPost)
if (mutation.type === 'attributes' && mutation.attributeName === 'class' && (mutation.target.matches(POST_CONTAINER_SELECTOR) || mutation.target.matches(INNER_POST_SELECTOR))) {
const wasHidden = mutation.oldValue ? mutation.oldValue.includes(HIDDEN_CLASS) : false;
const isHidden = mutation.target.classList.contains(HIDDEN_CLASS);
if (wasHidden !== isHidden) {
const postContainer = mutation.target.closest(POST_CONTAINER_SELECTOR);
const postId = postContainer ? postContainer.id : 'unknown';
log(`Mutation: Class change on post ${postId}. Hidden: ${isHidden}. Triggering link update.`);
needsLinkUpdate = true;
}
}
// Check for new nodes being added
else if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
// If a post container is added directly
if (node.matches(POST_CONTAINER_SELECTOR)) {
log(`Mutation: New post container added (ID: ${node.id}). Triggering link update and adding menu observer.`);
needsLinkUpdate = true;
const hideButton = node.querySelector(HIDE_BUTTON_SELECTOR);
if (hideButton) {
addCustomHideMenuOptions(hideButton); // Attach observer to the new hide button
}
} else {
// Check for post containers within the added node's subtree
const newPosts = node.querySelectorAll(POST_CONTAINER_SELECTOR);
if (newPosts.length > 0) {
log(`Mutation: New posts added within subtree. Triggering link update and adding menu observers.`);
needsLinkUpdate = true;
newPosts.forEach(post => {
const hideButton = post.querySelector(HIDE_BUTTON_SELECTOR);
if (hideButton) {
addCustomHideMenuOptions(hideButton); // Attach observer to new hide buttons
}
});
}
}
}
});
// Also check removed nodes in case backlinks need updating
mutation.removedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches(POST_CONTAINER_SELECTOR) || node.querySelector(POST_CONTAINER_SELECTOR)) {
log(`Mutation: Post removed. Triggering link update.`);
needsLinkUpdate = true;
}
}
});
}
}
if (needsLinkUpdate) {
debouncedUpdateAllLinks();
}
});
observer.observe(threadContainer, {
attributes: true, // Watch for attribute changes (like 'class')
attributeFilter: ['class'], // Only care about class changes
attributeOldValue: true,// Need old value to see if 'hidden' changed
childList: true, // Watch for new nodes being added or removed
subtree: true // Watch descendants (the posts and their inner content)
});
log('MutationObserver attached to thread container for link updates and new menu options.');
} else {
warn('Thread container not found. Links and menu options will not update automatically on dynamic changes.');
}
})();