an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
1//import * as ATPAPI from "@atproto/api"
2import { useAtom } from "jotai";
3import * as React from "react";
4
5import {
6 usePollData,
7 usePollMutationQueue,
8} from "~/providers/PollMutationQueueProvider";
9//import { useAuth } from "~/providers/UnifiedAuthProvider";
10import { renderSnack } from "~/routes/__root";
11import { imgCDNAtom } from "~/utils/atoms";
12import { useQueryArbitrary, useQueryConstellation, useQueryProfile } from "~/utils/useQuery";
13
14import { type embedtryfall } from "./PostEmbeds";
15import { ExternalLinkEmbed } from "./PostEmbeds";
16
17export function PollEmbed({
18 did,
19 rkey,
20 redactedLoading,
21 embedtryfall
22}: {
23 did: string;
24 rkey: string;
25 redactedLoading?: boolean;
26 embedtryfall?: embedtryfall;
27}) {
28 //const { agent } = useAuth();
29 const { refreshPollData } = usePollMutationQueue();
30 const pollUri = `at://${did}/app.reddwarf.embed.poll/${rkey}`;
31 const { data: pollRecord, isLoading, error } = useQueryArbitrary(pollUri);
32 const dontLoadPolls = embedtryfall && (isLoading || pollRecord === undefined || error !== null) || false
33
34 const { data: voteCountsA } = useQueryConstellation({
35 method: "/links/count/distinct-dids",
36 target: pollUri,
37 collection: "app.reddwarf.poll.vote.a",
38 path: ".subject.uri",
39 customkey: "constellation-polls",
40 enabled: !dontLoadPolls
41 });
42
43 const { data: voteCountsB } = useQueryConstellation({
44 method: "/links/count/distinct-dids",
45 target: pollUri,
46 collection: "app.reddwarf.poll.vote.b",
47 path: ".subject.uri",
48 customkey: "constellation-polls",
49 enabled: !dontLoadPolls
50 });
51
52 const { data: voteCountsC } = useQueryConstellation({
53 method: "/links/count/distinct-dids",
54 target: pollUri,
55 collection: "app.reddwarf.poll.vote.c",
56 path: ".subject.uri",
57 customkey: "constellation-polls",
58 enabled: !dontLoadPolls
59 });
60
61 const { data: voteCountsD } = useQueryConstellation({
62 method: "/links/count/distinct-dids",
63 target: pollUri,
64 collection: "app.reddwarf.poll.vote.d",
65 path: ".subject.uri",
66 customkey: "constellation-polls",
67 enabled: !dontLoadPolls
68 });
69
70 // const { data: votersA } = useQueryConstellation({
71 // method: "/links",
72 // target: pollUri,
73 // collection: "app.reddwarf.poll.vote.a",
74 // path: ".subject.uri",
75 // customkey: "constellation-polls",
76 // enabled: !isLoading
77 // });
78 // const { data: votersB } = useQueryConstellation({
79 // method: "/links",
80 // target: pollUri,
81 // collection: "app.reddwarf.poll.vote.b",
82 // path: ".subject.uri",
83 // customkey: "constellation-polls",
84 // enabled: !isLoading
85 // });
86 // const { data: votersC } = useQueryConstellation({
87 // method: "/links",
88 // target: pollUri,
89 // collection: "app.reddwarf.poll.vote.c",
90 // path: ".subject.uri",
91 // customkey: "constellation-polls",
92 // enabled: !isLoading
93 // });
94 // const { data: votersD } = useQueryConstellation({
95 // method: "/links",
96 // target: pollUri,
97 // collection: "app.reddwarf.poll.vote.d",
98 // path: ".subject.uri",
99 // customkey: "constellation-polls",
100 // enabled: !isLoading
101 // });
102
103 const poll = {
104 ...(pollRecord?.value ?? {}),
105 multiple: true,
106 } as {
107 a: string;
108 b: string;
109 c?: string;
110 d?: string;
111 expiry?: string;
112 multiple?: boolean;
113 createdAt: string;
114 };
115
116 const options = [poll.a, poll.b, poll.c, poll.d].filter(Boolean);
117
118 const serverCounts = {
119 a: parseInt((voteCountsA as any)?.total || "0"),
120 b: parseInt((voteCountsB as any)?.total || "0"),
121 c: parseInt((voteCountsC as any)?.total || "0"),
122 d: parseInt((voteCountsD as any)?.total || "0"),
123 };
124
125 const { results, totalVotes, handleVote, votersA, votersB, votersC, votersD } = usePollData(
126 pollUri,
127 pollRecord?.cid,
128 !!poll.multiple,
129 serverCounts,
130 !dontLoadPolls
131 );
132 if (dontLoadPolls && embedtryfall) {
133 const link = embedtryfall.embed.external;
134 const onOpen = embedtryfall.onOpen
135 return (
136 <>
137 {/* pass thru confirm<br />
138 embedtryfall = {JSON.stringify(embedtryfall, null, 2)}<br />
139 isLoading = {JSON.stringify(isLoading, null, 2)}<br />
140 pollRecord = {JSON.stringify(pollRecord, null, 2)}<br />
141 error = {JSON.stringify(error, null, 2)}<br /> */}
142 <ExternalLinkEmbed link={link} onOpen={onOpen} style={{ marginTop: 0 }} redactedLoading={redactedLoading}/>
143 </>
144 )
145 }
146 if (isLoading && !embedtryfall) {
147 return (
148 <div className="animate-pulse">
149 <div className="flex items-center gap-2 mb-3">
150 <div className="h-6 w-20 bg-gray-300 dark:bg-gray-600 rounded"></div>
151 <div className="h-6 w-32 bg-gray-300 dark:bg-gray-600 rounded"></div>
152 </div>
153 <div className="space-y-2">
154 <div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg"></div>
155 <div className="h-12 bg-gray-300 dark:bg-gray-600 rounded-lg w-3/4"></div>
156 </div>
157 </div>
158 );
159 }
160
161 if (error || !pollRecord?.value) {
162 return <div className="text-red-500 text-sm p-2">Failed to load poll</div>;
163 }
164 const isExpired = false;
165
166 return (
167 <>
168 <div className={`${redactedLoading ? "pointer-events-none": ""} my-4`}>
169 <div className="mb-4 flex items-center gap-3">
170 <div className="flex items-center gap-1.5 rounded-lg border-gray-300 dark:border-gray-600 pl-2 pr-2.5 py-1 text-sm font-medium uppercase tracking-wide text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-800">
171 <IconMdiGlobe />
172 <span>Public Poll</span>
173 </div>
174
175 <span className="text-sm font-normal text-gray-500 dark:text-gray-400 flex flex-row items-center gap-1">
176 {poll.multiple ? (
177 <IconMdiCheckboxMultipleMarked />
178 ) : (
179 <IconMdiCheckCircle />
180 )}
181 <span className="md:flex hidden">
182 {poll.multiple
183 ? "Select one or more options"
184 : "Select one option"}
185 </span>
186 </span>
187
188 <button
189 onClick={(e) => {
190 e.stopPropagation();
191 refreshPollData(pollUri);
192 }}
193 className="ml-auto rounded-full h-8 outline outline-gray-200 text-gray-700 dark:outline-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors px-3 py-1 text-[12px] flex items-center gap-1"
194 title="Refresh poll data"
195 >
196 <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
197 <path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" />
198 </svg>
199 Refresh
200 </button>
201 </div>
202
203 <div className="space-y-3">
204 {options.map((optionText, index) => {
205 const optionKey = ["a", "b", "c", "d"][index] as
206 | "a"
207 | "b"
208 | "c"
209 | "d";
210 const { topVoterDids } = results[optionKey];
211 const optionState = results[optionKey];
212 const hasVotedForOption = optionState.hasVoted;
213 const votePercentage =
214 totalVotes > 0 ? (optionState.count / totalVotes) * 100 : 0;
215
216 const votersData = (() => {
217 if (optionKey === "a") return votersA?.linking_records || [];
218 if (optionKey === "b") return votersB?.linking_records || [];
219 if (optionKey === "c") return votersC?.linking_records || [];
220 if (optionKey === "d") return votersD?.linking_records || [];
221 return [];
222 })();
223 const topVoters = votersData
224 .filter((v: any) => !!v.did)
225 .slice(0, 5);
226
227 return (
228 <div
229 key={index}
230 className={`group relative h-12 items-center justify-between rounded-lg border px-4 flex overflow-hidden ${
231 !isExpired
232 ? hasVotedForOption
233 ? "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"
234 : "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"
235 : "bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700"
236 }`}
237 onClick={(e) => {
238 e.stopPropagation();
239 if (!isExpired) {
240 handleVote(optionKey);
241 }
242 }}
243 >
244 <div
245 className="absolute inset-y-0 left-0 bg-gray-300 dark:bg-gray-700 group-hover:bg-gray-400 dark:group-hover:bg-gray-600 transition-[width]"
246 style={{ width: `${votePercentage}%` }}
247 />
248
249 <span className="relative z-[2] text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
250 {optionText}
251 {hasVotedForOption && (
252 <span className="ml-2 text-gray-600 dark:text-gray-400">
253 {poll.multiple ? "✓" : "✓ (click to remove)"}
254 </span>
255 )}
256 </span>
257
258 <div className="relative z-[2] flex items-center gap-2">
259 {topVoterDids.length > 0 && (
260 <div className="flex -space-x-2">
261 {topVoterDids.map((did, idx) => (
262 <div
263 key={did}
264 className="w-5 h-5 rounded-full border-2 border-white dark:border-gray-900 overflow-hidden bg-gray-200"
265 style={{ zIndex: 5 - idx }}
266 >
267 <PollOptionAvatar did={did} />
268 </div>
269 ))}
270 </div>
271 )}
272
273 <span className="text-sm font-medium text-gray-600 dark:text-gray-400">
274 {votePercentage.toFixed(0)}%
275 </span>
276 </div>
277 </div>
278 );
279 })}
280 </div>
281
282 <div className="mt-4 flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
283 <div className="flex items-center gap-2">
284 <IconMdiClockOutline />
285 <span>Never expires</span>
286 </div>
287
288 <button
289 onClick={(e) => {
290 e.stopPropagation();
291 renderSnack({
292 title: "Not implemented yet...",
293 description: "Opening PDSLS",
294 });
295 const pdslsUrl = `https://pdsls.dev/at://${did}/app.reddwarf.embed.poll/${rkey}#backlinks`;
296 window.open(pdslsUrl, "_blank");
297 }}
298 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]"
299 >
300 View all {totalVotes} votes
301 </button>
302 </div>
303 </div>
304 </>
305 );
306}
307
308export function PollOptionAvatar({ did }: { did: string }) {
309 const [imgcdn] = useAtom(imgCDNAtom);
310 const { data: profileRecord } = useQueryProfile(
311 `at://${did}/app.bsky.actor.profile/self`,
312 );
313
314 const avatarUrl = getAvatarUrl(profileRecord, did, imgcdn);
315
316 if (!avatarUrl) {
317 return <div className="w-full h-full bg-gray-500" />;
318 }
319
320 return (
321 <img
322 src={avatarUrl}
323 alt="voter"
324 className="w-full h-full object-cover"
325 onError={(e) => {
326 const target = e.target as HTMLImageElement;
327 target.style.display = "none";
328 target.parentElement!.style.backgroundColor = "#6b7280";
329 }}
330 />
331 );
332}
333
334function getAvatarUrl(opProfile: any, did: string, cdn: string) {
335 const link = opProfile?.value?.avatar?.ref?.["$link"];
336 if (!link) return null;
337 return `https://${cdn}/img/avatar/plain/${did}/${link}@jpeg`;
338}