Quickblock And Such (prev. BEPC)

Adds quick buttons for weblink, mute, and block, directly on posts, always visible, not even hidden in the post dropdown menu. Also adds a link to clearsky from the three-dot menu on profiles. Tested and works on web as of dec 14, msg me on bsky (lauren1701.bsky.social) if it breaks

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name         Quickblock And Such (prev. BEPC)
// @version      0.0.24
// @description  Adds quick buttons for weblink, mute, and block, directly on posts, always visible, not even hidden in the post dropdown menu. Also adds a link to clearsky from the three-dot menu on profiles. Tested and works on web as of dec 14, msg me on bsky (lauren1701.bsky.social) if it breaks
// @match        https://bsky.app/*
// @namespace    https://lauren1701.bsky.social
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';
    console.log("Quickblock: top of script");

    // css for clearsky link hover
    const style = document.createElement('style');
    style.textContent = `
      .menu-item-hover:hover {
        background-color: rgba(128,128,128,0.1) !important;
      }
    `;
    document.head.appendChild(style);

    let profileCache = {};

    // Get auth token from localStorage
    function account() {
        const storedData = localStorage.getItem('BSKY_STORAGE');
        try {
            const localStorageData = JSON.parse(storedData);
            return {account: localStorageData.session.currentAccount, token: localStorageData.session.currentAccount.accessJwt, hostApi: localStorageData.session.currentAccount.pdsUrl.replace(/\/*$/, '')};
        } catch (error) {
            console.error('Failed to parse session data:', error);
            throw error;
        }
    }

    function showToast(message, duration = 3000) {
        const toast = document.createElement('div');
        toast.textContent = message;
        toast.style.cssText = `
            position: fixed;
            bottom: 20px;
            left: 20px;
            background: rgba(0, 0, 0, 0.8);
            color: white;
            padding: 12px 24px;
            border-radius: 8px;
            z-index: 10000;
            transition: opacity ${duration/1000}s;
        `;

        document.body.appendChild(toast);

        setTimeout(() => {
            toast.style.opacity = '0';
            setTimeout(() => toast.remove(), duration);
        }, duration);
    }

    function hideUserPosts(username) {
        // Don't hide posts if we're on a profile page
        if (window.location.pathname.match(/\/profile\/[^\/]+\/?([?#].*)?$/)) {
            return;
        }

        const selectors = [
            `[data-testid="feedItem-by-${username}"]`,
            `[data-testid="postThreadItem-by-${username}"]`
        ];

        selectors.forEach(selector => {
            const posts = document.querySelectorAll(selector);
            posts.forEach(post => {
                // Animate the post out

                post.style.display = 'inherit';
                const height = post.offsetHeight;
                post.style.height = height + 'px';
                post.style.transition = 'opacity 0.3s, height 0.3s';

                // After animation, collapse the height
                setTimeout(() => {
                    post.style.height = '0';
                    post.style.margin = '0';
                    post.style.padding = '0';
                    post.style.opacity = '0';
                    post.style.overflow = 'hidden';
                    setTimeout(() => {
                        if (post.style.display === "initial") return;
                        post.style = 'display: none;';
                    }, 400);
                }, 5);
            });
        });
    }

    function unhideUserPosts(username) {
        // Don't hide posts if we're on a profile page
        if (window.location.pathname.match(/\/profile\/[^\/]+\/?([?#].*)?$/)) {
            return;
        }

        const selectors = [
            `[data-testid="feedItem-by-${username}"]`,
            `[data-testid="postThreadItem-by-${username}"]`
        ];

        selectors.forEach(selector => {
            const posts = document.querySelectorAll(selector);
            posts.forEach(post => {
                post.style = 'display: initial;';
                // Animate the post out

            });
        });
    }



    // Create button container and style it
    function createButtonContainer() {
        const container = document.createElement('div');
        container.className = 'enhanced-post-controls';
        return container;
    }

    // Create a button with common styling
    function createButton(emoji, label, color = 'inherit') {
        const button = document.createElement('button');
        button.innerHTML = emoji;
        button.title = label;
        const opacity = '0.4';
        button.style.cssText = `
            background: none;
            border: none;
            cursor: pointer;
            font-size: 14px;
            padding: 2px;
            margin-left: 4px;
            color: ${color};
            opacity: ${opacity};
            transition: opacity 0.2s, transform 0.2s;
        `;

        button.addEventListener('mouseenter', () => {
            button.style.opacity = '1';
            button.style.transform = 'scale(1.1)';
        });

        button.addEventListener('mouseleave', () => {
            button.style.opacity = opacity;
            button.style.transform = 'scale(1)';
        });

        return button;
    }

    // Extract handle from post element
    function extractHandle(postElement) {
        // Look for the handle link element
        const handleElement = postElement.querySelector('a[href^="/profile/"]');
        if (handleElement) {
            const handle = handleElement.getAttribute('href').split('/profile/')[1];
            return handle.replace(/\/post.*/, "");
        }
        return null;
    }
    const mute_svg = `<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-volume-x"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>`;
    const external_link_svg = `<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`;
    const block_svg = `<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-slash"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>`;

    function addClearskyLink(menu, user) {
        if (!menu || menu.querySelector('.clearsky-link')) return;

        const last = menu.children[menu.children.length-1];
        const cloned = last.cloneNode(true);

        const link = document.createElement('a');
        link.href = `https://clearsky.app/${user}/lists`; // Set your target URL
        link.target = '_blank';
        link.rel = 'noopener noreferrer';

        link.className = cloned.className + ' menu-item-hover clearsky-link'; // Add our new class
        link.setAttribute('style', cloned.getAttribute('style'));
        link.setAttribute('role', 'menuitem');
        link.setAttribute('tabindex', '-1');
        link.setAttribute('aria-label', 'View on Clearsky');
        link.setAttribute('data-testid', 'profileHeaderDropdownDataBtn');
        //console.log(cloned.children);

        link.appendChild(cloned.children[0]);
        link.appendChild(cloned.children[0]);
        link.children[0].innerText = "View on Clearsky";

        link.children[1].innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="rgba(128,128,0)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-moon"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`;

        menu.appendChild(link);
    }

    // Add controls to a post
    function addControlsToPost(post) {
        if (!post || post.querySelector('.enhanced-post-controls')) return;

        const handle = extractHandle(post);
        if (!handle) {
            console.log("Quickblock: No handle found for post", post);
            return;
        }

        console.log("Quickblock: Adding controls for handle:", handle);

        const container = createButtonContainer();

        // Create buttons as before
        const linkButton = createButton(external_link_svg, "Open profile's website");
        linkButton.addEventListener('click', (e) => {
            e.stopPropagation();
            window.open(`https://${handle}`, '_blank');
        });

        const spacer = document.createElement("div");
        spacer.style = "flex-grow: 1;";

        const muteButton = createButton(mute_svg, 'Mute User', 'rgb(200, 128, 68)');
        muteButton.addEventListener('click', (e) => {
            e.stopPropagation();
            handleMute(handle);
        });

        const blockButton = createButton(block_svg, 'Block User', 'rgb(255, 68, 68)');
        blockButton.addEventListener('click', (e) => {
            e.stopPropagation();
            handleBlock(handle);
        });

        container.appendChild(linkButton);
        container.appendChild(spacer);
        container.appendChild(muteButton);
        container.appendChild(blockButton);

        // Adjust container styling
        container.style.cssText = `
            display: flex;
            gap: 2px;
            margin-left: 2px;
            position: relative;
            top: 1px;
            flex: 1;
        `;
        // Determine post type and insertion point
        let insertionPoint;

        // Check if this is a thread root by looking for "who can reply"
        const isThreadRoot = !!post.querySelector('button[aria-label="Who can reply"]');

        if (isThreadRoot) {
            // Find a parent div that contains exactly two role="link" divs
            const allDivs = post.querySelectorAll('div');
            for (const div of allDivs) {
                const linkDivs = div.querySelectorAll(':scope > div[role="link"]');
                if (linkDivs.length === 2) {
                    // Insert after this div's parent
                    insertionPoint = div.parentElement;
                    break;
                }
            }
        } else {
            // Regular feed post or reply - use the date element parent
            const dateLink = post.querySelector('a[href^="/profile/"][href*="/post/"]');
            insertionPoint = dateLink?.parentElement;
        }

        if (insertionPoint) {
            // Insert after the target element
            if (isThreadRoot) {
                insertionPoint.insertBefore(container, insertionPoint.children[2]);
            } else {
                insertionPoint.appendChild(container);
            }
        } else {
            console.error("Quickblock: No suitable insertion point found in post:", post);
        }
    }
    async function getProfile(actor) {
        if (profileCache[actor]) {
            return profileCache[actor];
        }

        const bskyStorage = JSON.parse(localStorage.getItem('BSKY_STORAGE'));
        const url = `${bskyStorage.session?.currentAccount?.pdsUrl}xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`;

        const response = await fetch(url, {
            headers: {
                'Content-Type': 'application/json',
                'authorization': `Bearer ${account().token}`,
            },
            method: 'GET'
        });

        if (!response.ok) throw new Error(`Failed to fetch profile: ${response.statusText}`);
        const profile = await response.json();
        profileCache[actor] = profile;
        return profile;
    }

    // Handle muting
    async function handleMute(userId) {
        try {
            hideUserPosts(userId);
            // Get the user's DID first
            const userProfile = await getProfile(userId);

            // Then make the actual mute request
            const response = await fetch(`${account().hostApi}/xrpc/app.bsky.graph.muteActor`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${account().token}`
                },
                body: JSON.stringify({ actor: userProfile.did })
            });

            if (response.ok) {
                showToast(`Muted ${userId}`);
            } else {
                alert('Failed to mute user');
                unhideUserPosts(userId);
            }
        } catch (error) {
            console.error('Error muting user:', error);
        }
    }
    // Handle blocking
    async function handleBlock(userId) {
        try {
            hideUserPosts(userId);
            const userProfile = await getProfile(userId);
            const bskyStorage = JSON.parse(localStorage.getItem('BSKY_STORAGE'));
            const url = `${account().hostApi}/xrpc/com.atproto.repo.createRecord`;
            const body = JSON.stringify({
                collection: 'app.bsky.graph.block',
                repo: bskyStorage.session.currentAccount.did,
                record: {
                    subject: userProfile.did,
                    createdAt: new Date().toISOString(),
                    $type: 'app.bsky.graph.block',
                }
            });

            const response = await fetch(url, {
                headers: {
                    'Content-Type': 'application/json',
                    'authorization': `Bearer ${account().token}`,
                },
                body,
                method: 'POST',
            });

            if (!response.ok) throw new Error(`Failed to block user: ${response.statusText}`);

            showToast(`Blocked ${userId}`);
        } catch (error) {
            unhideUserPosts(userId);
            console.error('Block user error:', error);
            alert(`Failed to block user "${userId}". Please check the console for more details.`);
        }
    }

    // Initialize
    function init() {
        console.log("Quickblock: Initializing");
        const observer = new MutationObserver((mutations) => {
            mutations.forEach(mutation => {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        // Look for posts using the role="link" attribute and data-testid pattern
                        const posts = node.querySelectorAll('[data-testid^="feedItem-by-"], [data-testid^="postThreadItem-by-"]');
                        console.log("Quickblock: Found", posts.length, "new posts");
                        posts.forEach(post => {
                          try {
                            addControlsToPost(post)
                          } catch (e) {
                            showToast(`Quickblock: error adding controls to post, see console: ${e}`);
                            console.error(e);
                            throw e;
                          }
                        });

                        const m = window.location.pathname.match(/\/profile\/([^\/]+)\/?([?#].*)?$/);
                        if (m) {
                            const menu = document.querySelector("[data-testid^='profileHeaderDropdownListAddRemoveBtn']")?.parentElement;
                            if (menu) {
                              addClearskyLink(menu, m[1])
                            }
                        }
                    }
                });
            });
        });

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

        // Handle initial posts
        const initialPosts = document.querySelectorAll('[data-testid^="feedItem-by-"], [data-testid^="postThreadItem-by-"]');
        console.log("Quickblock: Found", initialPosts.length, "initial posts");
        initialPosts.forEach(post => addControlsToPost(post));
    }

    // Start the script
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();