Bluesky Image Download Button

Adds a download button to Bluesky images and videos. Built off coredumperror's script with a few improvements.

// ==UserScript==
// @name        Bluesky Image Download Button
// @namespace   KanashiiWolf
// @match       https://bsky.app/*
// @grant       GM_setValue
// @grant       GM_getValue
// @grant       GM_info
// @version     1.7.6
// @author      KanashiiWolf, the-nelsonator, coredumperror
// @description Adds a download button to Bluesky images and videos. Built off coredumperror's script with a few improvements.
// @license     MIT
// ==/UserScript==

(function() {
    'use strict';

    // Filename template settings
    const defaultTemplate = "@<%username>-bsky-<%post_id>-<%img_num>";
    let filenameTemplate = GM_getValue('filename', defaultTemplate);

    const postUrlRegex = /\/profile\/[^/]+\/post\/[A-Za-z0-9]+/;

    // Download button HTML (using template literals for readability)
    const downloadButtonHTML = `
        <div class="download-button" style="
            cursor: pointer;
            z-index: 999;
            display: table;
            font-size: 15px;
            color: white;
            position: absolute;
            left: 5px;
            top: 5px;
            background: #0000007f;
            height: 30px;
            width: 30px;
            border-radius: 15px;
            text-align: center;">
            <svg class="icon" style="
                width: 15px;
                height: 15px;
                vertical-align: top;
                display: inline-block;
                margin-top: 7px;
                fill: currentColor;
                overflow: hidden;"
                viewBox="0 0 1024 1024"
                version="1.1"
                xmlns="http://www.w3.org/2000/svg">
                <path d="M925.248 356.928l-258.176-258.176a64 64 0 0 0-45.248-18.752H144a64 64 0 0 0-64 64v736a64 64 0 0 0 64 64h736a64 64 0 0 0 64-64V402.176a64 64 0 0 0-18.752-45.248zM288 144h192V256H288V144z m448 736H288V736h448v144z m144 0H800V704a32 32 0 0 0-32-32H256a32 32 0 0 0-32 32v176H144v-736H224V288a32 32 0 0 0 32 32h256a32 32 0 0 0 32-32V144h77.824l258.176 258.176V880z"></path>
            </svg>
        </div>`;

    const config = {
        childList: true,
        subtree: true
    };
    let headerNode;
    let settingsButton = false;

    const waitForLoad = (mutationList, observer) => {
        for (const mutation of mutationList) {
            for (let node of mutation.addedNodes) {
                if (!(node instanceof HTMLElement)) continue;
                headerNode = node.querySelector('[aria-label="Account"]');
                if (headerNode && !settingsButton) {
                    addFilenameSettings(headerNode);
                    settingsButton = true;
                    observer.disconnect();
                }
            }
        }
    };

    const waitForContent = (mutationList, observer) => {
        for (const mutation of mutationList) {
            for (let node of mutation.addedNodes) {
                if (!(node instanceof HTMLElement)) continue;

                const img = node.querySelector('img[src^="https://cdn.bsky.app/img/feed_thumbnail"]');
                if (img) {
                    img.setAttribute('processed', '');
                    addDownloadButton(img);
                }

                const vid = node.querySelector('video[poster^="https://video.bsky.app/watch"]');
                if (vid) {
                    vid.setAttribute('processed', '');
                    addDownloadButton(vid, true);
                }
            }
        }
    };

    function addFilenameSettings(node) {
        const settingsInput = document.createElement('input');
        settingsInput.id = 'filename-input-space';
        settingsInput.style.cssText = `
            display: flex;
            align-items: center;
            justify-content: center;
            margin-top: 10px;
            text-align: center;
            display: none;`;

        settingsInput.addEventListener('keypress', (e) => {
            if (e.which === 13) {
                settingsInput.style.display = 'none';
                settingsButton.style.display = 'flex';
                filenameTemplate = settingsInput.value;
                GM_setValue('filename', filenameTemplate);
            }
        });

        const settingsButton = document.createElement('a');
        settingsButton.id = 'filename-input-button';
        settingsButton.textContent = `Download Button Filename Template v${GM_info.script.version}`;
        settingsButton.style.cssText = `
            display: flex;
            align-items: center;
            justify-content: center;
            margin-top: 10px;
            border: 2px solid;
            cursor: pointer;`;

        settingsButton.addEventListener('click', (e) => {
            e.preventDefault();
            settingsButton.style.display = 'none';
            settingsInput.style.display = 'flex';
            settingsInput.focus();
            settingsInput.value = filenameTemplate;
        });

        node.parentNode.insertBefore(settingsButton, node);
        node.parentNode.insertBefore(settingsInput, settingsButton);
    }

    const contentObserver = new MutationObserver(waitForContent);
    const settingsObserver = new MutationObserver(waitForLoad);
    settingsObserver.observe(document, config);
    contentObserver.observe(document, config);

    function downloadContent(url, data) {
        const urlArray = url.split('/');
        const did = data.isVideo ? urlArray[4] : urlArray[6];
        const cid = data.isVideo ?
            urlArray[5] :
            urlArray[7].split('@')[0];

        fetch(`https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`)
            .then(response => {
                if (!response.ok) {
                    throw new Error(`Couldn't retrieve blob! Response: ${response}`);
                }
                return response.blob();
            })
            .then(blob => sendFile(data, blob));
    }

    function getExtensionFromBlob(blob) {
        // Create a mapping of common MIME types to their extensions
        const mimeTypeToExtension = {
            'image/jpeg': 'jpg',
            'image/png': 'png',
            'image/gif': 'gif',
            'image/webp': 'webp',
            'image/svg+xml': 'svg',
            'audio/mpeg': 'mp3',
            'audio/ogg': 'ogg',
            'audio/wav': 'wav',
            'video/mp4': 'mp4',
            'video/webm': 'webm',
            'video/ogg': 'ogv',
            'application/pdf': 'pdf',
            'text/plain': 'txt',
            'text/html': 'html',
            'application/json': 'json',
            'application/zip': 'zip',
            // Add more MIME types and extensions as needed
        };

        // Get the MIME type from the blob
        const mimeType = blob.type;

        // Check if the MIME type is in the mapping
        if (mimeTypeToExtension[mimeType]) {
            return mimeTypeToExtension[mimeType];
        }

        // If the MIME type is not found, try to guess the extension from the file name
        if (blob.name) {
            const fileName = blob.name;
            const lastDotIndex = fileName.lastIndexOf('.');
            if (lastDotIndex !== -1) {
                return fileName.substring(lastDotIndex + 1).toLowerCase();
            }
        }

        // If all else fails, return an empty string
        return '';
    }

    async function sendFile(data, blob) {
        const filename = convertFilename(data) + `.${getExtensionFromBlob(blob)}`;
        if (window.showSaveFilePicker) {
            try {
                const handle = await showSaveFilePicker({
                    suggestedName: filename
                });
                const writable = await handle.createWritable();
                writable.write(blob);
                writable.close();
            } catch (e) {
                return; // Pointless AbortError, maybe catch FNF and such later.
            }
        } else {
            const saveImg = document.createElement("a");
            saveImg.href = URL.createObjectURL(blob);
            saveImg.download = filename;
            saveImg.click();
            setTimeout(() => URL.revokeObjectURL(saveImg.href), 60000);
        }
    }

    function createDownloadLink() {
        let downloadLink = document.getElementById('img-download-button');
        if (!downloadLink) {
            downloadLink = document.createElement('a');
            downloadLink.id = 'img-download-button';
            document.getElementById('root').appendChild(downloadLink);
        }
        return downloadLink;
    }

    function getImageNumber(image) {
        const ancestor = image.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement.parentElement;
        const postImages = ancestor.getElementsByTagName('img');
        for (let i = 0; i < postImages.length; i++) {
            if (postImages[i].src === image.src) {
                return i;
            }
        }
        return 0;
    }

    function addDownloadButton(element, isVideo = false) {
        if (element == null) return;
        let downloadBtn = document.createElement('div');
        let downloadBtnParent;
        const mediaUrl = isVideo ? element.poster : element.src;

        if (mediaUrl.includes('feed_thumbnail') || isVideo) {
            downloadBtnParent = element.parentElement.parentElement;
            downloadBtnParent.appendChild(downloadBtn);
            downloadBtn.outerHTML = downloadButtonHTML;
        } else if (mediaUrl.includes('feed_fullsize')) {
            return;
        }

        downloadBtn = downloadBtnParent.getElementsByClassName('download-button')[0];

        const postPath = getPostLink(element);
        const pathArray = postPath.split('/');
        const username = pathArray[2];
        const uname = username.split('.')[0];
        const postId = pathArray[4];
        const timestamp = new Date().getTime();
        const imageNumber = isVideo ? 0 : getImageNumber(element);

        const data = {
            uname: uname,
            username: username,
            postId: postId,
            timestamp: timestamp,
            imageNumber: imageNumber,
            isVideo: isVideo
        };

        // Prevent non-click events
        downloadBtn.addEventListener('touchstart', e => e.preventDefault());
        downloadBtn.addEventListener('mousedown', e => e.preventDefault());

        downloadBtn.addEventListener('click', e => {
            e.stopPropagation();
            downloadContent(mediaUrl, data);
            return false;
        });
    };

    function getPostLink(element) {
        let path = element.parentElement.innerHTML.match(postUrlRegex);
        while (path == null) {
            element = element.parentElement;
            path = element.innerHTML.match(postUrlRegex);
            if (element.innerHTML.includes("postThreadItem")) {
                return window.location.pathname;
            }
        }
        return path[0];
    }

    function convertFilename(data) {
        return filenameTemplate
            .replace("<%uname>", data.uname)
            .replace("<%username>", data.username)
            .replace("<%post_id>", data.postId)
            .replace("<%timestamp>", data.timestamp)
            .replace("<%img_num>", data.imageNumber);
    }
})();