an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app

Poll participant small view

+140 -80
+140 -80
src/components/UniversalPostRenderer.tsx
··· 1 1 import * as ATPAPI from "@atproto/api"; 2 - import { useQuery } from "@tanstack/react-query"; 2 + import { useQueryClient } from "@tanstack/react-query"; 3 3 import { useNavigate } from "@tanstack/react-router"; 4 4 import DOMPurify from "dompurify"; 5 5 import { useAtom } from "jotai"; ··· 14 14 enableBridgyTextAtom, 15 15 enableWafrnTextAtom, 16 16 imgCDNAtom, 17 + slingshotURLAtom, 17 18 } from "~/utils/atoms"; 18 19 import { useGetOneToOneState } from "~/utils/followState"; 19 20 import { useHydratedEmbed } from "~/utils/useHydrated"; 20 21 import { 21 - constructConstellationQuery, 22 22 useQueryArbitrary, 23 23 useQueryConstellation, 24 24 useQueryIdentity, ··· 2152 2152 2153 2153 // Query vote counts for each option 2154 2154 const [constellationurl] = useAtom(constellationURLAtom); 2155 + const [imgcdn] = useAtom(imgCDNAtom); 2156 + const [slingshoturl] = useAtom(slingshotURLAtom); 2157 + const queryClient = useQueryClient(); 2155 2158 2156 2159 const { data: voteCountsA } = useQueryConstellation({ 2157 2160 method: "/links/count/distinct-dids", ··· 2182 2185 }); 2183 2186 2184 2187 // Query first page of voters for each option to get PFPs 2185 - const { data: votersA } = useQuery( 2186 - constructConstellationQuery({ 2187 - constellation: constellationurl, 2188 - method: "/links", 2189 - target: pollUri, 2190 - collection: "app.reddwarf.poll.vote.a", 2191 - path: ".subject.uri", 2192 - }), 2193 - ); 2188 + const { data: votersA } = useQueryConstellation({ 2189 + method: "/links", 2190 + target: pollUri, 2191 + collection: "app.reddwarf.poll.vote.a", 2192 + path: ".subject.uri", 2193 + }); 2194 2194 2195 - const { data: votersB } = useQuery( 2196 - constructConstellationQuery({ 2197 - constellation: constellationurl, 2198 - method: "/links", 2199 - target: pollUri, 2200 - collection: "app.reddwarf.poll.vote.b", 2201 - path: ".subject.uri", 2202 - }), 2203 - ); 2195 + const { data: votersB } = useQueryConstellation({ 2196 + method: "/links", 2197 + target: pollUri, 2198 + collection: "app.reddwarf.poll.vote.b", 2199 + path: ".subject.uri", 2200 + }); 2204 2201 2205 - const { data: votersC } = useQuery( 2206 - constructConstellationQuery({ 2207 - constellation: constellationurl, 2208 - method: "/links", 2209 - target: pollUri, 2210 - collection: "app.reddwarf.poll.vote.c", 2211 - path: ".subject.uri", 2212 - }), 2213 - ); 2202 + const { data: votersC } = useQueryConstellation({ 2203 + method: "/links", 2204 + target: pollUri, 2205 + collection: "app.reddwarf.poll.vote.c", 2206 + path: ".subject.uri", 2207 + }); 2214 2208 2215 - const { data: votersD } = useQuery( 2216 - constructConstellationQuery({ 2217 - constellation: constellationurl, 2218 - method: "/links", 2219 - target: pollUri, 2220 - collection: "app.reddwarf.poll.vote.d", 2221 - path: ".subject.uri", 2222 - }), 2223 - ); 2209 + const { data: votersD } = useQueryConstellation({ 2210 + method: "/links", 2211 + target: pollUri, 2212 + collection: "app.reddwarf.poll.vote.d", 2213 + path: ".subject.uri", 2214 + }); 2224 2215 2225 2216 // Check if user has already voted for each option in this poll 2226 2217 const userVotesA = useGetOneToOneState( ··· 2267 2258 : undefined, 2268 2259 ); 2269 2260 2270 - if (isLoading) { 2271 - return ( 2272 - <div className="animate-pulse"> 2273 - <div className="flex items-center gap-2 mb-3"> 2274 - <div className="h-6 w-20 bg-gray-300 dark:bg-gray-600 rounded"></div> 2275 - <div className="h-6 w-32 bg-gray-300 dark:bg-gray-600 rounded"></div> 2276 - </div> 2277 - <div className="space-y-2"> 2278 - <div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg"></div> 2279 - <div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg w-3/4"></div> 2280 - </div> 2281 - </div> 2282 - ); 2283 - } 2284 2261 2285 - if (error || !pollRecord?.value) { 2286 - return <div className="text-red-500 text-sm p-2">Failed to load poll</div>; 2287 - } 2288 2262 2289 - const poll = pollRecord.value as { 2263 + const poll = pollRecord?.value as { 2290 2264 a: string; 2291 2265 b: string; 2292 2266 c?: string; ··· 2297 2271 }; 2298 2272 2299 2273 const options = [poll.a, poll.b, poll.c, poll.d].filter(Boolean); 2300 - const isExpired = false //poll.expiry ? new Date(poll.expiry) < new Date() : false; 2301 - 2302 - // todo unused waiting for private polls 2303 - // undefined for public polls which equals never expires 2304 - const formattedDate = undefined; 2305 - // const formattedDate = poll.expiry 2306 - // ? new Date(poll.expiry).toLocaleDateString("en-US", { 2307 - // month: "short", 2308 - // day: "numeric", 2309 - // hour: "numeric", 2310 - // minute: "2-digit", 2311 - // }) 2312 - // : null; 2313 2274 2314 2275 // Calculate vote counts 2315 2276 const voteData = [ 2316 2277 { 2317 2278 option: "a", 2318 2279 count: parseInt((voteCountsA as any)?.total || "0"), 2319 - voters: (votersA as any)?.linking_records || [], 2280 + voters: votersA?.linking_records || [], 2320 2281 }, 2321 2282 { 2322 2283 option: "b", 2323 2284 count: parseInt((voteCountsB as any)?.total || "0"), 2324 - voters: (votersB as any)?.linking_records || [], 2285 + voters: votersB?.linking_records || [], 2325 2286 }, 2326 2287 { 2327 2288 option: "c", 2328 2289 count: parseInt((voteCountsC as any)?.total || "0"), 2329 - voters: (votersC as any)?.linking_records || [], 2290 + voters: votersC?.linking_records || [], 2330 2291 }, 2331 2292 { 2332 2293 option: "d", 2333 2294 count: parseInt((voteCountsD as any)?.total || "0"), 2334 - voters: (votersD as any)?.linking_records || [], 2295 + voters: votersD?.linking_records || [], 2335 2296 }, 2336 2297 ].slice(0, options.length); 2337 2298 2299 + if (isLoading) { 2300 + return ( 2301 + <div className="animate-pulse"> 2302 + <div className="flex items-center gap-2 mb-3"> 2303 + <div className="h-6 w-20 bg-gray-300 dark:bg-gray-600 rounded"></div> 2304 + <div className="h-6 w-32 bg-gray-300 dark:bg-gray-600 rounded"></div> 2305 + </div> 2306 + <div className="space-y-2"> 2307 + <div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg"></div> 2308 + <div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg w-3/4"></div> 2309 + </div> 2310 + </div> 2311 + ); 2312 + } 2313 + 2314 + if (error || !pollRecord?.value) { 2315 + return <div className="text-red-500 text-sm p-2">Failed to load poll</div>; 2316 + } 2317 + const isExpired = false; //poll.expiry ? new Date(poll.expiry) < new Date() : false; 2318 + 2319 + // todo unused waiting for private polls 2320 + // undefined for public polls which equals never expires 2321 + const formattedDate = undefined; 2322 + // const formattedDate = poll.expiry 2323 + // ? new Date(poll.expiry).toLocaleDateString("en-US", { 2324 + // month: "short", 2325 + // day: "numeric", 2326 + // hour: "numeric", 2327 + // minute: "2-digit", 2328 + // }) 2329 + // : null; 2330 + 2331 + 2338 2332 const totalVotes = voteData.reduce((sum, item) => sum + item.count, 0); 2339 2333 2340 2334 const handleVote = async (option: string) => { ··· 2460 2454 } 2461 2455 })(); 2462 2456 2457 + const rowData = voteData.find((v) => v.option === optionKey); 2463 2458 const hasVotedForOption = 2464 2459 userVotesForOption && userVotesForOption.length > 0; 2465 2460 const voteCount = 2466 2461 voteData.find((v) => v.option === optionKey)?.count ?? 0; 2467 2462 const votePercentage = 2468 2463 totalVotes > 0 ? (voteCount / totalVotes) * 100 : 0; 2464 + 2465 + // Extract just the DIDs we want to show (top 2) 2466 + const topVoters = rowData?.voters 2467 + .filter(v => !!v.did) 2468 + .slice(0, 2) || []; 2469 2469 2470 2470 return ( 2471 2471 <div ··· 2494 2494 )} 2495 2495 </span> 2496 2496 2497 - {/* Vote count */} 2498 - <span className="relative z-10 text-sm font-medium text-gray-600 dark:text-gray-400"> 2499 - {votePercentage.toFixed(0)}% 2500 - </span> 2497 + {/* Avatar circles and vote count */} 2498 + <div className="relative z-10 flex items-center gap-2"> 2499 + {/* Avatar circles - semi overlapping */} 2500 + 2501 + {topVoters.length > 0 && ( 2502 + <div className="flex -space-x-2"> 2503 + {topVoters.map((voter, idx) => ( 2504 + <div 2505 + key={voter.did} // Use DID as key, it's stable 2506 + className="w-5 h-5 rounded-full border-2 border-white dark:border-gray-900 overflow-hidden bg-gray-200" 2507 + style={{ zIndex: 2 - idx }} 2508 + > 2509 + {/* The Component handles the async fetch! */} 2510 + <PollOptionAvatar 2511 + did={voter.did} 2512 + /> 2513 + </div> 2514 + ))} 2515 + </div> 2516 + )} 2517 + 2518 + {/* Vote count */} 2519 + <span className="text-sm font-medium text-gray-600 dark:text-gray-400"> 2520 + {votePercentage.toFixed(0)}% 2521 + </span> 2522 + </div> 2501 2523 </div> 2502 2524 ); 2503 2525 })} ··· 2509 2531 <div className="flex items-center gap-2"> 2510 2532 <IconMdiClockOutline /> 2511 2533 {/* <span>Expires {formattedDate}</span> */} 2512 - {formattedDate ? !isExpired ? ( 2513 - <span>Expires {formattedDate}</span> 2514 - ) : (<span>Expired at {formattedDate}</span>) : <span>Never expires</span>} 2534 + {formattedDate ? ( 2535 + !isExpired ? ( 2536 + <span>Expires {formattedDate}</span> 2537 + ) : ( 2538 + <span>Expired at {formattedDate}</span> 2539 + ) 2540 + ) : ( 2541 + <span>Never expires</span> 2542 + )} 2515 2543 </div> 2516 2544 2517 2545 {/* Status */} ··· 2528 2556 </div> 2529 2557 </div> 2530 2558 </div> 2559 + ); 2560 + } 2561 + 2562 + function PollOptionAvatar({ 2563 + did, 2564 + }: { 2565 + did: string; 2566 + }) { 2567 + const [imgcdn] = useAtom(imgCDNAtom); 2568 + // Each avatar handles its own data fetching 2569 + // If this specific DID is already in cache, it loads instantly 2570 + const { data: profileRecord } = useQueryProfile(`at://${did}/app.bsky.actor.profile/self`) 2571 + 2572 + //const profile = profileRecord?.value as ATPAPI.AppBskyActorProfile.Record; 2573 + const avatarUrl = getAvatarUrl(profileRecord, did, imgcdn); 2574 + 2575 + if (!avatarUrl) { 2576 + // Fallback grey circle 2577 + return <div className="w-full h-full bg-gray-500" />; 2578 + } 2579 + 2580 + return ( 2581 + <img 2582 + src={avatarUrl} 2583 + alt="voter" 2584 + className="w-full h-full object-cover" 2585 + onError={(e) => { 2586 + const target = e.target as HTMLImageElement; 2587 + target.style.display = "none"; 2588 + target.parentElement!.style.backgroundColor = "#6b7280"; 2589 + }} 2590 + /> 2531 2591 ); 2532 2592 } 2533 2593