8chan Hiding Enhancer

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.

  1. // ==UserScript==
  2. // @name 8chan Hiding Enhancer
  3. // @namespace nipah-scripts-8chan
  4. // @version 1.5.1
  5. // @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.
  6. // @author nipah, Gemini
  7. // @license MIT
  8. // @match https://8chan.moe/*/res/*.html*
  9. // @match https://8chan.se/*/res/*.html*
  10. // @grant none
  11. // @run-at document-idle
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. const SCRIPT_NAME = 'Hiding Enhancer';
  18. const BACKLINK_SELECTOR = '.panelBacklinks a, .altBacklinks a';
  19. const QUOTE_LINK_SELECTOR = '.quoteLink';
  20. const ALL_LINK_SELECTORS = `${BACKLINK_SELECTOR}, ${QUOTE_LINK_SELECTOR}`;
  21. const POST_CONTAINER_SELECTOR = '.opCell, .postCell';
  22. const INNER_POST_SELECTOR = '.innerOP, .innerPost'; // Selector for the inner content div
  23. const THREAD_CONTAINER_SELECTOR = '#divThreads'; // Container for all posts in the thread
  24. const HIDDEN_CLASS = 'hidden'; // Class added to the container when hidden by hiding.js
  25. const HIDDEN_QUOTE_CLASS = 'hidden-quote'; // Class to add to quote links for hidden posts
  26. const TOOLTIP_SELECTOR = '.quoteTooltip'; // Selector for the tooltip element
  27. const HIDE_BUTTON_SELECTOR = '.hideButton'; // Selector for the hide menu button
  28. const HIDE_MENU_SELECTOR = '.floatingList.extraMenu'; // Selector for the hide menu dropdown
  29. const LABEL_ID_SELECTOR = '.labelId'; // Selector for the post ID label
  30. const UNHIDE_BUTTON_SELECTOR = '.unhideButton'; // Selector for the site's unhide button
  31.  
  32. const log = (...args) => console.log(`[${SCRIPT_NAME}]`, ...args);
  33. const warn = (...args) => console.warn(`[${SCRIPT_NAME}]`, ...args);
  34. const error = (...args) => console.error(`[${SCRIPT_NAME}]`, ...args);
  35.  
  36.  
  37. let debounceTimer = null;
  38. const DEBOUNCE_DELAY = 250; // ms
  39.  
  40.  
  41. /**
  42. * Injects custom CSS styles into the document head.
  43. */
  44. function addCustomStyles() {
  45. const style = document.createElement('style');
  46. style.type = 'text/css';
  47. style.innerHTML = `
  48. .${HIDDEN_QUOTE_CLASS} {
  49. text-decoration: line-through !important;
  50. }
  51. /* Style for the dynamically added menu items */
  52. ${HIDE_MENU_SELECTOR} li[data-action^="hide-recursive"],
  53. ${HIDE_MENU_SELECTOR} li[data-action^="filter-id-recursive"],
  54. ${HIDE_MENU_SELECTOR} li[data-action="show-id"],
  55. ${HIDE_MENU_SELECTOR} li[data-action="show-all"] {
  56. cursor: pointer;
  57. }
  58. `;
  59. document.head.appendChild(style);
  60. log('Custom styles injected.');
  61. }
  62.  
  63. /**
  64. * Extracts the target post ID from a link's href attribute.
  65. * Works for both backlinks and quote links.
  66. * @param {HTMLAnchorElement} linkElement - The link <a> element.
  67. * @returns {string|null} The target post ID as a string, or null if not found.
  68. */
  69. function getTargetPostIdFromLink(linkElement) {
  70. if (!linkElement || !linkElement.href) {
  71. return null;
  72. }
  73. // Match the post number after the last '#'
  74. const match = linkElement.href.match(/#(\d+)$/);
  75. // Only return numeric post ID
  76. return match ? match[1] : null;
  77. }
  78.  
  79. /**
  80. * Checks if a post is currently hidden based on its ID.
  81. * @param {string} postId - The ID of the post to check.
  82. * @returns {boolean} True if the post is hidden, false otherwise.
  83. */
  84. function isPostHidden(postId) {
  85. if (!postId) return false;
  86. const postContainer = document.getElementById(postId);
  87. if (!postContainer) return false;
  88.  
  89. // Check if the main container (.opCell or .postCell) is hidden (can happen with thread hiding)
  90. if (postContainer.classList.contains(HIDDEN_CLASS)) {
  91. return true;
  92. }
  93.  
  94. // Check if the inner content container (.innerOP or .innerPost) is hidden (common for post hiding)
  95. const innerContent = postContainer.querySelector(INNER_POST_SELECTOR);
  96. return innerContent ? innerContent.classList.contains(HIDDEN_CLASS) : false;
  97. }
  98.  
  99. /**
  100. * Updates the visibility or style of a single link based on its target post's hidden status.
  101. * Handles both backlinks and quote links.
  102. * @param {HTMLAnchorElement} linkElement - The link <a> element to update.
  103. */
  104. function updateLinkVisibility(linkElement) {
  105. const targetPostId = getTargetPostIdFromLink(linkElement);
  106. // Ensure it's a numeric post ID link
  107. if (!targetPostId) return;
  108.  
  109. const hidden = isPostHidden(targetPostId);
  110.  
  111. if (linkElement.classList.contains('quoteLink')) {
  112. // It's a quote link, apply strikethrough
  113. if (hidden) {
  114. linkElement.classList.add(HIDDEN_QUOTE_CLASS);
  115. // // log(`Adding strikethrough to quote link ${linkElement.href} pointing to hidden post ${targetPostId}`);
  116. } else {
  117. linkElement.classList.remove(HIDDEN_QUOTE_CLASS);
  118. // // log(`Removing strikethrough from quote link ${linkElement.href} pointing to visible post ${targetPostId}`);
  119. }
  120. } else {
  121. // It's a backlink, hide/show the element
  122. if (hidden) {
  123. linkElement.style.display = 'none';
  124. // // log(`Hiding backlink ${linkElement.href} pointing to hidden post ${targetPostId}`);
  125. } else {
  126. // Reset display.
  127. linkElement.style.display = '';
  128. // // log(`Showing backlink ${linkElement.href} pointing to visible post ${targetPostId}`);
  129. }
  130. }
  131. }
  132.  
  133. /**
  134. * Iterates through all relevant links (backlinks and quote links) on the page and updates their visibility/style.
  135. */
  136. function updateAllLinks() {
  137. log('Updating all link visibility/style...');
  138. const links = document.querySelectorAll(ALL_LINK_SELECTORS);
  139. links.forEach(updateLinkVisibility);
  140. log(`Checked ${links.length} links.`);
  141. }
  142.  
  143. /**
  144. * Debounced version of updateAllLinks.
  145. */
  146. function debouncedUpdateAllLinks() {
  147. clearTimeout(debounceTimer);
  148. debounceTimer = setTimeout(updateAllLinks, DEBOUNCE_DELAY);
  149. }
  150.  
  151. /**
  152. * Overrides the site's tooltips.loadTooltip function to prevent tooltips for hidden posts.
  153. */
  154. function overrideLoadTooltip() {
  155. // Check if tooltips object and loadTooltip function exist
  156. if (typeof tooltips === 'undefined' || typeof tooltips.loadTooltip !== 'function') {
  157. // Not ready, try again later
  158. setTimeout(overrideLoadTooltip, 100);
  159. return;
  160. }
  161.  
  162. const originalLoadTooltip = tooltips.loadTooltip;
  163.  
  164. tooltips.loadTooltip = function(tooltip, quoteUrl, replyId, isInline) {
  165. // Only intercept hover tooltips (isInline is false for hover tooltips)
  166. if (!isInline) {
  167. const matches = quoteUrl.match(/\/(\w+)\/res\/(\d+)\.html\#(\d+)/);
  168. const targetPostId = matches ? matches[3] : null; // Post ID is the 3rd group
  169.  
  170. if (targetPostId && isPostHidden(targetPostId)) {
  171. log(`Preventing hover tooltip for quote to hidden post ${targetPostId}`);
  172. // Remove the tooltip element that was just created by the site's code
  173. if (tooltip && tooltip.parentNode) {
  174. tooltip.parentNode.removeChild(tooltip);
  175. }
  176. // Clear the site's internal reference if it points to the removed tooltip
  177. // This is important so tooltips.removeIfExists doesn't try to remove it again
  178. if (tooltips.currentTooltip === tooltip) {
  179. tooltips.currentTooltip = null;
  180. }
  181. // Prevent the original function from running
  182. return;
  183. }
  184. }
  185.  
  186. // If it's an inline quote OR the target post is not hidden, call the original function
  187. originalLoadTooltip.apply(this, arguments);
  188. };
  189.  
  190. log('tooltips.loadTooltip overridden to prevent tooltips for hidden posts.');
  191. }
  192.  
  193. /**
  194. * Implements the recursive hiding logic using the site's hiding.hidePost function.
  195. * Hides a specific post and all its replies recursively.
  196. * @param {string} startPostId - The ID of the post to start hiding from.
  197. */
  198. function hidePostAndRepliesRecursivelyUserscript(startPostId) {
  199. // Ensure site objects are available
  200. if (typeof hiding === 'undefined' || typeof hiding.hidePost !== 'function' || typeof tooltips === 'undefined' || typeof tooltips.knownPosts === 'undefined') {
  201. error('Site hiding or tooltips objects not available. Cannot perform recursive hide.');
  202. return;
  203. }
  204.  
  205. const boardUri = window.location.pathname.split('/')[1]; // Get boardUri from URL
  206.  
  207. function recursiveHide(currentPostId) {
  208. const postElement = document.getElementById(currentPostId);
  209. if (!postElement) {
  210. // Post element not found (might be filtered or not loaded)
  211. // log(`Post element ${currentPostId} not found.`);
  212. return;
  213. }
  214.  
  215. // Check if the post already has the site's unhide button, indicating it's already hidden
  216. if (postElement.querySelector(UNHIDE_BUTTON_SELECTOR)) {
  217. // log(`Post ${currentPostId} is already hidden (unhide button found). Skipping.`);
  218. } else {
  219. const linkSelf = postElement.querySelector('.linkSelf');
  220. if (linkSelf) {
  221. log(`Hiding post ${currentPostId}`);
  222. // Call the site's hidePost function
  223. hiding.hidePost(linkSelf);
  224. } else {
  225. warn(`Could not find .linkSelf for post ${currentPostId}. Cannot hide.`);
  226. }
  227. }
  228.  
  229. // Find replies using the site's tooltips.knownPosts structure
  230. const knownPost = tooltips.knownPosts[boardUri]?.[currentPostId];
  231.  
  232. if (!knownPost || !knownPost.added || knownPost.added.length === 0) {
  233. // log(`No known replies for post ${currentPostId}. Stopping recursion.`);
  234. return; // No replies or post not found in knownPosts
  235. }
  236.  
  237. // Recursively hide replies
  238. knownPost.added.forEach((replyString) => {
  239. const [replyBoard, replyId] = replyString.split('_');
  240.  
  241. // Only hide replies within the same board and thread
  242. // The site's knownPosts structure seems to only track replies within the same thread anyway
  243. if (replyBoard === boardUri) {
  244. recursiveHide(replyId);
  245. }
  246. });
  247. }
  248.  
  249. // Start the recursive hiding process
  250. log(`Starting recursive hide from post ${startPostId}`);
  251. recursiveHide(startPostId);
  252. log(`Finished recursive hide from post ${startPostId}`);
  253.  
  254. // After hiding is done, trigger a link update to reflect changes
  255. debouncedUpdateAllLinks();
  256. }
  257.  
  258. /**
  259. * Implements the recursive filtering logic.
  260. * Adds an ID filter and recursively hides replies for all posts matching that ID.
  261. * @param {string} targetId - The raw ID string (e.g., '0feed1') to filter by.
  262. * @param {string} clickedPostId - The ID of the post whose menu was clicked (used for context/logging).
  263. */
  264. function filterIdAndHideAllMatchingAndReplies(targetId, clickedPostId) {
  265. // Ensure site objects are available
  266. 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') {
  267. error('Site settingsMenu, hiding, tooltips, or hiding.buildPostFilterId objects not available. Cannot perform recursive ID filter.');
  268. return;
  269. }
  270.  
  271. const boardUri = window.location.pathname.split('/')[1];
  272. const threadId = window.location.pathname.split('/')[3].split('.')[0]; // Extract thread ID from URL
  273.  
  274. // Find the linkSelf element for the clicked post to pass to buildPostFilterId
  275. const clickedPostElement = document.getElementById(clickedPostId);
  276. let formattedFilterString = targetId; // Fallback to raw ID
  277.  
  278. if (clickedPostElement) {
  279. const linkSelf = clickedPostElement.querySelector('.linkSelf');
  280. if (linkSelf) {
  281. // Use the site's function to get the formatted ID string
  282. formattedFilterString = hiding.buildPostFilterId(linkSelf, targetId);
  283. } else {
  284. warn(`Could not find .linkSelf for clicked post ${clickedPostId}. Using raw ID for filter.`);
  285. }
  286. } else {
  287. warn(`Could not find clicked post element ${clickedPostId}. Using raw ID for filter.`);
  288. }
  289.  
  290.  
  291. log(`Applying Filter ID++ for ID: ${targetId} (formatted as "${formattedFilterString}") triggered from post ${clickedPostId})`);
  292.  
  293. // 1. Add the ID filter using the site's function
  294. // Type 4 is for filtering by ID
  295. settingsMenu.createFilter(formattedFilterString, false, 4);
  296. log(`Added filter for ID: ${formattedFilterString}`);
  297.  
  298. // Give the site's filter logic a moment to apply the 'hidden' class
  299. // Then find all posts with this ID and recursively hide their replies
  300. setTimeout(() => {
  301. const allPosts = document.querySelectorAll(POST_CONTAINER_SELECTOR);
  302.  
  303. allPosts.forEach(postElement => {
  304. const postIdLabel = postElement.querySelector(LABEL_ID_SELECTOR);
  305. const currentPostId = postElement.id;
  306.  
  307. // Check if the post matches the target ID
  308. if (postIdLabel && postIdLabel.textContent === targetId) {
  309. log(`Found post ${currentPostId} matching ID ${targetId}. Recursively hiding its replies.`);
  310. // Call the recursive hide function starting from this post.
  311. // hidePostAndRepliesRecursivelyUserscript will handle hiding the post itself
  312. // (if not already hidden by the filter) and its replies.
  313. hidePostAndRepliesRecursivelyUserscript(currentPostId);
  314. }
  315. });
  316.  
  317. // After hiding is done, trigger a link update to reflect changes
  318. // This is already handled by hidePostAndRepliesRecursivelyUserscript,
  319. // but calling it again here after the loop ensures all changes are caught.
  320. debouncedUpdateAllLinks();
  321.  
  322. }, DEBOUNCE_DELAY + 50); // Wait slightly longer than the debounce delay
  323. }
  324.  
  325. /**
  326. * Removes all filters associated with a specific raw ID from the site's settings.
  327. * @param {string} targetId - The raw ID string (e.g., '0feed1') to remove filters for.
  328. * @param {string} clickedPostId - The ID of the post whose menu was clicked (used for context/logging).
  329. */
  330. function removeIdFilters(targetId, clickedPostId) {
  331. // Ensure site objects are available
  332. if (typeof settingsMenu === 'undefined' || typeof settingsMenu.loadedFilters === 'undefined' || typeof hiding === 'undefined' || typeof hiding.checkFilters !== 'function' || typeof hiding.buildPostFilterId !== 'function') {
  333. error('Site settingsMenu, hiding, or hiding.buildPostFilterId objects not available. Cannot remove ID filters.');
  334. return;
  335. }
  336.  
  337. const boardUri = window.location.pathname.split('/')[1];
  338. const threadId = window.location.pathname.split('/')[3].split('.')[0]; // Extract thread ID from URL
  339.  
  340. // Find the linkSelf element for the clicked post to pass to buildPostFilterId
  341. const clickedPostElement = document.getElementById(clickedPostId);
  342. let formattedFilterString = targetId; // Fallback to raw ID
  343.  
  344. if (clickedPostElement) {
  345. const linkSelf = clickedPostElement.querySelector('.linkSelf');
  346. if (linkSelf) {
  347. // Use the site's function to get the formatted ID string
  348. formattedFilterString = hiding.buildPostFilterId(linkSelf, targetId);
  349. } else {
  350. warn(`Could not find .linkSelf for clicked post ${clickedPostId}. Using raw ID for filter removal check.`);
  351. }
  352. } else {
  353. warn(`Could not find clicked post element ${clickedPostId}. Using raw ID for filter removal check.`);
  354. }
  355.  
  356. log(`Attempting to remove filters for ID: ${targetId} (formatted as "${formattedFilterString}") triggered from post ${clickedPostId})`);
  357.  
  358. // Filter out the matching filters
  359. const initialFilterCount = settingsMenu.loadedFilters.length;
  360. settingsMenu.loadedFilters = settingsMenu.loadedFilters.filter(filter => {
  361. // Check if it's an ID filter (type 4 or 5) and if the filter content matches the formatted ID string
  362. return !( (filter.type === 4 || filter.type === 5) && filter.filter === formattedFilterString );
  363. });
  364.  
  365. const removedCount = initialFilterCount - settingsMenu.loadedFilters.length;
  366.  
  367. if (removedCount > 0) {
  368. log(`Removed ${removedCount} filter(s) for ID: ${formattedFilterString}`);
  369. // Update localStorage
  370. localStorage.setItem('filterData', JSON.stringify(settingsMenu.loadedFilters));
  371. // Trigger the site's filter update
  372. hiding.checkFilters();
  373. log('Triggered site filter update.');
  374. } else {
  375. log(`No filters found for ID: ${formattedFilterString} to remove.`);
  376. }
  377.  
  378. // After removing filters, trigger a link update to reflect changes (posts might become visible)
  379. debouncedUpdateAllLinks();
  380. }
  381.  
  382. /**
  383. * Removes all ID filters and manual hides for the current thread.
  384. */
  385. function showAllInThread() {
  386. // Ensure site objects are available
  387. if (typeof settingsMenu === 'undefined' || typeof settingsMenu.loadedFilters === 'undefined' || typeof hiding === 'undefined' || typeof hiding.checkFilters !== 'function' || typeof hiding.buildPostFilterId !== 'function') {
  388. error('Site settingsMenu, hiding, or hiding.buildPostFilterId objects not available. Cannot show all in thread.');
  389. return;
  390. }
  391.  
  392. const boardUri = window.location.pathname.split('/')[1];
  393. const threadId = window.location.pathname.split('/')[3].split('.')[0]; // Extract thread ID from URL
  394.  
  395. log(`Attempting to show all posts in thread /${boardUri}/res/${threadId}.html`);
  396.  
  397. let filtersRemoved = 0;
  398. let unhideButtonsClicked = 0;
  399.  
  400. // 1. Find and click all existing unhide buttons in the current thread
  401. log('Searching for and clicking existing unhide buttons...');
  402. const allPostsInThread = document.querySelectorAll(POST_CONTAINER_SELECTOR);
  403. allPostsInThread.forEach(postElement => {
  404. const postId = postElement.id;
  405. if (!postId) return; // Skip if element has no ID
  406.  
  407. let unhideButton = null;
  408. if (postId === threadId) {
  409. // For the thread (OP), the button is the previous sibling
  410. unhideButton = postElement.previousElementSibling;
  411. if (!unhideButton || !unhideButton.matches(UNHIDE_BUTTON_SELECTOR)) {
  412. unhideButton = null; // Reset if not found or doesn't match
  413. }
  414. } else {
  415. // For regular posts, the button is inside the post container
  416. unhideButton = postElement.querySelector(UNHIDE_BUTTON_SELECTOR);
  417. }
  418.  
  419. if (unhideButton) {
  420. log(`Clicking unhide button for ${postId}`);
  421. unhideButton.click();
  422. unhideButtonsClicked++;
  423. }
  424. });
  425. log(`Clicked ${unhideButtonsClicked} unhide button(s).`);
  426.  
  427. // 2. Remove ID filters specific to this thread from settingsMenu
  428. const initialFilterCount = settingsMenu.loadedFilters.length;
  429. settingsMenu.loadedFilters = settingsMenu.loadedFilters.filter(filter => {
  430. // Check if it's an ID filter (type 4 or 5) and if the filter content starts with the board-thread prefix
  431. const isThreadIdFilter = (filter.type === 4 || filter.type === 5) && filter.filter.startsWith(`${boardUri}-${threadId}-`);
  432. if (isThreadIdFilter) {
  433. filtersRemoved++;
  434. }
  435. return !isThreadIdFilter;
  436. });
  437.  
  438. if (filtersRemoved > 0) {
  439. log(`Removed ${filtersRemoved} ID filter(s) specific to this thread.`);
  440. // Update localStorage for filters
  441. localStorage.setItem('filterData', JSON.stringify(settingsMenu.loadedFilters));
  442. } else {
  443. log('No ID filters specific to this thread found to remove.');
  444. }
  445.  
  446. // 3. Trigger the site's filter update AFTER a short delay to allow button clicks to process
  447. // and for the filter removal to take effect.
  448. setTimeout(() => {
  449. hiding.checkFilters();
  450. log('Triggered site filter update after delay.');
  451.  
  452. // 5. Trigger userscript link update
  453. debouncedUpdateAllLinks();
  454.  
  455. log('Finished "Show All" action.');
  456. }, 100); // 100ms delay
  457. }
  458.  
  459.  
  460. /**
  461. * Adds the custom "Hide post++", "Filter ID++", "Show ID", and "Show All" options to a hide button's menu when it appears.
  462. * Uses a MutationObserver to detect when the menu is added to the button.
  463. * @param {HTMLElement} hideButton - The hide button element.
  464. */
  465. function addCustomHideMenuOptions(hideButton) {
  466. // Create a new observer for each hide button
  467. // This observer will stay active for the lifetime of the hideButton element
  468. const observer = new MutationObserver((mutationsList) => {
  469. for (const mutation of mutationsList) {
  470. if (mutation.type === 'childList') {
  471. for (const addedNode of mutation.addedNodes) {
  472. // Check if the added node is the menu we're looking for
  473. if (addedNode.nodeType === Node.ELEMENT_NODE && addedNode.matches(HIDE_MENU_SELECTOR)) {
  474. // Check if this menu is a child of the target hideButton
  475. if (hideButton.contains(addedNode)) {
  476. // Menu appeared, now add the custom options if they're not already there
  477. const menuUl = addedNode.querySelector('ul');
  478. if (menuUl) {
  479. const postContainer = hideButton.closest(POST_CONTAINER_SELECTOR);
  480. const postId = postContainer ? postContainer.id : null;
  481. const isOP = postContainer ? postContainer.classList.contains('opCell') : false;
  482. const postIdLabel = postContainer ? postContainer.querySelector(LABEL_ID_SELECTOR) : null;
  483. const postIDText = postIdLabel ? postIdLabel.textContent : null;
  484.  
  485. // Find anchor points for insertion
  486. const hidePostPlusItem = Array.from(menuUl.querySelectorAll('li')).find(li => li.textContent.trim() === 'Hide post+');
  487. const filterIdPlusItem = Array.from(menuUl.querySelectorAll('li')).find(li => li.textContent.trim() === 'Filter ID+');
  488.  
  489. // Keep track of the last item we inserted after
  490. let lastInsertedAfter = null;
  491.  
  492. // --- Add "Hide post++" ---
  493. // Only add for reply posts and if it doesn't exist
  494. if (!isOP && postId && !menuUl.querySelector('li[data-action="hide-recursive"]')) {
  495. const hideRecursiveItem = document.createElement('li');
  496. hideRecursiveItem.textContent = 'Hide post++';
  497. hideRecursiveItem.dataset.action = 'hide-recursive';
  498.  
  499. hideRecursiveItem.addEventListener('click', (event) => {
  500. log(`'Hide post++' clicked for post ${postId}`);
  501. hidePostAndRepliesRecursivelyUserscript(postId);
  502. });
  503.  
  504. // Insert after "Hide post+" if found
  505. if (hidePostPlusItem) {
  506. hidePostPlusItem.after(hideRecursiveItem);
  507. lastInsertedAfter = hideRecursiveItem;
  508. log(`Added 'Hide post++' option after 'Hide post+' for post ${postId}.`);
  509. } else {
  510. // Fallback: append to the end if "Hide post+" isn't found (shouldn't happen for replies)
  511. menuUl.appendChild(hideRecursiveItem);
  512. lastInsertedAfter = hideRecursiveItem;
  513. warn(`'Hide post+' not found for post ${postId}. Appended 'Hide post++' to end.`);
  514. }
  515. }
  516.  
  517. // --- Add "Filter ID++" ---
  518. // Only add if the post has an ID and it doesn't exist
  519. if (postIDText && !menuUl.querySelector('li[data-action="filter-id-recursive"]')) {
  520. const filterIdRecursiveItem = document.createElement('li');
  521. filterIdRecursiveItem.textContent = 'Filter ID++';
  522. filterIdRecursiveItem.dataset.action = 'filter-id-recursive';
  523.  
  524. filterIdRecursiveItem.addEventListener('click', (event) => {
  525. filterIdAndHideAllMatchingAndReplies(postIDText, postId);
  526. });
  527.  
  528. // Insert after "Filter ID+" if it exists, otherwise after the last item we added ("Hide post++")
  529. if (filterIdPlusItem) {
  530. filterIdPlusItem.after(filterIdRecursiveItem);
  531. lastInsertedAfter = filterIdRecursiveItem;
  532. log(`Added 'Filter ID++' option after 'Filter ID+' for post ${postId}.`);
  533. } else if (lastInsertedAfter) { // If Hide post++ was added
  534. lastInsertedAfter.after(filterIdRecursiveItem);
  535. lastInsertedAfter = filterIdRecursiveItem;
  536. warn(`'Filter ID+' not found for post ${postId}. Appended 'Filter ID++' after last added item.`);
  537. } else {
  538. // Fallback: append to the end if neither "Filter ID+" nor "Hide post++" were present/added
  539. menuUl.appendChild(filterIdRecursiveItem);
  540. lastInsertedAfter = filterIdRecursiveItem;
  541. warn(`Neither 'Filter ID+' nor previous custom item found for post ${postId}. Appended 'Filter ID++' to end.`);
  542. }
  543. }
  544.  
  545. // --- Add "Show ID" ---
  546. // Only add if the post has an ID and it doesn't exist
  547. if (postIDText && !menuUl.querySelector('li[data-action="show-id"]')) {
  548. const showIdItem = document.createElement('li');
  549. showIdItem.textContent = 'Show ID';
  550. showIdItem.dataset.action = 'show-id';
  551.  
  552. showIdItem.addEventListener('click', (event) => {
  553. removeIdFilters(postIDText, postId);
  554. // Simulate click outside to close menu via site's logic
  555. setTimeout(() => document.body.click(), 0);
  556. });
  557.  
  558. // Insert after the last item we added ("Filter ID++" or "Hide post++")
  559. if (lastInsertedAfter) {
  560. lastInsertedAfter.after(showIdItem);
  561. lastInsertedAfter = showIdItem;
  562. log(`Added 'Show ID' option after last added custom item for post ${postId}.`);
  563. } else if (filterIdPlusItem) {
  564. // Fallback if no custom items were added before this, but "Filter ID+" exists
  565. filterIdPlusItem.after(showIdItem);
  566. lastInsertedAfter = showIdItem;
  567. warn(`No previous custom item found for post ${postId}. Appended 'Show ID' after 'Filter ID+'.`);
  568. } else {
  569. // Fallback: append to the end if nothing else was added/found
  570. menuUl.appendChild(showIdItem);
  571. lastInsertedAfter = showIdItem;
  572. warn(`Neither previous custom item nor 'Filter ID+' found for post ${postId}. Appended 'Show ID' to end.`);
  573. }
  574. }
  575.  
  576. // --- Add "Show All" ---
  577. // Add this option regardless of post type or ID, if it doesn't exist
  578. if (!menuUl.querySelector('li[data-action="show-all"]')) {
  579. const showAllItem = document.createElement('li');
  580. showAllItem.textContent = 'Show All';
  581. showAllItem.dataset.action = 'show-all';
  582.  
  583. showAllItem.addEventListener('click', (event) => {
  584. log(`'Show All' clicked for post ${postId}`);
  585. showAllInThread();
  586. // Simulate click outside to close menu via site's logic
  587. setTimeout(() => document.body.click(), 0);
  588. });
  589.  
  590. // Insert after the last item we added ("Show ID", "Filter ID++", or "Hide post++")
  591. if (lastInsertedAfter) {
  592. lastInsertedAfter.after(showAllItem);
  593. } else {
  594. // Fallback: append to the end if no other custom items were added
  595. menuUl.appendChild(showAllItem);
  596. }
  597. log(`Added 'Show All' option for post ${postId}.`);
  598. }
  599.  
  600.  
  601. } else {
  602. warn('Could not find ul inside hide menu.');
  603. }
  604. }
  605. }
  606. }
  607. }
  608. }
  609. });
  610.  
  611. // Start observing the hide button for added children (the menu appears as a child)
  612. observer.observe(hideButton, { childList: true });
  613. }
  614.  
  615. /**
  616. * Finds all existing hide buttons on the page and attaches the menu observer logic.
  617. */
  618. function addCustomHideOptionsToExistingButtons() {
  619. const hideButtons = document.querySelectorAll(HIDE_BUTTON_SELECTOR);
  620. hideButtons.forEach(addCustomHideMenuOptions);
  621. log(`Attached menu observers to ${hideButtons.length} existing hide buttons.`);
  622. }
  623.  
  624.  
  625. // --- Initialization ---
  626.  
  627. log('Initializing...');
  628.  
  629. // Add custom CSS styles
  630. addCustomStyles();
  631.  
  632. // Initial setup after a short delay to ensure site scripts are ready
  633. setTimeout(() => {
  634. updateAllLinks(); // Update links based on initial hidden posts
  635. overrideLoadTooltip(); // Override tooltip function
  636. addCustomHideOptionsToExistingButtons(); // Add menu options to posts already on the page
  637. }, 500);
  638.  
  639.  
  640. // Observe changes in the thread container to catch new posts or visibility changes
  641. const threadContainer = document.querySelector(THREAD_CONTAINER_SELECTOR);
  642. if (threadContainer) {
  643. const observer = new MutationObserver((mutationsList) => {
  644. let needsLinkUpdate = false;
  645. for (const mutation of mutationsList) {
  646. // Check for class changes on post containers (.opCell, .postCell) or their inner content (.innerOP, .innerPost)
  647. if (mutation.type === 'attributes' && mutation.attributeName === 'class' && (mutation.target.matches(POST_CONTAINER_SELECTOR) || mutation.target.matches(INNER_POST_SELECTOR))) {
  648. const wasHidden = mutation.oldValue ? mutation.oldValue.includes(HIDDEN_CLASS) : false;
  649. const isHidden = mutation.target.classList.contains(HIDDEN_CLASS);
  650. if (wasHidden !== isHidden) {
  651. const postContainer = mutation.target.closest(POST_CONTAINER_SELECTOR);
  652. const postId = postContainer ? postContainer.id : 'unknown';
  653. log(`Mutation: Class change on post ${postId}. Hidden: ${isHidden}. Triggering link update.`);
  654. needsLinkUpdate = true;
  655. }
  656. }
  657. // Check for new nodes being added
  658. else if (mutation.type === 'childList') {
  659. mutation.addedNodes.forEach(node => {
  660. if (node.nodeType === Node.ELEMENT_NODE) {
  661. // If a post container is added directly
  662. if (node.matches(POST_CONTAINER_SELECTOR)) {
  663. log(`Mutation: New post container added (ID: ${node.id}). Triggering link update and adding menu observer.`);
  664. needsLinkUpdate = true;
  665. const hideButton = node.querySelector(HIDE_BUTTON_SELECTOR);
  666. if (hideButton) {
  667. addCustomHideMenuOptions(hideButton); // Attach observer to the new hide button
  668. }
  669. } else {
  670. // Check for post containers within the added node's subtree
  671. const newPosts = node.querySelectorAll(POST_CONTAINER_SELECTOR);
  672. if (newPosts.length > 0) {
  673. log(`Mutation: New posts added within subtree. Triggering link update and adding menu observers.`);
  674. needsLinkUpdate = true;
  675. newPosts.forEach(post => {
  676. const hideButton = post.querySelector(HIDE_BUTTON_SELECTOR);
  677. if (hideButton) {
  678. addCustomHideMenuOptions(hideButton); // Attach observer to new hide buttons
  679. }
  680. });
  681. }
  682. }
  683. }
  684. });
  685. // Also check removed nodes in case backlinks need updating
  686. mutation.removedNodes.forEach(node => {
  687. if (node.nodeType === Node.ELEMENT_NODE) {
  688. if (node.matches(POST_CONTAINER_SELECTOR) || node.querySelector(POST_CONTAINER_SELECTOR)) {
  689. log(`Mutation: Post removed. Triggering link update.`);
  690. needsLinkUpdate = true;
  691. }
  692. }
  693. });
  694. }
  695. }
  696.  
  697. if (needsLinkUpdate) {
  698. debouncedUpdateAllLinks();
  699. }
  700. });
  701.  
  702. observer.observe(threadContainer, {
  703. attributes: true, // Watch for attribute changes (like 'class')
  704. attributeFilter: ['class'], // Only care about class changes
  705. attributeOldValue: true,// Need old value to see if 'hidden' changed
  706. childList: true, // Watch for new nodes being added or removed
  707. subtree: true // Watch descendants (the posts and their inner content)
  708. });
  709.  
  710. log('MutationObserver attached to thread container for link updates and new menu options.');
  711.  
  712. } else {
  713. warn('Thread container not found. Links and menu options will not update automatically on dynamic changes.');
  714. }
  715.  
  716. })();