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