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