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