Skip to content

Commit 3ea7ef6

Browse files
committed
popularity contest
1 parent 2a3a7c5 commit 3ea7ef6

File tree

3 files changed

+297
-3
lines changed

3 files changed

+297
-3
lines changed

src/pages/apps/bsky-follow-suggestions.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { showHTTPError } from "../../components/error"
77
import { getProfileList, IExpandedProfileDetails } from "../../components/bsky"
88

99
const requestFrequency = 250
10-
const maxSuggestions = 250
10+
const maxSuggestions = 100
1111
const maxSuggestionDetailRequests = 1000
1212

1313
const Bsky = () => {
@@ -266,7 +266,7 @@ const Bsky = () => {
266266
onClick={() => {
267267
clearApplicationState()
268268
setError(null)
269-
setParams("handle", handleRef.current?.value || "")
269+
setParams("handle", handleRef.current?.value || myHandle || "")
270270
getSuggestions(handleRef.current?.value || "")
271271
setStarted(true)
272272
}}
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import React, { useEffect, useRef, useState } from "react"
2+
import { useLocation, navigate } from "@reach/router"
3+
import Layout from "../../components/layout"
4+
import Closer from "../../components/closer"
5+
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"
6+
import { showHTTPError } from "../../components/error"
7+
import { getProfileList, IExpandedProfileDetails } from "../../components/bsky"
8+
9+
const starletteRope = 5
10+
const requestFrequency = 250
11+
const absoluteMaxStarlettes = 100
12+
13+
const Bsky = () => {
14+
// START: GENERIC STATE
15+
// This kind of state is likely to be used in most applications.
16+
const location = useLocation()
17+
const searchParams = new URLSearchParams(location.search)
18+
const setParams = (key: string, value: string) => {
19+
const url = new URL(window.location.href)
20+
url.searchParams.set(key, value)
21+
window.history.pushState(null, "", url.toString())
22+
}
23+
const [error, setError] = useState<React.ReactNode>()
24+
const [started, setStarted] = useState<boolean>(false)
25+
const [done, setDone] = useState<boolean>(false)
26+
// END: GENERIC STATE
27+
28+
// START: APPLICATION STATE
29+
// Reset all of this stuff whenever there is an error
30+
// or whenever the user does something that implies a page refesh.
31+
const [profile, setProfile] = useState<ProfileViewDetailed | null>(null)
32+
const [popularity, setPopularity] = useState<{
33+
[key: string]: number
34+
}>({})
35+
const [popularityIndex, setPopularityIndex] = useState<number>(0)
36+
const [popularityDetails, setPopularityDetails] = useState<{
37+
[key: string]: IExpandedProfileDetails
38+
}>({})
39+
const clearApplicationState = () => {
40+
setProfile(null)
41+
setPopularity({})
42+
setPopularityIndex(0)
43+
setPopularityDetails({})
44+
setStarted(false)
45+
setDone(false)
46+
}
47+
// END: APPLICATION STATE
48+
49+
// START: UI STATE
50+
// This is similar to the application state,
51+
// different in that it doesn't need to be reset.
52+
const handleRef = useRef<HTMLInputElement | null>(null)
53+
// END: UI STATE
54+
55+
const myHandle = searchParams.get("handle")
56+
const maxStarlettes =
57+
absoluteMaxStarlettes < (profile?.followsCount || 0)
58+
? absoluteMaxStarlettes
59+
: profile?.followsCount || 0
60+
const maxPopularity = Object.values(popularity).sort((a, b) => b - a)[0] || 0
61+
62+
// Check for "get profile" conditions
63+
useEffect(() => {
64+
if (started && !done) {
65+
const timer = setTimeout(() => getProfile(myHandle), requestFrequency)
66+
return () => clearTimeout(timer)
67+
}
68+
}, [started, done])
69+
70+
// Run "check profile"
71+
const getProfile = async (handle: string | null) => {
72+
if (done) return
73+
if (!handle) return
74+
75+
const response = await fetch(
76+
`${process.env.GATSBY_API_URL}/bsky/${handle}/profile`
77+
)
78+
if (!response.ok) {
79+
clearApplicationState()
80+
showHTTPError(setError, response)
81+
return
82+
}
83+
const data: { [key: string]: ProfileViewDetailed } = await response.json()
84+
const profile = Object.values(data)[0]
85+
setProfile(() => profile)
86+
}
87+
88+
// Check for "get popularity" conditions
89+
useEffect(() => {
90+
if (started && !done && popularityIndex != -1) {
91+
getPopularity()
92+
}
93+
}, [started, done, popularityIndex])
94+
95+
// Run "get popularity"
96+
const getPopularity = async () => {
97+
if (done) return
98+
99+
const popularityCopy = { ...popularity }
100+
const response = await fetch(
101+
`${process.env.GATSBY_API_URL}/bsky/${myHandle}/popularity/${popularityIndex}`
102+
)
103+
if (!response.ok) {
104+
clearApplicationState()
105+
showHTTPError(setError, response)
106+
return
107+
}
108+
const data: { popularity: { [key: string]: number }; next: number } =
109+
await response.json()
110+
111+
Object.entries(data.popularity).forEach(([handle, followers]) => {
112+
if (popularityCopy[handle]) {
113+
popularityCopy[handle] += followers
114+
} else {
115+
popularityCopy[handle] = followers
116+
}
117+
})
118+
119+
setPopularity(() => popularityCopy)
120+
setPopularityIndex(() => data.next)
121+
}
122+
123+
// Check for "get popularity details" conditions
124+
useEffect(() => {
125+
if (
126+
started &&
127+
!done &&
128+
Object.keys(popularity).length > 0 &&
129+
Object.keys(popularityDetails).length < maxStarlettes
130+
) {
131+
getPopularityDetails()
132+
}
133+
}, [started, done, popularity, popularityDetails])
134+
135+
// Run "get popularity details"
136+
const getPopularityDetails = async () => {
137+
if (done) return
138+
139+
const handle = Object.keys(popularity)
140+
.filter((handle) => {
141+
// Don't request details for handles we already have details for
142+
return !popularityDetails[handle]
143+
})
144+
.filter((handle) => {
145+
// Don't request details for handles that have a score of 0 or less than goal
146+
return popularity[handle] > starletteRope
147+
})[0]
148+
149+
const response = await fetch(
150+
`${process.env.GATSBY_API_URL}/bsky/${handle}/profile`
151+
)
152+
if (!response.ok) {
153+
clearApplicationState()
154+
showHTTPError(setError, response)
155+
return
156+
}
157+
const data: { [key: string]: ProfileViewDetailed } = await response.json()
158+
159+
setPopularityDetails((prevDetails) => ({
160+
...prevDetails,
161+
[Object.values(data)[0].handle]: {
162+
profile: Object.values(data)[0],
163+
score: popularity[Object.values(data)[0].handle],
164+
},
165+
}))
166+
}
167+
168+
// Check done ness
169+
useEffect(() => {
170+
if (
171+
started &&
172+
!done &&
173+
Object.keys(popularityDetails).length != 0 &&
174+
Object.keys(popularityDetails).length == maxStarlettes
175+
) {
176+
setDone(true)
177+
}
178+
}, [started, done, popularityDetails])
179+
180+
const contentBlock = (
181+
<div>
182+
<div className="post-content">
183+
<div className="flex flex-column align-items-center">
184+
<h3>
185+
✨✨✨ Starlettes: {Object.values(popularityDetails).length} ✨✨✨
186+
</h3>
187+
<hr />
188+
</div>
189+
{getProfileList(
190+
Object.values(popularityDetails).sort((a, b) => {
191+
return (b.score || 0) - (a.score || 0)
192+
}),
193+
(details: IExpandedProfileDetails | null) => {
194+
return (
195+
<div>
196+
<p>
197+
Adoring Fans: {details?.score}{" "}
198+
{"⭐️".repeat(
199+
Math.round((10 * (details?.score || 0)) / maxPopularity)
200+
)}
201+
</p>
202+
<div className="progress" style={{ height: "20px" }}>
203+
<div
204+
className="progress-bar bg-success"
205+
role="progressbar"
206+
style={{
207+
width:
208+
(
209+
(100 * (details?.score || 0)) /
210+
maxPopularity
211+
).toString() + "%",
212+
}}
213+
aria-valuenow={details?.score || 0}
214+
aria-valuemin={0}
215+
aria-valuemax={maxPopularity}
216+
></div>
217+
</div>
218+
</div>
219+
)
220+
}
221+
)}
222+
</div>
223+
</div>
224+
)
225+
226+
return (
227+
<Layout>
228+
<section className="post-body">
229+
<div className="post-header">
230+
<h2>
231+
<p>Bluesky Popularity Contest</p>
232+
</h2>
233+
<div className="input-group mb-3">
234+
<input
235+
type="text"
236+
className="form-control"
237+
defaultValue={myHandle || ""}
238+
placeholder="enter handle"
239+
aria-label="enter handle"
240+
aria-describedby="button-addon2"
241+
ref={handleRef}
242+
/>
243+
<button
244+
className="btn btn-outline-secondary"
245+
type="button"
246+
onClick={() => {
247+
clearApplicationState()
248+
setError(null)
249+
setParams("handle", handleRef.current?.value || myHandle || "")
250+
setStarted(true)
251+
}}
252+
>
253+
Paparazzi!
254+
</button>
255+
</div>
256+
<p className="large">📸 📸 📸</p>
257+
<p>
258+
You are a daring camera-enby, out here to capture the stars! They
259+
won't avoid your notice, no matter how hard they try! The best of
260+
the best will be crowned the most popular starlette in the halls of
261+
Bluesky! This is a popularity contest, after all!
262+
</p>
263+
<p>Something broken? Reload the page and try again.</p>
264+
{started && !done ? (
265+
<div className="flex-center flex">
266+
<div className="spinner-border" role="status">
267+
<span className="sr-only">Loading...</span>
268+
</div>
269+
<div className="spinner-border" role="status">
270+
<span className="sr-only">Loading...</span>
271+
</div>
272+
<div className="spinner-border" role="status">
273+
<span className="sr-only">Loading...</span>
274+
</div>
275+
</div>
276+
) : null}
277+
</div>
278+
<div className="post-content">
279+
{error ? error : null}
280+
<div>
281+
<div className="flex flex-column gap-4">{contentBlock}</div>
282+
</div>
283+
</div>
284+
<Closer />
285+
</section>
286+
</Layout>
287+
)
288+
}
289+
290+
export default Bsky

src/sass/post.scss

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@
8080
}
8181
}
8282

83+
p.large {
84+
font-size: 4em;
85+
}
86+
8387
.post-content {
8488
padding: 0.5em;
8589
background: #fff;
@@ -109,7 +113,7 @@
109113
}
110114
}
111115

112-
button {
116+
button & .button {
113117
padding: 0.5em;
114118
margin: 0.5em;
115119
border-radius: 1em;

0 commit comments

Comments
 (0)