leafscriptbro / 4chan Thread Follower

// ==UserScript==
// @name         4chan Thread Follower
// @license      MIT
// @version      0.15.0
// @description  Follow threads on 4chan. Adds checkboxes to the bottom of threads to auto-scroll to the bottom and to follow links to new threads.
// @author       leafscriptbro
// @match        https://boards.4chan.org/*
// @match        https://boards.4channel.org/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=4chan.org
// @downloadURL  https://openuserjs.org/install/leafscriptbro/4chan_Thread_Follower.user.js
// @updateURL    https://openuserjs.org/meta/leafscriptbro/4chan_Thread_Follower.meta.js
// @homepage     https://openuserjs.org/scripts/leafscriptbro/4chan_Thread_Follower
// @grant        none
// ==/UserScript==

'use strict';

// Global settings

let progName = "4chan Thread Follower";
let settingsName = "4chan-thread-follower-settings";
let openInNewTab = true;

let autoScrollCheckboxId = "auto-scroll-to-bottom-checkbox";
let autoScrollCheckboxLabel = "Auto-scroll to bottom";
let autoScrollSettingName = "auto-scroll-to-bottom";
let autoScrollBottomOffset = 190;
let autoScrollUpdateInterval = 500;

let followLinksCheckboxId = "follow-new-general-links-checkbox";
let followLinksCheckboxLabel = "Follow new general links";
let followLinksSettingName = "follow-new-general-links";
let newGeneralThreadPattern = /\b(migrate|fresh(ly)? baked|fresh (th|b)read|new|next (th|b)read|get in(to)?)\b/i;
let followLinksUpdateInterval = 1000;
let minFollowReplyNumber = 150;
let minFollowLinks = 2;

// Performance/persistence optimization - get settings one time on page load
let settings = getSettings();

// Global functions

function getBottomScrollPosition() {
  return document.body.scrollHeight - window.innerHeight - autoScrollBottomOffset;
}

function getAutoScrollCheckbox() {
  return document.getElementById(autoScrollCheckboxId);
}

function getFollowLinksCheckbox() {
  return document.getElementById(followLinksCheckboxId);
}

function getSettings() {
  let settings = localStorage[settingsName] || "{}";

  let parsedSettings = {};
  try {
    parsedSettings = JSON.parse(settings);
  }
  catch (error) {
    console.error(progName + ": error parsing settings object");
    console.error(error);
  }

  return parsedSettings;
}

function getPageSettings(href = window.location.href) {
  return getSettings()[href] || {};
}

function setPageSettings(href = window.location.href, newSettings = {}) {
  settings[href] = newSettings;
  localStorage[settingsName] = JSON.stringify(settings);
}

function setPageSetting(href, settingName, settingValue) {
  let pageSettings = getPageSettings(href);

  pageSettings[settingName] = settingValue;
  settings[href] = pageSettings;
  localStorage[settingsName] = JSON.stringify(settings);
}

// Disable following links to avoid being unable to access the page after changing URL
function resetFollowLinksSetting() {
  setPageSetting(window.location.href, followLinksSettingName, false);
  if (followLinksCheckbox) {
    followLinksCheckbox.checked = false;
  }
}

// Get the bottom nav links
let navLinksBotElements = document.getElementsByClassName("navLinksBot");
if (navLinksBotElements.length === 0) {
  console.warn(progName + ": warning: cannot find bottom nav links, aborting");
  return;
}
if (navLinksBotElements.length > 1) {
  console.warn(progName + ": warning: more than one bottom nav links found, using the first one");
}
let navLinksBot = navLinksBotElements[0];

// Add our checkboxes to the bottom page
navLinksBot.innerHTML += "<span> [ <label><input type=\"checkbox\" id=\"" + autoScrollCheckboxId + "\" title=\"" + autoScrollCheckboxLabel + "\"> " + autoScrollCheckboxLabel + "</label> ] </span>";
navLinksBot.innerHTML += "<span> [ <label><input type=\"checkbox\" id=\"" + followLinksCheckboxId + "\" title=\"" + followLinksCheckboxLabel + "\"> " + followLinksCheckboxLabel + "</label> ] </span>";

// Get initial settings
let initialSettings = getPageSettings();

// Get auto-scroll checkbox
let autoScrollCheckbox = getAutoScrollCheckbox();
if (!autoScrollCheckbox) {
  console.warn(progName + ": warning: can't find the \"" + autoScrollCheckboxLabel + "\" checkbox");
}

