Inkbunny Quick Block & Favorite Buttons

Adds quick block and favorite buttons to submission thumbnails. Uses the official Inkbunny API for favorite detection (credentials are sent only to inkbunny.net).

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Inkbunny Quick Block & Favorite Buttons
// @namespace    http://tampermonkey.net/
// @version      6.1
// @description  Adds quick block and favorite buttons to submission thumbnails. Uses the official Inkbunny API for favorite detection (credentials are sent only to inkbunny.net).
// @author       YourUsername
// @license      MIT
// @match        https://inkbunny.net/*
// @match        https://*.inkbunny.net/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  const BLOCK_BUTTON_CLASS = "ib-quick-block-btn";
  const FAV_BUTTON_CLASS = "ib-quick-fav-btn";
  const CONTAINER_CLASS = "quick-action-buttons";
  const SID_STORAGE_KEY = "ib_quick_buttons_api_sid";
  const LOGIN_COOLDOWN_KEY = "ib_quick_buttons_login_cooldown";
  const LOGIN_COOLDOWN_MS = 60000; // 1 minute cooldown after cancelled login

  const SUBMISSION_SELECTOR = [
    "div.widget_thumbnailHugeCompleteFromSubmission",
    "div.widget_thumbnailLargeCompleteFromSubmission",
    "div.widget_thumbnailCompleteFromSubmission",
  ].join(", ");

  let cachedCurrentUserId = null;
  let cachedApiSid = GM_getValue(SID_STORAGE_KEY, null);
  let favoritedSubmissions = new Set();
  let checkedSubmissions = new Set();
  let checkInProgress = false;
  let loginInProgress = null;

  // Inject styles once
  function injectStyles() {
    const style = document.createElement("style");
    style.textContent = `
      .${CONTAINER_CLASS} {
        display: flex;
        justify-content: space-between;
        padding: 4px;
      }

      .${BLOCK_BUTTON_CLASS},
      .${FAV_BUTTON_CLASS} {
        width: 24px;
        height: 24px;
        font-size: 20px;
        cursor: pointer;
        opacity: 0.6;
        display: flex;
        align-items: center;
        justify-content: center;
        background: rgba(255, 255, 255, 0.2);
        border-radius: 4px;
        border: none;
        padding: 0;
        line-height: 1;
      }

      .${BLOCK_BUTTON_CLASS}:hover,
      .${FAV_BUTTON_CLASS}:hover {
        opacity: 1;
      }

      .${FAV_BUTTON_CLASS}[data-favorited="true"] {
        opacity: 1;
      }

      .ib-login-overlay {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: rgba(0, 0, 0, 0.7);
        display: flex;
        align-items: center;
        justify-content: center;
        z-index: 10000;
      }

      .ib-login-modal {
        background: #2a2a2a;
        border-radius: 8px;
        padding: 24px;
        max-width: 360px;
        width: 90%;
        color: #fff;
        font-family: sans-serif;
      }

      .ib-login-modal h3 {
        margin: 0 0 8px 0;
        font-size: 18px;
      }

      .ib-login-modal p {
        margin: 0 0 16px 0;
        font-size: 13px;
        color: #aaa;
      }

      .ib-login-modal input {
        width: 100%;
        padding: 10px;
        margin-bottom: 12px;
        border: 1px solid #444;
        border-radius: 4px;
        background: #1a1a1a;
        color: #fff;
        font-size: 14px;
        box-sizing: border-box;
      }

      .ib-login-modal input:focus {
        outline: none;
        border-color: #6a6aff;
      }

      .ib-login-modal-buttons {
        display: flex;
        gap: 8px;
        justify-content: flex-end;
      }

      .ib-login-modal button {
        padding: 8px 16px;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-size: 14px;
      }

      .ib-login-modal button.cancel {
        background: #444;
        color: #fff;
      }

      .ib-login-modal button.submit {
        background: #5a5aff;
        color: #fff;
      }

      .ib-login-modal button:hover {
        opacity: 0.9;
      }

      .ib-login-modal .error {
        color: #ff6b6b;
        font-size: 13px;
        margin-bottom: 12px;
      }
    `;
    document.head.appendChild(style);
  }

  // Debounce utility
  function debounce(fn, delay) {
    let timeout;
    return function (...args) {
      clearTimeout(timeout);
      timeout = setTimeout(() => fn.apply(this, args), delay);
    };
  }

  function getToken() {
    return document.querySelector('input[name="token"]')?.value;
  }

  async function getCurrentUserId() {
    if (cachedCurrentUserId) return cachedCurrentUserId;
    const res = await fetch("https://inkbunny.net/account.php");
    const html = await res.text();
    cachedCurrentUserId = html.match(/name="user_id" value="(\d+)"/)?.[1];
    return cachedCurrentUserId;
  }

  function clearApiSession() {
    cachedApiSid = null;
    GM_deleteValue(SID_STORAGE_KEY);
  }

  function isLoginOnCooldown() {
    const cooldownUntil = GM_getValue(LOGIN_COOLDOWN_KEY, null);
    if (!cooldownUntil) return false;
    if (Date.now() < cooldownUntil) return true;
    GM_deleteValue(LOGIN_COOLDOWN_KEY);
    return false;
  }

  function setLoginCooldown() {
    GM_setValue(LOGIN_COOLDOWN_KEY, Date.now() + LOGIN_COOLDOWN_MS);
  }

  function showLoginModal() {
    return new Promise((resolve) => {
      const overlay = document.createElement("div");
      overlay.className = "ib-login-overlay";

      const modal = document.createElement("div");
      modal.className = "ib-login-modal";

      modal.innerHTML = `
        <h3>Inkbunny API Login</h3>
        <p>Authentication Required: Please enter your Inkbunny credentials to enable automatic favorite detection. This script communicates exclusively with the official Inkbunny API. Your login is used only to retrieve your favorite list and is stored locally in your browser's private script storage.</p>
        <div class="error" style="display: none;"></div>
        <input type="text" name="username" placeholder="Username" autocomplete="username">
        <input type="password" name="password" placeholder="Password" autocomplete="current-password">
        <div class="ib-login-modal-buttons">
          <button type="button" class="cancel">Cancel</button>
          <button type="button" class="submit">Login</button>
        </div>
      `;

      overlay.appendChild(modal);
      document.body.appendChild(overlay);

      const usernameInput = modal.querySelector('input[name="username"]');
      const passwordInput = modal.querySelector('input[name="password"]');
      const errorDiv = modal.querySelector(".error");
      const cancelBtn = modal.querySelector("button.cancel");
      const submitBtn = modal.querySelector("button.submit");

      usernameInput.focus();

      function close(result) {
        overlay.remove();
        resolve(result);
      }

      cancelBtn.onclick = () => {
        setLoginCooldown();
        close(null);
      };

      overlay.onclick = (e) => {
        if (e.target === overlay) {
          setLoginCooldown();
          close(null);
        }
      };

      async function doLogin() {
        const username = usernameInput.value.trim();
        const password = passwordInput.value;

        if (!username || !password) {
          errorDiv.textContent = "Please enter both username and password.";
          errorDiv.style.display = "block";
          return;
        }

        submitBtn.disabled = true;
        submitBtn.textContent = "Logging in...";

        try {
          const res = await fetch("https://inkbunny.net/api_login.php", {
            method: "POST",
            headers: { "Content-Type": "application/x-www-form-urlencoded" },
            body: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`,
          });
          const data = await res.json();

          if (data.error_code) {
            errorDiv.textContent = "Login failed: " + data.error_message;
            errorDiv.style.display = "block";
            submitBtn.disabled = false;
            submitBtn.textContent = "Login";
            return;
          }

          cachedApiSid = data.sid;
          GM_setValue(SID_STORAGE_KEY, data.sid);
          close(data.sid);
        } catch (e) {
          errorDiv.textContent = "Network error. Please try again.";
          errorDiv.style.display = "block";
          submitBtn.disabled = false;
          submitBtn.textContent = "Login";
        }
      }

      submitBtn.onclick = doLogin;

      passwordInput.onkeydown = (e) => {
        if (e.key === "Enter") doLogin();
      };
    });
  }

  async function getApiSession() {
    if (cachedApiSid) return cachedApiSid;

    if (isLoginOnCooldown()) return null;

    if (loginInProgress) return loginInProgress;

    loginInProgress = showLoginModal();
    const result = await loginInProgress;
    loginInProgress = null;
    return result;
  }

  async function checkFavoritesViaApi(submissionIds) {
    if (submissionIds.length === 0) return;

    const [sid, userId] = await Promise.all([
      getApiSession(),
      getCurrentUserId(),
    ]);

    if (!sid || !userId) return;

    const batchSize = 100;
    for (let i = 0; i < submissionIds.length; i += batchSize) {
      const batch = submissionIds.slice(i, i + batchSize);

      const params = new URLSearchParams({
        sid,
        favs_user_id: userId,
        submission_ids: batch.join(","),
      });

      try {
        const res = await fetch(
          `https://inkbunny.net/api_search.php?${params}`
        );
        const data = await res.json();

        if (data.error_code) {
          console.error("API search failed:", data.error_message);
          if (data.error_code === 2) {
            clearApiSession();
            const newSid = await getApiSession();
            if (newSid) {
              params.set("sid", newSid);
              const retryRes = await fetch(
                `https://inkbunny.net/api_search.php?${params}`
              );
              const retryData = await retryRes.json();
              if (!retryData.error_code && retryData.submissions) {
                retryData.submissions.forEach((sub) => {
                  favoritedSubmissions.add(sub.submission_id);
                });
              }
            }
          }
          batch.forEach((id) => checkedSubmissions.add(id));
          continue;
        }

        if (data.submissions) {
          data.submissions.forEach((sub) => {
            favoritedSubmissions.add(sub.submission_id);
          });
        }

        batch.forEach((id) => checkedSubmissions.add(id));
      } catch (e) {
        console.error("Error checking favorites:", e);
      }
    }

    updateFavoriteButtons();
  }

  function updateFavoriteButtons() {
    document.querySelectorAll(`.${FAV_BUTTON_CLASS}`).forEach((btn) => {
      const subId = btn.dataset.submissionId;
      if (favoritedSubmissions.has(subId) && btn.dataset.favorited !== "true") {
        markAsFavorited(btn);
      }
    });
  }

  async function getTargetUserId(username) {
    const res = await fetch(`https://inkbunny.net/${username}`);
    const html = await res.text();
    return html.match(/user_id=(\d+)/)?.[1];
  }

  async function blockUser(username) {
    const token = getToken();
    const [me, them] = await Promise.all([
      getCurrentUserId(),
      getTargetUserId(username),
    ]);

    if (!token || !me || !them) return alert("Session error.");

    const body = new URLSearchParams({
      token,
      username,
      owner_user_id: me,
      return_to_user_id: them,
    });

    const res = await fetch(
      "https://inkbunny.net/block_artist_content_process.php",
      {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: body.toString(),
      }
    );

    if (res.ok && !res.url.includes("error.php")) {
      document
        .querySelectorAll(`div[class*="widget_thumbnail"][class*="Submission"]`)
        .forEach((sub) => {
          if (sub.querySelector(`a[href="/${username}"]`)) sub.remove();
        });
    }
  }

  async function favoriteSubmission(submissionId, btn) {
    const token = getToken();
    if (!token) return alert("Session error.");

    const body = new URLSearchParams({
      token,
      stars: "1",
      add: "true",
      remove: "",
      submission_id: submissionId,
    });

    const res = await fetch("https://inkbunny.net/submissionfav_process.php", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        "X-Requested-With": "XMLHttpRequest",
      },
      body: body.toString(),
    });

    if (res.ok) {
      markAsFavorited(btn);
      favoritedSubmissions.add(submissionId);
    } else {
      btn.style.opacity = "0.6";
    }
  }

  async function unfavoriteSubmission(submissionId, btn) {
    const token = getToken();
    if (!token) return alert("Session error.");

    const body = new URLSearchParams({
      token,
      stars: "1",
      add: "",
      remove: "true",
      submission_id: submissionId,
    });

    const res = await fetch("https://inkbunny.net/submissionfav_process.php", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        "X-Requested-With": "XMLHttpRequest",
      },
      body: body.toString(),
    });

    if (res.ok) {
      markAsNotFavorited(btn);
      favoritedSubmissions.delete(submissionId);
    } else {
      btn.style.opacity = "1";
    }
  }

  function markAsFavorited(btn) {
    btn.textContent = "⭐";
    btn.title = "Remove from favorites";
    btn.dataset.favorited = "true";
  }

  function markAsNotFavorited(btn) {
    btn.textContent = "☆";
    btn.title = "Add to favorites";
    btn.dataset.favorited = "false";
  }

  function getSubmissionId(submission) {
    const link = submission.querySelector('a[href*="/s/"]');
    if (link) {
      const match = link.getAttribute("href").match(/\/s\/(\d+)/);
      return match?.[1];
    }
    return null;
  }

  function createButton(emoji, title, className) {
    const btn = document.createElement("span");
    btn.className = className;
    btn.textContent = emoji;
    btn.title = title;
    return btn;
  }

  function getOrCreateContainer(submission) {
    let container = submission.querySelector(`.${CONTAINER_CLASS}`);
    if (!container) {
      container = document.createElement("div");
      container.className = CONTAINER_CLASS;
      submission.appendChild(container);
    }
    return container;
  }

  function addButtons() {
    const submissions = document.querySelectorAll(SUBMISSION_SELECTOR);
    const uncheckedIds = [];

    submissions.forEach((submission) => {
      submission.style.height = "auto";

      const submissionId = getSubmissionId(submission);
      const userLink = submission.querySelector("a.widget_userNameSmall");

      if (submission.querySelector(`.${CONTAINER_CLASS}`)) return;

      const container = getOrCreateContainer(submission);

      // Add block button (left side)
      if (userLink) {
        const username = userLink.getAttribute("href").replace("/", "");
        const blockBtn = createButton(
          "🚫",
          `Block ${username}`,
          BLOCK_BUTTON_CLASS
        );
        blockBtn.onclick = (e) => {
          e.preventDefault();
          e.stopPropagation();
          blockBtn.style.opacity = "0.2";
          blockUser(username);
        };
        container.appendChild(blockBtn);
      } else {
        const placeholder = document.createElement("span");
        placeholder.style.width = "24px";
        container.appendChild(placeholder);
      }

      // Add favorite button (right side)
      if (submissionId) {
        const isFavorited = favoritedSubmissions.has(submissionId);
        const favBtn = createButton(
          isFavorited ? "⭐" : "☆",
          isFavorited ? "Remove from favorites" : "Add to favorites",
          FAV_BUTTON_CLASS
        );
        favBtn.dataset.submissionId = submissionId;
        favBtn.dataset.favorited = isFavorited ? "true" : "false";

        favBtn.onclick = (e) => {
          e.preventDefault();
          e.stopPropagation();
          favBtn.style.opacity = "0.3";
          if (favBtn.dataset.favorited === "true") {
            unfavoriteSubmission(submissionId, favBtn);
          } else {
            favoriteSubmission(submissionId, favBtn);
          }
        };

        container.appendChild(favBtn);

        if (!checkedSubmissions.has(submissionId)) {
          uncheckedIds.push(submissionId);
        }
      }
    });

    if (uncheckedIds.length > 0 && !checkInProgress) {
      checkInProgress = true;
      checkFavoritesViaApi(uncheckedIds).finally(() => {
        checkInProgress = false;
      });
    }
  }

  // Initialize
  injectStyles();
  addButtons();

  const debouncedAddButtons = debounce(addButtons, 250);

  new MutationObserver(() => {
    debouncedAddButtons();
  }).observe(document.body, {
    childList: true,
    subtree: true,
  });
})();