Initial commit

This commit is contained in:
snow flurry 2024-04-26 17:34:38 -07:00
commit 259a789bda
4 changed files with 343 additions and 0 deletions

26
LICENSE.txt Normal file
View file

@ -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.

31
README.md Normal file
View file

@ -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

View file

@ -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);
});
})();

90
winterify.user.js Normal file
View file

@ -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 = "&#10052;&#65039;";
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 = "&#10052;&#65039;";
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 });
})();