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