AH/DLP/QQ/SB/SV highlight visited links

Keep a history of visited threads, also highlights watched threads on AH/DLP/QQ/SB/SV forums.

// ==UserScript==
// @name AH/DLP/QQ/SB/SV highlight visited links
// @description Keep a history of visited threads, also highlights watched threads on AH/DLP/QQ/SB/SV forums.
// @author C89sd
// @version 1.0.6
// @match https://questionablequesting.com/*
// @match https://forum.questionablequesting.com/*
// @match https://forums.spacebattles.com/*
// @match https://forums.sufficientvelocity.com/*
// @match https://forums.darklordpotter.net/*
// @match https://www.alternatehistory.com/*
// @grant GM_setValue
// @grant GM_getValue
// @namespace https://greasyfork.org/users/1376767
// ==/UserScript==


// Toasts via LocalStorage reload
const toast = document.createElement('div');
toast.id = 'toast';
toast.style.position = 'fixed';
toast.style.bottom = '20px';
toast.style.right = '20px';
toast.style.backgroundColor = '#333';
toast.style.color = '#fff';
toast.style.padding = '10px';
toast.style.borderRadius = '5px';
toast.style.opacity = '0';
toast.style.display = 'none';
toast.style.transition = 'opacity 0.5s ease';
toast.style.zIndex = '1000';
document.body.appendChild(toast);

function _showToast(message, duration = 3000) {
toast.textContent = message;
toast.style.display = 'block';
setTimeout(() => { toast.style.opacity = '1'; }, 10);
setTimeout(() => { toast.style.opacity = '0'; }, duration - 500);
setTimeout(() => { toast.style.display = 'none'; }, duration);
}
function showToast(message) {
localStorage.setItem('toastMessage', message);
}
function showToastOnPageLoad() {
const message = localStorage.getItem('toastMessage');
if (message) {
_showToast(message);
localStorage.removeItem('toastMessage');
}
}
window.addEventListener('load', showToastOnPageLoad);


// Colors & Functions
const dom = window.location.hostname;
const sites = ['spacebattles.com', 'sufficientvelocity.com', 'questionablequesting.com', 'alternatehistory.com', 'darklordpotter.net'];
const [isSB, isSV, isQQ, isAH, isDLP] = sites.map(site => dom.includes(site));

const defaultColor =
isSB ? 'rgb(0, 255, 0)'
: isDLP ? 'rgb(150,150,150)'
: isSV ? 'rgb(40, 161, 221)'
: isQQ ? 'rgb(51, 121, 200)'
: 'rgb(20, 20, 20)' // isAH
const highlightColor =
isSB ? 'rgb(223, 166, 255)'
: isDLP ? 'rgb(183, 128, 215)'
: isSV ? 'rgb(152, 100, 184)'
: 'rgb(119, 69, 150)' // isAH || isQQ
const highlightYellowColor =
isSB ? 'rgb(223, 185, 0)'
: isDLP ? 'rgb(180, 147, 0)'
: isSV ? 'rgb(209, 176, 44)'
: 'rgb(145, 117, 0)' // isAH || isQQ

const threadRegex = new RegExp(`(${sites.map(s => s.replace('.', '\\.')).join('|')}).*?/threads/`);
function isThreadUrl(url) {
  return threadRegex.test(url);
}

