Unfollow tool for Bluesky
1import { 2 type Component, 3 createEffect, 4 createSignal, 5 For, 6 onMount, 7 Show, 8} from "solid-js"; 9import { createStore } from "solid-js/store"; 10 11import { XRPC } from "@atcute/client"; 12import { 13 AppBskyGraphFollow, 14 At, 15 Brand, 16 ComAtprotoRepoApplyWrites, 17} from "@atcute/client/lexicons"; 18import { 19 configureOAuth, 20 createAuthorizationUrl, 21 finalizeAuthorization, 22 getSession, 23 OAuthUserAgent, 24 resolveFromIdentity, 25 type Session, 26} from "@atcute/oauth-browser-client"; 27 28configureOAuth({ 29 metadata: { 30 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 31 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 32 }, 33}); 34 35enum RepoStatus { 36 BLOCKEDBY = 1 << 0, 37 BLOCKING = 1 << 1, 38 DELETED = 1 << 2, 39 DEACTIVATED = 1 << 3, 40 SUSPENDED = 1 << 4, 41 YOURSELF = 1 << 5, 42 NONMUTUAL = 1 << 6, 43} 44 45type FollowRecord = { 46 did: string; 47 handle: string; 48 uri: string; 49 status: RepoStatus; 50 status_label: string; 51 toDelete: boolean; 52 visible: boolean; 53}; 54 55const [followRecords, setFollowRecords] = createStore<FollowRecord[]>([]); 56const [loginState, setLoginState] = createSignal(false); 57let rpc: XRPC; 58let agent: OAuthUserAgent; 59 60const resolveDid = async (did: string) => { 61 const res = await fetch( 62 did.startsWith("did:web") ? 63 `https://${did.split(":")[2]}/.well-known/did.json` 64 : "https://plc.directory/" + did, 65 ); 66 67 return res.json().then((doc) => { 68 for (const alias of doc.alsoKnownAs) { 69 if (alias.includes("at://")) { 70 return alias.split("//")[1]; 71 } 72 } 73 }); 74}; 75 76const Login: Component = () => { 77 const [loginInput, setLoginInput] = createSignal(""); 78 const [handle, setHandle] = createSignal(""); 79 const [notice, setNotice] = createSignal(""); 80 81 onMount(async () => { 82 setNotice("Loading..."); 83 84 const init = async (): Promise<Session | undefined> => { 85 const params = new URLSearchParams(location.hash.slice(1)); 86 87 if (params.has("state") && (params.has("code") || params.has("error"))) { 88 history.replaceState(null, "", location.pathname + location.search); 89 90 const session = await finalizeAuthorization(params); 91 const did = session.info.sub; 92 93 localStorage.setItem("lastSignedIn", did); 94 return session; 95 } else { 96 const lastSignedIn = localStorage.getItem("lastSignedIn"); 97 98 if (lastSignedIn) { 99 try { 100 return await getSession(lastSignedIn as At.DID); 101 } catch (err) { 102 localStorage.removeItem("lastSignedIn"); 103 throw err; 104 } 105 } 106 } 107 }; 108 109 const session = await init().catch(() => {}); 110 111 if (session) { 112 agent = new OAuthUserAgent(session); 113 rpc = new XRPC({ handler: agent }); 114 115 setLoginState(true); 116 setHandle(await resolveDid(agent.sub)); 117 } 118 119 setNotice(""); 120 }); 121 122 const loginBsky = async (handle: string) => { 123 try { 124 setNotice(`Resolving your identity...`); 125 const resolved = await resolveFromIdentity(handle); 126 127 setNotice(`Contacting your data server...`); 128 const authUrl = await createAuthorizationUrl({ 129 scope: import.meta.env.VITE_OAUTH_SCOPE, 130 ...resolved, 131 }); 132 133 setNotice(`Redirecting...`); 134 await new Promise((resolve) => setTimeout(resolve, 250)); 135 136 location.assign(authUrl); 137 } catch (err) { 138 setNotice("Error during OAuth login"); 139 } 140 }; 141 142 const logoutBsky = async () => { 143 await agent.signOut(); 144 }; 145 146 return ( 147 <div class="flex flex-col items-center"> 148 <Show when={!loginState() && !notice().includes("Loading")}> 149 <form 150 class="flex flex-col items-center" 151 onsubmit={(e) => e.preventDefault()} 152 > 153 <label for="handle">Handle:</label> 154 <input 155 type="text" 156 id="handle" 157 placeholder="user.bsky.social" 158 class="mb-3 mt-1 rounded-md border border-black px-2 py-1" 159 onInput={(e) => setLoginInput(e.currentTarget.value)} 160 /> 161 <button 162 onclick={() => loginBsky(loginInput())} 163 class="rounded bg-blue-500 px-2 py-2 font-bold text-white hover:bg-blue-700" 164 > 165 Login 166 </button> 167 </form> 168 </Show> 169 <Show when={loginState() && handle()}> 170 <div class="mb-4"> 171 Logged in as @{handle()} ( 172 <a href="" class="text-red-600" onclick={() => logoutBsky()}> 173 Logout 174 </a> 175 ) 176 </div> 177 </Show> 178 <Show when={notice()}> 179 <div class="m-3">{notice()}</div> 180 </Show> 181 </div> 182 ); 183}; 184 185const Fetch: Component = () => { 186 const [progress, setProgress] = createSignal(0); 187 const [followCount, setFollowCount] = createSignal(0); 188 const [notice, setNotice] = createSignal(""); 189 190 const fetchHiddenAccounts = async () => { 191 const fetchFollows = async () => { 192 const PAGE_LIMIT = 100; 193 const fetchPage = async (cursor?: string) => { 194 return await rpc.get("com.atproto.repo.listRecords", { 195 params: { 196 repo: agent.sub, 197 collection: "app.bsky.graph.follow", 198 limit: PAGE_LIMIT, 199 cursor: cursor, 200 }, 201 }); 202 }; 203 204 let res = await fetchPage(); 205 let follows = res.data.records; 206 207 while (res.data.cursor && res.data.records.length >= PAGE_LIMIT) { 208 res = await fetchPage(res.data.cursor); 209 follows = follows.concat(res.data.records); 210 } 211 212 return follows; 213 }; 214 215 setProgress(0); 216 setNotice(""); 217 218 const follows = await fetchFollows(); 219 setFollowCount(follows.length); 220 const tmpFollows: FollowRecord[] = []; 221 222 follows.forEach(async (record) => { 223 let status: RepoStatus | undefined = undefined; 224 const follow = record.value as AppBskyGraphFollow.Record; 225 let handle = ""; 226 227 try { 228 const res = await rpc.get("app.bsky.actor.getProfile", { 229 params: { actor: follow.subject }, 230 }); 231 232 handle = res.data.handle; 233 const viewer = res.data.viewer!; 234 235 if (!viewer.followedBy) status = RepoStatus.NONMUTUAL; 236 237 if (viewer.blockedBy) { 238 status = 239 viewer.blocking || viewer.blockingByList ? 240 RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING 241 : RepoStatus.BLOCKEDBY; 242 } else if (res.data.did.includes(agent.sub)) { 243 status = RepoStatus.YOURSELF; 244 } else if (viewer.blocking || viewer.blockingByList) { 245 status = RepoStatus.BLOCKING; 246 } 247 } catch (e: any) { 248 handle = await resolveDid(follow.subject); 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 tmpFollows.push({ 270 did: follow.subject, 271 handle: handle, 272 uri: record.uri, 273 status: status, 274 status_label: status_label, 275 toDelete: false, 276 visible: status != RepoStatus.NONMUTUAL, 277 }); 278 } 279 setProgress(progress() + 1); 280 if (progress() == followCount()) setFollowRecords(tmpFollows); 281 }); 282 }; 283 284 const unfollow = async () => { 285 const writes = followRecords 286 .filter((record) => record.toDelete) 287 .map((record): Brand.Union<ComAtprotoRepoApplyWrites.Delete> => { 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 rpc.call("com.atproto.repo.applyWrites", { 298 data: { 299 repo: agent.sub, 300 writes: writes.slice(i, i + BATCHSIZE), 301 }, 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={!followRecords.length}> 316 <button 317 type="button" 318 onclick={() => fetchHiddenAccounts()} 319 class="rounded bg-blue-500 px-2 py-2 font-bold text-white hover:bg-blue-700" 320 > 321 Preview 322 </button> 323 </Show> 324 <Show when={followRecords.length}> 325 <button 326 type="button" 327 onclick={() => unfollow()} 328 class="rounded bg-green-600 px-2 py-2 font-bold text-white hover:bg-green-700" 329 > 330 Confirm 331 </button> 332 </Show> 333 <Show when={notice()}> 334 <div class="m-3">{notice()}</div> 335 </Show> 336 <Show when={followCount() && progress() != followCount()}> 337 <div class="m-3"> 338 Progress: {progress()}/{followCount()} 339 </div> 340 </Show> 341 </div> 342 ); 343}; 344 345const Follows: Component = () => { 346 const [selectedCount, setSelectedCount] = createSignal(0); 347 348 createEffect(() => { 349 setSelectedCount(followRecords.filter((record) => record.toDelete).length); 350 }); 351 352 function editRecords( 353 status: RepoStatus, 354 field: keyof FollowRecord, 355 value: boolean, 356 ) { 357 const range = followRecords 358 .map((record, index) => { 359 if (record.status & status) return index; 360 }) 361 .filter((i) => i !== undefined); 362 setFollowRecords(range, field, value); 363 } 364 365 const options: { status: RepoStatus; label: string }[] = [ 366 { status: RepoStatus.DELETED, label: "Deleted" }, 367 { status: RepoStatus.DEACTIVATED, label: "Deactivated" }, 368 { status: RepoStatus.SUSPENDED, label: "Suspended" }, 369 { status: RepoStatus.BLOCKEDBY, label: "Blocked By" }, 370 { status: RepoStatus.BLOCKING, label: "Blocking" }, 371 { status: RepoStatus.NONMUTUAL, label: "Non Mutual" }, 372 ]; 373 374 return ( 375 <div class="mt-6 flex flex-col sm:w-full sm:flex-row sm:justify-center"> 376 <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"> 377 <For each={options}> 378 {(option, index) => ( 379 <div 380 classList={{ 381 "sm:pb-2 min-w-36 sm:mb-2 mt-3 sm:mt-0": true, 382 "sm:border-b sm:border-b-gray-300": 383 index() < options.length - 1, 384 }} 385 > 386 <div> 387 <label class="mb-2 mt-1 inline-flex cursor-pointer items-center"> 388 <input 389 type="checkbox" 390 class="peer sr-only" 391 checked={option.status != RepoStatus.NONMUTUAL} 392 onChange={(e) => 393 editRecords( 394 option.status, 395 "visible", 396 e.currentTarget.checked, 397 ) 398 } 399 /> 400 <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 rtl:peer-checked:after:-translate-x-full dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"></span> 401 <span class="ms-3 select-none dark:text-gray-300"> 402 {option.label} 403 </span> 404 </label> 405 </div> 406 <div class="flex items-center"> 407 <input 408 type="checkbox" 409 id={option.label} 410 class="h-4 w-4 rounded" 411 onChange={(e) => 412 editRecords( 413 option.status, 414 "toDelete", 415 e.currentTarget.checked, 416 ) 417 } 418 /> 419 <label for={option.label} class="ml-2 select-none"> 420 Select All 421 </label> 422 </div> 423 </div> 424 )} 425 </For> 426 <div class="min-w-36 pt-3 sm:pt-0"> 427 <span> 428 Selected: {selectedCount()}/{followRecords.length} 429 </span> 430 </div> 431 </div> 432 <div class="sm:min-w-96"> 433 <For each={followRecords}> 434 {(record, index) => ( 435 <Show when={record.visible}> 436 <div 437 classList={{ 438 "mb-1 flex items-center border-b py-1": true, 439 "bg-red-400": record.toDelete, 440 }} 441 > 442 <div class="mx-2"> 443 <input 444 type="checkbox" 445 id={"record" + index()} 446 class="h-4 w-4 rounded" 447 checked={record.toDelete} 448 onChange={(e) => 449 setFollowRecords( 450 index(), 451 "toDelete", 452 e.currentTarget.checked, 453 ) 454 } 455 /> 456 </div> 457 <div> 458 <label for={"record" + index()} class="flex flex-col"> 459 <span>@{record.handle}</span> 460 <span>{record.did}</span> 461 <span>{record.status_label}</span> 462 </label> 463 </div> 464 </div> 465 </Show> 466 )} 467 </For> 468 </div> 469 </div> 470 ); 471}; 472 473const App: Component = () => { 474 return ( 475 <div class="m-5 flex flex-col items-center"> 476 <h1 class="mb-3 text-xl font-bold">cleanfollow-bsky</h1> 477 <div class="mb-3 text-center"> 478 <p>Unfollow blocked, deleted, suspended, and deactivated accounts</p> 479 <p>By default, every account will be unselected</p> 480 <div> 481 <a 482 class="text-blue-600 hover:underline" 483 href="https://github.com/notjuliet/cleanfollow-bsky" 484 > 485 Source Code 486 </a> 487 <span> | </span> 488 <a 489 class="text-blue-600 hover:underline" 490 href="https://bsky.app/profile/did:plc:b3pn34agqqchkaf75v7h43dk" 491 > 492 Bluesky 493 </a> 494 <span> | </span> 495 <a 496 class="text-blue-600 hover:underline" 497 href="https://mary-ext.codeberg.page/bluesky-quiet-posters/" 498 > 499 Quiet Posters 500 </a> 501 </div> 502 </div> 503 <Login /> 504 <Show when={loginState()}> 505 <Fetch /> 506 <Show when={followRecords.length}> 507 <Follows /> 508 </Show> 509 </Show> 510 </div> 511 ); 512}; 513 514export default App;