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 NONMUTUAL = 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; 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 }; 148 149 return ( 150 <div class="flex flex-col items-center"> 151 <Show when={!loginState() && !notice().includes("Loading")}> 152 <form 153 class="flex flex-col items-center" 154 onsubmit={(e) => e.preventDefault()} 155 > 156 <label for="handle">Handle:</label> 157 <input 158 type="text" 159 id="handle" 160 placeholder="user.bsky.social" 161 class="mb-3 mt-1 rounded-md border border-black px-2 py-1" 162 onInput={(e) => setLoginInput(e.currentTarget.value)} 163 /> 164 <button 165 onclick={() => loginBsky(loginInput())} 166 class="rounded bg-blue-500 px-2 py-2 font-bold text-white hover:bg-blue-700" 167 > 168 Login 169 </button> 170 </form> 171 </Show> 172 <Show when={loginState() && handle()}> 173 <div class="mb-4"> 174 Logged in as @{handle()} ( 175 <a href="" class="text-red-600" onclick={() => logoutBsky()}> 176 Logout 177 </a> 178 ) 179 </div> 180 </Show> 181 <Show when={notice()}> 182 <div class="m-3">{notice()}</div> 183 </Show> 184 </div> 185 ); 186}; 187 188const Fetch: Component = () => { 189 const [progress, setProgress] = createSignal(0); 190 const [followCount, setFollowCount] = createSignal(0); 191 const [notice, setNotice] = createSignal(""); 192 193 const fetchHiddenAccounts = async () => { 194 const fetchFollows = async () => { 195 const PAGE_LIMIT = 100; 196 const fetchPage = async (cursor?: string) => { 197 return await rpc.get("com.atproto.repo.listRecords", { 198 params: { 199 repo: agent.sub, 200 collection: "app.bsky.graph.follow", 201 limit: PAGE_LIMIT, 202 cursor: cursor, 203 }, 204 }); 205 }; 206 207 let res = await fetchPage(); 208 let follows = res.data.records; 209 210 while (res.data.cursor && res.data.records.length >= PAGE_LIMIT) { 211 res = await fetchPage(res.data.cursor); 212 follows = follows.concat(res.data.records); 213 } 214 215 return follows; 216 }; 217 218 setProgress(0); 219 setNotice(""); 220 221 const follows = await fetchFollows(); 222 setFollowCount(follows.length); 223 const tmpFollows: FollowRecord[] = []; 224 225 follows.forEach(async (record) => { 226 let status: RepoStatus | undefined = undefined; 227 const follow = record.value as AppBskyGraphFollow.Record; 228 let handle = ""; 229 230 try { 231 const res = await rpc.get("app.bsky.actor.getProfile", { 232 params: { actor: follow.subject }, 233 }); 234 235 handle = res.data.handle; 236 const viewer = res.data.viewer!; 237 238 if (!viewer.followedBy) status = RepoStatus.NONMUTUAL; 239 240 if (viewer.blockedBy) { 241 status = 242 viewer.blocking || viewer.blockingByList ? 243 RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING 244 : RepoStatus.BLOCKEDBY; 245 } else if (res.data.did.includes(agent.sub)) { 246 status = RepoStatus.YOURSELF; 247 } else if (viewer.blocking || viewer.blockingByList) { 248 status = RepoStatus.BLOCKING; 249 } 250 } catch (e: any) { 251 handle = await resolveDid(follow.subject); 252 253 status = 254 e.message.includes("not found") ? RepoStatus.DELETED 255 : e.message.includes("deactivated") ? RepoStatus.DEACTIVATED 256 : e.message.includes("suspended") ? RepoStatus.SUSPENDED 257 : undefined; 258 } 259 260 const status_label = 261 status == RepoStatus.DELETED ? "Deleted" 262 : status == RepoStatus.DEACTIVATED ? "Deactivated" 263 : status == RepoStatus.SUSPENDED ? "Suspended" 264 : status == RepoStatus.NONMUTUAL ? "Non Mutual" 265 : status == RepoStatus.YOURSELF ? "Literally Yourself" 266 : status == RepoStatus.BLOCKING ? "Blocking" 267 : status == RepoStatus.BLOCKEDBY ? "Blocked by" 268 : RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING ? "Mutual Block" 269 : ""; 270 271 if (status !== undefined) { 272 tmpFollows.push({ 273 did: follow.subject, 274 handle: handle, 275 uri: record.uri, 276 status: status, 277 status_label: status_label, 278 toDelete: false, 279 visible: status != RepoStatus.NONMUTUAL, 280 }); 281 } 282 setProgress(progress() + 1); 283 if (progress() == followCount()) setFollowRecords(tmpFollows); 284 }); 285 }; 286 287 const unfollow = async () => { 288 const writes = followRecords 289 .filter((record) => record.toDelete) 290 .map((record): Brand.Union<ComAtprotoRepoApplyWrites.Delete> => { 291 return { 292 $type: "com.atproto.repo.applyWrites#delete", 293 collection: "app.bsky.graph.follow", 294 rkey: record.uri.split("/").pop()!, 295 }; 296 }); 297 298 const BATCHSIZE = 200; 299 for (let i = 0; i < writes.length; i += BATCHSIZE) { 300 await rpc.call("com.atproto.repo.applyWrites", { 301 data: { 302 repo: agent.sub, 303 writes: writes.slice(i, i + BATCHSIZE), 304 }, 305 }); 306 } 307 308 setFollowRecords([]); 309 setProgress(0); 310 setFollowCount(0); 311 setNotice( 312 `Unfollowed ${writes.length} account${writes.length > 1 ? "s" : ""}`, 313 ); 314 }; 315 316 return ( 317 <div class="flex flex-col items-center"> 318 <Show when={!followRecords.length}> 319 <button 320 type="button" 321 onclick={() => fetchHiddenAccounts()} 322 class="rounded bg-blue-500 px-2 py-2 font-bold text-white hover:bg-blue-700" 323 > 324 Preview 325 </button> 326 </Show> 327 <Show when={followRecords.length}> 328 <button 329 type="button" 330 onclick={() => unfollow()} 331 class="rounded bg-green-600 px-2 py-2 font-bold text-white hover:bg-green-700" 332 > 333 Confirm 334 </button> 335 </Show> 336 <Show when={notice()}> 337 <div class="m-3">{notice()}</div> 338 </Show> 339 <Show when={followCount() && progress() != followCount()}> 340 <div class="m-3"> 341 Progress: {progress()}/{followCount()} 342 </div> 343 </Show> 344 </div> 345 ); 346}; 347 348const Follows: Component = () => { 349 const [selectedCount, setSelectedCount] = createSignal(0); 350 351 createEffect(() => { 352 setSelectedCount(followRecords.filter((record) => record.toDelete).length); 353 }); 354 355 function editRecords( 356 status: RepoStatus, 357 field: keyof FollowRecord, 358 value: boolean, 359 ) { 360 const range = followRecords 361 .map((record, index) => { 362 if (record.status & status) return index; 363 }) 364 .filter((i) => i !== undefined); 365 setFollowRecords(range, field, value); 366 } 367 368 const options: { status: RepoStatus; label: string }[] = [ 369 { status: RepoStatus.DELETED, label: "Deleted" }, 370 { status: RepoStatus.DEACTIVATED, label: "Deactivated" }, 371 { status: RepoStatus.SUSPENDED, label: "Suspended" }, 372 { status: RepoStatus.BLOCKEDBY, label: "Blocked By" }, 373 { status: RepoStatus.BLOCKING, label: "Blocking" }, 374 { status: RepoStatus.NONMUTUAL, label: "Non Mutual" }, 375 ]; 376 377 return ( 378 <div class="mt-6 flex flex-col sm:w-full sm:flex-row sm:justify-center"> 379 <div class="sticky top-0 mb-3 mr-5 flex w-full flex-wrap justify-around border-b border-b-gray-400 bg-white pb-3 sm:top-3 sm:mb-0 sm:w-auto sm:flex-col sm:self-start sm:border-none"> 380 <For each={options}> 381 {(option, index) => ( 382 <div 383 classList={{ 384 "sm:pb-2 min-w-36 sm:mb-2 mt-3 sm:mt-0": true, 385 "sm:border-b sm:border-b-gray-300": 386 index() < options.length - 1, 387 }} 388 > 389 <div> 390 <label class="mb-2 mt-1 inline-flex cursor-pointer items-center"> 391 <input 392 type="checkbox" 393 class="peer sr-only" 394 checked={option.status != RepoStatus.NONMUTUAL} 395 onChange={(e) => 396 editRecords( 397 option.status, 398 "visible", 399 e.currentTarget.checked, 400 ) 401 } 402 /> 403 <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> 404 <span class="ms-3 select-none dark:text-gray-300"> 405 {option.label} 406 </span> 407 </label> 408 </div> 409 <div class="flex items-center"> 410 <input 411 type="checkbox" 412 id={option.label} 413 class="h-4 w-4 rounded" 414 onChange={(e) => 415 editRecords( 416 option.status, 417 "toDelete", 418 e.currentTarget.checked, 419 ) 420 } 421 /> 422 <label for={option.label} class="ml-2 select-none"> 423 Select All 424 </label> 425 </div> 426 </div> 427 )} 428 </For> 429 <div class="min-w-36 pt-3 sm:pt-0"> 430 <span> 431 Selected: {selectedCount()}/{followRecords.length} 432 </span> 433 </div> 434 </div> 435 <div class="sm:min-w-96"> 436 <For each={followRecords}> 437 {(record, index) => ( 438 <Show when={record.visible}> 439 <div 440 classList={{ 441 "mb-1 flex items-center border-b py-1": true, 442 "bg-red-400": record.toDelete, 443 }} 444 > 445 <div class="mx-2"> 446 <input 447 type="checkbox" 448 id={"record" + index()} 449 class="h-4 w-4 rounded" 450 checked={record.toDelete} 451 onChange={(e) => 452 setFollowRecords( 453 index(), 454 "toDelete", 455 e.currentTarget.checked, 456 ) 457 } 458 /> 459 </div> 460 <div> 461 <label for={"record" + index()} class="flex flex-col"> 462 <Show when={record.handle.length}> 463 <span>@{record.handle}</span> 464 </Show> 465 <span>{record.did}</span> 466 <span>{record.status_label}</span> 467 </label> 468 </div> 469 </div> 470 </Show> 471 )} 472 </For> 473 </div> 474 </div> 475 ); 476}; 477 478const App: Component = () => { 479 return ( 480 <div class="m-5 flex flex-col items-center"> 481 <h1 class="mb-3 text-xl font-bold">cleanfollow-bsky</h1> 482 <div class="mb-3 text-center"> 483 <p>Unfollow blocked, deleted, suspended, and deactivated accounts</p> 484 <p>By default, every account will be unselected</p> 485 <div> 486 <a 487 class="text-blue-600 hover:underline" 488 href="https://github.com/notjuliet/cleanfollow-bsky" 489 > 490 Source Code 491 </a> 492 <span> | </span> 493 <a 494 class="text-blue-600 hover:underline" 495 href="https://bsky.app/profile/did:plc:b3pn34agqqchkaf75v7h43dk" 496 > 497 Bluesky 498 </a> 499 <span> | </span> 500 <a 501 class="text-blue-600 hover:underline" 502 href="https://mary-ext.codeberg.page/bluesky-quiet-posters/" 503 > 504 Quiet Posters 505 </a> 506 </div> 507 </div> 508 <Login /> 509 <Show when={loginState()}> 510 <Fetch /> 511 <Show when={followRecords.length}> 512 <Follows /> 513 </Show> 514 </Show> 515 </div> 516 ); 517}; 518 519export default App;