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

+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>({});