Chub.AI Timeline Improvements

Make timeline easier to use

Per 30-07-2025. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==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-07-30e
// @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 LAST_FAVORITE_UPDATE_KEY = "lastFavoriteUpdate";
const HIDDEN_CARDS_KEY = "hiddenCards";

(async function() {
  'use strict';

  const { onNavigate } = VM;
  
  // 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.");
    }
  }

  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.4";
    }

    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 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 }, LAST_FAVORITE_UPDATE_KEY
    );

    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: 5 * 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-segmented-item-label", 2000);
    } catch {
      return false;
    }

    const selectedLabels = Array.from(document.querySelectorAll("label.ant-segmented-item-selected .ant-segmented-item-label"));

    if (selectedLabels.length === 0) {
      return false;
    }

    return selectedLabels.some(e => e.textContent?.includes("Timeline") ?? false);
  }

  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 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 = card.createdAt === card.lastActivityAt;
      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"));
    }
  }

  const updateFavoritesCmd = GM_registerMenuCommand("Update Favorites", async _ev => {
    const favorites = await fetchFavorites();
    await GM.setValue("favorites", favorites.nodes);
    await GM.setValue(LAST_FAVORITE_UPDATE_KEY, new Date().toISOString());
  }, {
    title: "Update Chub.AI favorite cache"
  });

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