a tool for shared writing and social publishing

make it much clearer poll votes are public

+145 -42
+109 -41
app/lish/[did]/[publication]/[rkey]/PublishedPollBlock.tsx
··· 1 "use client"; 2 3 - import { PubLeafletBlocksPoll, PubLeafletPollDefinition, PubLeafletPollVote } from "lexicons/api"; 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"; 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)} 133 > 134 - See Results 135 - </button> 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} 234 totalVotes={totalVotes} 235 winner={isWinner} 236 /> ··· 240 ); 241 }; 242 243 const PollResult = (props: { 244 option: PubLeafletPollDefinition.Option; 245 votes: number; 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> 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
···
··· 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}