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