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