Greasy Fork

Greasy Fork is available in English.

AH/DLP/QQ/SB/SV/FFN/HPF/PC/OR Highlight visited fanfics

Track and highlight visited and watched* fanfiction links across the following sites: AlternateHistory*, DarkLordPotter*, QuestionableQuesting*, SpaceBattles*, SufficientVelocity*, FanFiction, HPFanfiction, PatronusCharm, a few old subreddits.

当前为 2025-05-22 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name AH/DLP/QQ/SB/SV/FFN/HPF/PC/OR Highlight visited fanfics
// @description Track and highlight visited and watched* fanfiction links across the following sites: AlternateHistory*, DarkLordPotter*, QuestionableQuesting*, SpaceBattles*, SufficientVelocity*, FanFiction, HPFanfiction, PatronusCharm, a few old subreddits.
// @author C89sd
// @version 1.34
//
// @include https://www.alternatehistory.com/*
// @include https://forums.darklordpotter.net/*
// @include https://forums.spacebattles.com/*
// @include https://forums.sufficientvelocity.com/*
// @include https://questionablequesting.com/*
// @include https://forum.questionablequesting.com/*
// @include https://m.fanfiction.net/*
// @include https://www.fanfiction.net/*
// @include https://hpfanfiction.org/fr/*
// @include https://www.hpfanfiction.org/fr/*
// @include https://patronuscharm.net/*
// @include https://www.patronuscharm.net/*
// @include /^https:\/\/old\.reddit\.com\/r\/(?:HP|masseffect|TheCitadel|[^\/]*?[Ff]an[Ff]ic)[^\/]*\/comments\//
// @include https://old.reddit.com/favicon.ico
//
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @namespace http://greasyfork.icu/users/1376767
// @run-at document-start
// ==/UserScript==

'use strict';

const CONF_AUTO_HIGHLIGHT = true; // false; // Set false for manual mode (click the title link to highlight & adds a second title).

// =====================================================================
// Navback-safe GM get/set
// =====================================================================
// We need to yield to let the userscript be reinjected in the iframe.
// We could async wait on a Promise of the iframe's ready message.
// But async functions can be interrupted when leaving the page.
// To keep the API sync, we run our own 'onBackForward' callbacks in onMsg.
const DEBUG = false;
// -------------------------------------- Iframe
if (window.self !== window.top) {
  unsafeWindow.top.GMproxy = {
    setValue: (key, val) => {
      if (DEBUG) console.log('Iframe SET', {key, length: val.length});
      return GM_setValue(key, val);
    },
    getValue: (key, def) => {
      const res = GM_getValue(key, def);
      if (DEBUG) console.log('Iframe GET', {key, def, length: res.length});
      return res;
    }
  }
  window.parent.postMessage('R', '*');
  if (DEBUG) console.log('Iframe message sent.');
  return; // --> [Exit] <--
}
// -------------------------------------- Main
let GMproxy = {}
let iframe = null;
let iframeReady = false;

const _setValue = GM_setValue;
const _getValue = GM_getValue;
GM_setValue = (key, val) => {
  if (iframe) {
    if (iframeReady) return GMproxy.setValue(key, val);
    else throw new Error(`GM_setValue, Iframe not ready, key=${key}`);
  } else {
    if (DEBUG) console.log('Main SET', {key, length: val.length});
    return _setValue(key, val);
  }
}
GM_getValue = (key, def) => {
  if (iframe) {
    if (iframeReady) return GMproxy.getValue(key, def);
    else throw new Error(`GM_getValue, Iframe not ready, key=${key}`);
  } else {
    const res = _getValue(key, def);
    if (DEBUG) console.log('Main GET', {key, def, length: res.length});
    return res;
  }
}

let backForwardQueue = [];
function onBackForward(fn) {
  backForwardQueue.push(fn);
}

