diff --git a/src/components/UniversalPostRenderer.tsx b/src/components/UniversalPostRenderer.tsx index 01df84e..1e4ef61 100644 --- a/src/components/UniversalPostRenderer.tsx +++ b/src/components/UniversalPostRenderer.tsx @@ -14,7 +14,6 @@ import { enableWafrnTextAtom, imgCDNAtom, } from "~/utils/atoms"; -import { useGetOneToOneState } from "~/utils/followState"; import { useHydratedEmbed } from "~/utils/useHydrated"; import { useQueryArbitrary, @@ -409,7 +408,7 @@ export function UniversalPostRendererATURILoader({ setReplies( links ? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"] - ?.records || 0 + ?.records || 0 : null, ); }, [links]); @@ -457,13 +456,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); @@ -623,7 +622,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" /> @@ -769,11 +768,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,7 +1258,6 @@ 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"; @@ -1496,18 +1494,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 @@ -1520,7 +1518,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 ( @@ -1534,12 +1532,12 @@ function UniversalPostRenderer({ : setMainItem ? onPostClick ? (e) => { - setMainItem({ post: post }); - onPostClick(e); - } + setMainItem({ post: post }); + onPostClick(e); + } : () => { - setMainItem({ post: post }); - } + setMainItem({ post: post }); + } : undefined } style={{ @@ -2022,10 +2020,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!", @@ -2133,7 +2131,7 @@ type Embed = | AppBskyEmbedVideo.View | AppBskyEmbedExternal.View | AppBskyEmbedRecordWithMedia.View - | { $type: string; [k: string]: unknown }; + | { $type: string;[k: string]: unknown }; enum PostEmbedViewContext { ThreadHighlighted = "ThreadHighlighted", @@ -2152,9 +2150,10 @@ 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 + // --- 1. Fetch Aggregate Counts & Avatars (Public Data) --- + // (We still fetch these here as they are View-specific data dependencies) + const { data: voteCountsA } = useQueryConstellation({ method: "/links/count/distinct-dids", target: pollUri, @@ -2183,80 +2182,21 @@ function PollEmbed({ did, rkey }: { did: string; rkey: string }) { path: ".subject.uri", }); - // Query first page of voters for each option to get PFPs + // Query first page of voters for Avatars const { data: votersA } = useQueryConstellation({ - method: "/links", - target: pollUri, - collection: "app.reddwarf.poll.vote.a", - path: ".subject.uri", + method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.a", path: ".subject.uri", }); - const { data: votersB } = useQueryConstellation({ - method: "/links", - target: pollUri, - collection: "app.reddwarf.poll.vote.b", - path: ".subject.uri", + method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.b", path: ".subject.uri", }); - const { data: votersC } = useQueryConstellation({ - method: "/links", - target: pollUri, - collection: "app.reddwarf.poll.vote.c", - path: ".subject.uri", + method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.c", path: ".subject.uri", }); - const { data: votersD } = useQueryConstellation({ - method: "/links", - target: pollUri, - collection: "app.reddwarf.poll.vote.d", - path: ".subject.uri", + method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.d", path: ".subject.uri", }); - // Check if user has already voted for each option in this poll - const userVotesA = useGetOneToOneState( - agent?.did - ? { - 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", - } - : undefined, - ); - - const userVotesC = useGetOneToOneState( - agent?.did - ? { - 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", - } - : undefined, - ); - + // --- 2. Prepare Data --- // todo: hardcoded to multiple for all public polls const poll = { ...(pollRecord?.value ?? {}), @@ -2273,38 +2213,6 @@ 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); - - const serverUserVotes = [ - ...(userVotesA || []), - ...(userVotesB || []), - ...(userVotesC || []), - ...(userVotesD || []), - ]; - - // Flatten counts const serverCounts = { a: parseInt((voteCountsA as any)?.total || "0"), b: parseInt((voteCountsB as any)?.total || "0"), @@ -2312,29 +2220,16 @@ function PollEmbed({ did, rkey }: { did: string; rkey: string }) { d: parseInt((voteCountsD as any)?.total || "0"), }; - // 3. THE MAGIC HOOK - const pollState = usePollData( + // --- 3. THE MAGIC HOOK (Now centralized) --- + // This hook now fetches self-votes internally and merges them with the serverCounts we passed in + const { results, totalVotes, handleVote } = usePollData( pollUri, + pollRecord?.cid, !!poll.multiple, - serverCounts, - serverUserVotes, + serverCounts ); - // 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, - ); - }; + // --- 4. Render --- if (isLoading) { return ( @@ -2476,51 +2371,31 @@ function PollEmbed({ did, rkey }: { did: string; rkey: string }) { {/* Options List with Results */}
{options.map((optionText, 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 optionKey = ["a", "b", "c", "d"][index] as "a" | "b" | "c" | "d"; + + const optionState = results[optionKey]; const hasVotedForOption = optionState.hasVoted; - const voteCount = optionState.count; - const votePercentage = - pollState.totalVotes > 0 - ? (voteCount / pollState.totalVotes) * 100 - : 0; + const votePercentage = totalVotes > 0 ? (optionState.count / totalVotes) * 100 : 0; - // Get the voters data for displaying avatars + // Helper to get voters for avatars const votersData = (() => { - switch (optionKey) { - case "a": - return votersA?.linking_records || []; - case "b": - return votersB?.linking_records || []; - case "c": - return votersC?.linking_records || []; - case "d": - return votersD?.linking_records || []; - default: - return []; - } + if (optionKey === 'a') return votersA?.linking_records || []; + if (optionKey === 'b') return votersB?.linking_records || []; + if (optionKey === 'c') return votersC?.linking_records || []; + if (optionKey === 'd') return votersD?.linking_records || []; + return []; })(); - - // Extract just the DIDs we want to show (top 5) - const topVoters = - votersData.filter((v) => !!v.did).slice(0, 5) || []; + const topVoters = votersData.filter((v: any) => !!v.did).slice(0, 5); return (
{ e.stopPropagation(); if (!isExpired) { @@ -2530,7 +2405,7 @@ function PollEmbed({ did, rkey }: { did: string; rkey: string }) { > {/* Vote percentage bar - always show */}
@@ -2609,7 +2484,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 {pollState.totalVotes} votes + View all {totalVotes} votes
@@ -2973,10 +2848,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, @@ -3674,9 +3549,8 @@ 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 index e559831..25c2b8e 100644 --- a/src/providers/PollMutationQueueProvider.tsx +++ b/src/providers/PollMutationQueueProvider.tsx @@ -4,161 +4,183 @@ 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"; +import { useGetOneToOneState } from "~/utils/followState"; + +// ------------------------------------------------------------------ +// Types +// ------------------------------------------------------------------ + +// We extend the LocalVote type internally to handle "Tombstones" +// (explicit instructions to hide a server-side vote) +type ExtendedLocalVote = LocalVote & { + action: "create" | "delete"; +}; interface PollMutationContextType { - castVote: ( - pollUri: string, - pollCid: string, - option: string, + castVoteRaw: ( + pollUri: string, + pollCid: string, + option: string, isMultiple: boolean, - currentServerVotes: string[] // Pass current user vote URIs to handle unvoting logic + currentServerVotes: string[], ) => Promise; - - getLocalVotes: (pollUri: string) => LocalVote[]; + + getLocalVotes: (pollUri: string) => ExtendedLocalVote[]; } -const PollMutationContext = createContext(undefined); +const PollMutationContext = createContext( + undefined, +); + +// ------------------------------------------------------------------ +// Provider +// ------------------------------------------------------------------ -export function PollMutationQueueProvider({ children }: { children: React.ReactNode }) { +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 }); - } + const getLocalVotes = useCallback( + (pollUri: string) => { + return (localVotes[pollUri] || []) as ExtendedLocalVote[]; + }, + [localVotes], + ); + + const updateLocalState = useCallback( + (pollUri: string, updater: (prev: ExtendedLocalVote[]) => ExtendedLocalVote[]) => { + setLocalVotes((prev) => ({ + ...prev, + [pollUri]: updater((prev[pollUri] || []) as ExtendedLocalVote[]), + })); + }, + [setLocalVotes], + ); + + const castVoteRaw = useCallback( + async ( + pollUri: string, + pollCid: string, + option: string, + isMultiple: boolean, + currentServerVotes: string[], + ) => { + if (!agent?.did) return; + + const optionKey = option as "a" | "b" | "c" | "d"; + const timestamp = Date.now(); + + // 1. DETERMINE CURRENT STATUS + const currentLocal = (localVotes[pollUri] || []) as ExtendedLocalVote[]; + const localEntry = currentLocal.find((v) => v.option === optionKey); + + // Check if ANY server vote exists for this option + const hasServerVote = currentServerVotes.some((uri) => + uri.includes(`app.reddwarf.poll.vote.${optionKey}`) + ); + + const isCurrentlyVoted = localEntry + ? localEntry.action === "create" + : hasServerVote; + + // ------------------------------------------------------------ + // ACTION: UNVOTE (Toggle Off) + // ------------------------------------------------------------ + if (isCurrentlyVoted) { + + // Optimistic Update: Tombstone + updateLocalState(pollUri, (prev) => { + const clean = prev.filter(v => v.option !== optionKey); + return [...clean, { + pollUri, + option: optionKey, + status: "pending", + action: "delete", + timestamp + }]; + }); + + try { + // FIX: Collect ALL URIs for this option (Server + Local) + // We want to nuke every record that matches this option to clean up state + const serverUris = currentServerVotes.filter(uri => + uri.includes(`app.reddwarf.poll.vote.${optionKey}`) + ); + + const urisToDelete = [...serverUris]; + if (localEntry?.uri) { + urisToDelete.push(localEntry.uri); + } + + // Deduplicate just in case + const uniqueUris = [...new Set(urisToDelete)]; + + // Parallel delete for everything found + await Promise.all( + uniqueUris.map(uri => { + const match = uri.match(/at:\/\/(.+)\/(.+)\/(.+)/); + if (!match) return Promise.resolve(); + const [, repo, collection, rkey] = match; + return agent.com.atproto.repo.deleteRecord({ + repo, + collection, + rkey, + }); + }) + ); + + } catch (e) { + console.error("Failed to unvote", e); + renderSnack({ title: "Failed to remove vote" }); + // Revert optimistic update + updateLocalState(pollUri, (prev) => prev.filter(v => v.timestamp !== timestamp)); } - } 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) + // ------------------------------------------------------------ + // ACTION: VOTE (Toggle On) + // ------------------------------------------------------------ + else { + // ... (The Vote logic remains the same, as the Single Choice cleanup + // logic there already iterated over the entire array) ... + + updateLocalState(pollUri, (prev) => { + const newState = isMultiple ? [...prev] : prev.filter(v => v.action !== 'create'); + const clean = newState.filter(v => v.option !== optionKey); + return [...clean, { + pollUri, + option: optionKey, + status: "pending", + action: "create", + timestamp + }]; + }); + + // Cleanup others if single choice + if (!isMultiple) { + const votesToDelete = [ + ...currentServerVotes, + ...(currentLocal.filter(v => v.action === 'create' && v.uri).map(v => v.uri) as string[]) + ]; + + // This was already safe because it iterates the whole array + votesToDelete.forEach((voteUri) => { + if (voteUri.includes(`app.reddwarf.poll.vote.${optionKey}`)) return; const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/); if (match) { - const [, repo, collection, rkey] = match; - agent.com.atproto.repo.deleteRecord({ repo, collection, rkey }).catch(console.error); + 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({ + // ... standard create logic collection: `app.reddwarf.poll.vote.${optionKey}`, repo: agent.assertDid, record: { @@ -168,123 +190,124 @@ export function PollMutationQueueProvider({ children }: { children: React.ReactN }, }); - // 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 - }]; + const clean = prev.filter(v => v.option !== optionKey); + return [...clean, { + pollUri, + option: optionKey, + status: "confirmed", + action: "create", + uri: res.data.uri, + timestamp: Date.now(), + }]; }); - } 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)); - } + renderSnack({ title: "Vote failed" }); + 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]); + } + }, + [agent, localVotes, updateLocalState, setLocalVotes], + ); return ( - + {children} ); } +// ------------------------------------------------------------------ +// Hooks +// ------------------------------------------------------------------ + export function usePollMutationQueue() { const context = use(PollMutationContext); if (!context) throw new Error("Missing PollMutationQueueProvider"); return context; } +function usePollSelfVotes(pollUri: string) { + const { agent } = useAuth(); + const agentDid = agent?.did; + + const userVotesA = useGetOneToOneState( + agentDid ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.a", path: ".subject.uri" } : undefined + ); + const userVotesB = useGetOneToOneState( + agentDid ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.b", path: ".subject.uri" } : undefined + ); + const userVotesC = useGetOneToOneState( + agentDid ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.c", path: ".subject.uri" } : undefined + ); + const userVotesD = useGetOneToOneState( + agentDid ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.d", path: ".subject.uri" } : undefined + ); + + return useMemo(() => { + return [ + ...(userVotesA || []), + ...(userVotesB || []), + ...(userVotesC || []), + ...(userVotesD || []), + ]; + }, [userVotesA, userVotesB, userVotesC, userVotesD]); +} + export function usePollData( pollUri: string, + pollCid: string | undefined, 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); + const { castVoteRaw, getLocalVotes } = usePollMutationQueue(); + const serverUserVotes = usePollSelfVotes(pollUri); + const localVotes = getLocalVotes(pollUri); // Returns ExtendedLocalVote[] - 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")), - }; + const handleVote = useCallback((optionKey: string) => { + if (!pollCid) return; + castVoteRaw(pollUri, pollCid, optionKey, isMultiple, serverUserVotes); + }, [pollUri, pollCid, isMultiple, serverUserVotes, castVoteRaw]); - // 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"), - }; + return useMemo(() => { + const calculateOptionState = (option: "a" | "b" | "c" | "d") => { + const localEntry = localVotes.find((v) => v.option === option); + const isServerVoted = serverUserVotes.some((uri) => uri.includes(`app.reddwarf.poll.vote.${option}`)); - // 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; + // --- MERGE STATUS LOGIC --- + let hasVoted = false; - 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; - } + if (localEntry) { + // 1. If we have an explicit local action, it overrides everything for this option + // 'create' = true, 'delete' = false + hasVoted = localEntry.action === "create"; } 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; + // 2. If no local action for this specific option... + if (isMultiple) { + // In multiple choice, server truth stands unless explicitly deleted (checked above) + hasVoted = isServerVoted; + } else { + // In single choice, we must check if we voted for *something else* locally + const hasSwitchedToOther = localVotes.some(v => v.option !== option && v.action === "create"); + if (hasSwitchedToOther) { + hasVoted = false; // Implicitly unvoted because we switched + } else { + hasVoted = isServerVoted; + } + } } - // COUNT MERGE: - // Start with server count. + // --- MERGE COUNT LOGIC --- let count = serverCounts[option] || 0; - // If we show it as voted LOCALLY, but Server doesn't know yet -> Add 1 - if (isLocallyVoted && !isServerVoted) { + // Adjust counts based on our "Virtual" state vs "Server" state + // If we are Voted locally but Server doesn't know -> +1 + if (hasVoted && !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) { + // If we are NOT Voted locally (e.g. unvoted or switched) but Server thinks we are -> -1 + if (!hasVoted && isServerVoted) { count = Math.max(0, count - 1); } @@ -297,15 +320,10 @@ export function usePollData( const stateD = calculateOptionState("d"); return { - results: { - a: stateA, - b: stateB, - c: stateC, - d: stateD, - }, - // Helper to check if user has interacted at all + results: { a: stateA, b: stateB, c: stateC, d: stateD }, hasVotedAny: stateA.hasVoted || stateB.hasVoted || stateC.hasVoted || stateD.hasVoted, - totalVotes: stateA.count + stateB.count + stateC.count + stateD.count + totalVotes: stateA.count + stateB.count + stateC.count + stateD.count, + handleVote, }; - }, [localVotes, serverUserVotes, serverCounts, isMultiple]); + }, [localVotes, serverUserVotes, serverCounts, isMultiple, handleVote]); } \ No newline at end of file