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