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 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 }