window.addEventListener('pageshow', (e) => {
  if (e.persisted) {
    const oldIframe = document.getElementById('gmproxy');
    if (oldIframe) oldIframe.remove();

    iframeReady = false;
    iframe = document.createElement('iframe');
    iframe.id = 'gmproxy';
    iframe.style.display = 'none';
    iframe.referrerPolicy = 'no-referrer';
    iframe.src = location.origin + '/favicon.ico';
    document.body.appendChild(iframe);

    const my_iframe = iframe;

    const controller = new AbortController();
    const onHide = (ev) => {
      if (DEBUG) console.log('Iframe aborted (pagehide).');
      controller.abort();
    };
    const onMsg = (ev) => {
      if (my_iframe !== iframe) {
        if (DEBUG) console.log('ERROR ! my_iframe !== iframe')
        controller.abort();
        return;
      }
      if (ev.source === iframe.contentWindow && ev.data === 'R') {
        GMproxy = unsafeWindow.GMproxy;
        iframeReady = true;
        controller.abort();
        if (DEBUG) console.log('Iframe message received. GMproxy=', GMproxy);
        backForwardQueue.forEach((fn) => { fn() });
      }
    };
    window.addEventListener('message', onMsg, { signal: controller.signal });
    window.addEventListener('pagehide', onHide, { signal: controller.signal });
  }
})

const _addEventListener = window.addEventListener;
window.addEventListener = (type, listener, options) => {
  if (type === 'pageshow') {
    throw new Error('Cannot register "pageshow" event listener, use onBackForward(fn)');
  }
  _addEventListener(type, listener, options);
};

// =====================================================================
// Deletion Toast
// =====================================================================

function assert(condition, message) {
  if (!condition) {
    alert(`[userscript:Highlight visited fanfics] ERROR\n${message}`);
  }
}

function createToastElement() {
  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);
  return toast;
}

let toastHistory = [];
let debounceTimer = null;
let cleanupTimer = null;

function showToast(message, message2, duration = 20000) {
  // debounce lock 350ms
  const button = document.getElementById('remove-latest-highlight');
  button.addEventListener('click', function() {
    button.disabled = true;
    button.style.filter = 'brightness(0.5)';
    setTimeout(() => {
      button.disabled = false;
      button.style.filter = '';
    }, 350);
  });

  _showToast(message, message2, duration);

  function _showToast(message, message2, duration) {
      let toast = document.getElementById('toast');
    if (!toast) {
      createToastElement();
      toast = document.getElementById('toast');
      if (!toast) {
        console.error('Toast element not found');
        return;
      }
    }

    function processMessage(msg) {
      if (!msg) return false;

      for (const site of sites) {
        const { prefix, toastUrlPrefix, toastUrlSuffix } = site;
        if (prefix && msg.startsWith(prefix)) {
          const id = msg.substring(prefix.length);
          const toastUrl = toastUrlPrefix + id + (toastUrlSuffix || '');

          const link = document.createElement('a');
          link.href = toastUrl;
          link.textContent = toastUrl;
          link.className = 'nohl-toast';
          link.style.color = '#1e90ff';
          link.style.textDecoration = 'none';
          link.target = '_blank';
          link.style.fontFamily = 'sans-serif';
          return link;
        }
      }
      const textSpan = document.createElement('div');
      textSpan.textContent = `removed "${msg}"`;
      textSpan.style.fontFamily = 'sans-serif';
      return textSpan;
    }

    const newElements = [];

    let matched1 = processMessage(message);
    let matched2 = processMessage(message2);
    if (matched1) newElements.push(matched1);
    if (matched1 && matched2) newElements.push(document.createElement('br'));
    if (matched2) newElements.push(matched2);
    newElements.push(document.createElement('hr'));

    const now = new Date().getTime();
    toastHistory = toastHistory.concat(newElements.map(element => ({ element, timestamp: now, duration })));

    scheduleCleanup();
    updateToast();

    // delete dom elements as their timestamp expire
    function scheduleCleanup() {
      if (cleanupTimer !== null) {
        clearTimeout(cleanupTimer);
      }
      const now = new Date().getTime();
      const nextCleanupTime = Math.min(...toastHistory.map(entry => entry.timestamp + entry.duration));

      cleanupTimer = setTimeout(() => {
        cleanupHistory();
        updateToast();
        scheduleCleanup();
      }, nextCleanupTime - now);
    }

    function cleanupHistory() {
      const now = new Date().getTime();
      toastHistory = toastHistory.filter(entry => entry.timestamp + entry.duration > now);
    }

    function updateToast() {
      const toast = document.getElementById('toast');
      if (!toast) return;

      toast.innerHTML = '';
      toastHistory.forEach((entry, index) => {
        const element = entry.element.cloneNode(true);
          element.style.textAlign = 'right';
          element.style.display = 'block';
        toast.appendChild(element);
      });

      if (toast.lastChild && toast.lastChild.tagName === 'HR') {
        toast.removeChild(toast.lastChild);
      }

      if (toastHistory.length > 0) {
        toast.style.display = 'block';
        setTimeout(() => { toast.style.opacity = '1'; }, 10);

        clearTimeout(toast._timeout);
        toast._timeout = setTimeout(() => {
          toast.style.opacity = '0';
          setTimeout(() => {
            toast.style.display = 'none';
          }, 500); // wait for the opacity animation to finish
        }, toastHistory[toastHistory.length - 1].duration - 500);
      } else {
        toast.style.display = 'none';
      }
    }
  }
}

