import { createSignal, For, Switch, Match, Show, type Component, } from "solid-js"; import { createStore } from "solid-js/store"; import { Agent } from "@atproto/api"; import { BrowserOAuthClient, OAuthAgent } from "@atproto/oauth-client-browser"; enum RepoStatus { BLOCKEDBY = 1 << 0, BLOCKING = 1 << 1, DELETED = 1 << 2, DEACTIVATED = 1 << 3, SUSPENDED = 1 << 4, YOURSELF = 1 << 5, } type FollowRecord = { did: string; handle: string; uri: string; status: RepoStatus; toBeDeleted: boolean; }; let [followRecords, setFollowRecords] = createStore([]); let [loginState, setLoginState] = createSignal(); const client = await BrowserOAuthClient.load({ clientId: "https://cleanfollow-bsky.pages.dev/client-metadata.json", handleResolver: "https://boletus.us-west.host.bsky.network", }); client.addEventListener("deleted", () => { setLoginState(false); }); let appAgent: Agent; let userHandle: string; const result: undefined | { agent: OAuthAgent; state?: string } = await client .init() .catch(() => {}); if (result) { appAgent = result.agent; setLoginState(true); const res = await appAgent.getProfile({ actor: appAgent.did! }); userHandle = res.data.handle; } const loginBsky = async (handle: string) => { try { await client.signIn(handle, { signal: new AbortController().signal, }); } catch (err) {} }; const logoutBsky = async () => { if (result) await client.revoke(result.agent.sub); }; const Follows: Component = () => { function selectRecords(status: RepoStatus, toBeDeleted: boolean) { followRecords.forEach((record, index) => { if (record.status & status) setFollowRecords(index, "toBeDeleted", toBeDeleted); }); } return (
selectRecords(RepoStatus.DELETED, e.currentTarget.checked) } />
selectRecords(RepoStatus.DEACTIVATED, e.currentTarget.checked) } />
selectRecords(RepoStatus.SUSPENDED, e.currentTarget.checked) } />
selectRecords(RepoStatus.BLOCKEDBY, e.currentTarget.checked) } />
selectRecords(RepoStatus.BLOCKING, e.currentTarget.checked) } />
{(record, index) => (
setFollowRecords( index(), "toBeDeleted", e.currentTarget.checked, ) } />
)}
); }; const Form: Component = () => { const [loginInput, setLoginInput] = createSignal(""); const [progress, setProgress] = createSignal(0); const [followCount, setFollowCount] = createSignal(0); const [notice, setNotice] = createSignal(""); const fetchHiddenAccounts = async () => { const fetchFollows = async () => { const PAGE_LIMIT = 100; const fetchPage = async (cursor?: any) => { return await appAgent.com.atproto.repo.listRecords({ repo: appAgent.did!, collection: "app.bsky.graph.follow", limit: PAGE_LIMIT, cursor: cursor, }); }; let res = await fetchPage(); let follows = res.data.records; while (res.data.cursor && res.data.records.length >= PAGE_LIMIT) { res = await fetchPage(res.data.cursor); follows = follows.concat(res.data.records); } return follows; }; setNotice(""); setProgress(0); await fetchFollows().then((follows) => follows.forEach(async (record: any) => { setFollowCount(follows.length); try { const res = await appAgent.getProfile({ actor: record.value.subject, }); if (res.data.viewer?.blockedBy) { const status = res.data.viewer?.blocking || res.data.viewer?.blockingByList ? RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING : RepoStatus.BLOCKEDBY; setFollowRecords(followRecords.length, { did: record.value.subject, handle: res.data.handle, uri: record.uri, status: status, toBeDeleted: false, }); } else if (res.data.did.includes(appAgent.did!)) { setFollowRecords(followRecords.length, { did: record.value.subject, handle: res.data.handle, uri: record.uri, status: RepoStatus.YOURSELF, toBeDeleted: false, }); } else if ( res.data.viewer?.blocking || res.data.viewer?.blockingByList ) { setFollowRecords(followRecords.length, { did: record.value.subject, handle: res.data.handle, uri: record.uri, status: RepoStatus.BLOCKING, toBeDeleted: false, }); } } catch (e: any) { const res = await fetch( record.value.subject.startsWith("did:web") ? "https://" + record.value.subject.split(":")[2] + "/.well-known/did.json" : "https://plc.directory/" + record.value.subject, ); const status = e.message.includes("not found") ? RepoStatus.DELETED : e.message.includes("deactivated") ? RepoStatus.DEACTIVATED : e.message.includes("suspended") ? RepoStatus.SUSPENDED : undefined; const handle = await res.json().then((doc) => { for (const alias of doc.alsoKnownAs) { if (alias.includes("at://")) { return alias.split("//")[1]; } } }); if (status !== undefined) { setFollowRecords(followRecords.length, { did: record.value.subject, handle: handle, uri: record.uri, status: status, toBeDeleted: false, }); } } setProgress(progress() + 1); }), ); }; const unfollow = async () => { const writes = followRecords .filter((record) => record.toBeDeleted) .map((record) => { return { $type: "com.atproto.repo.applyWrites#delete", collection: "app.bsky.graph.follow", rkey: record.uri.split("/").pop(), }; }); const BATCHSIZE = 200; for (let i = 0; i < writes.length; i += BATCHSIZE) { await appAgent.com.atproto.repo.applyWrites({ repo: appAgent.did!, writes: writes.slice(i, i + BATCHSIZE), }); } setFollowRecords([]); setProgress(0); setFollowCount(0); setNotice(`Unfollowed ${writes.length} accounts`); }; return (
setLoginInput(e.currentTarget.value)} />
Logged in as {userHandle} ( logoutBsky()}> Logout )
{notice()}
Progress: {progress()}/{followCount()}
); }; const App: Component = () => { return (

cleanfollow-bsky

Unfollow blocked, deleted, suspended, and deactivated accounts

Source Code | Bluesky | Quiet Posters
); }; export default App;