an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
1import { useQueryClient } from "@tanstack/react-query";
2import { useAtom } from "jotai";
3import React, { createContext, use, useCallback, useMemo } from "react";
4
5import { useAuth } from "~/providers/UnifiedAuthProvider";
6import { renderSnack } from "~/routes/__root";
7import { localPollVotesAtom, type LocalVote } from "~/utils/atoms";
8import { useGetOneToOneState } from "~/utils/followState";
9import { useQueryConstellation } from "~/utils/useQuery";
10
11// ------------------------------------------------------------------
12// Types
13// ------------------------------------------------------------------
14
15// We extend the LocalVote type internally to handle "Tombstones"
16// (explicit instructions to hide a server-side vote)
17type ExtendedLocalVote = LocalVote & {
18 action: "create" | "delete";
19};
20
21interface PollMutationContextType {
22 castVoteRaw: (
23 pollUri: string,
24 pollCid: string,
25 option: string,
26 isMultiple: boolean,
27 currentServerVotes: string[],
28 ) => Promise<void>;
29
30 getLocalVotes: (pollUri: string) => ExtendedLocalVote[];
31 refreshPollData: (pollUri?: string) => void;
32}
33
34const PollMutationContext = createContext<PollMutationContextType | undefined>(
35 undefined,
36);
37
38// ------------------------------------------------------------------
39// Provider
40// ------------------------------------------------------------------
41
42export function PollMutationQueueProvider({
43 children,
44}: {
45 children: React.ReactNode;
46}) {
47 const { agent } = useAuth();
48 const queryClient = useQueryClient();
49 const [localVotes, setLocalVotes] = useAtom(localPollVotesAtom);
50
51 const getLocalVotes = useCallback(
52 (pollUri: string) => {
53 return (localVotes[pollUri] || []) as ExtendedLocalVote[];
54 },
55 [localVotes],
56 );
57
58 const updateLocalState = useCallback(
59 (
60 pollUri: string,
61 updater: (prev: ExtendedLocalVote[]) => ExtendedLocalVote[],
62 ) => {
63 setLocalVotes((prev) => ({
64 ...prev,
65 [pollUri]: updater((prev[pollUri] || []) as ExtendedLocalVote[]),
66 }));
67 },
68 [setLocalVotes],
69 );
70
71 const refreshPollData = useCallback(
72 (pollUri?: string) => {
73 // Clear all local pending votes for this poll or all polls
74 if (pollUri) {
75 // Clear local state for specific poll
76 setLocalVotes((prev) => {
77 const newState = { ...prev };
78 delete newState[pollUri];
79 return newState;
80 });
81 } else {
82 // Clear all local votes
83 setLocalVotes({});
84 }
85
86 // Invalidate all poll constellation queries using predicate function
87 queryClient.invalidateQueries({
88 predicate: (query) => {
89 const queryKey = query.queryKey;
90 return (
91 Array.isArray(queryKey) && queryKey.includes("constellation-polls")
92 );
93 },
94 });
95
96 // If specific poll URI provided, also invalidate that poll's data
97 if (pollUri) {
98 queryClient.invalidateQueries({
99 queryKey: ["arbitrary", pollUri],
100 });
101 }
102 },
103 [queryClient, setLocalVotes],
104 );
105
106 const castVoteRaw = useCallback(
107 async (
108 pollUri: string,
109 pollCid: string,
110 option: string,
111 isMultiple: boolean,
112 currentServerVotes: string[],
113 ) => {
114 if (!agent?.did) {
115 renderSnack({
116 title: "Please log in to vote",
117 description: "You need to be authenticated to participate in polls",
118 });
119 return;
120 }
121
122 const optionKey = option as "a" | "b" | "c" | "d";
123 const timestamp = Date.now();
124
125 // 1. DETERMINE CURRENT STATUS
126 const currentLocal = (localVotes[pollUri] || []) as ExtendedLocalVote[];
127 const localEntry = currentLocal.find((v) => v.option === optionKey);
128
129 // Check if ANY server vote exists for this option
130 const hasServerVote = currentServerVotes.some((uri) =>
131 uri.includes(`app.reddwarf.poll.vote.${optionKey}`),
132 );
133
134 const isCurrentlyVoted = localEntry
135 ? localEntry.action === "create"
136 : hasServerVote;
137
138 // ------------------------------------------------------------
139 // ACTION: UNVOTE (Toggle Off)
140 // ------------------------------------------------------------
141 if (isCurrentlyVoted) {
142 // Optimistic Update: Tombstone
143 updateLocalState(pollUri, (prev) => {
144 const clean = prev.filter((v) => v.option !== optionKey);
145 return [
146 ...clean,
147 {
148 pollUri,
149 option: optionKey,
150 status: "pending",
151 action: "delete",
152 timestamp,
153 },
154 ];
155 });
156
157 try {
158 // FIX: Collect ALL URIs for this option (Server + Local)
159 // We want to nuke every record that matches this option to clean up state
160 const serverUris = currentServerVotes.filter((uri) =>
161 uri.includes(`app.reddwarf.poll.vote.${optionKey}`),
162 );
163
164 const urisToDelete = [...serverUris];
165 if (localEntry?.uri) {
166 urisToDelete.push(localEntry.uri);
167 }
168
169 // Deduplicate just in case
170 const uniqueUris = [...new Set(urisToDelete)];
171
172 // Parallel delete for everything found
173 await Promise.all(
174 uniqueUris.map((uri) => {
175 const match = uri.match(/at:\/\/(.+)\/(.+)\/(.+)/);
176 if (!match) return Promise.resolve();
177 const [, repo, collection, rkey] = match;
178 return agent.com.atproto.repo.deleteRecord({
179 repo,
180 collection,
181 rkey,
182 });
183 }),
184 );
185 } catch (e) {
186 console.error("Failed to unvote", e);
187 renderSnack({ title: "Failed to remove vote" });
188 // Revert optimistic update
189 updateLocalState(pollUri, (prev) =>
190 prev.filter((v) => v.timestamp !== timestamp),
191 );
192 }
193 }
194
195 // ------------------------------------------------------------
196 // ACTION: VOTE (Toggle On)
197 // ------------------------------------------------------------
198 else {
199 // ... (The Vote logic remains the same, as the Single Choice cleanup
200 // logic there already iterated over the entire array) ...
201
202 updateLocalState(pollUri, (prev) => {
203 const newState = isMultiple
204 ? [...prev]
205 : prev.filter((v) => v.action !== "create");
206 const clean = newState.filter((v) => v.option !== optionKey);
207 return [
208 ...clean,
209 {
210 pollUri,
211 option: optionKey,
212 status: "pending",
213 action: "create",
214 timestamp,
215 },
216 ];
217 });
218
219 // Cleanup others if single choice
220 if (!isMultiple) {
221 const votesToDelete = [
222 ...currentServerVotes,
223 ...(currentLocal
224 .filter((v) => v.action === "create" && v.uri)
225 .map((v) => v.uri) as string[]),
226 ];
227
228 // This was already safe because it iterates the whole array
229 votesToDelete.forEach((voteUri) => {
230 if (voteUri.includes(`app.reddwarf.poll.vote.${optionKey}`)) return;
231 const match = voteUri.match(/at:\/\/(.+)\/(.+)\/(.+)/);
232 if (match) {
233 const [, repo, collection, rkey] = match;
234 agent.com.atproto.repo
235 .deleteRecord({ repo, collection, rkey })
236 .catch(console.error);
237 }
238 });
239 }
240
241 try {
242 const res = await agent.com.atproto.repo.createRecord({
243 // ... standard create logic
244 collection: `app.reddwarf.poll.vote.${optionKey}`,
245 repo: agent.assertDid,
246 record: {
247 $type: `app.reddwarf.poll.vote.${optionKey}`,
248 subject: { uri: pollUri, cid: pollCid },
249 createdAt: new Date().toISOString(),
250 },
251 });
252
253 updateLocalState(pollUri, (prev) => {
254 const clean = prev.filter((v) => v.option !== optionKey);
255 return [
256 ...clean,
257 {
258 pollUri,
259 option: optionKey,
260 status: "confirmed",
261 action: "create",
262 uri: res.data.uri,
263 timestamp: Date.now(),
264 },
265 ];
266 });
267 } catch (e) {
268 console.error("Vote failed", e);
269 renderSnack({ title: "Vote failed" });
270 updateLocalState(pollUri, (prev) =>
271 prev.filter((v) => v.timestamp !== timestamp),
272 );
273 }
274 }
275 },
276 [agent, localVotes, updateLocalState, setLocalVotes],
277 );
278
279 return (
280 <PollMutationContext
281 value={{ castVoteRaw, getLocalVotes, refreshPollData }}
282 >
283 {children}
284 </PollMutationContext>
285 );
286}
287
288// ------------------------------------------------------------------
289// Hooks
290// ------------------------------------------------------------------
291
292export function usePollMutationQueue() {
293 const context = use(PollMutationContext);
294 if (!context) throw new Error("Missing PollMutationQueueProvider");
295 return context;
296}
297
298function usePollSelfVotes(pollUri: string, enabled?: boolean) {
299 const { agent } = useAuth();
300 const agentDid = agent?.did;
301
302 const { uris: userVotesA } = useGetOneToOneState(
303 agentDid && enabled
304 ? {
305 target: pollUri,
306 user: agentDid,
307 collection: "app.reddwarf.poll.vote.a",
308 path: ".subject.uri",
309 enabled: enabled
310 }
311 : undefined,
312 );
313 const { uris: userVotesB } = useGetOneToOneState(
314 agentDid && enabled
315 ? {
316 target: pollUri,
317 user: agentDid,
318 collection: "app.reddwarf.poll.vote.b",
319 path: ".subject.uri",
320 enabled: enabled
321 }
322 : undefined,
323 );
324 const { uris: userVotesC } = useGetOneToOneState(
325 agentDid && enabled
326 ? {
327 target: pollUri,
328 user: agentDid,
329 collection: "app.reddwarf.poll.vote.c",
330 path: ".subject.uri",
331 enabled: enabled
332 }
333 : undefined,
334 );
335 const { uris: userVotesD } = useGetOneToOneState(
336 agentDid && enabled
337 ? {
338 target: pollUri,
339 user: agentDid,
340 collection: "app.reddwarf.poll.vote.d",
341 path: ".subject.uri",
342 enabled: enabled
343 }
344 : undefined,
345 );
346
347 return useMemo(() => {
348 return [
349 ...(userVotesA || []),
350 ...(userVotesB || []),
351 ...(userVotesC || []),
352 ...(userVotesD || []),
353 ];
354 }, [userVotesA, userVotesB, userVotesC, userVotesD]);
355}
356
357export function usePollData(
358 pollUri: string,
359 pollCid: string | undefined,
360 isMultiple: boolean,
361 serverCounts: { a: number; b: number; c: number; d: number },
362 enabled?: boolean
363) {
364 const { agent } = useAuth();
365 const myDid = agent?.did;
366
367 const { castVoteRaw, getLocalVotes } = usePollMutationQueue();
368 const serverUserVotes = usePollSelfVotes(pollUri, enabled); // Our own votes from server
369 const localVotes = getLocalVotes(pollUri); // Pending local actions
370
371 // 1. FETCHING - Move the logic here
372 // We only need the first page/subset to show avatars
373 const { data: votersA } = useQueryConstellation({
374 method: "/links",
375 target: pollUri,
376 collection: "app.reddwarf.poll.vote.a",
377 path: ".subject.uri",
378 customkey: "constellation-polls",
379 enabled: enabled,
380 });
381 const { data: votersB } = useQueryConstellation({
382 method: "/links",
383 target: pollUri,
384 collection: "app.reddwarf.poll.vote.b",
385 path: ".subject.uri",
386 customkey: "constellation-polls",
387 enabled: enabled,
388 });
389 const { data: votersC } = useQueryConstellation({
390 method: "/links",
391 target: pollUri,
392 collection: "app.reddwarf.poll.vote.c",
393 path: ".subject.uri",
394 customkey: "constellation-polls",
395 enabled: enabled,
396 });
397 const { data: votersD } = useQueryConstellation({
398 method: "/links",
399 target: pollUri,
400 collection: "app.reddwarf.poll.vote.d",
401 path: ".subject.uri",
402 customkey: "constellation-polls",
403 enabled: enabled,
404 });
405
406 const handleVote = useCallback(
407 (optionKey: string) => {
408 if (!pollCid) return;
409 castVoteRaw(pollUri, pollCid, optionKey, isMultiple, serverUserVotes);
410 },
411 [pollUri, pollCid, isMultiple, serverUserVotes, castVoteRaw],
412 );
413
414 return useMemo(() => {
415 // Helper to clean a raw list: extract DIDs, Deduplicate, Remove Self
416 const processServerList = (data: any) => {
417 const records = data?.linking_records || [];
418 const dids = records.map((r: any) => r.did).filter(Boolean) as string[];
419
420 // 2. Deduplicate everyone (Set removes duplicates)
421 // 3. Remove self from the list (to ensure we don't appear twice or when we shouldn't)
422 const uniqueOthers = new Set(dids.filter((did) => did !== myDid));
423
424 return Array.from(uniqueOthers);
425 };
426
427 const serverLists = {
428 a: processServerList(votersA),
429 b: processServerList(votersB),
430 c: processServerList(votersC),
431 d: processServerList(votersD),
432 };
433
434 const calculateOptionState = (option: "a" | "b" | "c" | "d") => {
435 // --- LOGIC: Determine if we have voted (Boolean) ---
436 const localEntry = localVotes.find((v) => v.option === option);
437 const isServerVoted = serverUserVotes.some((uri) =>
438 uri.includes(`app.reddwarf.poll.vote.${option}`),
439 );
440
441 let hasVoted = false;
442
443 if (localEntry) {
444 hasVoted = localEntry.action === "create";
445 } else {
446 if (isMultiple) {
447 hasVoted = isServerVoted;
448 } else {
449 // Single choice: if we created a vote elsewhere locally, this one is false
450 const hasSwitched = localVotes.some(
451 (v) => v.option !== option && v.action === "create",
452 );
453 hasVoted = hasSwitched ? false : isServerVoted;
454 }
455 }
456
457 // --- LOGIC: Calculate Count ---
458 let count = serverCounts[option] || 0;
459 if (hasVoted && !isServerVoted) count++;
460 if (!hasVoted && isServerVoted) count = Math.max(0, count - 1);
461
462 // --- LOGIC: Finalize Avatar List ---
463 // 4. Add back self purely using the hasVoted state
464 let finalVoters = serverLists[option];
465
466 if (hasVoted && myDid) {
467 finalVoters = [myDid, ...finalVoters];
468 }
469
470 return {
471 hasVoted,
472 count,
473 // We only return the DIDs now, top 5
474 topVoterDids: finalVoters.slice(0, 5),
475 };
476 };
477
478 const stateA = calculateOptionState("a");
479 const stateB = calculateOptionState("b");
480 const stateC = calculateOptionState("c");
481 const stateD = calculateOptionState("d");
482
483 return {
484 results: { a: stateA, b: stateB, c: stateC, d: stateD },
485 hasVotedAny:
486 stateA.hasVoted ||
487 stateB.hasVoted ||
488 stateC.hasVoted ||
489 stateD.hasVoted,
490 totalVotes: stateA.count + stateB.count + stateC.count + stateD.count,
491 handleVote,
492 votersA,
493 votersB,
494 votersC,
495 votersD
496 };
497 }, [
498 localVotes,
499 serverUserVotes,
500 serverCounts,
501 votersA,
502 votersB,
503 votersC,
504 votersD, // Dependencies for fetching
505 isMultiple,
506 handleVote,
507 myDid,
508 ]);
509}