// =====================================================================
// Sites
// =====================================================================

const sites = [
  {
    domain: 'fanfiction.net',
    const: 'IS_FFN',
    prefix: 'ffn_',
    toastUrlPrefix: 'https://m.fanfiction.net/s/',
    extract: (url) => (url.match(/https?:\/\/[^\/]*fanfiction\.net\/s\/(\d+)/)?.[1] || null),
  },
  {
    domain: 'hpfanfiction.org',
    const: 'IS_HPF',
    prefix: 'hpf_',
    toastUrlPrefix: 'https://www.hpfanfiction.org/fr/viewstory.php?sid=',
    extract: (url) => (url.match(/https?:\/\/[^\/]*hpfanfiction\.org\/fr\/viewstory\.php\?.*?sid=(\d+)/)?.[1] || null),
  },
  {
    domain: 'patronuscharm.net',
    const: 'IS_PAT',
    prefix: 'pat_',
    toastUrlPrefix: 'https://www.patronuscharm.net/s/',
    toastUrlSuffix: '/1/',
    extract: (url) => (url.match(/https?:\/\/[^\/]*patronuscharm\.net\/s\/(\d+)/)?.[1] || null),
  },
  {
    domain: 'spacebattles.com',
    const: 'IS_SB',
    prefix: 'xsb_',
    toastUrlPrefix: 'https://forums.spacebattles.com/threads/',
    extract: (url) => (url.match(/https?:\/\/[^\/]*spacebattles\.com.*?\/threads\/[^\/]{2,}\.(\d+)/)?.[1] || null),
    xenforo: true
  },
  {
    domain: 'sufficientvelocity.com',
    const: 'IS_SV',
    prefix: 'xsv_',
    toastUrlPrefix: 'https://forums.sufficientvelocity.com/threads/',
    extract: (url) => (url.match(/https?:\/\/[^\/]*sufficientvelocity\.com.*?\/threads\/[^\/]{2,}\.(\d+)/)?.[1] || null),
    xenforo: true
  },
  {
    domain: 'questionablequesting.com',
    const: 'IS_QQ',
    prefix: 'xqq_',
    toastUrlPrefix: 'https://forum.questionablequesting.com/threads/',
    extract: (url) => (url.match(/https?:\/\/[^\/]*questionablequesting\.com.*?\/threads\/[^\/]{2,}\.(\d+)/)?.[1] || null),
    xenforo: true
  },
  {
    domain: 'alternatehistory.com',
    const: 'IS_AH',
    prefix: 'xah_',
    toastUrlPrefix: 'https://www.alternatehistory.com/forum/threads/',
    extract: (url) => (url.match(/https?:\/\/[^\/]*alternatehistory\.com.*?\/threads\/[^\/]{2,}\.(\d+)/)?.[1] || null),
    xenforo: true
  },
  {
    domain: 'darklordpotter.net',
    const: 'IS_DLP',
    prefix: 'xdl_',
    toastUrlPrefix: 'https://forums.darklordpotter.net/threads/',
    extract: (url) => (url.match(/https?:\/\/[^\/]*darklordpotter\.net.*?\/threads\/[^\/]{2,}\.(\d+)/)?.[1] || null),
    xenforo: true
  }
];

