NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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);