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