Discord Image NSFW Mask

Add NSFW mask over images in Discord Web, reveal on hover.

当前为 2025-06-19 提交的版本,查看 最新版本

// ==UserScript==
// @name         Discord Image NSFW Mask
// @namespace    http://tampermonkey.net/
// @version      0.3
// @description  Add NSFW mask over images in Discord Web, reveal on hover.
// @match        https://discord.com/*
// @grant        none
// @author       chatgpt
// @license      WTFPL
// ==/UserScript==

(function() {
    'use strict';

    const style = document.createElement("style");
    style.textContent = `
        .nsfw-mask {
            position: relative; /* For absolute positioning of overlay */
            display: block;    /* Make it block to fill parent width */
            width: 100%;       /* Fill parent width */
            height: 100%;      /* Fill parent height (assumes parent has dimensions) */
            overflow: hidden;  /* Hide anything from img that might overflow if object-fit is cover */
        }
        .nsfw-mask img {
            filter: blur(8px);
            transition: filter 0.3s ease-in-out;
            display: block;    /* Standard for images within containers */
            width: 100%;       /* Make image fill the mask */
            height: 100%;      /* Make image fill the mask */
            object-fit: cover; /* Cover the area, might crop if aspect ratios differ */
        }
        .nsfw-overlay {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 0, 0.65); /* Slightly darker */
            color: white;
            display: flex;
            justify-content: center;
            align-items: center;
            font-weight: bold;
            font-size: 12px;
            text-align: center;
            pointer-events: none; /* Crucial */
            opacity: 1;
            transition: opacity 0.3s ease-in-out;
            border-radius: 3px; /* Optional: match Discord's image border-radius */
            box-sizing: border-box;
        }

        /* ---- KEY CHANGE HERE ---- */
        /* Target a stable Discord wrapper for hover, then affect children */
        /* Using [class*="clickableWrapper"]:hover is more resilient to class name changes */
        /* You can also use the specific class if it's stable enough for you: .clickableWrapper_af017a:hover */

        div[class*="clickableWrapper"]:hover .nsfw-mask img,
        div[class*="imageWrapper"]:hover .nsfw-mask img  /* Fallback for other structures if clickableWrapper is not present */
        {
            filter: none;
        }

        div[class*="clickableWrapper"]:hover .nsfw-mask .nsfw-overlay,
        div[class*="imageWrapper"]:hover .nsfw-mask .nsfw-overlay
        {
            opacity: 0;
        }

        /* Old hover rules (can be removed or kept as fallback if the above are too specific) */
        /*
        .nsfw-mask:hover img {
            filter: none;
        }
        .nsfw-mask:hover .nsfw-overlay {
            opacity: 0;
        }
        */
    `;
    document.head.appendChild(style);

    function processImage(img) {
        if (img.closest('.nsfw-mask')) return; // Already processed

        // The direct parent of the image is often a div like 'loadingOverlay...' or similar
        const imageParent = img.parentNode;
        if (!imageParent) return;

        const wrapper = document.createElement('div');
        wrapper.className = 'nsfw-mask';

        // If the original image had a specific display style we might want to respect,
        // but for this setup, nsfw-mask is block and 100% width/height.
        // const originalDisplay = window.getComputedStyle(img).display;
        // if (originalDisplay === 'block') {
        //     wrapper.style.display = 'block';
        // }

        const overlay = document.createElement('div');
        overlay.className = 'nsfw-overlay';
        overlay.textContent = 'NSFW - Hover';

        // DOM manipulation:
        // imageParent -> img  BECOMES  imageParent -> wrapper -> img, overlay
        imageParent.insertBefore(wrapper, img); // Insert wrapper before img in its original parent
        wrapper.appendChild(img);             // Move the image inside the wrapper
        wrapper.appendChild(overlay);         // Add the overlay inside the wrapper
    }

    const observer = new MutationObserver((mutationsList) => {
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        // Check if the added node is an image or contains images
                        const images = [];
                        if (node.tagName === 'IMG') {
                            images.push(node);
                        } else {
                            node.querySelectorAll('img').forEach(img => images.push(img));
                        }

                        images.forEach(img => {
                            if (!img.closest('.nsfw-mask') &&
                                img.src &&
                                (img.src.includes('/attachments/') || img.src.includes('cdn.discordapp.com/ephemeral-attachments/')) &&
                                !img.closest('a[href^="/channels/"][href*="/users/"]') &&
                                !img.classList.contains('emoji') &&
                                !img.closest('[class*="avatar"]') &&
                                !img.closest('[class*="reaction"]') &&
                                img.offsetWidth > 16 && img.offsetHeight > 16) { // Basic check for actual images not tiny icons

                                // Ensure the image is somewhat loaded or use a timeout
                                if (img.complete || (img.offsetWidth > 0 && img.offsetHeight > 0)) {
                                    processImage(img);
                                } else {
                                    img.onload = () => processImage(img);
                                    // Fallback timeout if onload doesn't fire (e.g. broken image, or already loaded before handler attached)
                                    setTimeout(() => {
                                        if (!img.closest('.nsfw-mask')) processImage(img);
                                    }, 300);
                                }
                            }
                        });
                    }
                });
            }
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });

    // Initial run for images already present on load
    // Use a timeout to allow Discord to finish its initial rendering
    setTimeout(() => {
        document.querySelectorAll('img').forEach(img => {
            if (!img.closest('.nsfw-mask') &&
                img.src &&
                (img.src.includes('/attachments/') || img.src.includes('cdn.discordapp.com/ephemeral-attachments/')) &&
                !img.closest('a[href^="/channels/"][href*="/users/"]') &&
                !img.classList.contains('emoji') &&
                !img.closest('[class*="avatar"]') &&
                !img.closest('[class*="reaction"]') &&
                img.offsetWidth > 16 && img.offsetHeight > 16) {
                processImage(img);
            }
        });
    }, 1000); // Increased timeout for initial scan

})();