Bluesky Enhanced Post Controls

Adds quick access buttons for linking, muting, and blocking directly on posts, always visible, not even hidden in the post dropdown menu. Modified based on https://greasyfork.org/en/scripts/517621-bluesky-quick-block-button, https://greasyfork.org/en/scripts/518436-bluesky-handle-link-button, https://greasyfork.org/en/scripts/507929-add-mute-user-button-to-bluesky-posts-menu. Written with the help of Claude 3.5.1, stackoverflow, repeated testing and iteration, and generally being a dork.

当前为 2024-11-25 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Bluesky Enhanced Post Controls
// @version      0.0.19
// @description  Adds quick access buttons for linking, muting, and blocking directly on posts, always visible, not even hidden in the post dropdown menu. Modified based on https://greasyfork.org/en/scripts/517621-bluesky-quick-block-button, https://greasyfork.org/en/scripts/518436-bluesky-handle-link-button, https://greasyfork.org/en/scripts/507929-add-mute-user-button-to-bluesky-posts-menu. Written with the help of Claude 3.5.1, stackoverflow, repeated testing and iteration, and generally being a dork.
// @match        https://bsky.app/*
// @namespace    https://lauren1701.bsky.social
// @grant        none
// @license      MIT
// ==/UserScript==

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

    let hostApi = null;
    let token = null;
    let profileCache = {};

    // Get auth token from localStorage
    function getTokenFromLocalStorage() {
        const storedData = localStorage.getItem('BSKY_STORAGE');
        if (storedData) {
            try {
                const localStorageData = JSON.parse(storedData);
                token = localStorageData.session.currentAccount.accessJwt;
                // Get the PDS URL instead of using a hardcoded host
                hostApi = localStorageData.session.currentAccount.pdsUrl.replace(/\/*$/, '');;
                return localStorageData.session.currentAccount;
            } catch (error) {
                console.error('Failed to parse session data:', error);
                return null;
            }
        }
        return null;
    }

    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;
            pointer-events: none;
            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.transition = 'opacity 0.3s, height 0.3s';
                post.style.opacity = '0';

                // After animation, collapse the height
                setTimeout(() => {
                    const height = post.offsetHeight;
                    post.style.height = height + 'px';
                    setTimeout(() => {
                        post.style.height = '0';
                        post.style.margin = '0';
                        post.style.padding = '0';
                        post.style.overflow = 'hidden';
                    }, 10);
                }, 300);
            });
        });
    }



    // Create button container and style it
    function createButtonContainer() {
        const container = document.createElement('div');
        container.className = 'enhanced-post-controls';
        container.style.cssText = `
            position: absolute;
            top: 2px;
            right: 2px;
            display: flex;
            gap: 2px;
            z-index: 1000;
        `;
        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;
        button.style.cssText = `
            background: none;
            border: none;
            cursor: pointer;
            font-size: 12px;
            padding: 2px;
            color: ${color};
            opacity: 0.3;
            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 = '0.3';
            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;
        }
        return null;
    }

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

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

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

        const container = createButtonContainer();

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

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

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

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

        // Adjust container styling
        container.style.cssText = `
            display: flex;
            gap: 2px;
            margin-left: 2px;
            position: relative;
            z-index: 1000;
        `;
        // 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("BEPC: 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 ${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) {
        if (!token) {
            alert('Not logged in');
            return;
        }

        try {
            // Get the user's DID first
            const userProfile = await getProfile(userId);

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

            if (response.ok) {
                showToast(`Muted ${userId}`);
                hideUserPosts(userId);
            } else {
                alert('Failed to mute user');
            }
        } catch (error) {
            console.error('Error muting user:', error);
        }
    }
    // Handle blocking
    async function handleBlock(username) {
        if (!token) {
            alert('You are not logged in.');
            return;
        }

        try {
            const userProfile = await getProfile(username);
            const bskyStorage = JSON.parse(localStorage.getItem('BSKY_STORAGE'));
            const url = `${bskyStorage.session.currentAccount.pdsUrl}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 ${bskyStorage.session.currentAccount.accessJwt}`,
                },
                body,
                method: 'POST',
            });

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

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

    // Initialize
    function init() {
        console.log("BEPC: Initializing");
        getTokenFromLocalStorage();

        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("BEPC: Found", posts.length, "new posts");
                        posts.forEach(post => {
                          try {
                            addControlsToPost(post)
                          } catch (e) {
                            showToast(`BEPC: error adding controls to post, see console: ${e}`);
                            console.error(e);
                            throw e;
                          }
                        });
                    }
                });
            });
        });

        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("BEPC: Found", initialPosts.length, "initial posts");
        initialPosts.forEach(post => addControlsToPost(post));
    }

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