import { createEffect, createSignal, For, onMount, Show } from "solid-js"; import { createStore } from "solid-js/store"; import { ComAtprotoRepoApplyWrites } from "@atcute/atproto"; import { AppBskyGraphFollow } from "@atcute/bluesky"; import { Client, CredentialManager } from "@atcute/client"; import { $type, ActorIdentifier, Did, Handle } from "@atcute/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, HIDDEN = 1 << 5, YOURSELF = 1 << 6, } 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: Client; let agent: OAuthUserAgent; let manager: CredentialManager; let agentDID: string; 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, ).catch((error: unknown) => { console.warn("Failed to resolve DID", { error, did }); }); if (!res) return ""; return res .json() .then((doc) => { for (const alias of doc.alsoKnownAs) { if (alias.includes("at://")) { return alias.split("//")[1]; } } }) .catch((error: unknown) => { console.warn("Failed to parse DID", { error, did }); return ""; }); }; const Login = () => { const [loginInput, setLoginInput] = createSignal(""); const [password, setPassword] = 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 Did); } catch (err) { localStorage.removeItem("lastSignedIn"); throw err; } } } }; const session = await init().catch(() => {}); if (session) { agent = new OAuthUserAgent(session); rpc = new Client({ handler: agent }); agentDID = agent.sub; setLoginState(true); setHandle(await resolveDid(agent.sub)); } setNotice(""); }); const getPDS = 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: any) => { for (const service of doc.service) { if (service.id === "#atproto_pds") return service.serviceEndpoint; } }); }; const resolveHandle = async (handle: string) => { const rpc = new Client({ handler: new CredentialManager({ service: "https://public.api.bsky.app", }), }); const res = await rpc.get("com.atproto.identity.resolveHandle", { params: { handle: handle as Handle }, }); if (!res.ok) throw new Error(res.data.error); return res.data.did; }; const loginBsky = async (login: string) => { if (password()) { agentDID = login.startsWith("did:") ? login : await resolveHandle(login); manager = new CredentialManager({ service: await getPDS(agentDID) }); rpc = new Client({ handler: manager }); await manager.login({ identifier: agentDID, password: password(), }); setLoginState(true); } else { try { setNotice(`Resolving your identity...`); const resolved = await resolveFromIdentity(login); 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)} /> setPassword(e.currentTarget.value)} />
Logged in as @{handle()}
{notice()}
); }; const Fetch = () => { 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: agentDID as ActorIdentifier, collection: "app.bsky.graph.follow", limit: PAGE_LIMIT, cursor: cursor, }, }); }; let res = await fetchPage(); if (!res.ok) throw new Error(res.data.error); 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); if (!res.ok) throw new Error(res.data.error); 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 > 1500) await timer(1000); follows.slice(i, i + 10).forEach(async (record) => { let status: RepoStatus | undefined = undefined; const follow = record.value as AppBskyGraphFollow.Main; let handle = ""; const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: follow.subject }, }); if (!res.ok) { handle = await resolveDid(follow.subject); const e = res.data as any; status = e.message.includes("not found") ? RepoStatus.DELETED : e.message.includes("deactivated") ? RepoStatus.DEACTIVATED : e.message.includes("suspended") ? RepoStatus.SUSPENDED : undefined; } else { handle = res.data.handle; const viewer = res.data.viewer!; if (res.data.labels?.some((label) => label.val === "!hide")) { status = RepoStatus.HIDDEN; } else if (viewer.blockedBy) { status = viewer.blocking || viewer.blockingByList ? RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING : RepoStatus.BLOCKEDBY; } else if (res.data.did.includes(agentDID)) { status = RepoStatus.YOURSELF; } else if (viewer.blocking || viewer.blockingByList) { status = RepoStatus.BLOCKING; } } 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" : status == RepoStatus.HIDDEN ? "Hidden by moderation service" : 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): $type.enforce => { 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.post("com.atproto.repo.applyWrites", { input: { repo: agentDID as ActorIdentifier, writes: writes.slice(i, i + BATCHSIZE), }, }); } setFollowRecords([]); setNotice(`Unfollowed ${writes.length} account${writes.length > 1 ? "s" : ""}`); }; return (
{notice()}
Progress: {progress()}/{followCount()}
); }; const Follows = () => { 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" }, { status: RepoStatus.HIDDEN, label: "Hidden" }, ]; return (
{(option, index) => (
editRecords(option.status, "toDelete", e.currentTarget.checked)} />
)}
Selected: {selectedCount()}/{followRecords.length}
{(record, index) => (
setFollowRecords(index(), "toDelete", e.currentTarget.checked)} />
)}
); }; const App = () => { 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;