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