// ==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.09.13a
// @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 HIDE_RECOMMENDED_CARDS = false;
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 fetchCharacter(author, character) {
const data = await queryInternalApi(`api/characters/${author}/${character}`, { full: true });
return data?.node ?? null;
}
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 fetchSearchResults(searchParams, extraKeys = {}) {
const items = await queryInternalApi("search", searchParams, "POST");
return items.data.nodes.map(card => {
return {
...card,
...extraKeys,
};
});
}
async function fetchAuthorCards(author, page) {
const PAGE_SIZE = 50;
return await fetchSearchResults({
page,
username: author,
first: PAGE_SIZE,
namespace: 'characters',
nsfw: true,
nsfl: true,
chub: true,
count: true,
exclude_mine: true,
include_forks: true,
sort: 'created_at',
min_tokens: 0,
}, { 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 forkUrl = node.querySelector("a:has(.anticon-fork)")?.attributes?.href?.value ?? null;
const fork = forkUrl?.replace(/^\/characters\//, "") ?? 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.setProperty("border-top", "thick solid pink", "important");
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 modifyCharacterPage(author, rating, highlightDiscussion, hideGallery, publicChatCount, forkCount, highlightUser, tagsToHighlight) {
let element;
try {
element = await waitForElement(".ant-row-center", 5000);
} catch {
console.error("no character elements found");
return;
}
if (HIDE_RECOMMENDED_CARDS) {
document.querySelector("#chara-list").style.display = "none";
}
const headers = element.querySelectorAll(".ant-collapse-header-text");
for (const header of headers) {
switch (header.textContent) {
case "Discussion":
if (highlightDiscussion) {
header.style.color = "cyan";
header.appendChild(document.createTextNode(` - Rating ${rating.toFixed(1)}/5.0`));
}
break;
case "Shared public chats":
if (publicChatCount == 0) {
header.parentElement.style.display = "none";
} else {
header.appendChild(document.createTextNode(` - ${publicChatCount}`));
}
break;
case "Gallery":
if (hideGallery) {
header.parentElement.style.display = "none";
}
break;
case "Forks":
if (forkCount == 0) {
header.parentElement.style.display = "none";
} else {
header.appendChild(document.createTextNode(` - ${forkCount}`));
}
break;
default:
break;
}
}
const authorLink = element.querySelector(`i > a[href="/users/${author}"]`);
if (authorLink) {
authorLink.style.color = "cyan";
}
const existingTags = new Map(
Array.from(element.querySelectorAll(".ant-tag"))
.map(tag => [tag.textContent.toLowerCase(), tag])
);
for (const followedTag of tagsToHighlight.map(tag => tag.toLowerCase())) {
const matchingTagElement = existingTags.get(followedTag);
if (matchingTagElement !== undefined) {
matchingTagElement.style.color = "cyan";
}
}
}
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));
let selectedItem;
// Wait for labels to exist.
try {
selectedItem = await waitForElement(".ant-select-selection-item", 2000);
} catch {
return false;
}
const selectedCategory = selectedItem?.title ?? "";
return selectedCategory === "Timeline";
}
async function getCurrentListPage() {
let activePage;
try {
activePage = await waitForElement(".ant-pagination-item-active", 2000);
} catch {
throw new Error("could not find active page");
}
return parseInt(activePage.title);
}
async function characterListLoaded(dataFn) {
const [favorites, follows, currentHiddenCards, cards, cardElements] =
await Promise.all([getFavorites(), getFollows(), getHidden(), dataFn, getVisibleCards()]);
let hiddenCards = currentHiddenCards;
// Match up card with element.
const cardsWithElements = new Map();
const elements = new Map(
cardElements
.filter(e => e !== null && e !== undefined)
.map(e => [e.path.split('/')[3], e])
);
for (const card of cards) {
if (card === null || card === undefined) {
continue;
}
const cardName = card.fullPath.split('/')[1];
const foundElement = elements.get(cardName);
if (foundElement !== undefined) {
elements.delete(cardName);
cardsWithElements.set(card, foundElement);
}
}
for (const [card, element] of cardsWithElements) {
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.delete(card.id);
saveHidden(hiddenCards);
});
} catch (e) {
console.error(`failed to modify card: ${e}`);
}
}
}
async function timelineLoaded(page) {
await characterListLoaded(fetchTimeline(page));
}
async function userLoaded(username) {
async function userListChanged() {
const currentPage = await getCurrentListPage();
await characterListLoaded(fetchAuthorCards(username, currentPage));
}
let charList;
try {
charList = await waitForElement("#chara-list", 5000);
} catch {
return;
}
userListChanged();
while (true) {
try {
await waitForListRehydration(charList);
userListChanged();
} catch {
break;
}
}
}
async function characterLoaded(author, char) {
const [favorites, follows, charData] =
await Promise.all([getFavorites(), getFollows(), fetchCharacter(author, char)]);
const rating = charData.rating;
const highlightDiscussion = charData.ratingCount > 0;
const hideGallery = !charData.hasGallery;
const publicChatCount = charData.n_public_chats;
const forkCount = charData.forksCount;
const followingUser = follows.users.has(author);
const followedTags = charData.topics.filter(tag => follows.tags.has(tag.toLowerCase()));
await modifyCharacterPage(author, rating, highlightDiscussion, hideGallery, publicChatCount, forkCount, followingUser, followedTags);
}
async function tagLoaded(tag, searchParams = null) {
let search;
if (searchParams !== null) {
search = searchParams;
} else {
search = {
topics: tag,
page: 1,
first: 20,
namespace: "*",
asc: false,
sort: "default",
min_tags: 2,
include_forks: true,
nsfw: true,
nsfl: true,
chub: true,
nsfw_only: false,
min_ai_rating: 0,
min_tokens: 50,
max_tokens: 100000,
exclude_mine: false,
};
}
const BAD_KEYS = new Set([
"special_mode", "name_like", "only_mine", "inclusive_or", "max_days_ago", "min_users_chatted"
]);
const OVERRIDES = {
exclude_mine: false,
}
search = Object.keys(search).filter(key => !BAD_KEYS.has(key)).reduce((obj, key) => {
obj[key] = search[key];
return obj;
}, {});
await characterListLoaded(fetchSearchResults({ ...search, ...OVERRIDES }));
}
async function handleNavigate() {
console.log(window.location);
const urlParams = new URLSearchParams(window.location.search);
const path = window.location.pathname;
// Check if on timeline.
if (path == "/") {
if ((urlParams.size === 0 && await isTimelineSelected()) || urlParams.get("segment") === "timeline") {
timelineLoaded(parseInt(urlParams.get("page") ?? "1"));
} else {
return;
}
}
// Check if on character page.
else if (path.startsWith("/characters/")) {
const authorAndChar = path.replace(/^\/characters\//, "").split('/');
characterLoaded(...authorAndChar);
} else if (path.startsWith("/users/")) {
const username = path.replace(/^\/users\//, "");
userLoaded(username);
} else if (path.startsWith("/tags/")) {
const tag = path.replace(/^\/tags\//, "");
tagLoaded(tag, (urlParams.size > 0) ? Object.fromEntries(urlParams) : null);
}
}
addEventListener("popstate", (event) => {
handleNavigate();
});
// 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) => {
let existingElement = document.querySelector(selector);
if (existingElement) {
return resolve(existingElement);
}
let timeoutPid;
if (timeout >= 0) {
timeoutPid = setTimeout(() => {
observer.disconnect();
reject(`Timed out after ${timeout} ms.`);
}, timeout);
}
const observer = new MutationObserver(mutations => {
let foundElement = document.querySelector(selector);
if (foundElement) {
if (timeoutPid) {
clearTimeout(timeoutPid);
}
observer.disconnect();
resolve(foundElement);
}
});
// 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
});
});
}
function waitForListRehydration(element, timeout = -1, debounceMs = 100, onlyDirectChildren = true) {
return new Promise((resolve, reject) => {
let timeoutPid;
if (timeout >= 0) {
timeoutPid = setTimeout(() => {
observer.disconnect();
reject(`Timed out after ${timeout} ms.`);
}, timeout);
}
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
if (timeoutPid) {
clearTimeout(timeoutPid);
}
timeoutPid = setTimeout(() => {
observer.disconnect();
resolve();
}, debounceMs);
}
}
});
observer.observe(element, {
childList: true,
subtree: !onlyDirectChildren
});
});
}
})();