an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
at main 338 lines 12 kB view raw
1//import * as ATPAPI from "@atproto/api" 2import { useAtom } from "jotai"; 3import * as React from "react"; 4 5import { 6 usePollData, 7 usePollMutationQueue, 8} from "~/providers/PollMutationQueueProvider"; 9//import { useAuth } from "~/providers/UnifiedAuthProvider"; 10import { renderSnack } from "~/routes/__root"; 11import { imgCDNAtom } from "~/utils/atoms"; 12import { useQueryArbitrary, useQueryConstellation, useQueryProfile } from "~/utils/useQuery"; 13 14import { type embedtryfall } from "./PostEmbeds"; 15import { ExternalLinkEmbed } from "./PostEmbeds"; 16 17export function PollEmbed({ 18 did, 19 rkey, 20 redactedLoading, 21 embedtryfall 22}: { 23 did: string; 24 rkey: string; 25 redactedLoading?: boolean; 26 embedtryfall?: embedtryfall; 27}) { 28 //const { agent } = useAuth(); 29 const { refreshPollData } = usePollMutationQueue(); 30 const pollUri = `at://${did}/app.reddwarf.embed.poll/${rkey}`; 31 const { data: pollRecord, isLoading, error } = useQueryArbitrary(pollUri); 32 const dontLoadPolls = embedtryfall && (isLoading || pollRecord === undefined || error !== null) || false 33 34 const { data: voteCountsA } = useQueryConstellation({ 35 method: "/links/count/distinct-dids", 36 target: pollUri, 37 collection: "app.reddwarf.poll.vote.a", 38 path: ".subject.uri", 39 customkey: "constellation-polls", 40 enabled: !dontLoadPolls 41 }); 42 43 const { data: voteCountsB } = useQueryConstellation({ 44 method: "/links/count/distinct-dids", 45 target: pollUri, 46 collection: "app.reddwarf.poll.vote.b", 47 path: ".subject.uri", 48 customkey: "constellation-polls", 49 enabled: !dontLoadPolls 50 }); 51 52 const { data: voteCountsC } = useQueryConstellation({ 53 method: "/links/count/distinct-dids", 54 target: pollUri, 55 collection: "app.reddwarf.poll.vote.c", 56 path: ".subject.uri", 57 customkey: "constellation-polls", 58 enabled: !dontLoadPolls 59 }); 60 61 const { data: voteCountsD } = useQueryConstellation({ 62 method: "/links/count/distinct-dids", 63 target: pollUri, 64 collection: "app.reddwarf.poll.vote.d", 65 path: ".subject.uri", 66 customkey: "constellation-polls", 67 enabled: !dontLoadPolls 68 }); 69 70 // const { data: votersA } = useQueryConstellation({ 71 // method: "/links", 72 // target: pollUri, 73 // collection: "app.reddwarf.poll.vote.a", 74 // path: ".subject.uri", 75 // customkey: "constellation-polls", 76 // enabled: !isLoading 77 // }); 78 // const { data: votersB } = useQueryConstellation({ 79 // method: "/links", 80 // target: pollUri, 81 // collection: "app.reddwarf.poll.vote.b", 82 // path: ".subject.uri", 83 // customkey: "constellation-polls", 84 // enabled: !isLoading 85 // }); 86 // const { data: votersC } = useQueryConstellation({ 87 // method: "/links", 88 // target: pollUri, 89 // collection: "app.reddwarf.poll.vote.c", 90 // path: ".subject.uri", 91 // customkey: "constellation-polls", 92 // enabled: !isLoading 93 // }); 94 // const { data: votersD } = useQueryConstellation({ 95 // method: "/links", 96 // target: pollUri, 97 // collection: "app.reddwarf.poll.vote.d", 98 // path: ".subject.uri", 99 // customkey: "constellation-polls", 100 // enabled: !isLoading 101 // }); 102 103 const poll = { 104 ...(pollRecord?.value ?? {}), 105 multiple: true, 106 } as { 107 a: string; 108 b: string; 109 c?: string; 110 d?: string; 111 expiry?: string; 112 multiple?: boolean; 113 createdAt: string; 114 }; 115 116 const options = [poll.a, poll.b, poll.c, poll.d].filter(Boolean); 117 118 const serverCounts = { 119 a: parseInt((voteCountsA as any)?.total || "0"), 120 b: parseInt((voteCountsB as any)?.total || "0"), 121 c: parseInt((voteCountsC as any)?.total || "0"), 122 d: parseInt((voteCountsD as any)?.total || "0"), 123 }; 124 125 const { results, totalVotes, handleVote, votersA, votersB, votersC, votersD } = usePollData( 126 pollUri, 127 pollRecord?.cid, 128 !!poll.multiple, 129 serverCounts, 130 !dontLoadPolls 131 ); 132 if (dontLoadPolls && embedtryfall) { 133 const link = embedtryfall.embed.external; 134 const onOpen = embedtryfall.onOpen 135 return ( 136 <> 137 {/* pass thru confirm<br /> 138 embedtryfall = {JSON.stringify(embedtryfall, null, 2)}<br /> 139 isLoading = {JSON.stringify(isLoading, null, 2)}<br /> 140 pollRecord = {JSON.stringify(pollRecord, null, 2)}<br /> 141 error = {JSON.stringify(error, null, 2)}<br /> */} 142 <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} redactedLoading={redactedLoading}/> 143 </> 144 ) 145 } 146 if (isLoading && !embedtryfall) { 147 return ( 148 <div className="animate-pulse"> 149 <div className="flex items-center gap-2 mb-3"> 150 <div className="h-6 w-20 bg-gray-300 dark:bg-gray-600 rounded"></div> 151 <div className="h-6 w-32 bg-gray-300 dark:bg-gray-600 rounded"></div> 152 </div> 153 <div className="space-y-2"> 154 <div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg"></div> 155 <div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg w-3/4"></div> 156 </div> 157 </div> 158 ); 159 } 160 161 if (error || !pollRecord?.value) { 162 return <div className="text-red-500 text-sm p-2">Failed to load poll</div>; 163 } 164 const isExpired = false; 165 166 return ( 167 <> 168 <div className={`${redactedLoading ? "pointer-events-none": ""} my-4`}> 169 <div className="mb-4 flex items-center gap-3"> 170 <div className="flex items-center gap-1.5 rounded-lg border-gray-300 dark:border-gray-600 pl-2 pr-2.5 py-1 text-sm font-medium uppercase tracking-wide text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800"> 171 <IconMdiGlobe /> 172 <span>Public Poll</span> 173 </div> 174 175 <span className="text-sm font-normal text-gray-500 dark:text-gray-400 flex flex-row items-center gap-1"> 176 {poll.multiple ? ( 177 <IconMdiCheckboxMultipleMarked /> 178 ) : ( 179 <IconMdiCheckCircle /> 180 )} 181 <span className="md:flex hidden"> 182 {poll.multiple 183 ? "Select one or more options" 184 : "Select one option"} 185 </span> 186 </span> 187 188 <button 189 onClick={(e) => { 190 e.stopPropagation(); 191 refreshPollData(pollUri); 192 }} 193 className="ml-auto rounded-full h-8 outline outline-gray-200 text-gray-700 dark:outline-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors px-3 py-1 text-[12px] flex items-center gap-1" 194 title="Refresh poll data" 195 > 196 <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> 197 <path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" /> 198 </svg> 199 Refresh 200 </button> 201 </div> 202 203 <div className="space-y-3"> 204 {options.map((optionText, index) => { 205 const optionKey = ["a", "b", "c", "d"][index] as 206 | "a" 207 | "b" 208 | "c" 209 | "d"; 210 const { topVoterDids } = results[optionKey]; 211 const optionState = results[optionKey]; 212 const hasVotedForOption = optionState.hasVoted; 213 const votePercentage = 214 totalVotes > 0 ? (optionState.count / totalVotes) * 100 : 0; 215 216 const votersData = (() => { 217 if (optionKey === "a") return votersA?.linking_records || []; 218 if (optionKey === "b") return votersB?.linking_records || []; 219 if (optionKey === "c") return votersC?.linking_records || []; 220 if (optionKey === "d") return votersD?.linking_records || []; 221 return []; 222 })(); 223 const topVoters = votersData 224 .filter((v: any) => !!v.did) 225 .slice(0, 5); 226 227 return ( 228 <div 229 key={index} 230 className={`group relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${ 231 !isExpired 232 ? hasVotedForOption 233 ? "bg-gray-100 dark:bg-gray-950 border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-900 cursor-pointer outline-2 outline-gray-500 dark:outline-gray-400" 234 : "bg-gray-100 dark:bg-gray-950 border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-900 cursor-pointer" 235 : "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700" 236 }`} 237 onClick={(e) => { 238 e.stopPropagation(); 239 if (!isExpired) { 240 handleVote(optionKey); 241 } 242 }} 243 > 244 <div 245 className="absolute inset-y-0 left-0 bg-gray-300 dark:bg-gray-700 group-hover:bg-gray-400 dark:group-hover:bg-gray-600 transition-[width]" 246 style={{ width: `${votePercentage}%` }} 247 /> 248 249 <span className="relative z-[2] text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> 250 {optionText} 251 {hasVotedForOption && ( 252 <span className="ml-2 text-gray-600 dark:text-gray-400"> 253 {poll.multiple ? "✓" : "✓ (click to remove)"} 254 </span> 255 )} 256 </span> 257 258 <div className="relative z-[2] flex items-center gap-2"> 259 {topVoterDids.length > 0 && ( 260 <div className="flex -space-x-2"> 261 {topVoterDids.map((did, idx) => ( 262 <div 263 key={did} 264 className="w-5 h-5 rounded-full border-2 border-white dark:border-gray-900 overflow-hidden bg-gray-200" 265 style={{ zIndex: 5 - idx }} 266 > 267 <PollOptionAvatar did={did} /> 268 </div> 269 ))} 270 </div> 271 )} 272 273 <span className="text-sm font-medium text-gray-600 dark:text-gray-400"> 274 {votePercentage.toFixed(0)}% 275 </span> 276 </div> 277 </div> 278 ); 279 })} 280 </div> 281 282 <div className="mt-4 flex items-center justify-between text-sm text-gray-500 dark:text-gray-400"> 283 <div className="flex items-center gap-2"> 284 <IconMdiClockOutline /> 285 <span>Never expires</span> 286 </div> 287 288 <button 289 onClick={(e) => { 290 e.stopPropagation(); 291 renderSnack({ 292 title: "Not implemented yet...", 293 description: "Opening PDSLS", 294 }); 295 const pdslsUrl = `https://pdsls.dev/at://${did}/app.reddwarf.embed.poll/${rkey}#backlinks`; 296 window.open(pdslsUrl, "_blank"); 297 }} 298 className="rounded-full h-10 bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors px-4 py-2 text-[14px]" 299 > 300 View all {totalVotes} votes 301 </button> 302 </div> 303 </div> 304 </> 305 ); 306} 307 308export function PollOptionAvatar({ did }: { did: string }) { 309 const [imgcdn] = useAtom(imgCDNAtom); 310 const { data: profileRecord } = useQueryProfile( 311 `at://${did}/app.bsky.actor.profile/self`, 312 ); 313 314 const avatarUrl = getAvatarUrl(profileRecord, did, imgcdn); 315 316 if (!avatarUrl) { 317 return <div className="w-full h-full bg-gray-500" />; 318 } 319 320 return ( 321 <img 322 src={avatarUrl} 323 alt="voter" 324 className="w-full h-full object-cover" 325 onError={(e) => { 326 const target = e.target as HTMLImageElement; 327 target.style.display = "none"; 328 target.parentElement!.style.backgroundColor = "#6b7280"; 329 }} 330 /> 331 ); 332} 333 334function getAvatarUrl(opProfile: any, did: string, cdn: string) { 335 const link = opProfile?.value?.avatar?.ref?.["$link"]; 336 if (!link) return null; 337 return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`; 338}