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