function extractThreadName(url) { // e.g "site.com/threads/[foo-bar].0000/"
let name = url;
name = name.replace(/.*?\/threads\//, '');
name = name.replace(/\/.*/, '');
name = name.replace(/\.\d+$/, '');
return name;
}

const THREAD_NAME = isThreadUrl(window.location.href) ? extractThreadName(window.location.href) : '~/~'; // ensure no-match


// Plugin Storage
function Storage_ReadMap() {
const rawData = GM_getValue("C89XF_visited", '{}');
try {
return JSON.parse(rawData);
} catch (e) {
alert('Failed to parse stored data:', e);
}
}
function Storage_AddEntry(key, val) {
if (/^\d+$/.test(key)) { return; } //do not save number links eg https://forums.spacebattles.com/threads/372848/ ; edge case seeen in https://forum.questionablequesting.com/threads/fanfic-search-thread.953/post-624056
var upToDateMap = Storage_ReadMap() // in case another tab wrote to it
if (upToDateMap[key]) { // preserve oldest time
} else {
upToDateMap[key] = val;
GM_setValue("C89XF_visited", JSON.stringify(upToDateMap));
}
}
function removeMostRecentEntry() {
const map = Storage_ReadMap();
let mostRecentKey = null;
let mostRecentDate = '';

for (const [key, date] of Object.entries(map)) {
if (date >= mostRecentDate) {
mostRecentDate = date;
mostRecentKey = key;
}
}
if (mostRecentKey) {
delete map[mostRecentKey];
GM_setValue("C89XF_visited", JSON.stringify(map));
showToast(`REMOVED\n${mostRecentKey}`); // ${mostRecentDate}`);
window.location.reload(); // restore old color that was overwritten
}
}


(() => {
"use strict";

const footer = document.createElement('div');
footer.style.width = '100%';
footer.style.padding = '5px';
footer.style.display = 'flex';
footer.style.justifyContent = 'center';
footer.style.gap = '10px';
footer.class = 'footer';


const navigationBar = isDLP ? document.querySelector('.pageNavLinkGroup') : document.querySelector('.block-outer');
const threadHasWatchedButton = Array.from(navigationBar.children).some(child => /Watched|Unwatch/.test(child.textContent));


// Turn title into a link
const firstH1 = document.querySelector('h1');
const title = firstH1.lastChild ? firstH1.lastChild : firstH1;
const titleLink = document.createElement('a');
titleLink.href = window.location.href;
if (title) {
  const titleClone = title.cloneNode(true);
  titleLink.appendChild(titleClone);
  title.parentNode.replaceChild(titleLink, title);
}


const BTN_1 = isSV ? ['button', 'button--link'] : ['button']
const BTN_2 = isSV ? ['button'] : (isDLP ? ['button', 'primary'] : ['button', 'button--link'])
const exportButton = document.createElement('button');
exportButton.textContent = 'Backup';
exportButton.classList.add(...BTN_1);
if (isSV) { exportButton.style.filter = 'brightness(82%)'; }
exportButton.addEventListener('click', exportVisitedLinks);
footer.appendChild(exportButton);

const importButton = document.createElement('button');
importButton.textContent = 'Restore';
importButton.classList.add(...BTN_1);
if (isSV) { importButton.style.filter = 'brightness(82%)'; }
importButton.addEventListener('click', importVisitedLinks);
footer.appendChild(importButton);

const updateButton = document.createElement('button');
updateButton.textContent = 'Remove latest highlight';
updateButton.classList.add(...BTN_2);
updateButton.addEventListener('click', removeMostRecentEntry);
footer.appendChild(updateButton);

const xFooter = document.querySelector('footer.p-footer');
if (xFooter) { xFooter.insertAdjacentElement('afterbegin', footer); }
else { document.body.appendChild(footer); }

function exportVisitedLinks() {
const data = GM_getValue("C89XF_visited", '{}');
const blob = new Blob([data], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'visited_links_backup.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

function importVisitedLinks() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = function(event) {
const file = event.target.files[0];
const reader = new FileReader();
reader.onload = function(e) {
try {
 const data = JSON.parse(e.target.result);
 GM_setValue("C89XF_visited", JSON.stringify(data));
 alert('Visited links imported successfully. Page will refresh.');
 window.location.reload();
} catch (error) {
 alert('Error importing file. Please make sure it\'s a valid JSON file.');
}
};
reader.readAsText(file);
};
input.click();
}

// Set link colors
const applyLinkStyles = () => {
const visitedLinks = Storage_ReadMap();
const links = document.getElementsByTagName("a");

for (let link of links) {
const href = link.href;
if (isThreadUrl(href)) {

const threadName = extractThreadName(href);
const isLinkToCurrentThread = threadName == THREAD_NAME;
if (isLinkToCurrentThread && !firstH1.contains(link)) { continue; } // highlight title

// seen
if (visitedLinks[threadName]) { link.style.color = highlightColor; }

// watched
let isWatched = false;
if (isLinkToCurrentThread) {
  isWatched = threadHasWatchedButton;
} else {
  const parent  = isDLP ? link.closest('div.titleText')
                        : link.closest('div.structItem');
  const hasIcon = isDLP ? parent && parent.getElementsByClassName('fa-eye').length > 0
                        : parent && parent.getElementsByClassName('structItem-status--watched').length > 0;
  isWatched = hasIcon;
}
if (isWatched) { link.style.color = highlightYellowColor; }

}
}

// Global click listener
if (!document.dataClickListenerAdded) {
  document.addEventListener("click", function(event) {
    // handle links
    const link = event.target.closest('a');
    if (link && link.tagName === 'A') {
      if (isThreadUrl(link.href)) {
        Storage_AddEntry(extractThreadName(link.href), new Date().toISOString().slice(0, 19).replace(/[-:T\.]/g, ''));
      }
    }

    // handle Watch/Unwatch buttons
    const button = event.target.closest('button, input[type="submit"]');
    if (button) {
      const buttonText = button.tagName === 'INPUT' ? button.value : button.textContent;

      if (/Watch/.test(buttonText)) {
        titleLink.style.color = highlightYellowColor;
      }
      if (/Unwatch/.test(buttonText)) {
        if (visitedLinks[THREAD_NAME]) {
          titleLink.style.color = highlightColor;
        } else {
          titleLink.style.color = defaultColor;
        }
      }
    }

  });
  document.dataClickListenerAdded = true;
}
};

// Apply styles on load
applyLinkStyles();
// Apply styles when navigating back
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
  applyLinkStyles();
}
});
})();