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