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