A Pen by Tibor Martini on CodePen.
Created
October 30, 2023 19:52
-
-
Save tibor/52be504bb9300448c61f30bdce8fc1e1 to your computer and use it in GitHub Desktop.
eYxNmZK
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<div id="app" class="bgcfff"> | |
<div v-cloak> | |
<section v-if="!access.access && !this.requestLimit"> | |
<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"> | |
App 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> | |
<div class="df"> | |
<button @click.prevent="logout()" class="pt05 pr05 pb05 pl05 br bs ttu fs0625 border cp fw800">Logout</button> | |
</div> | |
<div v-if="loadingMyFollowings" class="mt10"> | |
<div>Loading your Follows</div> | |
</div> | |
<div v-if="progress.value > 0 && progress.value < progress.max" 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 class="mt05 df"><label class="pt05 pr05 pb05 pl05 border br bs cp"><input type="checkbox" v-model="showFollowing"> Show accounts you already follow</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"> | |
<template v-for="acc in accountArray" :key="acc.did"> | |
<tr v-if="!acc?.viewer?.following || showFollowing"> | |
<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> | |
</template> | |
</table> | |
</div> | |
</div> | |
</div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const { createApp } = Vue; | |
createApp({ | |
data() { | |
return { | |
login: { | |
identifier: "", | |
password: "", | |
isAppPwd: null | |
}, | |
requestLimit: false, | |
requestLimitReset: null, | |
showFollowing: true, | |
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); | |
}); | |
return data; | |
}) | |
.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.replace("@", ""), | |
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) => { | |
console.log(err); | |
this.retryRequest(); | |
this.pause(300).then(() => 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(did, cursor) { | |
this.loadingMyFollowings = true; | |
this.getUsersFollowings(did, cursor) | |
.then((data) => { | |
data.follows.forEach((follow) => { | |
this.usersImFollowing[follow["did"]] = { | |
...follow | |
}; | |
this.progress.max++; | |
}); | |
if (data?.cursor) { | |
this.pause(0).then(() => | |
this.getMyFollowings(did, data.cursor) | |
); | |
} else { | |
this.loadingMyFollowings = false; | |
Promise.all( | |
Object.keys(this.usersImFollowing).map((user) => | |
this.getMyFollowingsFollowings(user) | |
) | |
); | |
} | |
}) | |
.catch((err) => { | |
this.retryRequest(); | |
this.pause(300).then(() => | |
this.getMyFollowings(did, cursor) | |
); | |
}); | |
}, | |
getMyFollowingsFollowings(did, cursor, count) { | |
if (!count) count = 0; | |
if (count > 20) { | |
this.progress.value++; | |
delete this.usersImFollowing[did]; | |
return; | |
} | |
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, | |
count++ | |
); | |
} else { | |
this.progress.value++; | |
delete this.usersImFollowing[did]; | |
this.sortAccounts(); | |
} | |
}) | |
.catch((err) => { | |
this.retryRequest(); | |
this.pause(300).then(() => | |
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) => { | |
this.retryRequest(); | |
this.pause(300).then(() => 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() { | |
console.log(this.requestLimit); | |
if (this.requestLimit) return; | |
this.requestLimit = true; | |
let count = this.requestLimitReset | |
? this.requestLimitReset - Date.now() | |
: 300; | |
console.log(count); | |
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 = Math.round(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) => { | |
console.log(err); | |
this.retryRequest(); | |
this.pause(300).then(() => this.mounted()); | |
}); | |
} | |
}, | |
mounted() { | |
this.mounted(); | |
} | |
}).mount("#app"); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.2.37/vue.global.prod.min.js"></script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
: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