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