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