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