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