diff --git a/src/components/UniversalPostRenderer.tsx b/src/components/UniversalPostRenderer.tsx index e3191fb..01df84e 100644 --- a/src/components/UniversalPostRenderer.tsx +++ b/src/components/UniversalPostRenderer.tsx @@ -1,5 +1,4 @@ import * as ATPAPI from "@atproto/api"; -import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import DOMPurify from "dompurify"; import { useAtom } from "jotai"; @@ -14,7 +13,6 @@ import { enableBridgyTextAtom, enableWafrnTextAtom, imgCDNAtom, - slingshotURLAtom, } from "~/utils/atoms"; import { useGetOneToOneState } from "~/utils/followState"; import { useHydratedEmbed } from "~/utils/useHydrated"; @@ -411,7 +409,7 @@ export function UniversalPostRendererATURILoader({ setReplies( links ? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"] - ?.records || 0 + ?.records || 0 : null, ); }, [links]); @@ -459,13 +457,13 @@ export function UniversalPostRendererATURILoader({ const replyAturis = repliesData ? repliesData.pages.flatMap((page) => - page - ? page.linking_records.map((record) => { - const aturi = `at://${record.did}/${record.collection}/${record.rkey}`; - return aturi; - }) - : [], - ) + page + ? page.linking_records.map((record) => { + const aturi = `at://${record.did}/${record.collection}/${record.rkey}`; + return aturi; + }) + : [], + ) : []; //const [oldestOpsReply, setOldestOpsReply] = useState(undefined); @@ -625,7 +623,7 @@ function MoreReplies({ atUri }: { atUri: string }) { opacity: 0.5, }} className="dark:bg-[repeating-linear-gradient(to_bottom,var(--color-gray-500)_0,var(--color-gray-400)_4px,transparent_4px,transparent_8px)]" - //className="border-gray-400 dark:border-gray-500" + //className="border-gray-400 dark:border-gray-500" /> @@ -771,11 +769,11 @@ export function UniversalPostRendererRawRecordShim({ const isQuotewithImages = isquotewithmedia && (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === - "app.bsky.embed.images"; + "app.bsky.embed.images"; const isQuotewithVideo = isquotewithmedia && (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === - "app.bsky.embed.video"; + "app.bsky.embed.video"; const hasMedia = hasEmbed && @@ -1259,6 +1257,10 @@ import { useEffect, useRef, useState } from "react"; import ReactPlayer from "react-player"; import defaultpfp from "~/../public/favicon.png"; +import { + usePollData, + usePollMutationQueue, +} from "~/providers/PollMutationQueueProvider"; import { useAuth } from "~/providers/UnifiedAuthProvider"; import { renderSnack } from "~/routes/__root"; import { @@ -1494,18 +1496,18 @@ function UniversalPostRenderer({ const tags = unfediwafrnTags ? unfediwafrnTags - .split("\n") - .map((t) => t.trim()) - .filter(Boolean) + .split("\n") + .map((t) => t.trim()) + .filter(Boolean) : undefined; const links = tags ? tags - .map((tag) => { - const encoded = encodeURIComponent(tag); - return `#${tag.replaceAll(" ", "-")}`; - }) - .join("
") + .map((tag) => { + const encoded = encodeURIComponent(tag); + return `#${tag.replaceAll(" ", "-")}`; + }) + .join("
") : ""; const unfediwafrn = unfediwafrnPartial @@ -1518,7 +1520,7 @@ function UniversalPostRenderer({ /* fuck you */ const isMainItem = false; - const setMainItem = (any: any) => { }; + const setMainItem = (any: any) => {}; // eslint-disable-next-line react-hooks/refs //console.log("Received ref in UniversalPostRenderer:", usedref); return ( @@ -1532,12 +1534,12 @@ function UniversalPostRenderer({ : setMainItem ? onPostClick ? (e) => { - setMainItem({ post: post }); - onPostClick(e); - } + setMainItem({ post: post }); + onPostClick(e); + } : () => { - setMainItem({ post: post }); - } + setMainItem({ post: post }); + } : undefined } style={{ @@ -2020,10 +2022,10 @@ function UniversalPostRenderer({ try { await navigator.clipboard.writeText( "https://bsky.app" + - "/profile/" + - post.author.handle + - "/post/" + - post.uri.split("/").pop(), + "/profile/" + + post.author.handle + + "/post/" + + post.uri.split("/").pop(), ); renderSnack({ title: "Copied to clipboard!", @@ -2131,7 +2133,7 @@ type Embed = | AppBskyEmbedVideo.View | AppBskyEmbedExternal.View | AppBskyEmbedRecordWithMedia.View - | { $type: string;[k: string]: unknown }; + | { $type: string; [k: string]: unknown }; enum PostEmbedViewContext { ThreadHighlighted = "ThreadHighlighted", @@ -2150,13 +2152,9 @@ function PollEmbed({ did, rkey }: { did: string; rkey: string }) { const { agent } = useAuth(); const pollUri = `at://${did}/app.reddwarf.embed.poll/${rkey}`; const { data: pollRecord, isLoading, error } = useQueryArbitrary(pollUri); + const { castVote } = usePollMutationQueue(); // Query vote counts for each option - const [constellationurl] = useAtom(constellationURLAtom); - const [imgcdn] = useAtom(imgCDNAtom); - const [slingshoturl] = useAtom(slingshotURLAtom); - const queryClient = useQueryClient(); - const { data: voteCountsA } = useQueryConstellation({ method: "/links/count/distinct-dids", target: pollUri, @@ -2218,49 +2216,47 @@ function PollEmbed({ did, rkey }: { did: string; rkey: string }) { const userVotesA = useGetOneToOneState( agent?.did ? { - target: pollUri, - user: agent?.did, - collection: "app.reddwarf.poll.vote.a", - path: ".subject.uri", - } + target: pollUri, + user: agent?.did, + collection: "app.reddwarf.poll.vote.a", + path: ".subject.uri", + } : undefined, ); const userVotesB = useGetOneToOneState( agent?.did ? { - target: pollUri, - user: agent?.did, - collection: "app.reddwarf.poll.vote.b", - path: ".subject.uri", - } + target: pollUri, + user: agent?.did, + collection: "app.reddwarf.poll.vote.b", + path: ".subject.uri", + } : undefined, ); const userVotesC = useGetOneToOneState( agent?.did ? { - target: pollUri, - user: agent?.did, - collection: "app.reddwarf.poll.vote.c", - path: ".subject.uri", - } + target: pollUri, + user: agent?.did, + collection: "app.reddwarf.poll.vote.c", + path: ".subject.uri", + } : undefined, ); const userVotesD = useGetOneToOneState( agent?.did ? { - target: pollUri, - user: agent?.did, - collection: "app.reddwarf.poll.vote.d", - path: ".subject.uri", - } + target: pollUri, + user: agent?.did, + collection: "app.reddwarf.poll.vote.d", + path: ".subject.uri", + } : undefined, ); - - // todo: hardcoded to multiple for all public polls const poll = { ...(pollRecord?.value ?? {}), @@ -2277,29 +2273,68 @@ function PollEmbed({ did, rkey }: { did: string; rkey: string }) { const options = [poll.a, poll.b, poll.c, poll.d].filter(Boolean); - // Calculate vote counts - const voteData = [ - { - option: "a", - count: parseInt((voteCountsA as any)?.total || "0"), - voters: votersA?.linking_records || [], - }, - { - option: "b", - count: parseInt((voteCountsB as any)?.total || "0"), - voters: votersB?.linking_records || [], - }, - { - option: "c", - count: parseInt((voteCountsC as any)?.total || "0"), - voters: votersC?.linking_records || [], - }, - { - option: "d", - count: parseInt((voteCountsD as any)?.total || "0"), - voters: votersD?.linking_records || [], - }, - ].slice(0, options.length); + // // Calculate vote counts + // const voteData = [ + // { + // option: "a", + // count: parseInt((voteCountsA as any)?.total || "0"), + // voters: votersA?.linking_records || [], + // }, + // { + // option: "b", + // count: parseInt((voteCountsB as any)?.total || "0"), + // voters: votersB?.linking_records || [], + // }, + // { + // option: "c", + // count: parseInt((voteCountsC as any)?.total || "0"), + // voters: votersC?.linking_records || [], + // }, + // { + // option: "d", + // count: parseInt((voteCountsD as any)?.total || "0"), + // voters: votersD?.linking_records || [], + // }, + // ].slice(0, options.length); + + const serverUserVotes = [ + ...(userVotesA || []), + ...(userVotesB || []), + ...(userVotesC || []), + ...(userVotesD || []), + ]; + + // Flatten counts + const serverCounts = { + a: parseInt((voteCountsA as any)?.total || "0"), + b: parseInt((voteCountsB as any)?.total || "0"), + c: parseInt((voteCountsC as any)?.total || "0"), + d: parseInt((voteCountsD as any)?.total || "0"), + }; + + // 3. THE MAGIC HOOK + const pollState = usePollData( + pollUri, + !!poll.multiple, + serverCounts, + serverUserVotes, + ); + + // 4. Handle Vote Wrapper + const handleVote = async (optionKey: string) => { + if (!pollRecord) return; + // Expiry check + if (isExpired) return; + + // Trigger the Provider logic + await castVote( + pollUri, + pollRecord.cid, + optionKey, + !!poll.multiple, + serverUserVotes, + ); + }; if (isLoading) { return ( @@ -2333,89 +2368,88 @@ function PollEmbed({ did, rkey }: { did: string; rkey: string }) { // }) // : null; + // const totalVotes = voteData.reduce((sum, item) => sum + item.count, 0); + + // const handleVote = async (option: string) => { + // if (!agent || isExpired) return; + + // try { + // // Get existing votes for this option + // const existingVotes = (() => { + // switch (option) { + // case "a": + // return userVotesA; + // case "b": + // return userVotesB; + // case "c": + // return userVotesC; + // case "d": + // return userVotesD; + // default: + // return []; + // } + // })(); + + // // If user has already voted for this option, delete all votes (unvote) + // if (existingVotes && existingVotes.length > 0) { + // for (const voteUri of existingVotes) { + // const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); + // if (match) { + // const [, did, collection, rkey] = match; + // await agent.com.atproto.repo.deleteRecord({ + // repo: did, + // collection, + // rkey, + // }); + // } + // } + // } else { + // // If not voted for this option, create new vote + // // First, delete votes from other options if poll doesn't allow multiple votes + // if (!poll.multiple) { + // const otherVotes = [ + // ...(userVotesA || []), + // ...(userVotesB || []), + // ...(userVotesC || []), + // ...(userVotesD || []), + // ].filter((vote) => { + // // Filter out votes for the current option + // return !vote.includes(`app.reddwarf.poll.vote.${option}`); + // }); + + // for (const voteUri of otherVotes) { + // const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); + // if (match) { + // const [, did, collection, rkey] = match; + // await agent.com.atproto.repo.deleteRecord({ + // repo: did, + // collection, + // rkey, + // }); + // } + // } + // } - const totalVotes = voteData.reduce((sum, item) => sum + item.count, 0); - - const handleVote = async (option: string) => { - if (!agent || isExpired) return; - - try { - // Get existing votes for this option - const existingVotes = (() => { - switch (option) { - case "a": - return userVotesA; - case "b": - return userVotesB; - case "c": - return userVotesC; - case "d": - return userVotesD; - default: - return []; - } - })(); - - // If user has already voted for this option, delete all votes (unvote) - if (existingVotes && existingVotes.length > 0) { - for (const voteUri of existingVotes) { - const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); - if (match) { - const [, did, collection, rkey] = match; - await agent.com.atproto.repo.deleteRecord({ - repo: did, - collection, - rkey, - }); - } - } - } else { - // If not voted for this option, create new vote - // First, delete votes from other options if poll doesn't allow multiple votes - if (!poll.multiple) { - const otherVotes = [ - ...(userVotesA || []), - ...(userVotesB || []), - ...(userVotesC || []), - ...(userVotesD || []), - ].filter((vote) => { - // Filter out votes for the current option - return !vote.includes(`app.reddwarf.poll.vote.${option}`); - }); - - for (const voteUri of otherVotes) { - const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); - if (match) { - const [, did, collection, rkey] = match; - await agent.com.atproto.repo.deleteRecord({ - repo: did, - collection, - rkey, - }); - } - } - } - - // Create new vote - await agent.com.atproto.repo.createRecord({ - collection: `app.reddwarf.poll.vote.${option}`, - repo: agent.assertDid, - record: { - $type: `app.reddwarf.poll.vote.${option}`, - subject: { - $type: "com.atproto.repo.strongRef", - uri: pollUri, - cid: pollRecord.cid, - }, - createdAt: new Date().toISOString(), - }, - // Let PDS generate rkey automatically - }); - } - } catch (error) { - console.error("Failed to vote:", error); - } - }; + // // Create new vote + // await agent.com.atproto.repo.createRecord({ + // collection: `app.reddwarf.poll.vote.${option}`, + // repo: agent.assertDid, + // record: { + // $type: `app.reddwarf.poll.vote.${option}`, + // subject: { + // $type: "com.atproto.repo.strongRef", + // uri: pollUri, + // cid: pollRecord.cid, + // }, + // createdAt: new Date().toISOString(), + // }, + // // Let PDS generate rkey automatically + // }); + // } + // } catch (error) { + // console.error("Failed to vote:", error); + // } + // }; return ( <> @@ -2430,59 +2464,67 @@ function PollEmbed({ did, rkey }: { did: string; rkey: string }) { {/* Multiplicity */} - {poll.multiple ? () : ()} + {poll.multiple ? ( + + ) : ( + + )} {poll.multiple ? "Select one or more options" : "Select one option"} - {/* Options List with Results */}
{options.map((optionText, index) => { - const optionKey = ["a", "b", "c", "d"][index]; + const optionKey = ["a", "b", "c", "d"][index] as + | "a" + | "b" + | "c" + | "d"; + + // Get the state from the hook + const optionState = pollState.results[optionKey]; + const hasVotedForOption = optionState.hasVoted; + const voteCount = optionState.count; + const votePercentage = + pollState.totalVotes > 0 + ? (voteCount / pollState.totalVotes) * 100 + : 0; - // Check if user has voted for this option - const userVotesForOption = (() => { + // Get the voters data for displaying avatars + const votersData = (() => { switch (optionKey) { case "a": - return userVotesA; + return votersA?.linking_records || []; case "b": - return userVotesB; + return votersB?.linking_records || []; case "c": - return userVotesC; + return votersC?.linking_records || []; case "d": - return userVotesD; + return votersD?.linking_records || []; default: return []; } })(); - const rowData = voteData.find((v) => v.option === optionKey); - const hasVotedForOption = - userVotesForOption && userVotesForOption.length > 0; - const voteCount = - voteData.find((v) => v.option === optionKey)?.count ?? 0; - const votePercentage = - totalVotes > 0 ? (voteCount / totalVotes) * 100 : 0; - - // Extract just the DIDs we want to show (top 2) - const topVoters = rowData?.voters - .filter(v => !!v.did) - .slice(0, 5) || []; + // Extract just the DIDs we want to show (top 5) + const topVoters = + votersData.filter((v) => !!v.did).slice(0, 5) || []; return (
{ e.stopPropagation(); if (!isExpired) { - handleVote(optionKey) + handleVote(optionKey); } }} > @@ -2515,9 +2557,7 @@ function PollEmbed({ did, rkey }: { did: string; rkey: string }) { style={{ zIndex: 5 - idx }} > {/* The Component handles the async fetch! */} - +
))}
@@ -2569,7 +2609,7 @@ function PollEmbed({ did, rkey }: { did: string; rkey: string }) { }} 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]" > - View all {totalVotes} votes + View all {pollState.totalVotes} votes @@ -2585,15 +2625,13 @@ function PollEmbed({ did, rkey }: { did: string; rkey: string }) { ); } -function PollOptionAvatar({ - did, -}: { - did: string; -}) { +function PollOptionAvatar({ did }: { did: string }) { const [imgcdn] = useAtom(imgCDNAtom); // Each avatar handles its own data fetching // If this specific DID is already in cache, it loads instantly - const { data: profileRecord } = useQueryProfile(`at://${did}/app.bsky.actor.profile/self`) + const { data: profileRecord } = useQueryProfile( + `at://${did}/app.bsky.actor.profile/self`, + ); //const profile = profileRecord?.value as ATPAPI.AppBskyActorProfile.Record; const avatarUrl = getAvatarUrl(profileRecord, did, imgcdn); @@ -2935,10 +2973,10 @@ function PostEmbeds({ width: "100%", aspectRatio: image.aspectRatio ? (() => { - const { width, height } = image.aspectRatio; - const ratio = width / height; - return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`; - })() + const { width, height } = image.aspectRatio; + const ratio = width / height; + return ratio < 0.5 ? "1 / 2" : `${width} / ${height}`; + })() : "1 / 1", // fallback to square //backgroundColor: theme.background, // fallback letterboxing color borderRadius: 12, @@ -3636,8 +3674,9 @@ const SmartHLSPlayer = ({ borderRadius: 12, overflow: "hidden", //border: `1px solid ${theme.border}`, - paddingTop: `${100 / (aspect ? aspect.width / aspect.height : 16 / 9) - }%`, // 16:9 = 56.25%, 4:3 = 75% + paddingTop: `${ + 100 / (aspect ? aspect.width / aspect.height : 16 / 9) + }%`, // 16:9 = 56.25%, 4:3 = 75% }} className="border border-gray-200 dark:border-gray-800 was7" > diff --git a/src/providers/PollMutationQueueProvider.tsx b/src/providers/PollMutationQueueProvider.tsx new file mode 100644 index 0000000..e559831 --- /dev/null +++ b/src/providers/PollMutationQueueProvider.tsx @@ -0,0 +1,311 @@ +import { useAtom } from "jotai"; +import React, { createContext, use, useCallback, useMemo } from "react"; + +import { useAuth } from "~/providers/UnifiedAuthProvider"; +import { renderSnack } from "~/routes/__root"; +import { localPollVotesAtom, type LocalVote } from "~/utils/atoms"; + +interface PollMutationContextType { + castVote: ( + pollUri: string, + pollCid: string, + option: string, + isMultiple: boolean, + currentServerVotes: string[] // Pass current user vote URIs to handle unvoting logic + ) => Promise; + + getLocalVotes: (pollUri: string) => LocalVote[]; +} + +const PollMutationContext = createContext(undefined); + +export function PollMutationQueueProvider({ children }: { children: React.ReactNode }) { + const { agent } = useAuth(); + const [localVotes, setLocalVotes] = useAtom(localPollVotesAtom); + + // Helper to safely update state + const updateLocalState = useCallback((pollUri: string, updater: (prev: LocalVote[]) => LocalVote[]) => { + setLocalVotes(prev => ({ + ...prev, + [pollUri]: updater(prev[pollUri] || []) + })); + }, [setLocalVotes]); + + const getLocalVotes = useCallback((pollUri: string) => { + return localVotes[pollUri] || []; + }, [localVotes]); + + const castVote = useCallback(async ( + pollUri: string, + pollCid: string, + option: string, + isMultiple: boolean, + currentServerVotes: string[] // Array of AT-URIs existing on server + ) => { + if (!agent?.did) return; + + const optionKey = option as 'a' | 'b' | 'c' | 'd'; + const timestamp = Date.now(); + + // 1. DETERMINE ACTION: Are we adding or removing? + // Check local state first, then server state + const currentLocal = localVotes[pollUri] || []; + + // Is this option currently selected in our "Merged" view? + // It's selected if it's in local state OR (in server state AND NOT specifically removed locally) + // For simplicity in this logic, we will assume if local state exists, it overrides server state for that option. + const isLocallySelected = currentLocal.find(v => v.option === optionKey); + + // Logic: Toggle + if (isLocallySelected) { + // --- UNVOTE OPERATION --- + + // 1. Optimistic Update: Remove from local state immediately + updateLocalState(pollUri, (prev) => prev.filter(v => v.option !== optionKey)); + + try { + // If it was 'confirmed' (has a URI) or was a server vote, we delete. + // If it was 'pending', we can't delete yet (complex edge case), strictly ideally we block interaction on pending. + + let uriToDelete = isLocallySelected.uri; + + // If local didn't have URI (rare race condition) check server votes + if (!uriToDelete) { + const serverMatch = currentServerVotes.find(v => v.includes(`app.reddwarf.poll.vote.${optionKey}`)); + if (serverMatch) uriToDelete = serverMatch; + } + + if (uriToDelete) { + const match = uriToDelete.match(/at:\/\/(.+)\/(.+)\/(.+)/); + if (match) { + const [, repo, collection, rkey] = match; + await agent.com.atproto.repo.deleteRecord({ repo, collection, rkey }); + } + } + } catch (e) { + console.error("Failed to unvote", e); + renderSnack({ title: "Failed to remove vote" }); + // Revert: add it back + updateLocalState(pollUri, (prev) => [...prev, isLocallySelected]); + } + + } else { + // --- VOTE OPERATION --- + + // 1. Optimistic Update: Add to local state + const tempVote: LocalVote = { + pollUri, + option: optionKey, + status: 'pending', + timestamp + }; + + updateLocalState(pollUri, (prev) => { + const newState = isMultiple ? [...prev] : []; // If single choice, clear other local votes + // Add new vote + newState.push(tempVote); + return newState; + }); + + // 2. Handle Single Choice - Network Side (Delete others) + if (!isMultiple) { + // We need to delete ANY existing votes (Server or Local Confirmed) that aren't this option + // Note: The UI updated instantly above, so the user sees the switch. Now we assume the debt. + const votesToDelete = [ + ...currentServerVotes, + ...(localVotes[pollUri]?.map(v => v.uri).filter(Boolean) as string[] || []) + ]; + + // Fire and forget deletions (or queue them) + votesToDelete.forEach(voteUri => { + if (voteUri.includes(`app.reddwarf.poll.vote.${optionKey}`)) return; // Don't delete self (shouldn't happen here but safety) + const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); + if (match) { + const [, repo, collection, rkey] = match; + agent.com.atproto.repo.deleteRecord({ repo, collection, rkey }).catch(console.error); + } + }); + } + + // 3. The 5-Second Grace Period Logic + let isTimedOut = false; + + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + if (!isTimedOut) { // Check purely for closure capture + // We check the *current* state. If it is still pending, we revert visual. + // We access the ref/current state via the setter callback to be safe + setLocalVotes(current => { + const pollVotes = current[pollUri] || []; + const myVote = pollVotes.find(v => v.option === optionKey && v.timestamp === timestamp); + + if (myVote && myVote.status === 'pending') { + isTimedOut = true; + // REVERT VISUALS (Requirement 1) + // We remove it from local state so the UI looks "unvoted", but the request continues. + return { + ...current, + [pollUri]: pollVotes.filter(v => v !== myVote) + }; + } + return current; + }); + } + resolve(); + }, 5000); + }); + + // 4. Perform Network Request + const performVote = async () => { + try { + const res = await agent.com.atproto.repo.createRecord({ + collection: `app.reddwarf.poll.vote.${optionKey}`, + repo: agent.assertDid, + record: { + $type: `app.reddwarf.poll.vote.${optionKey}`, + subject: { uri: pollUri, cid: pollCid }, + createdAt: new Date().toISOString(), + }, + }); + + // SUCCESS! + + // Requirement 2: Hold the URI. + // We force this into the state with status 'confirmed'. + // Even if we timed out earlier (and removed it), this puts it back! + updateLocalState(pollUri, (prev) => { + // Remove any pending entry for this option (if it exists) + const clean = prev.filter(v => v.option !== optionKey); + return [...clean, { + pollUri, + option: optionKey, + status: 'confirmed', + uri: res.data.uri, + timestamp: Date.now() // Update timestamp to fresh + }]; + }); + + } catch (e) { + console.error("Vote failed", e); + if (!isTimedOut) { + renderSnack({ title: "Vote failed" }); + // Revert optimistic state + updateLocalState(pollUri, (prev) => prev.filter(v => v.timestamp !== timestamp)); + } + } + }; + + // Run them + // We don't await the timeout for the UI, but the timeout logic runs in parallel + performVote(); + // We don't await performVote here to unblock UI, but the logic inside handles state updates + } + + }, [agent, localVotes, updateLocalState, setLocalVotes]); + + return ( + + {children} + + ); +} + +export function usePollMutationQueue() { + const context = use(PollMutationContext); + if (!context) throw new Error("Missing PollMutationQueueProvider"); + return context; +} + +export function usePollData( + pollUri: string, + isMultiple: boolean, + serverCounts: { a: number; b: number; c: number; d: number }, + serverUserVotes: string[] // Array of AT-URIs (e.g. ['at://.../vote.a/...']) +) { + const { getLocalVotes } = usePollMutationQueue(); + const localVotes = getLocalVotes(pollUri); + + return useMemo(() => { + // 1. Identify which options the SERVER thinks we voted for + const serverState = { + a: serverUserVotes.some((uri) => uri.includes("app.reddwarf.poll.vote.a")), + b: serverUserVotes.some((uri) => uri.includes("app.reddwarf.poll.vote.b")), + c: serverUserVotes.some((uri) => uri.includes("app.reddwarf.poll.vote.c")), + d: serverUserVotes.some((uri) => uri.includes("app.reddwarf.poll.vote.d")), + }; + + // 2. Identify which options LOCAL STATE thinks we voted for + // (Pending or Confirmed Stale-While-Revalidate) + const localState = { + a: localVotes.some((v) => v.option === "a"), + b: localVotes.some((v) => v.option === "b"), + c: localVotes.some((v) => v.option === "c"), + d: localVotes.some((v) => v.option === "d"), + }; + + // 3. Determine if we have ANY local activity + // If this is Single Choice, and we have a local vote, strictly ignore server votes for other options. + const hasAnyLocalVote = localVotes.length > 0; + + const calculateOptionState = (option: "a" | "b" | "c" | "d") => { + const isLocallyVoted = localState[option]; + const isServerVoted = serverState[option]; + + // STATUS MERGE: + // If Single Choice: Local Vote overrides everything. + // If Multi Choice: Local Vote || Server Vote. + let hasVoted = isLocallyVoted; + + if (!isMultiple) { + // Single Choice Logic: + // If we haven't touched this poll locally, trust the server. + // If we HAVE touched it locally (voted for X), ignore server's Y. + if (!hasAnyLocalVote && isServerVoted) { + hasVoted = true; + } + } else { + // Multi Choice Logic: + // Simple Union. (Note: Unvoting in multi-choice with your provider might flicker + // because unvoting deletes the local record, causing fall-through to server record. + // But adding votes works perfectly). + hasVoted = isLocallyVoted || isServerVoted; + } + + // COUNT MERGE: + // Start with server count. + let count = serverCounts[option] || 0; + + // If we show it as voted LOCALLY, but Server doesn't know yet -> Add 1 + if (isLocallyVoted && !isServerVoted) { + count++; + } + + // Edge Case: If we show it as NOT voted (because we switched to another option locally), + // but Server still counts it -> Subtract 1 (Visual only) + // This happens in single choice switching A -> B. + // We want to decrement A visually while incrementing B. + if (!isMultiple && hasAnyLocalVote && !isLocallyVoted && isServerVoted) { + count = Math.max(0, count - 1); + } + + return { hasVoted, count }; + }; + + const stateA = calculateOptionState("a"); + const stateB = calculateOptionState("b"); + const stateC = calculateOptionState("c"); + const stateD = calculateOptionState("d"); + + return { + results: { + a: stateA, + b: stateB, + c: stateC, + d: stateD, + }, + // Helper to check if user has interacted at all + hasVotedAny: stateA.hasVoted || stateB.hasVoted || stateC.hasVoted || stateD.hasVoted, + totalVotes: stateA.count + stateB.count + stateC.count + stateD.count + }; + }, [localVotes, serverUserVotes, serverCounts, isMultiple]); +} \ No newline at end of file diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 91a9fa5..bb31d39 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -25,6 +25,7 @@ import Login from "~/components/Login"; import { NotFound } from "~/components/NotFound"; import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; +import { PollMutationQueueProvider } from "~/providers/PollMutationQueueProvider"; import { UnifiedAuthProvider, useAuth } from "~/providers/UnifiedAuthProvider"; import { composerAtom, hueAtom, useAtomCssVar } from "~/utils/atoms"; import { seo } from "~/utils/seo"; @@ -83,12 +84,14 @@ function RootComponent() { return ( - - - - - - + + + + + + + + ); @@ -176,7 +179,8 @@ function Snack(props: ToastProps) { ) : null} -