Unfollow tool for Bluesky
1import { createEffect, createSignal, For, onMount, Show } from "solid-js"; 2import { createStore } from "solid-js/store"; 3 4import { CredentialManager, Client } from "@atcute/client"; 5import { 6 configureOAuth, 7 createAuthorizationUrl, 8 finalizeAuthorization, 9 getSession, 10 OAuthUserAgent, 11 resolveFromIdentity, 12 type Session, 13} from "@atcute/oauth-browser-client"; 14import { $type, Did, Handle } from "@atcute/lexicons"; 15import { ComAtprotoRepoApplyWrites } from "@atcute/atproto"; 16import { AppBskyGraphFollow } from "@atcute/bluesky"; 17 18configureOAuth({ 19 metadata: { 20 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 21 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 22 }, 23}); 24 25enum RepoStatus { 26 BLOCKEDBY = 1 << 0, 27 BLOCKING = 1 << 1, 28 DELETED = 1 << 2, 29 DEACTIVATED = 1 << 3, 30 SUSPENDED = 1 << 4, 31 HIDDEN = 1 << 5, 32 YOURSELF = 1 << 6, 33} 34 35type FollowRecord = { 36 did: string; 37 handle: string; 38 uri: string; 39 status: RepoStatus; 40 status_label: string; 41 toDelete: boolean; 42 visible: boolean; 43}; 44 45const [followRecords, setFollowRecords] = createStore<FollowRecord[]>([]); 46const [loginState, setLoginState] = createSignal(false); 47let rpc: Client; 48let agent: OAuthUserAgent; 49let manager: CredentialManager; 50 51const resolveDid = async (did: string) => { 52 const res = await fetch( 53 did.startsWith("did:web") ? 54 `https://${did.split(":")[2]}/.well-known/did.json` 55 : "https://plc.directory/" + did, 56 ).catch((error: unknown) => { 57 console.warn("Failed to resolve DID", { error, did }); 58 }); 59 if (!res) return ""; 60 61 return res 62 .json() 63 .then((doc) => { 64 for (const alias of doc.alsoKnownAs) { 65 if (alias.includes("at://")) { 66 return alias.split("//")[1]; 67 } 68 } 69 }) 70 .catch((error: unknown) => { 71 console.warn("Failed to parse DID", { error, did }); 72 return ""; 73 }); 74}; 75 76const Login = () => { 77 const [loginInput, setLoginInput] = createSignal(""); 78 const [password, setPassword] = createSignal(""); 79 const [handle, setHandle] = createSignal(""); 80 const [notice, setNotice] = createSignal(""); 81 82 onMount(async () => { 83 setNotice("Loading..."); 84 85 const init = async (): Promise<Session | undefined> => { 86 const params = new URLSearchParams(location.hash.slice(1)); 87 88 if (params.has("state") && (params.has("code") || params.has("error"))) { 89 history.replaceState(null, "", location.pathname + location.search); 90 91 const session = await finalizeAuthorization(params); 92 const did = session.info.sub; 93 94 localStorage.setItem("lastSignedIn", did); 95 return session; 96 } else { 97 const lastSignedIn = localStorage.getItem("lastSignedIn"); 98 99 if (lastSignedIn) { 100 try { 101 return await getSession(lastSignedIn as Did); 102 } catch (err) { 103 localStorage.removeItem("lastSignedIn"); 104 throw err; 105 } 106 } 107 } 108 }; 109 110 const session = await init().catch(() => {}); 111 112 if (session) { 113 agent = new OAuthUserAgent(session); 114 rpc = new Client({ handler: agent }); 115 116 setLoginState(true); 117 setHandle(await resolveDid(agent.sub)); 118 } 119 120 setNotice(""); 121 }); 122 123 const getPDS = async (did: string) => { 124 const res = await fetch( 125 did.startsWith("did:web") ? 126 `https://${did.split(":")[2]}/.well-known/did.json` 127 : "https://plc.directory/" + did, 128 ); 129 130 return res.json().then((doc: any) => { 131 for (const service of doc.service) { 132 if (service.id === "#atproto_pds") return service.serviceEndpoint; 133 } 134 }); 135 }; 136 137 const resolveHandle = async (handle: string) => { 138 const rpc = new Client({ 139 handler: new CredentialManager({ 140 service: "https://public.api.bsky.app", 141 }), 142 }); 143 const res = await rpc.get("com.atproto.identity.resolveHandle", { 144 params: { handle: handle as Handle }, 145 }); 146 if (!res.ok) throw new Error(res.data.error); 147 return res.data.did; 148 }; 149 150 const loginBsky = async (login: string) => { 151 if (password()) { 152 const agentDID = login.startsWith("did:") ? login : await resolveHandle(login); 153 manager = new CredentialManager({ service: await getPDS(agentDID) }); 154 rpc = new Client({ handler: manager }); 155 156 await manager.login({ 157 identifier: agentDID, 158 password: password(), 159 }); 160 setLoginState(true); 161 } else { 162 try { 163 setNotice(`Resolving your identity...`); 164 const resolved = await resolveFromIdentity(login); 165 166 setNotice(`Contacting your data server...`); 167 const authUrl = await createAuthorizationUrl({ 168 scope: import.meta.env.VITE_OAUTH_SCOPE, 169 ...resolved, 170 }); 171 172 setNotice(`Redirecting...`); 173 await new Promise((resolve) => setTimeout(resolve, 250)); 174 175 location.assign(authUrl); 176 } catch { 177 setNotice("Error during OAuth login"); 178 } 179 } 180 }; 181 182 const logoutBsky = async () => { 183 await agent.signOut(); 184 setLoginState(false); 185 }; 186 187 return ( 188 <div class="flex flex-col items-center"> 189 <Show when={!loginState() && !notice().includes("Loading")}> 190 <form class="flex flex-col" onsubmit={(e) => e.preventDefault()}> 191 <label for="handle" class="ml-0.5"> 192 Handle 193 </label> 194 <input 195 type="text" 196 id="handle" 197 placeholder="user.bsky.social" 198 class="dark:bg-dark-100 mb-2 rounded-lg border border-gray-400 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-gray-300" 199 onInput={(e) => setLoginInput(e.currentTarget.value)} 200 /> 201 <label for="password" class="ml-0.5"> 202 App Password 203 </label> 204 <input 205 type="password" 206 id="password" 207 placeholder="leave empty for oauth" 208 class="dark:bg-dark-100 mb-2 rounded-lg border border-gray-400 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-gray-300" 209 onInput={(e) => setPassword(e.currentTarget.value)} 210 /> 211 <button 212 onclick={() => loginBsky(loginInput())} 213 class="rounded bg-blue-600 py-1.5 font-bold text-slate-100 hover:bg-blue-700" 214 > 215 Login 216 </button> 217 </form> 218 </Show> 219 <Show when={loginState() && handle()}> 220 <div class="mb-4"> 221 Logged in as @{handle()} 222 <button 223 class="ml-2 bg-transparent text-red-500 dark:text-red-400" 224 onclick={() => logoutBsky()} 225 > 226 Logout 227 </button> 228 </div> 229 </Show> 230 <Show when={notice()}> 231 <div class="m-3">{notice()}</div> 232 </Show> 233 </div> 234 ); 235}; 236 237const Fetch = () => { 238 const [progress, setProgress] = createSignal(0); 239 const [followCount, setFollowCount] = createSignal(0); 240 const [notice, setNotice] = createSignal(""); 241 242 const fetchHiddenAccounts = async () => { 243 const fetchFollows = async () => { 244 const PAGE_LIMIT = 100; 245 const fetchPage = async (cursor?: string) => { 246 return await rpc.get("com.atproto.repo.listRecords", { 247 params: { 248 repo: agent.sub, 249 collection: "app.bsky.graph.follow", 250 limit: PAGE_LIMIT, 251 cursor: cursor, 252 }, 253 }); 254 }; 255 256 let res = await fetchPage(); 257 if (!res.ok) throw new Error(res.data.error); 258 let follows = res.data.records; 259 setNotice(`Fetching follows: ${follows.length}`); 260 261 while (res.data.cursor && res.data.records.length >= PAGE_LIMIT) { 262 setNotice(`Fetching follows: ${follows.length}`); 263 res = await fetchPage(res.data.cursor); 264 if (!res.ok) throw new Error(res.data.error); 265 follows = follows.concat(res.data.records); 266 } 267 268 return follows; 269 }; 270 271 setProgress(0); 272 const follows = await fetchFollows(); 273 setFollowCount(follows.length); 274 const tmpFollows: FollowRecord[] = []; 275 setNotice(""); 276 277 const timer = (ms: number) => new Promise((res) => setTimeout(res, ms)); 278 for (let i = 0; i < follows.length; i = i + 10) { 279 if (follows.length > 1000) await timer(1000); 280 follows.slice(i, i + 10).forEach(async (record) => { 281 let status: RepoStatus | undefined = undefined; 282 const follow = record.value as AppBskyGraphFollow.Main; 283 let handle = ""; 284 285 try { 286 const res = await rpc.get("app.bsky.actor.getProfile", { 287 params: { actor: follow.subject }, 288 }); 289 290 if (!res.ok) throw new Error(res.data.error); 291 handle = res.data.handle; 292 const viewer = res.data.viewer!; 293 294 if (res.data.labels?.some((label) => label.val === "!hide")) { 295 status = RepoStatus.HIDDEN; 296 } else if (viewer.blockedBy) { 297 status = 298 viewer.blocking || viewer.blockingByList ? 299 RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING 300 : RepoStatus.BLOCKEDBY; 301 } else if (res.data.did.includes(agent.sub)) { 302 status = RepoStatus.YOURSELF; 303 } else if (viewer.blocking || viewer.blockingByList) { 304 status = RepoStatus.BLOCKING; 305 } 306 } catch (e: any) { 307 handle = await resolveDid(follow.subject); 308 309 status = 310 e.message.includes("not found") ? RepoStatus.DELETED 311 : e.message.includes("deactivated") ? RepoStatus.DEACTIVATED 312 : e.message.includes("suspended") ? RepoStatus.SUSPENDED 313 : undefined; 314 } 315 316 const status_label = 317 status == RepoStatus.DELETED ? "Deleted" 318 : status == RepoStatus.DEACTIVATED ? "Deactivated" 319 : status == RepoStatus.SUSPENDED ? "Suspended" 320 : status == RepoStatus.YOURSELF ? "Literally Yourself" 321 : status == RepoStatus.BLOCKING ? "Blocking" 322 : status == RepoStatus.BLOCKEDBY ? "Blocked by" 323 : status == RepoStatus.HIDDEN ? "Hidden by moderation service" 324 : RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING ? "Mutual Block" 325 : ""; 326 327 if (status !== undefined) { 328 tmpFollows.push({ 329 did: follow.subject, 330 handle: handle, 331 uri: record.uri, 332 status: status, 333 status_label: status_label, 334 toDelete: false, 335 visible: true, 336 }); 337 } 338 setProgress(progress() + 1); 339 if (progress() === followCount()) { 340 if (tmpFollows.length === 0) setNotice("No accounts to unfollow"); 341 setFollowRecords(tmpFollows); 342 setProgress(0); 343 setFollowCount(0); 344 } 345 }); 346 } 347 }; 348 349 const unfollow = async () => { 350 const writes = followRecords 351 .filter((record) => record.toDelete) 352 .map((record): $type.enforce<ComAtprotoRepoApplyWrites.Delete> => { 353 return { 354 $type: "com.atproto.repo.applyWrites#delete", 355 collection: "app.bsky.graph.follow", 356 rkey: record.uri.split("/").pop()!, 357 }; 358 }); 359 360 const BATCHSIZE = 200; 361 for (let i = 0; i < writes.length; i += BATCHSIZE) { 362 await rpc.post("com.atproto.repo.applyWrites", { 363 input: { 364 repo: agent.sub, 365 writes: writes.slice(i, i + BATCHSIZE), 366 }, 367 }); 368 } 369 370 setFollowRecords([]); 371 setNotice(`Unfollowed ${writes.length} account${writes.length > 1 ? "s" : ""}`); 372 }; 373 374 return ( 375 <div class="flex flex-col items-center"> 376 <Show when={followCount() === 0 && !followRecords.length}> 377 <button 378 type="button" 379 onclick={() => fetchHiddenAccounts()} 380 class="rounded bg-blue-600 px-2 py-2 font-bold text-slate-100 hover:bg-blue-700" 381 > 382 Preview 383 </button> 384 </Show> 385 <Show when={followRecords.length}> 386 <button 387 type="button" 388 onclick={() => unfollow()} 389 class="rounded bg-blue-600 px-2 py-2 font-bold text-slate-100 hover:bg-blue-700" 390 > 391 Confirm 392 </button> 393 </Show> 394 <Show when={notice()}> 395 <div class="m-3">{notice()}</div> 396 </Show> 397 <Show when={followCount() && progress() != followCount()}> 398 <div class="m-3"> 399 Progress: {progress()}/{followCount()} 400 </div> 401 </Show> 402 </div> 403 ); 404}; 405 406const Follows = () => { 407 const [selectedCount, setSelectedCount] = createSignal(0); 408 409 createEffect(() => { 410 setSelectedCount(followRecords.filter((record) => record.toDelete).length); 411 }); 412 413 function editRecords(status: RepoStatus, field: keyof FollowRecord, value: boolean) { 414 const range = followRecords 415 .map((record, index) => { 416 if (record.status & status) return index; 417 }) 418 .filter((i) => i !== undefined); 419 setFollowRecords(range, field, value); 420 } 421 422 const options: { status: RepoStatus; label: string }[] = [ 423 { status: RepoStatus.DELETED, label: "Deleted" }, 424 { status: RepoStatus.DEACTIVATED, label: "Deactivated" }, 425 { status: RepoStatus.SUSPENDED, label: "Suspended" }, 426 { status: RepoStatus.BLOCKEDBY, label: "Blocked By" }, 427 { status: RepoStatus.BLOCKING, label: "Blocking" }, 428 { status: RepoStatus.HIDDEN, label: "Hidden" }, 429 ]; 430 431 return ( 432 <div class="mt-6 flex flex-col sm:w-full sm:flex-row sm:justify-center"> 433 <div class="dark:bg-dark-500 sticky top-0 mb-3 mr-5 flex w-full flex-wrap justify-around border-b border-b-gray-400 bg-slate-100 pb-3 sm:top-3 sm:mb-0 sm:w-auto sm:flex-col sm:self-start sm:border-none"> 434 <For each={options}> 435 {(option, index) => ( 436 <div 437 classList={{ 438 "sm:pb-2 min-w-36 sm:mb-2 mt-3 sm:mt-0": true, 439 "sm:border-b sm:border-b-gray-300 dark:sm:border-b-gray-500": 440 index() < options.length - 1, 441 }} 442 > 443 <div> 444 <label class="mb-2 mt-1 inline-flex cursor-pointer items-center"> 445 <input 446 type="checkbox" 447 class="peer sr-only" 448 checked 449 onChange={(e) => editRecords(option.status, "visible", e.currentTarget.checked)} 450 /> 451 <span class="peer relative h-5 w-9 rounded-full bg-gray-200 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rtl:peer-checked:after:-translate-x-full dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"></span> 452 <span class="ms-3 select-none">{option.label}</span> 453 </label> 454 </div> 455 <div class="flex items-center"> 456 <input 457 type="checkbox" 458 id={option.label} 459 class="h-4 w-4 rounded" 460 onChange={(e) => editRecords(option.status, "toDelete", e.currentTarget.checked)} 461 /> 462 <label for={option.label} class="ml-2 select-none"> 463 Select All 464 </label> 465 </div> 466 </div> 467 )} 468 </For> 469 <div class="min-w-36 pt-3 sm:pt-0"> 470 <span> 471 Selected: {selectedCount()}/{followRecords.length} 472 </span> 473 </div> 474 </div> 475 <div class="sm:min-w-96"> 476 <For each={followRecords}> 477 {(record, index) => ( 478 <Show when={record.visible}> 479 <div 480 classList={{ 481 "mb-1 flex items-center border-b dark:border-b-gray-500 py-1": true, 482 "bg-red-300 dark:bg-rose-800": record.toDelete, 483 }} 484 > 485 <div class="mx-2"> 486 <input 487 type="checkbox" 488 id={"record" + index()} 489 class="h-4 w-4 rounded" 490 checked={record.toDelete} 491 onChange={(e) => setFollowRecords(index(), "toDelete", e.currentTarget.checked)} 492 /> 493 </div> 494 <div> 495 <label for={"record" + index()} class="flex flex-col"> 496 <Show when={record.handle.length}> 497 <span class="flex items-center gap-x-1"> 498 @{record.handle} 499 <a 500 href={`https://bsky.app/profile/${record.did}`} 501 target="_blank" 502 class="group/tooltip relative flex items-center" 503 > 504 <button class="i-tabler-external-link text-sm text-blue-500 dark:text-blue-400" /> 505 <span class="left-50% dark:bg-dark-600 pointer-events-none absolute top-5 z-10 hidden w-[14ch] -translate-x-1/2 rounded border border-neutral-500 bg-slate-200 p-1 text-center text-xs group-hover/tooltip:block"> 506 Bluesky profile 507 </span> 508 </a> 509 </span> 510 </Show> 511 <span class="flex items-center gap-x-1"> 512 {record.did} 513 <a 514 href={ 515 record.did.startsWith("did:plc:") ? 516 `https://web.plc.directory/did/${record.did}` 517 : `https://${record.did.replace("did:web:", "")}/.well-known/did.json` 518 } 519 target="_blank" 520 class="group/tooltip relative flex items-center" 521 > 522 <button class="i-tabler-external-link text-sm text-blue-500 dark:text-blue-400" /> 523 <span class="left-50% dark:bg-dark-600 pointer-events-none absolute top-5 z-10 hidden w-[14ch] -translate-x-1/2 rounded border border-neutral-500 bg-slate-200 p-1 text-center text-xs group-hover/tooltip:block"> 524 DID document 525 </span> 526 </a> 527 </span> 528 <span>{record.status_label}</span> 529 </label> 530 </div> 531 </div> 532 </Show> 533 )} 534 </For> 535 </div> 536 </div> 537 ); 538}; 539 540const App = () => { 541 const [theme, setTheme] = createSignal( 542 ( 543 localStorage.theme === "dark" || 544 (!("theme" in localStorage) && 545 globalThis.matchMedia("(prefers-color-scheme: dark)").matches) 546 ) ? 547 "dark" 548 : "light", 549 ); 550 551 return ( 552 <div class="m-5 flex flex-col items-center text-slate-900 dark:text-slate-100"> 553 <div class="mb-2 flex w-[20rem] items-center"> 554 <div class="basis-1/3"> 555 <div 556 class="w-fit cursor-pointer" 557 title="Theme" 558 onclick={() => { 559 setTheme(theme() === "light" ? "dark" : "light"); 560 if (theme() === "dark") document.documentElement.classList.add("dark"); 561 else document.documentElement.classList.remove("dark"); 562 localStorage.theme = theme(); 563 }} 564 > 565 {theme() === "dark" ? 566 <div class="i-tabler-moon-stars text-xl" /> 567 : <div class="i-tabler-sun text-xl" />} 568 </div> 569 </div> 570 <div class="basis-1/3 text-center text-xl font-bold"> 571 <a href="" class="hover:underline"> 572 cleanfollow 573 </a> 574 </div> 575 <div class="justify-right flex basis-1/3 gap-x-2"> 576 <a title="GitHub" href="https://github.com/notjuliet/cleanfollow-bsky" target="_blank"> 577 <button class="i-bi-github text-xl" /> 578 </a> 579 <a title="Donate" href="https://ko-fi.com/notjuliet" target="_blank"> 580 <button class="i-simple-icons-kofi text-xl" /> 581 </a> 582 </div> 583 </div> 584 <div class="mb-2 text-center"> 585 <p>Select inactive or blocked accounts to unfollow</p> 586 </div> 587 <Login /> 588 <Show when={loginState()}> 589 <Fetch /> 590 <Show when={followRecords.length}> 591 <Follows /> 592 </Show> 593 </Show> 594 </div> 595 ); 596}; 597 598export default App;