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://boletus.us-west.host.bsky.network", 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 if (status !== undefined) { 269 setFollowRecords(followRecords.length, { 270 did: record.value.subject, 271 handle: handle, 272 uri: record.uri, 273 status: status, 274 toBeDeleted: false, 275 }); 276 } 277 } 278 setProgress(progress() + 1); 279 }), 280 ); 281 }; 282 283 const unfollow = async () => { 284 const writes = followRecords 285 .filter((record) => record.toBeDeleted) 286 .map((record) => { 287 return { 288 $type: "com.atproto.repo.applyWrites#delete", 289 collection: "app.bsky.graph.follow", 290 rkey: record.uri.split("/").pop(), 291 }; 292 }); 293 294 const BATCHSIZE = 200; 295 for (let i = 0; i < writes.length; i += BATCHSIZE) { 296 await appAgent.com.atproto.repo.applyWrites({ 297 repo: appAgent.did!, 298 writes: writes.slice(i, i + BATCHSIZE), 299 }); 300 } 301 302 setFollowRecords([]); 303 setProgress(0); 304 setFollowCount(0); 305 setNotice(`Unfollowed ${writes.length} accounts`); 306 }; 307 308 return ( 309 <div class="flex flex-col items-center"> 310 <div class="flex flex-col items-center"> 311 <Show when={!loginState()}> 312 <label for="handle">Handle:</label> 313 <input 314 type="text" 315 id="handle" 316 placeholder="user.bsky.social" 317 class="rounded-md mt-1 py-1 pl-2 pr-2 mb-3 ring-1 ring-inset ring-gray-300" 318 onInput={(e) => setLoginInput(e.currentTarget.value)} 319 /> 320 <button 321 type="button" 322 onclick={() => loginBsky(loginInput())} 323 class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" 324 > 325 Login 326 </button> 327 </Show> 328 <Show when={loginState()}> 329 <div class="mb-5"> 330 Logged in as {userHandle} ( 331 <a href="" class="text-red-600" onclick={() => logoutBsky()}> 332 Logout 333 </a> 334 ) 335 </div> 336 <Show when={!followRecords.length}> 337 <button 338 type="button" 339 onclick={() => fetchHiddenAccounts()} 340 class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" 341 > 342 Preview 343 </button> 344 </Show> 345 <Show when={followRecords.length}> 346 <button 347 type="button" 348 onclick={() => unfollow()} 349 class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded" 350 > 351 Confirm 352 </button> 353 </Show> 354 </Show> 355 </div> 356 <Show when={notice()}> 357 <div class="m-3">{notice()}</div> 358 </Show> 359 <Show when={loginState() && followCount()}> 360 <div class="m-3"> 361 Progress: {progress()}/{followCount()} 362 </div> 363 </Show> 364 </div> 365 ); 366}; 367 368const App: Component = () => { 369 return ( 370 <div class="flex flex-col items-center m-5"> 371 <h1 class="text-2xl mb-5">cleanfollow-bsky</h1> 372 <div class="mb-3 text-center"> 373 <p> 374 Unfollows blocked by, deleted, suspended, and deactivated accounts 375 </p> 376 <div> 377 <a 378 class="text-blue-600 hover:underline" 379 href="https://github.com/notjuliet/cleanfollow-bsky" 380 > 381 Source Code 382 </a> 383 <span> | </span> 384 <a 385 class="text-blue-600 hover:underline" 386 href="https://bsky.app/profile/adorable.mom" 387 > 388 Bluesky 389 </a> 390 <span> | </span> 391 <a 392 class="text-blue-600 hover:underline" 393 href="https://mary-ext.codeberg.page/bluesky-quiet-posters/" 394 > 395 Quiet Posters 396 </a> 397 </div> 398 </div> 399 <Form /> 400 <Show when={loginState()}> 401 <Follows /> 402 </Show> 403 </div> 404 ); 405}; 406 407export default App;