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