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