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