Greasy Fork is available in English.

Bluesky Content Manager

Enhance your Bluesky feed with advanced content filtering. Hides posts based on a customizable blocklist with case-insensitive and plural matching.

// ==UserScript==
// @name         Bluesky Content Manager
// @namespace    https://greasyfork.org/en/users/567951-stuart-saddler
// @version      2.4
// @description  Enhance your Bluesky feed with advanced content filtering. Hides posts based on a customizable blocklist with case-insensitive and plural matching.
// @license      MIT
// @match        https://bsky.app/*
// @icon         https://i.ibb.co/YySpmDk/Bluesky-Content-Manager.png
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @connect      bsky.social
// @run-at       document-idle
// ==/UserScript==

(async function () {
    'use strict';

    function shouldProcessPage() {
        return window.location.pathname !== '/notifications';
    }

    function escapeRegExp(string) {
        return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    const CSS = `
    .content-filtered {
        display: none !important;
        height: 0 !important;
        overflow: hidden !important;
    }
    .bluesky-filter-dialog {
        position: fixed;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        background: white;
        padding: 20px;
        border-radius: 8px;
        z-index: 1000000;
        box-shadow: 0 4px 12px rgba(0,0,0,0.15);
        min-width: 300px;
        max-width: 350px;
        font-family: Arial, sans-serif;
        color: #333;
    }
    .bluesky-filter-dialog h2 {
        margin-top: 0;
        color: #0079d3;
        font-size: 1.5em;
        font-weight: bold;
    }
    .bluesky-filter-dialog p {
        font-size: 0.9em;
        margin-bottom: 10px;
        color: #555;
    }
    .bluesky-filter-dialog textarea {
        width: calc(100% - 16px);
        height: 150px;
        padding: 8px;
        margin: 10px 0;
        border: 1px solid #ccc;
        border-radius: 4px;
        font-family: monospace;
        background: #f9f9f9;
        color: #000;
    }
    .bluesky-filter-dialog .button-container {
        display: flex;
        justify-content: flex-end;
        gap: 10px;
        margin-top: 10px;
    }
    .bluesky-filter-dialog button {
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 8px 16px;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-size: 1em;
        text-align: center;
    }
    .bluesky-filter-dialog .save-btn {
        background-color: #0079d3;
        color: white;
    }
    .bluesky-filter-dialog .cancel-btn {
        background-color: #f2f2f2;
        color: #333;
    }
    .bluesky-filter-dialog button:hover {
        opacity: 0.9;
    }
    .bluesky-filter-overlay {
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background: rgba(0,0,0,0.5);
        z-index: 999999;
    }`;

    if (typeof GM_addStyle !== 'undefined') {
        GM_addStyle(CSS);
    } else {
        const style = document.createElement('style');
        style.textContent = CSS;
        document.head.appendChild(style);
    }

    const filteredTerms = JSON.parse(GM_getValue('filteredTerms', '[]')).map(t => t.trim().toLowerCase());
    const processedPosts = new WeakSet();
    let sessionToken = null;
    const profileCache = new Map();
    let blockedCount = 0;
    let menuCommandId = null;
    let observer = null;

    function updateMenuCommand() {
        if (menuCommandId) {
            GM_unregisterMenuCommand(menuCommandId);
        }
        menuCommandId = GM_registerMenuCommand(`Configure blocklist (${blockedCount} blocked)`, showConfigUI);
    }

    function createConfigUI() {
        const overlay = document.createElement('div');
        overlay.className = 'bluesky-filter-overlay';
        const dialog = document.createElement('div');
        dialog.className = 'bluesky-filter-dialog';
        dialog.innerHTML = `
            <h2>Bluesky Filter Keywords</h2>
            <p>Enter keywords one per line. Filtering is case-insensitive and matches common plural forms.</p>
            <textarea spellcheck="false">${filteredTerms.join('\n')}</textarea>
            <div class="button-container">
                <button class="cancel-btn">Cancel</button>
                <button class="save-btn">Save</button>
            </div>
        `;

        document.body.appendChild(overlay);
        document.body.appendChild(dialog);

        const closeDialog = () => {
            dialog.remove();
            overlay.remove();
        };

        dialog.querySelector('.save-btn').addEventListener('click', async () => {
            const newKeywords = dialog.querySelector('textarea').value
                .split('\n')
                .map(k => k.trim().toLowerCase())
                .filter(k => k.length > 0);
            await GM_setValue('filteredTerms', JSON.stringify(newKeywords));
            blockedCount = 0;
            closeDialog();
            location.reload();
        });

        dialog.querySelector('.cancel-btn').addEventListener('click', closeDialog);
        overlay.addEventListener('click', closeDialog);
    }

    function showConfigUI() {
        createConfigUI();
    }

    function debugLog(type, data = null) {
        console.log(`🔍 [Bluesky Filter] ${type}:`, data || '');
    }

    function listStorage() {
        debugLog('Listing localStorage');
        for (let i = 0; i < localStorage.length; i++) {
            const key = localStorage.key(i);
            const value = localStorage.getItem(key);
            console.log(`localStorage[${key}]:`, value);
        }
    }

    function waitForAuth() {
        return new Promise((resolve, reject) => {
            const maxAttempts = 30;
            let attempts = 0;

            const checkAuth = () => {
                attempts++;
                let session = localStorage.getItem('BSKY_STORAGE');
                if (session) {
                    try {
                        const parsed = JSON.parse(session);
                        if (parsed.session?.accounts?.[0]?.accessJwt) {
                            sessionToken = parsed.session.accounts[0].accessJwt;
                            debugLog('Auth Success', 'Token retrieved');
                            resolve(true);
                            return;
                        }
                    } catch (e) {
                        debugLog('Auth Error', e);
                    }
                }
                if (attempts === 1) {
                    listStorage();
                }
                if (attempts >= maxAttempts) {
                    reject('Authentication timeout');
                    return;
                }
                setTimeout(checkAuth, 1000);
            };

            checkAuth();
        });
    }

    async function fetchProfile(did) {
        if (!sessionToken) {
            debugLog('Fetch Profile Error', 'No session token available');
            return null;
        }

        if (profileCache.has(did)) {
            debugLog('Fetch Profile', 'Using cached profile', did);
            return profileCache.get(did);
        }

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://bsky.social/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`,
                headers: {
                    'Authorization': `Bearer ${sessionToken}`,
                    'Accept': 'application/json'
                },
                onload: function(response) {
                    if (response.status === 200) {
                        try {
                            const data = JSON.parse(response.responseText);
                            debugLog('Profile Data', {did: did, description: data.description});
                            profileCache.set(did, data);
                            resolve(data);
                        } catch (e) {
                            debugLog('Profile Parsing Error', e);
                            reject(e);
                        }
                    } else if (response.status === 401) {
                        debugLog('Auth Expired', 'Session token expired');
                        sessionToken = null;
                        reject('Auth expired');
                    } else {
                        debugLog('Profile Fetch Error', `HTTP ${response.status}`);
                        reject(`HTTP ${response.status}`);
                    }
                },
                onerror: function(error) {
                    debugLog('Fetch Profile Error', error);
                    reject(error);
                }
            });
        });
    }

    function cleanText(text) {
        return text
            .normalize('NFKD')
            .replace(/\s+/g, ' ')
            .toLowerCase()
            .trim();
    }

    async function processPost(post) {
        if (!shouldProcessPage() || processedPosts.has(post)) return;
        processedPosts.add(post);

        const authorLink = post.querySelector('a[href^="/profile/"]');
        if (!authorLink) {
            debugLog('Process Post', 'Author link not found');
            return;
        }

        const nameElement = authorLink.querySelector('span');
        if (nameElement) {
            const rawAuthorName = nameElement.textContent;
            const cleanedAuthorName = cleanText(rawAuthorName);
            debugLog('Raw Author Name', rawAuthorName);
            debugLog('Cleaned Author Name', cleanedAuthorName);

            const nameContainsFilteredTerm = filteredTerms.some(term => {
                const escapedTerm = escapeRegExp(term);
                const pattern = new RegExp(`${escapedTerm}`, 'i');
                const matches = pattern.test(cleanedAuthorName) || pattern.test(rawAuthorName.toLowerCase());
                if (matches) {
                    debugLog('Match Found in Author Name', { term, rawAuthorName, cleanedAuthorName });
                }
                return matches;
            });

            if (nameContainsFilteredTerm) {
                const postContainer = authorLink.closest('div[role="link"]')?.closest('div.css-175oi2r');
                if (postContainer) {
                    postContainer.remove();
                    blockedCount++;
                    updateMenuCommand();
                    debugLog('Filtered Post by Name', {rawAuthorName, cleanedAuthorName});
                    return;
                }
            }
        }

        const didMatch = authorLink.href.match(/\/profile\/(.+)/);
        if (!didMatch || !didMatch[1]) {
            debugLog('Process Post', 'DID not found in URL');
            return;
        }

        const did = decodeURIComponent(didMatch[1]);
        if (!did) {
            debugLog('Process Post', 'Empty DID');
            return;
        }

        debugLog('Processing Post', {did});

        const postContentElement = post.querySelector('div[data-testid="postText"]');
        if (postContentElement) {
            const rawPostText = postContentElement.textContent;
            const cleanedPostText = cleanText(rawPostText);
            debugLog('Raw Post Content', rawPostText);
            debugLog('Cleaned Post Content', cleanedPostText);

            const textContainsFilteredTerm = filteredTerms.some(term => {
                const escapedTerm = escapeRegExp(term);
                const pattern = new RegExp(`${escapedTerm}`, 'i');
                const matches = pattern.test(cleanedPostText) || pattern.test(rawPostText.toLowerCase());
                if (matches) {
                    debugLog('Match Found in Post Text', { term, rawPostText, cleanedPostText });
                }
                return matches;
            });

            if (textContainsFilteredTerm) {
                const postContainer = authorLink.closest('div[role="link"]')?.closest('div.css-175oi2r');
                if (postContainer) {
                    postContainer.remove();
                    blockedCount++;
                    updateMenuCommand();
                    debugLog('Filtered Post by Text', {did, rawPostText, cleanedPostText});
                    return;
                }
            }
        }

        const imageElements = post.querySelectorAll('img[alt]');
        if (imageElements.length > 0) {
            const altTexts = Array.from(imageElements).map(img => img.alt);
            const cleanedAltTexts = altTexts.map(alt => cleanText(alt));
            debugLog('Alt Texts Found', altTexts);

            const altTextContainsFilteredTerm = filteredTerms.some(term => {
                const escapedTerm = escapeRegExp(term);
                const pattern = new RegExp(`${escapedTerm}`, 'i');
                const matches = altTexts.some(alt => pattern.test(alt.toLowerCase())) ||
                               cleanedAltTexts.some(alt => pattern.test(alt));
                if (matches) {
                    debugLog('Match Found in Alt Text', {
                        term,
                        matchedAltTexts: altTexts.filter(alt => pattern.test(alt.toLowerCase()))
                    });
                }
                return matches;
            });

            if (altTextContainsFilteredTerm) {
                const postContainer = authorLink.closest('div[role="link"]')?.closest('div.css-175oi2r');
                if (postContainer) {
                    postContainer.remove();
                    blockedCount++;
                    updateMenuCommand();
                    debugLog('Filtered Post by Alt Text', {altTexts});
                    return;
                }
            }
        }

        const ariaLabelElements = post.querySelectorAll('[aria-label]');
        if (ariaLabelElements.length > 0) {
            const ariaLabels = Array.from(ariaLabelElements).map(el => el.getAttribute('aria-label'));
            const cleanedAriaLabels = ariaLabels.map(label => cleanText(label));
            debugLog('Aria-Labels Found', ariaLabels);

            const ariaLabelContainsFilteredTerm = filteredTerms.some(term => {
                const escapedTerm = escapeRegExp(term);
                const pattern = new RegExp(`${escapedTerm}`, 'i');
                const matches = ariaLabels.some(label => pattern.test(label.toLowerCase())) ||
                               cleanedAriaLabels.some(label => pattern.test(label));
                if (matches) {
                    debugLog('Match Found in Aria-Label', {
                        term,
                        matchedAriaLabels: ariaLabels.filter(label => pattern.test(label.toLowerCase()))
                    });
                }
                return matches;
            });

            if (ariaLabelContainsFilteredTerm) {
                const postContainer = authorLink.closest('div[role="link"]')?.closest('div.css-175oi2r');
                if (postContainer) {
                    postContainer.remove();
                    blockedCount++;
                    updateMenuCommand();
                    debugLog('Filtered Post by Aria-Label', {ariaLabels});
                    return;
                }
            }
        }
    }

    function observePosts() {
        observer = new MutationObserver((mutations) => {
            if (!shouldProcessPage()) {
                return;
            }

            mutations.forEach(mutation => {
                if (mutation.type === 'childList') {
                    const addedNodes = Array.from(mutation.addedNodes).filter(node => node.nodeType === Node.ELEMENT_NODE);
                    if (addedNodes.length > 0) {
                        debugLog('Observer', `Detected ${addedNodes.length} added node(s)`);
                    }
                    addedNodes.forEach(node => {
                        const authorLinks = node.querySelectorAll('a[href^="/profile/"]');
                        if (authorLinks.length > 0) {
                            debugLog('Observer', `Detected ${authorLinks.length} new post(s)`);
                            authorLinks.forEach(authorLink => {
                                const container = authorLink.closest('div[role="link"]')?.closest('div.css-175oi2r');
                                if (container) {
                                    processPost(container);
                                } else {
                                    debugLog('Observer', 'Post container not found for a new post');
                                }
                            });
                        }

                        const addedImages = node.querySelectorAll('img[alt]');
                        addedImages.forEach(img => {
                            debugLog('Observer', 'New Image with Alt', img.alt);
                            const postContainer = img.closest('div[role="link"]')?.closest('div.css-175oi2r');
                            if (postContainer) {
                                processPost(postContainer);
                            }
                        });

                        const addedAriaLabels = node.querySelectorAll('[aria-label]');
                        addedAriaLabels.forEach(el => {
                            const ariaLabel = el.getAttribute('aria-label');
                            debugLog('Observer', 'New Element with Aria-Label', ariaLabel);
                            const postContainer = el.closest('div[role="link"]')?.closest('div.css-175oi2r');
                            if (postContainer) {
                                processPost(postContainer);
                            }
                        });
                    });
                } else if (mutation.type === 'attributes' && (mutation.attributeName === 'alt' || mutation.attributeName === 'aria-label')) {
                    const target = mutation.target;
                    if (mutation.attributeName === 'alt') {
                        const altText = target.getAttribute('alt');
                        debugLog('Observer', 'Alt attribute changed', altText);
                    } else if (mutation.attributeName === 'aria-label') {
                        const ariaLabel = target.getAttribute('aria-label');
                        debugLog('Observer', 'Aria-label attribute changed', ariaLabel);
                    }
                    const postContainer = target.closest('div[role="link"]')?.closest('div.css-175oi2r');
                    if (postContainer) {
                        processPost(postContainer);
                    }
                }
            });
        });

        if (shouldProcessPage()) {
            observer.observe(document.body, {
                childList: true,
                subtree: true,
                attributes: true,
                attributeFilter: ['alt', 'aria-label']
            });
        }

        // Add URL change detection
        let lastPath = window.location.pathname;
        setInterval(() => {
            if (window.location.pathname !== lastPath) {
                lastPath = window.location.pathname;
                if (!shouldProcessPage()) {
                    observer.disconnect();
                } else {
                    observer.observe(document.body, {
                        childList: true,
                        subtree: true,
                        attributes: true,
                        attributeFilter: ['alt', 'aria-label']
                    });
                }
            }
        }, 1000);
    }

    if (shouldProcessPage()) {
        waitForAuth().then(() => {
            observePosts();
        }).catch((err) => {
            debugLog('Initialization Error', err);
        });
    }

    updateMenuCommand();
    debugLog('Script Loaded', { filteredTerms, timestamp: new Date().toISOString() });
})();