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