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

Configure Feed

Select the types of activity you want to include in your feed.

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