DGG Auto Image Embed (Release 09/07/25)

Shows a preview of images linked in destiny.gg chat without opening new tabs. NSFW blur toggle + preserves autoscroll + optional 4chan block.

当前为 2025-09-07 提交的版本,查看 最新版本

// ==UserScript==
// @name        DGG Auto Image Embed (Release 09/07/25)
// @namespace   boofus
// @description Shows a preview of images linked in destiny.gg chat without opening new tabs. NSFW blur toggle + preserves autoscroll + optional 4chan block.
// @match       https://www.destiny.gg/embed/chat*
// @icon        https://cdn.destiny.gg/2.49.0/emotes/6296cf7e8ccd0.png
// @version     10.2.1
// @author      boofus (credits: legolas, vyneer)
// @license     MIT
// @grant       GM_xmlhttpRequest
// @run-at      document-end
// @connect     discordapp.net
// @connect     discordapp.com
// @connect     pbs.twimg.com
// @connect     polecat.me
// @connect     imgur.com
// @connect     i.imgur.com
// @connect     gyazo.com
// @connect     redd.it
// @connect     i.redd.it
// @connect     catbox.moe
// @connect     i.4cdn.org
// ==/UserScript==

/**
 * Match direct-image links from common hosts.
 * NOTE: No 'g' flag — .test() should be stateless per call.
 */
const imageRegex = /https?:\/\/(?:i\.redd\.it|redd\.it|pbs\.twimg\.com|(?:media|cdn)\.discordapp\.(?:net|com)|i\.imgur\.com|imgur\.com|gyazo\.com|polecat\.me|catbox\.moe|i\.4cdn\.org)\/[^\s"'<>]+?\.(?:png|jpe?g|gif|gifv|webp)(?:\?[^\s"'<>]*)?$/i;

let overlay;

// ---------------- Settings (stolen spirit from vyneer/legolas) ----------------

class ConfigItem {
  constructor(keyName, defaultValue) {
    this.keyName = keyName;
    this.defaultValue = defaultValue;
  }
}

const configItems = {
  BlurNSFW : new ConfigItem("BlurNSFW", true),
  HideLink : new ConfigItem("HideLink", true),
  Block4cdn: new ConfigItem("Block4cdn", true), // default block i.4cdn.org embeds
};

class Config {
  #configItems;
  #configKeyPrefix;
  constructor(configKeys, keyPrefix) {
    this.#configItems = configKeys;
    this.#configKeyPrefix = keyPrefix;

    for (const key in this.#configItems) {
      const configKey = this.#configItems[key];
      const keyName = configKey.keyName;
      const privateKeyName = `#${keyName}`;
      Object.defineProperty(this, key, {
        set: function (value) {
          this[privateKeyName] = value;
          this.#save(keyName, value);
        },
        get: function () {
          if (this[privateKeyName] === undefined) {
            this[privateKeyName] = this.#load(keyName) ?? configKey.defaultValue;
          }
          return this[privateKeyName];
        },
      });
    }
  }
  #getFullKeyName(configKey) {
    return `${this.#configKeyPrefix}${configKey}`;
  }
  #save(configKey, value) {
    const fullKeyName = this.#getFullKeyName(configKey);
    unsafeWindow.localStorage.setItem(fullKeyName, JSON.stringify(value));
  }
  #load(configKey) {
    const fullKeyName = this.#getFullKeyName(configKey);
    const item = unsafeWindow.localStorage.getItem(fullKeyName);
    if (item != null) return JSON.parse(item);
  }
}

const config = new Config(configItems, "img-util.");

// sticky-bottom state shared with image handler
let shouldStickToBottom = true;

function getChatEl() {
  return unsafeWindow.document.getElementsByClassName("chat-lines")[0];
}
function isAtBottom(el, threshold = 2) {
  return (el.scrollHeight - el.scrollTop - el.clientHeight) <= threshold;
}
function scrollToBottom(el) {
  requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; });
}

function addSettings() {
  const settingsArea = document.querySelector("#chat-settings-form");
  if (!settingsArea) {
    console.warn("[DGG Img Preview] settings form not found");
    return;
  }

  // Title
  const settingsTitle = document.createElement("h4");
  settingsTitle.textContent = "D.GG Img Preview Settings";

  // Helper to add a checkbox row
  const addCheckbox = (labelText, keyName) => {
    const wrap = document.createElement("div");
    wrap.className = "form-group checkbox";
    const label = document.createElement("label");
    label.textContent = labelText;
    const input = document.createElement("input");
    input.type = "checkbox";
    input.name = keyName;
    input.checked = !!config[keyName];
    input.addEventListener("change", () => {
      config[keyName] = input.checked;
    });
    label.prepend(input);
    wrap.appendChild(label);
    return wrap;
  };

  const blurNsfw = addCheckbox("Blur nsfw/nsfl images", "BlurNSFW");
  const hideLink = addCheckbox("Hide link after preview", "HideLink");
  const block4   = addCheckbox("Disable 4chan (i.4cdn.org) auto-embeds", "Block4cdn");

  settingsArea.appendChild(settingsTitle);
  settingsArea.appendChild(blurNsfw);
  settingsArea.appendChild(hideLink);
  settingsArea.appendChild(block4);

  console.log("[DGG Img Preview] Settings Added");
}

