Unfollow tool for Bluesky

oauth

+173
package-lock.json
···
"solid-js": "^1.8.11"
},
"devDependencies": {
+
"@atproto/oauth-client-browser": "^0.1.4",
"@tailwindcss/forms": "^0.5.7",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
···
"node": ">=6.0.0"
}
},
+
"node_modules/@atproto-labs/did-resolver": {
+
"version": "0.1.2",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.1.2.tgz",
+
"integrity": "sha512-d/nQHoieDo0tf0OX45LJcLQlSuyzVOV5lND7krlSxeAyD3pO5Fx1G8FtmkoPlMt4LT1OCIIQNmjh42pOcGH3WA==",
+
"dev": true,
+
"dependencies": {
+
"@atproto-labs/fetch": "0.1.0",
+
"@atproto-labs/pipe": "0.1.0",
+
"@atproto-labs/simple-store": "0.1.1",
+
"@atproto-labs/simple-store-memory": "0.1.1",
+
"@atproto/did": "0.1.1",
+
"zod": "^3.23.8"
+
}
+
},
+
"node_modules/@atproto-labs/fetch": {
+
"version": "0.1.0",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.1.0.tgz",
+
"integrity": "sha512-uirja+uA/C4HNk7vayM+AJqsccxQn2wVziUHxbsjJGt/K6Q8ZOKDaEX2+GrcXvpUVcqUKh+94JFjuzH+CAEUlg==",
+
"dev": true,
+
"dependencies": {
+
"@atproto-labs/pipe": "0.1.0"
+
},
+
"optionalDependencies": {
+
"zod": "^3.23.8"
+
}
+
},
+
"node_modules/@atproto-labs/handle-resolver": {
+
"version": "0.1.2",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.1.2.tgz",
+
"integrity": "sha512-0D8d1QpGqyp0DLYnKpAFJ5YaIgiRUHMqKnbd1d0reOuJoa7ebwxMolNhP3RnKlOQ/9gaL3Y3ORZFeEjXK+eRqg==",
+
"dev": true,
+
"dependencies": {
+
"@atproto-labs/simple-store": "0.1.1",
+
"@atproto-labs/simple-store-memory": "0.1.1",
+
"@atproto/did": "0.1.1",
+
"zod": "^3.23.8"
+
}
+
},
+
"node_modules/@atproto-labs/identity-resolver": {
+
"version": "0.1.2",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.1.2.tgz",
+
"integrity": "sha512-166XTfq/gvdzmJT6tMvMvsT4h9yVyse8yJVn534j5GPGTqPtyky57/SNyO+R8QbOr4ffG0NQRO+OAazsVR0mVw==",
+
"dev": true,
+
"dependencies": {
+
"@atproto-labs/did-resolver": "0.1.2",
+
"@atproto-labs/handle-resolver": "0.1.2",
+
"@atproto/syntax": "0.3.0"
+
}
+
},
+
"node_modules/@atproto-labs/pipe": {
+
"version": "0.1.0",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.0.tgz",
+
"integrity": "sha512-ghOqHFyJlQVFPESzlVHjKroP0tPzbmG5Jms0dNI9yLDEfL8xp4OFPWLX4f6T8mRq69wWs4nIDM3sSsFbFqLa1w==",
+
"dev": true
+
},
+
"node_modules/@atproto-labs/simple-store": {
+
"version": "0.1.1",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz",
+
"integrity": "sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==",
+
"dev": true
+
},
+
"node_modules/@atproto-labs/simple-store-memory": {
+
"version": "0.1.1",
+
"resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.1.tgz",
+
"integrity": "sha512-PCRqhnZ8NBNBvLku53O56T0lsVOtclfIrQU/rwLCc4+p45/SBPrRYNBi6YFq5rxZbK6Njos9MCmILV/KLQxrWA==",
+
"dev": true,
+
"dependencies": {
+
"@atproto-labs/simple-store": "0.1.1",
+
"lru-cache": "^10.2.0"
+
}
+
},
+
"node_modules/@atproto-labs/simple-store-memory/node_modules/lru-cache": {
+
"version": "10.4.3",
+
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+
"dev": true
+
},
"node_modules/@atproto/api": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.13.1.tgz",
···
"zod": "^3.21.4"
}
},
+
"node_modules/@atproto/did": {
+
"version": "0.1.1",
+
"resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.1.1.tgz",
+
"integrity": "sha512-FA+U8C8ACQLjG/TSgtaQyjvXxzOYzwK0+T6FJ1oj2BtKUixq4t8zpvo4zdIrnVimXeGQWo1/U1ghke58SmRpmQ==",
+
"dev": true,
+
"dependencies": {
+
"zod": "^3.23.8"
+
}
+
},
+
"node_modules/@atproto/jwk": {
+
"version": "0.1.1",
+
"resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.1.tgz",
+
"integrity": "sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==",
+
"dev": true,
+
"dependencies": {
+
"multiformats": "^9.9.0",
+
"zod": "^3.23.8"
+
}
+
},
+
"node_modules/@atproto/jwk-jose": {
+
"version": "0.1.2",
+
"resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.2.tgz",
+
"integrity": "sha512-lDwc/6lLn2aZ/JpyyggyjLFsJPMntrVzryyGUx5aNpuTS8SIuc4Ky0REhxqfLopQXJJZCuRRjagHG3uP05/moQ==",
+
"dev": true,
+
"dependencies": {
+
"@atproto/jwk": "0.1.1",
+
"jose": "^5.2.0"
+
}
+
},
+
"node_modules/@atproto/jwk-webcrypto": {
+
"version": "0.1.2",
+
"resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.1.2.tgz",
+
"integrity": "sha512-vTBUbUZXh0GI+6KJiPGukmI4BQEHFAij8fJJ4WnReF/hefAs3ISZtrWZHGBebz+q2EcExYlnhhlmxvDzV7veGw==",
+
"dev": true,
+
"dependencies": {
+
"@atproto/jwk": "0.1.1",
+
"@atproto/jwk-jose": "0.1.2"
+
}
+
},
"node_modules/@atproto/lexicon": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.1.tgz",
···
"@atproto/syntax": "^0.3.0",
"iso-datestring-validator": "^2.2.2",
"multiformats": "^9.9.0",
+
"zod": "^3.23.8"
+
}
+
},
+
"node_modules/@atproto/oauth-client": {
+
"version": "0.1.4",
+
"resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.1.4.tgz",
+
"integrity": "sha512-GjDTHPr0KhAk1dDS7VaTNT1JojgmLBideYOAsvzHwH8nmaE9DhBG+AU2/w3TYcCvfJl954RgzzO9pdJL+1JLaA==",
+
"dev": true,
+
"dependencies": {
+
"@atproto-labs/did-resolver": "0.1.2",
+
"@atproto-labs/fetch": "0.1.0",
+
"@atproto-labs/handle-resolver": "0.1.2",
+
"@atproto-labs/identity-resolver": "0.1.2",
+
"@atproto-labs/simple-store": "0.1.1",
+
"@atproto-labs/simple-store-memory": "0.1.1",
+
"@atproto/api": "0.13.1",
+
"@atproto/did": "0.1.1",
+
"@atproto/jwk": "0.1.1",
+
"@atproto/oauth-types": "0.1.2",
+
"@atproto/xrpc": "0.6.0",
+
"multiformats": "^9.9.0",
+
"zod": "^3.23.8"
+
}
+
},
+
"node_modules/@atproto/oauth-client-browser": {
+
"version": "0.1.4",
+
"resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.1.4.tgz",
+
"integrity": "sha512-RhBtW2uO9iIJ+b2M0HAYYbKKAGM9yKAqSMesy23AYE5XyVqCy9GSzkE3yFliW0m3tZZMohOGiJ4yWR6/QBkuvA==",
+
"dev": true,
+
"dependencies": {
+
"@atproto-labs/did-resolver": "0.1.2",
+
"@atproto-labs/handle-resolver": "0.1.2",
+
"@atproto-labs/simple-store": "0.1.1",
+
"@atproto/did": "0.1.1",
+
"@atproto/jwk": "0.1.1",
+
"@atproto/jwk-webcrypto": "0.1.2",
+
"@atproto/oauth-client": "0.1.4",
+
"@atproto/oauth-types": "0.1.2"
+
}
+
},
+
"node_modules/@atproto/oauth-types": {
+
"version": "0.1.2",
+
"resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.1.2.tgz",
+
"integrity": "sha512-yySPPTLxteFJ3O3xVWEhvBFx7rczgo4LK2nQNeqAPMZdYd5dpgvuZZ88nQQge074BfuOc0MWTnr0kPdxQMjjPw==",
+
"dev": true,
+
"dependencies": {
+
"@atproto/jwk": "0.1.1",
"zod": "^3.23.8"
}
},
···
"dev": true,
"bin": {
"jiti": "bin/jiti.js"
+
}
+
},
+
"node_modules/jose": {
+
"version": "5.7.0",
+
"resolved": "https://registry.npmjs.org/jose/-/jose-5.7.0.tgz",
+
"integrity": "sha512-3P9qfTYDVnNn642LCAqIKbTGb9a1TBxZ9ti5zEVEr48aDdflgRjhspWFb6WM4PzAfFbGMJYC4+803v8riCRAKw==",
+
"dev": true,
+
"funding": {
+
"url": "https://github.com/sponsors/panva"
},
"node_modules/js-tokens": {
+1
package.json
···
},
"license": "0BSD",
"devDependencies": {
+
"@atproto/oauth-client-browser": "^0.1.4",
"@tailwindcss/forms": "^0.5.7",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
+84 -79
src/App.tsx
···
} from "solid-js";
import { createStore } from "solid-js/store";
-
import { Agent, AtpAgent } from "@atproto/api";
+
import { Agent } from "@atproto/api";
+
import { BrowserOAuthClient } from "@atproto/oauth-client-browser";
enum RepoStatus {
BLOCKEDBY,
···
};
let [followRecords, setFollowRecords] = createStore<FollowRecord[]>([]);
-
let [notice, setNotice] = createSignal("");
+
let [loginState, setLoginState] = createSignal<boolean>();
-
const resolveHandle = async (handle: string) => {
-
const agent = new AtpAgent({
-
service: "https://public.api.bsky.app",
-
});
+
const client = await BrowserOAuthClient.load({
+
clientId: "https://oauth.cleanfollow-bsky.pages.dev/client-metadata.json",
+
handleResolver: "https://bsky.social",
+
});
-
try {
-
const res = await agent.com.atproto.identity.resolveHandle({
-
handle: handle,
-
});
-
return res.data.did;
-
} catch (e: any) {
-
setNotice(e.message);
-
}
-
};
-
-
const fetchServiceEndpoint = async (handle: string) => {
-
const did = await resolveHandle(handle);
-
if (!did) return;
-
-
const res = await fetch(
-
did.startsWith("did:web")
-
? "https://" + did.split(":")[2] + "/.well-known/did.json"
-
: "https://plc.directory/" + did,
-
);
-
-
return await res.json().then((doc) => {
-
for (const service of doc.service) {
-
if (service.id.includes("#atproto_pds")) {
-
return service.serviceEndpoint;
-
}
-
}
-
});
-
};
+
let appAgent: Agent;
+
let userHandle: string;
-
const loginBsky = async (handle: string, password: string) => {
-
const serviceURL = await fetchServiceEndpoint(handle);
+
const result: undefined | { agent: OAuthAgent; state?: string } = await client
+
.init()
+
.catch(() => {});
-
const agent = new AtpAgent({
-
service: serviceURL,
-
});
+
if (result) {
+
const { agent, state } = result;
+
appAgent = agent;
+
setLoginState(true);
+
if (state != null) {
+
console.log(
+
`${agent.sub} was successfully authenticated (state: ${state})`,
+
);
+
} else {
+
console.log(`${agent.sub} was restored (last active session)`);
+
}
+
const res = await appAgent.getProfile({ actor: appAgent.did! });
+
userHandle = res.data.handle;
+
}
+
const loginBsky = async (handle: string) => {
try {
-
await agent.login({
-
identifier: handle,
-
password: password,
+
await client.signIn(handle, {
+
state: "some value needed later",
+
signal: new AbortController().signal,
});
-
} catch (e: any) {
-
setNotice(e.message);
+
} catch (err) {
+
console.log(
+
'The user aborted the authorization process by navigating "back"',
+
);
}
+
};
-
return agent;
+
const logoutBsky = async () => {
+
const agent = result;
+
setLoginState(false);
+
if (agent) await client.revoke(agent.sub);
};
const Follows: Component = () => {
···
};
const Form: Component = () => {
-
const [handle, setHandle] = createSignal("");
-
const [password, setPassword] = createSignal("");
+
const [loginInput, setLoginInput] = createSignal("");
const [progress, setProgress] = createSignal(0);
const [followCount, setFollowCount] = createSignal(0);
-
-
let agent: Agent;
+
const [notice, setNotice] = createSignal("");
-
const fetchHiddenAccounts = async (handle: string, password: string) => {
-
const fetchFollows = async (agent: Agent) => {
+
const fetchHiddenAccounts = async () => {
+
const fetchFollows = async () => {
const PAGE_LIMIT = 100;
const fetchPage = async (cursor?: any) => {
-
return await agent.com.atproto.repo.listRecords({
-
repo: agent.did!,
+
return await appAgent.com.atproto.repo.listRecords({
+
repo: appAgent.did!,
collection: "app.bsky.graph.follow",
limit: PAGE_LIMIT,
cursor: cursor,
···
return follows;
};
-
setNotice("Logging in...");
-
agent = await loginBsky(handle, password);
-
if (!agent) return;
setNotice("");
setProgress(0);
-
await fetchFollows(agent).then((follows) =>
+
await fetchFollows().then((follows) =>
follows.forEach(async (record: any) => {
setFollowCount(follows.length);
try {
-
const res = await agent.getProfile({ actor: record.value.subject });
+
const res = await appAgent.getProfile({
+
actor: record.value.subject,
+
});
if (res.data.viewer?.blockedBy) {
setFollowRecords(followRecords.length, {
did: record.value.subject,
···
const BATCHSIZE = 200;
for (let i = 0; i < writes.length; i += BATCHSIZE) {
-
await agent.com.atproto.repo.applyWrites({
-
repo: agent.did!,
+
await appAgent.com.atproto.repo.applyWrites({
+
repo: appAgent.did!,
writes: writes.slice(i, i + BATCHSIZE),
});
}
···
return (
<div class="flex flex-col items-center">
<div class="flex flex-col items-center">
-
<input
-
type="text"
-
placeholder="Handle"
-
class="rounded-md py-1 pl-2 pr-2 mb-3 ring-1 ring-inset ring-gray-300"
-
onInput={(e) => setHandle(e.currentTarget.value)}
-
/>
-
<input
-
type="password"
-
placeholder="App Password"
-
class="rounded-md py-1 pl-2 pr-2 mb-5 ring-1 ring-inset ring-gray-300"
-
onInput={(e) => setPassword(e.currentTarget.value)}
-
/>
-
<div>
+
<Show when={!loginState()}>
+
<label for="handle">Handle:</label>
+
<input
+
type="text"
+
id="handle"
+
placeholder="user.bsky.social"
+
class="rounded-md mt-1 py-1 pl-2 pr-2 mb-3 ring-1 ring-inset ring-gray-300"
+
onInput={(e) => setLoginInput(e.currentTarget.value)}
+
/>
+
<button
+
type="button"
+
onclick={() => loginBsky(loginInput())}
+
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
+
>
+
Login
+
</button>
+
</Show>
+
<Show when={loginState()}>
+
<div class="mb-5">
+
Logged in as {userHandle} (
+
<a href="" class="text-red-600" onclick={() => logoutBsky()}>
+
Logout
+
</a>
+
)
+
</div>
<Show when={!followRecords.length}>
<button
type="button"
-
onclick={() => fetchHiddenAccounts(handle(), password())}
+
onclick={() => fetchHiddenAccounts()}
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Preview
···
<button
type="button"
onclick={() => unfollow()}
-
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
+
class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
>
Confirm
</button>
</Show>
-
</div>
+
</Show>
</div>
<Show when={notice()}>
<div class="m-3">{notice()}</div>
</Show>
-
<Show when={followCount()}>
+
<Show when={loginState() && followCount()}>
<div class="m-3">
Progress: {progress()}/{followCount()}
</div>
···
return (
<div class="flex flex-col items-center m-5">
<h1 class="text-2xl mb-5">cleanfollow-bsky</h1>
-
<div class="mb-5 text-center">
+
<div class="mb-3 text-center">
<p>
Unfollows blocked by, deleted, suspended, and deactivated accounts
</p>
···
</div>
</div>
<Form />
-
<Follows />
+
<Show when={loginState()}>
+
<Follows />
+
</Show>
</div>
);
};
+1
vite.config.ts
···
solidPlugin(),
],
server: {
+
host: "127.0.0.1",
port: 3000,
},
build: {