Skip to content

Instantly share code, notes, and snippets.

@tibor
Created October 29, 2023 13:38
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tibor/a7c2dd96dc2474a9df62ab24493455d9 to your computer and use it in GitHub Desktop.
Save tibor/a7c2dd96dc2474a9df62ab24493455d9 to your computer and use it in GitHub Desktop.
eYxNmZK
<div id="app">
<div v-cloak>
<ol v-if="!access.access">
<li>
<p>We need your login data to get your Follows (i.e. accounts that you follow). Please use an App Password.</p>
<form>
<div class="df fww">
<label class="ml05 mt05 fs0625 ttu db">
Handle
<input type="text" v-model="login.identifier" class="pt05 pr05 pb05 pl05 fs100 db" placeholder="@account.bsky.social">
</label>
<label class="ml05 mt05 fs0625 ttu">
Password
<input type="text" v-model="login.password" class="pt05 pr05 pb05 pl05 fs100 db" placeholder="abcd-abcd-abcd-abcd">
</label>
</div>
<input type="submit" value="Login" @click.prevent="newSession()" class="pt05 pr05 pb05 pl05 border br bs fs0625 ttu bgcgreen cgreen ml05 mt10">
</form>
<div v-if="login.isAppPwd !== null && !login.isAppPwd" class="bgcyellow cyellow border br pt025 pr05 pb025 pl05 mt05">Please use an <a href="https://bsky.app/settings/app-passwords" target="_blank" class="fw800">app password</a> instead of your regular password</div>
</li>
<li class="mt05">We get all follows of your follows (i.e. accounts in your network)</li>
<li class="mt05">We show you a list of accounts sorted by popularity in your network (i.e. accounts which are followed by the most people in your network)</li>
</ol>
<div v-else class="df">
<button @click.prevent="logout()" class="pt05 pr05 pb05 pl05 br bs ttu fs0625 border">Logout</button>
</div>
<div v-if="loadingMyFollowings" class="mt10">
<div>Loading your Follows</div>
</div>
<div v-else-if="progress.value > 0" class="mt05">
<div class="mt10">You follow {{progress.max}} Accounts at the moment</div>
<label class="db fs0625">
{{progress.value}} of {{progress.max}} Accounts checked
<progress :value="progress.value" :max="progress.max" class="db"></progress>
</label>
</div>
<table class="lsn mt20">
<tr v-for="acc in accountArray" :key="acc.did">
<td class="mt05">
<img :src="acc.avatar" width=40 height=40 loading=lazy class="br100">
</td>
<td class="mt05 ml05">
<div class="df jcsb fww">
<div class="fw800 fs100" :title="acc.description">{{acc.displayName}}
</div>
<div class="fs0625 ttu">
<span v-if="acc.labels.find(label => label?.val === 'impersonation')" class="bgcred cred border br pr025 pl025">🚨Impersonation</span>
<span v-if="acc?.viewer?.followedBy" class="bgcyellow cyellow border br pr025 pl025">😊 Follows you</span>
</div>
</div>
<div class="fs0625"> <a :href="`https://bsky.app/profile/${acc.handle}`" target="_blank" class="noa">@{{acc.handle}}</a></div>
</td>
<td class="mt05 ml05">
<button @click.prevent="followUnfollow(acc)" :class="`pt025 pr05 pb025 pl05 br fs0625 ttu fw800 bn bs border cp ${acc.viewer.following ? 'bgcred cred' : ''}`">{{acc.viewer.following ? 'Unfollow' : 'Follow'}}</button>
</td>
</tr>
</table>
</div>
</div>
const { createApp } = Vue;
createApp({
data() {
return {
login: {
identifier: "",
password: "",
isAppPwd: null
},
access: {},
usersImFollowing: {},
accountList: {},
accountArray: [],
loadingMyFollowings: false,
progress: {
value: 0,
max: 0
}
};
},
methods: {
checkPwd() {
let regex = /([a-z\d]{4}\-){4}/;
return (this.login.isAppPwd = regex.test(
this.login.password + "-"
));
},
logout() {
this.access.length = 0;
localStorage.clear();
},
requestTokens(url, options) {
return fetch(url, options)
.then((req) => req.json())
.then((data) => {
this.access = {
access: data.accessJwt,
refresh: data.refreshJwt,
did: data.did
};
Object.entries(this.access).forEach(([key, value]) => {
localStorage.setItem(key, value);
});
});
},
newSession() {
if (!this.checkPwd()) return;
if (this.access.access) return;
let headers = new Headers();
headers.append("Content-Type", "application/json");
let loginData = JSON.stringify({
identifier: this.login.identifier,
password: this.login.password
});
let options = {
headers,
body: loginData,
method: "POST"
};
this.requestTokens(
"https://bsky.social/xrpc/com.atproto.server.createSession",
options
).then(() => this.getMyFollowings(data.did));
},
getUsersFollowings(did, cursor) {
let headers = new Headers();
headers.append("Content-Type", "application/json");
headers.append("Authorization", "Bearer " + this.access.access);
options = {
headers
};
return fetch(
`https://bsky.social/xrpc/app.bsky.graph.getFollows?actor=${did}&limit=100${
cursor ? "&cursor=" + cursor : ""
}`,
options
)
.then((resp) => {
if (!resp.ok) throw Error(resp.status_text);
return resp.json();
})
.catch((err) => {
console.log("Error getUsersFollowing", err);
//timeout and handle again
});
},
getMyFollowings(handle, cursor) {
this.loadingMyFollowings = true;
this.getUsersFollowings(handle, cursor)
.then((data) => {
data.follows.forEach((follow) => {
this.usersImFollowing[follow["did"]] = {
...follow
};
this.progress.max++;
});
if (data?.cursor) {
this.pause(0).then(() =>
this.getMyFollowings(handle, data.cursor)
);
} else {
this.loadingMyFollowings = false;
Promise.all(
Object.keys(this.usersImFollowing).map((user) =>
this.getMyFollowingsFollowings(user)
)
).then((values) => console.log(values));
}
})
.catch((err) => {
console.log("Error getMyFollowings", err);
});
},
getMyFollowingsFollowings(did, cursor) {
if (!did) {
did = Object.keys(this.usersImFollowing)[0];
if (this.usersImFollowing.length === 0) return;
}
this.getUsersFollowings(did, cursor)
.then((data) => {
data.follows.forEach((user) => {
//if (!!user?.viewer?.following) return;
if (user.did === this.access.did) return;
this.accountList[user["did"]] = {
...user,
followers:
(this.accountList[user["did"]]?.followers ||
0) + 1
};
});
if (data?.cursor) {
this.getMyFollowingsFollowings(did, data.cursor);
} else {
this.progress.value++;
delete this.usersImFollowing[did];
//this.getMyFollowingsFollowings();
this.pause(0).then(() => this.sortAccounts());
}
})
.catch((err) =>
console.log("Error getFollowingsFollowings", err, did)
);
},
followUnfollow(acc) {
console.log(`Funktion followUnfollow`, acc);
let handle = acc.handle;
let did = acc.did;
let follow = !!acc?.viewer?.following;
let url = "https://bsky.social/xrpc/com.atproto.repo.createRecord";
let payload = {
repo: this.access.did,
collection: "app.bsky.graph.follow",
record: {
subject: did,
createdAt: "2030-10-18T05:31:12.156888Z"
}
};
if (follow) {
delete payload.record;
payload["rkey"] = acc?.viewer?.following
?.split("/")
?.splice(-1)[0];
url = "https://bsky.social/xrpc/com.atproto.repo.deleteRecord";
}
let headers = new Headers();
headers.append("Content-Type", "application/json");
headers.append("Authorization", `Bearer ${this.access.access}`);
let options = {
headers,
body: JSON.stringify(payload),
method: "POST"
};
fetch(url, options)
.then((resp) => {
if (resp.ok && resp.json()) {
return resp.json();
} else if (resp.ok) {
return;
} else {
return Promise.reject(resp);
}
})
.then((data) => {
this.accountList[did].viewer.following = data?.uri;
console.log(
`Function followUnfollow fertig ${handle}`,
data
);
})
.catch((err) => {
console.log(
`Error followUnfollow`,
err.status,
err.statusText
);
//err.json().then((a) => console.log(a));
});
},
sortAccounts() {
this.accountArray = Object.values(this.accountList).sort(
(a, b) => b.followers - a.followers
);
this.accountArray = this.accountArray.slice(0, 250);
},
pause(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
},
mounted() {
if (!localStorage.getItem("refresh")) return;
let headers = new Headers();
headers.append("Content-Type", "application/json");
headers.append(
"Authorization",
"Bearer " + localStorage.getItem("refresh")
);
let options = {
headers,
method: "POST"
};
this.requestTokens(
"https://bsky.social/xrpc/com.atproto.server.refreshSession",
options
).then(() => this.getMyFollowings(this.access.did));
}
}).mount("#app");
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.2.37/vue.global.prod.min.js"></script>
:root {
font-size: 100%;
margin: 0 auto;
padding: 0;
--lh: 1;
--white: #fff;
font-family: sans-serif;
max-width: 320px;
}
[v-cloak] {
display: none;
}
.df {
display: flex;
}
.db {
display: block;
}
.fww {
flex-wrap: wrap;
}
.jcsb {
justify-content: space-between;
}
.fs100 {
font-size: 1rem;
}
.fs0625 {
font-size: calc(0.625rem * var(--lh));
line-height: calc(var(--lh) / 0.625);
}
.fw800 {
font-weight: 800;
}
.ttu {
text-transform: uppercase;
letter-spacing: 0.1px;
}
.border {
border: 1px solid currentColor;
}
.pt05 {
padding-top: calc(0.5rem * var(--lh));
}
.pt025 {
padding-top: calc(0.25rem * var(--lh));
}
.pr05 {
padding-right: calc(0.5rem * var(--lh));
}
.pr025 {
padding-right: calc(0.25rem * var(--lh));
}
.pb05 {
padding-bottom: calc(0.5rem * var(--lh));
}
.pb025 {
padding-bottom: calc(0.25rem * var(--lh));
}
.pl05 {
padding-left: calc(0.5rem * var(--lh));
}
.pl025 {
padding-left: calc(0.25rem * var(--lh));
}
.mt20 {
margin-top: calc(2rem * var(--lh));
}
.mt10 {
margin-top: calc(1rem * var(--lh));
}
.mt05 {
margin-top: calc(0.5rem * var(--lh));
}
.ml05 {
margin-left: calc(0.5rem * var(--lh));
}
.lsn {
list-style: none;
}
.bn {
border: none;
}
.noa {
text-decoration: none;
color: currentColor;
}
.br {
border-radius: 4px;
}
.br100 {
border-radius: 1000px;
}
.bs {
box-shadow: 0rem 0.05rem 0.1rem -0.05rem hsla(0deg, 0%, 0%, 0.5);
}
.bs:hover {
box-shadow: 0rem 0.15rem 0.25rem -0.2rem hsla(0deg, 0%, 0%, 0.5);
}
.bs:active {
box-shadow: 0rem 0.05rem 0.1rem -0.05rem hsla(0deg, 0%, 0%, 0.5) inset;
}
.bgcred {
background-color: hsl(0 75% 80% / 1);
}
.bgcyellow {
background-color: hsl(60 75% 80% / 1);
}
.bgcgreen {
background-color: hsl(120 75% 80% / 1);
}
.cfff {
color: var(--white);
}
.cred {
color: hsl(0 80% 20% / 1);
}
.cyellow {
color: hsl(40 50% 20% / 1);
}
.cp {
cursor: pointer;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment