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

Polls state

+589 -213
+243 -204
src/components/UniversalPostRenderer.tsx
··· 1 1 import * as ATPAPI from "@atproto/api"; 2 - import { useQueryClient } from "@tanstack/react-query"; 3 2 import { useNavigate } from "@tanstack/react-router"; 4 3 import DOMPurify from "dompurify"; 5 4 import { useAtom } from "jotai"; ··· 14 13 enableBridgyTextAtom, 15 14 enableWafrnTextAtom, 16 15 imgCDNAtom, 17 - slingshotURLAtom, 18 16 } from "~/utils/atoms"; 19 17 import { useGetOneToOneState } from "~/utils/followState"; 20 18 import { useHydratedEmbed } from "~/utils/useHydrated"; ··· 411 409 setReplies( 412 410 links 413 411 ? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"] 414 - ?.records || 0 412 + ?.records || 0 415 413 : null, 416 414 ); 417 415 }, [links]); ··· 459 457 460 458 const replyAturis = repliesData 461 459 ? repliesData.pages.flatMap((page) => 462 - page 463 - ? page.linking_records.map((record) => { 464 - const aturi = `at://${record.did}/${record.collection}/${record.rkey}`; 465 - return aturi; 466 - }) 467 - : [], 468 - ) 460 + page 461 + ? page.linking_records.map((record) => { 462 + const aturi = `at://${record.did}/${record.collection}/${record.rkey}`; 463 + return aturi; 464 + }) 465 + : [], 466 + ) 469 467 : []; 470 468 471 469 //const [oldestOpsReply, setOldestOpsReply] = useState<string | undefined>(undefined); ··· 625 623 opacity: 0.5, 626 624 }} 627 625 className="dark:bg-[repeating-linear-gradient(to_bottom,var(--color-gray-500)_0,var(--color-gray-400)_4px,transparent_4px,transparent_8px)]" 628 - //className="border-gray-400 dark:border-gray-500" 626 + //className="border-gray-400 dark:border-gray-500" 629 627 /> 630 628 </div> 631 629 ··· 771 769 const isQuotewithImages = 772 770 isquotewithmedia && 773 771 (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 774 - "app.bsky.embed.images"; 772 + "app.bsky.embed.images"; 775 773 const isQuotewithVideo = 776 774 isquotewithmedia && 777 775 (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 778 - "app.bsky.embed.video"; 776 + "app.bsky.embed.video"; 779 777 780 778 const hasMedia = 781 779 hasEmbed && ··· 1259 1257 import ReactPlayer from "react-player"; 1260 1258 1261 1259 import defaultpfp from "~/../public/favicon.png"; 1260 + import { 1261 + usePollData, 1262 + usePollMutationQueue, 1263 + } from "~/providers/PollMutationQueueProvider"; 1262 1264 import { useAuth } from "~/providers/UnifiedAuthProvider"; 1263 1265 import { renderSnack } from "~/routes/__root"; 1264 1266 import { ··· 1494 1496 1495 1497 const tags = unfediwafrnTags 1496 1498 ? unfediwafrnTags 1497 - .split("\n") 1498 - .map((t) => t.trim()) 1499 - .filter(Boolean) 1499 + .split("\n") 1500 + .map((t) => t.trim()) 1501 + .filter(Boolean) 1500 1502 : undefined; 1501 1503 1502 1504 const links = tags 1503 1505 ? tags 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>") 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>") 1509 1511 : ""; 1510 1512 1511 1513 const unfediwafrn = unfediwafrnPartial ··· 1518 1520 1519 1521 /* fuck you */ 1520 1522 const isMainItem = false; 1521 - const setMainItem = (any: any) => { }; 1523 + const setMainItem = (any: any) => {}; 1522 1524 // eslint-disable-next-line react-hooks/refs 1523 1525 //console.log("Received ref in UniversalPostRenderer:", usedref); 1524 1526 return ( ··· 1532 1534 : setMainItem 1533 1535 ? onPostClick 1534 1536 ? (e) => { 1535 - setMainItem({ post: post }); 1536 - onPostClick(e); 1537 - } 1537 + setMainItem({ post: post }); 1538 + onPostClick(e); 1539 + } 1538 1540 : () => { 1539 - setMainItem({ post: post }); 1540 - } 1541 + setMainItem({ post: post }); 1542 + } 1541 1543 : undefined 1542 1544 } 1543 1545 style={{ ··· 2020 2022 try { 2021 2023 await navigator.clipboard.writeText( 2022 2024 "https://bsky.app" + 2023 - "/profile/" + 2024 - post.author.handle + 2025 - "/post/" + 2026 - post.uri.split("/").pop(), 2025 + "/profile/" + 2026 + post.author.handle + 2027 + "/post/" + 2028 + post.uri.split("/").pop(), 2027 2029 ); 2028 2030 renderSnack({ 2029 2031 title: "Copied to clipboard!", ··· 2131 2133 | AppBskyEmbedVideo.View 2132 2134 | AppBskyEmbedExternal.View 2133 2135 | AppBskyEmbedRecordWithMedia.View 2134 - | { $type: string;[k: string]: unknown }; 2136 + | { $type: string; [k: string]: unknown }; 2135 2137 2136 2138 enum PostEmbedViewContext { 2137 2139 ThreadHighlighted = "ThreadHighlighted", ··· 2150 2152 const { agent } = useAuth(); 2151 2153 const pollUri = `at://${did}/app.reddwarf.embed.poll/${rkey}`; 2152 2154 const { data: pollRecord, isLoading, error } = useQueryArbitrary(pollUri); 2155 + const { castVote } = usePollMutationQueue(); 2153 2156 2154 2157 // Query vote counts for each option 2155 - const [constellationurl] = useAtom(constellationURLAtom); 2156 - const [imgcdn] = useAtom(imgCDNAtom); 2157 - const [slingshoturl] = useAtom(slingshotURLAtom); 2158 - const queryClient = useQueryClient(); 2159 - 2160 2158 const { data: voteCountsA } = useQueryConstellation({ 2161 2159 method: "/links/count/distinct-dids", 2162 2160 target: pollUri, ··· 2218 2216 const userVotesA = useGetOneToOneState( 2219 2217 agent?.did 2220 2218 ? { 2221 - target: pollUri, 2222 - user: agent?.did, 2223 - collection: "app.reddwarf.poll.vote.a", 2224 - path: ".subject.uri", 2225 - } 2219 + target: pollUri, 2220 + user: agent?.did, 2221 + collection: "app.reddwarf.poll.vote.a", 2222 + path: ".subject.uri", 2223 + } 2226 2224 : undefined, 2227 2225 ); 2228 2226 2229 2227 const userVotesB = useGetOneToOneState( 2230 2228 agent?.did 2231 2229 ? { 2232 - target: pollUri, 2233 - user: agent?.did, 2234 - collection: "app.reddwarf.poll.vote.b", 2235 - path: ".subject.uri", 2236 - } 2230 + target: pollUri, 2231 + user: agent?.did, 2232 + collection: "app.reddwarf.poll.vote.b", 2233 + path: ".subject.uri", 2234 + } 2237 2235 : undefined, 2238 2236 ); 2239 2237 2240 2238 const userVotesC = useGetOneToOneState( 2241 2239 agent?.did 2242 2240 ? { 2243 - target: pollUri, 2244 - user: agent?.did, 2245 - collection: "app.reddwarf.poll.vote.c", 2246 - path: ".subject.uri", 2247 - } 2241 + target: pollUri, 2242 + user: agent?.did, 2243 + collection: "app.reddwarf.poll.vote.c", 2244 + path: ".subject.uri", 2245 + } 2248 2246 : undefined, 2249 2247 ); 2250 2248 2251 2249 const userVotesD = useGetOneToOneState( 2252 2250 agent?.did 2253 2251 ? { 2254 - target: pollUri, 2255 - user: agent?.did, 2256 - collection: "app.reddwarf.poll.vote.d", 2257 - path: ".subject.uri", 2258 - } 2252 + target: pollUri, 2253 + user: agent?.did, 2254 + collection: "app.reddwarf.poll.vote.d", 2255 + path: ".subject.uri", 2256 + } 2259 2257 : undefined, 2260 2258 ); 2261 2259 2262 - 2263 - 2264 2260 // todo: hardcoded to multiple for all public polls 2265 2261 const poll = { 2266 2262 ...(pollRecord?.value ?? {}), ··· 2277 2273 2278 2274 const options = [poll.a, poll.b, poll.c, poll.d].filter(Boolean); 2279 2275 2280 - // Calculate vote counts 2281 - const voteData = [ 2282 - { 2283 - option: "a", 2284 - count: parseInt((voteCountsA as any)?.total || "0"), 2285 - voters: votersA?.linking_records || [], 2286 - }, 2287 - { 2288 - option: "b", 2289 - count: parseInt((voteCountsB as any)?.total || "0"), 2290 - voters: votersB?.linking_records || [], 2291 - }, 2292 - { 2293 - option: "c", 2294 - count: parseInt((voteCountsC as any)?.total || "0"), 2295 - voters: votersC?.linking_records || [], 2296 - }, 2297 - { 2298 - option: "d", 2299 - count: parseInt((voteCountsD as any)?.total || "0"), 2300 - voters: votersD?.linking_records || [], 2301 - }, 2302 - ].slice(0, options.length); 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 + const serverCounts = { 2309 + a: parseInt((voteCountsA as any)?.total || "0"), 2310 + b: parseInt((voteCountsB as any)?.total || "0"), 2311 + c: parseInt((voteCountsC as any)?.total || "0"), 2312 + d: parseInt((voteCountsD as any)?.total || "0"), 2313 + }; 2314 + 2315 + // 3. THE MAGIC HOOK 2316 + const pollState = usePollData( 2317 + pollUri, 2318 + !!poll.multiple, 2319 + serverCounts, 2320 + serverUserVotes, 2321 + ); 2322 + 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 + }; 2303 2338 2304 2339 if (isLoading) { 2305 2340 return ( ··· 2333 2368 // }) 2334 2369 // : null; 2335 2370 2371 + // const totalVotes = voteData.reduce((sum, item) => sum + item.count, 0); 2336 2372 2337 - const totalVotes = voteData.reduce((sum, item) => sum + item.count, 0); 2373 + // const handleVote = async (option: string) => { 2374 + // if (!agent || isExpired) return; 2338 2375 2339 - const handleVote = async (option: string) => { 2340 - if (!agent || isExpired) return; 2376 + // try { 2377 + // // Get existing votes for this option 2378 + // const existingVotes = (() => { 2379 + // switch (option) { 2380 + // case "a": 2381 + // return userVotesA; 2382 + // case "b": 2383 + // return userVotesB; 2384 + // case "c": 2385 + // return userVotesC; 2386 + // case "d": 2387 + // return userVotesD; 2388 + // default: 2389 + // return []; 2390 + // } 2391 + // })(); 2341 2392 2342 - try { 2343 - // Get existing votes for this option 2344 - const existingVotes = (() => { 2345 - switch (option) { 2346 - case "a": 2347 - return userVotesA; 2348 - case "b": 2349 - return userVotesB; 2350 - case "c": 2351 - return userVotesC; 2352 - case "d": 2353 - return userVotesD; 2354 - default: 2355 - return []; 2356 - } 2357 - })(); 2393 + // // If user has already voted for this option, delete all votes (unvote) 2394 + // if (existingVotes && existingVotes.length > 0) { 2395 + // for (const voteUri of existingVotes) { 2396 + // const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); 2397 + // if (match) { 2398 + // const [, did, collection, rkey] = match; 2399 + // await agent.com.atproto.repo.deleteRecord({ 2400 + // repo: did, 2401 + // collection, 2402 + // rkey, 2403 + // }); 2404 + // } 2405 + // } 2406 + // } else { 2407 + // // If not voted for this option, create new vote 2408 + // // First, delete votes from other options if poll doesn't allow multiple votes 2409 + // if (!poll.multiple) { 2410 + // const otherVotes = [ 2411 + // ...(userVotesA || []), 2412 + // ...(userVotesB || []), 2413 + // ...(userVotesC || []), 2414 + // ...(userVotesD || []), 2415 + // ].filter((vote) => { 2416 + // // Filter out votes for the current option 2417 + // return !vote.includes(`app.reddwarf.poll.vote.${option}`); 2418 + // }); 2358 2419 2359 - // If user has already voted for this option, delete all votes (unvote) 2360 - if (existingVotes && existingVotes.length > 0) { 2361 - for (const voteUri of existingVotes) { 2362 - const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); 2363 - if (match) { 2364 - const [, did, collection, rkey] = match; 2365 - await agent.com.atproto.repo.deleteRecord({ 2366 - repo: did, 2367 - collection, 2368 - rkey, 2369 - }); 2370 - } 2371 - } 2372 - } else { 2373 - // If not voted for this option, create new vote 2374 - // First, delete votes from other options if poll doesn't allow multiple votes 2375 - if (!poll.multiple) { 2376 - const otherVotes = [ 2377 - ...(userVotesA || []), 2378 - ...(userVotesB || []), 2379 - ...(userVotesC || []), 2380 - ...(userVotesD || []), 2381 - ].filter((vote) => { 2382 - // Filter out votes for the current option 2383 - return !vote.includes(`app.reddwarf.poll.vote.${option}`); 2384 - }); 2420 + // for (const voteUri of otherVotes) { 2421 + // const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); 2422 + // if (match) { 2423 + // const [, did, collection, rkey] = match; 2424 + // await agent.com.atproto.repo.deleteRecord({ 2425 + // repo: did, 2426 + // collection, 2427 + // rkey, 2428 + // }); 2429 + // } 2430 + // } 2431 + // } 2385 2432 2386 - for (const voteUri of otherVotes) { 2387 - const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); 2388 - if (match) { 2389 - const [, did, collection, rkey] = match; 2390 - await agent.com.atproto.repo.deleteRecord({ 2391 - repo: did, 2392 - collection, 2393 - rkey, 2394 - }); 2395 - } 2396 - } 2397 - } 2398 - 2399 - // Create new vote 2400 - await agent.com.atproto.repo.createRecord({ 2401 - collection: `app.reddwarf.poll.vote.${option}`, 2402 - repo: agent.assertDid, 2403 - record: { 2404 - $type: `app.reddwarf.poll.vote.${option}`, 2405 - subject: { 2406 - $type: "com.atproto.repo.strongRef", 2407 - uri: pollUri, 2408 - cid: pollRecord.cid, 2409 - }, 2410 - createdAt: new Date().toISOString(), 2411 - }, 2412 - // Let PDS generate rkey automatically 2413 - }); 2414 - } 2415 - } catch (error) { 2416 - console.error("Failed to vote:", error); 2417 - } 2418 - }; 2433 + // // Create new vote 2434 + // await agent.com.atproto.repo.createRecord({ 2435 + // collection: `app.reddwarf.poll.vote.${option}`, 2436 + // repo: agent.assertDid, 2437 + // record: { 2438 + // $type: `app.reddwarf.poll.vote.${option}`, 2439 + // subject: { 2440 + // $type: "com.atproto.repo.strongRef", 2441 + // uri: pollUri, 2442 + // cid: pollRecord.cid, 2443 + // }, 2444 + // createdAt: new Date().toISOString(), 2445 + // }, 2446 + // // Let PDS generate rkey automatically 2447 + // }); 2448 + // } 2449 + // } catch (error) { 2450 + // console.error("Failed to vote:", error); 2451 + // } 2452 + // }; 2419 2453 2420 2454 return ( 2421 2455 <> ··· 2430 2464 2431 2465 {/* Multiplicity */} 2432 2466 <span className="text-sm font-normal text-gray-500 dark:text-gray-400 flex flex-row items-center gap-1"> 2433 - {poll.multiple ? (<IconMdiCheckboxMultipleMarked />) : (<IconMdiCheckCircle />)} 2467 + {poll.multiple ? ( 2468 + <IconMdiCheckboxMultipleMarked /> 2469 + ) : ( 2470 + <IconMdiCheckCircle /> 2471 + )} 2434 2472 {poll.multiple ? "Select one or more options" : "Select one option"} 2435 2473 </span> 2436 - 2437 2474 </div> 2438 2475 2439 2476 {/* Options List with Results */} 2440 2477 <div className="space-y-3"> 2441 2478 {options.map((optionText, index) => { 2442 - const optionKey = ["a", "b", "c", "d"][index]; 2479 + const optionKey = ["a", "b", "c", "d"][index] as 2480 + | "a" 2481 + | "b" 2482 + | "c" 2483 + | "d"; 2443 2484 2444 - // Check if user has voted for this option 2445 - const userVotesForOption = (() => { 2485 + // Get the state from the hook 2486 + const optionState = pollState.results[optionKey]; 2487 + const hasVotedForOption = optionState.hasVoted; 2488 + const voteCount = optionState.count; 2489 + const votePercentage = 2490 + pollState.totalVotes > 0 2491 + ? (voteCount / pollState.totalVotes) * 100 2492 + : 0; 2493 + 2494 + // Get the voters data for displaying avatars 2495 + const votersData = (() => { 2446 2496 switch (optionKey) { 2447 2497 case "a": 2448 - return userVotesA; 2498 + return votersA?.linking_records || []; 2449 2499 case "b": 2450 - return userVotesB; 2500 + return votersB?.linking_records || []; 2451 2501 case "c": 2452 - return userVotesC; 2502 + return votersC?.linking_records || []; 2453 2503 case "d": 2454 - return userVotesD; 2504 + return votersD?.linking_records || []; 2455 2505 default: 2456 2506 return []; 2457 2507 } 2458 2508 })(); 2459 2509 2460 - const rowData = voteData.find((v) => v.option === optionKey); 2461 - const hasVotedForOption = 2462 - userVotesForOption && userVotesForOption.length > 0; 2463 - const voteCount = 2464 - voteData.find((v) => v.option === optionKey)?.count ?? 0; 2465 - const votePercentage = 2466 - totalVotes > 0 ? (voteCount / totalVotes) * 100 : 0; 2467 - 2468 - // Extract just the DIDs we want to show (top 2) 2469 - const topVoters = rowData?.voters 2470 - .filter(v => !!v.did) 2471 - .slice(0, 5) || []; 2510 + // Extract just the DIDs we want to show (top 5) 2511 + const topVoters = 2512 + votersData.filter((v) => !!v.did).slice(0, 5) || []; 2472 2513 2473 2514 return ( 2474 2515 <div 2475 2516 key={index} 2476 - className={`group relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${!isExpired 2477 - ? hasVotedForOption 2478 - ? "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" 2479 - : "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" 2480 - : "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700" 2481 - }`} 2517 + className={`group relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${ 2518 + !isExpired 2519 + ? hasVotedForOption 2520 + ? "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 + : "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 + : "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700" 2523 + }`} 2482 2524 onClick={(e) => { 2483 2525 e.stopPropagation(); 2484 2526 if (!isExpired) { 2485 - handleVote(optionKey) 2527 + handleVote(optionKey); 2486 2528 } 2487 2529 }} 2488 2530 > ··· 2515 2557 style={{ zIndex: 5 - idx }} 2516 2558 > 2517 2559 {/* The Component handles the async fetch! */} 2518 - <PollOptionAvatar 2519 - did={voter.did} 2520 - /> 2560 + <PollOptionAvatar did={voter.did} /> 2521 2561 </div> 2522 2562 ))} 2523 2563 </div> ··· 2569 2609 }} 2570 2610 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]" 2571 2611 > 2572 - View all {totalVotes} votes 2612 + View all {pollState.totalVotes} votes 2573 2613 </button> 2574 2614 </div> 2575 2615 </div> ··· 2585 2625 ); 2586 2626 } 2587 2627 2588 - function PollOptionAvatar({ 2589 - did, 2590 - }: { 2591 - did: string; 2592 - }) { 2628 + function PollOptionAvatar({ did }: { did: string }) { 2593 2629 const [imgcdn] = useAtom(imgCDNAtom); 2594 2630 // Each avatar handles its own data fetching 2595 2631 // If this specific DID is already in cache, it loads instantly 2596 - const { data: profileRecord } = useQueryProfile(`at://${did}/app.bsky.actor.profile/self`) 2632 + const { data: profileRecord } = useQueryProfile( 2633 + `at://${did}/app.bsky.actor.profile/self`, 2634 + ); 2597 2635 2598 2636 //const profile = profileRecord?.value as ATPAPI.AppBskyActorProfile.Record; 2599 2637 const avatarUrl = getAvatarUrl(profileRecord, did, imgcdn); ··· 2935 2973 width: "100%", 2936 2974 aspectRatio: image.aspectRatio 2937 2975 ? (() => { 2938 - const { width, height } = image.aspectRatio; 2939 - const ratio = width / height; 2940 - return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`; 2941 - })() 2976 + const { width, height } = image.aspectRatio; 2977 + const ratio = width / height; 2978 + return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`; 2979 + })() 2942 2980 : "1 / 1", // fallback to square 2943 2981 //backgroundColor: theme.background, // fallback letterboxing color 2944 2982 borderRadius: 12, ··· 3636 3674 borderRadius: 12, 3637 3675 overflow: "hidden", 3638 3676 //border: `1px solid ${theme.border}`, 3639 - paddingTop: `${100 / (aspect ? aspect.width / aspect.height : 16 / 9) 3640 - }%`, // 16:9 = 56.25%, 4:3 = 75% 3677 + paddingTop: `${ 3678 + 100 / (aspect ? aspect.width / aspect.height : 16 / 9) 3679 + }%`, // 16:9 = 56.25%, 4:3 = 75% 3641 3680 }} 3642 3681 className="border border-gray-200 dark:border-gray-800 was7" 3643 3682 >
+311
src/providers/PollMutationQueueProvider.tsx
··· 1 + import { useAtom } from "jotai"; 2 + import React, { createContext, use, useCallback, useMemo } from "react"; 3 + 4 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 5 + import { renderSnack } from "~/routes/__root"; 6 + import { localPollVotesAtom, type LocalVote } from "~/utils/atoms"; 7 + 8 + interface PollMutationContextType { 9 + castVote: ( 10 + pollUri: string, 11 + pollCid: string, 12 + option: string, 13 + isMultiple: boolean, 14 + currentServerVotes: string[] // Pass current user vote URIs to handle unvoting logic 15 + ) => Promise<void>; 16 + 17 + getLocalVotes: (pollUri: string) => LocalVote[]; 18 + } 19 + 20 + const PollMutationContext = createContext<PollMutationContextType | undefined>(undefined); 21 + 22 + export function PollMutationQueueProvider({ children }: { children: React.ReactNode }) { 23 + const { agent } = useAuth(); 24 + 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]); 33 + 34 + const getLocalVotes = useCallback((pollUri: string) => { 35 + return localVotes[pollUri] || []; 36 + }, [localVotes]); 37 + 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; 46 + 47 + const optionKey = option as 'a' | 'b' | 'c' | 'd'; 48 + const timestamp = Date.now(); 49 + 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)); 65 + 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 + } 77 + 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 + } 84 + } 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 + } 91 + 92 + } else { 93 + // --- VOTE OPERATION --- 94 + 95 + // 1. Optimistic Update: Add to local state 96 + const tempVote: LocalVote = { 97 + pollUri, 98 + option: optionKey, 99 + status: 'pending', 100 + timestamp 101 + }; 102 + 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 + }); 109 + 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) 122 + const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); 123 + if (match) { 124 + const [, repo, collection, rkey] = match; 125 + agent.com.atproto.repo.deleteRecord({ repo, collection, rkey }).catch(console.error); 126 + } 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 + }); 157 + 158 + // 4. Perform Network Request 159 + const performVote = async () => { 160 + try { 161 + const res = await agent.com.atproto.repo.createRecord({ 162 + collection: `app.reddwarf.poll.vote.${optionKey}`, 163 + repo: agent.assertDid, 164 + record: { 165 + $type: `app.reddwarf.poll.vote.${optionKey}`, 166 + subject: { uri: pollUri, cid: pollCid }, 167 + createdAt: new Date().toISOString(), 168 + }, 169 + }); 170 + 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 + 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 + }]; 186 + }); 187 + 188 + } catch (e) { 189 + 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 + } 195 + } 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]); 205 + 206 + return ( 207 + <PollMutationContext value={{ castVote, getLocalVotes }}> 208 + {children} 209 + </PollMutationContext> 210 + ); 211 + } 212 + 213 + export function usePollMutationQueue() { 214 + const context = use(PollMutationContext); 215 + if (!context) throw new Error("Missing PollMutationQueueProvider"); 216 + return context; 217 + } 218 + 219 + export function usePollData( 220 + pollUri: string, 221 + isMultiple: boolean, 222 + serverCounts: { a: number; b: number; c: number; d: number }, 223 + serverUserVotes: string[] // Array of AT-URIs (e.g. ['at://.../vote.a/...']) 224 + ) { 225 + const { getLocalVotes } = usePollMutationQueue(); 226 + const localVotes = getLocalVotes(pollUri); 227 + 228 + 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 + const calculateOptionState = (option: "a" | "b" | "c" | "d") => { 251 + const isLocallyVoted = localState[option]; 252 + const isServerVoted = serverState[option]; 253 + 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; 265 + } 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 + } 273 + 274 + // COUNT MERGE: 275 + // Start with server count. 276 + let count = serverCounts[option] || 0; 277 + 278 + // If we show it as voted LOCALLY, but Server doesn't know yet -> Add 1 279 + if (isLocallyVoted && !isServerVoted) { 280 + count++; 281 + } 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) { 288 + count = Math.max(0, count - 1); 289 + } 290 + 291 + return { hasVoted, count }; 292 + }; 293 + 294 + const stateA = calculateOptionState("a"); 295 + const stateB = calculateOptionState("b"); 296 + const stateC = calculateOptionState("c"); 297 + const stateD = calculateOptionState("d"); 298 + 299 + 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 307 + hasVotedAny: stateA.hasVoted || stateB.hasVoted || stateC.hasVoted || stateD.hasVoted, 308 + totalVotes: stateA.count + stateB.count + stateC.count + stateD.count 309 + }; 310 + }, [localVotes, serverUserVotes, serverCounts, isMultiple]); 311 + }
+17 -9
src/routes/__root.tsx
··· 25 25 import { NotFound } from "~/components/NotFound"; 26 26 import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; 27 27 import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; 28 + import { PollMutationQueueProvider } from "~/providers/PollMutationQueueProvider"; 28 29 import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; 29 30 import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms"; 30 31 import { seo } from "~/utils/seo"; ··· 83 84 return ( 84 85 <UnifiedAuthProvider> 85 86 <LikeMutationQueueProvider> 86 - <RootDocument> 87 - <KeepAliveProvider> 88 - <AppToaster /> 89 - <KeepAliveOutlet /> 90 - </KeepAliveProvider> 91 - </RootDocument> 87 + <PollMutationQueueProvider> 88 + <RootDocument> 89 + <KeepAliveProvider> 90 + <AppToaster /> 91 + <KeepAliveOutlet /> 92 + </KeepAliveProvider> 93 + </RootDocument> 94 + </PollMutationQueueProvider> 92 95 </LikeMutationQueueProvider> 93 96 </UnifiedAuthProvider> 94 97 ); ··· 176 179 </button> 177 180 </div> 178 181 ) : null} 179 - <button className=" ml-4" 182 + <button 183 + className=" ml-4" 180 184 onClick={() => { 181 185 sonnerToast.dismiss(id); 182 186 }} ··· 232 236 ? "notifications" 233 237 : isProfile 234 238 ? "profile" 235 - : isModeration 239 + : isModeration 236 240 ? "moderation" 237 241 : "home"; 238 242 ··· 806 810 <IconMaterialSymbolsSettingsOutline className="w-6 h-6" /> 807 811 } 808 812 ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />} 809 - active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"} 813 + active={ 814 + locationEnum === "settings" || 815 + locationEnum === "feeds" || 816 + locationEnum === "moderation" 817 + } 810 818 onClickCallbback={() => 811 819 navigate({ 812 820 to: "/settings",
+18
src/utils/atoms.ts
··· 153 153 "enableWafrnTextAtom", 154 154 false 155 155 ); 156 + 157 + 158 + // polls state 159 + 160 + export type PollVoteStatus = 'pending' | 'confirmed'; 161 + 162 + export interface LocalVote { 163 + pollUri: string; 164 + option: 'a' | 'b' | 'c' | 'd'; 165 + status: PollVoteStatus; 166 + uri?: string; // The AT-URI. 'undefined' if pending 167 + timestamp: number; 168 + } 169 + 170 + // Map: PollURI -> Array of Votes (because a user can vote for A and B in multi-choice) 171 + export type PollStateMap = Record<string, LocalVote[]>; 172 + 173 + export const localPollVotesAtom = atom<PollStateMap>({});