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