import { createSignal, For, Switch, Match, Show, type Component, } from "solid-js"; import { createStore } from "solid-js/store"; import { Agent } from "@atproto/api"; import { BrowserOAuthClient } 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, NONMUTUAL = 1 << 6, } type FollowRecord = { did: string; handle: string; uri: string; status: RepoStatus; toBeDeleted: boolean; visible: boolean; }; const [followRecords, setFollowRecords] = createStore([]); const [loginState, setLoginState] = createSignal(); const [notice, setNotice] = 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 agent: Agent; let userHandle: string; const result = await client.init().catch(() => {}); if (result) { agent = new Agent(result.session); setLoginState(true); const res = await agent.getProfile({ actor: agent.did! }); userHandle = res.data.handle; } const loginBsky = async (handle: string) => { setNotice("Redirecting..."); try { await client.signIn(handle, { scope: "atproto transition:generic", signal: new AbortController().signal, }); } catch (err) { setNotice("Error during OAuth redirection"); } }; const logoutBsky = async () => { if (result) await client.revoke(result.session.sub); }; const Follows: Component = () => { function selectRecords(status: RepoStatus, toBeDeleted: boolean) { followRecords.forEach((record, index) => { if (record.status & status) setFollowRecords(index, "toBeDeleted", toBeDeleted); }); } function changeVisibility(status: RepoStatus, visible: boolean) { followRecords.forEach((record, index) => { if (record.status & status) setFollowRecords(index, "visible", visible); }); } const options: { status: RepoStatus; label: string }[] = [ { status: RepoStatus.DELETED, label: "Deleted" }, { status: RepoStatus.DEACTIVATED, label: "Deactivated" }, { status: RepoStatus.SUSPENDED, label: "Suspended" }, { status: RepoStatus.BLOCKEDBY, label: "Blocked By" }, { status: RepoStatus.BLOCKING, label: "Blocking" }, { status: RepoStatus.NONMUTUAL, label: "Non Mutual" }, ]; return (
{(option, index) => (
selectRecords(option.status, 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 fetchHiddenAccounts = async () => { const fetchFollows = async () => { const PAGE_LIMIT = 100; const fetchPage = async (cursor?: any) => { return await agent.com.atproto.repo.listRecords({ repo: agent.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 agent.getProfile({ actor: record.value.subject, }); const viewer = res.data.viewer!; let status: RepoStatus | undefined = undefined; if (!viewer.followedBy) status = RepoStatus.NONMUTUAL; if (viewer.blockedBy) { status = viewer.blocking || viewer.blockingByList ? RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING : RepoStatus.BLOCKEDBY; } else if (res.data.did.includes(agent.did!)) { status = RepoStatus.YOURSELF; } else if (viewer.blocking || viewer.blockingByList) { status = RepoStatus.BLOCKING; } if (status !== undefined) { setFollowRecords(followRecords.length, { did: record.value.subject, handle: res.data.handle, uri: record.uri, status: status, toBeDeleted: false, visible: true, }); } } 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, visible: true, }); } } 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 agent.com.atproto.repo.applyWrites({ repo: agent.did!, writes: writes.slice(i, i + BATCHSIZE), }); } setFollowRecords([]); setProgress(0); setFollowCount(0); setNotice( `Unfollowed ${writes.length} account${writes.length > 1 ? "s" : ""}`, ); }; return (
e.preventDefault()} > 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

By default, every account will be unselected

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