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