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