// ==UserScript==
// @name Reddit highlighter for Min_'s "AO3: kudosed seen history"
// @description Addon to highlight AO3 links marked-as-seen via Min_'s "AO3: Kudosed and seen history" across all subreddits (old&new).
// @namespace https://gf.qytechs.cn/users/1376767
// @author C89sd
// @version 1.1
// @include https://archiveofourown.org/*
// @include https://old.reddit.com/favicon.ico
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @run-at document-start
// @include /^https:\/\/(?:old\.)?reddit\.com\/r\/[^\/]*\/comments\//
// ==/UserScript==
/* To run only on a few subs: replace the @include above with the one below and customise it.
// @include /^https:\/\/(?:old\.)?reddit\.com\/r\/(?:AO3|HP|masseffect|TheCitadel|[^\/]*?(?:[Ff]an[Ff]ic|[Hh]ero))[^\/]*\/comments\//
This matches:
- Anyhting starting with (?:AO3|HP|masseffect|TheCitadel)
- Anything containing (?:FanFic|Fanfic|fanfic|fanFic|Hero|hero) // note: case-insensitive is not supported and must be done manually.
*/
'use strict';
// =====================================================================
// Navback-safe GM get/set
// =====================================================================
const DEBUG = false;
// -------------------------------------- Iframe
if (window.self !== window.top) {
if (DEBUG) console.log('IFRAME CREATED!');
unsafeWindow.top.GMproxy3 = {
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 dontBotherReloadingThereAreNoLinks = false;
const cleanupCtrl = new AbortController();
const cleanupSig = cleanupCtrl.signal;
// ------------
let GMproxy3 = {}
let iframe = null;
let iframeReady = false;
const _setValue = GM_setValue;
const _getValue = GM_getValue;
GM_setValue = (key, val) => {
if (iframe) {
if (iframeReady) return GMproxy3.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 GMproxy3.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 (DEBUG) console.log('pageshow persisted=', e.persisted);
if (e.persisted && !dontBotherReloadingThereAreNoLinks) {
const oldIframe = document.getElementById('gmproxy3');
if (oldIframe) oldIframe.remove();
iframeReady = false;
iframe = document.createElement('iframe');
iframe.id = 'gmproxy3';
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') {
GMproxy3 = unsafeWindow.GMproxy3;
iframeReady = true;
controller.abort();
if (DEBUG) console.log('Iframe message received. GMproxy3=', GMproxy3);
if (DEBUG) console.log('Running onBackForward fns=', backForwardQueue);
backForwardQueue.forEach((fn) => { fn() });
}
};
window.addEventListener('message', onMsg, { signal: controller.signal });
window.addEventListener('pagehide', onHide, { signal: controller.signal });
}
}, { cleanupSig })
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);
};
// =====================================================================
// Main
// =====================================================================
const url = document.URL;
const IS_AO3 = url.startsWith('https://archiveofourown.org');
const IS_REDDIT = !IS_AO3; // old/new
const workIdRegex = /^https:\/\/archiveofourown\.org.*?\/works\/(\d+)/;
const seriesRegex = /^https:\/\/archiveofourown\.org.*?\/series\/\d+/;
let applyingForSecondTime = false; // skip removing classes 1rst time
// Find all highlightable links on ao3 and reddit
let ao3LinksAndIds = null;
document.addEventListener("DOMContentLoaded", () => {
if (DEBUG) console.log('DOMContentLoaded getting links');
if (ao3LinksAndIds === null) {
ao3LinksAndIds = [];
// [el, workId, isSeries]
for (const link of document.querySelectorAll('a[href^="https://arc"]')) { // 0.07ms down from 1.51ms over 5000 comments !
const match = workIdRegex.exec(link.href);
if (match) {
const workId = match[1];
ao3LinksAndIds.push([link, workId, false]);
} else {
if (seriesRegex.test(link.href)) {
ao3LinksAndIds.push([link, 0, true]);
}
}
}
if (ao3LinksAndIds.length === 0) {
if (DEBUG) console.log('DOMContentLoaded 0 links, abort!');
dontBotherReloadingThereAreNoLinks = true;
cleanupCtrl.abort();
}
else {
if (DEBUG) console.log('DOMContentLoaded links=', ao3LinksAndIds);
}
}
}, { cleanupSig });
if (IS_AO3) {
if (DEBUG) console.log('AO3 PATH');
// Note: 'storage' events come from other tabs only.
// Since the script is by definition in every AO3 tab, we don't need it.
// Override Storage.prototype.setItem
const originalSetItem = Storage.prototype.setItem;
Storage.prototype.setItem = function(key, value) {
if (DEBUG) console.log('setItem() intercepted,length:', key, value.length);
if (iframe && !iframeReady) {
console.warn(`AO3 Exporter: iframe && !iframeReady, skipped change`);
} else {
if (key === 'kudoshistory_seen') { GM_setValue('seen', value); if (DEBUG) console.log('interecpted seen[:100],', value.slice(0, 100)); }
if (key === 'kudoshistory_skipped') GM_setValue('skipped', value);
}
// Call the original method
return originalSetItem.call(this, key, value);
};
return;
}
if (IS_REDDIT) {
if (DEBUG) console.log('REDDIT PATH');
// note: nested for early exit?
// Apply styles on load
document.addEventListener("DOMContentLoaded", () => {
if (DEBUG) console.log('updateHighlight() DOMContentLoaded');
GM_addStyle('.khxr-skipped {color: rgb(165, 149, 110) !important;}' +
'.khxr-seen {color: rgb(194, 121, 227) !important;}' +
'.khxr-series::before {content: "⧉"; margin-right: 0.3em }' + // ⁂
'.khxr-work {text-decoration: underline !important; text-decoration-style: wavy !important; }');
updateHighlight();
// Apply styles when navigating back
onBackForward(() => {
if (DEBUG) console.log('updateHighlight() onBackForward');
updateHighlight();
});
// Apply styles on tab change.
document.addEventListener('focus', () => { // focus in
if (DEBUG) console.log('updateHighlight() focus');
updateHighlight();
}, { cleanupSig });
document.addEventListener("visibilitychange", () => { // alt-tab in
if (!document.hidden) {
if (DEBUG) console.log('updateHighlight() visibilitychange');
updateHighlight();
}
}, { cleanupSig });
}, { cleanupSig });
return;
}
const MAX_RETRIES = 20
let retryCount = 0
function updateHighlight() {
if (dontBotherReloadingThereAreNoLinks) return;
// fetch up to date seen list
if (iframe && !iframeReady) {
console.warn(`AO3 Exporter: iframe && !iframeReady, retries=`, retryCount);
if (retryCount >= MAX_RETRIES) {
console.error(`iframe not ready after ${MAX_RETRIES} attempts`);
retryCount = 0
return
}
retryCount++;
setTimeout(updateHighlight, 100);
return
}
const seen = GM_getValue('seen', '');
const skipped = GM_getValue('skipped', '');
function isInList(list, workId) { return list.indexOf(',' + workId + ',') > -1; }
if (DEBUG) console.log('highlight seen[:100],', seen.slice(0, 100));
if (DEBUG) console.log('... doing updateHighlight(', ao3LinksAndIds.length ,')');
for (const [link, id, isSeries] of ao3LinksAndIds) {
if (isSeries) {
link.classList.add('khxr-series');
} else {
link.classList.add('khxr-work');
if (applyingForSecondTime) link.classList.remove('khxr-seen', 'khxr-skipped');
if (isInList(seen, id)) link.classList.add('khxr-seen');
if (isInList(skipped, id)) link.classList.add('khxr-skipped');
}
}
applyingForSecondTime = true;
}