Unfollow tool for Bluesky
1import {
2 createSignal,
3 onMount,
4 For,
5 Show,
6 type Component,
7 createEffect,
8} from "solid-js";
9import { createStore } from "solid-js/store";
10
11import {
12 Agent,
13 AppBskyGraphFollow,
14 ComAtprotoRepoApplyWrites,
15 ComAtprotoRepoListRecords,
16} from "@atproto/api";
17import { BrowserOAuthClient } from "@atproto/oauth-client-browser";
18
19enum RepoStatus {
20 BLOCKEDBY = 1 << 0,
21 BLOCKING = 1 << 1,
22 DELETED = 1 << 2,
23 DEACTIVATED = 1 << 3,
24 SUSPENDED = 1 << 4,
25 YOURSELF = 1 << 5,
26 NONMUTUAL = 1 << 6,
27}
28
29type FollowRecord = {
30 did: string;
31 handle: string;
32 uri: string;
33 status: RepoStatus;
34 status_label: string;
35 toDelete: boolean;
36 visible: boolean;
37};
38
39const [followRecords, setFollowRecords] = createStore<FollowRecord[]>([]);
40const [loginState, setLoginState] = createSignal(false);
41let agent: Agent;
42
43const resolveDid = async (did: string) => {
44 const res = await fetch(
45 did.startsWith("did:web") ?
46 `https://${did.split(":")[2]}/.well-known/did.json`
47 : "https://plc.directory/" + did,
48 );
49
50 return res.json().then((doc) => {
51 for (const alias of doc.alsoKnownAs) {
52 if (alias.includes("at://")) {
53 return alias.split("//")[1];
54 }
55 }
56 });
57};
58
59const Login: Component = () => {
60 const [loginInput, setLoginInput] = createSignal("");
61 const [handle, setHandle] = createSignal("");
62 const [notice, setNotice] = createSignal("");
63 let client: BrowserOAuthClient;
64 let sub: string;
65
66 onMount(async () => {
67 setNotice("Loading...");
68 client = await BrowserOAuthClient.load({
69 clientId: "https://cleanfollow-bsky.pages.dev/client-metadata.json",
70 handleResolver: "https://boletus.us-west.host.bsky.network",
71 });
72
73 client.addEventListener("deleted", () => {
74 setLoginState(false);
75 });
76 const result = await client.init().catch(() => {});
77
78 if (result) {
79 agent = new Agent(result.session);
80 setLoginState(true);
81 setHandle(await resolveDid(agent.assertDid));
82 sub = result.session.sub;
83 }
84 setNotice("");
85 });
86
87 const loginBsky = async (handle: string) => {
88 setNotice("Redirecting...");
89 try {
90 await client.signIn(handle, {
91 scope: "atproto transition:generic",
92 signal: new AbortController().signal,
93 });
94 } catch (err) {
95 setNotice("Error during OAuth redirection");
96 }
97 };
98
99 const logoutBsky = async () => {
100 if (sub) await client.revoke(sub);
101 };
102
103 return (
104 <div class="flex flex-col items-center">
105 <Show when={!loginState() && !notice().includes("Loading")}>
106 <form
107 class="flex flex-col items-center"
108 onsubmit={(e) => e.preventDefault()}
109 >
110 <label for="handle">Handle:</label>
111 <input
112 type="text"
113 id="handle"
114 placeholder="user.bsky.social"
115 class="mb-3 mt-1 rounded-md px-2 py-1"
116 onInput={(e) => setLoginInput(e.currentTarget.value)}
117 />
118 <button
119 onclick={() => loginBsky(loginInput())}
120 class="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
121 >
122 Login
123 </button>
124 </form>
125 </Show>
126 <Show when={loginState() && handle()}>
127 <div class="mb-5">
128 Logged in as @{handle()} (
129 <a href="" class="text-red-600" onclick={() => logoutBsky()}>
130 Logout
131 </a>
132 )
133 </div>
134 </Show>
135 <Show when={notice()}>
136 <div class="m-3">{notice()}</div>
137 </Show>
138 </div>
139 );
140};
141
142const Fetch: Component = () => {
143 const [progress, setProgress] = createSignal(0);
144 const [followCount, setFollowCount] = createSignal(0);
145 const [notice, setNotice] = createSignal("");
146
147 const fetchHiddenAccounts = async () => {
148 const fetchFollows = async () => {
149 const PAGE_LIMIT = 100;
150 const fetchPage = async (cursor?: string) => {
151 return await agent.com.atproto.repo.listRecords({
152 repo: agent.assertDid,
153 collection: "app.bsky.graph.follow",
154 limit: PAGE_LIMIT,
155 cursor: cursor,
156 });
157 };
158
159 let res = await fetchPage();
160 let follows = res.data.records;
161
162 while (res.data.cursor && res.data.records.length >= PAGE_LIMIT) {
163 res = await fetchPage(res.data.cursor);
164 follows = follows.concat(res.data.records);
165 }
166
167 return follows;
168 };
169
170 setProgress(0);
171 setNotice("");
172
173 const follows = await fetchFollows();
174 setFollowCount(follows.length);
175 let tmpFollows: FollowRecord[] = [];
176
177 follows.forEach(async (record: ComAtprotoRepoListRecords.Record) => {
178 let status: RepoStatus | undefined = undefined;
179 const follow = record.value as AppBskyGraphFollow.Record;
180 let handle = "";
181
182 try {
183 const res = await agent.getProfile({
184 actor: follow.subject,
185 });
186
187 handle = res.data.handle;
188 const viewer = res.data.viewer!;
189
190 if (!viewer.followedBy) status = RepoStatus.NONMUTUAL;
191
192 if (viewer.blockedBy) {
193 status =
194 viewer.blocking || viewer.blockingByList ?
195 RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING
196 : RepoStatus.BLOCKEDBY;
197 } else if (res.data.did.includes(agent.assertDid)) {
198 status = RepoStatus.YOURSELF;
199 } else if (viewer.blocking || viewer.blockingByList) {
200 status = RepoStatus.BLOCKING;
201 }
202 } catch (e: any) {
203 handle = await resolveDid(follow.subject);
204
205 status =
206 e.message.includes("not found") ? RepoStatus.DELETED
207 : e.message.includes("deactivated") ? RepoStatus.DEACTIVATED
208 : e.message.includes("suspended") ? RepoStatus.SUSPENDED
209 : undefined;
210 }
211
212 const status_label =
213 status == RepoStatus.DELETED ? "Deleted"
214 : status == RepoStatus.DEACTIVATED ? "Deactivated"
215 : status == RepoStatus.SUSPENDED ? "Suspended"
216 : status == RepoStatus.NONMUTUAL ? "Non Mutual"
217 : status == RepoStatus.YOURSELF ? "Literally Yourself"
218 : status == RepoStatus.BLOCKING ? "Blocking"
219 : status == RepoStatus.BLOCKEDBY ? "Blocked by"
220 : RepoStatus.BLOCKEDBY | RepoStatus.BLOCKING ? "Mutual Block"
221 : "";
222
223 if (status !== undefined) {
224 tmpFollows.push({
225 did: follow.subject,
226 handle: handle,
227 uri: record.uri,
228 status: status,
229 status_label: status_label,
230 toDelete: false,
231 visible: status == RepoStatus.NONMUTUAL ? false : true,
232 });
233 }
234 setProgress(progress() + 1);
235 if (progress() == followCount()) setFollowRecords(tmpFollows);
236 });
237 };
238
239 const unfollow = async () => {
240 const writes = followRecords
241 .filter((record) => record.toDelete)
242 .map((record) => {
243 return {
244 $type: "com.atproto.repo.applyWrites#delete",
245 collection: "app.bsky.graph.follow",
246 rkey: record.uri.split("/").pop(),
247 } as ComAtprotoRepoApplyWrites.Delete;
248 });
249
250 const BATCHSIZE = 200;
251 for (let i = 0; i < writes.length; i += BATCHSIZE) {
252 await agent.com.atproto.repo.applyWrites({
253 repo: agent.assertDid,
254 writes: writes.slice(i, i + BATCHSIZE),
255 });
256 }
257
258 setFollowRecords([]);
259 setProgress(0);
260 setFollowCount(0);
261 setNotice(
262 `Unfollowed ${writes.length} account${writes.length > 1 ? "s" : ""}`,
263 );
264 };
265
266 return (
267 <div class="flex flex-col items-center">
268 <Show when={!followRecords.length}>
269 <button
270 type="button"
271 onclick={() => fetchHiddenAccounts()}
272 class="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
273 >
274 Preview
275 </button>
276 </Show>
277 <Show when={followRecords.length}>
278 <button
279 type="button"
280 onclick={() => unfollow()}
281 class="rounded bg-green-500 px-4 py-2 font-bold text-white hover:bg-green-700"
282 >
283 Confirm
284 </button>
285 </Show>
286 <Show when={notice()}>
287 <div class="m-3">{notice()}</div>
288 </Show>
289 <Show when={followCount() && progress() != followCount()}>
290 <div class="m-3">
291 Progress: {progress()}/{followCount()}
292 </div>
293 </Show>
294 </div>
295 );
296};
297
298const Follows: Component = () => {
299 const [selectedCount, setSelectedCount] = createSignal(0);
300
301 createEffect(() => {
302 setSelectedCount(followRecords.filter((record) => record.toDelete).length);
303 });
304
305 function editRecords(
306 status: RepoStatus,
307 field: keyof FollowRecord,
308 value: boolean,
309 ) {
310 const range = followRecords
311 .map((record, index) => {
312 if (record.status & status) return index;
313 })
314 .filter((i) => i !== undefined);
315 setFollowRecords(range, field, value);
316 }
317
318 const options: { status: RepoStatus; label: string }[] = [
319 { status: RepoStatus.DELETED, label: "Deleted" },
320 { status: RepoStatus.DEACTIVATED, label: "Deactivated" },
321 { status: RepoStatus.SUSPENDED, label: "Suspended" },
322 { status: RepoStatus.BLOCKEDBY, label: "Blocked By" },
323 { status: RepoStatus.BLOCKING, label: "Blocking" },
324 { status: RepoStatus.NONMUTUAL, label: "Non Mutual" },
325 ];
326
327 return (
328 <div class="mt-6 flex flex-col sm:w-full sm:flex-row sm:justify-center">
329 <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">
330 <For each={options}>
331 {(option, index) => (
332 <div
333 classList={{
334 "sm:pb-2 min-w-36 sm:mb-2 mt-3 sm:mt-0": true,
335 "sm:border-b sm:border-b-gray-300":
336 index() < options.length - 1,
337 }}
338 >
339 <div>
340 <label class="mb-2 mt-1 inline-flex cursor-pointer items-center">
341 <input
342 type="checkbox"
343 class="peer sr-only"
344 checked={
345 option.status == RepoStatus.NONMUTUAL ? false : true
346 }
347 onChange={(e) =>
348 editRecords(
349 option.status,
350 "visible",
351 e.currentTarget.checked,
352 )
353 }
354 />
355 <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 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800 rtl:peer-checked:after:-translate-x-full"></span>
356 <span class="ms-3 select-none dark:text-gray-300">
357 {option.label}
358 </span>
359 </label>
360 </div>
361 <div class="flex items-center">
362 <input
363 type="checkbox"
364 id={option.label}
365 class="h-4 w-4 rounded"
366 onChange={(e) =>
367 editRecords(
368 option.status,
369 "toDelete",
370 e.currentTarget.checked,
371 )
372 }
373 />
374 <label for={option.label} class="ml-2 select-none">
375 Select All
376 </label>
377 </div>
378 </div>
379 )}
380 </For>
381 <div class="min-w-36 pt-3 sm:pt-0">
382 <span>
383 Selected: {selectedCount()}/{followRecords.length}
384 </span>
385 </div>
386 </div>
387 <div class="sm:min-w-96">
388 <For each={followRecords}>
389 {(record, index) => (
390 <Show when={record.visible}>
391 <div class="mb-2 flex items-center border-b pb-2">
392 <div class="mr-4">
393 <input
394 type="checkbox"
395 id={"record" + index()}
396 class="h-4 w-4 rounded"
397 checked={record.toDelete}
398 onChange={(e) =>
399 setFollowRecords(
400 index(),
401 "toDelete",
402 e.currentTarget.checked,
403 )
404 }
405 />
406 </div>
407 <div classList={{ "bg-red-300": record.toDelete }}>
408 <label for={"record" + index()} class="flex flex-col">
409 <span>@{record.handle}</span>
410 <span>{record.did}</span>
411 <span>{record.status_label}</span>
412 </label>
413 </div>
414 </div>
415 </Show>
416 )}
417 </For>
418 </div>
419 </div>
420 );
421};
422
423const App: Component = () => {
424 return (
425 <div class="m-5 flex flex-col items-center">
426 <h1 class="mb-5 text-2xl">cleanfollow-bsky</h1>
427 <div class="mb-3 text-center">
428 <p>Unfollow blocked, deleted, suspended, and deactivated accounts</p>
429 <p>By default, every account will be unselected</p>
430 <div>
431 <a
432 class="text-blue-600 hover:underline"
433 href="https://github.com/notjuliet/cleanfollow-bsky"
434 >
435 Source Code
436 </a>
437 <span> | </span>
438 <a
439 class="text-blue-600 hover:underline"
440 href="https://bsky.app/profile/adorable.mom"
441 >
442 Bluesky
443 </a>
444 <span> | </span>
445 <a
446 class="text-blue-600 hover:underline"
447 href="https://mary-ext.codeberg.page/bluesky-quiet-posters/"
448 >
449 Quiet Posters
450 </a>
451 </div>
452 </div>
453 <Login />
454 <Show when={loginState()}>
455 <Fetch />
456 <Show when={followRecords.length}>
457 <Follows />
458 </Show>
459 </Show>
460 </div>
461 );
462};
463
464export default App;