cohost-userscripts/following-list-tracker.user.js

212 lines
7.7 KiB
JavaScript

// ==UserScript==
// @name Cohost Following list tracker
// @namespace datagirl.xyz
// @match https://cohost.org/rc/project/following*
// @grant GM.getValue
// @grant GM.setValue
// @version 1.1
// @author snow flurry <snow@datagirl.xyz>
// @description Puts a dot next to users you follow with unread posts.
// ==/UserScript==
// Changelog:
// 1.1:
// - Default to showing "unknown" followed users as unread.
// - If you want the previous setting, set "unreadForUnknown"
// to false in the script's Values tab.
(async () => {
// Used by GM.getValue/GM.setValue
const gmvalLastViewed = "lastViewed";
const notifyDotClass = "__x-notify-dot";
// Whether to show "unknown" users as unread by default
const unreadForUnknown = GM.getValue("unreadForUnknown", true);
// Stolen from the notification dot on the "refresh" button :3
const dotSvgPath = `
<path d="M29.9375 34.712c-4.5219 1.3518-8.8029 1.9644-12.843
1.8377-4.0401-.1267-7.50633-1.0808-10.39884-2.8624-2.89253-1.7814-4.8862-4.5046-5.981084-8.1695-1.101144-3.6858-.929664-7.062.514464-10.1285
1.44415-3.0666 3.82337-5.75117 7.13767-8.05372C11.681 5.03302 15.5992 3.2058 20.1211 1.85394 24.6221.508325 28.8858-.104798 32.9123.014595
36.9387.133964 40.4019 1.07761 43.3017 2.8455c2.8999 1.76792 4.8972 4.48429 5.9921 8.1491 1.0949 3.6649.9166 7.0374-.5349 10.1176-1.4514
3.0802-3.826 5.7804-7.1235 8.1008-3.2976 2.3204-7.1969 4.1534-11.6979 5.499Z" fill="currentColor">
</path>`;
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! Use unreadForUnknown to figure
// out what to do.
if (unreadForUnknown) {
latest[projId] = {
id: 0,
date: "1970-01-01T00:00:00.000Z"
};
} else {
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
// <ul/> with buttons. Since :has isn't yet stable in Firefox,
// we do some querySelector stuff to check for it.
let sidebar = findSidebar();
// We should be tied to /rc/project/following, but better safe
// than sorry
if (sidebar == null) {
console.error("Couldn't find following sidebar!");
}
const listObserver = new MutationObserver(async (records) => {
// "+ Load More" button is the last node in the list. Sadly,
// I'm not aware of a better guessing method.
if (new Array(...records).find((rec) =>
(rec.nextSibling == null &&
rec.addedNodes.length > 0)
) != null)
{
// HACK: when refreshing, the "+ Load More" button is
// removed, and is put back after refreshing is complete.
// We can use this to determine if a refresh occurred, and
// perform a full refresh of the ul.
const sidebar = findSidebar();
if (sidebar) {
updateProfileDots(sidebar.children);
}
} else {
// Typically happens when the user clicks "+ Load More"
for (const record of records) {
if (record.addedNodes.length > 0) {
updateProfileDots(record.addedNodes);
}
}
}
});
listObserver.observe(sidebar, { childList: true, subtree: true });
// Iterate all list items, each of which corresponding to a
// following entry
updateProfileDots(sidebar.children);
});
})();