Unfollow tool for Bluesky
1import {
2 createEffect,
3 createSignal,
4 For,
5 onMount,
6 Show,
7 type Component,
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 ComAtprotoRepoListRecords,
18} from "@atcute/client/lexicons";
19import {
20 configureOAuth,
21 createAuthorizationUrl,
22 finalizeAuthorization,
23 getSession,
24 OAuthUserAgent,
25 resolveFromIdentity,
26 type Session,
27} from "@atcute/oauth-browser-client";
28
29configureOAuth({
30 metadata: {
31 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
32 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL,
33 },
34});
35
36enum RepoStatus {
37 BLOCKEDBY = 1 << 0,
38 BLOCKING = 1 << 1,
39 DELETED = 1 << 2,
40 DEACTIVATED = 1 << 3,
41 SUSPENDED = 1 << 4,
42 YOURSELF = 1 << 5,
43 NONMUTUAL = 1 << 6,
44}
45
46type FollowRecord = {
47 did: string;
48 handle: string;
49 uri: string;
50 status: RepoStatus;
51 status_label: string;
52 toDelete: boolean;
53 visible: boolean;
54};
55
56const [followRecords, setFollowRecords] = createStore<FollowRecord[]>([]);
57const [loginState, setLoginState] = createSignal(false);
58let rpc: XRPC;
59let agent: OAuthUserAgent;
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.json().then((doc) => {
69 for (const alias of doc.alsoKnownAs) {
70 if (alias.includes("at://")) {
71 return alias.split("//")[1];
72 }
73 }
74 });
75};
76
77const Login: Component = () => {
78 const [loginInput, setLoginInput] = createSignal("");
79 const [handle, setHandle] = createSignal("");
80 const [notice, setNotice] = createSignal("");
81
82 onMount(async () => {
83 setNotice("Loading...");
84
85 const init = async (): Promise<Session | undefined> => {
86 const params = new URLSearchParams(location.hash.slice(1));
87
88 if (params.has("state") && (params.has("code") || params.has("error"))) {
89 history.replaceState(null, "", location.pathname + location.search);
90
91 const session = await finalizeAuthorization(params);
92 const did = session.info.sub;
93
94 localStorage.setItem("lastSignedIn", did);
95 return session;
96 } else {
97 const lastSignedIn = localStorage.getItem("lastSignedIn");
98
99 if (lastSignedIn) {
100 try {
101 const session = await getSession(lastSignedIn as At.DID);
102 return session;
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 (err) {
140 setNotice("Error during OAuth login");
141 }
142 };
143
144 const logoutBsky = async () => {
145 await agent.signOut();
146 };
147
148 return (
149 <div class="flex flex-col items-center">
150 <Show when={!loginState() && !notice().includes("Loading")}>
151 <form
152 class="flex flex-col items-center"
153 onsubmit={(e) => e.preventDefault()}
154 >
155 <label for="handle">Handle:</label>
156 <input
157 type="text"
158 id="handle"
159 placeholder="user.bsky.social"
160 class="mb-3 mt-1 rounded-md border border-black px-2 py-1"
161 onInput={(e) => setLoginInput(e.currentTarget.value)}
162 />
163 <button
164 onclick={() => loginBsky(loginInput())}
165 class="rounded bg-blue-500 px-2 py-2 font-bold text-white hover:bg-blue-700"
166 >
167 Login
168 </button>
169 </form>
170 </Show>
171 <Show when={loginState() && handle()}>
172 <div class="mb-5">
173 Logged in as @{handle()} (
174 <a href="" class="text-red-600" onclick={() => logoutBsky()}>
175 Logout
176 </a>
177 )
178 </div>
179 </Show>
180 <Show when={notice()}>
181 <div class="m-3">{notice()}</div>
182 </Show>
183 </div>
184 );
185};
186
187const Fetch: Component = () => {
188 const [progress, setProgress] = createSignal(0);
189 const [followCount, setFollowCount] = createSignal(0);
190 const [notice, setNotice] = createSignal("");
191
192 const fetchHiddenAccounts = async () => {
193 const fetchFollows = async () => {
194 const PAGE_LIMIT = 100;
195 const fetchPage = async (cursor?: string) => {
196 return await rpc.get("com.atproto.repo.listRecords", {
197 params: {
198 repo: agent.sub,
199 collection: "app.bsky.graph.follow",
200 limit: PAGE_LIMIT,
201 cursor: cursor,
202 },
203 });
204 };
205
206 let res = await fetchPage();
207 let follows = res.data.records;
208
209 while (res.data.cursor && res.data.records.length >= PAGE_LIMIT) {
210 res = await fetchPage(res.data.cursor);
211 follows = follows.concat(res.data.records);
212 }
213
214 return follows;
215 };
216
217 setProgress(0);
218 setNotice("");
219
220 const follows = await fetchFollows();
221 setFollowCount(follows.length);
222 let tmpFollows: FollowRecord[] = [];
223
224 follows.forEach(async (record: ComAtprotoRepoListRecords.Record) => {
225 let status: RepoStatus | undefined = undefined;
226 const follow = record.value as AppBskyGraphFollow.Record;
227 let handle = "";
228
229 try {
230 const res = await rpc.get("app.bsky.actor.getProfile", {
231 params: { actor: follow.subject },
232 });
233
234 handle = res.data.handle;
235 const viewer = res.data.viewer!;
236
237 if (!viewer.followedBy) status = RepoStatus.NONMUTUAL;
238
239 if (viewer.blockedBy) {
240 status =
241 viewer.blocking || viewer.blockingByList ?
242 RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING
243 : RepoStatus.BLOCKEDBY;
244 } else if (res.data.did.includes(agent.sub)) {
245 status = RepoStatus.YOURSELF;
246 } else if (viewer.blocking || viewer.blockingByList) {
247 status = RepoStatus.BLOCKING;
248 }
249 } catch (e: any) {
250 handle = await resolveDid(follow.subject);
251
252 status =
253 e.message.includes("not found") ? RepoStatus.DELETED
254 : e.message.includes("deactivated") ? RepoStatus.DEACTIVATED
255 : e.message.includes("suspended") ? RepoStatus.SUSPENDED
256 : undefined;
257 }
258
259 const status_label =
260 status == RepoStatus.DELETED ? "Deleted"
261 : status == RepoStatus.DEACTIVATED ? "Deactivated"
262 : status == RepoStatus.SUSPENDED ? "Suspended"
263 : status == RepoStatus.NONMUTUAL ? "Non Mutual"
264 : status == RepoStatus.YOURSELF ? "Literally Yourself"
265 : status == RepoStatus.BLOCKING ? "Blocking"
266 : status == RepoStatus.BLOCKEDBY ? "Blocked by"
267 : RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING ? "Mutual Block"
268 : "";
269
270 if (status !== undefined) {
271 tmpFollows.push({
272 did: follow.subject,
273 handle: handle,
274 uri: record.uri,
275 status: status,
276 status_label: status_label,
277 toDelete: false,
278 visible: status == RepoStatus.NONMUTUAL ? false : true,
279 });
280 }
281 setProgress(progress() + 1);
282 if (progress() == followCount()) setFollowRecords(tmpFollows);
283 });
284 };
285
286 const unfollow = async () => {
287 const writes = followRecords
288 .filter((record) => record.toDelete)
289 .map((record): Brand.Union<ComAtprotoRepoApplyWrites.Delete> => {
290 return {
291 $type: "com.atproto.repo.applyWrites#delete",
292 collection: "app.bsky.graph.follow",
293 rkey: record.uri.split("/").pop()!,
294 };
295 });
296
297 const BATCHSIZE = 200;
298 for (let i = 0; i < writes.length; i += BATCHSIZE) {
299 await rpc.call("com.atproto.repo.applyWrites", {
300 data: {
301 repo: agent.sub,
302 writes: writes.slice(i, i + BATCHSIZE),
303 },
304 });
305 }
306
307 setFollowRecords([]);
308 setProgress(0);
309 setFollowCount(0);
310 setNotice(
311 `Unfollowed ${writes.length} account${writes.length > 1 ? "s" : ""}`,
312 );
313 };
314
315 return (
316 <div class="flex flex-col items-center">
317 <Show when={!followRecords.length}>
318 <button
319 type="button"
320 onclick={() => fetchHiddenAccounts()}
321 class="rounded bg-blue-500 px-2 py-2 font-bold text-white hover:bg-blue-700"
322 >
323 Preview
324 </button>
325 </Show>
326 <Show when={followRecords.length}>
327 <button
328 type="button"
329 onclick={() => unfollow()}
330 class="rounded bg-green-600 px-2 py-2 font-bold text-white hover:bg-green-700"
331 >
332 Confirm
333 </button>
334 </Show>
335 <Show when={notice()}>
336 <div class="m-3">{notice()}</div>
337 </Show>
338 <Show when={followCount() && progress() != followCount()}>
339 <div class="m-3">
340 Progress: {progress()}/{followCount()}
341 </div>
342 </Show>
343 </div>
344 );
345};
346
347const Follows: Component = () => {
348 const [selectedCount, setSelectedCount] = createSignal(0);
349
350 createEffect(() => {
351 setSelectedCount(followRecords.filter((record) => record.toDelete).length);
352 });
353
354 function editRecords(
355 status: RepoStatus,
356 field: keyof FollowRecord,
357 value: boolean,
358 ) {
359 const range = followRecords
360 .map((record, index) => {
361 if (record.status & status) return index;
362 })
363 .filter((i) => i !== undefined);
364 setFollowRecords(range, field, value);
365 }
366
367 const options: { status: RepoStatus; label: string }[] = [
368 { status: RepoStatus.DELETED, label: "Deleted" },
369 { status: RepoStatus.DEACTIVATED, label: "Deactivated" },
370 { status: RepoStatus.SUSPENDED, label: "Suspended" },
371 { status: RepoStatus.BLOCKEDBY, label: "Blocked By" },
372 { status: RepoStatus.BLOCKING, label: "Blocking" },
373 { status: RepoStatus.NONMUTUAL, label: "Non Mutual" },
374 ];
375
376 return (
377 <div class="mt-6 flex flex-col sm:w-full sm:flex-row sm:justify-center">
378 <div class="sticky top-0 mb-3 mr-5 flex w-full flex-wrap justify-around border-b border-b-gray-400 bg-white pb-3 sm:top-3 sm:mb-0 sm:w-auto sm:flex-col sm:self-start sm:border-none">
379 <For each={options}>
380 {(option, index) => (
381 <div
382 classList={{
383 "sm:pb-2 min-w-36 sm:mb-2 mt-3 sm:mt-0": true,
384 "sm:border-b sm:border-b-gray-300":
385 index() < options.length - 1,
386 }}
387 >
388 <div>
389 <label class="mb-2 mt-1 inline-flex cursor-pointer items-center">
390 <input
391 type="checkbox"
392 class="peer sr-only"
393 checked={
394 option.status == RepoStatus.NONMUTUAL ? false : true
395 }
396 onChange={(e) =>
397 editRecords(
398 option.status,
399 "visible",
400 e.currentTarget.checked,
401 )
402 }
403 />
404 <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>
405 <span class="ms-3 select-none dark:text-gray-300">
406 {option.label}
407 </span>
408 </label>
409 </div>
410 <div class="flex items-center">
411 <input
412 type="checkbox"
413 id={option.label}
414 class="h-4 w-4 rounded"
415 onChange={(e) =>
416 editRecords(
417 option.status,
418 "toDelete",
419 e.currentTarget.checked,
420 )
421 }
422 />
423 <label for={option.label} class="ml-2 select-none">
424 Select All
425 </label>
426 </div>
427 </div>
428 )}
429 </For>
430 <div class="min-w-36 pt-3 sm:pt-0">
431 <span>
432 Selected: {selectedCount()}/{followRecords.length}
433 </span>
434 </div>
435 </div>
436 <div class="sm:min-w-96">
437 <For each={followRecords}>
438 {(record, index) => (
439 <Show when={record.visible}>
440 <div
441 classList={{
442 "mb-1 flex items-center border-b py-1": true,
443 "bg-red-400": record.toDelete,
444 }}
445 >
446 <div class="mx-2">
447 <input
448 type="checkbox"
449 id={"record" + index()}
450 class="h-4 w-4 rounded"
451 checked={record.toDelete}
452 onChange={(e) =>
453 setFollowRecords(
454 index(),
455 "toDelete",
456 e.currentTarget.checked,
457 )
458 }
459 />
460 </div>
461 <div>
462 <label for={"record" + index()} class="flex flex-col">
463 <span>@{record.handle}</span>
464 <span>{record.did}</span>
465 <span>{record.status_label}</span>
466 </label>
467 </div>
468 </div>
469 </Show>
470 )}
471 </For>
472 </div>
473 </div>
474 );
475};
476
477const App: Component = () => {
478 return (
479 <div class="m-5 flex flex-col items-center">
480 <h1 class="mb-5 text-2xl">cleanfollow-bsky</h1>
481 <div class="mb-3 text-center">
482 <p>Unfollow blocked, deleted, suspended, and deactivated accounts</p>
483 <p>By default, every account will be unselected</p>
484 <div>
485 <a
486 class="text-blue-600 hover:underline"
487 href="https://github.com/notjuliet/cleanfollow-bsky"
488 >
489 Source Code
490 </a>
491 <span> | </span>
492 <a
493 class="text-blue-600 hover:underline"
494 href="https://bsky.app/profile/adorable.mom"
495 >
496 Bluesky
497 </a>
498 <span> | </span>
499 <a
500 class="text-blue-600 hover:underline"
501 href="https://mary-ext.codeberg.page/bluesky-quiet-posters/"
502 >
503 Quiet Posters
504 </a>
505 </div>
506 </div>
507 <Login />
508 <Show when={loginState()}>
509 <Fetch />
510 <Show when={followRecords.length}>
511 <Follows />
512 </Show>
513 </Show>
514 </div>
515 );
516};
517
518export default App;