// Get follow links checkbox
let followLinksCheckbox = getFollowLinksCheckbox();
if (!followLinksCheckbox) {
  console.warn(progName + ": warning: can't find the \"" + followLinksCheckboxLabel + "\" checkbox");
}

// Set initial checked values
if (autoScrollCheckbox && initialSettings[autoScrollSettingName]) {
  autoScrollCheckbox.checked = true;
}
if (followLinksCheckbox && initialSettings[followLinksSettingName]) {
  followLinksCheckbox.checked = true;
}

// Set on change functions
if (autoScrollCheckbox) {
  autoScrollCheckbox.addEventListener("change", function onChangeAutoScrollCheckbox() {
    setPageSetting(window.location.href, autoScrollSettingName, autoScrollCheckbox.checked);
    if (autoScrollCheckbox.checked) {
      window.scrollTo(0, getBottomScrollPosition());
    }
  });
}
if (followLinksCheckbox) {
  followLinksCheckbox.addEventListener("change", function onChangeFollowLinksCheckbox() {
    setPageSetting(window.location.href, followLinksSettingName, followLinksCheckbox.checked);
  });
}

let isAutoScrollPaused = false;
let targetHref = null;

// Set global scroll event listeners

document.addEventListener("scroll", function globalScrollEventListener() {
  isAutoScrollPaused = window.scrollY < getBottomScrollPosition();
});

document.addEventListener("click", function globalMouseMoveEventListener() {
  // Navigate to target href if it failed before in a click callback to get around browser restrictions
  if (targetHref) {
    window.open(targetHref, "_blank").focus();
    resetFollowLinksSetting();
    targetHref = null;
  }
});

// Set auto-scroll update interval
setInterval(function autoScrollIntervalCallback() {
  if (!isAutoScrollPaused && autoScrollCheckbox && autoScrollCheckbox.checked) {
    // Scroll to bottom
    let currentScroll = window.scrollY;
    let newScroll = getBottomScrollPosition();
    if (currentScroll < newScroll) {
      window.scrollTo(0, newScroll);
    }
  }
}, autoScrollUpdateInterval);

// Set follow links update interval
let didWarnOnUrlParseError = false;
setInterval(function followLinksAutoScrollInterval() {
  if (followLinksCheckbox && followLinksCheckbox.checked && !targetHref) {
    // Get board name from URL
    let pathMatch = window.location.pathname.match("/([^/]+)/thread/[0-9]+");
    if (!pathMatch) {
      if (!didWarnOnUrlParseError) {
        console.warn(progName + ": warning: can't parse current board from URL pathname");
        didWarnOnUrlParseError = true;
      }
      return;
    }
    let currentBoard = pathMatch[1];

    // Get the contents of all replies
    let replyMessageElements = document.querySelectorAll(".replyContainer .postMessage");
    // Get only the replies after the minimum number
    replyMessageElements = Array.from(replyMessageElements).slice(minFollowReplyNumber);

    for (let replyMessageElement of replyMessageElements) {
      // Get all cross-thread links in the reply
      let crossThreadLinks = replyMessageElement.querySelectorAll("[href*=\"/" + currentBoard + "/thread/\"][class=\"quotelink\"]");

      if (crossThreadLinks.length >= minFollowLinks) {
        // Get the href of the first link
        let firstCrossThreadLinkHref = crossThreadLinks[0].href;

        // Check for multiple unique links
        let hasMultipleUniqueLinks = false;
        for (let crossThreadLink of crossThreadLinks) {
          if (crossThreadLink.href != firstCrossThreadLinkHref) {
            hasMultipleUniqueLinks = true;
            break;
          }
        }

        if (!hasMultipleUniqueLinks && replyMessageElement.textContent.match(newGeneralThreadPattern)) {
          // Set new page target

          // Persist settings over to new page
          setPageSettings(firstCrossThreadLinkHref, getPageSettings());

          // Navigate to new URL

          let didChangeUrl = false;

          if (openInNewTab) {
            let newWindow = window.open(firstCrossThreadLinkHref, "_blank");

            if (newWindow) {
              newWindow.focus();
              didChangeUrl = true;
            }
            else {
              targetHref = firstCrossThreadLinkHref;
            }
          }
          else {
            window.location.href = firstCrossThreadLinkHref;
            didChangeUrl = true;
          }

          if (didChangeUrl) {
            resetFollowLinksSetting();
          }
        }
      }
    }
  }
}, followLinksUpdateInterval);