Initial commit
This commit is contained in:
commit
259a789bda
26
LICENSE.txt
Normal file
26
LICENSE.txt
Normal 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
31
README.md
Normal 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
|
196
following-list-tracker.user.js
Normal file
196
following-list-tracker.user.js
Normal 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
90
winterify.user.js
Normal 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 = "❄️";
|
||||||
|
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 });
|
||||||
|
})();
|
||||||
|
|
Loading…
Reference in a new issue