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