commit 259a789bdabcb3cf54ae24371bff0b2b3b67d513 Author: snow flurry <snow@datagirl.xyz> Date: Fri Apr 26 17:34:38 2024 -0700 Initial commit diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..c0d6068 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,26 @@ +Copyright (C) 2024, snow flurry. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..33c1102 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Cohost Userscripts + +Various userscripts for Cohost. Requires a userscript manager, such as +[ViolentMonkey]. + +These are updated on an "as I have the energy to update them" basis, so I can't +make any promises about their functionality. Feel free to send me [an ask] on +Cohost or an email, although I might not respond. + +# About the Userscripts + +## winterify: Add a snowy background to your Cohost + +That's it! The file is about 3KiB because I embedded a GIF in it. that's the +snow flurry quality guarantee 😎 + +## Cohost Following list tracker + +Adds a little "unread" dot to users you're following who have unread posts in +the [Following page]. Since I wanted to avoid making API calls for multiple +reasons, there are a couple caveats: + +* If the script has never "seen" the user you're following before, it avoids + putting a dot next to them. As a result, people who you newly followed or who + weren't loaded into the list previously won't show an unread dot. +* Detecting unread posts on refresh is liable to break if @staff sneezes in the + "refresh" button's general direction. + +[ViolentMonkey]: https://violentmonkey.github.io/ +[an ask]: https://cohost.org/flurry/ask +[Following page]: https://cohost.org/rc/project/following diff --git a/following-list-tracker.user.js b/following-list-tracker.user.js new file mode 100644 index 0000000..bba1ad2 --- /dev/null +++ b/following-list-tracker.user.js @@ -0,0 +1,196 @@ +// ==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 <snow@datagirl.xyz> +// @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 = ` + <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! 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 + // <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); + }); +})(); diff --git a/winterify.user.js b/winterify.user.js new file mode 100644 index 0000000..6084737 --- /dev/null +++ b/winterify.user.js @@ -0,0 +1,90 @@ +// ==UserScript== +// @name winterify cohost +// @namespace datagirl.xyz +// @match https://cohost.org/* +// @grant none +// @version 1.6.1 +// @author snow flurry +// @description 11/14/2022, 1:16:16 PM +// ==/UserScript== + +// Changelog: +// 1.5: +// - Use a MutationObserver instead of Interval (less brute forcing) +// - Removed foreground snow effect + + +(() => { + // if you want snowfall mode to run by default, set this to true. + const defaultOn = true; + + const divId = 'userscript-snowfall'; + + function makeWinter() { + let activateBtn = document.createElement("button"); + activateBtn.innerHTML = "❄️"; + activateBtn.id = "userscript-winterify-activate"; + activateBtn.style.padding = "0.5em 0.75em"; + + // only create the snowfall if it doesn't already exist + // (so we don't mess up the DOM more than we already are) + let snowfall = document.getElementById(divId); + if (snowfall == null) { + let snowfall = document.createElement("div"); + snowfall.id = divId; + + snowfall.style.position = "fixed"; + snowfall.style.top = snowfall.style.bottom = snowfall.style.left = snowfall.style.right = "0"; + snowfall.style.pointerEvents = "none"; + + const setBg = (snowfall, isDark) => { + // this darkens the background, but the 20% opacity (hopefully!) doesn't darken it too much. + darkMode = isDark ? "" : "#000"; + snowfall.style.background = `url('https://staging.cohostcdn.org/attachment/cc9ae945-a270-4059-8992-70f74128c332/snowfall_anim.gif') ${darkMode}`; + }; + + snowfall.style.opacity = "0.2"; + snowfall.style.zIndex = "-1"; + snowfall.style.display = defaultOn ? "block" : "none"; + setBg(snowfall); + + // try to respond to light and dark modes + if (window.matchMedia) { + const darkMatch = window.matchMedia('(prefers-color-scheme: dark)'); + setBg(snowfall, darkMatch.matches); + darkMatch.addEventListener('change', (ev) => { + setBg(snowfall, ev.matches); + }); + + // and append it to the body + document.body.append(snowfall); + } else { + // assume dark, I guess? + setBg(snowfall, true); + } + + activateBtn.addEventListener("click", function(ev) { + let snowfall = document.getElementById(divId); + ev.target.innerHTML = "❄️"; + snowfall.style.display = (snowfall.style.display == "none") ? "block" : "none"; + }); + + let nav = document.querySelector("nav"); + if (nav != null) { + nav.prepend(activateBtn); + } + } + } + + // TODO: this feels like a bad idea, but I wasn't able to find a better way + // to handle this by messing with __reactContainer$[bytes]. If you know of a + // better way, send an ask on cohost (@flurry) pls! + const app = document.getElementById("app"); + const observer = new MutationObserver(() => { + if (document.getElementById(divId) == null) { + makeWinter(); + } + }); + observer.observe(app, { subtree: true, childList: true }); +})(); +