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