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