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