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

Polls dedupe pfp

+86 -42
+14 -16
src/components/UniversalPostRenderer.tsx
··· 2372 2372 <div className="space-y-3"> 2373 2373 {options.map((optionText, index) => { 2374 2374 const optionKey = ["a", "b", "c", "d"][index] as "a" | "b" | "c" | "d"; 2375 - 2375 + const { topVoterDids } = results[optionKey]; 2376 2376 const optionState = results[optionKey]; 2377 2377 const hasVotedForOption = optionState.hasVoted; 2378 2378 const votePercentage = totalVotes > 0 ? (optionState.count / totalVotes) * 100 : 0; ··· 2422 2422 {/* Avatar circles and vote count */} 2423 2423 <div className="relative z-[2] flex items-center gap-2"> 2424 2424 {/* Avatar circles - semi overlapping */} 2425 - 2426 - {topVoters.length > 0 && ( 2427 - <div className="flex -space-x-2"> 2428 - {topVoters.map((voter, idx) => ( 2429 - <div 2430 - key={voter.did} // Use DID as key, it's stable 2431 - className="w-5 h-5 rounded-full border-2 border-white dark:border-gray-900 overflow-hidden bg-gray-200" 2432 - style={{ zIndex: 5 - idx }} 2433 - > 2434 - {/* The Component handles the async fetch! */} 2435 - <PollOptionAvatar did={voter.did} /> 2436 - </div> 2437 - ))} 2438 - </div> 2439 - )} 2425 + {topVoterDids.length > 0 && ( 2426 + <div className="flex -space-x-2"> 2427 + {topVoterDids.map((did, idx) => ( 2428 + <div 2429 + key={did} 2430 + className="w-5 h-5 rounded-full border-2 border-white dark:border-gray-900 overflow-hidden bg-gray-200" 2431 + style={{ zIndex: 5 - idx }} 2432 + > 2433 + <PollOptionAvatar did={did} /> 2434 + </div> 2435 + ))} 2436 + </div> 2437 + )} 2440 2438 2441 2439 {/* Vote count */} 2442 2440 <span className="text-sm font-medium text-gray-600 dark:text-gray-400">
+72 -26
src/providers/PollMutationQueueProvider.tsx
··· 5 5 import { renderSnack } from "~/routes/__root"; 6 6 import { localPollVotesAtom, type LocalVote } from "~/utils/atoms"; 7 7 import { useGetOneToOneState } from "~/utils/followState"; 8 + import { useQueryConstellation } from "~/utils/useQuery"; 8 9 9 10 // ------------------------------------------------------------------ 10 11 // Types ··· 254 255 ]; 255 256 }, [userVotesA, userVotesB, userVotesC, userVotesD]); 256 257 } 258 + type VoterRef = { did: string }; 257 259 258 260 export function usePollData( 259 261 pollUri: string, ··· 261 263 isMultiple: boolean, 262 264 serverCounts: { a: number; b: number; c: number; d: number }, 263 265 ) { 266 + const { agent } = useAuth(); 267 + const myDid = agent?.did; 268 + 264 269 const { castVoteRaw, getLocalVotes } = usePollMutationQueue(); 265 - const serverUserVotes = usePollSelfVotes(pollUri); 266 - const localVotes = getLocalVotes(pollUri); // Returns ExtendedLocalVote[] 270 + const serverUserVotes = usePollSelfVotes(pollUri); // Our own votes from server 271 + const localVotes = getLocalVotes(pollUri); // Pending local actions 272 + 273 + // 1. FETCHING - Move the logic here 274 + // We only need the first page/subset to show avatars 275 + const { data: votersA } = useQueryConstellation({ 276 + method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.a", path: ".subject.uri", 277 + }); 278 + const { data: votersB } = useQueryConstellation({ 279 + method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.b", path: ".subject.uri", 280 + }); 281 + const { data: votersC } = useQueryConstellation({ 282 + method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.c", path: ".subject.uri", 283 + }); 284 + const { data: votersD } = useQueryConstellation({ 285 + method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.d", path: ".subject.uri", 286 + }); 267 287 268 288 const handleVote = useCallback((optionKey: string) => { 269 289 if (!pollCid) return; ··· 271 291 }, [pollUri, pollCid, isMultiple, serverUserVotes, castVoteRaw]); 272 292 273 293 return useMemo(() => { 294 + // Helper to clean a raw list: extract DIDs, Deduplicate, Remove Self 295 + const processServerList = (data: any) => { 296 + const records = data?.linking_records || []; 297 + const dids = records.map((r: any) => r.did).filter(Boolean) as string[]; 298 + 299 + // 2. Deduplicate everyone (Set removes duplicates) 300 + // 3. Remove self from the list (to ensure we don't appear twice or when we shouldn't) 301 + const uniqueOthers = new Set(dids.filter((did) => did !== myDid)); 302 + 303 + return Array.from(uniqueOthers); 304 + }; 305 + 306 + const serverLists = { 307 + a: processServerList(votersA), 308 + b: processServerList(votersB), 309 + c: processServerList(votersC), 310 + d: processServerList(votersD), 311 + }; 312 + 274 313 const calculateOptionState = (option: "a" | "b" | "c" | "d") => { 314 + // --- LOGIC: Determine if we have voted (Boolean) --- 275 315 const localEntry = localVotes.find((v) => v.option === option); 276 - const isServerVoted = serverUserVotes.some((uri) => uri.includes(`app.reddwarf.poll.vote.${option}`)); 316 + const isServerVoted = serverUserVotes.some((uri) => 317 + uri.includes(`app.reddwarf.poll.vote.${option}`) 318 + ); 277 319 278 - // --- MERGE STATUS LOGIC --- 279 320 let hasVoted = false; 280 321 281 322 if (localEntry) { 282 - // 1. If we have an explicit local action, it overrides everything for this option 283 - // 'create' = true, 'delete' = false 284 323 hasVoted = localEntry.action === "create"; 285 324 } else { 286 - // 2. If no local action for this specific option... 287 325 if (isMultiple) { 288 - // In multiple choice, server truth stands unless explicitly deleted (checked above) 289 326 hasVoted = isServerVoted; 290 327 } else { 291 - // In single choice, we must check if we voted for *something else* locally 292 - const hasSwitchedToOther = localVotes.some(v => v.option !== option && v.action === "create"); 293 - if (hasSwitchedToOther) { 294 - hasVoted = false; // Implicitly unvoted because we switched 295 - } else { 296 - hasVoted = isServerVoted; 297 - } 328 + // Single choice: if we created a vote elsewhere locally, this one is false 329 + const hasSwitched = localVotes.some((v) => v.option !== option && v.action === "create"); 330 + hasVoted = hasSwitched ? false : isServerVoted; 298 331 } 299 332 } 300 333 301 - // --- MERGE COUNT LOGIC --- 334 + // --- LOGIC: Calculate Count --- 302 335 let count = serverCounts[option] || 0; 336 + if (hasVoted && !isServerVoted) count++; 337 + if (!hasVoted && isServerVoted) count = Math.max(0, count - 1); 303 338 304 - // Adjust counts based on our "Virtual" state vs "Server" state 305 - // If we are Voted locally but Server doesn't know -> +1 306 - if (hasVoted && !isServerVoted) { 307 - count++; 308 - } 309 - // If we are NOT Voted locally (e.g. unvoted or switched) but Server thinks we are -> -1 310 - if (!hasVoted && isServerVoted) { 311 - count = Math.max(0, count - 1); 339 + // --- LOGIC: Finalize Avatar List --- 340 + // 4. Add back self purely using the hasVoted state 341 + let finalVoters = serverLists[option]; 342 + 343 + if (hasVoted && myDid) { 344 + finalVoters = [myDid, ...finalVoters]; 312 345 } 313 346 314 - return { hasVoted, count }; 347 + return { 348 + hasVoted, 349 + count, 350 + // We only return the DIDs now, top 5 351 + topVoterDids: finalVoters.slice(0, 5) 352 + }; 315 353 }; 316 354 317 355 const stateA = calculateOptionState("a"); ··· 325 363 totalVotes: stateA.count + stateB.count + stateC.count + stateD.count, 326 364 handleVote, 327 365 }; 328 - }, [localVotes, serverUserVotes, serverCounts, isMultiple, handleVote]); 366 + }, [ 367 + localVotes, 368 + serverUserVotes, 369 + serverCounts, 370 + votersA, votersB, votersC, votersD, // Dependencies for fetching 371 + isMultiple, 372 + handleVote, 373 + myDid, 374 + ]); 329 375 }