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