Unfollow tool for Bluesky
at main 20 kB view raw
1import { createEffect, createSignal, For, onMount, Show } from "solid-js"; 2import { createStore } from "solid-js/store"; 3 4import { ComAtprotoRepoApplyWrites } from "@atcute/atproto"; 5import { AppBskyGraphFollow } from "@atcute/bluesky"; 6import { Client, CredentialManager } from "@atcute/client"; 7import { $type, ActorIdentifier, Did, Handle } from "@atcute/lexicons"; 8import { 9 configureOAuth, 10 createAuthorizationUrl, 11 finalizeAuthorization, 12 getSession, 13 OAuthUserAgent, 14 resolveFromIdentity, 15 type Session, 16} from "@atcute/oauth-browser-client"; 17 18configureOAuth({ 19 metadata: { 20 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 21 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, 22 }, 23}); 24 25enum RepoStatus { 26 BLOCKEDBY = 1 << 0, 27 BLOCKING = 1 << 1, 28 DELETED = 1 << 2, 29 DEACTIVATED = 1 << 3, 30 SUSPENDED = 1 << 4, 31 HIDDEN = 1 << 5, 32 YOURSELF = 1 << 6, 33} 34 35type FollowRecord = { 36 did: string; 37 handle: string; 38 uri: string; 39 status: RepoStatus; 40 status_label: string; 41 toDelete: boolean; 42 visible: boolean; 43}; 44 45const [followRecords, setFollowRecords] = createStore<FollowRecord[]>([]); 46const [loginState, setLoginState] = createSignal(false); 47let rpc: Client; 48let agent: OAuthUserAgent; 49let manager: CredentialManager; 50let agentDID: string; 51 52const resolveDid = async (did: string) => { 53 const res = await fetch( 54 did.startsWith("did:web") ? 55 `https://${did.split(":")[2]}/.well-known/did.json` 56 : "https://plc.directory/" + did, 57 ).catch((error: unknown) => { 58 console.warn("Failed to resolve DID", { error, did }); 59 }); 60 if (!res) return ""; 61 62 return res 63 .json() 64 .then((doc) => { 65 for (const alias of doc.alsoKnownAs) { 66 if (alias.includes("at://")) { 67 return alias.split("//")[1]; 68 } 69 } 70 }) 71 .catch((error: unknown) => { 72 console.warn("Failed to parse DID", { error, did }); 73 return ""; 74 }); 75}; 76 77const Login = () => { 78 const [loginInput, setLoginInput] = createSignal(""); 79 const [password, setPassword] = createSignal(""); 80 const [handle, setHandle] = createSignal(""); 81 const [notice, setNotice] = createSignal(""); 82 83 onMount(async () => { 84 setNotice("Loading..."); 85 86 const init = async (): Promise<Session | undefined> => { 87 const params = new URLSearchParams(location.hash.slice(1)); 88 89 if (params.has("state") && (params.has("code") || params.has("error"))) { 90 history.replaceState(null, "", location.pathname + location.search); 91 92 const session = await finalizeAuthorization(params); 93 const did = session.info.sub; 94 95 localStorage.setItem("lastSignedIn", did); 96 return session; 97 } else { 98 const lastSignedIn = localStorage.getItem("lastSignedIn"); 99 100 if (lastSignedIn) { 101 try { 102 return await getSession(lastSignedIn as Did); 103 } catch (err) { 104 localStorage.removeItem("lastSignedIn"); 105 throw err; 106 } 107 } 108 } 109 }; 110 111 const session = await init().catch(() => {}); 112 113 if (session) { 114 agent = new OAuthUserAgent(session); 115 rpc = new Client({ handler: agent }); 116 agentDID = agent.sub; 117 118 setLoginState(true); 119 setHandle(await resolveDid(agent.sub)); 120 } 121 122 setNotice(""); 123 }); 124 125 const getPDS = async (did: string) => { 126 const res = await fetch( 127 did.startsWith("did:web") ? 128 `https://${did.split(":")[2]}/.well-known/did.json` 129 : "https://plc.directory/" + did, 130 ); 131 132 return res.json().then((doc: any) => { 133 for (const service of doc.service) { 134 if (service.id === "#atproto_pds") return service.serviceEndpoint; 135 } 136 }); 137 }; 138 139 const resolveHandle = async (handle: string) => { 140 const rpc = new Client({ 141 handler: new CredentialManager({ 142 service: "https://public.api.bsky.app", 143 }), 144 }); 145 const res = await rpc.get("com.atproto.identity.resolveHandle", { 146 params: { handle: handle as Handle }, 147 }); 148 if (!res.ok) throw new Error(res.data.error); 149 return res.data.did; 150 }; 151 152 const loginBsky = async (login: string) => { 153 if (password()) { 154 agentDID = login.startsWith("did:") ? login : await resolveHandle(login); 155 manager = new CredentialManager({ service: await getPDS(agentDID) }); 156 rpc = new Client({ handler: manager }); 157 158 await manager.login({ 159 identifier: agentDID, 160 password: password(), 161 }); 162 setLoginState(true); 163 } else { 164 try { 165 setNotice(`Resolving your identity...`); 166 const resolved = await resolveFromIdentity(login); 167 168 setNotice(`Contacting your data server...`); 169 const authUrl = await createAuthorizationUrl({ 170 scope: import.meta.env.VITE_OAUTH_SCOPE, 171 ...resolved, 172 }); 173 174 setNotice(`Redirecting...`); 175 await new Promise((resolve) => setTimeout(resolve, 250)); 176 177 location.assign(authUrl); 178 } catch { 179 setNotice("Error during OAuth login"); 180 } 181 } 182 }; 183 184 const logoutBsky = async () => { 185 await agent.signOut(); 186 setLoginState(false); 187 }; 188 189 return ( 190 <div class="flex flex-col items-center"> 191 <Show when={!loginState() && !notice().includes("Loading")}> 192 <form class="flex flex-col" onsubmit={(e) => e.preventDefault()}> 193 <label for="handle" class="ml-0.5"> 194 Handle 195 </label> 196 <input 197 type="text" 198 id="handle" 199 placeholder="user.bsky.social" 200 class="dark:bg-dark-100 mb-2 rounded-lg border border-gray-400 px-2 py-1 focus:ring-1 focus:ring-gray-300 focus:outline-none" 201 onInput={(e) => setLoginInput(e.currentTarget.value)} 202 /> 203 <label for="password" class="ml-0.5"> 204 App Password 205 </label> 206 <input 207 type="password" 208 id="password" 209 placeholder="leave empty for oauth" 210 class="dark:bg-dark-100 mb-2 rounded-lg border border-gray-400 px-2 py-1 focus:ring-1 focus:ring-gray-300 focus:outline-none" 211 onInput={(e) => setPassword(e.currentTarget.value)} 212 /> 213 <button 214 onclick={() => loginBsky(loginInput())} 215 class="rounded bg-blue-600 py-1.5 font-bold text-slate-100 hover:bg-blue-700" 216 > 217 Login 218 </button> 219 </form> 220 </Show> 221 <Show when={loginState() && handle()}> 222 <div class="mb-4"> 223 Logged in as @{handle()} 224 <button 225 class="ml-2 bg-transparent text-red-500 dark:text-red-400" 226 onclick={() => logoutBsky()} 227 > 228 Logout 229 </button> 230 </div> 231 </Show> 232 <Show when={notice()}> 233 <div class="m-3">{notice()}</div> 234 </Show> 235 </div> 236 ); 237}; 238 239const Fetch = () => { 240 const [progress, setProgress] = createSignal(0); 241 const [followCount, setFollowCount] = createSignal(0); 242 const [notice, setNotice] = createSignal(""); 243 244 const fetchHiddenAccounts = async () => { 245 const fetchFollows = async () => { 246 const PAGE_LIMIT = 100; 247 const fetchPage = async (cursor?: string) => { 248 return await rpc.get("com.atproto.repo.listRecords", { 249 params: { 250 repo: agentDID as ActorIdentifier, 251 collection: "app.bsky.graph.follow", 252 limit: PAGE_LIMIT, 253 cursor: cursor, 254 }, 255 }); 256 }; 257 258 let res = await fetchPage(); 259 if (!res.ok) throw new Error(res.data.error); 260 let follows = res.data.records; 261 setNotice(`Fetching follows: ${follows.length}`); 262 263 while (res.data.cursor && res.data.records.length >= PAGE_LIMIT) { 264 setNotice(`Fetching follows: ${follows.length}`); 265 res = await fetchPage(res.data.cursor); 266 if (!res.ok) throw new Error(res.data.error); 267 follows = follows.concat(res.data.records); 268 } 269 270 return follows; 271 }; 272 273 setProgress(0); 274 const follows = await fetchFollows(); 275 setFollowCount(follows.length); 276 const tmpFollows: FollowRecord[] = []; 277 setNotice(""); 278 279 const timer = (ms: number) => new Promise((res) => setTimeout(res, ms)); 280 for (let i = 0; i < follows.length; i = i + 10) { 281 if (follows.length > 1500) await timer(1000); 282 follows.slice(i, i + 10).forEach(async (record) => { 283 let status: RepoStatus | undefined = undefined; 284 const follow = record.value as AppBskyGraphFollow.Main; 285 let handle = ""; 286 287 const res = await rpc.get("app.bsky.actor.getProfile", { 288 params: { actor: follow.subject }, 289 }); 290 291 if (!res.ok) { 292 handle = await resolveDid(follow.subject); 293 const e = res.data as any; 294 295 status = 296 e.message.includes("not found") ? RepoStatus.DELETED 297 : e.message.includes("deactivated") ? RepoStatus.DEACTIVATED 298 : e.message.includes("suspended") ? RepoStatus.SUSPENDED 299 : undefined; 300 } else { 301 handle = res.data.handle; 302 const viewer = res.data.viewer!; 303 304 if (res.data.labels?.some((label) => label.val === "!hide")) { 305 status = RepoStatus.HIDDEN; 306 } else if (viewer.blockedBy) { 307 status = 308 viewer.blocking || viewer.blockingByList ? 309 RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING 310 : RepoStatus.BLOCKEDBY; 311 } else if (res.data.did.includes(agentDID)) { 312 status = RepoStatus.YOURSELF; 313 } else if (viewer.blocking || viewer.blockingByList) { 314 status = RepoStatus.BLOCKING; 315 } 316 } 317 318 const status_label = 319 status == RepoStatus.DELETED ? "Deleted" 320 : status == RepoStatus.DEACTIVATED ? "Deactivated" 321 : status == RepoStatus.SUSPENDED ? "Suspended" 322 : status == RepoStatus.YOURSELF ? "Literally Yourself" 323 : status == RepoStatus.BLOCKING ? "Blocking" 324 : status == RepoStatus.BLOCKEDBY ? "Blocked by" 325 : status == RepoStatus.HIDDEN ? "Hidden by moderation service" 326 : RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING ? "Mutual Block" 327 : ""; 328 329 if (status !== undefined) { 330 tmpFollows.push({ 331 did: follow.subject, 332 handle: handle, 333 uri: record.uri, 334 status: status, 335 status_label: status_label, 336 toDelete: false, 337 visible: true, 338 }); 339 } 340 setProgress(progress() + 1); 341 if (progress() === followCount()) { 342 if (tmpFollows.length === 0) setNotice("No accounts to unfollow"); 343 setFollowRecords(tmpFollows); 344 setProgress(0); 345 setFollowCount(0); 346 } 347 }); 348 } 349 }; 350 351 const unfollow = async () => { 352 const writes = followRecords 353 .filter((record) => record.toDelete) 354 .map((record): $type.enforce<ComAtprotoRepoApplyWrites.Delete> => { 355 return { 356 $type: "com.atproto.repo.applyWrites#delete", 357 collection: "app.bsky.graph.follow", 358 rkey: record.uri.split("/").pop()!, 359 }; 360 }); 361 362 const BATCHSIZE = 200; 363 for (let i = 0; i < writes.length; i += BATCHSIZE) { 364 await rpc.post("com.atproto.repo.applyWrites", { 365 input: { 366 repo: agentDID as ActorIdentifier, 367 writes: writes.slice(i, i + BATCHSIZE), 368 }, 369 }); 370 } 371 372 setFollowRecords([]); 373 setNotice(`Unfollowed ${writes.length} account${writes.length > 1 ? "s" : ""}`); 374 }; 375 376 return ( 377 <div class="flex flex-col items-center"> 378 <Show when={followCount() === 0 && !followRecords.length}> 379 <button 380 type="button" 381 onclick={() => fetchHiddenAccounts()} 382 class="rounded bg-blue-600 px-2 py-2 font-bold text-slate-100 hover:bg-blue-700" 383 > 384 Preview 385 </button> 386 </Show> 387 <Show when={followRecords.length}> 388 <button 389 type="button" 390 onclick={() => unfollow()} 391 class="rounded bg-blue-600 px-2 py-2 font-bold text-slate-100 hover:bg-blue-700" 392 > 393 Confirm 394 </button> 395 </Show> 396 <Show when={notice()}> 397 <div class="m-3">{notice()}</div> 398 </Show> 399 <Show when={followCount() && progress() != followCount()}> 400 <div class="m-3"> 401 Progress: {progress()}/{followCount()} 402 </div> 403 </Show> 404 </div> 405 ); 406}; 407 408const Follows = () => { 409 const [selectedCount, setSelectedCount] = createSignal(0); 410 411 createEffect(() => { 412 setSelectedCount(followRecords.filter((record) => record.toDelete).length); 413 }); 414 415 function editRecords(status: RepoStatus, field: keyof FollowRecord, value: boolean) { 416 const range = followRecords 417 .map((record, index) => { 418 if (record.status & status) return index; 419 }) 420 .filter((i) => i !== undefined); 421 setFollowRecords(range, field, value); 422 } 423 424 const options: { status: RepoStatus; label: string }[] = [ 425 { status: RepoStatus.DELETED, label: "Deleted" }, 426 { status: RepoStatus.DEACTIVATED, label: "Deactivated" }, 427 { status: RepoStatus.SUSPENDED, label: "Suspended" }, 428 { status: RepoStatus.BLOCKEDBY, label: "Blocked By" }, 429 { status: RepoStatus.BLOCKING, label: "Blocking" }, 430 { status: RepoStatus.HIDDEN, label: "Hidden" }, 431 ]; 432 433 return ( 434 <div class="mt-6 flex flex-col sm:w-full sm:flex-row sm:justify-center"> 435 <div class="dark:bg-dark-500 sticky top-0 mr-5 mb-3 flex w-full flex-wrap justify-around border-b border-b-gray-400 bg-slate-100 pb-3 sm:top-3 sm:mb-0 sm:w-auto sm:flex-col sm:self-start sm:border-none"> 436 <For each={options}> 437 {(option, index) => ( 438 <div 439 classList={{ 440 "sm:pb-2 min-w-36 sm:mb-2 mt-3 sm:mt-0": true, 441 "sm:border-b sm:border-b-gray-300 dark:sm:border-b-gray-500": 442 index() < options.length - 1, 443 }} 444 > 445 <div> 446 <label class="mt-1 mb-2 inline-flex items-center"> 447 <input 448 type="checkbox" 449 class="peer sr-only" 450 checked 451 onChange={(e) => editRecords(option.status, "visible", e.currentTarget.checked)} 452 /> 453 <span class="peer relative h-5 w-9 rounded-full bg-gray-200 peer-checked:bg-blue-600 peer-focus:ring-4 peer-focus:ring-blue-300 peer-focus:outline-none 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:after:translate-x-full peer-checked:after:border-white rtl:peer-checked:after:-translate-x-full dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"></span> 454 <span class="ms-3 select-none">{option.label}</span> 455 </label> 456 </div> 457 <div class="flex items-center"> 458 <input 459 type="checkbox" 460 id={option.label} 461 class="h-4 w-4 rounded" 462 onChange={(e) => editRecords(option.status, "toDelete", e.currentTarget.checked)} 463 /> 464 <label for={option.label} class="ml-2 select-none"> 465 Select All 466 </label> 467 </div> 468 </div> 469 )} 470 </For> 471 <div class="min-w-36 pt-3 sm:pt-0"> 472 <span> 473 Selected: {selectedCount()}/{followRecords.length} 474 </span> 475 </div> 476 </div> 477 <div class="sm:min-w-96"> 478 <For each={followRecords}> 479 {(record, index) => ( 480 <Show when={record.visible}> 481 <div 482 classList={{ 483 "mb-1 flex items-center border-b border-b-gray-300 dark:border-b-gray-500 py-1": true, 484 "bg-red-300 dark:bg-rose-800": record.toDelete, 485 }} 486 > 487 <div class="mx-2"> 488 <input 489 type="checkbox" 490 id={"record" + index()} 491 class="h-4 w-4 rounded" 492 checked={record.toDelete} 493 onChange={(e) => setFollowRecords(index(), "toDelete", e.currentTarget.checked)} 494 /> 495 </div> 496 <div> 497 <label for={"record" + index()} class="flex flex-col"> 498 <Show when={record.handle.length}> 499 <span class="flex items-center gap-x-1"> 500 @{record.handle} 501 <span class="group/tooltip relative flex items-center"> 502 <a 503 class="icon-[lucide--external-link] text-sm text-blue-500 dark:text-blue-400" 504 href={`https://bsky.app/profile/${record.did}`} 505 target="_blank" 506 ></a> 507 <span class="left-50% dark:bg-dark-600 pointer-events-none absolute top-5 z-10 hidden w-[14ch] -translate-x-1/2 rounded border border-neutral-500 bg-slate-200 p-1 text-center text-xs group-hover/tooltip:block"> 508 Bluesky profile 509 </span> 510 </span> 511 </span> 512 </Show> 513 <span class="flex items-center gap-x-1"> 514 {record.did} 515 <span class="group/tooltip relative flex items-center"> 516 <a 517 class="icon-[lucide--external-link] text-sm text-blue-500 dark:text-blue-400" 518 href={ 519 record.did.startsWith("did:plc:") ? 520 `https://web.plc.directory/did/${record.did}` 521 : `https://${record.did.replace("did:web:", "")}/.well-known/did.json` 522 } 523 target="_blank" 524 ></a> 525 <span class="left-50% dark:bg-dark-600 pointer-events-none absolute top-5 z-10 hidden w-[14ch] -translate-x-1/2 rounded border border-neutral-500 bg-slate-200 p-1 text-center text-xs group-hover/tooltip:block"> 526 DID document 527 </span> 528 </span> 529 </span> 530 <span>{record.status_label}</span> 531 </label> 532 </div> 533 </div> 534 </Show> 535 )} 536 </For> 537 </div> 538 </div> 539 ); 540}; 541 542const App = () => { 543 const [theme, setTheme] = createSignal( 544 ( 545 localStorage.theme === "dark" || 546 (!("theme" in localStorage) && 547 globalThis.matchMedia("(prefers-color-scheme: dark)").matches) 548 ) ? 549 "dark" 550 : "light", 551 ); 552 553 return ( 554 <div class="m-5 flex flex-col items-center text-slate-900 dark:text-slate-100"> 555 <div class="mb-2 flex w-[20rem] items-center"> 556 <div class="basis-1/3"> 557 <div 558 class="flex w-fit items-center" 559 title="Theme" 560 onclick={() => { 561 setTheme(theme() === "light" ? "dark" : "light"); 562 if (theme() === "dark") document.documentElement.classList.add("dark"); 563 else document.documentElement.classList.remove("dark"); 564 localStorage.theme = theme(); 565 }} 566 > 567 {theme() === "dark" ? 568 <div class="icon-[lucide--moon] text-xl" /> 569 : <div class="icon-[lucide--sun] text-xl" />} 570 </div> 571 </div> 572 <div class="basis-1/3 text-center text-xl font-bold"> 573 <a href="" class="hover:underline"> 574 cleanfollow 575 </a> 576 </div> 577 <div class="flex basis-1/3 justify-end gap-x-2"> 578 <a 579 class="flex items-center" 580 title="GitHub" 581 href="https://github.com/notjuliet/cleanfollow-bsky" 582 target="_blank" 583 > 584 <span class="icon-[simple-icons--github] text-xl"></span> 585 </a> 586 </div> 587 </div> 588 <div class="mb-2 text-center"> 589 <p>Select inactive or blocked accounts to unfollow</p> 590 </div> 591 <Login /> 592 <Show when={loginState()}> 593 <Fetch /> 594 <Show when={followRecords.length}> 595 <Follows /> 596 </Show> 597 </Show> 598 </div> 599 ); 600}; 601 602export default App;