sites.forEach(site => {
  site.test_domain = (url) => new RegExp(`^https?:\/\/[^\/]*${site.domain}\/`).test(url);
});

const DOMAIN = window.location.hostname;

const { IS_FFN, IS_HPF, IS_PAT, IS_SB, IS_SV, IS_QQ, IS_AH, IS_DLP } = Object.fromEntries(sites.map(site => [site.const, DOMAIN.includes(site.domain)]));
const IS_RED = DOMAIN.includes('reddit.com');


const SITE = sites.find(site => DOMAIN.includes(site.domain)) || null; // `sites` entry of the current page.
function maybeGetSite(url) {
  // Optimisation: most links are pointing to the current page, try it before scanning all the site.
  if (SITE?.test_domain(url)) {
    return SITE.extract(url) ? SITE : null;
  } else {
    return sites.find(site => site.test_domain(url) && site.extract(url)) || null;
  }
}
function maybeGetPrefixedThreadId(site, url) {
  const extractedId = site.extract(url);
  if (!extractedId) {
    return null;
  }
  return site.prefix + extractedId;
}
const SITE_IS_THREAD = SITE ? Boolean(maybeGetPrefixedThreadId(SITE, document.URL)) : null; // Thread(story) vs Forum/Search page.
const SITE_PREFIXED_THREAD_ID = SITE ? (maybeGetPrefixedThreadId(SITE, document.URL) || "~/~") : "~/~"; // Can be used to lookup the DB, "~/~" will never match.

const IS_XENFORO = SITE ? SITE.xenforo : false;
// Old function to extract thread name instead of ID (for SB, SV, QQ, DLP).
function extractThreadName(url) {
  let name = url;
  name = name.replace(/.*?\/threads\//, ''); // Remove everything before /threads/
  name = name.replace(/\/.*/, '');           // Remove everything after /
  name = name.replace(/\.\d+$/, '');         // Remove trailing `.digits`
  return name;
}

// =====================================================================
// Colors
// =====================================================================

function InjectColors() {
  // dark mode
  const DM = IS_QQ && window.getComputedStyle(document.body).color.match(/\d+/g)[0] > 128;

  const purpleHighlightColor =
      IS_SB  ? 'rgb(165, 122, 195)' :
      IS_QQ  ? (DM ? 'rgb(166, 116, 199)' : 'rgb(119, 69, 150)') :
      IS_DLP ? 'rgb(166, 113, 198)' :
      IS_SV  ? 'rgb(175, 129, 206)' :
      IS_FFN ? 'rgb(135, 15, 135)' :
      IS_HPF ? 'rgb(135, 15, 135)' :
      IS_RED ? 'rgb(194, 121, 227)' :
               'rgb(119, 69, 150)'; // IS_AH

  const pinkHighlightColor =
      IS_SB ? 'rgb(213, 119, 142)' :
      IS_QQ ? (DM ? 'rgb(213, 119, 142)' : 'rgb(159, 70, 92)'):
      IS_SV ? 'rgb(209, 112, 136)' :
              'rgb(200, 105, 129)';

  const yellowHighlightColor =
      IS_SB  ? 'rgb(223, 185, 0)' :
      IS_DLP ? 'rgb(180, 147, 0)' :
      IS_SV  ? 'rgb(209, 176, 44)' :
               'rgb(145, 117, 0)'; // IS_AH || IS_QQ

  GM_addStyle(`
    .hl-name-seen { color: ${pinkHighlightColor}   !important; }
    .hl-seen      { color: ${purpleHighlightColor} !important; }
    .hl-watched   { color: ${yellowHighlightColor} !important; }
  `);
}

// =====================================================================
// Storage
// =====================================================================

// Plugin Storage
function Storage_ReadMap() {
  const rawData = GM_getValue("C89XF_visited", '{}');
  try {
    return JSON.parse(rawData);
  } catch (e) {
    assert(false, `Failed to parse stored data: ${e}`);
    throw new Error(`Failed to parse stored data: ${e}`);
  }
}

function Storage_AddEntry(key, val) {
  if (!key) { return; } // do not store null
  if (/^\d+$/.test(key)) { return; } // do not save number links e.g. 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]) {
    return false; // preserve oldest time
  } else {
    upToDateMap[key] = val;
    GM_setValue("C89XF_visited", JSON.stringify(upToDateMap));
    return true;
  }
}

