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