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