8chan Lightweight Extended Suite

Minimalist extender for 8chan with in-line replies, spoiler revealing and media preview for videos & images

  1. // ==UserScript==
  2. // @name 8chan Lightweight Extended Suite
  3. // @namespace https://greatest.deepsurf.us/en/scripts/533173
  4. // @version 2.6.2
  5. // @description Minimalist extender for 8chan with in-line replies, spoiler revealing and media preview for videos & images
  6. // @author impregnator
  7. // @match https://8chan.moe/*
  8. // @match https://8chan.se/*
  9. // @match https://8chan.cc/*
  10. // @grant none
  11. // @license MIT
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. // Function to process images and replace spoiler placeholders with thumbnails
  18. function processImages(images, isCatalog = false) {
  19. images.forEach(img => {
  20. // Check if the image is a spoiler placeholder (custom or default)
  21. if (img.src.includes('custom.spoiler') || img.src.includes('spoiler.png')) {
  22. let fullFileUrl;
  23. if (isCatalog) {
  24. // Catalog: Get the href from the parent <a class="linkThumb">
  25. const link = img.closest('a.linkThumb');
  26. if (link) {
  27. // Construct the thumbnail URL based on the thread URL
  28. fullFileUrl = link.href;
  29. const threadMatch = fullFileUrl.match(/\/([a-z0-9]+)\/res\/([0-9]+)\.html$/i);
  30. if (threadMatch && threadMatch[1] && threadMatch[2]) {
  31. const board = threadMatch[1];
  32. const threadId = threadMatch[2];
  33. // Fetch the thread page to find the actual image URL
  34. fetchThreadImage(board, threadId).then(thumbnailUrl => {
  35. if (thumbnailUrl) {
  36. img.src = thumbnailUrl;
  37. }
  38. });
  39. }
  40. }
  41. } else {
  42. // Thread: Get the parent <a> element containing the full-sized file URL
  43. const link = img.closest('a.imgLink');
  44. if (link) {
  45. // Extract the full-sized file URL
  46. fullFileUrl = link.href;
  47. // Extract the file hash (everything after /.media/ up to the extension)
  48. const fileHash = fullFileUrl.match(/\/\.media\/([a-f0-9]+)\.[a-z0-9]+$/i);
  49. if (fileHash && fileHash[1]) {
  50. // Construct the thumbnail URL using the current domain
  51. const thumbnailUrl = `${window.location.origin}/.media/t_${fileHash[1]}`;
  52. // Replace the spoiler image with the thumbnail
  53. img.src = thumbnailUrl;
  54. }
  55. }
  56. }
  57. }
  58. });
  59. }
  60.  
  61. // Function to fetch the thread page and extract the thumbnail URL
  62. async function fetchThreadImage(board, threadId) {
  63. try {
  64. const response = await fetch(`https://${window.location.host}/${board}/res/${threadId}.html`);
  65. const text = await response.text();
  66. const parser = new DOMParser();
  67. const doc = parser.parseFromString(text, 'text/html');
  68. // Find the first image in the thread's OP post
  69. const imgLink = doc.querySelector('.uploadCell a.imgLink');
  70. if (imgLink) {
  71. const fullFileUrl = imgLink.href;
  72. const fileHash = fullFileUrl.match(/\/\.media\/([a-f0-9]+)\.[a-z0-9]+$/i);
  73. if (fileHash && fileHash[1]) {
  74. return `${window.location.origin}/.media/t_${fileHash[1]}`;
  75. }
  76. }
  77. return null;
  78. } catch (error) {
  79. console.error('Error fetching thread image:', error);
  80. return null;
  81. }
  82. }
  83.  
  84. // Process existing images on page load
  85. const isCatalogPage = window.location.pathname.includes('catalog.html');
  86. if (isCatalogPage) {
  87. const initialCatalogImages = document.querySelectorAll('.catalogCell a.linkThumb img');
  88. processImages(initialCatalogImages, true);
  89. } else {
  90. const initialThreadImages = document.querySelectorAll('.uploadCell img');
  91. processImages(initialThreadImages, false);
  92. }
  93.  
  94. // Set up MutationObserver to handle dynamically added posts
  95. const observer = new MutationObserver(mutations => {
  96. mutations.forEach(mutation => {
  97. if (mutation.addedNodes.length) {
  98. // Check each added node for new images
  99. mutation.addedNodes.forEach(node => {
  100. if (node.nodeType === Node.ELEMENT_NODE) {
  101. if (isCatalogPage) {
  102. const newCatalogImages = node.querySelectorAll('.catalogCell a.linkThumb img');
  103. processImages(newCatalogImages, true);
  104. } else {
  105. const newThreadImages = node.querySelectorAll('.uploadCell img');
  106. processImages(newThreadImages, false);
  107. }
  108. }
  109. });
  110. }
  111. });
  112. });
  113.  
  114. // Observe changes to the document body, including child nodes and subtrees
  115. observer.observe(document.body, {
  116. childList: true,
  117. subtree: true
  118. });
  119. })();
  120.  
  121. //Opening all posts from the catalog in a new tag section
  122.  
  123. // Add click event listener to catalog thumbnail images
  124. document.addEventListener('click', function(e) {
  125. // Check if the clicked element is an image inside a catalog cell
  126. if (e.target.tagName === 'IMG' && e.target.closest('.catalogCell')) {
  127. // Find the parent link with class 'linkThumb'
  128. const link = e.target.closest('.linkThumb');
  129. if (link) {
  130. // Prevent default link behavior
  131. e.preventDefault();
  132. // Open the thread in a new tab
  133. window.open(link.href, '_blank');
  134. }
  135. }
  136. });
  137.  
  138. //Automatically redirect to catalog section
  139.  
  140. // Redirect to catalog if on a board's main page, excluding overboard pages
  141. (function() {
  142. const currentPath = window.location.pathname;
  143. // Check if the path matches a board's main page (e.g., /v/, /a/) but not overboard pages
  144. if (currentPath.match(/^\/[a-zA-Z0-9]+\/$/) && !currentPath.match(/^\/(sfw|overboard)\/$/)) {
  145. // Redirect to the catalog page
  146. window.location.replace(currentPath + 'catalog.html');
  147. }
  148. })();
  149.  
  150. // Text spoiler revealer
  151.  
  152. (function() {
  153. // Function to reveal spoilers
  154. function revealSpoilers() {
  155. const spoilers = document.querySelectorAll('span.spoiler');
  156. spoilers.forEach(spoiler => {
  157. // Override default spoiler styles to make text visible
  158. spoiler.style.background = 'none';
  159. spoiler.style.color = 'inherit';
  160. spoiler.style.textShadow = 'none';
  161. });
  162. }
  163.  
  164. // Run initially for existing spoilers
  165. revealSpoilers();
  166.  
  167. // Set up MutationObserver to watch for new spoilers
  168. const observer = new MutationObserver((mutations) => {
  169. mutations.forEach(mutation => {
  170. if (mutation.addedNodes.length > 0) {
  171. // Check if new nodes contain spoilers
  172. mutation.addedNodes.forEach(node => {
  173. if (node.nodeType === Node.ELEMENT_NODE) {
  174. const newSpoilers = node.querySelectorAll('span.spoiler');
  175. newSpoilers.forEach(spoiler => {
  176. spoiler.style.background = 'none';
  177. spoiler.style.color = 'inherit';
  178. spoiler.style.textShadow = 'none';
  179. });
  180. }
  181. });
  182. }
  183. });
  184. });
  185.  
  186. // Observe the document body for changes (new posts)
  187. observer.observe(document.body, {
  188. childList: true,
  189. subtree: true
  190. });
  191. })();
  192.  
  193. //Hash navigation
  194. // Add # links to backlinks and quote links for scrolling
  195. (function() {
  196. // Function to add # link to backlinks and quote links
  197. function addHashLinks(container = document) {
  198. const links = container.querySelectorAll('.panelBacklinks a, .altBacklinks a, .divMessage .quoteLink');
  199. links.forEach(link => {
  200. // Skip if # link already exists or processed
  201. if (link.nextSibling && link.nextSibling.classList && link.nextSibling.classList.contains('hash-link-container')) return;
  202. if (link.dataset.hashProcessed) return;
  203. // Create # link as a span to avoid <a> processing
  204. const hashLink = document.createElement('span');
  205. hashLink.textContent = ' #';
  206. hashLink.style.cursor = 'pointer';
  207. hashLink.style.color = '#0000EE'; // Match link color
  208. hashLink.title = 'Scroll to post';
  209. hashLink.className = 'hash-link';
  210. hashLink.dataset.hashListener = 'true'; // Mark as processed
  211. // Wrap # link in a span to isolate it
  212. const container = document.createElement('span');
  213. container.className = 'hash-link-container';
  214. container.appendChild(hashLink);
  215. link.insertAdjacentElement('afterend', container);
  216. link.dataset.hashProcessed = 'true'; // Mark as processed
  217. });
  218. }
  219.  
  220. // Event delegation for hash link clicks to mimic .linkSelf behavior
  221. document.addEventListener('click', function(e) {
  222. if (e.target.classList.contains('hash-link')) {
  223. e.preventDefault();
  224. e.stopPropagation();
  225. const link = e.target.closest('.hash-link-container').previousElementSibling;
  226. const postId = link.textContent.replace('>>', '');
  227. if (document.getElementById(postId)) {
  228. window.location.hash = `#${postId}`;
  229. console.log(`Navigated to post #${postId}`);
  230. } else {
  231. console.log(`Post ${postId} not found`);
  232. }
  233. }
  234. }, true);
  235.  
  236. // Process existing backlinks and quote links on page load
  237. addHashLinks();
  238. console.log('Hash links applied on page load');
  239.  
  240. // Patch inline reply logic to apply hash links to new inline content
  241. if (window.tooltips) {
  242. // Patch loadTooltip to apply hash links after content is loaded
  243. const originalLoadTooltip = tooltips.loadTooltip;
  244. tooltips.loadTooltip = function(element, quoteUrl, sourceId, isInline) {
  245. originalLoadTooltip.apply(this, arguments);
  246. if (isInline) {
  247. // Wait for content to be fully loaded
  248. setTimeout(() => {
  249. addHashLinks(element);
  250. console.log('Hash links applied to loaded tooltip content:', quoteUrl);
  251. }, 0);
  252. }
  253. };
  254.  
  255. // Patch addLoadedTooltip to ensure hash links are applied
  256. const originalAddLoadedTooltip = tooltips.addLoadedTooltip;
  257. tooltips.addLoadedTooltip = function(htmlContents, tooltip, quoteUrl, replyId, isInline) {
  258. originalAddLoadedTooltip.apply(this, arguments);
  259. if (isInline) {
  260. addHashLinks(htmlContents);
  261. console.log('Hash links applied to inline tooltip content:', quoteUrl);
  262. }
  263. };
  264.  
  265. // Patch addInlineClick to apply hash links after appending
  266. const originalAddInlineClick = tooltips.addInlineClick;
  267. tooltips.addInlineClick = function(quote, innerPost, isBacklink, quoteTarget, sourceId) {
  268. if (!quote.href || quote.classList.contains('hash-link') || quote.closest('.hash-link-container') || quote.href.includes('#q')) {
  269. console.log('Skipped invalid or hash link:', quote.href || quote.textContent);
  270. return;
  271. }
  272. // Clone quote to remove existing listeners
  273. const newQuote = quote.cloneNode(true);
  274. quote.parentNode.replaceChild(newQuote, quote);
  275. quote = newQuote;
  276.  
  277. // Reapply hover events
  278. tooltips.addHoverEvents(quote, innerPost, quoteTarget, sourceId);
  279. console.log('Hover events reapplied for:', quoteTarget.quoteUrl);
  280.  
  281. // Add click handler
  282. quote.addEventListener('click', function(e) {
  283. console.log('linkQuote clicked:', quoteTarget.quoteUrl);
  284. if (!tooltips.inlineReplies) {
  285. console.log('inlineReplies disabled');
  286. return;
  287. }
  288. e.preventDefault();
  289. e.stopPropagation();
  290.  
  291. // Find or create replyPreview
  292. let replyPreview = innerPost.querySelector('.replyPreview');
  293. if (!replyPreview) {
  294. replyPreview = document.createElement('div');
  295. replyPreview.className = 'replyPreview';
  296. innerPost.appendChild(replyPreview);
  297. }
  298.  
  299. // Check for duplicates or loading
  300. if (tooltips.loadingPreviews[quoteTarget.quoteUrl] ||
  301. tooltips.quoteAlreadyAdded(quoteTarget.quoteUrl, innerPost)) {
  302. console.log('Duplicate or loading:', quoteTarget.quoteUrl);
  303. return;
  304. }
  305.  
  306. // Create and load inline post
  307. const placeHolder = document.createElement('div');
  308. placeHolder.style.whiteSpace = 'normal';
  309. placeHolder.className = 'inlineQuote';
  310. tooltips.loadTooltip(placeHolder, quoteTarget.quoteUrl, sourceId, true);
  311.  
  312. // Verify post loaded
  313. if (!placeHolder.querySelector('.linkSelf')) {
  314. console.log('Failed to load post:', quoteTarget.quoteUrl);
  315. return;
  316. }
  317.  
  318. // Add close button
  319. const close = document.createElement('a');
  320. close.innerText = 'X';
  321. close.className = 'closeInline';
  322. close.onclick = () => placeHolder.remove();
  323. placeHolder.querySelector('.postInfo').prepend(close);
  324.  
  325. // Process quotes in the new inline post
  326. Array.from(placeHolder.querySelectorAll('.linkQuote'))
  327. .forEach(a => tooltips.processQuote(a, false, true));
  328.  
  329. if (tooltips.bottomBacklinks) {
  330. const alts = placeHolder.querySelector('.altBacklinks');
  331. if (alts && alts.firstChild) {
  332. Array.from(alts.firstChild.children)
  333. .forEach(a => tooltips.processQuote(a, true));
  334. }
  335. }
  336.  
  337. // Append to replyPreview and apply hash links
  338. replyPreview.appendChild(placeHolder);
  339. addHashLinks(placeHolder);
  340. console.log('Inline post appended and hash links applied:', quoteTarget.quoteUrl);
  341.  
  342. tooltips.removeIfExists();
  343. }, true);
  344. };
  345.  
  346. // Patch processQuote to skip hash links
  347. const originalProcessQuote = tooltips.processQuote;
  348. tooltips.processQuote = function(quote, isBacklink) {
  349. if (!quote.href || quote.classList.contains('hash-link') || quote.closest('.hash-link-container') || quote.href.includes('#q')) {
  350. console.log('Skipped invalid or hash link in processQuote:', quote.href || quote.textContent);
  351. return;
  352. }
  353. originalProcessQuote.apply(this, arguments);
  354. };
  355. }
  356.  
  357. // Set up MutationObserver to handle dynamically added or updated backlinks and quote links
  358. const observer = new MutationObserver(mutations => {
  359. mutations.forEach(mutation => {
  360. if (mutation.addedNodes.length) {
  361. mutation.addedNodes.forEach(node => {
  362. if (node.nodeType === Node.ELEMENT_NODE) {
  363. // Check for new backlink or quote link <a> elements
  364. const newLinks = node.matches('.panelBacklinks a, .altBacklinks a, .divMessage .quoteLink') ? [node] : node.querySelectorAll('.panelBacklinks a, .altBacklinks a, .divMessage .quoteLink');
  365. newLinks.forEach(link => {
  366. addHashLinks(link.parentElement);
  367. console.log('Hash links applied to new link:', link.textContent);
  368. });
  369. }
  370. });
  371. }
  372. });
  373. });
  374.  
  375. // Observe changes to the posts container
  376. const postsContainer = document.querySelector('.divPosts') || document.body;
  377. observer.observe(postsContainer, {
  378. childList: true,
  379. subtree: true
  380. });
  381. })();
  382. //--Hash navigation
  383.  
  384. //Inline reply chains
  385.  
  386. (function() {
  387. 'use strict';
  388.  
  389. console.log('Userscript is running');
  390.  
  391. // Add CSS for visual nesting
  392. const style = document.createElement('style');
  393. style.innerHTML = `
  394. .inlineQuote .replyPreview {
  395. margin-left: 20px;
  396. border-left: 1px solid #ccc;
  397. padding-left: 10px;
  398. }
  399. .closeInline {
  400. color: #ff0000;
  401. cursor: pointer;
  402. margin-left: 5px;
  403. font-weight: bold;
  404. }
  405. `;
  406. document.head.appendChild(style);
  407.  
  408. // Wait for tooltips to initialize
  409. window.addEventListener('load', function() {
  410. if (!window.tooltips) {
  411. console.error('tooltips module not found');
  412. return;
  413. }
  414. console.log('tooltips module found');
  415.  
  416. // Ensure Inline Replies is enabled
  417. if (!tooltips.inlineReplies) {
  418. console.log('Enabling Inline Replies');
  419. localStorage.setItem('inlineReplies', 'true');
  420. tooltips.inlineReplies = true;
  421.  
  422. // Check and update the checkbox, retrying if not yet loaded
  423. const enableCheckbox = () => {
  424. const inlineCheckbox = document.getElementById('settings-SW5saW5lIFJlcGxpZX');
  425. if (inlineCheckbox) {
  426. inlineCheckbox.checked = true;
  427. console.log('Inline Replies checkbox checked');
  428. return true;
  429. }
  430. console.warn('Inline Replies checkbox not found, retrying...');
  431. return false;
  432. };
  433.  
  434. // Try immediately
  435. if (!enableCheckbox()) {
  436. // Retry every 500ms up to 5 seconds
  437. let attempts = 0;
  438. const maxAttempts = 10;
  439. const interval = setInterval(() => {
  440. if (enableCheckbox() || attempts >= maxAttempts) {
  441. clearInterval(interval);
  442. if (attempts >= maxAttempts) {
  443. console.error('Failed to find Inline Replies checkbox after retries');
  444. }
  445. }
  446. attempts++;
  447. }, 500);
  448. }
  449. } else {
  450. console.log('Inline Replies already enabled');
  451. }
  452.  
  453. // Override addLoadedTooltip to ensure replyPreview exists
  454. const originalAddLoadedTooltip = tooltips.addLoadedTooltip;
  455. tooltips.addLoadedTooltip = function(htmlContents, tooltip, quoteUrl, replyId, isInline) {
  456. console.log('addLoadedTooltip called for:', quoteUrl);
  457. originalAddLoadedTooltip.apply(this, arguments);
  458. if (isInline) {
  459. let replyPreview = htmlContents.querySelector('.replyPreview');
  460. if (!replyPreview) {
  461. replyPreview = document.createElement('div');
  462. replyPreview.className = 'replyPreview';
  463. htmlContents.appendChild(replyPreview);
  464. }
  465. }
  466. };
  467.  
  468. // Override addInlineClick for nested replies, excluding post number links
  469. tooltips.addInlineClick = function(quote, innerPost, isBacklink, quoteTarget, sourceId) {
  470. // Skip post number links (href starts with #q)
  471. if (quote.href.includes('#q')) {
  472. console.log('Skipping post number link:', quote.href);
  473. return;
  474. }
  475.  
  476. // Remove existing listeners by cloning
  477. const newQuote = quote.cloneNode(true);
  478. quote.parentNode.replaceChild(newQuote, quote);
  479. quote = newQuote;
  480.  
  481. // Reapply hover events to preserve preview functionality
  482. tooltips.addHoverEvents(quote, innerPost, quoteTarget, sourceId);
  483. console.log('Hover events reapplied for:', quoteTarget.quoteUrl);
  484.  
  485. // Add click handler
  486. quote.addEventListener('click', function(e) {
  487. console.log('linkQuote clicked:', quoteTarget.quoteUrl);
  488. if (!tooltips.inlineReplies) {
  489. console.log('inlineReplies disabled');
  490. return;
  491. }
  492. e.preventDefault();
  493. e.stopPropagation(); // Prevent site handlers
  494.  
  495. // Find or create replyPreview
  496. let replyPreview = innerPost.querySelector('.replyPreview');
  497. if (!replyPreview) {
  498. replyPreview = document.createElement('div');
  499. replyPreview.className = 'replyPreview';
  500. innerPost.appendChild(replyPreview);
  501. }
  502.  
  503. // Check for duplicates or loading
  504. if (tooltips.loadingPreviews[quoteTarget.quoteUrl] ||
  505. tooltips.quoteAlreadyAdded(quoteTarget.quoteUrl, innerPost)) {
  506. console.log('Duplicate or loading:', quoteTarget.quoteUrl);
  507. return;
  508. }
  509.  
  510. // Create and load inline post
  511. const placeHolder = document.createElement('div');
  512. placeHolder.style.whiteSpace = 'normal';
  513. placeHolder.className = 'inlineQuote';
  514. tooltips.loadTooltip(placeHolder, quoteTarget.quoteUrl, sourceId, true);
  515.  
  516. // Verify post loaded
  517. if (!placeHolder.querySelector('.linkSelf')) {
  518. console.log('Failed to load post:', quoteTarget.quoteUrl);
  519. return;
  520. }
  521.  
  522. // Add close button
  523. const close = document.createElement('a');
  524. close.innerText = 'X';
  525. close.className = 'closeInline';
  526. close.onclick = () => placeHolder.remove();
  527. placeHolder.querySelector('.postInfo').prepend(close);
  528.  
  529. // Process quotes in the new inline post
  530. Array.from(placeHolder.querySelectorAll('.linkQuote'))
  531. .forEach(a => tooltips.processQuote(a, false, true));
  532.  
  533. if (tooltips.bottomBacklinks) {
  534. const alts = placeHolder.querySelector('.altBacklinks');
  535. if (alts && alts.firstChild) {
  536. Array.from(alts.firstChild.children)
  537. .forEach(a => tooltips.processQuote(a, true));
  538. }
  539. }
  540.  
  541. // Append to replyPreview
  542. replyPreview.appendChild(placeHolder);
  543. console.log('Inline post appended:', quoteTarget.quoteUrl);
  544.  
  545. tooltips.removeIfExists();
  546. }, true); // Use capture phase
  547. };
  548.  
  549. // Reprocess all existing linkQuote and backlink elements, excluding post numbers
  550. console.log('Reprocessing linkQuote elements');
  551. const quotes = document.querySelectorAll('.linkQuote, .panelBacklinks a');
  552. quotes.forEach(quote => {
  553. const innerPost = quote.closest('.innerPost, .innerOP');
  554. if (!innerPost) {
  555. console.log('No innerPost found for quote:', quote.href);
  556. return;
  557. }
  558.  
  559. // Skip post number links
  560. if (quote.href.includes('#q')) {
  561. console.log('Skipping post number link:', quote.href);
  562. return;
  563. }
  564.  
  565. const isBacklink = quote.parentElement.classList.contains('panelBacklinks') ||
  566. quote.parentElement.classList.contains('altBacklinks');
  567. const quoteTarget = api.parsePostLink(quote.href);
  568. const sourceId = api.parsePostLink(innerPost.querySelector('.linkSelf').href).post;
  569.  
  570. tooltips.addInlineClick(quote, innerPost, isBacklink, quoteTarget, sourceId);
  571. });
  572.  
  573. // Observe for dynamically added posts
  574. const observer = new MutationObserver(mutations => {
  575. mutations.forEach(mutation => {
  576. mutation.addedNodes.forEach(node => {
  577. if (node.nodeType !== 1) return;
  578. const newQuotes = node.querySelectorAll('.linkQuote, .panelBacklinks a');
  579. newQuotes.forEach(quote => {
  580. if (quote.dataset.processed || quote.href.includes('#q')) {
  581. if (quote.href.includes('#q')) {
  582. console.log('Skipping post number link:', quote.href);
  583. }
  584. return;
  585. }
  586. quote.dataset.processed = 'true';
  587. const innerPost = quote.closest('.innerPost, .innerOP');
  588. if (!innerPost) return;
  589.  
  590. const isBacklink = quote.parentElement.classList.contains('panelBacklinks') ||
  591. quote.parentElement.classList.contains('altBacklinks');
  592. const quoteTarget = api.parsePostLink(quote.href);
  593. const sourceId = api.parsePostLink(innerPost.querySelector('.linkSelf').href).post;
  594.  
  595. tooltips.addInlineClick(quote, innerPost, isBacklink, quoteTarget, sourceId);
  596. });
  597. });
  598. });
  599. });
  600. observer.observe(document.querySelector('.divPosts') || document.body, {
  601. childList: true,
  602. subtree: true
  603. });
  604. console.log('MutationObserver set up');
  605. });
  606. })();
  607.  
  608. //--Inline replies
  609.  
  610. //Auto TOS Accept with Delay
  611. (function() {
  612. 'use strict';
  613.  
  614. // Check if on the disclaimer page
  615. if (window.location.pathname === '/.static/pages/disclaimer.html') {
  616. // Redirect to confirmed page after 1-second delay
  617. setTimeout(() => {
  618. window.location.replace('.static/pages/confirmed.html');
  619. console.log('Automatically redirected from disclaimer to confirmed page after 1-second delay');
  620. }, 1000);
  621. }
  622. })();
  623. //--Auto TOS Accept with Delay
  624.  
  625. //Media Auto-Preview
  626. // Auto-preview images and videos on hover for un-expanded thumbnails, disabling native hover
  627. (function() {
  628. 'use strict';
  629.  
  630. // Disable native hover preview
  631. localStorage.setItem('hoveringImage', 'false'); // Disable "Image Preview on Hover" setting
  632. if (window.thumbs && typeof window.thumbs.removeHoveringExpand === 'function') {
  633. window.thumbs.removeHoveringExpand(); // Remove native hover listeners
  634. }
  635. // Override addHoveringExpand to prevent re-enabling
  636. if (window.thumbs) {
  637. window.thumbs.addHoveringExpand = function() {
  638. // Do nothing to prevent native hover preview
  639. console.log('Native hover preview (addHoveringExpand) blocked by userscript');
  640. };
  641. }
  642.  
  643. // Supported file extensions for images and videos
  644. const supportedExtensions = {
  645. image: ['.gif', '.webp', '.png', '.jfif', '.pjpeg', '.jpeg', '.pjp', '.jpg', '.bmp', '.dib', '.svgz', '.svg'],
  646. video: ['.webm', '.m4v', '.mp4', '.ogm', '.ogv', '.avi', '.asx', '.mpg', '.mpeg']
  647. };
  648.  
  649. // Create preview container
  650. const previewContainer = document.createElement('div');
  651. previewContainer.style.position = 'fixed';
  652. previewContainer.style.zIndex = '1000';
  653. previewContainer.style.pointerEvents = 'none'; // Allow clicks to pass through
  654. previewContainer.style.display = 'none';
  655. document.body.appendChild(previewContainer);
  656.  
  657. // Function to check if URL is a supported image or video
  658. function isSupportedMedia(url) {
  659. const ext = (url.match(/\.[a-z0-9]+$/i) || [''])[0].toLowerCase();
  660. return supportedExtensions.image.includes(ext) || supportedExtensions.video.includes(ext);
  661. }
  662.  
  663. // Function to check if URL is a video
  664. function isVideo(url) {
  665. const ext = (url.match(/\.[a-z0-9]+$/i) || [''])[0].toLowerCase();
  666. return supportedExtensions.video.includes(ext);
  667. }
  668.  
  669. // Function to check if link is in un-expanded state
  670. function isUnexpanded(link) {
  671. const thumbnail = link.querySelector('img:not(.imgExpanded)');
  672. const expanded = link.querySelector('img.imgExpanded');
  673. return thumbnail && window.getComputedStyle(thumbnail).display !== 'none' &&
  674. (!expanded || window.getComputedStyle(expanded).display === 'none');
  675. }
  676.  
  677. // Function to calculate preview dimensions
  678. function getPreviewDimensions(naturalWidth, naturalHeight) {
  679. // Detect zoom level
  680. const zoomLevel = window.devicePixelRatio || 1; // Fallback to 1 if undefined
  681. // Content area (excludes scrollbar) for max size
  682. const maxWidth = document.documentElement.clientWidth;
  683. const maxHeight = document.documentElement.clientHeight;
  684. // Screen resolution for small media check
  685. const screenWidth = window.screen.width || 1920; // Fallback to 1920
  686. const screenHeight = window.screen.height || 1080; // Fallback to 1080
  687.  
  688. // If media fits within screen resolution, use full native size
  689. if (naturalWidth <= screenWidth && naturalHeight <= screenHeight) {
  690. let width = naturalWidth;
  691. let height = naturalHeight;
  692.  
  693. // If native size exceeds content area, scale down
  694. const scaleByWidth = maxWidth / width;
  695. const scaleByHeight = maxHeight / height;
  696. const scale = Math.min(scaleByWidth, scaleByHeight, 1);
  697. width = Math.round(width * scale);
  698. height = Math.round(height * scale);
  699.  
  700. return { width, height };
  701. }
  702.  
  703. // Otherwise, adjust for zoom and scale to fit content area
  704. let width = naturalWidth / zoomLevel;
  705. let height = naturalHeight / zoomLevel;
  706.  
  707. const scaleByWidth = maxWidth / width;
  708. const scaleByHeight = maxHeight / height;
  709. const scale = Math.min(scaleByWidth, scaleByHeight, 1);
  710. width = Math.round(width * scale);
  711. height = Math.round(height * scale);
  712.  
  713. return { width, height };
  714. }
  715.  
  716. // Function to position preview near cursor
  717. function positionPreview(event) {
  718. const mouseX = event.clientX;
  719. const mouseY = event.clientY;
  720. const previewWidth = previewContainer.offsetWidth;
  721. const previewHeight = previewContainer.offsetHeight;
  722.  
  723. // Skip if dimensions are not yet available
  724. if (previewWidth === 0 || previewHeight === 0) {
  725. return;
  726. }
  727.  
  728. // Use content area for positioning (excludes scrollbar)
  729. const maxWidth = document.documentElement.clientWidth;
  730. const maxHeight = document.documentElement.clientHeight;
  731.  
  732. // Calculate centered position
  733. const centerX = (maxWidth - previewWidth) / 2;
  734. const centerY = (maxHeight - previewHeight) / 2;
  735.  
  736. // Allow cursor to influence position with a bounded offset
  737. const maxOffset = 100; // Maximum pixels to shift from center
  738. const cursorOffsetX = Math.max(-maxOffset, Math.min(maxOffset, mouseX - maxWidth / 2));
  739. const cursorOffsetY = Math.max(-maxOffset, Math.min(maxOffset, mouseY - maxHeight / 2));
  740.  
  741. // Calculate initial position with cursor influence
  742. let left = centerX + cursorOffsetX;
  743. let top = centerY + cursorOffsetY;
  744.  
  745. // Ensure preview stays fully within content area
  746. left = Math.max(0, Math.min(left, maxWidth - previewWidth));
  747. top = Math.max(0, Math.min(top, maxHeight - previewHeight));
  748.  
  749. previewContainer.style.left = `${left}px`;
  750. previewContainer.style.top = `${top}px`;
  751. }
  752.  
  753. // Function to show preview
  754. function showPreview(link, event) {
  755. if (!isUnexpanded(link)) return; // Skip if expanded
  756. const url = link.href;
  757. if (!isSupportedMedia(url)) return;
  758.  
  759. // Clear existing preview
  760. previewContainer.innerHTML = '';
  761.  
  762. if (isVideo(url)) {
  763. // Create video element
  764. const video = document.createElement('video');
  765. video.src = url;
  766. video.autoplay = true;
  767. video.muted = false; // Play with audio
  768. video.loop = true;
  769. video.style.maxWidth = '100%';
  770. video.style.maxHeight = '100%';
  771.  
  772. // Set dimensions and position when metadata is loaded
  773. video.onloadedmetadata = () => {
  774. const { width, height } = getPreviewDimensions(video.videoWidth, video.videoHeight);
  775. video.width = width;
  776. video.height = height;
  777. previewContainer.style.width = `${width}px`;
  778. previewContainer.style.height = `${height}px`;
  779. previewContainer.style.display = 'block'; // Show after dimensions are set
  780. positionPreview(event);
  781. };
  782.  
  783. previewContainer.appendChild(video);
  784. } else {
  785. // Create image element
  786. const img = document.createElement('img');
  787. img.src = url;
  788. img.style.maxWidth = '100%';
  789. img.style.maxHeight = '100%';
  790.  
  791. // Set dimensions and position when image is loaded
  792. img.onload = () => {
  793. const { width, height } = getPreviewDimensions(img.naturalWidth, img.naturalHeight);
  794. img.width = width;
  795. img.height = height;
  796. previewContainer.style.width = `${width}px`;
  797. previewContainer.style.height = `${height}px`;
  798. previewContainer.style.display = 'block'; // Show after dimensions are set
  799. positionPreview(event);
  800. };
  801.  
  802. previewContainer.appendChild(img);
  803. }
  804. }
  805.  
  806. // Function to hide preview
  807. function hidePreview() {
  808. previewContainer.style.display = 'none';
  809. // Stop video playback
  810. const video = previewContainer.querySelector('video');
  811. if (video) {
  812. video.pause();
  813. video.currentTime = 0;
  814. }
  815. previewContainer.innerHTML = '';
  816. }
  817.  
  818. // Function to apply hover events to links
  819. function applyHoverEvents(container = document) {
  820. const links = container.querySelectorAll('.uploadCell a.imgLink');
  821. links.forEach(link => {
  822. // Skip if already processed
  823. if (link.dataset.previewProcessed) return;
  824. link.dataset.previewProcessed = 'true';
  825.  
  826. link.addEventListener('mouseenter', (e) => {
  827. showPreview(link, e);
  828. });
  829.  
  830. link.addEventListener('mousemove', (e) => {
  831. if (previewContainer.style.display === 'block') {
  832. positionPreview(e);
  833. }
  834. });
  835.  
  836. link.addEventListener('mouseleave', () => {
  837. hidePreview();
  838. });
  839.  
  840. // Hide preview on click if expanded
  841. link.addEventListener('click', () => {
  842. if (!isUnexpanded(link)) {
  843. hidePreview();
  844. }
  845. });
  846. });
  847. }
  848.  
  849. // Apply hover events to existing links on page load
  850. applyHoverEvents();
  851. console.log('Media preview events applied on page load');
  852.  
  853. // Patch inline reply logic to apply hover events to new inline content
  854. if (window.tooltips) {
  855. // Patch loadTooltip to apply hover events after content is loaded
  856. const originalLoadTooltip = tooltips.loadTooltip;
  857. tooltips.loadTooltip = function(element, quoteUrl, sourceId, isInline) {
  858. originalLoadTooltip.apply(this, arguments);
  859. if (isInline) {
  860. setTimeout(() => {
  861. applyHoverEvents(element);
  862. console.log('Media preview events applied to loaded tooltip content:', quoteUrl);
  863. }, 0);
  864. }
  865. };
  866.  
  867. // Patch addLoadedTooltip to ensure hover events are applied
  868. const originalAddLoadedTooltip = tooltips.addLoadedTooltip;
  869. tooltips.addLoadedTooltip = function(htmlContents, tooltip, quoteUrl, replyId, isInline) {
  870. originalAddLoadedTooltip.apply(this, arguments);
  871. if (isInline) {
  872. applyHoverEvents(htmlContents);
  873. console.log('Media preview events applied to inline tooltip content:', quoteUrl);
  874. }
  875. };
  876. }
  877.  
  878. // Set up MutationObserver to handle dynamically added posts
  879. const observer = new MutationObserver(mutations => {
  880. mutations.forEach(mutation => {
  881. if (mutation.addedNodes.length) {
  882. mutation.addedNodes.forEach(node => {
  883. if (node.nodeType === Node.ELEMENT_NODE) {
  884. // Handle new posts and inline replies
  885. const newLinks = node.matches('.uploadCell a.imgLink') ? [node] : node.querySelectorAll('.uploadCell a.imgLink');
  886. newLinks.forEach(link => {
  887. applyHoverEvents(link.parentElement);
  888. console.log('Media preview events applied to new link:', link.href);
  889. });
  890. }
  891. });
  892. }
  893. });
  894. });
  895.  
  896. // Observe changes to the posts container
  897. const postsContainer = document.querySelector('.divPosts') || document.body;
  898. observer.observe(postsContainer, {
  899. childList: true,
  900. subtree: true
  901. });
  902. })();
  903. //--Media Auto-Preview
  904.  
  905. //Post Age Tooltip
  906. // Show a tooltip with time elapsed since post when hovering over date/time
  907. (function() {
  908. 'use strict';
  909.  
  910. // Create tooltip container
  911. const tooltip = document.createElement('div');
  912. tooltip.style.position = 'fixed';
  913. tooltip.style.zIndex = '1000';
  914. tooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
  915. tooltip.style.color = '#fff';
  916. tooltip.style.padding = '5px 10px';
  917. tooltip.style.borderRadius = '4px';
  918. tooltip.style.fontSize = '12px';
  919. tooltip.style.pointerEvents = 'none';
  920. tooltip.style.display = 'none';
  921. document.body.appendChild(tooltip);
  922.  
  923. // Parse timestamp (e.g., "04/16/2025 (Wed) 21:23:21")
  924. function parseTimestamp(text) {
  925. const match = text.match(/^(\d{2})\/(\d{2})\/(\d{4}).*?(\d{2}):(\d{2}):(\d{2})$/);
  926. if (!match) return null;
  927. const [, month, day, year, hours, minutes, seconds] = match;
  928. const isoString = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
  929. const date = new Date(isoString);
  930. return isNaN(date.getTime()) ? null : date;
  931. }
  932.  
  933. // Format elapsed time
  934. function formatElapsedTime(postDate) {
  935. const now = new Date();
  936. const diffMs = now - postDate;
  937. if (diffMs < 0) return 'Just now';
  938.  
  939. const diffSeconds = Math.floor(diffMs / 1000);
  940. if (diffSeconds < 60) {
  941. return `${diffSeconds} second${diffSeconds === 1 ? '' : 's'} ago`;
  942. }
  943.  
  944. const diffMinutes = Math.floor(diffSeconds / 60);
  945. if (diffMinutes < 60) {
  946. return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
  947. }
  948.  
  949. const diffHours = Math.floor(diffMinutes / 60);
  950. if (diffHours < 24) {
  951. const remainingMinutes = diffMinutes % 60;
  952. return `${diffHours} hour${diffHours === 1 ? '' : 's'} and ${remainingMinutes} minute${remainingMinutes === 1 ? '' : 's'} ago`;
  953. }
  954.  
  955. const diffDays = Math.floor(diffHours / 24);
  956. const remainingHours = diffHours % 24;
  957. return `${diffDays} day${diffDays === 1 ? '' : 's'} and ${remainingHours} hour${remainingHours === 1 ? '' : 's'} ago`;
  958. }
  959.  
  960. // Position tooltip above element
  961. function positionTooltip(event, element) {
  962. const rect = element.getBoundingClientRect();
  963. const left = event.clientX;
  964. const top = rect.top - tooltip.offsetHeight - 5;
  965.  
  966. tooltip.style.left = `${left}px`;
  967. tooltip.style.top = `${top}px`;
  968. }
  969.  
  970. // Show tooltip
  971. function showTooltip(element, event) {
  972. const postDate = parseTimestamp(element.textContent);
  973. if (!postDate) {
  974. tooltip.style.display = 'none';
  975. return;
  976. }
  977.  
  978. tooltip.textContent = formatElapsedTime(postDate);
  979. tooltip.style.display = 'block';
  980. positionTooltip(event, element);
  981. }
  982.  
  983. // Hide tooltip
  984. function hideTooltip() {
  985. tooltip.style.display = 'none';
  986. }
  987.  
  988. // Apply tooltip events to labelCreated elements
  989. function applyTooltipEvents(container = document) {
  990. const dateSpans = container.querySelectorAll('span.labelCreated');
  991. dateSpans.forEach(span => {
  992. // Remove existing listeners to avoid duplicates
  993. span.removeEventListener('mouseenter', showTooltip);
  994. span.removeEventListener('mouseleave', hideTooltip);
  995.  
  996. span.addEventListener('mouseenter', (e) => {
  997. showTooltip(span, e);
  998. });
  999.  
  1000. span.addEventListener('mouseleave', () => {
  1001. hideTooltip();
  1002. });
  1003. });
  1004. }
  1005.  
  1006. // Apply tooltip events on page load
  1007. applyTooltipEvents();
  1008. console.log('Post age tooltip events applied on page load');
  1009.  
  1010. // Patch inline reply logic
  1011. if (window.tooltips) {
  1012. const originalLoadTooltip = tooltips.loadTooltip;
  1013. tooltips.loadTooltip = function(element, quoteUrl, sourceId, isInline) {
  1014. originalLoadTooltip.apply(this, arguments);
  1015. if (isInline) {
  1016. setTimeout(() => {
  1017. applyTooltipEvents(element);
  1018. console.log('Post age tooltip events applied to loaded tooltip content:', quoteUrl);
  1019. }, 0);
  1020. }
  1021. };
  1022.  
  1023. const originalAddLoadedTooltip = tooltips.addLoadedTooltip;
  1024. tooltips.addLoadedTooltip = function(htmlContents, tooltip, quoteUrl, replyId, isInline) {
  1025. originalAddLoadedTooltip.apply(this, arguments);
  1026. if (isInline) {
  1027. applyTooltipEvents(htmlContents);
  1028. console.log('Post age tooltip events applied to inline tooltip content:', quoteUrl);
  1029. }
  1030. };
  1031. }
  1032.  
  1033. //Force-Enable Local Times
  1034. (function() {
  1035. 'use strict';
  1036. localStorage.setItem('localTime', 'true');
  1037. console.log('Local Times setting enabled');
  1038. })();
  1039.  
  1040. // MutationObserver for dynamically added posts
  1041. const observer = new MutationObserver(mutations => {
  1042. mutations.forEach(mutation => {
  1043. if (mutation.addedNodes.length) {
  1044. mutation.addedNodes.forEach(node => {
  1045. if (node.nodeType === Node.ELEMENT_NODE) {
  1046. const newSpans = node.matches('span.labelCreated') ? [node] : node.querySelectorAll('span.labelCreated');
  1047. newSpans.forEach(span => {
  1048. applyTooltipEvents(span.parentElement);
  1049. console.log('Post age tooltip events applied to new span:', span.textContent);
  1050. });
  1051. }
  1052. });
  1053. }
  1054. });
  1055. });
  1056.  
  1057. // Observe posts container
  1058. const postsContainer = document.querySelector('.divPosts') || document.body;
  1059. observer.observe(postsContainer, {
  1060. childList: true,
  1061. subtree: true
  1062. });
  1063. })();
  1064. //--Post Age Tooltip
  1065.  
  1066. //Last Read Post Tracker
  1067. (function() {
  1068. 'use strict';
  1069.  
  1070. // Only run on thread pages (e.g., /vyt/res/24600.html)
  1071. if (!window.location.pathname.match(/\/res\/\d+\.html$/)) {
  1072. console.log('Not a thread page, exiting Last Read Post Tracker');
  1073. return;
  1074. }
  1075.  
  1076. // Get thread ID from URL (e.g., "24600" from /vyt/res/24600.html)
  1077. const threadIdMatch = window.location.pathname.match(/(\d+)\.html$/);
  1078. if (!threadIdMatch) {
  1079. console.error('Could not extract thread ID from URL:', window.location.pathname);
  1080. return;
  1081. }
  1082. const threadId = threadIdMatch[1];
  1083.  
  1084. // Load last read posts from localStorage
  1085. let lastReadPosts = {};
  1086. try {
  1087. lastReadPosts = JSON.parse(localStorage.getItem('lastReadPosts') || '{}');
  1088. } catch (e) {
  1089. console.error('Failed to parse lastReadPosts from localStorage:', e);
  1090. }
  1091. let lastReadPostId = lastReadPosts[threadId] || null;
  1092. let currentArrow = null;
  1093.  
  1094. // Throttle function to limit scroll event frequency
  1095. function throttle(fn, wait) {
  1096. let lastCall = 0;
  1097. return function(...args) {
  1098. const now = Date.now();
  1099. if (now - lastCall >= wait) {
  1100. lastCall = now;
  1101. fn(...args);
  1102. }
  1103. };
  1104. }
  1105.  
  1106. // Add arrow to a post (only called on page load)
  1107. function addArrow(postContainer) {
  1108. if (currentArrow) {
  1109. currentArrow.remove();
  1110. currentArrow = null;
  1111. }
  1112. const postInfo = postContainer.querySelector('.postInfo.title');
  1113. if (!postInfo) {
  1114. console.error('postInfo.title not found in postContainer:', postContainer.outerHTML);
  1115. return;
  1116. }
  1117. const arrow = document.createElement('span');
  1118. arrow.textContent = '→';
  1119. arrow.style.color = '#ff0000';
  1120. arrow.style.marginLeft = '5px';
  1121. postInfo.appendChild(arrow);
  1122. currentArrow = arrow;
  1123. console.log(`Added arrow to post on load: ${postContainer.id || postContainer.className}`);
  1124. }
  1125.  
  1126. // Update last read post based on scroll position (no arrow during scroll)
  1127. function updateLastReadPost() {
  1128. const posts = document.querySelectorAll('.postCell, .postContainer');
  1129. if (!posts.length) {
  1130. console.warn('No post elements found. Available classes:',
  1131. Array.from(document.querySelectorAll('[class*="post"], [class*="reply"]'))
  1132. .map(el => el.className)
  1133. .filter((v, i, a) => a.indexOf(v) === i));
  1134. // Retry after a short delay if no posts are found
  1135. setTimeout(() => requestAnimationFrame(updateLastReadPost), 500);
  1136. return;
  1137. }
  1138.  
  1139. let newLastReadPostId = lastReadPostId;
  1140. posts.forEach(post => {
  1141. const rect = post.getBoundingClientRect();
  1142. // Extract post ID from id or linkQuote
  1143. let postId = post.id.match(/^(?:pc|p|post-)?(\d+)$/)?.[1];
  1144. if (!postId) {
  1145. const linkQuote = post.querySelector('.linkQuote');
  1146. postId = linkQuote?.textContent.trim().replace('>>', '') || null;
  1147. }
  1148. if (!postId) {
  1149. console.warn('Could not extract post ID from:', post.outerHTML);
  1150. return;
  1151. }
  1152. // Consider post read if its top is above viewport center and visible
  1153. if (rect.top < window.innerHeight / 2 && rect.bottom > 0) {
  1154. newLastReadPostId = postId;
  1155. }
  1156. });
  1157.  
  1158. if (newLastReadPostId && newLastReadPostId !== lastReadPostId) {
  1159. lastReadPostId = newLastReadPostId;
  1160. console.log(`Tracked last read post for thread ${threadId}: ${lastReadPostId} (no arrow)`);
  1161. }
  1162. }
  1163.  
  1164. // Save last read post to localStorage when leaving the thread
  1165. function saveLastReadPost() {
  1166. if (lastReadPostId) {
  1167. lastReadPosts[threadId] = lastReadPostId;
  1168. try {
  1169. localStorage.setItem('lastReadPosts', JSON.stringify(lastReadPosts));
  1170. console.log(`Saved last read post for thread ${threadId}: ${lastReadPostId}`);
  1171. } catch (e) {
  1172. console.error('Failed to save lastReadPosts to localStorage:', e);
  1173. }
  1174. }
  1175. }
  1176.  
  1177. // Scroll to last read post on load and show arrow (MODIFIED for v2.5.2 base)
  1178. function scrollToLastReadPost() {
  1179. // --- Check for conditions where we should NOT scroll to the stored last read post ---
  1180. const currentHash = window.location.hash;
  1181. const referrer = document.referrer; // Check referrer
  1182. // Check if referrer is from an overboard page
  1183. const isFromOverboard = referrer.includes('/overboard') || referrer.includes('/sfw');
  1184. // Check if the hash targets a specific post (e.g., #12345)
  1185. const hasPostHash = currentHash.match(/^#(\d+)$/);
  1186.  
  1187. // Condition 1: Came from overboard AND there's a specific post hash (#postId)
  1188. if (hasPostHash && isFromOverboard) {
  1189. console.log('[Last Read Tracker] Overboard navigation with post hash detected. Skipping scroll to last read post. Allowing default browser scroll.');
  1190. // Let the browser handle the scroll based on the hash.
  1191. // The Shared Link Handler below will also see this hash but might refine scroll/clear hash later if needed.
  1192. return; // Exit without scrolling to stored position
  1193. }
  1194.  
  1195. // Condition 2: Page loaded with a specific post hash (#postId), even if not from overboard
  1196. // This check prevents this module from overriding the Shared Link Handler's job,
  1197. // which should handle scrolling to the *target* post ID in this case.
  1198. if (hasPostHash && !isFromOverboard) {
  1199. console.log('[Last Read Tracker] Initial post hash detected (not from overboard). Skipping scroll to stored last read post (handled by Shared Link Handler).');
  1200. return; // Exit without scrolling to stored position
  1201. }
  1202.  
  1203. // --- If neither condition above was met, proceed with original logic ---
  1204. // Check if we have a stored lastReadPostId for this thread
  1205. if (lastReadPostId) {
  1206. // Find the post container using the lastReadPostId FROM STORAGE
  1207. // Original querySelector from v2.5.2:
  1208. const postContainer = document.querySelector(`[id="pc${lastReadPostId}"], [id="p${lastReadPostId}"], [id="post-${lastReadPostId}"], .postCell .linkQuote[href*="${lastReadPostId}"], .postContainer .linkQuote[href*="${lastReadPostId}"]`)?.closest('.postCell, .postContainer');
  1209.  
  1210. if (postContainer) {
  1211. // Scroll to the *stored* last read post because no initial #postId hash was present
  1212. postContainer.scrollIntoView({ behavior: 'smooth', block: 'center' });
  1213. addArrow(postContainer); // Add arrow only when scrolling to stored position
  1214. console.log(`[Last Read Tracker] Scrolled to stored last read post: ${lastReadPostId}`);
  1215. } else {
  1216. // Post container not found in DOM yet, maybe retry
  1217. console.warn(`[Last Read Tracker] Stored last read post container for ID ${lastReadPostId} not found, retrying in 500ms...`);
  1218. // Retry only if the DOM might still be loading (don't retry indefinitely if Conditions 1 or 2 were met earlier)
  1219. setTimeout(scrollToLastReadPost, 500);
  1220. }
  1221. } else {
  1222. // No stored last read post ID for this thread
  1223. console.log('[Last Read Tracker] No stored last read post found for thread:', threadId);
  1224. }
  1225. }
  1226.  
  1227. // Wait for DOM to be fully ready
  1228. function initialize() {
  1229. if (document.readyState === 'complete' || document.readyState === 'interactive') {
  1230. scrollToLastReadPost();
  1231. // Attach throttled scroll handler using requestAnimationFrame
  1232. const throttledUpdate = throttle(() => requestAnimationFrame(updateLastReadPost), 200);
  1233. window.addEventListener('scroll', throttledUpdate);
  1234. // Log DOM state for debugging
  1235. console.log('Initial post elements found:', document.querySelectorAll('.postCell, .postContainer').length);
  1236. } else {
  1237. setTimeout(initialize, 100); // Retry until DOM is ready
  1238. }
  1239. }
  1240.  
  1241. // Start initialization
  1242. initialize();
  1243.  
  1244. // Save last read post when leaving the thread
  1245. window.addEventListener('beforeunload', saveLastReadPost);
  1246.  
  1247. console.log('Last Read Post Tracker initialized for thread', threadId);
  1248. })();
  1249. //--Last Read Post Tracker
  1250.  
  1251. //Post Number Click Hash Purge
  1252. (function() {
  1253. 'use strict';
  1254.  
  1255. // Event delegation for post number clicks (.linkQuote with #q<postId> in .postInfo.title)
  1256. document.addEventListener('click', function(e) {
  1257. const link = e.target.closest('.postInfo.title .linkQuote[href*="#q"]');
  1258. if (link) {
  1259. e.preventDefault(); // Block qr.js's default hash-setting
  1260. e.stopPropagation(); // Stop other handlers
  1261. const postId = link.href.match(/#q(\d+)/)?.[1];
  1262. if (!postId) {
  1263. console.warn('Could not extract post ID from link:', link.href);
  1264. return;
  1265. }
  1266. const post = document.getElementById(postId);
  1267. if (!post) {
  1268. console.warn(`Post ${postId} not found for quick reply`);
  1269. return;
  1270. }
  1271.  
  1272. // Preserve current scroll position
  1273. const scrollX = window.scrollX;
  1274. const scrollY = window.scrollY;
  1275. console.log(`Preserving scroll position: x=${scrollX}, y=${scrollY}`);
  1276.  
  1277. // Temporarily block scrollIntoView to prevent qr.js scrolling
  1278. const originalScrollIntoView = Element.prototype.scrollIntoView;
  1279. Element.prototype.scrollIntoView = function() {
  1280. console.log(`Blocked scrollIntoView for post ${postId} during click`);
  1281. };
  1282.  
  1283. // Manually trigger quick reply
  1284. if (window.qr && typeof window.qr.showQr === 'function') {
  1285. window.qr.showQr(postId);
  1286. // Restore scrollIntoView
  1287. Element.prototype.scrollIntoView = originalScrollIntoView;
  1288. // Clear any hash using only history.replaceState
  1289. history.replaceState(null, '', window.location.pathname);
  1290. // Ensure no residual hash
  1291. if (window.location.hash) {
  1292. console.log(`Residual hash detected: ${window.location.hash}, clearing`);
  1293. history.replaceState(null, '', window.location.pathname);
  1294. }
  1295. // Restore scroll position to counter any changes
  1296. window.scrollTo(scrollX, scrollY);
  1297. console.log(`Post number click #q${postId}, opened quick reply, cleared hash, restored scroll: x=${scrollX}, y=${scrollY}`);
  1298. } else {
  1299. console.warn('qr.showQr not available, falling back to default behavior');
  1300. // Allow default behavior if qr.js is unavailable
  1301. Element.prototype.scrollIntoView = originalScrollIntoView;
  1302. window.location.hash = `#q${postId}`;
  1303. }
  1304. }
  1305. }, true);
  1306. })();
  1307. //--Post Number Click Hash Purge
  1308.  
  1309. //Quick Reply Clear Button
  1310. (function() {
  1311. 'use strict';
  1312.  
  1313. // Function to add Clear button to quick reply form
  1314. function addClearButton() {
  1315. const qrForm = document.querySelector('#quick-reply');
  1316. if (!qrForm) {
  1317. console.log('Quick reply form not found');
  1318. return;
  1319. }
  1320.  
  1321. // Check if Clear button already exists
  1322. if (qrForm.querySelector('.qr-clear-button')) {
  1323. console.log('Clear button already added');
  1324. return;
  1325. }
  1326.  
  1327. // Create Clear button
  1328. const clearButton = document.createElement('button');
  1329. clearButton.type = 'button'; // Prevent form submission
  1330. clearButton.className = 'qr-clear-button';
  1331. clearButton.textContent = 'Clear';
  1332. clearButton.style.marginLeft = '5px';
  1333. clearButton.style.padding = '2px 6px';
  1334. clearButton.style.cursor = 'pointer';
  1335. clearButton.style.border = '1px solid';
  1336. clearButton.style.borderRadius = '3px';
  1337.  
  1338. // Add click handler to clear all fields
  1339. clearButton.addEventListener('click', () => {
  1340. const qrBody = qrForm.querySelector('#qrbody');
  1341. const qrName = qrForm.querySelector('#qrname');
  1342. const qrSubject = qrForm.querySelector('#qrsubject');
  1343. if (qrBody) qrBody.value = '';
  1344. if (qrName) qrName.value = '';
  1345. if (qrSubject) qrSubject.value = '';
  1346. console.log('Cleared all quick reply fields');
  1347. });
  1348.  
  1349. // Insert button after the submit button or at the end of the form
  1350. const submitButton = qrForm.querySelector('input[type="submit"]');
  1351. if (submitButton) {
  1352. submitButton.insertAdjacentElement('afterend', clearButton);
  1353. } else {
  1354. qrForm.appendChild(clearButton);
  1355. }
  1356. console.log('Added Clear button to quick reply form');
  1357. }
  1358.  
  1359. // Function to clear message body only
  1360. function clearMessageBody() {
  1361. const qrBody = document.querySelector('#qrbody');
  1362. if (qrBody) {
  1363. qrBody.value = '';
  1364. console.log('Cleared quick reply message body');
  1365. } else {
  1366. console.log('Quick reply message body not found');
  1367. }
  1368. }
  1369.  
  1370. // Track quick reply display state
  1371. let isQrVisible = document.querySelector('#quick-reply') && window.getComputedStyle(document.querySelector('#quick-reply')).display !== 'none';
  1372.  
  1373. // Observe quick reply form for display changes
  1374. const observer = new MutationObserver(mutations => {
  1375. mutations.forEach(mutation => {
  1376. if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
  1377. const qrForm = document.querySelector('#quick-reply');
  1378. if (!qrForm) return;
  1379. const isNowVisible = window.getComputedStyle(qrForm).display !== 'none';
  1380. if (isNowVisible && !isQrVisible) {
  1381. // Quick reply opened
  1382. addClearButton();
  1383. console.log('Quick reply opened, added Clear button');
  1384. } else if (!isNowVisible && isQrVisible) {
  1385. // Quick reply closed
  1386. clearMessageBody();
  1387. }
  1388. isQrVisible = isNowVisible;
  1389. }
  1390. });
  1391. });
  1392.  
  1393. // Start observing the quick reply form (if it exists)
  1394. const qrForm = document.querySelector('#quick-reply');
  1395. if (qrForm) {
  1396. observer.observe(qrForm, {
  1397. attributes: true,
  1398. attributeFilter: ['style']
  1399. });
  1400. // Initial check
  1401. if (window.getComputedStyle(qrForm).display !== 'none') {
  1402. addClearButton();
  1403. isQrVisible = true;
  1404. }
  1405. }
  1406.  
  1407. // Handle direct close button clicks
  1408. document.addEventListener('click', (e) => {
  1409. if (e.target.closest('.close-btn')) {
  1410. clearMessageBody();
  1411. }
  1412. }, true);
  1413.  
  1414. console.log('Quick Reply Clear Button initialized');
  1415. })();
  1416. //--Quick Reply Clear Button
  1417.  
  1418. //Hash Quote Click Hash Purge
  1419. (function() {
  1420. 'use strict';
  1421.  
  1422. // Event delegation for hash quote clicks (.hash-link)
  1423. document.addEventListener('click', function(e) {
  1424. if (e.target.classList.contains('hash-link')) {
  1425. e.preventDefault(); // Block original Hash navigation handler
  1426. e.stopPropagation(); // Stop other handlers
  1427. const link = e.target.closest('.hash-link-container').previousElementSibling;
  1428. if (!link || !link.textContent.startsWith('>>')) {
  1429. console.warn('Invalid hash link or no associated quote:', e.target);
  1430. return;
  1431. }
  1432. const postId = link.textContent.replace('>>', '');
  1433. const post = document.getElementById(postId);
  1434. if (!post) {
  1435. console.warn(`Post ${postId} not found for hash quote navigation`);
  1436. return;
  1437. }
  1438.  
  1439. // Preserve current scroll position as fallback
  1440. const scrollX = window.scrollX;
  1441. const scrollY = window.scrollY;
  1442. console.log(`Preserving scroll position for hash quote: x=${scrollX}, y=${scrollY}`);
  1443.  
  1444. // Scroll to post
  1445. post.scrollIntoView({ behavior: 'smooth', block: 'center' });
  1446. // Set hash temporarily to trigger scroll (if needed by browser)
  1447. window.location.hash = `#${postId}`;
  1448. // Immediately clear hash
  1449. history.replaceState(null, '', window.location.pathname);
  1450. // Ensure no residual hash
  1451. if (window.location.hash) {
  1452. console.log(`Residual hash detected: ${window.location.hash}, clearing`);
  1453. history.replaceState(null, '', window.location.pathname);
  1454. }
  1455. // Restore scroll position if browser overrides
  1456. window.scrollTo(scrollX, scrollY);
  1457. console.log(`Hash quote click #${postId}, scrolled to post, cleared hash, restored scroll: x=${scrollX}, y=${scrollY}`);
  1458. }
  1459. }, true);
  1460. })();
  1461. //--Hash Quote Click Hash Purge
  1462.  
  1463. //Shared Post Link Handler with Overboard Handling (for v2.5.2 base)
  1464. (function() {
  1465. 'use strict';
  1466.  
  1467. // Only run on thread pages
  1468. if (!window.location.pathname.match(/\/res\/\d+\.html$/)) {
  1469. // console.log('[Shared Link Handler] Not a thread page, exiting.');
  1470. return;
  1471. }
  1472.  
  1473. const initialHash = window.location.hash;
  1474. const referrer = document.referrer;
  1475. const isFromOverboard = referrer.includes('/overboard') || referrer.includes('/sfw');
  1476. console.log(`[Shared Link Handler] Initial Load - Hash: "${initialHash}", Referrer: "${referrer}", FromOverboard: ${isFromOverboard}`);
  1477.  
  1478. const postIdMatch = initialHash.match(/^#(\d+)$/);
  1479. const isDirectPostLink = !!postIdMatch;
  1480. const targetPostId = postIdMatch ? postIdMatch[1] : null;
  1481.  
  1482. // Handle direct shared links (e.g., #123456)
  1483. if (isDirectPostLink && targetPostId) {
  1484. console.log(`[Shared Link Handler] Direct post link detected: #${targetPostId}`);
  1485. // The modified Last Read Tracker already prevents scrolling to the *stored* position
  1486. // if this hash exists. Now we just need to handle the scrolling *to the target*
  1487. // and the hash clearing, respecting the overboard case.
  1488.  
  1489. window.addEventListener('load', () => {
  1490. // Use a small timeout to allow the browser's potential initial scroll to happen first
  1491. setTimeout(() => {
  1492. const post = document.getElementById(targetPostId) ||
  1493. document.querySelector(`.postCell .linkQuote[href*="${targetPostId}"], .postContainer .linkQuote[href*="${targetPostId}"]`)?.closest('.postCell, .postContainer');
  1494.  
  1495. if (post) {
  1496. if (!isFromOverboard) {
  1497. // If NOT from overboard, ensure we scroll smoothly to the target post
  1498. console.log('[Shared Link Handler] Scrolling to target post (not from overboard).');
  1499. post.scrollIntoView({ behavior: 'smooth', block: 'center' });
  1500. // Clear the hash AFTER scrolling to prevent conflicts
  1501. history.replaceState(null, '', window.location.pathname);
  1502. console.log('[Shared Link Handler] Cleared shared post hash after scrolling.');
  1503. } else {
  1504. // If FROM overboard, the browser should handle the initial scroll.
  1505. // We *still* want to clear the hash afterwards to prevent conflicts
  1506. // with the Last Read Tracker saving logic or subsequent interactions.
  1507. console.log('[Shared Link Handler] From overboard link. Browser should have scrolled. Clearing hash.');
  1508. history.replaceState(null, '', window.location.pathname);
  1509. console.log('[Shared Link Handler] Cleared shared post hash (from overboard).');
  1510. }
  1511. } else {
  1512. // Post specified in hash not found
  1513. console.warn(`[Shared Link Handler] Shared post ${targetPostId} not found.`);
  1514. // Clear the invalid hash anyway
  1515. history.replaceState(null, '', window.location.pathname);
  1516. console.log('[Shared Link Handler] Cleared non-existent shared post hash.');
  1517. }
  1518. }, 100); // 100ms delay
  1519. }, { once: true });
  1520. }
  1521. // Handle quick reply hashes (#q<postId>) on load (regardless of referrer)
  1522. else if (initialHash.match(/^#q\d+$/)) {
  1523. console.log('[Shared Link Handler] Quick reply hash detected on load.');
  1524. window.addEventListener('load', () => {
  1525. // Preserve current scroll position (could be 0,0 or where Last Read Tracker scrolled)
  1526. const scrollX = window.scrollX;
  1527. const scrollY = window.scrollY;
  1528. // Clear quick reply hash
  1529. history.replaceState(null, '', window.location.pathname);
  1530. // Restore scroll position just in case clearing the hash triggered a scroll
  1531. window.scrollTo(scrollX, scrollY);
  1532. console.log(`[Shared Link Handler] Cleared quick reply hash ${initialHash} on load, restored scroll: x=${scrollX}, y=${scrollY}`);
  1533. }, { once: true });
  1534. }
  1535. // If no initial hash, do nothing - Last Read Tracker handles it.
  1536.  
  1537. // --- Hash Change Listener ---
  1538. // Block ALL default hashchange scrolling triggered by site scripts or manual hash changes
  1539. // for post/QR hashes, as the script should manage scrolling and state.
  1540. window.addEventListener('hashchange', (e) => {
  1541. const currentHash = window.location.hash;
  1542. // Check for #q<digits> or #<digits>
  1543. if (currentHash.match(/^#(q)?\d+$/)) {
  1544. console.log(`[Shared Link Handler] Hashchange event detected for ${currentHash}. Preventing default and clearing.`);
  1545. e.preventDefault(); // Prevent default scroll/action
  1546. e.stopPropagation(); // Prevent other listeners (like site's qr.js)
  1547. const scrollX = window.scrollX;
  1548. const scrollY = window.scrollY;
  1549. // Clear the hash immediately
  1550. history.replaceState(null, '', window.location.pathname);
  1551. // Restore scroll position
  1552. window.scrollTo(scrollX, scrollY);
  1553. // console.log(`[Shared Link Handler] Blocked hashchange, cleared hash ${currentHash}, restored scroll: x=${scrollX}, y=${scrollY}`);
  1554. }
  1555. }, true); // Use capture phase
  1556.  
  1557. console.log('[Shared Link Handler] Initialized.');
  1558. })();
  1559. //--Shared Post Link Handler with Overboard Handling (for v2.5.2 base)