tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
27
pulls
pipelines
make it much clearer poll votes are public
awarm.space
4 months ago
524bab27
d642a149
+145
-42
3 changed files
expand all
collapse all
unified
split
app
lish
[did]
[publication]
[rkey]
PublishedPollBlock.tsx
getVoterIdentities.ts
components
Popover.tsx
+109
-41
app/lish/[did]/[publication]/[rkey]/PublishedPollBlock.tsx
···
1
"use client";
2
3
-
import { PubLeafletBlocksPoll, PubLeafletPollDefinition, PubLeafletPollVote } from "lexicons/api";
0
0
0
0
4
import { useState, useEffect } from "react";
5
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
6
import { useIdentityData } from "components/IdentityProvider";
···
10
import { Popover } from "components/Popover";
11
import LoginForm from "app/login/LoginForm";
12
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
0
0
0
13
14
// Helper function to extract the first option from a vote record
15
const getVoteOption = (voteRecord: any): string | null => {
···
101
setShowResults={setShowResults}
102
optimisticVote={optimisticVote}
103
/>
104
-
{isCreator && !hasVoted && (
105
<div className="flex justify-start">
106
<button
107
className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
···
124
disabled={!identity?.atp_did}
125
/>
126
))}
127
-
<div className="flex justify-between items-center">
128
-
<div className="flex justify-end gap-2">
129
-
{isCreator && (
130
-
<button
131
-
className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
132
-
onClick={() => setShowResults(!showResults)}
0
0
0
0
0
0
0
0
133
>
134
-
See Results
135
-
</button>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
136
)}
137
</div>
138
-
{identity?.atp_did ? (
139
-
<ButtonPrimary
140
-
className="place-self-end"
141
-
onClick={handleVote}
142
-
disabled={!selectedOption || isVoting}
143
-
>
144
-
{isVoting ? "Voting..." : "Vote!"}
145
-
</ButtonPrimary>
146
-
) : (
147
-
<Popover
148
-
asChild
149
-
trigger={
150
-
<ButtonPrimary className="place-self-center">
151
-
<BlueskyTiny /> Login to vote
152
-
</ButtonPrimary>
153
-
}
154
-
>
155
-
{isClient && (
156
-
<LoginForm
157
-
text="Log in to vote on this poll!"
158
-
noEmail
159
-
redirectRoute={window?.location.href + "?refreshAuth"}
160
-
/>
161
-
)}
162
-
</Popover>
163
-
)}
164
</div>
165
</>
166
)}
···
221
return (
222
<>
223
{pollRecord.options.map((option, index) => {
224
-
const votes = allVotes.filter(
225
(v) => getVoteOption(v.record) === index.toString(),
226
-
).length;
227
-
const isWinner = totalVotes > 0 && votes === highestVotes;
228
229
return (
230
<PollResult
231
key={index}
232
option={option}
233
-
votes={votes}
0
234
totalVotes={totalVotes}
235
winner={isWinner}
236
/>
···
240
);
241
};
242
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
243
const PollResult = (props: {
244
option: PubLeafletPollDefinition.Option;
245
votes: number;
0
246
totalVotes: number;
247
winner: boolean;
248
}) => {
···
258
className="pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10"
259
>
260
<div className="grow max-w-full truncate">{props.option.text}</div>
261
-
<div>{props.votes}</div>
262
</div>
263
<div className="pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0">
264
<div
···
1
"use client";
2
3
+
import {
4
+
PubLeafletBlocksPoll,
5
+
PubLeafletPollDefinition,
6
+
PubLeafletPollVote,
7
+
} from "lexicons/api";
8
import { useState, useEffect } from "react";
9
import { ButtonPrimary, ButtonSecondary } from "components/Buttons";
10
import { useIdentityData } from "components/IdentityProvider";
···
14
import { Popover } from "components/Popover";
15
import LoginForm from "app/login/LoginForm";
16
import { BlueskyTiny } from "components/Icons/BlueskyTiny";
17
+
import { getVoterIdentities, VoterIdentity } from "./getVoterIdentities";
18
+
import { Json } from "supabase/database.types";
19
+
import { InfoSmall } from "components/Icons/InfoSmall";
20
21
// Helper function to extract the first option from a vote record
22
const getVoteOption = (voteRecord: any): string | null => {
···
108
setShowResults={setShowResults}
109
optimisticVote={optimisticVote}
110
/>
111
+
{!hasVoted && (
112
<div className="flex justify-start">
113
<button
114
className="w-fit flex gap-2 items-center justify-start text-sm text-accent-contrast"
···
131
disabled={!identity?.atp_did}
132
/>
133
))}
134
+
<div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-2 items-center pt-2">
135
+
<div className="text-sm text-tertiary">All votes are public</div>
136
+
<div className="flex sm:gap-3 sm:flex-row flex-col-reverse sm:justify-end justify-center gap-1 items-center">
137
+
<button
138
+
className="w-fit font-bold text-accent-contrast"
139
+
onClick={() => setShowResults(!showResults)}
140
+
>
141
+
See Results
142
+
</button>
143
+
{identity?.atp_did ? (
144
+
<ButtonPrimary
145
+
className="place-self-end"
146
+
onClick={handleVote}
147
+
disabled={!selectedOption || isVoting}
148
>
149
+
{isVoting ? "Voting..." : "Vote!"}
150
+
</ButtonPrimary>
151
+
) : (
152
+
<Popover
153
+
asChild
154
+
trigger={
155
+
<ButtonPrimary className="place-self-center">
156
+
<BlueskyTiny /> Login to vote
157
+
</ButtonPrimary>
158
+
}
159
+
>
160
+
{isClient && (
161
+
<LoginForm
162
+
text="Log in to vote on this poll!"
163
+
noEmail
164
+
redirectRoute={window?.location.href + "?refreshAuth"}
165
+
/>
166
+
)}
167
+
</Popover>
168
)}
169
</div>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
170
</div>
171
</>
172
)}
···
227
return (
228
<>
229
{pollRecord.options.map((option, index) => {
230
+
const voteRecords = allVotes.filter(
231
(v) => getVoteOption(v.record) === index.toString(),
232
+
);
233
+
const isWinner = totalVotes > 0 && voteRecords.length === highestVotes;
234
235
return (
236
<PollResult
237
key={index}
238
option={option}
239
+
votes={voteRecords.length}
240
+
voteRecords={voteRecords}
241
totalVotes={totalVotes}
242
winner={isWinner}
243
/>
···
247
);
248
};
249
250
+
const VoterListPopover = (props: {
251
+
votes: number;
252
+
voteRecords: { voter_did: string; record: Json }[];
253
+
}) => {
254
+
const [voterIdentities, setVoterIdentities] = useState<VoterIdentity[]>([]);
255
+
const [isLoading, setIsLoading] = useState(false);
256
+
const [hasFetched, setHasFetched] = useState(false);
257
+
258
+
const handleOpenChange = async () => {
259
+
if (!hasFetched && props.voteRecords.length > 0) {
260
+
setIsLoading(true);
261
+
setHasFetched(true);
262
+
try {
263
+
const dids = props.voteRecords.map((v) => v.voter_did);
264
+
const identities = await getVoterIdentities(dids);
265
+
setVoterIdentities(identities);
266
+
} catch (error) {
267
+
console.error("Failed to fetch voter identities:", error);
268
+
} finally {
269
+
setIsLoading(false);
270
+
}
271
+
}
272
+
};
273
+
274
+
return (
275
+
<Popover
276
+
trigger={
277
+
<button
278
+
className="hover:underline cursor-pointer"
279
+
disabled={props.votes === 0}
280
+
>
281
+
{props.votes}
282
+
</button>
283
+
}
284
+
onOpenChange={handleOpenChange}
285
+
className="w-64 max-h-80"
286
+
>
287
+
{isLoading ? (
288
+
<div className="flex justify-center py-4">
289
+
<div className="text-sm text-secondary">Loading...</div>
290
+
</div>
291
+
) : (
292
+
<div className="flex flex-col gap-1 text-sm py-0.5">
293
+
{voterIdentities.map((voter) => (
294
+
<a
295
+
key={voter.did}
296
+
href={`https://bsky.app/profile/${voter.handle || voter.did}`}
297
+
target="_blank"
298
+
rel="noopener noreferrer"
299
+
className=""
300
+
>
301
+
@{voter.handle || voter.did}
302
+
</a>
303
+
))}
304
+
</div>
305
+
)}
306
+
</Popover>
307
+
);
308
+
};
309
+
310
const PollResult = (props: {
311
option: PubLeafletPollDefinition.Option;
312
votes: number;
313
+
voteRecords: { voter_did: string; record: Json }[];
314
totalVotes: number;
315
winner: boolean;
316
}) => {
···
326
className="pollResultContent text-accent-contrast relative flex gap-2 justify-between z-10"
327
>
328
<div className="grow max-w-full truncate">{props.option.text}</div>
329
+
<VoterListPopover votes={props.votes} voteRecords={props.voteRecords} />
330
</div>
331
<div className="pollResultBG absolute bg-bg-page w-full top-0 bottom-0 left-0 right-0 flex flex-row z-0">
332
<div
+35
app/lish/[did]/[publication]/[rkey]/getVoterIdentities.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
"use server";
2
+
3
+
import { idResolver } from "app/(home-pages)/reader/idResolver";
4
+
5
+
export type VoterIdentity = {
6
+
did: string;
7
+
handle: string | null;
8
+
};
9
+
10
+
export async function getVoterIdentities(
11
+
dids: string[],
12
+
): Promise<VoterIdentity[]> {
13
+
const identities = await Promise.all(
14
+
dids.map(async (did) => {
15
+
try {
16
+
const resolved = await idResolver.did.resolve(did);
17
+
const handle = resolved?.alsoKnownAs?.[0]
18
+
? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix
19
+
: null;
20
+
return {
21
+
did,
22
+
handle,
23
+
};
24
+
} catch (error) {
25
+
console.error(`Failed to resolve DID ${did}:`, error);
26
+
return {
27
+
did,
28
+
handle: null,
29
+
};
30
+
}
31
+
}),
32
+
);
33
+
34
+
return identities;
35
+
}
+1
-1
components/Popover.tsx
···
43
max-w-(--radix-popover-content-available-width)
44
max-h-(--radix-popover-content-available-height)
45
border border-border rounded-md shadow-md
46
-
overflow-y-scroll no-scrollbar
47
${props.className}
48
`}
49
side={props.side}
···
43
max-w-(--radix-popover-content-available-width)
44
max-h-(--radix-popover-content-available-height)
45
border border-border rounded-md shadow-md
46
+
overflow-y-scroll
47
${props.className}
48
`}
49
side={props.side}