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