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 state v2

+303 -411
+65 -191
src/components/UniversalPostRenderer.tsx
··· 14 14 enableWafrnTextAtom, 15 15 imgCDNAtom, 16 16 } from "~/utils/atoms"; 17 - import { useGetOneToOneState } from "~/utils/followState"; 18 17 import { useHydratedEmbed } from "~/utils/useHydrated"; 19 18 import { 20 19 useQueryArbitrary, ··· 409 408 setReplies( 410 409 links 411 410 ? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"] 412 - ?.records || 0 411 + ?.records || 0 413 412 : null, 414 413 ); 415 414 }, [links]); ··· 457 456 458 457 const replyAturis = repliesData 459 458 ? repliesData.pages.flatMap((page) => 460 - page 461 - ? page.linking_records.map((record) => { 462 - const aturi = `at://${record.did}/${record.collection}/${record.rkey}`; 463 - return aturi; 464 - }) 465 - : [], 466 - ) 459 + page 460 + ? page.linking_records.map((record) => { 461 + const aturi = `at://${record.did}/${record.collection}/${record.rkey}`; 462 + return aturi; 463 + }) 464 + : [], 465 + ) 467 466 : []; 468 467 469 468 //const [oldestOpsReply, setOldestOpsReply] = useState<string | undefined>(undefined); ··· 623 622 opacity: 0.5, 624 623 }} 625 624 className="dark:bg-[repeating-linear-gradient(to_bottom,var(--color-gray-500)_0,var(--color-gray-400)_4px,transparent_4px,transparent_8px)]" 626 - //className="border-gray-400 dark:border-gray-500" 625 + //className="border-gray-400 dark:border-gray-500" 627 626 /> 628 627 </div> 629 628 ··· 769 768 const isQuotewithImages = 770 769 isquotewithmedia && 771 770 (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 772 - "app.bsky.embed.images"; 771 + "app.bsky.embed.images"; 773 772 const isQuotewithVideo = 774 773 isquotewithmedia && 775 774 (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 776 - "app.bsky.embed.video"; 775 + "app.bsky.embed.video"; 777 776 778 777 const hasMedia = 779 778 hasEmbed && ··· 1259 1258 import defaultpfp from "~/../public/favicon.png"; 1260 1259 import { 1261 1260 usePollData, 1262 - usePollMutationQueue, 1263 1261 } from "~/providers/PollMutationQueueProvider"; 1264 1262 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1265 1263 import { renderSnack } from "~/routes/__root"; ··· 1496 1494 1497 1495 const tags = unfediwafrnTags 1498 1496 ? unfediwafrnTags 1499 - .split("\n") 1500 - .map((t) => t.trim()) 1501 - .filter(Boolean) 1497 + .split("\n") 1498 + .map((t) => t.trim()) 1499 + .filter(Boolean) 1502 1500 : undefined; 1503 1501 1504 1502 const links = tags 1505 1503 ? tags 1506 - .map((tag) => { 1507 - const encoded = encodeURIComponent(tag); 1508 - return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`; 1509 - }) 1510 - .join("<br>") 1504 + .map((tag) => { 1505 + const encoded = encodeURIComponent(tag); 1506 + return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`; 1507 + }) 1508 + .join("<br>") 1511 1509 : ""; 1512 1510 1513 1511 const unfediwafrn = unfediwafrnPartial ··· 1520 1518 1521 1519 /* fuck you */ 1522 1520 const isMainItem = false; 1523 - const setMainItem = (any: any) => {}; 1521 + const setMainItem = (any: any) => { }; 1524 1522 // eslint-disable-next-line react-hooks/refs 1525 1523 //console.log("Received ref in UniversalPostRenderer:", usedref); 1526 1524 return ( ··· 1534 1532 : setMainItem 1535 1533 ? onPostClick 1536 1534 ? (e) => { 1537 - setMainItem({ post: post }); 1538 - onPostClick(e); 1539 - } 1535 + setMainItem({ post: post }); 1536 + onPostClick(e); 1537 + } 1540 1538 : () => { 1541 - setMainItem({ post: post }); 1542 - } 1539 + setMainItem({ post: post }); 1540 + } 1543 1541 : undefined 1544 1542 } 1545 1543 style={{ ··· 2022 2020 try { 2023 2021 await navigator.clipboard.writeText( 2024 2022 "https://bsky.app" + 2025 - "/profile/" + 2026 - post.author.handle + 2027 - "/post/" + 2028 - post.uri.split("/").pop(), 2023 + "/profile/" + 2024 + post.author.handle + 2025 + "/post/" + 2026 + post.uri.split("/").pop(), 2029 2027 ); 2030 2028 renderSnack({ 2031 2029 title: "Copied to clipboard!", ··· 2133 2131 | AppBskyEmbedVideo.View 2134 2132 | AppBskyEmbedExternal.View 2135 2133 | AppBskyEmbedRecordWithMedia.View 2136 - | { $type: string; [k: string]: unknown }; 2134 + | { $type: string;[k: string]: unknown }; 2137 2135 2138 2136 enum PostEmbedViewContext { 2139 2137 ThreadHighlighted = "ThreadHighlighted", ··· 2152 2150 const { agent } = useAuth(); 2153 2151 const pollUri = `at://${did}/app.reddwarf.embed.poll/${rkey}`; 2154 2152 const { data: pollRecord, isLoading, error } = useQueryArbitrary(pollUri); 2155 - const { castVote } = usePollMutationQueue(); 2156 2153 2157 - // Query vote counts for each option 2154 + // --- 1. Fetch Aggregate Counts & Avatars (Public Data) --- 2155 + // (We still fetch these here as they are View-specific data dependencies) 2156 + 2158 2157 const { data: voteCountsA } = useQueryConstellation({ 2159 2158 method: "/links/count/distinct-dids", 2160 2159 target: pollUri, ··· 2183 2182 path: ".subject.uri", 2184 2183 }); 2185 2184 2186 - // Query first page of voters for each option to get PFPs 2185 + // Query first page of voters for Avatars 2187 2186 const { data: votersA } = useQueryConstellation({ 2188 - method: "/links", 2189 - target: pollUri, 2190 - collection: "app.reddwarf.poll.vote.a", 2191 - path: ".subject.uri", 2187 + method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.a", path: ".subject.uri", 2192 2188 }); 2193 - 2194 2189 const { data: votersB } = useQueryConstellation({ 2195 - method: "/links", 2196 - target: pollUri, 2197 - collection: "app.reddwarf.poll.vote.b", 2198 - path: ".subject.uri", 2190 + method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.b", path: ".subject.uri", 2199 2191 }); 2200 - 2201 2192 const { data: votersC } = useQueryConstellation({ 2202 - method: "/links", 2203 - target: pollUri, 2204 - collection: "app.reddwarf.poll.vote.c", 2205 - path: ".subject.uri", 2193 + method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.c", path: ".subject.uri", 2206 2194 }); 2207 - 2208 2195 const { data: votersD } = useQueryConstellation({ 2209 - method: "/links", 2210 - target: pollUri, 2211 - collection: "app.reddwarf.poll.vote.d", 2212 - path: ".subject.uri", 2196 + method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.d", path: ".subject.uri", 2213 2197 }); 2214 2198 2215 - // Check if user has already voted for each option in this poll 2216 - const userVotesA = useGetOneToOneState( 2217 - agent?.did 2218 - ? { 2219 - target: pollUri, 2220 - user: agent?.did, 2221 - collection: "app.reddwarf.poll.vote.a", 2222 - path: ".subject.uri", 2223 - } 2224 - : undefined, 2225 - ); 2226 - 2227 - const userVotesB = useGetOneToOneState( 2228 - agent?.did 2229 - ? { 2230 - target: pollUri, 2231 - user: agent?.did, 2232 - collection: "app.reddwarf.poll.vote.b", 2233 - path: ".subject.uri", 2234 - } 2235 - : undefined, 2236 - ); 2237 - 2238 - const userVotesC = useGetOneToOneState( 2239 - agent?.did 2240 - ? { 2241 - target: pollUri, 2242 - user: agent?.did, 2243 - collection: "app.reddwarf.poll.vote.c", 2244 - path: ".subject.uri", 2245 - } 2246 - : undefined, 2247 - ); 2248 - 2249 - const userVotesD = useGetOneToOneState( 2250 - agent?.did 2251 - ? { 2252 - target: pollUri, 2253 - user: agent?.did, 2254 - collection: "app.reddwarf.poll.vote.d", 2255 - path: ".subject.uri", 2256 - } 2257 - : undefined, 2258 - ); 2259 - 2199 + // --- 2. Prepare Data --- 2260 2200 // todo: hardcoded to multiple for all public polls 2261 2201 const poll = { 2262 2202 ...(pollRecord?.value ?? {}), ··· 2273 2213 2274 2214 const options = [poll.a, poll.b, poll.c, poll.d].filter(Boolean); 2275 2215 2276 - // // Calculate vote counts 2277 - // const voteData = [ 2278 - // { 2279 - // option: "a", 2280 - // count: parseInt((voteCountsA as any)?.total || "0"), 2281 - // voters: votersA?.linking_records || [], 2282 - // }, 2283 - // { 2284 - // option: "b", 2285 - // count: parseInt((voteCountsB as any)?.total || "0"), 2286 - // voters: votersB?.linking_records || [], 2287 - // }, 2288 - // { 2289 - // option: "c", 2290 - // count: parseInt((voteCountsC as any)?.total || "0"), 2291 - // voters: votersC?.linking_records || [], 2292 - // }, 2293 - // { 2294 - // option: "d", 2295 - // count: parseInt((voteCountsD as any)?.total || "0"), 2296 - // voters: votersD?.linking_records || [], 2297 - // }, 2298 - // ].slice(0, options.length); 2299 - 2300 - const serverUserVotes = [ 2301 - ...(userVotesA || []), 2302 - ...(userVotesB || []), 2303 - ...(userVotesC || []), 2304 - ...(userVotesD || []), 2305 - ]; 2306 - 2307 - // Flatten counts 2308 2216 const serverCounts = { 2309 2217 a: parseInt((voteCountsA as any)?.total || "0"), 2310 2218 b: parseInt((voteCountsB as any)?.total || "0"), ··· 2312 2220 d: parseInt((voteCountsD as any)?.total || "0"), 2313 2221 }; 2314 2222 2315 - // 3. THE MAGIC HOOK 2316 - const pollState = usePollData( 2223 + // --- 3. THE MAGIC HOOK (Now centralized) --- 2224 + // This hook now fetches self-votes internally and merges them with the serverCounts we passed in 2225 + const { results, totalVotes, handleVote } = usePollData( 2317 2226 pollUri, 2227 + pollRecord?.cid, 2318 2228 !!poll.multiple, 2319 - serverCounts, 2320 - serverUserVotes, 2229 + serverCounts 2321 2230 ); 2322 2231 2323 - // 4. Handle Vote Wrapper 2324 - const handleVote = async (optionKey: string) => { 2325 - if (!pollRecord) return; 2326 - // Expiry check 2327 - if (isExpired) return; 2328 - 2329 - // Trigger the Provider logic 2330 - await castVote( 2331 - pollUri, 2332 - pollRecord.cid, 2333 - optionKey, 2334 - !!poll.multiple, 2335 - serverUserVotes, 2336 - ); 2337 - }; 2232 + // --- 4. Render --- 2338 2233 2339 2234 if (isLoading) { 2340 2235 return ( ··· 2476 2371 {/* Options List with Results */} 2477 2372 <div className="space-y-3"> 2478 2373 {options.map((optionText, index) => { 2479 - const optionKey = ["a", "b", "c", "d"][index] as 2480 - | "a" 2481 - | "b" 2482 - | "c" 2483 - | "d"; 2374 + const optionKey = ["a", "b", "c", "d"][index] as "a" | "b" | "c" | "d"; 2484 2375 2485 - // Get the state from the hook 2486 - const optionState = pollState.results[optionKey]; 2376 + const optionState = results[optionKey]; 2487 2377 const hasVotedForOption = optionState.hasVoted; 2488 - const voteCount = optionState.count; 2489 - const votePercentage = 2490 - pollState.totalVotes > 0 2491 - ? (voteCount / pollState.totalVotes) * 100 2492 - : 0; 2378 + const votePercentage = totalVotes > 0 ? (optionState.count / totalVotes) * 100 : 0; 2493 2379 2494 - // Get the voters data for displaying avatars 2380 + // Helper to get voters for avatars 2495 2381 const votersData = (() => { 2496 - switch (optionKey) { 2497 - case "a": 2498 - return votersA?.linking_records || []; 2499 - case "b": 2500 - return votersB?.linking_records || []; 2501 - case "c": 2502 - return votersC?.linking_records || []; 2503 - case "d": 2504 - return votersD?.linking_records || []; 2505 - default: 2506 - return []; 2507 - } 2382 + if (optionKey === 'a') return votersA?.linking_records || []; 2383 + if (optionKey === 'b') return votersB?.linking_records || []; 2384 + if (optionKey === 'c') return votersC?.linking_records || []; 2385 + if (optionKey === 'd') return votersD?.linking_records || []; 2386 + return []; 2508 2387 })(); 2509 - 2510 - // Extract just the DIDs we want to show (top 5) 2511 - const topVoters = 2512 - votersData.filter((v) => !!v.did).slice(0, 5) || []; 2388 + const topVoters = votersData.filter((v: any) => !!v.did).slice(0, 5); 2513 2389 2514 2390 return ( 2515 2391 <div 2516 2392 key={index} 2517 - className={`group relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${ 2518 - !isExpired 2393 + className={`group relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${!isExpired 2519 2394 ? hasVotedForOption 2520 2395 ? "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" 2521 2396 : "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" 2522 2397 : "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700" 2523 - }`} 2398 + }`} 2524 2399 onClick={(e) => { 2525 2400 e.stopPropagation(); 2526 2401 if (!isExpired) { ··· 2530 2405 > 2531 2406 {/* Vote percentage bar - always show */} 2532 2407 <div 2533 - 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" 2408 + 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 transition-[width]" 2534 2409 style={{ width: `${votePercentage}%` }} 2535 2410 /> 2536 2411 ··· 2609 2484 }} 2610 2485 className="rounded-full h-10 bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors px-4 py-2 text-[14px]" 2611 2486 > 2612 - View all {pollState.totalVotes} votes 2487 + View all {totalVotes} votes 2613 2488 </button> 2614 2489 </div> 2615 2490 </div> ··· 2973 2848 width: "100%", 2974 2849 aspectRatio: image.aspectRatio 2975 2850 ? (() => { 2976 - const { width, height } = image.aspectRatio; 2977 - const ratio = width / height; 2978 - return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`; 2979 - })() 2851 + const { width, height } = image.aspectRatio; 2852 + const ratio = width / height; 2853 + return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`; 2854 + })() 2980 2855 : "1 / 1", // fallback to square 2981 2856 //backgroundColor: theme.background, // fallback letterboxing color 2982 2857 borderRadius: 12, ··· 3674 3549 borderRadius: 12, 3675 3550 overflow: "hidden", 3676 3551 //border: `1px solid ${theme.border}`, 3677 - paddingTop: `${ 3678 - 100 / (aspect ? aspect.width / aspect.height : 16 / 9) 3679 - }%`, // 16:9 = 56.25%, 4:3 = 75% 3552 + paddingTop: `${100 / (aspect ? aspect.width / aspect.height : 16 / 9) 3553 + }%`, // 16:9 = 56.25%, 4:3 = 75% 3680 3554 }} 3681 3555 className="border border-gray-200 dark:border-gray-800 was7" 3682 3556 >
+238 -220
src/providers/PollMutationQueueProvider.tsx
··· 4 4 import { useAuth } from "~/providers/UnifiedAuthProvider"; 5 5 import { renderSnack } from "~/routes/__root"; 6 6 import { localPollVotesAtom, type LocalVote } from "~/utils/atoms"; 7 + import { useGetOneToOneState } from "~/utils/followState"; 8 + 9 + // ------------------------------------------------------------------ 10 + // Types 11 + // ------------------------------------------------------------------ 12 + 13 + // We extend the LocalVote type internally to handle "Tombstones" 14 + // (explicit instructions to hide a server-side vote) 15 + type ExtendedLocalVote = LocalVote & { 16 + action: "create" | "delete"; 17 + }; 7 18 8 19 interface PollMutationContextType { 9 - castVote: ( 10 - pollUri: string, 11 - pollCid: string, 12 - option: string, 20 + castVoteRaw: ( 21 + pollUri: string, 22 + pollCid: string, 23 + option: string, 13 24 isMultiple: boolean, 14 - currentServerVotes: string[] // Pass current user vote URIs to handle unvoting logic 25 + currentServerVotes: string[], 15 26 ) => Promise<void>; 16 - 17 - getLocalVotes: (pollUri: string) => LocalVote[]; 27 + 28 + getLocalVotes: (pollUri: string) => ExtendedLocalVote[]; 18 29 } 19 30 20 - const PollMutationContext = createContext<PollMutationContextType | undefined>(undefined); 31 + const PollMutationContext = createContext<PollMutationContextType | undefined>( 32 + undefined, 33 + ); 21 34 22 - export function PollMutationQueueProvider({ children }: { children: React.ReactNode }) { 35 + // ------------------------------------------------------------------ 36 + // Provider 37 + // ------------------------------------------------------------------ 38 + 39 + export function PollMutationQueueProvider({ 40 + children, 41 + }: { 42 + children: React.ReactNode; 43 + }) { 23 44 const { agent } = useAuth(); 24 45 const [localVotes, setLocalVotes] = useAtom(localPollVotesAtom); 25 - 26 - // Helper to safely update state 27 - const updateLocalState = useCallback((pollUri: string, updater: (prev: LocalVote[]) => LocalVote[]) => { 28 - setLocalVotes(prev => ({ 29 - ...prev, 30 - [pollUri]: updater(prev[pollUri] || []) 31 - })); 32 - }, [setLocalVotes]); 46 + 47 + const getLocalVotes = useCallback( 48 + (pollUri: string) => { 49 + return (localVotes[pollUri] || []) as ExtendedLocalVote[]; 50 + }, 51 + [localVotes], 52 + ); 53 + 54 + const updateLocalState = useCallback( 55 + (pollUri: string, updater: (prev: ExtendedLocalVote[]) => ExtendedLocalVote[]) => { 56 + setLocalVotes((prev) => ({ 57 + ...prev, 58 + [pollUri]: updater((prev[pollUri] || []) as ExtendedLocalVote[]), 59 + })); 60 + }, 61 + [setLocalVotes], 62 + ); 63 + 64 + const castVoteRaw = useCallback( 65 + async ( 66 + pollUri: string, 67 + pollCid: string, 68 + option: string, 69 + isMultiple: boolean, 70 + currentServerVotes: string[], 71 + ) => { 72 + if (!agent?.did) return; 73 + 74 + const optionKey = option as "a" | "b" | "c" | "d"; 75 + const timestamp = Date.now(); 76 + 77 + // 1. DETERMINE CURRENT STATUS 78 + const currentLocal = (localVotes[pollUri] || []) as ExtendedLocalVote[]; 79 + const localEntry = currentLocal.find((v) => v.option === optionKey); 80 + 81 + // Check if ANY server vote exists for this option 82 + const hasServerVote = currentServerVotes.some((uri) => 83 + uri.includes(`app.reddwarf.poll.vote.${optionKey}`) 84 + ); 85 + 86 + const isCurrentlyVoted = localEntry 87 + ? localEntry.action === "create" 88 + : hasServerVote; 89 + 90 + // ------------------------------------------------------------ 91 + // ACTION: UNVOTE (Toggle Off) 92 + // ------------------------------------------------------------ 93 + if (isCurrentlyVoted) { 33 94 34 - const getLocalVotes = useCallback((pollUri: string) => { 35 - return localVotes[pollUri] || []; 36 - }, [localVotes]); 95 + // Optimistic Update: Tombstone 96 + updateLocalState(pollUri, (prev) => { 97 + const clean = prev.filter(v => v.option !== optionKey); 98 + return [...clean, { 99 + pollUri, 100 + option: optionKey, 101 + status: "pending", 102 + action: "delete", 103 + timestamp 104 + }]; 105 + }); 37 106 38 - const castVote = useCallback(async ( 39 - pollUri: string, 40 - pollCid: string, 41 - option: string, 42 - isMultiple: boolean, 43 - currentServerVotes: string[] // Array of AT-URIs existing on server 44 - ) => { 45 - if (!agent?.did) return; 107 + try { 108 + // FIX: Collect ALL URIs for this option (Server + Local) 109 + // We want to nuke every record that matches this option to clean up state 110 + const serverUris = currentServerVotes.filter(uri => 111 + uri.includes(`app.reddwarf.poll.vote.${optionKey}`) 112 + ); 46 113 47 - const optionKey = option as 'a' | 'b' | 'c' | 'd'; 48 - const timestamp = Date.now(); 114 + const urisToDelete = [...serverUris]; 115 + if (localEntry?.uri) { 116 + urisToDelete.push(localEntry.uri); 117 + } 49 118 50 - // 1. DETERMINE ACTION: Are we adding or removing? 51 - // Check local state first, then server state 52 - const currentLocal = localVotes[pollUri] || []; 53 - 54 - // Is this option currently selected in our "Merged" view? 55 - // It's selected if it's in local state OR (in server state AND NOT specifically removed locally) 56 - // For simplicity in this logic, we will assume if local state exists, it overrides server state for that option. 57 - const isLocallySelected = currentLocal.find(v => v.option === optionKey); 58 - 59 - // Logic: Toggle 60 - if (isLocallySelected) { 61 - // --- UNVOTE OPERATION --- 62 - 63 - // 1. Optimistic Update: Remove from local state immediately 64 - updateLocalState(pollUri, (prev) => prev.filter(v => v.option !== optionKey)); 119 + // Deduplicate just in case 120 + const uniqueUris = [...new Set(urisToDelete)]; 65 121 66 - try { 67 - // If it was 'confirmed' (has a URI) or was a server vote, we delete. 68 - // If it was 'pending', we can't delete yet (complex edge case), strictly ideally we block interaction on pending. 69 - 70 - let uriToDelete = isLocallySelected.uri; 71 - 72 - // If local didn't have URI (rare race condition) check server votes 73 - if (!uriToDelete) { 74 - const serverMatch = currentServerVotes.find(v => v.includes(`app.reddwarf.poll.vote.${optionKey}`)); 75 - if (serverMatch) uriToDelete = serverMatch; 76 - } 122 + // Parallel delete for everything found 123 + await Promise.all( 124 + uniqueUris.map(uri => { 125 + const match = uri.match(/at:\/\/(.+)\/(.+)\/(.+)/); 126 + if (!match) return Promise.resolve(); 127 + const [, repo, collection, rkey] = match; 128 + return agent.com.atproto.repo.deleteRecord({ 129 + repo, 130 + collection, 131 + rkey, 132 + }); 133 + }) 134 + ); 77 135 78 - if (uriToDelete) { 79 - const match = uriToDelete.match(/at:\/\/(.+)\/(.+)\/(.+)/); 80 - if (match) { 81 - const [, repo, collection, rkey] = match; 82 - await agent.com.atproto.repo.deleteRecord({ repo, collection, rkey }); 83 - } 136 + } catch (e) { 137 + console.error("Failed to unvote", e); 138 + renderSnack({ title: "Failed to remove vote" }); 139 + // Revert optimistic update 140 + updateLocalState(pollUri, (prev) => prev.filter(v => v.timestamp !== timestamp)); 84 141 } 85 - } catch (e) { 86 - console.error("Failed to unvote", e); 87 - renderSnack({ title: "Failed to remove vote" }); 88 - // Revert: add it back 89 - updateLocalState(pollUri, (prev) => [...prev, isLocallySelected]); 90 142 } 91 143 92 - } else { 93 - // --- VOTE OPERATION --- 144 + // ------------------------------------------------------------ 145 + // ACTION: VOTE (Toggle On) 146 + // ------------------------------------------------------------ 147 + else { 148 + // ... (The Vote logic remains the same, as the Single Choice cleanup 149 + // logic there already iterated over the entire array) ... 94 150 95 - // 1. Optimistic Update: Add to local state 96 - const tempVote: LocalVote = { 97 - pollUri, 98 - option: optionKey, 99 - status: 'pending', 100 - timestamp 101 - }; 151 + updateLocalState(pollUri, (prev) => { 152 + const newState = isMultiple ? [...prev] : prev.filter(v => v.action !== 'create'); 153 + const clean = newState.filter(v => v.option !== optionKey); 154 + return [...clean, { 155 + pollUri, 156 + option: optionKey, 157 + status: "pending", 158 + action: "create", 159 + timestamp 160 + }]; 161 + }); 102 162 103 - updateLocalState(pollUri, (prev) => { 104 - const newState = isMultiple ? [...prev] : []; // If single choice, clear other local votes 105 - // Add new vote 106 - newState.push(tempVote); 107 - return newState; 108 - }); 163 + // Cleanup others if single choice 164 + if (!isMultiple) { 165 + const votesToDelete = [ 166 + ...currentServerVotes, 167 + ...(currentLocal.filter(v => v.action === 'create' && v.uri).map(v => v.uri) as string[]) 168 + ]; 109 169 110 - // 2. Handle Single Choice - Network Side (Delete others) 111 - if (!isMultiple) { 112 - // We need to delete ANY existing votes (Server or Local Confirmed) that aren't this option 113 - // Note: The UI updated instantly above, so the user sees the switch. Now we assume the debt. 114 - const votesToDelete = [ 115 - ...currentServerVotes, 116 - ...(localVotes[pollUri]?.map(v => v.uri).filter(Boolean) as string[] || []) 117 - ]; 118 - 119 - // Fire and forget deletions (or queue them) 120 - votesToDelete.forEach(voteUri => { 121 - if (voteUri.includes(`app.reddwarf.poll.vote.${optionKey}`)) return; // Don't delete self (shouldn't happen here but safety) 170 + // This was already safe because it iterates the whole array 171 + votesToDelete.forEach((voteUri) => { 172 + if (voteUri.includes(`app.reddwarf.poll.vote.${optionKey}`)) return; 122 173 const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); 123 174 if (match) { 124 - const [, repo, collection, rkey] = match; 125 - agent.com.atproto.repo.deleteRecord({ repo, collection, rkey }).catch(console.error); 175 + const [, repo, collection, rkey] = match; 176 + agent.com.atproto.repo.deleteRecord({ repo, collection, rkey }).catch(console.error); 126 177 } 127 - }); 128 - } 129 - 130 - // 3. The 5-Second Grace Period Logic 131 - let isTimedOut = false; 132 - 133 - const timeoutPromise = new Promise<void>((resolve) => { 134 - setTimeout(() => { 135 - if (!isTimedOut) { // Check purely for closure capture 136 - // We check the *current* state. If it is still pending, we revert visual. 137 - // We access the ref/current state via the setter callback to be safe 138 - setLocalVotes(current => { 139 - const pollVotes = current[pollUri] || []; 140 - const myVote = pollVotes.find(v => v.option === optionKey && v.timestamp === timestamp); 141 - 142 - if (myVote && myVote.status === 'pending') { 143 - isTimedOut = true; 144 - // REVERT VISUALS (Requirement 1) 145 - // We remove it from local state so the UI looks "unvoted", but the request continues. 146 - return { 147 - ...current, 148 - [pollUri]: pollVotes.filter(v => v !== myVote) 149 - }; 150 - } 151 - return current; 152 - }); 153 - } 154 - resolve(); 155 - }, 5000); 156 - }); 178 + }); 179 + } 157 180 158 - // 4. Perform Network Request 159 - const performVote = async () => { 160 181 try { 161 182 const res = await agent.com.atproto.repo.createRecord({ 183 + // ... standard create logic 162 184 collection: `app.reddwarf.poll.vote.${optionKey}`, 163 185 repo: agent.assertDid, 164 186 record: { ··· 168 190 }, 169 191 }); 170 192 171 - // SUCCESS! 172 - 173 - // Requirement 2: Hold the URI. 174 - // We force this into the state with status 'confirmed'. 175 - // Even if we timed out earlier (and removed it), this puts it back! 176 193 updateLocalState(pollUri, (prev) => { 177 - // Remove any pending entry for this option (if it exists) 178 - const clean = prev.filter(v => v.option !== optionKey); 179 - return [...clean, { 180 - pollUri, 181 - option: optionKey, 182 - status: 'confirmed', 183 - uri: res.data.uri, 184 - timestamp: Date.now() // Update timestamp to fresh 185 - }]; 194 + const clean = prev.filter(v => v.option !== optionKey); 195 + return [...clean, { 196 + pollUri, 197 + option: optionKey, 198 + status: "confirmed", 199 + action: "create", 200 + uri: res.data.uri, 201 + timestamp: Date.now(), 202 + }]; 186 203 }); 187 - 188 204 } catch (e) { 189 205 console.error("Vote failed", e); 190 - if (!isTimedOut) { 191 - renderSnack({ title: "Vote failed" }); 192 - // Revert optimistic state 193 - updateLocalState(pollUri, (prev) => prev.filter(v => v.timestamp !== timestamp)); 194 - } 206 + renderSnack({ title: "Vote failed" }); 207 + updateLocalState(pollUri, (prev) => prev.filter(v => v.timestamp !== timestamp)); 195 208 } 196 - }; 197 - 198 - // Run them 199 - // We don't await the timeout for the UI, but the timeout logic runs in parallel 200 - performVote(); 201 - // We don't await performVote here to unblock UI, but the logic inside handles state updates 202 - } 203 - 204 - }, [agent, localVotes, updateLocalState, setLocalVotes]); 209 + } 210 + }, 211 + [agent, localVotes, updateLocalState, setLocalVotes], 212 + ); 205 213 206 214 return ( 207 - <PollMutationContext value={{ castVote, getLocalVotes }}> 215 + <PollMutationContext value={{ castVoteRaw, getLocalVotes }}> 208 216 {children} 209 217 </PollMutationContext> 210 218 ); 211 219 } 212 220 221 + // ------------------------------------------------------------------ 222 + // Hooks 223 + // ------------------------------------------------------------------ 224 + 213 225 export function usePollMutationQueue() { 214 226 const context = use(PollMutationContext); 215 227 if (!context) throw new Error("Missing PollMutationQueueProvider"); 216 228 return context; 217 229 } 218 230 231 + function usePollSelfVotes(pollUri: string) { 232 + const { agent } = useAuth(); 233 + const agentDid = agent?.did; 234 + 235 + const userVotesA = useGetOneToOneState( 236 + agentDid ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.a", path: ".subject.uri" } : undefined 237 + ); 238 + const userVotesB = useGetOneToOneState( 239 + agentDid ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.b", path: ".subject.uri" } : undefined 240 + ); 241 + const userVotesC = useGetOneToOneState( 242 + agentDid ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.c", path: ".subject.uri" } : undefined 243 + ); 244 + const userVotesD = useGetOneToOneState( 245 + agentDid ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.d", path: ".subject.uri" } : undefined 246 + ); 247 + 248 + return useMemo(() => { 249 + return [ 250 + ...(userVotesA || []), 251 + ...(userVotesB || []), 252 + ...(userVotesC || []), 253 + ...(userVotesD || []), 254 + ]; 255 + }, [userVotesA, userVotesB, userVotesC, userVotesD]); 256 + } 257 + 219 258 export function usePollData( 220 259 pollUri: string, 260 + pollCid: string | undefined, 221 261 isMultiple: boolean, 222 262 serverCounts: { a: number; b: number; c: number; d: number }, 223 - serverUserVotes: string[] // Array of AT-URIs (e.g. ['at://.../vote.a/...']) 224 263 ) { 225 - const { getLocalVotes } = usePollMutationQueue(); 226 - const localVotes = getLocalVotes(pollUri); 264 + const { castVoteRaw, getLocalVotes } = usePollMutationQueue(); 265 + const serverUserVotes = usePollSelfVotes(pollUri); 266 + const localVotes = getLocalVotes(pollUri); // Returns ExtendedLocalVote[] 267 + 268 + const handleVote = useCallback((optionKey: string) => { 269 + if (!pollCid) return; 270 + castVoteRaw(pollUri, pollCid, optionKey, isMultiple, serverUserVotes); 271 + }, [pollUri, pollCid, isMultiple, serverUserVotes, castVoteRaw]); 227 272 228 273 return useMemo(() => { 229 - // 1. Identify which options the SERVER thinks we voted for 230 - const serverState = { 231 - a: serverUserVotes.some((uri) => uri.includes("app.reddwarf.poll.vote.a")), 232 - b: serverUserVotes.some((uri) => uri.includes("app.reddwarf.poll.vote.b")), 233 - c: serverUserVotes.some((uri) => uri.includes("app.reddwarf.poll.vote.c")), 234 - d: serverUserVotes.some((uri) => uri.includes("app.reddwarf.poll.vote.d")), 235 - }; 236 - 237 - // 2. Identify which options LOCAL STATE thinks we voted for 238 - // (Pending or Confirmed Stale-While-Revalidate) 239 - const localState = { 240 - a: localVotes.some((v) => v.option === "a"), 241 - b: localVotes.some((v) => v.option === "b"), 242 - c: localVotes.some((v) => v.option === "c"), 243 - d: localVotes.some((v) => v.option === "d"), 244 - }; 245 - 246 - // 3. Determine if we have ANY local activity 247 - // If this is Single Choice, and we have a local vote, strictly ignore server votes for other options. 248 - const hasAnyLocalVote = localVotes.length > 0; 249 - 250 274 const calculateOptionState = (option: "a" | "b" | "c" | "d") => { 251 - const isLocallyVoted = localState[option]; 252 - const isServerVoted = serverState[option]; 275 + const localEntry = localVotes.find((v) => v.option === option); 276 + const isServerVoted = serverUserVotes.some((uri) => uri.includes(`app.reddwarf.poll.vote.${option}`)); 253 277 254 - // STATUS MERGE: 255 - // If Single Choice: Local Vote overrides everything. 256 - // If Multi Choice: Local Vote || Server Vote. 257 - let hasVoted = isLocallyVoted; 258 - 259 - if (!isMultiple) { 260 - // Single Choice Logic: 261 - // If we haven't touched this poll locally, trust the server. 262 - // If we HAVE touched it locally (voted for X), ignore server's Y. 263 - if (!hasAnyLocalVote && isServerVoted) { 264 - hasVoted = true; 278 + // --- MERGE STATUS LOGIC --- 279 + let hasVoted = false; 280 + 281 + if (localEntry) { 282 + // 1. If we have an explicit local action, it overrides everything for this option 283 + // 'create' = true, 'delete' = false 284 + hasVoted = localEntry.action === "create"; 285 + } else { 286 + // 2. If no local action for this specific option... 287 + if (isMultiple) { 288 + // In multiple choice, server truth stands unless explicitly deleted (checked above) 289 + hasVoted = isServerVoted; 290 + } 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 + } 265 298 } 266 - } else { 267 - // Multi Choice Logic: 268 - // Simple Union. (Note: Unvoting in multi-choice with your provider might flicker 269 - // because unvoting deletes the local record, causing fall-through to server record. 270 - // But adding votes works perfectly). 271 - hasVoted = isLocallyVoted || isServerVoted; 272 299 } 273 300 274 - // COUNT MERGE: 275 - // Start with server count. 301 + // --- MERGE COUNT LOGIC --- 276 302 let count = serverCounts[option] || 0; 277 303 278 - // If we show it as voted LOCALLY, but Server doesn't know yet -> Add 1 279 - if (isLocallyVoted && !isServerVoted) { 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) { 280 307 count++; 281 308 } 282 - 283 - // Edge Case: If we show it as NOT voted (because we switched to another option locally), 284 - // but Server still counts it -> Subtract 1 (Visual only) 285 - // This happens in single choice switching A -> B. 286 - // We want to decrement A visually while incrementing B. 287 - if (!isMultiple && hasAnyLocalVote && !isLocallyVoted && isServerVoted) { 309 + // If we are NOT Voted locally (e.g. unvoted or switched) but Server thinks we are -> -1 310 + if (!hasVoted && isServerVoted) { 288 311 count = Math.max(0, count - 1); 289 312 } 290 313 ··· 297 320 const stateD = calculateOptionState("d"); 298 321 299 322 return { 300 - results: { 301 - a: stateA, 302 - b: stateB, 303 - c: stateC, 304 - d: stateD, 305 - }, 306 - // Helper to check if user has interacted at all 323 + results: { a: stateA, b: stateB, c: stateC, d: stateD }, 307 324 hasVotedAny: stateA.hasVoted || stateB.hasVoted || stateC.hasVoted || stateD.hasVoted, 308 - totalVotes: stateA.count + stateB.count + stateC.count + stateD.count 325 + totalVotes: stateA.count + stateB.count + stateC.count + stateD.count, 326 + handleVote, 309 327 }; 310 - }, [localVotes, serverUserVotes, serverCounts, isMultiple]); 328 + }, [localVotes, serverUserVotes, serverCounts, isMultiple, handleVote]); 311 329 }