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