// ==UserScript== // @name Extend "AO3: Kudosed and seen history" | Export/Import + Standalone Light/Dark mode toggle // @description Add Export/Import history to TXT buttons at the bottom of the page. │ Fix back-navigation not being collapsed. │ Color and rename the confusing Seen/Unseen buttons. │ Enhance the title. │ Fix "Mark as seen on open" triggering on external links. ║ Standalone feature: Light/Dark site skin toggle button. // @author C89sd // @version 1.28 // @match https://archiveofourown.org/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @namespace https://greasyfork.org/users/1376767 // @downloadURL none // ==/UserScript== 'use strict'; const ENHANCED_SEEN_BUTTON = true; // Seen button is colored and renamed / Immediately mark seen / Blink when navigating back const COLORED_TITLE_LINK = true; // Title becomes a colored link const ENHANCED_MARK_SEEN_ON_OPEN = true; // Enable improved "Mark seen on open" feature with a distinction between SEEN Now and Old SEEN const IGNORE_EXTERNAL_LINKS = true; // Mark as seen when a link is clicked on AO3 but not from other sites (e.g. reddit). If false, 'New SEEN' will tell you if it was a new or old link. const SITE_SKINS = [ "Default", "Reversi" ]; // The AO3 Kudosed History script requires a manual reload after a link is clicked: // - Clicked fics are not collpased and when navigating back. // - Seen changes made from the other page are not taken into account. // // To fix this: // Intercept clicks on links to immediately trigger the 'seen' button collapse and various blink effects. // Write the current fic id/state to localStorage from fics. // When back-navigating, read it back and try to find its link on-screen to update its collapsed status. let currentSeenState = null; // Updated inside of fics (MutationObserver on the fic's seen button). if (ENHANCED_SEEN_BUTTON) { const isWork = /^https:\/\/archiveofourown\.org(?:\/collections\/[^\/]+)?(\/works\/\d+)/ let refererData = {}; // {} | { workSlashId + seenState } of the referrer page (lastest when navigating back and forth). // About to leave page: write state for the next page to load. window.addEventListener("pagehide", function (event) { // Note: Doing this in 'unload'(desktop) or 'beforeunload'(mobile) caused 'event.persisted' to be false. const match = window.location.href.match(isWork); if (match) { // Note: sessionStorage did not work on desktop; and GM_setValue bechmarked 27% slower than localStorage. if (currentSeenState === null && refererData?.workSlashId === match[1]) { // Carry seenState back over adult content warning pages (they have no seen button). localStorage.setItem("C89AO3_state", JSON.stringify({"workSlashId": match[1], "seenState": (refererData?.seenState)})); } else { localStorage.setItem("C89AO3_state", JSON.stringify({"workSlashId": match[1], "seenState": (currentSeenState)})); } } else { localStorage.setItem("C89AO3_state", '{}'); } }); // Navigated back: load state communicated from originating page. // updated on page load/back navigation/etc. window.addEventListener("pageshow", function (event) { let data = localStorage.getItem("C89AO3_state"); refererData = JSON.parse(data ? data : '{}'); // console.log('navigated back: data=', data, ', persisted=', event.persisted) }); // Blink functionality. const flashCSS = ` @keyframes flash-glow { 0% { box-shadow: 0 0 4px currentColor; } 100% { box-shadow: 0 0 4px transparent; } } @keyframes slide-left { 0% { transform: translateX(6px); } 100% { transform: translateX(0); } } /* When opening, slide down */ li[role="article"]:not(.marked-seen).blink div.header.module { transition: all 0.25s ease-out; //0.15s } /* Always blink border */ li.blink { animation: flash-glow 0.25s ease-in 1; } /* When closing, slide title left */ //li.blink.marked-seen div.header.module { // animation: slide-left 0.15s ease-out 1; //} //li.blink.marked-seen { // animation: flash-glow 0.2s ease-out 1; //} /* When collapsing, slide title in */ //li[role="article"].blink.marked-seen * h4.heading { // transition: all 0.300s ease-out; //} //li[role="article"]:not(.marked-seen) * ul.required-tags { // transition: all 0.1s ease-out; //}`; GM_addStyle(flashCSS); let blinkTimeout; function blink(article) { // console.log("BLINK from ", article) clearTimeout(blinkTimeout); article.classList.remove('blink'); void article.offsetWidth; // reflow article.classList.add('blink'); blinkTimeout = setTimeout(() => { article.classList.remove('blink'); }, 250); } // Navigated back: blink + update seen state. window.addEventListener('pageshow', (event) => { // console.log("navigated back, persisted=", event.persisted) if (event.persisted) { // If we navigated back. if (refererData?.workSlashId) { // If we read a fic id from localStorage. // Try finding the link of the fic we navigated back from and toggle its parent visibility. // Note: use *= because there can be: '.com/works/123' or '.com/collections/u1/works/132' or ?foo at the end. const titleLink = document.querySelector(`h4.heading > a[href*="${refererData.workSlashId}"]`); if (titleLink) { const article = titleLink.closest('li[role="article"]'); if (article) { blink(article); if ( refererData?.seenState === true && !article.classList.contains('marked-seen')) { article.classList.add('marked-seen'); } else if (refererData?.seenState === false && article.classList.contains('marked-seen')) { article.classList.remove('marked-seen'); } } } } } }); // Floating seen button click: blink. // The AO3 script calls event.stopPropagation() so document.addEventListener('click') does not work, we do his: function onKhToggleClick(e) { // console.log("click (floating seen .kh-toggle) ", event.target) const article = event.target.closest('li[role="article"]'); if (article) { if (e.target.textContent === 'seen') blink(article); } } function attachToAll() { document.querySelectorAll('.kh-toggle').forEach(el => { // avoid double-binding if (!el.__khListenerAttached) { el.addEventListener('click', onKhToggleClick, /* capture */ true); el.__khListenerAttached = true; } }); } attachToAll(); // Title click: blink + send click event to floating seen button + redirect. document.addEventListener('click', function(event) { // console.log("click (title) ", event.target) const titleLink = event.target.closest('h4.heading > a'); if (titleLink) { const article = titleLink.closest('li[role="article"]'); if (article) { const seenButton = article.querySelector('div.kh-toggles>a') if (seenButton) { // Give the "seen" action time to execute before loading the page. event.preventDefault(); blink(article); // Click the seen button (unless the fic is collapsed - that would unmark it!). if (!article.classList.contains('marked-seen')) { seenButton.click(); } // Wait for seenButton.click() to complete before reloading. requestIdleCallback(() => { window.location.href = titleLink.href; }); } } } }); } // GET the preferences form, find the current skin_id, and POST the next skin_id. function getPreferencesForm(user) { // GET the preferences fetch(`https://archiveofourown.org/users/${user}/preferences`, { method: 'GET', headers: { 'Content-Type': 'text/html' } }) .then(response => response.text()) .then(responseText => { const doc = new DOMParser().parseFromString(responseText, 'text/html'); // Extract the authenticity token const authenticity_token = doc.querySelector('input[name="authenticity_token"]')?.value; if (authenticity_token) { // console.log('authenticity_token: ', authenticity_token); // Log the token } else { alert('[userscript:Extend AO3] Error\n[authenticity_token] not found!'); return; } // Find the