// =====================================================================
// Main
// =====================================================================

addEventListener("DOMContentLoaded", (event) => {
  // If another script redirects the page, it crashes on document.body, exit gracefully.
  if (!document.body) {
    if (DEBUG) console.log("Error: document.body is null.");
    return;
  }

  InjectColors()

  // ============================= Title ===============================

  const buttonsList = IS_DLP ? document.querySelector('.pageNavLinkGroup').children : // navigation bar
                               document.querySelectorAll('div.block-outer-opposite > div.buttonGroup > a > span');
  const threadHasWatchedButton = buttonsList ? Array.from(buttonsList).some(child => /Watched|Unwatch/.test(child.textContent)) : false;

  // Turn title into a link
  const firstH1 = IS_FFN ? document.querySelector('div[align="center"] > b, div#profile_top > b') :
                  IS_HPF ? document.querySelector('div#pagetitle > a, div#content > b > a') :
                  IS_PAT ? document.querySelector('span[title]') :
                  document.querySelector('h1');
  let secondH1 = null;

  const titleLink = document.createElement('a');
  // note: clicking thread titles no longer reloads, so we strip the
  if (SITE_IS_THREAD) titleLink.href = window.location.origin + window.location.pathname; // direct page link, pathname strips the # which prevent reloading
  else                titleLink.href = window.location.origin + '/' + window.location.pathname.split('/').slice(1,3).join('/'); // forum root link

  if (firstH1) {
    const title = firstH1.lastChild ? firstH1.lastChild : firstH1;
    if (title) {
      const titleClone = title.cloneNode(true);
      titleLink.appendChild(titleClone); // Put title in an empty link.
      const titleParent = title.parentNode;
      titleParent.replaceChild(titleLink, title); // Swap title with title-link.

      // Second title above threadmarks
      if (!CONF_AUTO_HIGHLIGHT) {
        const header = document.querySelector("div.threadmarkListingHeader")
        if (header) {
          const block = header.closest("div.block")
          secondH1 = titleParent.cloneNode(true);
          block.parentNode.insertBefore(secondH1, block.nextSibling);
        }
      }
    }
  }

  function isTitle(link) {
    return firstH1.contains(link) || (secondH1 && secondH1.contains(link));
  }

  // ============================= Footer ===============================

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

  const BTN_1 = IS_SV ? ['button', 'button--link'] : ['button']
  const BTN_2 = IS_SV ? ['button'] : (IS_DLP ? ['button', 'primary'] : ['button', 'button--link'])
  const exportButton = document.createElement('button');
  exportButton.textContent = 'Backup';
  exportButton.classList.add(...BTN_1);
  if (IS_SV) { 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 (IS_SV) { importButton.style.filter = 'brightness(82%)'; }
  importButton.addEventListener('click', importVisitedLinks);
  footer.appendChild(importButton);

  const updateButton = document.createElement('button');
  updateButton.id = 'remove-latest-highlight';
  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); }

  // ============================= Export ===============================

 function exportVisitedLinks() {
    const pad = (num) => String(num).padStart(2, '0');
    const now = new Date();
    const year = now.getFullYear();
    const month = pad(now.getMonth() + 1);
    const day = pad(now.getDate());
    const hours = pad(now.getHours());
    const minutes = pad(now.getMinutes());
    const seconds = pad(now.getSeconds()); // Add seconds
    const map = Storage_ReadMap();
    const size = map ? Object.keys(map).length : 0;

    const data = GM_getValue("C89XF_visited", '{}');
    const blob = new Blob([data], {type: 'text/plain'});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `visited_fanfics_backup_${year}_${month}_${day}_${hours}${minutes}${seconds} +${size}.txt`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  // ============================= Import ===============================

  function importVisitedLinks() {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = '.txt, .json';
    input.onchange = function(event) {
      const file = event.target.files[0];
      const reader = new FileReader();
      reader.onload = function(e) {
        const data_before = Storage_ReadMap();
        try {
          const data = JSON.parse(e.target.result);
          GM_setValue("C89XF_visited", JSON.stringify(data));

          const length_before = Object.keys(data_before).length;
          const length_after = Object.keys(data).length;
          const diff = length_after - length_before;

          var notes =`\n- Entries: ${length_before} → ${length_after} (total: ${diff >= 0 ? "+" : ""}${diff})`;
          notes += "\n\n—— DATA ——\n"
          notes += JSON.stringify(data).slice(0, 350) + '...';

          alert('Visited fanfics restored successfully.') // Page will refresh.' + notes);
          // window.location.reload();
          applyLinkStyles(true);

        } catch (error) {
          alert('Error importing file. Please make sure it\'s a valid JSON file.');
        }
      };
      reader.readAsText(file);
    };
    input.click();
  }

  // ========================== Remove Recent ============================

  function removeMostRecentEntry() {
    const map = Storage_ReadMap();
    let mostRecentKey = null;
    let mostRecentDate = '';
    let previousmostRecentKey = null;
    let previousMostRecentDate = '';

    for (const [key, date] of Object.entries(map)) {
      if (date >= mostRecentDate) { // find last entry with the greatest date
        previousMostRecentDate = mostRecentDate;
        previousmostRecentKey = mostRecentKey;
        mostRecentDate = date;
        mostRecentKey = key;
      }
    }
    if (mostRecentKey) {
      delete map[mostRecentKey];

      const twoKeys = previousmostRecentKey && previousMostRecentDate == mostRecentDate;
      if (twoKeys) {
        delete map[previousmostRecentKey];
      }

      GM_setValue("C89XF_visited", JSON.stringify(map));

      showToast(`${mostRecentKey}`, twoKeys ? `${previousmostRecentKey}` : null); // ${mostRecentDate}`);
      applyLinkStyles(true);
    }
  }

  // ========================== Apply Styles ============================

  let last = 0;
  // Set link colors
  function applyLinkStyles(force = false) {
    // Debounce
    const now = Date.now();
    if (!force && now - last < 500) return;
    last = now;

    if (DEBUG) console.log('--- apply link styles');

    const visitedLinks = Storage_ReadMap();

    const links = document.getElementsByTagName("a");

    // const start = Date.now();
    for (let link of links) {
      if (link.classList.contains('nohl-toast')) continue;  // Toast message link

      const url = link.href;
      const site = maybeGetSite(url);
      if (site) {
        const prefixedId = maybeGetPrefixedThreadId(site, url);
        if (prefixedId) {

          // Do not highlight self-referential links (unless it is the title).
          const linkPointsToCurrentPage = (prefixedId == SITE_PREFIXED_THREAD_ID);
          if (linkPointsToCurrentPage) {
            if (!isTitle(link)) { continue }
          }

          // Clear previous classes (when reapplying)
          link.classList.remove('hl-seen', 'hl-name-seen', 'hl-watched');

          // Hihlight seen links.
          if (visitedLinks[prefixedId]) {
            link.classList.add('hl-seen');
          }
          else {
            if (IS_XENFORO) {
              if (visitedLinks[extractThreadName(url)]) {
                // Compatiblity: we used to store threadName instead of prefixedId.
                // TODO: we just found an old entry, maybe insert in the DB instead of just coloring, this would prevent DB loss from future title changes.
                link.classList.add('hl-name-seen');
              }
            }
          }

          // Hihlight watched links.
          if (IS_XENFORO) {
            let isWatched = false;

            if (SITE_IS_THREAD) {
              // In Story threads, the only link to highlight is the Title Link.
              if (linkPointsToCurrentPage) { isWatched = threadHasWatchedButton; }
            }
            else {
              // In Forum view, check the bell/eye icon next to the link.
              const parent  = IS_DLP ? link.closest('div.titleText')
                                    : link.closest('div.structItem');
              const hasIcon = IS_DLP ? parent && parent.getElementsByClassName('fa-eye').length > 0
                                    : parent && parent.getElementsByClassName('structItem-status--watched').length > 0;
              isWatched = hasIcon;
            }

            if (isWatched) {
              link.classList.add('hl-watched');
            }
          }
        }
      }
    }
    // const end = Date.now();
    // console.log(`Execution time: ${end - start} ms`);
  };

  // ========================= Click Listener ===========================

  // Global click listener
  if (!document.dataClickListenerAdded) {
    document.addEventListener("click", function(event) {

      let wasAdded = false;

      // handle links
      const link = event.target.closest('a');
      if (link && link.tagName === 'A') {
        if (link.closest('#toast')) { return; } // Toast message link
        if (link.textContent === 'Table des matières') { return; } // HPF
        if (link.textContent === 'Suivant') { return; }            // HPF
        if (link.textContent === 'Précédent') { return; }          // HPF

        // if (CONF_AUTO_HIGHLIGHT && link.textContent === 'Reader mode') { return; }

        // TODO: Performance: skip nav links so they don't trigger db reads.

        let dontReload = false;
        let addClidkedLink = false;
        if (CONF_AUTO_HIGHLIGHT) {
          addClidkedLink = true;
        } else {
          // if (link.textContent === 'Reader mode') addClidkedLink = true;
          // if (link.textContent === 'View content') addClidkedLink = true;
        }
        if (isTitle(link)) { addClidkedLink = true; dontReload = true; }

        const site = maybeGetSite(link.href);

        if (addClidkedLink) {
          if (site) {
            const date = new Date().toISOString().slice(0, 19).replace(/[-:T\.]/g, '');
            const prefixedId = maybeGetPrefixedThreadId(site, link.href);

            // Do not update when clicking self-referential links (unless it is the title).
            const linkPointsToCurrentPage = (prefixedId == SITE_PREFIXED_THREAD_ID);
            if (linkPointsToCurrentPage) {
              if (!isTitle(link)) { return }
            }

            // note: Storage_AddEntry does nothing if there is already an entry.
            wasAdded |= Storage_AddEntry(prefixedId, date);

            if (site.xenforo) {
              const threadName = extractThreadName(link.href);
              wasAdded |= Storage_AddEntry(threadName, date);
            }
          }
        }

        if (SITE_IS_THREAD && dontReload) { // reload on forum title click; we could disable titling there but I like clicking it
          event.preventDefault();
          applyLinkStyles(true);
        }
      }

      // handle Watch/Unwatch buttons: update title color
      if (IS_XENFORO) {
        // DLP:         <input type="submit" value="Watch Thread" class="button primary">  .tagName  === 'INPUT'
        // SB/SV/AH/QQ: <button type="submit" class="button--primary button"><span class="button-text">Watch</span></button>
        // Note: Even though <button> was clicked, if mouse hovered <span> then `even.target = span`.
        let button = event.target.matches('input[type="submit"], button[type="submit"], button[type="submit"] span') ? event.target : null;
        if (button) {
          let buttonText = button.value || button.textContent;
          if (buttonText) {
            if (/Watch/.test(buttonText)) {
              titleLink.classList.add('hl-watched');
            }
            else if (/Unwatch/.test(buttonText)) {
              titleLink.classList.remove('hl-watched');
            }
          }
        }
      }

    //   if (wasAdded) {
    //     event.preventDefault();
    //     const link = event.target;
    //     setTimeout(() => {
    //       console.log('~~~~DELAY~~~~~', link.href)
    //       window.location.href = link.href;
    //     }, 50);
    //   }


    // }, true); // Capture phase
    });

    document.dataClickListenerAdded = true;
  }

  // =========================== Callbacks ==============================

  // Apply styles when navigating back
  onBackForward(() => {
    applyLinkStyles(true);
  });

  // Apply styles on tab change.
  document.addEventListener('focus', () => { // focus in
    applyLinkStyles();
  });
  document.addEventListener("visibilitychange", () => { // alt-tab in
    if (!document.hidden) { // alt-tab in
      applyLinkStyles();
    }
  });

  // Apply styles on load
  applyLinkStyles(true);
});