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