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