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