import { useQueryClient } from "@tanstack/react-query"; 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"; import { useGetOneToOneState } from "~/utils/followState"; import { useQueryConstellation } from "~/utils/useQuery"; // ------------------------------------------------------------------ // 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 { castVoteRaw: ( pollUri: string, pollCid: string, option: string, isMultiple: boolean, currentServerVotes: string[], ) => Promise; getLocalVotes: (pollUri: string) => ExtendedLocalVote[]; refreshPollData: (pollUri?: string) => void; } const PollMutationContext = createContext( undefined, ); // ------------------------------------------------------------------ // Provider // ------------------------------------------------------------------ export function PollMutationQueueProvider({ children, }: { children: React.ReactNode; }) { const { agent } = useAuth(); const queryClient = useQueryClient(); const [localVotes, setLocalVotes] = useAtom(localPollVotesAtom); 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 refreshPollData = useCallback( (pollUri?: string) => { // Clear all local pending votes for this poll or all polls if (pollUri) { // Clear local state for specific poll setLocalVotes((prev) => { const newState = { ...prev }; delete newState[pollUri]; return newState; }); } else { // Clear all local votes setLocalVotes({}); } // Invalidate all poll constellation queries using predicate function queryClient.invalidateQueries({ predicate: (query) => { const queryKey = query.queryKey; return ( Array.isArray(queryKey) && queryKey.includes("constellation-polls") ); }, }); // If specific poll URI provided, also invalidate that poll's data if (pollUri) { queryClient.invalidateQueries({ queryKey: ["arbitrary", pollUri], }); } }, [queryClient, setLocalVotes], ); const castVoteRaw = useCallback( async ( pollUri: string, pollCid: string, option: string, isMultiple: boolean, currentServerVotes: string[], ) => { if (!agent?.did) { renderSnack({ title: "Please log in to vote", description: "You need to be authenticated to participate in polls", }); 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), ); } } // ------------------------------------------------------------ // 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); } }); } try { const res = await agent.com.atproto.repo.createRecord({ // ... standard create logic 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(), }, }); updateLocalState(pollUri, (prev) => { 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); renderSnack({ title: "Vote failed" }); updateLocalState(pollUri, (prev) => prev.filter((v) => v.timestamp !== timestamp), ); } } }, [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, enabled?: boolean) { const { agent } = useAuth(); const agentDid = agent?.did; const { uris: userVotesA } = useGetOneToOneState( agentDid && enabled ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.a", path: ".subject.uri", enabled: enabled } : undefined, ); const { uris: userVotesB } = useGetOneToOneState( agentDid && enabled ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.b", path: ".subject.uri", enabled: enabled } : undefined, ); const { uris: userVotesC } = useGetOneToOneState( agentDid && enabled ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.c", path: ".subject.uri", enabled: enabled } : undefined, ); const { uris: userVotesD } = useGetOneToOneState( agentDid && enabled ? { target: pollUri, user: agentDid, collection: "app.reddwarf.poll.vote.d", path: ".subject.uri", enabled: enabled } : 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 }, enabled?: boolean ) { const { agent } = useAuth(); const myDid = agent?.did; const { castVoteRaw, getLocalVotes } = usePollMutationQueue(); const serverUserVotes = usePollSelfVotes(pollUri, enabled); // Our own votes from server const localVotes = getLocalVotes(pollUri); // Pending local actions // 1. FETCHING - Move the logic here // We only need the first page/subset to show avatars const { data: votersA } = useQueryConstellation({ method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.a", path: ".subject.uri", customkey: "constellation-polls", enabled: enabled, }); const { data: votersB } = useQueryConstellation({ method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.b", path: ".subject.uri", customkey: "constellation-polls", enabled: enabled, }); const { data: votersC } = useQueryConstellation({ method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.c", path: ".subject.uri", customkey: "constellation-polls", enabled: enabled, }); const { data: votersD } = useQueryConstellation({ method: "/links", target: pollUri, collection: "app.reddwarf.poll.vote.d", path: ".subject.uri", customkey: "constellation-polls", enabled: enabled, }); const handleVote = useCallback( (optionKey: string) => { if (!pollCid) return; castVoteRaw(pollUri, pollCid, optionKey, isMultiple, serverUserVotes); }, [pollUri, pollCid, isMultiple, serverUserVotes, castVoteRaw], ); return useMemo(() => { // Helper to clean a raw list: extract DIDs, Deduplicate, Remove Self const processServerList = (data: any) => { const records = data?.linking_records || []; const dids = records.map((r: any) => r.did).filter(Boolean) as string[]; // 2. Deduplicate everyone (Set removes duplicates) // 3. Remove self from the list (to ensure we don't appear twice or when we shouldn't) const uniqueOthers = new Set(dids.filter((did) => did !== myDid)); return Array.from(uniqueOthers); }; const serverLists = { a: processServerList(votersA), b: processServerList(votersB), c: processServerList(votersC), d: processServerList(votersD), }; const calculateOptionState = (option: "a" | "b" | "c" | "d") => { // --- LOGIC: Determine if we have voted (Boolean) --- const localEntry = localVotes.find((v) => v.option === option); const isServerVoted = serverUserVotes.some((uri) => uri.includes(`app.reddwarf.poll.vote.${option}`), ); let hasVoted = false; if (localEntry) { hasVoted = localEntry.action === "create"; } else { if (isMultiple) { hasVoted = isServerVoted; } else { // Single choice: if we created a vote elsewhere locally, this one is false const hasSwitched = localVotes.some( (v) => v.option !== option && v.action === "create", ); hasVoted = hasSwitched ? false : isServerVoted; } } // --- LOGIC: Calculate Count --- let count = serverCounts[option] || 0; if (hasVoted && !isServerVoted) count++; if (!hasVoted && isServerVoted) count = Math.max(0, count - 1); // --- LOGIC: Finalize Avatar List --- // 4. Add back self purely using the hasVoted state let finalVoters = serverLists[option]; if (hasVoted && myDid) { finalVoters = [myDid, ...finalVoters]; } return { hasVoted, count, // We only return the DIDs now, top 5 topVoterDids: finalVoters.slice(0, 5), }; }; 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 }, hasVotedAny: stateA.hasVoted || stateB.hasVoted || stateC.hasVoted || stateD.hasVoted, totalVotes: stateA.count + stateB.count + stateC.count + stateD.count, handleVote, votersA, votersB, votersC, votersD }; }, [ localVotes, serverUserVotes, serverCounts, votersA, votersB, votersC, votersD, // Dependencies for fetching isMultiple, handleVote, myDid, ]); }