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