Unfollow tool for Bluesky
1import {
2 type Component,
3 createEffect,
4 createSignal,
5 For,
6 onMount,
7 Show,
8} from "solid-js";
9import { createStore } from "solid-js/store";
10
11import { XRPC } from "@atcute/client";
12import {
13 AppBskyGraphFollow,
14 At,
15 Brand,
16 ComAtprotoRepoApplyWrites,
17} from "@atcute/client/lexicons";
18import {
19 configureOAuth,
20 createAuthorizationUrl,
21 finalizeAuthorization,
22 getSession,
23 OAuthUserAgent,
24 resolveFromIdentity,
25 type Session,
26} from "@atcute/oauth-browser-client";
27
28configureOAuth({
29 metadata: {
30 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
31 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL,
32 },
33});
34
35enum RepoStatus {
36 BLOCKEDBY = 1 << 0,
37 BLOCKING = 1 << 1,
38 DELETED = 1 << 2,
39 DEACTIVATED = 1 << 3,
40 SUSPENDED = 1 << 4,
41 YOURSELF = 1 << 5,
42}
43
44type FollowRecord = {
45 did: string;
46 handle: string;
47 uri: string;
48 status: RepoStatus;
49 status_label: string;
50 toDelete: boolean;
51 visible: boolean;
52};
53
54const [followRecords, setFollowRecords] = createStore<FollowRecord[]>([]);
55const [loginState, setLoginState] = createSignal(false);
56let rpc: XRPC;
57let agent: OAuthUserAgent;
58
59const resolveDid = async (did: string) => {
60 const res = await fetch(
61 did.startsWith("did:web") ?
62 `https://${did.split(":")[2]}/.well-known/did.json`
63 : "https://plc.directory/" + did,
64 );
65
66 return res
67 .json()
68 .then((doc) => {
69 for (const alias of doc.alsoKnownAs) {
70 if (alias.includes("at://")) {
71 return alias.split("//")[1];
72 }
73 }
74 })
75 .catch(() => "");
76};
77
78const Login: Component = () => {
79 const [loginInput, setLoginInput] = createSignal("");
80 const [handle, setHandle] = createSignal("");
81 const [notice, setNotice] = createSignal("");
82
83 onMount(async () => {
84 setNotice("Loading...");
85
86 const init = async (): Promise<Session | undefined> => {
87 const params = new URLSearchParams(location.hash.slice(1));
88
89 if (params.has("state") && (params.has("code") || params.has("error"))) {
90 history.replaceState(null, "", location.pathname + location.search);
91
92 const session = await finalizeAuthorization(params);
93 const did = session.info.sub;
94
95 localStorage.setItem("lastSignedIn", did);
96 return session;
97 } else {
98 const lastSignedIn = localStorage.getItem("lastSignedIn");
99
100 if (lastSignedIn) {
101 try {
102 return await getSession(lastSignedIn as At.DID);
103 } catch (err) {
104 localStorage.removeItem("lastSignedIn");
105 throw err;
106 }
107 }
108 }
109 };
110
111 const session = await init().catch(() => {});
112
113 if (session) {
114 agent = new OAuthUserAgent(session);
115 rpc = new XRPC({ handler: agent });
116
117 setLoginState(true);
118 setHandle(await resolveDid(agent.sub));
119 }
120
121 setNotice("");
122 });
123
124 const loginBsky = async (handle: string) => {
125 try {
126 setNotice(`Resolving your identity...`);
127 const resolved = await resolveFromIdentity(handle);
128
129 setNotice(`Contacting your data server...`);
130 const authUrl = await createAuthorizationUrl({
131 scope: import.meta.env.VITE_OAUTH_SCOPE,
132 ...resolved,
133 });
134
135 setNotice(`Redirecting...`);
136 await new Promise((resolve) => setTimeout(resolve, 250));
137
138 location.assign(authUrl);
139 } catch {
140 setNotice("Error during OAuth login");
141 }
142 };
143
144 const logoutBsky = async () => {
145 await agent.signOut();
146 setLoginState(false);
147 };
148
149 return (
150 <div class="flex flex-col items-center">
151 <Show when={!loginState() && !notice().includes("Loading")}>
152 <form class="flex flex-col" onsubmit={(e) => e.preventDefault()}>
153 <label for="handle" class="ml-0.5">
154 Handle
155 </label>
156 <input
157 type="text"
158 id="handle"
159 placeholder="user.bsky.social"
160 class="dark:bg-dark-100 mb-2 rounded-lg border border-gray-400 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-gray-300"
161 onInput={(e) => setLoginInput(e.currentTarget.value)}
162 />
163 <button
164 onclick={() => loginBsky(loginInput())}
165 class="rounded bg-blue-600 py-1.5 font-bold text-slate-100 hover:bg-blue-700"
166 >
167 Login
168 </button>
169 </form>
170 <div class="mt-3">
171 <p>Remember to use your main password, not an app password.</p>
172 <p>The session is only stored on your browser.</p>
173 <p>Make sure to check the URL you will be authenticated through.</p>
174 <p>
175 (<b>bsky.social</b> unless you are on a selfhosted server)
176 </p>
177 </div>
178 </Show>
179 <Show when={loginState() && handle()}>
180 <div class="mb-4">
181 Logged in as @{handle()}
182 <a
183 href=""
184 class="ml-2 text-red-500 dark:text-red-400"
185 onclick={() => logoutBsky()}
186 >
187 Logout
188 </a>
189 </div>
190 </Show>
191 <Show when={notice()}>
192 <div class="m-3">{notice()}</div>
193 </Show>
194 </div>
195 );
196};
197
198const Fetch: Component = () => {
199 const [progress, setProgress] = createSignal(0);
200 const [followCount, setFollowCount] = createSignal(0);
201 const [notice, setNotice] = createSignal("");
202
203 const fetchHiddenAccounts = async () => {
204 const fetchFollows = async () => {
205 const PAGE_LIMIT = 100;
206 const fetchPage = async (cursor?: string) => {
207 return await rpc.get("com.atproto.repo.listRecords", {
208 params: {
209 repo: agent.sub,
210 collection: "app.bsky.graph.follow",
211 limit: PAGE_LIMIT,
212 cursor: cursor,
213 },
214 });
215 };
216
217 let res = await fetchPage();
218 let follows = res.data.records;
219 setNotice(`Fetching follows: ${follows.length}`);
220
221 while (res.data.cursor && res.data.records.length >= PAGE_LIMIT) {
222 setNotice(`Fetching follows: ${follows.length}`);
223 res = await fetchPage(res.data.cursor);
224 follows = follows.concat(res.data.records);
225 }
226
227 return follows;
228 };
229
230 setProgress(0);
231 const follows = await fetchFollows();
232 setFollowCount(follows.length);
233 const tmpFollows: FollowRecord[] = [];
234 setNotice("");
235
236 const timer = (ms: number) => new Promise((res) => setTimeout(res, ms));
237 for (let i = 0; i < follows.length; i = i + 10) {
238 if (follows.length > 1000) await timer(1000);
239 follows.slice(i, i + 10).forEach(async (record) => {
240 let status: RepoStatus | undefined = undefined;
241 const follow = record.value as AppBskyGraphFollow.Record;
242 let handle = "";
243
244 try {
245 const res = await rpc.get("app.bsky.actor.getProfile", {
246 params: { actor: follow.subject },
247 });
248
249 handle = res.data.handle;
250 const viewer = res.data.viewer!;
251
252 if (viewer.blockedBy) {
253 status =
254 viewer.blocking || viewer.blockingByList ?
255 RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING
256 : RepoStatus.BLOCKEDBY;
257 } else if (res.data.did.includes(agent.sub)) {
258 status = RepoStatus.YOURSELF;
259 } else if (viewer.blocking || viewer.blockingByList) {
260 status = RepoStatus.BLOCKING;
261 }
262 } catch (e: any) {
263 handle = await resolveDid(follow.subject);
264
265 status =
266 e.message.includes("not found") ? RepoStatus.DELETED
267 : e.message.includes("deactivated") ? RepoStatus.DEACTIVATED
268 : e.message.includes("suspended") ? RepoStatus.SUSPENDED
269 : undefined;
270 }
271
272 const status_label =
273 status == RepoStatus.DELETED ? "Deleted"
274 : status == RepoStatus.DEACTIVATED ? "Deactivated"
275 : status == RepoStatus.SUSPENDED ? "Suspended"
276 : status == RepoStatus.YOURSELF ? "Literally Yourself"
277 : status == RepoStatus.BLOCKING ? "Blocking"
278 : status == RepoStatus.BLOCKEDBY ? "Blocked by"
279 : RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING ? "Mutual Block"
280 : "";
281
282 if (status !== undefined) {
283 tmpFollows.push({
284 did: follow.subject,
285 handle: handle,
286 uri: record.uri,
287 status: status,
288 status_label: status_label,
289 toDelete: false,
290 visible: true,
291 });
292 }
293 setProgress(progress() + 1);
294 if (progress() === followCount()) {
295 setFollowRecords(tmpFollows);
296 setProgress(0);
297 setFollowCount(0);
298 }
299 });
300 }
301 if (progress() === followCount() && followRecords.length === 0)
302 setNotice("No accounts to unfollow");
303 };
304
305 const unfollow = async () => {
306 const writes = followRecords
307 .filter((record) => record.toDelete)
308 .map((record): Brand.Union<ComAtprotoRepoApplyWrites.Delete> => {
309 return {
310 $type: "com.atproto.repo.applyWrites#delete",
311 collection: "app.bsky.graph.follow",
312 rkey: record.uri.split("/").pop()!,
313 };
314 });
315
316 const BATCHSIZE = 200;
317 for (let i = 0; i < writes.length; i += BATCHSIZE) {
318 await rpc.call("com.atproto.repo.applyWrites", {
319 data: {
320 repo: agent.sub,
321 writes: writes.slice(i, i + BATCHSIZE),
322 },
323 });
324 }
325
326 setFollowRecords([]);
327 setNotice(
328 `Unfollowed ${writes.length} account${writes.length > 1 ? "s" : ""}`,
329 );
330 };
331
332 return (
333 <div class="flex flex-col items-center">
334 <Show when={followCount() === 0 && !followRecords.length}>
335 <button
336 type="button"
337 onclick={() => fetchHiddenAccounts()}
338 class="rounded bg-blue-600 px-2 py-2 font-bold text-slate-100 hover:bg-blue-700"
339 >
340 Preview
341 </button>
342 </Show>
343 <Show when={followRecords.length}>
344 <button
345 type="button"
346 onclick={() => unfollow()}
347 class="rounded bg-blue-600 px-2 py-2 font-bold text-slate-100 hover:bg-blue-700"
348 >
349 Confirm
350 </button>
351 </Show>
352 <Show when={notice()}>
353 <div class="m-3">{notice()}</div>
354 </Show>
355 <Show when={followCount() && progress() != followCount()}>
356 <div class="m-3">
357 Progress: {progress()}/{followCount()}
358 </div>
359 </Show>
360 </div>
361 );
362};
363
364const Follows: Component = () => {
365 const [selectedCount, setSelectedCount] = createSignal(0);
366
367 createEffect(() => {
368 setSelectedCount(followRecords.filter((record) => record.toDelete).length);
369 });
370
371 function editRecords(
372 status: RepoStatus,
373 field: keyof FollowRecord,
374 value: boolean,
375 ) {
376 const range = followRecords
377 .map((record, index) => {
378 if (record.status & status) return index;
379 })
380 .filter((i) => i !== undefined);
381 setFollowRecords(range, field, value);
382 }
383
384 const options: { status: RepoStatus; label: string }[] = [
385 { status: RepoStatus.DELETED, label: "Deleted" },
386 { status: RepoStatus.DEACTIVATED, label: "Deactivated" },
387 { status: RepoStatus.SUSPENDED, label: "Suspended" },
388 { status: RepoStatus.BLOCKEDBY, label: "Blocked By" },
389 { status: RepoStatus.BLOCKING, label: "Blocking" },
390 ];
391
392 return (
393 <div class="mt-6 flex flex-col sm:w-full sm:flex-row sm:justify-center">
394 <div class="dark:bg-dark-500 sticky top-0 mb-3 mr-5 flex w-full flex-wrap justify-around border-b border-b-gray-400 bg-slate-100 pb-3 sm:top-3 sm:mb-0 sm:w-auto sm:flex-col sm:self-start sm:border-none">
395 <For each={options}>
396 {(option, index) => (
397 <div
398 classList={{
399 "sm:pb-2 min-w-36 sm:mb-2 mt-3 sm:mt-0": true,
400 "sm:border-b sm:border-b-gray-300 dark:sm:border-b-gray-500":
401 index() < options.length - 1,
402 }}
403 >
404 <div>
405 <label class="mb-2 mt-1 inline-flex cursor-pointer items-center">
406 <input
407 type="checkbox"
408 class="peer sr-only"
409 checked
410 onChange={(e) =>
411 editRecords(
412 option.status,
413 "visible",
414 e.currentTarget.checked,
415 )
416 }
417 />
418 <span class="peer relative h-5 w-9 rounded-full bg-gray-200 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rtl:peer-checked:after:-translate-x-full dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800"></span>
419 <span class="ms-3 select-none">{option.label}</span>
420 </label>
421 </div>
422 <div class="flex items-center">
423 <input
424 type="checkbox"
425 id={option.label}
426 class="h-4 w-4 rounded"
427 onChange={(e) =>
428 editRecords(
429 option.status,
430 "toDelete",
431 e.currentTarget.checked,
432 )
433 }
434 />
435 <label for={option.label} class="ml-2 select-none">
436 Select All
437 </label>
438 </div>
439 </div>
440 )}
441 </For>
442 <div class="min-w-36 pt-3 sm:pt-0">
443 <span>
444 Selected: {selectedCount()}/{followRecords.length}
445 </span>
446 </div>
447 </div>
448 <div class="sm:min-w-96">
449 <For each={followRecords}>
450 {(record, index) => (
451 <Show when={record.visible}>
452 <div
453 classList={{
454 "mb-1 flex items-center border-b dark:border-b-gray-500 py-1":
455 true,
456 "bg-red-300 dark:bg-rose-800": record.toDelete,
457 }}
458 >
459 <div class="mx-2">
460 <input
461 type="checkbox"
462 id={"record" + index()}
463 class="h-4 w-4 rounded"
464 checked={record.toDelete}
465 onChange={(e) =>
466 setFollowRecords(
467 index(),
468 "toDelete",
469 e.currentTarget.checked,
470 )
471 }
472 />
473 </div>
474 <div>
475 <label for={"record" + index()} class="flex flex-col">
476 <Show when={record.handle.length}>
477 <span>@{record.handle}</span>
478 </Show>
479 <span>{record.did}</span>
480 <span>{record.status_label}</span>
481 </label>
482 </div>
483 </div>
484 </Show>
485 )}
486 </For>
487 </div>
488 </div>
489 );
490};
491
492const App: Component = () => {
493 const [theme, setTheme] = createSignal(
494 (
495 localStorage.theme === "dark" ||
496 (!("theme" in localStorage) &&
497 globalThis.matchMedia("(prefers-color-scheme: dark)").matches)
498 ) ?
499 "dark"
500 : "light",
501 );
502
503 return (
504 <div class="m-5 flex flex-col items-center text-slate-900 dark:text-slate-100">
505 <div class="mb-2 flex w-[20rem] items-center">
506 <div class="basis-1/3">
507 <div
508 class="w-fit cursor-pointer"
509 title="Theme"
510 onclick={() => {
511 setTheme(theme() === "light" ? "dark" : "light");
512 if (theme() === "dark")
513 document.documentElement.classList.add("dark");
514 else document.documentElement.classList.remove("dark");
515 localStorage.theme = theme();
516 }}
517 >
518 {theme() === "dark" ?
519 <div class="i-tabler-moon-stars text-xl" />
520 : <div class="i-tabler-sun text-xl" />}
521 </div>
522 </div>
523 <div class="basis-1/3 text-center text-xl font-bold">
524 <a href="">cleanfollow</a>
525 </div>
526 <div class="justify-right flex basis-1/3 gap-x-2">
527 <a
528 title="Bluesky"
529 href="https://bsky.app/profile/did:plc:b3pn34agqqchkaf75v7h43dk"
530 target="_blank"
531 >
532 <div class="i-fa6-brands-bluesky text-xl" />
533 </a>
534 <a
535 title="GitHub"
536 href="https://github.com/notjuliet/cleanfollow-bsky"
537 target="_blank"
538 >
539 <div class="i-bi-github text-xl" />
540 </a>
541 </div>
542 </div>
543 <div class="mb-2 text-center">
544 <p>Select inactive or blocked accounts to unfollow</p>
545 </div>
546 <Login />
547 <Show when={loginState()}>
548 <Fetch />
549 <Show when={followRecords.length}>
550 <Follows />
551 </Show>
552 </Show>
553 </div>
554 );
555};
556
557export default App;