Unfollow tool for Bluesky
1import { 2 createSignal, 3 onMount, 4 For, 5 Show, 6 type Component, 7 createEffect, 8} from "solid-js"; 9import { createStore } from "solid-js/store"; 10 11import { 12 BrowserOAuthClient, 13 OAuthSession, 14} from "@atproto/oauth-client-browser"; 15import "@atcute/bluesky/lexicons"; 16import { XRPC } from "@atcute/client"; 17import { 18 AppBskyGraphFollow, 19 ComAtprotoRepoListRecords, 20 ComAtprotoRepoApplyWrites, 21 Brand, 22} from "@atcute/client/lexicons"; 23 24enum RepoStatus { 25 BLOCKEDBY = 1 << 0, 26 BLOCKING = 1 << 1, 27 DELETED = 1 << 2, 28 DEACTIVATED = 1 << 3, 29 SUSPENDED = 1 << 4, 30 YOURSELF = 1 << 5, 31 NONMUTUAL = 1 << 6, 32} 33 34type FollowRecord = { 35 did: string; 36 handle: string; 37 uri: string; 38 status: RepoStatus; 39 status_label: string; 40 toDelete: boolean; 41 visible: boolean; 42}; 43 44const [followRecords, setFollowRecords] = createStore<FollowRecord[]>([]); 45const [loginState, setLoginState] = createSignal(false); 46let rpc: XRPC; 47let session: OAuthSession; 48 49const resolveDid = async (did: string) => { 50 const res = await fetch( 51 did.startsWith("did:web") ? 52 `https://${did.split(":")[2]}/.well-known/did.json` 53 : "https://plc.directory/" + did, 54 ); 55 56 return res.json().then((doc) => { 57 for (const alias of doc.alsoKnownAs) { 58 if (alias.includes("at://")) { 59 return alias.split("//")[1]; 60 } 61 } 62 }); 63}; 64 65const Login: Component = () => { 66 const [loginInput, setLoginInput] = createSignal(""); 67 const [handle, setHandle] = createSignal(""); 68 const [notice, setNotice] = createSignal(""); 69 let client: BrowserOAuthClient; 70 71 onMount(async () => { 72 setNotice("Loading..."); 73 client = await BrowserOAuthClient.load({ 74 clientId: "https://cleanfollow-bsky.pages.dev/client-metadata.json", 75 handleResolver: "https://boletus.us-west.host.bsky.network", 76 }); 77 78 client.addEventListener("deleted", () => { 79 setLoginState(false); 80 }); 81 const result = await client.init().catch(() => {}); 82 83 if (result) { 84 session = result.session; 85 rpc = new XRPC({ 86 handler: { handle: session.fetchHandler.bind(session) }, 87 }); 88 setLoginState(true); 89 setHandle(await resolveDid(session.did)); 90 } 91 setNotice(""); 92 }); 93 94 const loginBsky = async (handle: string) => { 95 setNotice("Redirecting..."); 96 try { 97 await client.signIn(handle, { 98 scope: "atproto transition:generic", 99 signal: new AbortController().signal, 100 }); 101 } catch (err) { 102 setNotice("Error during OAuth redirection"); 103 } 104 }; 105 106 const logoutBsky = async () => { 107 if (session.sub) await client.revoke(session.sub); 108 }; 109 110 return ( 111 <div class="flex flex-col items-center"> 112 <Show when={!loginState() && !notice().includes("Loading")}> 113 <form 114 class="flex flex-col items-center" 115 onsubmit={(e) => e.preventDefault()} 116 > 117 <label for="handle">Handle:</label> 118 <input 119 type="text" 120 id="handle" 121 placeholder="user.bsky.social" 122 class="mb-3 mt-1 rounded-md px-2 py-1" 123 onInput={(e) => setLoginInput(e.currentTarget.value)} 124 /> 125 <button 126 onclick={() => loginBsky(loginInput())} 127 class="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700" 128 > 129 Login 130 </button> 131 </form> 132 </Show> 133 <Show when={loginState() && handle()}> 134 <div class="mb-5"> 135 Logged in as @{handle()} ( 136 <a href="" class="text-red-600" onclick={() => logoutBsky()}> 137 Logout 138 </a> 139 ) 140 </div> 141 </Show> 142 <Show when={notice()}> 143 <div class="m-3">{notice()}</div> 144 </Show> 145 </div> 146 ); 147}; 148 149const Fetch: Component = () => { 150 const [progress, setProgress] = createSignal(0); 151 const [followCount, setFollowCount] = createSignal(0); 152 const [notice, setNotice] = createSignal(""); 153 154 const fetchHiddenAccounts = async () => { 155 const fetchFollows = async () => { 156 const PAGE_LIMIT = 100; 157 const fetchPage = async (cursor?: string) => { 158 return await rpc.get("com.atproto.repo.listRecords", { 159 params: { 160 repo: session.did, 161 collection: "app.bsky.graph.follow", 162 limit: PAGE_LIMIT, 163 cursor: cursor, 164 }, 165 }); 166 }; 167 168 let res = await fetchPage(); 169 let follows = res.data.records; 170 171 while (res.data.cursor && res.data.records.length >= PAGE_LIMIT) { 172 res = await fetchPage(res.data.cursor); 173 follows = follows.concat(res.data.records); 174 } 175 176 return follows; 177 }; 178 179 setProgress(0); 180 setNotice(""); 181 182 const follows = await fetchFollows(); 183 setFollowCount(follows.length); 184 let tmpFollows: FollowRecord[] = []; 185 186 follows.forEach(async (record: ComAtprotoRepoListRecords.Record) => { 187 let status: RepoStatus | undefined = undefined; 188 const follow = record.value as AppBskyGraphFollow.Record; 189 let handle = ""; 190 191 try { 192 const res = await rpc.get("app.bsky.actor.getProfile", { 193 params: { actor: follow.subject }, 194 }); 195 196 handle = res.data.handle; 197 const viewer = res.data.viewer!; 198 199 if (!viewer.followedBy) status = RepoStatus.NONMUTUAL; 200 201 if (viewer.blockedBy) { 202 status = 203 viewer.blocking || viewer.blockingByList ? 204 RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING 205 : RepoStatus.BLOCKEDBY; 206 } else if (res.data.did.includes(session.did)) { 207 status = RepoStatus.YOURSELF; 208 } else if (viewer.blocking || viewer.blockingByList) { 209 status = RepoStatus.BLOCKING; 210 } 211 } catch (e: any) { 212 handle = await resolveDid(follow.subject); 213 214 status = 215 e.message.includes("not found") ? RepoStatus.DELETED 216 : e.message.includes("deactivated") ? RepoStatus.DEACTIVATED 217 : e.message.includes("suspended") ? RepoStatus.SUSPENDED 218 : undefined; 219 } 220 221 const status_label = 222 status == RepoStatus.DELETED ? "Deleted" 223 : status == RepoStatus.DEACTIVATED ? "Deactivated" 224 : status == RepoStatus.SUSPENDED ? "Suspended" 225 : status == RepoStatus.NONMUTUAL ? "Non Mutual" 226 : status == RepoStatus.YOURSELF ? "Literally Yourself" 227 : status == RepoStatus.BLOCKING ? "Blocking" 228 : status == RepoStatus.BLOCKEDBY ? "Blocked by" 229 : RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING ? "Mutual Block" 230 : ""; 231 232 if (status !== undefined) { 233 tmpFollows.push({ 234 did: follow.subject, 235 handle: handle, 236 uri: record.uri, 237 status: status, 238 status_label: status_label, 239 toDelete: false, 240 visible: status == RepoStatus.NONMUTUAL ? false : true, 241 }); 242 } 243 setProgress(progress() + 1); 244 if (progress() == followCount()) setFollowRecords(tmpFollows); 245 }); 246 }; 247 248 const unfollow = async () => { 249 const writes = followRecords 250 .filter((record) => record.toDelete) 251 .map((record): Brand.Union<ComAtprotoRepoApplyWrites.Delete> => { 252 return { 253 $type: "com.atproto.repo.applyWrites#delete", 254 collection: "app.bsky.graph.follow", 255 rkey: record.uri.split("/").pop()!, 256 }; 257 }); 258 259 const BATCHSIZE = 200; 260 for (let i = 0; i < writes.length; i += BATCHSIZE) { 261 await rpc.call("com.atproto.repo.applyWrites", { 262 data: { 263 repo: session.did, 264 writes: writes.slice(i, i + BATCHSIZE), 265 }, 266 }); 267 } 268 269 setFollowRecords([]); 270 setProgress(0); 271 setFollowCount(0); 272 setNotice( 273 `Unfollowed ${writes.length} account${writes.length > 1 ? "s" : ""}`, 274 ); 275 }; 276 277 return ( 278 <div class="flex flex-col items-center"> 279 <Show when={!followRecords.length}> 280 <button 281 type="button" 282 onclick={() => fetchHiddenAccounts()} 283 class="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700" 284 > 285 Preview 286 </button> 287 </Show> 288 <Show when={followRecords.length}> 289 <button 290 type="button" 291 onclick={() => unfollow()} 292 class="rounded bg-green-500 px-4 py-2 font-bold text-white hover:bg-green-700" 293 > 294 Confirm 295 </button> 296 </Show> 297 <Show when={notice()}> 298 <div class="m-3">{notice()}</div> 299 </Show> 300 <Show when={followCount() && progress() != followCount()}> 301 <div class="m-3"> 302 Progress: {progress()}/{followCount()} 303 </div> 304 </Show> 305 </div> 306 ); 307}; 308 309const Follows: Component = () => { 310 const [selectedCount, setSelectedCount] = createSignal(0); 311 312 createEffect(() => { 313 setSelectedCount(followRecords.filter((record) => record.toDelete).length); 314 }); 315 316 function editRecords( 317 status: RepoStatus, 318 field: keyof FollowRecord, 319 value: boolean, 320 ) { 321 const range = followRecords 322 .map((record, index) => { 323 if (record.status & status) return index; 324 }) 325 .filter((i) => i !== undefined); 326 setFollowRecords(range, field, value); 327 } 328 329 const options: { status: RepoStatus; label: string }[] = [ 330 { status: RepoStatus.DELETED, label: "Deleted" }, 331 { status: RepoStatus.DEACTIVATED, label: "Deactivated" }, 332 { status: RepoStatus.SUSPENDED, label: "Suspended" }, 333 { status: RepoStatus.BLOCKEDBY, label: "Blocked By" }, 334 { status: RepoStatus.BLOCKING, label: "Blocking" }, 335 { status: RepoStatus.NONMUTUAL, label: "Non Mutual" }, 336 ]; 337 338 return ( 339 <div class="mt-6 flex flex-col sm:w-full sm:flex-row sm:justify-center"> 340 <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"> 341 <For each={options}> 342 {(option, index) => ( 343 <div 344 classList={{ 345 "sm:pb-2 min-w-36 sm:mb-2 mt-3 sm:mt-0": true, 346 "sm:border-b sm:border-b-gray-300": 347 index() < options.length - 1, 348 }} 349 > 350 <div> 351 <label class="mb-2 mt-1 inline-flex cursor-pointer items-center"> 352 <input 353 type="checkbox" 354 class="peer sr-only" 355 checked={ 356 option.status == RepoStatus.NONMUTUAL ? false : true 357 } 358 onChange={(e) => 359 editRecords( 360 option.status, 361 "visible", 362 e.currentTarget.checked, 363 ) 364 } 365 /> 366 <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 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800 rtl:peer-checked:after:-translate-x-full"></span> 367 <span class="ms-3 select-none dark:text-gray-300"> 368 {option.label} 369 </span> 370 </label> 371 </div> 372 <div class="flex items-center"> 373 <input 374 type="checkbox" 375 id={option.label} 376 class="h-4 w-4 rounded" 377 onChange={(e) => 378 editRecords( 379 option.status, 380 "toDelete", 381 e.currentTarget.checked, 382 ) 383 } 384 /> 385 <label for={option.label} class="ml-2 select-none"> 386 Select All 387 </label> 388 </div> 389 </div> 390 )} 391 </For> 392 <div class="min-w-36 pt-3 sm:pt-0"> 393 <span> 394 Selected: {selectedCount()}/{followRecords.length} 395 </span> 396 </div> 397 </div> 398 <div class="sm:min-w-96"> 399 <For each={followRecords}> 400 {(record, index) => ( 401 <Show when={record.visible}> 402 <div class="mb-2 flex items-center border-b pb-2"> 403 <div class="mr-4"> 404 <input 405 type="checkbox" 406 id={"record" + index()} 407 class="h-4 w-4 rounded" 408 checked={record.toDelete} 409 onChange={(e) => 410 setFollowRecords( 411 index(), 412 "toDelete", 413 e.currentTarget.checked, 414 ) 415 } 416 /> 417 </div> 418 <div classList={{ "bg-red-300": record.toDelete }}> 419 <label for={"record" + index()} class="flex flex-col"> 420 <span>@{record.handle}</span> 421 <span>{record.did}</span> 422 <span>{record.status_label}</span> 423 </label> 424 </div> 425 </div> 426 </Show> 427 )} 428 </For> 429 </div> 430 </div> 431 ); 432}; 433 434const App: Component = () => { 435 return ( 436 <div class="m-5 flex flex-col items-center"> 437 <h1 class="mb-5 text-2xl">cleanfollow-bsky</h1> 438 <div class="mb-3 text-center"> 439 <p>Unfollow blocked, deleted, suspended, and deactivated accounts</p> 440 <p>By default, every account will be unselected</p> 441 <div> 442 <a 443 class="text-blue-600 hover:underline" 444 href="https://github.com/notjuliet/cleanfollow-bsky" 445 > 446 Source Code 447 </a> 448 <span> | </span> 449 <a 450 class="text-blue-600 hover:underline" 451 href="https://bsky.app/profile/adorable.mom" 452 > 453 Bluesky 454 </a> 455 <span> | </span> 456 <a 457 class="text-blue-600 hover:underline" 458 href="https://mary-ext.codeberg.page/bluesky-quiet-posters/" 459 > 460 Quiet Posters 461 </a> 462 </div> 463 </div> 464 <Login /> 465 <Show when={loginState()}> 466 <Fetch /> 467 <Show when={followRecords.length}> 468 <Follows /> 469 </Show> 470 </Show> 471 </div> 472 ); 473}; 474 475export default App;