Skip to content

Instantly share code, notes, and snippets.

@tibor
Created October 29, 2023 16:37
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/8df74fdcacbbdaec65b3bf2517647f45 to your computer and use it in GitHub Desktop.
Save tibor/8df74fdcacbbdaec65b3bf2517647f45 to your computer and use it in GitHub Desktop.
eYxNmZK
<div id="app" class="bgcfff">
<div v-cloak>
<section v-if="!access.access">
<h1 class="mt20">Who to Follow on Bluesky?</h1>
<p class="mt05">How the app works:</p>
<ol class="mt05">
<li>
<p>We need your login data to get your Follows (i.e. accounts that you follow). <strong>Please use an <a href="https://bsky.app/settings/app-passwords" target="_blank" class="texta">App Password</a>.</strong> App Passwords are distinct from your login password, have only limited access, and you can withdraw them at any time in your profile.</p>
<form class="df fww aife">
<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 bgcfff c000 border" placeholder="@account.bsky.social">
</label>
<label class="ml05 mt05 fs0625 ttu">
Password
<input type="password" v-model="login.password" class="pt05 pr05 pb05 pl05 fs100 db bgcfff c000 border" placeholder="abcd-abcd-abcd-abcd">
</label>
</div>
<input type="submit" value="Login" @click.prevent="newSession()" class="pt05 pr05 pb05 pl05 border br bs fs0625 fw800 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>
</section>
<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-if="progress.value > 0" class="mt05">
<label class="db mt10">
You follow {{progress.max}} accounts, we checked {{progress.value}} of them
<progress :value="progress.value" :max="progress.max" class="db"></progress>
</label>
</div>
<div v-if="requestLimit" class="pt05 pr05 pb05 pl05 bgcyellow cyellow border br mt05">
At the moment we hit an API limit by Bluesky. The app should start working again in around {{countDownSec}} seconds.
</div>
<table class="lsn mt20 w100">
<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 fww aic">
<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 br pr025 pl025 ml05 fw800">🚨Impersonation</span>
<span v-if="acc?.viewer?.followedBy" class="bgcyellow cyellow br pr025 pl025 ml05 fw800">😊 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(accountList[acc.did])" :class="`pt025 pr05 pb025 pl05 br fs0625 ttu fw800 bn bs border cp w100 tac ${accountList[acc.did]?.viewer?.following ? 'bgcred cred' : ''}`">{{accountList[acc.did]?.viewer?.following ? 'Unfollow' : 'Follow'}}</button>
</td>
</tr>
</table>
</div>
</div>
const { createApp } = Vue;
createApp({
data() {
return {
login: {
identifier: "",
password: "",
isAppPwd: null
},
requestLimit: false,
requestLimitReset: null,
countDownSec: 0,
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) => {
if (!req.ok)
return Promise.reject({
status: req.status,
ok: req.ok,
statusText: req.statusText
});
return 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);
});
})
.catch((err) => {
return Promise.reject({ status: 429 });
});
},
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((data) => this.getMyFollowings(data.did))
.catch((err) => {
this.retryRequest(() => this.newSession());
});
},
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) {
return Promise.reject({
status: resp.status,
statusText: resp.statusText
});
}
this.requestLimitReset = resp.headers.get(
"Ratelimit-Reset"
);
return resp.json();
})
.catch((err) => {
return Promise.reject({ status: 429 });
});
},
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)
)
);
}
})
.catch((err) => {
if (err === "TypeError: Failed to fetch") {
this.retryRequest(() =>
this.getMyFollowings(handle, cursor)
);
}
});
},
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.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.pause(0).then(() => this.sortAccounts());
}
})
.catch((err) => {
if (err === "TypeError: Failed to fetch") {
this.retryRequest(() =>
this.getMyFollowingsFollowings(did, cursor)
);
}
});
},
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 && !follow) {
return resp.json();
} else if (resp.ok) {
console.log("Unfollowed erfolgreich");
return {
uri: null
};
} else {
return Promise.reject(resp);
}
})
.then((data) => {
this.accountList[did].viewer.following = data.uri;
})
.catch((err) => {
if (err === "TypeError: Failed to fetch") {
this.retryRequest(() => this.followUnfollow(acc));
}
});
},
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 * 1000));
},
retryRequest(fn) {
this.requestLimit = true;
let count = this.requestLimitReset
? this.requestLimitReset - Date.now()
: 300;
this.countDown(count);
this.pause(count).then(() => {
this.requestLimit = false;
fn;
});
},
countDown(start) {
if (start <= 0) return;
this.pause(1).then(() => {
this.countDown(start - 1);
this.countDownSec = start - 1;
});
}
},
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))
.catch((err) => {
this.retryRequest(() => this.mounted());
});
}
}).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;
--black: #000;
--blue: hsl(232 91% 57% / 1);
font-family: sans-serif;
max-width: 100%;
color-scheme: light dark;
}
[v-cloak] {
display: none;
}
.df {
display: flex;
}
.db {
display: block;
}
.fww {
flex-wrap: wrap;
}
.aic {
align-items: center;
}
.aife {
align-items: flex-end;
}
.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;
}
.w100 {
width: 100%;
}
.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;
}
.texta {
color: currentColor;
text-decoration: none;
background: linear-gradient(90deg, var(--blue), var(--blue)) no-repeat;
background-position: 0% 90%;
background-size: 100% 10%;
transition: 200ms 100ms all;
}
.texta:hover {
color: var(--white);
background-position: 0 100%;
background-size: 100% 100%;
}
.bgcfff {
background-color: var(--white);
}
.bgcred {
background-color: hsl(0 75% 80% / 1);
}
.bgcyellow {
background-color: hsl(50 75% 90% / 1);
}
.bgcgreen {
background-color: hsl(120 75% 80% / 1);
}
.cfff {
color: var(--white);
}
.c000 {
color: var(--black);
}
.cred {
color: hsl(0 80% 20% / 1);
}
.cyellow {
color: hsl(40 50% 20% / 1);
}
.cgreen {
color: hsl(120 75% 20% / 1);
}
.cp {
cursor: pointer;
}
.tac {
text-align: center;
}
@media (prefers-color-scheme: dark) {
:root {
--white: #000;
--black: #fff;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment