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