an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
at main 509 lines 15 kB view raw
1import { useQueryClient } from "@tanstack/react-query"; 2import { useAtom } from "jotai"; 3import React, { createContext, use, useCallback, useMemo } from "react"; 4 5import { useAuth } from "~/providers/UnifiedAuthProvider"; 6import { renderSnack } from "~/routes/__root"; 7import { localPollVotesAtom, type LocalVote } from "~/utils/atoms"; 8import { useGetOneToOneState } from "~/utils/followState"; 9import { useQueryConstellation } from "~/utils/useQuery"; 10 11// ------------------------------------------------------------------ 12// Types 13// ------------------------------------------------------------------ 14 15// We extend the LocalVote type internally to handle "Tombstones" 16// (explicit instructions to hide a server-side vote) 17type ExtendedLocalVote = LocalVote & { 18 action: "create" | "delete"; 19}; 20 21interface PollMutationContextType { 22 castVoteRaw: ( 23 pollUri: string, 24 pollCid: string, 25 option: string, 26 isMultiple: boolean, 27 currentServerVotes: string[], 28 ) => Promise<void>; 29 30 getLocalVotes: (pollUri: string) => ExtendedLocalVote[]; 31 refreshPollData: (pollUri?: string) => void; 32} 33 34const PollMutationContext = createContext<PollMutationContextType | undefined>( 35 undefined, 36); 37 38// ------------------------------------------------------------------ 39// Provider 40// ------------------------------------------------------------------ 41 42export function PollMutationQueueProvider({ 43 children, 44}: { 45 children: React.ReactNode; 46}) { 47 const { agent } = useAuth(); 48 const queryClient = useQueryClient(); 49 const [localVotes, setLocalVotes] = useAtom(localPollVotesAtom); 50 51 const getLocalVotes = useCallback( 52 (pollUri: string) => { 53 return (localVotes[pollUri] || []) as ExtendedLocalVote[]; 54 }, 55 [localVotes], 56 ); 57 58 const updateLocalState = useCallback( 59 ( 60 pollUri: string, 61 updater: (prev: ExtendedLocalVote[]) => ExtendedLocalVote[], 62 ) => { 63 setLocalVotes((prev) => ({ 64 ...prev, 65 [pollUri]: updater((prev[pollUri] || []) as ExtendedLocalVote[]), 66 })); 67 }, 68 [setLocalVotes], 69 ); 70 71 const refreshPollData = useCallback( 72 (pollUri?: string) => { 73 // Clear all local pending votes for this poll or all polls 74 if (pollUri) { 75 // Clear local state for specific poll 76 setLocalVotes((prev) => { 77 const newState = { ...prev }; 78 delete newState[pollUri]; 79 return newState; 80 }); 81 } else { 82 // Clear all local votes 83 setLocalVotes({}); 84 } 85 86 // Invalidate all poll constellation queries using predicate function 87 queryClient.invalidateQueries({ 88 predicate: (query) => { 89 const queryKey = query.queryKey; 90 return ( 91 Array.isArray(queryKey) && queryKey.includes("constellation-polls") 92 ); 93 }, 94 }); 95 96 // If specific poll URI provided, also invalidate that poll's data 97 if (pollUri) { 98 queryClient.invalidateQueries({ 99 queryKey: ["arbitrary", pollUri], 100 }); 101 } 102 }, 103 [queryClient, setLocalVotes], 104 ); 105 106 const castVoteRaw = useCallback( 107 async ( 108 pollUri: string, 109 pollCid: string, 110 option: string, 111 isMultiple: boolean, 112 currentServerVotes: string[], 113 ) => { 114 if (!agent?.did) { 115 renderSnack({ 116 title: "Please log in to vote", 117 description: "You need to be authenticated to participate in polls", 118 }); 119 return; 120 } 121 122 const optionKey = option as "a" | "b" | "c" | "d"; 123 const timestamp = Date.now(); 124 125 // 1. DETERMINE CURRENT STATUS 126 const currentLocal = (localVotes[pollUri] || []) as ExtendedLocalVote[]; 127 const localEntry = currentLocal.find((v) => v.option === optionKey); 128 129 // Check if ANY server vote exists for this option 130 const hasServerVote = currentServerVotes.some((uri) => 131 uri.includes(`app.reddwarf.poll.vote.${optionKey}`), 132 ); 133 134 const isCurrentlyVoted = localEntry 135 ? localEntry.action === "create" 136 : hasServerVote; 137 138 // ------------------------------------------------------------ 139 // ACTION: UNVOTE (Toggle Off) 140 // ------------------------------------------------------------ 141 if (isCurrentlyVoted) { 142 // Optimistic Update: Tombstone 143 updateLocalState(pollUri, (prev) => { 144 const clean = prev.filter((v) => v.option !== optionKey); 145 return [ 146 ...clean, 147 { 148 pollUri, 149 option: optionKey, 150 status: "pending", 151 action: "delete", 152 timestamp, 153 }, 154 ]; 155 }); 156 157 try { 158 // FIX: Collect ALL URIs for this option (Server + Local) 159 // We want to nuke every record that matches this option to clean up state 160 const serverUris = currentServerVotes.filter((uri) => 161 uri.includes(`app.reddwarf.poll.vote.${optionKey}`), 162 ); 163 164 const urisToDelete = [...serverUris]; 165 if (localEntry?.uri) { 166 urisToDelete.push(localEntry.uri); 167 } 168 169 // Deduplicate just in case 170 const uniqueUris = [...new Set(urisToDelete)]; 171 172 // Parallel delete for everything found 173 await Promise.all( 174 uniqueUris.map((uri) => { 175 const match = uri.match(/at:\/\/(.+)\/(.+)\/(.+)/); 176 if (!match) return Promise.resolve(); 177 const [, repo, collection, rkey] = match; 178 return agent.com.atproto.repo.deleteRecord({ 179 repo, 180 collection, 181 rkey, 182 }); 183 }), 184 ); 185 } catch (e) { 186 console.error("Failed to unvote", e); 187 renderSnack({ title: "Failed to remove vote" }); 188 // Revert optimistic update 189 updateLocalState(pollUri, (prev) => 190 prev.filter((v) => v.timestamp !== timestamp), 191 ); 192 } 193 } 194 195 // ------------------------------------------------------------ 196 // ACTION: VOTE (Toggle On) 197 // ------------------------------------------------------------ 198 else { 199 // ... (The Vote logic remains the same, as the Single Choice cleanup 200 // logic there already iterated over the entire array) ... 201 202 updateLocalState(pollUri, (prev) => { 203 const newState = isMultiple 204 ? [...prev] 205 : prev.filter((v) => v.action !== "create"); 206 const clean = newState.filter((v) => v.option !== optionKey); 207 return [ 208 ...clean, 209 { 210 pollUri, 211 option: optionKey, 212 status: "pending", 213 action: "create", 214 timestamp, 215 }, 216 ]; 217 }); 218 219 // Cleanup others if single choice 220 if (!isMultiple) { 221 const votesToDelete = [ 222 ...currentServerVotes, 223 ...(currentLocal 224 .filter((v) => v.action === "create" && v.uri) 225 .map((v) => v.uri) as string[]), 226 ]; 227 228 // This was already safe because it iterates the whole array 229 votesToDelete.forEach((voteUri) => { 230 if (voteUri.includes(`app.reddwarf.poll.vote.${optionKey}`)) return; 231 const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); 232 if (match) { 233 const [, repo, collection, rkey] = match; 234 agent.com.atproto.repo 235 .deleteRecord({ repo, collection, rkey }) 236 .catch(console.error); 237 } 238 }); 239 } 240 241 try { 242 const res = await agent.com.atproto.repo.createRecord({ 243 // ... standard create logic 244 collection: `app.reddwarf.poll.vote.${optionKey}`, 245 repo: agent.assertDid, 246 record: { 247 $type: `app.reddwarf.poll.vote.${optionKey}`, 248 subject: { uri: pollUri, cid: pollCid }, 249 createdAt: new Date().toISOString(), 250 }, 251 }); 252 253 updateLocalState(pollUri, (prev) => { 254 const clean = prev.filter((v) => v.option !== optionKey); 255 return [ 256 ...clean, 257 { 258 pollUri, 259 option: optionKey, 260 status: "confirmed", 261 action: "create", 262 uri: res.data.uri, 263 timestamp: Date.now(), 264 }, 265 ]; 266 }); 267 } catch (e) { 268 console.error("Vote failed", e); 269 renderSnack({ title: "Vote failed" }); 270 updateLocalState(pollUri, (prev) => 271 prev.filter((v) => v.timestamp !== timestamp), 272 ); 273 } 274 } 275 }, 276 [agent, localVotes, updateLocalState, setLocalVotes], 277 ); 278 279 return ( 280 <PollMutationContext 281 value={{ castVoteRaw, getLocalVotes, refreshPollData }} 282 > 283 {children} 284 </PollMutationContext> 285 ); 286} 287 288// ------------------------------------------------------------------ 289// Hooks 290// ------------------------------------------------------------------ 291 292export function usePollMutationQueue() { 293 const context = use(PollMutationContext); 294 if (!context) throw new Error("Missing PollMutationQueueProvider"); 295 return context; 296} 297 298function usePollSelfVotes(pollUri: string, enabled?: boolean) { 299 const { agent } = useAuth(); 300 const agentDid = agent?.did; 301 302 const { uris: userVotesA } = useGetOneToOneState( 303 agentDid && enabled 304 ? { 305 target: pollUri, 306 user: agentDid, 307 collection: "app.reddwarf.poll.vote.a", 308 path: ".subject.uri", 309 enabled: enabled 310 } 311 : undefined, 312 ); 313 const { uris: userVotesB } = useGetOneToOneState( 314 agentDid && enabled 315 ? { 316 target: pollUri, 317 user: agentDid, 318 collection: "app.reddwarf.poll.vote.b", 319 path: ".subject.uri", 320 enabled: enabled 321 } 322 : undefined, 323 ); 324 const { uris: userVotesC } = useGetOneToOneState( 325 agentDid && enabled 326 ? { 327 target: pollUri, 328 user: agentDid, 329 collection: "app.reddwarf.poll.vote.c", 330 path: ".subject.uri", 331 enabled: enabled 332 } 333 : undefined, 334 ); 335 const { uris: userVotesD } = useGetOneToOneState( 336 agentDid && enabled 337 ? { 338 target: pollUri, 339 user: agentDid, 340 collection: "app.reddwarf.poll.vote.d", 341 path: ".subject.uri", 342 enabled: enabled 343 } 344 : undefined, 345 ); 346 347 return useMemo(() => { 348 return [ 349 ...(userVotesA || []), 350 ...(userVotesB || []), 351 ...(userVotesC || []), 352 ...(userVotesD || []), 353 ]; 354 }, [userVotesA, userVotesB, userVotesC, userVotesD]); 355} 356 357export function usePollData( 358 pollUri: string, 359 pollCid: string | undefined, 360 isMultiple: boolean, 361 serverCounts: { a: number; b: number; c: number; d: number }, 362 enabled?: boolean 363) { 364 const { agent } = useAuth(); 365 const myDid = agent?.did; 366 367 const { castVoteRaw, getLocalVotes } = usePollMutationQueue(); 368 const serverUserVotes = usePollSelfVotes(pollUri, enabled); // Our own votes from server 369 const localVotes = getLocalVotes(pollUri); // Pending local actions 370 371 // 1. FETCHING - Move the logic here 372 // We only need the first page/subset to show avatars 373 const { data: votersA } = useQueryConstellation({ 374 method: "/links", 375 target: pollUri, 376 collection: "app.reddwarf.poll.vote.a", 377 path: ".subject.uri", 378 customkey: "constellation-polls", 379 enabled: enabled, 380 }); 381 const { data: votersB } = useQueryConstellation({ 382 method: "/links", 383 target: pollUri, 384 collection: "app.reddwarf.poll.vote.b", 385 path: ".subject.uri", 386 customkey: "constellation-polls", 387 enabled: enabled, 388 }); 389 const { data: votersC } = useQueryConstellation({ 390 method: "/links", 391 target: pollUri, 392 collection: "app.reddwarf.poll.vote.c", 393 path: ".subject.uri", 394 customkey: "constellation-polls", 395 enabled: enabled, 396 }); 397 const { data: votersD } = useQueryConstellation({ 398 method: "/links", 399 target: pollUri, 400 collection: "app.reddwarf.poll.vote.d", 401 path: ".subject.uri", 402 customkey: "constellation-polls", 403 enabled: enabled, 404 }); 405 406 const handleVote = useCallback( 407 (optionKey: string) => { 408 if (!pollCid) return; 409 castVoteRaw(pollUri, pollCid, optionKey, isMultiple, serverUserVotes); 410 }, 411 [pollUri, pollCid, isMultiple, serverUserVotes, castVoteRaw], 412 ); 413 414 return useMemo(() => { 415 // Helper to clean a raw list: extract DIDs, Deduplicate, Remove Self 416 const processServerList = (data: any) => { 417 const records = data?.linking_records || []; 418 const dids = records.map((r: any) => r.did).filter(Boolean) as string[]; 419 420 // 2. Deduplicate everyone (Set removes duplicates) 421 // 3. Remove self from the list (to ensure we don't appear twice or when we shouldn't) 422 const uniqueOthers = new Set(dids.filter((did) => did !== myDid)); 423 424 return Array.from(uniqueOthers); 425 }; 426 427 const serverLists = { 428 a: processServerList(votersA), 429 b: processServerList(votersB), 430 c: processServerList(votersC), 431 d: processServerList(votersD), 432 }; 433 434 const calculateOptionState = (option: "a" | "b" | "c" | "d") => { 435 // --- LOGIC: Determine if we have voted (Boolean) --- 436 const localEntry = localVotes.find((v) => v.option === option); 437 const isServerVoted = serverUserVotes.some((uri) => 438 uri.includes(`app.reddwarf.poll.vote.${option}`), 439 ); 440 441 let hasVoted = false; 442 443 if (localEntry) { 444 hasVoted = localEntry.action === "create"; 445 } else { 446 if (isMultiple) { 447 hasVoted = isServerVoted; 448 } else { 449 // Single choice: if we created a vote elsewhere locally, this one is false 450 const hasSwitched = localVotes.some( 451 (v) => v.option !== option && v.action === "create", 452 ); 453 hasVoted = hasSwitched ? false : isServerVoted; 454 } 455 } 456 457 // --- LOGIC: Calculate Count --- 458 let count = serverCounts[option] || 0; 459 if (hasVoted && !isServerVoted) count++; 460 if (!hasVoted && isServerVoted) count = Math.max(0, count - 1); 461 462 // --- LOGIC: Finalize Avatar List --- 463 // 4. Add back self purely using the hasVoted state 464 let finalVoters = serverLists[option]; 465 466 if (hasVoted && myDid) { 467 finalVoters = [myDid, ...finalVoters]; 468 } 469 470 return { 471 hasVoted, 472 count, 473 // We only return the DIDs now, top 5 474 topVoterDids: finalVoters.slice(0, 5), 475 }; 476 }; 477 478 const stateA = calculateOptionState("a"); 479 const stateB = calculateOptionState("b"); 480 const stateC = calculateOptionState("c"); 481 const stateD = calculateOptionState("d"); 482 483 return { 484 results: { a: stateA, b: stateB, c: stateC, d: stateD }, 485 hasVotedAny: 486 stateA.hasVoted || 487 stateB.hasVoted || 488 stateC.hasVoted || 489 stateD.hasVoted, 490 totalVotes: stateA.count + stateB.count + stateC.count + stateD.count, 491 handleVote, 492 votersA, 493 votersB, 494 votersC, 495 votersD 496 }; 497 }, [ 498 localVotes, 499 serverUserVotes, 500 serverCounts, 501 votersA, 502 votersB, 503 votersC, 504 votersD, // Dependencies for fetching 505 isMultiple, 506 handleVote, 507 myDid, 508 ]); 509}