Sleazy Fork is available in English.

Chub.AI Timeline Improvements

Make timeline easier to use

// ==UserScript==
// @name         Chub.AI Timeline Improvements
// @description  Make timeline easier to use
// @match        https://chub.ai/*
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM_registerMenuCommand
// @version      2025.08.09a
// @author       anden3
// @license      MIT
// @require      https://cdn.jsdelivr.net/npm/@violentmonkey/url#sha384-MW/Hes7CLT6ZD4zwzTUVdtXL/VaIDQ3uMFVuOx46Q0xILNG6vEueFrCaYNKw+YE3
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @namespace    https://greasyfork.org/users/1499640
// ==/UserScript==

/* globals VM */
/* jshint esversion: 11 */

let API_KEY = "YOUR API KEY HERE";
const API_BASE = "https://inference.chub.ai/api";
const INTERNAL_API_BASE = "https://gateway.chub.ai";

const HIDDEN_CARDS_KEY = "hiddenCards";

(async function() {
  'use strict';
  
  // Acquire API key. First check localStorage, next cache, and last the hardcoded value.
  const localStorageAPIKey = localStorage.getItem("URQL_TOKEN");

  if (localStorageAPIKey !== null) {
    API_KEY = localStorageAPIKey;
    await GM.setValue("API_KEY", API_KEY);
  } else {
    const possiblyCachedAPIKey = await GM.getValue("API_KEY", null) ?? null;

    if (possiblyCachedAPIKey !== null) {
      API_KEY = possiblyCachedAPIKey;
    } else if (API_KEY !== "YOUR API KEY HERE") {
      await GM.setValue("API_KEY", API_KEY);
    } else {
      throw new Error("No API key could be located, please log in.");
    }
  }

  const { onNavigate } = VM;

  async function queryApi(url, queryParams = {}, method = "GET") {
    const searchParams = new URLSearchParams(queryParams);
    console.log(`${url}?${searchParams}`);

    const response = await fetch(`${url}?${searchParams}`, {
      method,
      headers: {
        "Content-Type": "application/json",
        "CH-API-KEY": API_KEY,
      },
    });

    if (!response.ok) {
      throw new Error(`Response status: ${response.status}`);
    }

    const result = await response.json();

    if (result.error !== undefined) {
      console.error(`API Query failed: ${result}`);
      throw new Error(result);
    }

    return result;
  }

  async function queryPublicApi(endpoint, queryParams = {}, method = "GET") {
    return await queryApi(`${API_BASE}/${endpoint}`, queryParams, method);
  }

  async function queryInternalApi(endpoint, queryParams = {}, method = "GET") {
    return await queryApi(`${INTERNAL_API_BASE}/${endpoint}`, queryParams, method);
  }


  async function getProfile() {
    return await queryInternalApi("api/account");
  }

  async function fetchTimeline(page) {
    const cards = await queryInternalApi("api/timeline/v1", { page, count: false });

    return cards.data.nodes.map(card => {
      const author = card.fullPath.split("/")[0];

      return {
        ...card,
        author,
      };
    });
  }

  async function fetchFavorites() {
    return await queryPublicApi("favorites");
  }

  async function fetchFollows() {
    const profile = await getProfile();

    if (profile.user_name === "You") {
      alert("The API key provided to this userscript (Chub.AI Timeline Improvements) is invalid.\nDisable this userscript if this alert is annoying.");
      throw new Error("API key failed to authenticate user, most likely invalid.");
    }

    let follows = {
      users: [],
      tags: [],
    };

    let page = 1;

    while (true) {
      let followData = await queryInternalApi(`api/follows/${profile.user_name}`, { page: page });

      if (followData.follows.length + followData.tag_follows.length === 0) {
        break;
      }

      follows.users.push(...followData.follows);
      follows.tags.push(...followData.tag_follows);

      page += 1;
      // Sleep
      await new Promise(r => setTimeout(r, 1000));
    }

    return follows;
  }

  async function getVisibleCards() {
    // Wait for cards to exist.
    try {
      await waitForElement("#chara-list", 5000);
    } catch {
      return [];
    }

    return Array.from(document.querySelectorAll("#chara-list > a"))
      .map(node => {
        const path = node.attributes.href.value;
        const fork = node.querySelector("a:has(.anticon-fork)")?.attributes?.href?.value ?? null;

        return {
          node,
          path,
          fork,
        }
      });
  }

  function modifyCard(element, isFavorited, forkOfFave, isNew, isHidden, highlightUser, tagsToHighlight, onHideButtonClick, onHiddenCardClick) {
    const cardBody = element.querySelector(".ant-card-body");

    // Add hide button.
    const moreButton = element.querySelector("button:has(span.anticon-more)");
    const buttonRow = moreButton.parentElement;

    let hideButton = document.createElement("button");
    hideButton.style = moreButton.style;
    hideButton.type = "button";
    hideButton.classList = moreButton.classList;
    hideButton.innerText = "🗑";

    moreButton.style.display = "none";
    buttonRow.appendChild(hideButton);

    hideButton.addEventListener("click", evt => {
      evt.stopPropagation();
      evt.preventDefault();

      cardBody.style.display = "none";
      isHidden = true;
      onHideButtonClick();
    });

    element.addEventListener("click", evt => {
      if (isHidden) {
        evt.stopImmediatePropagation();
        evt.stopPropagation();
        evt.preventDefault();

        cardBody.style.display = "unset";
        isHidden = false;

        onHiddenCardClick();
      }
    });

    if (isFavorited) {
      element.style.borderTop = "thick solid pink";
      isHidden = true;
    }

    if (isHidden) {
      cardBody.style.display = "none";
    }

    if (!isNew) {
      element.style.opacity = "0.5";
    }

    if (highlightUser) {
      const userLink = element.querySelector(".ant-row > p > a");
      userLink.style.color = "cyan";
    }

    if (forkOfFave) {
      const fork = element.querySelector("a:has(.anticon-fork)");
      fork.style.color = "cyan";
    }

    if (tagsToHighlight.length > 0) {
      const lowercaseTags = tagsToHighlight.map(tag => tag.toLowerCase());
      let tagsNotInContainer = tagsToHighlight;

      const tagContainer = element.querySelector(".ant-row > div:has(> span.cursor-pointer > .ant-tag)");
      const tags = Array.from(
        tagContainer.querySelectorAll("span.cursor-pointer > .ant-tag > span:first-child")
      );

      if (tags.length === 0) {
        // Need to create our own tag elements.
        return;
      }

      const tagToCopy = tags[0].parentElement.parentElement;

      const existingTags = new Map(tags.map(tag => [tag.textContent.toLowerCase(), [tag.parentElement.parentElement, tag]]));

      let tagElementsToHighlight = [];

      for (let i = tagsToHighlight.length - 1; i >= 0; i--) {
        if (existingTags.has(lowercaseTags[i])) {
          tagsNotInContainer.splice(i, 1);

          const [tagElement, tagTextElement] = existingTags.get(lowercaseTags[i]);
          tagElementsToHighlight.push([tagElement, tagTextElement]);

        } else {
          const clonedTag = tagToCopy.cloneNode(true);
          const clonedTagText = clonedTag.querySelector(".ant-tag > span:first-child");
          clonedTagText.textContent = tagsToHighlight[i];
          tagElementsToHighlight.push([clonedTag, clonedTagText]);
        }
      }

      tagElementsToHighlight.sort((a, b) => a[1].textContent.toLowerCase() < b[1].textContent.toLowerCase() ? -1 : 1);


      for (let i = tagElementsToHighlight.length - 1; i >= 0; i--) {
        const [tagElement, tagTextElement] = tagElementsToHighlight[i];
        tagTextElement.style.color = "cyan";
        tagContainer.insertBefore(tagElement, tagContainer.children[0]);
      }
    }
  }

  async function cacheIsStale(key, thresholdMs = 60 * 1000) {
    const lastCacheUpdate = await GM.getValue(key, null);

    if (lastCacheUpdate === null) {
      return true;
    }

    const msSinceUpdate = Math.max(new Date() - new Date(lastCacheUpdate), 0);
    return msSinceUpdate >= thresholdMs;
  }

  const DEFAULT_CACHE_OPTIONS = {
    staleThresholdMs: 60 * 1000,
    mappingFn: foo => foo,
  };

  // Tries from cache if fresh, else fetches values.
  async function getCachedCollection(
  cacheKey,
   fetchFn,
   cacheOptions,
   lastUpdateKey = `last${cacheKey}Update`,
  ) {
    const cache_options = {
      ...DEFAULT_CACHE_OPTIONS,
      ...cacheOptions,
    };

    const _cmd = GM_registerMenuCommand(`Update ${cacheKey}`, async _ev => {
      const values = cache_options.mappingFn(await fetchFn);
      await GM.setValue(cacheKey, values);
      await GM.setValue(lastUpdateKey, new Date().toISOString());
    }, {
      title: `Update Chub.AI ${cacheKey} cache`
    });

    const cachedValues = await GM.getValue(cacheKey, null) ?? null;

    if (cachedValues === null || await cacheIsStale(lastUpdateKey, cache_options.staleThresholdMs)) {
      const values = cache_options.mappingFn(await fetchFn());

      await GM.setValue(cacheKey, values);
      await GM.setValue(lastUpdateKey, new Date().toISOString());

      return values;
    } else {
      return cachedValues;
    }
  }


  async function getFavorites() {
    const favorites = await getCachedCollection(
      "favorites", fetchFavorites, { mappingFn: faves => faves.nodes }, "lastFavoriteUpdate"
    );

    return {
      ids: new Set(favorites.map(fave => fave.id)),
      paths: new Set(favorites.map(fave => fave.fullPath)),
    };
  }

  async function getFollows() {
    let followsData = await getCachedCollection("follows", fetchFollows, { staleThresholdMs: 60 * 60 * 1000 });

    return {
      users: new Set(followsData.users.map(u => u.username)),
      tags: new Set(followsData.tags.map(t => t.tagname.toLowerCase())),
    };
  }

  async function getHidden() {
    const hiddenCards = await GM.getValue(HIDDEN_CARDS_KEY, []) ?? [];
    return new Set(hiddenCards);
  }

  async function saveHidden(hiddenCards) {
    const hiddenCardsArray = Array.from(hiddenCards);
    await GM.setValue(HIDDEN_CARDS_KEY, hiddenCardsArray);
  }

  async function isTimelineSelected() {
    await new Promise(r => setTimeout(r, 2000));

    // Wait for labels to exist.
    try {
      await waitForElement(".ant-select-selection-item", 2000);
    } catch {
      return false;
    }

    const selectedCategory = document.querySelector(".ant-select-selection-item")?.title ?? "";
    return selectedCategory === "Timeline";
  }

  async function timelineLoaded(page) {
    const favorites = await getFavorites();
    const follows = await getFollows();
    let hiddenCards = await getHidden();

    const cards = await fetchTimeline(page);
    const cardElements = await getVisibleCards();

    for (let i in cards) {
      const card = cards[i];
      const element = cardElements[i];

      const msSinceUpdate = new Date(card.lastActivityAt) - new Date(card.createdAt);
      const hoursSinceUpdate = msSinceUpdate / (1000 * 60 * 60);

      const followedTags = card.topics.filter(tag => follows.tags.has(tag.toLowerCase()));
      const followingUser = follows.users.has(card.author);
      const isFavorited = favorites.ids.has(card.id);
      const forkOfFave = (element.fork !== null) ? favorites.paths.has(element.fork) : false;
      const isNew = hoursSinceUpdate <= 24;
      const isHidden = hiddenCards.has(card.id);

      try {
        modifyCard(element.node, isFavorited, forkOfFave, isNew, isHidden, followingUser, followedTags, () => {
          if (!isFavorited) {
            hiddenCards.add(card.id);
            saveHidden(hiddenCards);
          }
        }, () => {
          hiddenCards.remove(card.id);
          saveHidden(hiddenCards);
        });
      } catch (e) {
        console.error(`failed to modify card: ${e}`);
      }
    }
  }

  async function handleNavigate() {
    console.log(window.location);

    if (window.location.pathname !== "/") {
      return;
    }

    const urlParams = new URLSearchParams(window.location.search);

    if ((urlParams.size === 0 && await isTimelineSelected()) || urlParams.get("segment") === "timeline") {
      timelineLoaded(parseInt(urlParams.get("page") ?? "1"));
    }
  }

  // Watch route change
  VM.onNavigate(handleNavigate);

  // Call it once for the initial state
  handleNavigate();

  // Source: https://stackoverflow.com/a/61511955
  function waitForElement(selector, timeout = -1) {
    return new Promise((resolve, reject) => {
      if (document.querySelector(selector)) {
        return resolve(document.querySelector(selector));
      }

      let timeoutPid;

      if (timeout >= 0) {
        timeoutPid = setTimeout(() => {
          observer.disconnect();
          reject(`Timed out after ${timeout} ms.`);
        }, timeout);
      }

      const observer = new MutationObserver(mutations => {
        if (document.querySelector(selector)) {
          if (timeoutPid) {
            clearTimeout(timeoutPid);
          }

          observer.disconnect();
          resolve(document.querySelector(selector));
        }
      });

      // If you get "parameter 1 is not of type 'Node'" error, see https://stackoverflow.com/a/77855838/492336
      observer.observe(document.body, {
        childList: true,
        subtree: true
      });
    });
  }
})();