// ==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 */});
}
})();