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 15 enableWafrnTextAtom, 16 16 imgCDNAtom, 17 17 } from "~/utils/atoms"; 18 + import { useGetOneToOneState } from "~/utils/followState"; 18 19 import { useHydratedEmbed } from "~/utils/useHydrated"; 19 20 import { 20 21 constructConstellationQuery, ··· 2221 2222 }), 2222 2223 ); 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 + 2224 2270 if (isLoading) { 2225 2271 return ( 2226 2272 <div className="animate-pulse"> ··· 2292 2338 if (!agent || isExpired) return; 2293 2339 2294 2340 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, 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(), 2304 2409 }, 2305 - createdAt: new Date().toISOString(), 2306 - }, 2307 - // Let PDS generate rkey automatically 2308 - }); 2410 + // Let PDS generate rkey automatically 2411 + }); 2412 + } 2309 2413 } catch (error) { 2310 2414 console.error("Failed to vote:", error); 2311 2415 } ··· 2337 2441 {options.map((optionText, index) => { 2338 2442 const optionKey = ["a", "b", "c", "d"][index]; 2339 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 + 2340 2467 return ( 2341 2468 <div 2342 2469 key={index} 2343 - className={`relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${ 2470 + className={`group relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${ 2344 2471 !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" 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" 2346 2475 : "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700" 2347 2476 }`} 2348 2477 onClick={() => !isExpired && handleVote(optionKey)} 2349 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 + 2350 2485 {/* Option text */} 2351 2486 <span className="relative z-10 text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> 2352 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 + )} 2353 2493 </span> 2354 2494 2355 2495 {/* Vote count */} 2356 2496 <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} 2497 + {votePercentage.toFixed(0)}% 2358 2498 </span> 2359 2499 </div> 2360 2500 );