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