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-300 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-300 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-300 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-300 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-300 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-300 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()}> 173 <div>@{record.handle} </div> 174 <div> {record.did} </div> 175 <div> 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 </div> 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 <div class="flex flex-col items-center"> 354 <Show when={!loginState()}> 355 <label for="handle">Handle:</label> 356 <input 357 type="text" 358 id="handle" 359 placeholder="user.bsky.social" 360 class="rounded-md mt-1 py-1 pl-2 pr-2 mb-3 ring-1 ring-inset ring-gray-300" 361 onInput={(e) => setLoginInput(e.currentTarget.value)} 362 /> 363 <button 364 type="button" 365 onclick={() => loginBsky(loginInput())} 366 class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" 367 > 368 Login 369 </button> 370 </Show> 371 <Show when={loginState()}> 372 <div class="mb-5"> 373 Logged in as {userHandle} ( 374 <a href="" class="text-red-600" onclick={() => logoutBsky()}> 375 Logout 376 </a> 377 ) 378 </div> 379 <Show when={!followRecords.length}> 380 <button 381 type="button" 382 onclick={() => fetchHiddenAccounts()} 383 class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" 384 > 385 Preview 386 </button> 387 </Show> 388 <Show when={followRecords.length}> 389 <button 390 type="button" 391 onclick={() => unfollow()} 392 class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded" 393 > 394 Confirm 395 </button> 396 </Show> 397 </Show> 398 </div> 399 <Show when={notice()}> 400 <div class="m-3">{notice()}</div> 401 </Show> 402 <Show when={loginState() && followCount()}> 403 <div class="m-3"> 404 Progress: {progress()}/{followCount()} 405 </div> 406 </Show> 407 </div> 408 ); 409}; 410 411const App: Component = () => { 412 return ( 413 <div class="flex flex-col items-center m-5"> 414 <h1 class="text-2xl mb-5">cleanfollow-bsky</h1> 415 <div class="mb-3 text-center"> 416 <p>Unfollow blocked, deleted, suspended, and deactivated accounts</p> 417 <p>By default, every account will be unselected</p> 418 <div> 419 <a 420 class="text-blue-600 hover:underline" 421 href="https://github.com/notjuliet/cleanfollow-bsky" 422 > 423 Source Code 424 </a> 425 <span> | </span> 426 <a 427 class="text-blue-600 hover:underline" 428 href="https://bsky.app/profile/adorable.mom" 429 > 430 Bluesky 431 </a> 432 <span> | </span> 433 <a 434 class="text-blue-600 hover:underline" 435 href="https://mary-ext.codeberg.page/bluesky-quiet-posters/" 436 > 437 Quiet Posters 438 </a> 439 </div> 440 </div> 441 <Form /> 442 <Show when={loginState()}> 443 <Follows /> 444 </Show> 445 </div> 446 ); 447}; 448 449export default App;