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