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