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

Configure Feed

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

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 );