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,
16 BLOCKING,
17 DELETED,
18 DEACTIVATED,
19 SUSPENDED,
20 YOURSELF,
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 when={record.status == RepoStatus.DELETED}>
174 Deleted
175 </Match>
176 <Match when={record.status == RepoStatus.DEACTIVATED}>
177 Deactivated
178 </Match>
179 <Match when={record.status == RepoStatus.BLOCKEDBY}>
180 Blocked by
181 </Match>
182 <Match when={record.status == RepoStatus.BLOCKING}>
183 Blocking
184 </Match>
185 <Match when={record.status == RepoStatus.SUSPENDED}>
186 Suspended
187 </Match>
188 <Match when={record.status == RepoStatus.YOURSELF}>
189 Literally Yourself
190 </Match>
191 </Switch>
192 </div>
193 </label>
194 </div>
195 </div>
196 )}
197 </For>
198 </div>
199 </div>
200 );
201};
202
203const Form: Component = () => {
204 const [loginInput, setLoginInput] = createSignal("");
205 const [progress, setProgress] = createSignal(0);
206 const [followCount, setFollowCount] = createSignal(0);
207 const [notice, setNotice] = createSignal("");
208
209 const fetchHiddenAccounts = async () => {
210 const fetchFollows = async () => {
211 const PAGE_LIMIT = 100;
212 const fetchPage = async (cursor?: any) => {
213 return await appAgent.com.atproto.repo.listRecords({
214 repo: appAgent.did!,
215 collection: "app.bsky.graph.follow",
216 limit: PAGE_LIMIT,
217 cursor: cursor,
218 });
219 };
220
221 let res = await fetchPage();
222 let follows = res.data.records;
223
224 while (res.data.cursor && res.data.records.length >= PAGE_LIMIT) {
225 res = await fetchPage(res.data.cursor);
226 follows = follows.concat(res.data.records);
227 }
228
229 return follows;
230 };
231
232 setNotice("");
233 setProgress(0);
234
235 await fetchFollows().then((follows) =>
236 follows.forEach(async (record: any) => {
237 setFollowCount(follows.length);
238
239 try {
240 const res = await appAgent.getProfile({
241 actor: record.value.subject,
242 });
243 if (res.data.viewer?.blockedBy) {
244 setFollowRecords(followRecords.length, {
245 did: record.value.subject,
246 handle: res.data.handle,
247 uri: record.uri,
248 status: RepoStatus.BLOCKEDBY,
249 toBeDeleted: false,
250 });
251 } else if (res.data.did.includes(appAgent.did!)) {
252 setFollowRecords(followRecords.length, {
253 did: record.value.subject,
254 handle: res.data.handle,
255 uri: record.uri,
256 status: RepoStatus.YOURSELF,
257 toBeDeleted: false,
258 });
259 } else if (
260 res.data.viewer?.blocking ||
261 res.data.viewer?.blockingByList
262 ) {
263 setFollowRecords(followRecords.length, {
264 did: record.value.subject,
265 handle: res.data.handle,
266 uri: record.uri,
267 status: RepoStatus.BLOCKING,
268 toBeDeleted: false,
269 });
270 }
271 } catch (e: any) {
272 const res = await fetch(
273 record.value.subject.startsWith("did:web")
274 ? "https://" +
275 record.value.subject.split(":")[2] +
276 "/.well-known/did.json"
277 : "https://plc.directory/" + record.value.subject,
278 );
279
280 const status = e.message.includes("not found")
281 ? RepoStatus.DELETED
282 : e.message.includes("deactivated")
283 ? RepoStatus.DEACTIVATED
284 : e.message.includes("suspended")
285 ? RepoStatus.SUSPENDED
286 : undefined;
287
288 const handle = await res.json().then((doc) => {
289 for (const alias of doc.alsoKnownAs) {
290 if (alias.includes("at://")) {
291 return alias.split("//")[1];
292 }
293 }
294 });
295
296 if (status !== undefined) {
297 setFollowRecords(followRecords.length, {
298 did: record.value.subject,
299 handle: handle,
300 uri: record.uri,
301 status: status,
302 toBeDeleted: false,
303 });
304 }
305 }
306 setProgress(progress() + 1);
307 }),
308 );
309 };
310
311 const unfollow = async () => {
312 const writes = followRecords
313 .filter((record) => record.toBeDeleted)
314 .map((record) => {
315 return {
316 $type: "com.atproto.repo.applyWrites#delete",
317 collection: "app.bsky.graph.follow",
318 rkey: record.uri.split("/").pop(),
319 };
320 });
321
322 const BATCHSIZE = 200;
323 for (let i = 0; i < writes.length; i += BATCHSIZE) {
324 await appAgent.com.atproto.repo.applyWrites({
325 repo: appAgent.did!,
326 writes: writes.slice(i, i + BATCHSIZE),
327 });
328 }
329
330 setFollowRecords([]);
331 setProgress(0);
332 setFollowCount(0);
333 setNotice(`Unfollowed ${writes.length} accounts`);
334 };
335
336 return (
337 <div class="flex flex-col items-center">
338 <div class="flex flex-col items-center">
339 <Show when={!loginState()}>
340 <label for="handle">Handle:</label>
341 <input
342 type="text"
343 id="handle"
344 placeholder="user.bsky.social"
345 class="rounded-md mt-1 py-1 pl-2 pr-2 mb-3 ring-1 ring-inset ring-gray-300"
346 onInput={(e) => setLoginInput(e.currentTarget.value)}
347 />
348 <button
349 type="button"
350 onclick={() => loginBsky(loginInput())}
351 class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
352 >
353 Login
354 </button>
355 </Show>
356 <Show when={loginState()}>
357 <div class="mb-5">
358 Logged in as {userHandle} (
359 <a href="" class="text-red-600" onclick={() => logoutBsky()}>
360 Logout
361 </a>
362 )
363 </div>
364 <Show when={!followRecords.length}>
365 <button
366 type="button"
367 onclick={() => fetchHiddenAccounts()}
368 class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
369 >
370 Preview
371 </button>
372 </Show>
373 <Show when={followRecords.length}>
374 <button
375 type="button"
376 onclick={() => unfollow()}
377 class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
378 >
379 Confirm
380 </button>
381 </Show>
382 </Show>
383 </div>
384 <Show when={notice()}>
385 <div class="m-3">{notice()}</div>
386 </Show>
387 <Show when={loginState() && followCount()}>
388 <div class="m-3">
389 Progress: {progress()}/{followCount()}
390 </div>
391 </Show>
392 </div>
393 );
394};
395
396const App: Component = () => {
397 return (
398 <div class="flex flex-col items-center m-5">
399 <h1 class="text-2xl mb-5">cleanfollow-bsky</h1>
400 <div class="mb-3 text-center">
401 <p>Unfollow blocked, deleted, suspended, and deactivated accounts</p>
402 <div>
403 <a
404 class="text-blue-600 hover:underline"
405 href="https://github.com/notjuliet/cleanfollow-bsky"
406 >
407 Source Code
408 </a>
409 <span> | </span>
410 <a
411 class="text-blue-600 hover:underline"
412 href="https://bsky.app/profile/adorable.mom"
413 >
414 Bluesky
415 </a>
416 <span> | </span>
417 <a
418 class="text-blue-600 hover:underline"
419 href="https://mary-ext.codeberg.page/bluesky-quiet-posters/"
420 >
421 Quiet Posters
422 </a>
423 </div>
424 </div>
425 <Form />
426 <Show when={loginState()}>
427 <Follows />
428 </Show>
429 </div>
430 );
431};
432
433export default App;