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