DGG Auto Image Embed

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

// ==UserScript==
// @name        DGG Auto Image Embed
// @namespace   boofus
// @description Shows a preview of images linked in destiny.gg chat without opening new tabs. NSFW blur toggle + preserves autoscroll.
// @match       https://www.destiny.gg/embed/chat*
// @icon        https://cdn.destiny.gg/2.49.0/emotes/6296cf7e8ccd0.png
// @version     10.1.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     gyazo.com
// @connect     redd.it
// @connect     i.4cdn.org
// ==/UserScript==


const imageRegex = /http.+(redd.it|pbs.twimg.com|(media|cdn)\.discordapp\.(net|com)|imgur\.com|gyazo\.com|polecat\.me|catbox\.moe|i\.4cdn\.org).+\.(png|jpe?g|gifv?)/gm;
let overlay;

// START STOLEN FROM VYNEER
// START STOLEN FROM LEGOLAS (also from vyneer)

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) // NEW: default to blocking i.4cdn.org
};

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

        // Initialize with defaults if not present
        for (const [k, item] of Object.entries(configKeys)) {
            if (this[k] === undefined) this[k] = item.defaultValue;
        }
    }

    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 must be global so image handler can read it
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; });
}
let addSettings = () => {

    let settingsArea = document.querySelector("#chat-settings-form");

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

    // Blur Nsfw
    let blurNsfw = document.createElement("div");
    blurNsfw.className = "form-group checkbox";

    let blurNsfwLabel = document.createElement("label");
    blurNsfwLabel.innerHTML = "Blur nsfw/nsfl images";
    blurNsfw.appendChild(blurNsfwLabel);

    let blurNsfwCheck = document.createElement("input");
    blurNsfwCheck.name = "blurNsfw";
    blurNsfwCheck.type = "checkbox";
    blurNsfwCheck.checked = config.BlurNSFW;
    blurNsfwCheck.addEventListener("change", () => {
        config.BlurNSFW = blurNsfwCheck.checked;
    });
    blurNsfwLabel.prepend(blurNsfwCheck);

    // Hide Link
    let hideLink = document.createElement("div");
    hideLink.className = "form-group checkbox";

    let hideLinkLabel = document.createElement("label");
    hideLinkLabel.innerHTML = "Hide Link from previewed messages";
    hideLink.appendChild(hideLinkLabel);

    let hideLinkCheck = document.createElement("input");
    hideLinkCheck.name = "HideLink";
    hideLinkCheck.type = "checkbox";
    hideLinkCheck.checked = config.HideLink;
    hideLinkCheck.addEventListener("change", () => {
        config.HideLink = hideLinkCheck.checked;
    });
    hideLinkLabel.prepend(hideLinkCheck);

    // NEW: Toggle i.4cdn.org embedding
    let block4cdn = document.createElement("div");
    block4cdn.className = "form-group checkbox";

    let block4cdnLabel = document.createElement("label");
    block4cdnLabel.innerHTML = "Disable 4Chan (i.4cdn.org) auto embeds";
    block4cdn.appendChild(block4cdnLabel);

    let block4cdnCheck = document.createElement("input");
    block4cdnCheck.name = "Block4cdn";
    block4cdnCheck.type = "checkbox";
    block4cdnCheck.checked = config.Block4cdn; // checked by default (disabled)
    block4cdnCheck.addEventListener("change", () => {
        config.Block4cdn = block4cdnCheck.checked;
    });
    block4cdnLabel.prepend(block4cdnCheck);

    // Add to settings
    settingsArea.appendChild(settingsTitle);
    settingsArea.appendChild(blurNsfw);
    settingsArea.appendChild(hideLink);
    settingsArea.appendChild(block4cdn);

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

// END STOLEN FROM VYNEER

function waitForElm(selector) { // stolen from stack overflow
    return new Promise(resolve => {
        if (unsafeWindow.document.querySelector(selector)) {
            return resolve(unsafeWindow.document.querySelector(selector));
        }

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

let chatObserver = new MutationObserver(function(mutations) {
    if (mutations[0].addedNodes[0] && mutations[0].addedNodes[0].tagName == "DIV" && mutations[0].addedNodes[0].querySelector(".externallink")) {
        let links = mutations[0].addedNodes[0].querySelectorAll(".externallink ");
        links.forEach((el) => {
            // NEW: if blocking is ON and link is i.4cdn.org, skip embedding
            if (config.Block4cdn && /https?:\/\/i\.4cdn\.org\//i.test(el.href)) return;

            if (imageRegex.test(el.href)) {

                GM_xmlhttpRequest({ // get image data since csp blocks us from just using the url directly
                    method: "GET",
                    url: el.href,
                    responseType: 'blob',
                    onload: function(res) {

                        var reader = new FileReader();
                        reader.readAsDataURL(res.response);

                        reader.onloadend = function() {

                            var base64data = reader.result;
                            let PreviewImage = unsafeWindow.document.createElement("img");

                            PreviewImage.src = base64data;

                            PreviewImage.style.maxHeight = "300px";
                            PreviewImage.style.maxWidth = "300px";
                            PreviewImage.style.marginLeft = "5px";
                            PreviewImage.style.marginBottom = "10px";
                            PreviewImage.style.marginTop = "10px";
                            PreviewImage.style.display = "block";
                            PreviewImage.style.cursor = "pointer";

                            let blurred = false;
                            if (el.className.includes("nsfw") && config.BlurNSFW || el.className.includes("nsfl") && config.BlurNSFW) {
                                PreviewImage.style.filter = "blur(15px)";
                                blurred = true;
                            }

                            // PEPE WINS

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

                                    overlay.appendChild(PreviewImg);

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

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

                            // append image, then only scroll if user was at bottom
                            const chatEl = unsafeWindow.document.getElementsByClassName("chat-lines")[0];
                            const _onReady = () => {
                                if (shouldStickToBottom) {
                                    requestAnimationFrame(() => { chatEl.scrollTop = chatEl.scrollHeight; });
                                }
                            };

                            if ('decode' in PreviewImage && typeof PreviewImage.decode === 'function') {
                                PreviewImage.decode().then(_onReady).catch(_onReady);
                            } else {
                                PreviewImage.addEventListener('load', _onReady, { once: true });
                                PreviewImage.addEventListener('error', _onReady, { once: true });
                            }

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

                        };

                    }
                });
            }
        });
    }
});
console.log("[D.gg Img Preview] Connecting");
waitForElm('.chat-lines').then((elm) => {
    // overlay
    overlay = document.createElement("div");
    overlay.style.position = "absolute";
    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 ---
    const chatEl = elm; // already resolved by waitForElm

    // initialize & track user intent
    shouldStickToBottom = isAtBottom(chatEl);
    chatEl.addEventListener('scroll', () => {
        shouldStickToBottom = isAtBottom(chatEl);
    }, { passive: true });

    // re-pin on growth (images decoding / layout changes)
    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("[D.gg Img Preview] Connected");
    console.log("[D.gg Img Preview] Adding Settings");
    addSettings();
});