// ---------------- utilities ----------------

function waitForElm(selector) { // classic SO helper
  return new Promise(resolve => {
    const found = unsafeWindow.document.querySelector(selector);
    if (found) return resolve(found);

    const observer = new MutationObserver(() => {
      const el = unsafeWindow.document.querySelector(selector);
      if (el) {
        observer.disconnect();
        resolve(el);
      }
    });
    observer.observe(unsafeWindow.document.body, { childList: true, subtree: true });
  });
}

// ---------------- core logic ----------------

function handleLink(el) {
  // Optional block
  if (config.Block4cdn && /https?:\/\/i\.4cdn\.org\//i.test(el.href)) return;

  // Stateless test
  if (!imageRegex.test(el.href)) return;

  GM_xmlhttpRequest({
    method: "GET",
    url: el.href,
    responseType: "blob",
    onload: function(res) {
      const reader = new FileReader();
      reader.readAsDataURL(res.response);
      reader.onloadend = function() {
        const base64data = reader.result;

        const img = unsafeWindow.document.createElement("img");
        img.src = base64data;
        img.style.maxHeight = "300px";
        img.style.maxWidth  = "300px";
        img.style.marginLeft = "5px";
        img.style.marginBottom = "10px";
        img.style.marginTop = "10px";
        img.style.display = "block";
        img.style.cursor = "pointer";

        // Blur for nsfw/nsfl links (class present on anchor)
        let blurred = false;
        if (config.BlurNSFW && (el.className.includes("nsfw") || el.className.includes("nsfl"))) {
          img.style.filter = "blur(15px)";
          blurred = true;
        }

        img.onclick = () => {
          if (blurred) {
            img.style.filter = "blur(0px)";
            blurred = false;
          } else {
            overlay.style.display = "flex";
            const full = unsafeWindow.document.createElement("img");
            full.src = base64data;
            full.style.maxHeight = "70%";
            full.style.maxWidth  = "70%";
            full.style.display = "block";
            full.style.position = "relative";
            overlay.appendChild(full);

            const openOriginal = document.createElement("a");
            openOriginal.href = el.href;
            openOriginal.textContent = "Open Original";
            openOriginal.target = "_blank";
            openOriginal.style.marginTop = "5px";
            openOriginal.style.color = "#999";
            overlay.appendChild(openOriginal);
          }
        };

        // Append once, then fix scroll after decode/load
        const chatEl = getChatEl();
        const onReady = () => {
          if (shouldStickToBottom) {
            requestAnimationFrame(() => { chatEl.scrollTop = chatEl.scrollHeight; });
          }
        };

        if ("decode" in img && typeof img.decode === "function") {
          img.decode().then(onReady).catch(onReady);
        } else {
          img.addEventListener("load", onReady, { once: true });
          img.addEventListener("error", onReady, { once: true });
        }

        el.parentNode.appendChild(img);
        if (config.HideLink) el.remove();
      };
    }
  });
}

const chatObserver = new MutationObserver((mutations) => {
  for (const m of mutations) {
    for (const n of m.addedNodes) {
      if (n.nodeType !== 1) continue; // ELEMENT_NODE
      if (!n.querySelector) continue;
      if (!n.querySelector(".externallink")) continue;

      // No trailing space in selector
      const links = n.querySelectorAll(".externallink");
      links.forEach(handleLink);
    }
  }
});

console.log("[DGG Img Preview] Connecting");
waitForElm(".chat-lines").then((chatEl) => {
  // Overlay
  overlay = document.createElement("div");
  overlay.style.position = "fixed";
  overlay.style.justifyContent = "center";
  overlay.style.alignItems = "center";
  overlay.style.inset = "0px";
  overlay.style.margin = "0px";
  overlay.style.background = "rgba(0,0,0,0.85)";
  overlay.style.zIndex = "999";
  overlay.style.height = "100%";
  overlay.style.width = "100%";
  overlay.style.display = "none";
  overlay.style.flexDirection = "column";
  overlay.onclick = () => { overlay.style.display = "none"; overlay.innerHTML = ""; };
  document.body.appendChild(overlay);

  // Sticky-bottom engine
  shouldStickToBottom = isAtBottom(chatEl);
  chatEl.addEventListener("scroll", () => {
    shouldStickToBottom = isAtBottom(chatEl);
  }, { passive: true });

  const ro = new ResizeObserver(() => {
    if (shouldStickToBottom) scrollToBottom(chatEl);
  });
  ro.observe(chatEl);

  // Start observing chat for new messages
  chatObserver.observe(chatEl, { attributes: false, childList: true, characterData: false, subtree: true });

  console.log("[DGG Img Preview] Connected");
  console.log("[DGG Img Preview] Adding Settings");
  addSettings();
});