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-300 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-300 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-300 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-300 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-300 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-300 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()}>
173 <div>@{record.handle} </div>
174 <div> {record.did} </div>
175 <div>
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 </div>
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 <div class="flex flex-col items-center">
354 <Show when={!loginState()}>
355 <label for="handle">Handle:</label>
356 <input
357 type="text"
358 id="handle"
359 placeholder="user.bsky.social"
360 class="rounded-md mt-1 py-1 pl-2 pr-2 mb-3 ring-1 ring-inset ring-gray-300"
361 onInput={(e) => setLoginInput(e.currentTarget.value)}
362 />
363 <button
364 type="button"
365 onclick={() => loginBsky(loginInput())}
366 class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
367 >
368 Login
369 </button>
370 </Show>
371 <Show when={loginState()}>
372 <div class="mb-5">
373 Logged in as {userHandle} (
374 <a href="" class="text-red-600" onclick={() => logoutBsky()}>
375 Logout
376 </a>
377 )
378 </div>
379 <Show when={!followRecords.length}>
380 <button
381 type="button"
382 onclick={() => fetchHiddenAccounts()}
383 class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
384 >
385 Preview
386 </button>
387 </Show>
388 <Show when={followRecords.length}>
389 <button
390 type="button"
391 onclick={() => unfollow()}
392 class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
393 >
394 Confirm
395 </button>
396 </Show>
397 </Show>
398 </div>
399 <Show when={notice()}>
400 <div class="m-3">{notice()}</div>
401 </Show>
402 <Show when={loginState() && followCount()}>
403 <div class="m-3">
404 Progress: {progress()}/{followCount()}
405 </div>
406 </Show>
407 </div>
408 );
409};
410
411const App: Component = () => {
412 return (
413 <div class="flex flex-col items-center m-5">
414 <h1 class="text-2xl mb-5">cleanfollow-bsky</h1>
415 <div class="mb-3 text-center">
416 <p>Unfollow blocked, deleted, suspended, and deactivated accounts</p>
417 <p>By default, every account will be unselected</p>
418 <div>
419 <a
420 class="text-blue-600 hover:underline"
421 href="https://github.com/notjuliet/cleanfollow-bsky"
422 >
423 Source Code
424 </a>
425 <span> | </span>
426 <a
427 class="text-blue-600 hover:underline"
428 href="https://bsky.app/profile/adorable.mom"
429 >
430 Bluesky
431 </a>
432 <span> | </span>
433 <a
434 class="text-blue-600 hover:underline"
435 href="https://mary-ext.codeberg.page/bluesky-quiet-posters/"
436 >
437 Quiet Posters
438 </a>
439 </div>
440 </div>
441 <Form />
442 <Show when={loginState()}>
443 <Follows />
444 </Show>
445 </div>
446 );
447};
448
449export default App;