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