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