// ==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.31 // // @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\/(?:masseffect|TheCitadel|[^\/]*?[Ff]an[Ff]ic)[^\/]*\/comments\// // @include https://old.reddit.com/favicon.ico // // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @namespace https://greasyfork.org/users/1376767 // @run-at document-start // @downloadURL none // ==/UserScript== 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); } function onDomContentLoaded(fn) { document.addEventListener('DOMContentLoaded', 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, window.location.href)) : null; // Thread(story) vs Forum/Search page. const SITE_PREFIXED_THREAD_ID = SITE ? (maybeGetPrefixedThreadId(SITE, window.location.href) || "~/~") : "~/~"; // 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 // ===================================================================== onDomContentLoaded(() => { 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: .tagName === 'INPUT' // SB/SV/AH/QQ: // Note: Even though