Bluesky Content Manager

Content filtering for Bluesky: block keywords, enforce alt-text, and auto-whitelist followed accounts. Now with improved dynamic content handling.

// ==UserScript==
// @name         Bluesky Content Manager
// @namespace    https://greasyfork.org/en/users/567951-stuart-saddler
// @version      3.4.9
// @description  Content filtering for Bluesky: block keywords, enforce alt-text, and auto-whitelist followed accounts. Now with improved dynamic content handling.
// @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';

    /***** CONFIG & GLOBALS *****/
    const filteredTerms = (JSON.parse(GM_getValue('filteredTerms','[]'))||[])
        .map(t=>t.trim().toLowerCase());
    const whitelistedUsers = new Set(
        (JSON.parse(GM_getValue('whitelistedUsers','[]'))||[])
        .map(u=>normalizeUsername(u))
    );
    let altTextEnforcementEnabled = GM_getValue('altTextEnforcementEnabled', false);
    let blockedCount = 0, menuCommandId = null;

    /***** CSS INJECTION *****/
    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 label {
        display: block;
        margin-top: 10px;
        font-size: 0.9em;
        color: #333;
    }
    .bluesky-filter-dialog input[type="checkbox"] {
        margin-right: 6px;
    }
    .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;
    }
    `;
    GM_addStyle(CSS);

    /***** UTILITIES *****/
    function normalizeUsername(u){ return u.toLowerCase().replace(/[\u200B-\u200F\u202A-\u202F]/g,'').trim(); }
    function escapeRegExp(s){ return s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'); }
    function cleanText(t){ return t.normalize('NFKD').replace(/\s+/g,' ').toLowerCase().trim(); }
    function getPostContainer(node){
        let cur=node;
        while(cur&&cur!==document.body){
            if(cur.matches('[data-testid="post"], div[role="link"], article')) return cur;
            cur=cur.parentElement;
        }
        return null;
    }
    function shouldProcessPage(){
        const p=window.location.pathname;
        return !p.startsWith('/profile/') && p!=='/notifications';
    }
    function isContentImage(img){
        return !(
            img.closest('[data-testid="avatar"]') ||
            img.classList.contains('avatar') ||
            img.classList.contains('css-9pa8cd')
        );
    }

    /***** MENU & CONFIG UI *****/
    function updateMenuCommand(){
        if(menuCommandId) GM_unregisterMenuCommand(menuCommandId);
        menuCommandId = GM_registerMenuCommand(`Configure Filters (${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 Content Manager</h2>
            <p>Blocklist Keywords (one per line). Case-insensitive, plural forms match.</p>
            <textarea spellcheck="false">${filteredTerms.join('\n')}</textarea>
            <label>
                <input type="checkbox" ${altTextEnforcementEnabled?'checked':''}>
                Enable Alt-Text Enforcement
            </label>
            <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 close=()=>{ dialog.remove(); overlay.remove(); };
        dialog.querySelector('.cancel-btn').addEventListener('click', close);
        overlay.addEventListener('click', close);
        dialog.querySelector('.save-btn').addEventListener('click', async ()=>{
            const lines=dialog.querySelector('textarea').value.split('\n');
            const terms=lines.map(l=>l.trim().toLowerCase()).filter(l=>l);
            await GM_setValue('filteredTerms', JSON.stringify(terms));
            altTextEnforcementEnabled = dialog.querySelector('input[type="checkbox"]').checked;
            await GM_setValue('altTextEnforcementEnabled', altTextEnforcementEnabled);
            blockedCount=0;
            close();
            location.reload();
        });
    }
    function showConfigUI(){ createConfigUI(); }

    /***** AUTH & PROFILE *****/
    let sessionToken=null, currentUserDid=null;
    const profileCache = new Map();
    function waitForAuth(){
        return new Promise((res,rej)=>{
            let attempts=0, max=30;
            (function check(){
                attempts++;
                const s=localStorage.getItem('BSKY_STORAGE');
                if(s){
                    try{
                        const p=JSON.parse(s);
                        if(p.session?.accounts?.[0]?.accessJwt){
                            sessionToken=p.session.accounts[0].accessJwt;
                            currentUserDid=p.session.accounts[0].did;
                            return res();
                        }
                    }catch{}
                }
                if(attempts>=max) return rej('Auth timeout');
                setTimeout(check,1000);
            })();
        });
    }
    async function fetchProfile(did){
        if(!sessionToken) return null;
        if(profileCache.has(did)) return profileCache.get(did);
        return new Promise((res,rej)=>{
            GM_xmlhttpRequest({
                method:'GET',
                url:`https://bsky.social/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`,
                headers:{ 'Authorization':`Bearer ${sessionToken}`, 'Accept':'application/json' },
                onload(resp){
                    if(resp.status===200){
                        try{ const d=JSON.parse(resp.responseText); profileCache.set(did,d); return res(d); }
                        catch(e){ return rej(e); }
                    }
                    if(resp.status===401){ sessionToken=null; return rej('Auth expired'); }
                    rej(`HTTP ${resp.status}`);
                },
                onerror(e){ rej(e); }
            });
        });
    }

    /***** AUTO-WHITELIST *****/
    async function fetchAllFollows(cursor=null, acc=[]){
        let url=`https://bsky.social/xrpc/app.bsky.graph.getFollows?actor=${encodeURIComponent(currentUserDid)}`;
        if(cursor) url+=`&cursor=${cursor}`;
        return new Promise((res,rej)=>{
            GM_xmlhttpRequest({
                method:'GET', url,
                headers:{ 'Authorization':`Bearer ${sessionToken}`, 'Accept':'application/json' },
                onload(resp){
                    if(resp.status===200){
                        try{
                            const d=JSON.parse(resp.responseText);
                            const all=acc.concat(d.follows||[]);
                            if(d.cursor) return fetchAllFollows(d.cursor, all).then(res).catch(rej);
                            return res(all);
                        }catch(e){ return rej(e); }
                    }
                    rej(`HTTP ${resp.status}`);
                },
                onerror(e){ rej(e); }
            });
        });
    }
    async function autoWhitelistFollowedAccounts(){
        if(!sessionToken||!currentUserDid) return;
        try{
            const f=await fetchAllFollows();
            f.forEach(u=>{
                let h=(u.subject?.handle)||u.handle;
                if(h&&!h.startsWith('@')) h='@'+h;
                whitelistedUsers.add(normalizeUsername(h));
            });
        }catch{}
    }

    /***** PROCESS POSTS *****/
    async function processPost(post){
        if(isWhitelisted(post)) return;
        if(!shouldProcessPage()) return;
        const container=getPostContainer(post);
        if(!container || container.classList.contains('bluesky-processed')) return;

        if(altTextEnforcementEnabled){
            const imgs=Array.from(post.querySelectorAll('img')).filter(isContentImage);
            if(imgs.some(i=>i.alt.trim()==="")){
                container.remove(); blockedCount++; updateMenuCommand(); return;
            }
            for(const img of imgs){
                const txt=img.alt.trim()||img.getAttribute('aria-label')?.trim()||"";
                const c=cleanText(txt);
                if(filteredTerms.some(term=>{
                    const re=new RegExp(`\\b${escapeRegExp(term)}\\b`,'i');
                    return re.test(txt)||re.test(c);
                })){
                    container.remove(); blockedCount++; updateMenuCommand(); return;
                }
            }
        }

        const authorLink=post.querySelector('a[href^="/profile/"]');
        if(authorLink){
            const rn=authorLink.querySelector('span')?.textContent||authorLink.textContent;
            const cn=cleanText(rn);
            if(filteredTerms.some(term=>{
                const re=new RegExp(`\\b${escapeRegExp(term)}\\b`,'i');
                return re.test(rn.toLowerCase())||re.test(cn);
            })){
                container.remove(); blockedCount++; updateMenuCommand(); return;
            }
        }

        const ptext=post.querySelector('div[data-testid="postText"]');
        if(ptext){
            const rt=ptext.textContent, ct=cleanText(rt);
            if(filteredTerms.some(term=>{
                const re=new RegExp(`\\b${escapeRegExp(term)}\\b`,'i');
                return re.test(rt.toLowerCase())||re.test(ct);
            })){
                container.remove(); blockedCount++; updateMenuCommand(); return;
            }
        }

        const all=container.textContent||"", call=cleanText(all);
        if(filteredTerms.some(term=>{
            const re=new RegExp(`\\b${escapeRegExp(term)}\\b`,'i');
            return re.test(all.toLowerCase())||re.test(call);
        })){
            container.remove(); blockedCount++; updateMenuCommand(); return;
        }
    }
    function isWhitelisted(post){
        const link=post.querySelector('a[href^="/profile/"]');
        if(!link) return false;
        const id=link.href.split('/profile/')[1].split(/[/?#]/)[0];
        return whitelistedUsers.has(normalizeUsername('@'+id));
    }

    /***** OBSERVE MUTATIONS *****/
    function observePosts(){
        const observer=new MutationObserver(muts=>{
            if(!shouldProcessPage()) return;
            muts.forEach(m=>{
                if(m.type==='childList'){
                    Array.from(m.addedNodes)
                        .filter(n=>n.nodeType===1)
                        .forEach(node=>{
                            if(node.tagName==='IMG'){
                                const c=getPostContainer(node);
                                if(c) setTimeout(()=>processPost(c),100);
                            }
                            node.querySelectorAll('img').forEach(i=>{
                                const c=getPostContainer(i);
                                if(c) setTimeout(()=>processPost(c),100);
                            });
                            node.querySelectorAll('a[href^="/profile/"]').forEach(l=>{
                                const c=getPostContainer(l);
                                if(c) setTimeout(()=>processPost(c),100);
                            });
                        });
                } else if(m.type==='attributes' && ['alt','aria-label','src'].includes(m.attributeName)){
                    const c=getPostContainer(m.target);
                    if(c) setTimeout(()=>processPost(c),100);
                } else if(m.type==='characterData'){
                    const c=getPostContainer(m.target.parentElement);
                    if(c) setTimeout(()=>processPost(c),100);
                }
            });
        });
        observer.observe(document.body,{
            childList:true,
            subtree:true,
            attributes:true,
            attributeFilter:['alt','aria-label','src'],
            characterData:true
        });

        let last=location.pathname;
        setInterval(()=>{
            if(location.pathname!==last){
                last=location.pathname;
                observer.disconnect();
                if(shouldProcessPage()){
                    observer.observe(document.body,{
                        childList:true,
                        subtree:true,
                        attributes:true,
                        attributeFilter:['alt','aria-label','src'],
                        characterData:true
                    });
                }
            }
        },1000);

        document.addEventListener('load',e=>{
            if(e.target.tagName==='IMG'){
                const c=getPostContainer(e.target);
                if(c) setTimeout(()=>processPost(c),100);
            }
        },true);
    }

    /***** INIT *****/
    document.querySelectorAll('[data-testid="post"], article, div[role="link"]')
        .forEach(el=>processPost(el));
    updateMenuCommand();

    if(shouldProcessPage()){
        waitForAuth()
            .then(()=>{
                autoWhitelistFollowedAccounts();
                observePosts();
            })
            .catch(()=>{/* auth failed */});
    }
})();