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