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