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