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

Polls unoptimistic and slow but works i guess

+156 -16
+156 -16
src/components/UniversalPostRenderer.tsx
··· 15 enableWafrnTextAtom, 16 imgCDNAtom, 17 } from "~/utils/atoms"; 18 import { useHydratedEmbed } from "~/utils/useHydrated"; 19 import { 20 constructConstellationQuery, ··· 2221 }), 2222 ); 2223 2224 if (isLoading) { 2225 return ( 2226 <div className="animate-pulse"> ··· 2292 if (!agent || isExpired) return; 2293 2294 try { 2295 - await agent.com.atproto.repo.createRecord({ 2296 - collection: `app.reddwarf.poll.vote.${option}`, 2297 - repo: agent.assertDid, 2298 - record: { 2299 - $type: `app.reddwarf.poll.vote.${option}`, 2300 - subject: { 2301 - $type: "com.atproto.repo.strongRef", 2302 - uri: pollUri, 2303 - cid: pollRecord.cid, 2304 }, 2305 - createdAt: new Date().toISOString(), 2306 - }, 2307 - // Let PDS generate rkey automatically 2308 - }); 2309 } catch (error) { 2310 console.error("Failed to vote:", error); 2311 } ··· 2337 {options.map((optionText, index) => { 2338 const optionKey = ["a", "b", "c", "d"][index]; 2339 2340 return ( 2341 <div 2342 key={index} 2343 - className={`relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${ 2344 !isExpired 2345 - ? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-pointer" 2346 : "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700" 2347 }`} 2348 onClick={() => !isExpired && handleVote(optionKey)} 2349 > 2350 {/* Option text */} 2351 <span className="relative z-10 text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> 2352 {optionText} 2353 </span> 2354 2355 {/* Vote count */} 2356 <span className="relative z-10 text-sm font-medium text-gray-600 dark:text-gray-400"> 2357 - {voteData.find(v => v.option === optionKey)?.count ?? 0} 2358 </span> 2359 </div> 2360 );
··· 15 enableWafrnTextAtom, 16 imgCDNAtom, 17 } from "~/utils/atoms"; 18 + import { useGetOneToOneState } from "~/utils/followState"; 19 import { useHydratedEmbed } from "~/utils/useHydrated"; 20 import { 21 constructConstellationQuery, ··· 2222 }), 2223 ); 2224 2225 + // Check if user has already voted for each option in this poll 2226 + const userVotesA = useGetOneToOneState( 2227 + agent?.did 2228 + ? { 2229 + target: pollUri, 2230 + user: agent?.did, 2231 + collection: "app.reddwarf.poll.vote.a", 2232 + path: ".subject.uri", 2233 + } 2234 + : undefined, 2235 + ); 2236 + 2237 + const userVotesB = useGetOneToOneState( 2238 + agent?.did 2239 + ? { 2240 + target: pollUri, 2241 + user: agent?.did, 2242 + collection: "app.reddwarf.poll.vote.b", 2243 + path: ".subject.uri", 2244 + } 2245 + : undefined, 2246 + ); 2247 + 2248 + const userVotesC = useGetOneToOneState( 2249 + agent?.did 2250 + ? { 2251 + target: pollUri, 2252 + user: agent?.did, 2253 + collection: "app.reddwarf.poll.vote.c", 2254 + path: ".subject.uri", 2255 + } 2256 + : undefined, 2257 + ); 2258 + 2259 + const userVotesD = useGetOneToOneState( 2260 + agent?.did 2261 + ? { 2262 + target: pollUri, 2263 + user: agent?.did, 2264 + collection: "app.reddwarf.poll.vote.d", 2265 + path: ".subject.uri", 2266 + } 2267 + : undefined, 2268 + ); 2269 + 2270 if (isLoading) { 2271 return ( 2272 <div className="animate-pulse"> ··· 2338 if (!agent || isExpired) return; 2339 2340 try { 2341 + // Get existing votes for this option 2342 + const existingVotes = (() => { 2343 + switch (option) { 2344 + case "a": 2345 + return userVotesA; 2346 + case "b": 2347 + return userVotesB; 2348 + case "c": 2349 + return userVotesC; 2350 + case "d": 2351 + return userVotesD; 2352 + default: 2353 + return []; 2354 + } 2355 + })(); 2356 + 2357 + // If user has already voted for this option, delete all votes (unvote) 2358 + if (existingVotes && existingVotes.length > 0) { 2359 + for (const voteUri of existingVotes) { 2360 + const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); 2361 + if (match) { 2362 + const [, did, collection, rkey] = match; 2363 + await agent.com.atproto.repo.deleteRecord({ 2364 + repo: did, 2365 + collection, 2366 + rkey, 2367 + }); 2368 + } 2369 + } 2370 + } else { 2371 + // If not voted for this option, create new vote 2372 + // First, delete votes from other options if poll doesn't allow multiple votes 2373 + if (!poll.multiple) { 2374 + const otherVotes = [ 2375 + ...(userVotesA || []), 2376 + ...(userVotesB || []), 2377 + ...(userVotesC || []), 2378 + ...(userVotesD || []), 2379 + ].filter((vote) => { 2380 + // Filter out votes for the current option 2381 + return !vote.includes(`app.reddwarf.poll.vote.${option}`); 2382 + }); 2383 + 2384 + for (const voteUri of otherVotes) { 2385 + const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); 2386 + if (match) { 2387 + const [, did, collection, rkey] = match; 2388 + await agent.com.atproto.repo.deleteRecord({ 2389 + repo: did, 2390 + collection, 2391 + rkey, 2392 + }); 2393 + } 2394 + } 2395 + } 2396 + 2397 + // Create new vote 2398 + await agent.com.atproto.repo.createRecord({ 2399 + collection: `app.reddwarf.poll.vote.${option}`, 2400 + repo: agent.assertDid, 2401 + record: { 2402 + $type: `app.reddwarf.poll.vote.${option}`, 2403 + subject: { 2404 + $type: "com.atproto.repo.strongRef", 2405 + uri: pollUri, 2406 + cid: pollRecord.cid, 2407 + }, 2408 + createdAt: new Date().toISOString(), 2409 }, 2410 + // Let PDS generate rkey automatically 2411 + }); 2412 + } 2413 } catch (error) { 2414 console.error("Failed to vote:", error); 2415 } ··· 2441 {options.map((optionText, index) => { 2442 const optionKey = ["a", "b", "c", "d"][index]; 2443 2444 + // Check if user has voted for this option 2445 + const userVotesForOption = (() => { 2446 + switch (optionKey) { 2447 + case "a": 2448 + return userVotesA; 2449 + case "b": 2450 + return userVotesB; 2451 + case "c": 2452 + return userVotesC; 2453 + case "d": 2454 + return userVotesD; 2455 + default: 2456 + return []; 2457 + } 2458 + })(); 2459 + 2460 + const hasVotedForOption = 2461 + userVotesForOption && userVotesForOption.length > 0; 2462 + const voteCount = 2463 + voteData.find((v) => v.option === optionKey)?.count ?? 0; 2464 + const votePercentage = 2465 + totalVotes > 0 ? (voteCount / totalVotes) * 100 : 0; 2466 + 2467 return ( 2468 <div 2469 key={index} 2470 + className={`group relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${ 2471 !isExpired 2472 + ? hasVotedForOption 2473 + ? "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" 2474 + : "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" 2475 : "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700" 2476 }`} 2477 onClick={() => !isExpired && handleVote(optionKey)} 2478 > 2479 + {/* Vote percentage bar - always show */} 2480 + <div 2481 + 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" 2482 + style={{ width: `${votePercentage}%` }} 2483 + /> 2484 + 2485 {/* Option text */} 2486 <span className="relative z-10 text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> 2487 {optionText} 2488 + {hasVotedForOption && ( 2489 + <span className="ml-2 text-gray-600 dark:text-gray-400"> 2490 + {poll.multiple ? "✓" : "✓ (click to remove)"} 2491 + </span> 2492 + )} 2493 </span> 2494 2495 {/* Vote count */} 2496 <span className="relative z-10 text-sm font-medium text-gray-600 dark:text-gray-400"> 2497 + {votePercentage.toFixed(0)}% 2498 </span> 2499 </div> 2500 );