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