// ==UserScript== // @name Cohost Following list tracker // @namespace datagirl.xyz // @match https://cohost.org/rc/project/following* // @grant GM.getValue // @grant GM.setValue // @version 1.0 // @author snow flurry // @description Puts a dot next to users you follow with unread posts. // ==/UserScript== (async () => { // Used by GM.getValue/GM.setValue const gmvalLastViewed = "lastViewed"; const notifyDotClass = "__x-notify-dot"; // Stolen from the notification dot on the "refresh" button :3 const dotSvgPath = ` `; const dotElem = document.createElementNS("http://www.w3.org/2000/svg", "svg"); dotElem.setAttribute('fill', 'none'); dotElem.setAttribute('viewBox', '0 0 50 37'); dotElem.classList.add("absolute", "-right-1", "-bottom-1", "h-4", "w-4", "text-cherry", "dark:text-mango", notifyDotClass); dotElem.setAttribute('style', "z-index: auto;"); dotElem.innerHTML = dotSvgPath; // Handle (re)initialization (e.g., when React attempts to rehydrate) // XXX: This might not be necessary anymore? Previous attempts to hook // React tended to require fighting the server-side hydration until it // gave up. Nowadays it only seems to trigger once. const onNeedsInit = (parentEl, callback) => { // Sentinel used to check if we're still hooked const sentinel = document.createElement("flr"); sentinel.id = "x-flurry-followpostcount-sentinel"; sentinel.style.setProperty("display", "none"); sentinel.ariaHidden = true; const appEl = document.getElementById("app"); const observer = new MutationObserver(async () => { if (document.getElementById(sentinel.id) == null) { app.appendChild(sentinel); await callback(appEl); } }); observer.observe(appEl, { subtree: true, childList: true }); }; // Gets the React properties for a given DOM element const reactProps = (elem) => { let propsKey = Object.keys(elem).find((k) => k.startsWith("__reactProps$")); // No properties, return nothing if (propsKey == null) return {}; return elem[propsKey].children.props; }; // Keeping latest as a global to avoid race conditions // in updateProfileDots const latest = await (async () => { let str = await GM.getValue(gmvalLastViewed, "{}"); let latest = JSON.parse(str); if (!latest) { console.warn(`Couldn't parse ${gmvalLastViewed}! Defaulting to an empty list.`); return {}; } return latest; })(); // Wrapper for GM.setValue that validates the output const storeLastViewed = async (obj) => { if (obj == null) { console.warn("Received null parameter. This should not happen!"); } else { await GM.setValue(gmvalLastViewed, JSON.stringify(obj)); } } const handleNotifiedClick = async (ev) => { const targetProps = reactProps(ev.currentTarget.parentElement); const projId = targetProps.project.projectId.toString(); latest[projId] = { id: targetProps.latestPost.postId, date: targetProps.latestPost.publishedAt }; // remove the notification dot, if any const notifyDot = ev.currentTarget.querySelector(`svg.${notifyDotClass}`); if (notifyDot != null) { notifyDot.parentElement.removeChild(notifyDot); } await storeLastViewed(latest); } const updateProfileDots = async (liList) => { for (const li of liList) { // the MutationObserver might give us non-li elements. // Ignore those ones if (li.tagName != "LI") { continue; } const liProps = reactProps(li); if (liProps == null || !(liProps.project)) continue; const projId = liProps.project.projectId.toString(); if (!(projId in latest)) { // No latest post! We won't show a dot, but will note this // for later. latest[projId] = { id: liProps.latestPost.postId, date: liProps.latestPost.publishedAt }; } if (li.querySelector(`svg.${notifyDotClass}`) != null) { // Dot already exists, we don't need to update it continue; } if (latest[projId].id != liProps.latestPost.postId) { // New post? make sure it's *actually* newer before // showing a dot const newDate = new Date(liProps.latestPost.publishedAt); const oldDate = new Date(latest[projId].date); if (newDate > oldDate) { // Okay, post is (probably) definitely newer. // Deploy the dot! const liDot = dotElem.cloneNode(true); li.querySelector("img").parentElement.appendChild(liDot); li.children[0].addEventListener("click", handleNotifiedClick); } } } // for (const li of liList) await storeLastViewed(latest); }; const app = document.getElementById("app"); const findSidebar = () => { for (const x of app.querySelectorAll("ul")) { if (x.querySelector("button") != null) { return x; } } return null; }; // Actual script onNeedsInit(app, async (app) => { // At time of writing, the following list sidebar is the only //