|
| 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 |
0 commit comments