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