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