A Pen by Tibor Martini on CodePen.
Created
October 29, 2023 15:40
-
-
Save tibor/33a294df924afe4e212231b1626a702b 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> | |
<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 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> | |
<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> |
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, | |
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"); |
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; | |
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; | |
} | |
.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; | |
} | |
.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