import { type Component, createEffect, createSignal, For, onMount, Show, } from "solid-js"; import { createStore } from "solid-js/store"; import { XRPC } from "@atcute/client"; import { AppBskyGraphFollow, At, Brand, ComAtprotoRepoApplyWrites, } from "@atcute/client/lexicons"; import { configureOAuth, createAuthorizationUrl, finalizeAuthorization, getSession, OAuthUserAgent, resolveFromIdentity, type Session, } from "@atcute/oauth-browser-client"; configureOAuth({ metadata: { client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, }, }); 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; status_label: string; toDelete: boolean; visible: boolean; }; const [followRecords, setFollowRecords] = createStore([]); const [loginState, setLoginState] = createSignal(false); let rpc: XRPC; let agent: OAuthUserAgent; const resolveDid = async (did: string) => { const res = await fetch( did.startsWith("did:web") ? `https://${did.split(":")[2]}/.well-known/did.json` : "https://plc.directory/" + did, ); return res .json() .then((doc) => { for (const alias of doc.alsoKnownAs) { if (alias.includes("at://")) { return alias.split("//")[1]; } } }) .catch(() => ""); }; const Login: Component = () => { const [loginInput, setLoginInput] = createSignal(""); const [handle, setHandle] = createSignal(""); const [notice, setNotice] = createSignal(""); onMount(async () => { setNotice("Loading..."); const init = async (): Promise => { const params = new URLSearchParams(location.hash.slice(1)); if (params.has("state") && (params.has("code") || params.has("error"))) { history.replaceState(null, "", location.pathname + location.search); const session = await finalizeAuthorization(params); const did = session.info.sub; localStorage.setItem("lastSignedIn", did); return session; } else { const lastSignedIn = localStorage.getItem("lastSignedIn"); if (lastSignedIn) { try { return await getSession(lastSignedIn as At.DID); } catch (err) { localStorage.removeItem("lastSignedIn"); throw err; } } } }; const session = await init().catch(() => {}); if (session) { agent = new OAuthUserAgent(session); rpc = new XRPC({ handler: agent }); setLoginState(true); setHandle(await resolveDid(agent.sub)); } setNotice(""); }); const loginBsky = async (handle: string) => { try { setNotice(`Resolving your identity...`); const resolved = await resolveFromIdentity(handle); setNotice(`Contacting your data server...`); const authUrl = await createAuthorizationUrl({ scope: import.meta.env.VITE_OAUTH_SCOPE, ...resolved, }); setNotice(`Redirecting...`); await new Promise((resolve) => setTimeout(resolve, 250)); location.assign(authUrl); } catch { setNotice("Error during OAuth login"); } }; const logoutBsky = async () => { await agent.signOut(); setLoginState(false); }; return (
e.preventDefault()}> setLoginInput(e.currentTarget.value)} />

Remember to use your main password, not an app password.

The session is only stored on your browser.

Make sure to check the URL you will be authenticated through.

(bsky.social unless you are on a selfhosted server)

Logged in as @{handle()} logoutBsky()} > Logout
{notice()}
); }; const Fetch: Component = () => { 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?: string) => { return await rpc.get("com.atproto.repo.listRecords", { params: { repo: agent.sub, collection: "app.bsky.graph.follow", limit: PAGE_LIMIT, cursor: cursor, }, }); }; let res = await fetchPage(); let follows = res.data.records; setNotice(`Fetching follows: ${follows.length}`); while (res.data.cursor && res.data.records.length >= PAGE_LIMIT) { setNotice(`Fetching follows: ${follows.length}`); res = await fetchPage(res.data.cursor); follows = follows.concat(res.data.records); } return follows; }; setProgress(0); const follows = await fetchFollows(); setFollowCount(follows.length); const tmpFollows: FollowRecord[] = []; setNotice(""); const timer = (ms: number) => new Promise((res) => setTimeout(res, ms)); for (let i = 0; i < follows.length; i = i + 10) { if (follows.length > 1000) await timer(1000); follows.slice(i, i + 10).forEach(async (record) => { let status: RepoStatus | undefined = undefined; const follow = record.value as AppBskyGraphFollow.Record; let handle = ""; try { const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: follow.subject }, }); handle = res.data.handle; const viewer = res.data.viewer!; if (viewer.blockedBy) { status = viewer.blocking || viewer.blockingByList ? RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING : RepoStatus.BLOCKEDBY; } else if (res.data.did.includes(agent.sub)) { status = RepoStatus.YOURSELF; } else if (viewer.blocking || viewer.blockingByList) { status = RepoStatus.BLOCKING; } } catch (e: any) { handle = await resolveDid(follow.subject); status = e.message.includes("not found") ? RepoStatus.DELETED : e.message.includes("deactivated") ? RepoStatus.DEACTIVATED : e.message.includes("suspended") ? RepoStatus.SUSPENDED : undefined; } const status_label = status == RepoStatus.DELETED ? "Deleted" : status == RepoStatus.DEACTIVATED ? "Deactivated" : status == RepoStatus.SUSPENDED ? "Suspended" : status == RepoStatus.YOURSELF ? "Literally Yourself" : status == RepoStatus.BLOCKING ? "Blocking" : status == RepoStatus.BLOCKEDBY ? "Blocked by" : RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING ? "Mutual Block" : ""; if (status !== undefined) { tmpFollows.push({ did: follow.subject, handle: handle, uri: record.uri, status: status, status_label: status_label, toDelete: false, visible: true, }); } setProgress(progress() + 1); if (progress() === followCount()) { if (tmpFollows.length === 0) setNotice("No accounts to unfollow"); setFollowRecords(tmpFollows); setProgress(0); setFollowCount(0); } }); } }; const unfollow = async () => { const writes = followRecords .filter((record) => record.toDelete) .map((record): Brand.Union => { 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 rpc.call("com.atproto.repo.applyWrites", { data: { repo: agent.sub, writes: writes.slice(i, i + BATCHSIZE), }, }); } setFollowRecords([]); setNotice( `Unfollowed ${writes.length} account${writes.length > 1 ? "s" : ""}`, ); }; return (
{notice()}
Progress: {progress()}/{followCount()}
); }; const Follows: Component = () => { const [selectedCount, setSelectedCount] = createSignal(0); createEffect(() => { setSelectedCount(followRecords.filter((record) => record.toDelete).length); }); function editRecords( status: RepoStatus, field: keyof FollowRecord, value: boolean, ) { const range = followRecords .map((record, index) => { if (record.status & status) return index; }) .filter((i) => i !== undefined); setFollowRecords(range, field, value); } 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" }, ]; return (
{(option, index) => (
editRecords( option.status, "toDelete", e.currentTarget.checked, ) } />
)}
Selected: {selectedCount()}/{followRecords.length}
{(record, index) => (
setFollowRecords( index(), "toDelete", e.currentTarget.checked, ) } />
)}
); }; const App: Component = () => { const [theme, setTheme] = createSignal( ( localStorage.theme === "dark" || (!("theme" in localStorage) && globalThis.matchMedia("(prefers-color-scheme: dark)").matches) ) ? "dark" : "light", ); return (
{ setTheme(theme() === "light" ? "dark" : "light"); if (theme() === "dark") document.documentElement.classList.add("dark"); else document.documentElement.classList.remove("dark"); localStorage.theme = theme(); }} > {theme() === "dark" ?
:
}

Select inactive or blocked accounts to unfollow

); }; export default App;