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