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