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