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, NONMUTUAL = 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: 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(); }; return (
e.preventDefault()} > setLoginInput(e.currentTarget.value)} />
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; while (res.data.cursor && res.data.records.length >= PAGE_LIMIT) { res = await fetchPage(res.data.cursor); follows = follows.concat(res.data.records); } return follows; }; setProgress(0); setNotice(""); const follows = await fetchFollows(); setFollowCount(follows.length); const tmpFollows: FollowRecord[] = []; follows.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.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.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.NONMUTUAL ? "Non Mutual" : 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: status != RepoStatus.NONMUTUAL, }); } setProgress(progress() + 1); if (progress() == followCount()) setFollowRecords(tmpFollows); }); }; 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([]); setProgress(0); setFollowCount(0); 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" }, { status: RepoStatus.NONMUTUAL, label: "Non Mutual" }, ]; 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 = () => { return (

cleanfollow-bsky

Select then unfollow inactive or blocked accounts

); }; export default App;