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