import { Button } from '@/components/ui/button' import { FAST_READ_RELAY_URLS, POLL_TYPE } from '@/constants' import { useFetchPollResults } from '@/hooks/useFetchPollResults' import { createPollResponseDraftEvent } from '@/lib/draft-event' import { getPollMetadataFromEvent } from '@/lib/event-metadata' import { parsePollOptionVisualParts } from '@/lib/poll-option-display' import { buildPollResultsReadRelayUrls } from '@/lib/relay-list-builder' import { cn, isPartiallyInViewport } from '@/lib/utils' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostrOptional } from '@/providers/nostr-context' import pollResultsService from '@/services/poll-results.service' import dayjs from 'dayjs' import { Skeleton } from '@/components/ui/skeleton' import { CheckCircle2 } from 'lucide-react' import { Event } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import logger from '@/lib/logger' import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import PollOptionContent from './PollOptionContent' /** * Persists "See results" across remounts (React Strict Mode dev double-mount, list recycle). * Scoped to this tab session only. */ const pollSessionRevealResultIds = new Set() export default function Poll({ event, className }: { event: Event; className?: string }) { const { t } = useTranslation() const nostr = useNostrOptional() const pubkey = nostr?.pubkey ?? null const { favoriteRelays, blockedRelays } = useFavoriteRelays() const publish = nostr?.publish ?? (async () => { throw new Error('Not logged in') }) const startLogin = nostr?.startLogin ?? (() => {}) const [isVoting, setIsVoting] = useState(false) const [selectedOptionIds, setSelectedOptionIds] = useState([]) /** User chose to view vote breakdown without voting first (card UX). */ const [resultsRevealed, setResultsRevealed] = useState( () => pollSessionRevealResultIds.has(event.id) ) useEffect(() => { setResultsRevealed(pollSessionRevealResultIds.has(event.id)) }, [event.id]) const pollResults = useFetchPollResults(event.id) const [isLoadingResults, setIsLoadingResults] = useState(false) const poll = useMemo(() => getPollMetadataFromEvent(event), [event]) const votedOptionIds = useMemo(() => { if (!pollResults || !pubkey) return [] return Object.entries(pollResults.results) .filter(([, voters]) => voters.has(pubkey)) .map(([optionId]) => optionId) }, [pollResults, pubkey]) const isExpired = useMemo(() => poll?.endsAt && dayjs().unix() > poll.endsAt, [poll]) const isMultipleChoice = useMemo(() => poll?.pollType === POLL_TYPE.MULTIPLE_CHOICE, [poll]) const canVote = useMemo(() => !isExpired && !votedOptionIds.length, [isExpired, votedOptionIds]) const showResults = useMemo(() => { return Boolean(isExpired) || resultsRevealed || event.pubkey === pubkey || !canVote }, [isExpired, resultsRevealed, event.pubkey, pubkey, canVote]) const [containerElement, setContainerElement] = useState(null) /** Stops viewport-triggered refetch loops when the first load fails or yields no subscriber update. */ const pollResultsViewportFetchDoneRef = useRef(false) useEffect(() => { pollResultsViewportFetchDoneRef.current = false }, [event.id]) const fetchResults = useCallback(async () => { const meta = getPollMetadataFromEvent(event) if (!meta) return undefined setIsLoadingResults(true) try { const relays = await buildPollResultsReadRelayUrls({ pollEvent: event, pollRelayUrls: meta.relayUrls, viewerPubkey: pubkey, viewerFavoriteRelayUrls: favoriteRelays, blockedRelays }) const optionIds = meta.options.map((o) => o.id) const multi = meta.pollType === POLL_TYPE.MULTIPLE_CHOICE return await pollResultsService.fetchResults( event.id, relays, optionIds, multi, meta.endsAt ) } catch (error) { logger.error('Failed to fetch poll results', { error, eventId: event.id }) toast.error('Failed to fetch poll results: ' + (error as Error).message) } finally { pollResultsViewportFetchDoneRef.current = true setIsLoadingResults(false) } }, [event, pubkey, favoriteRelays, blockedRelays]) useEffect(() => { if ( isExpired || pollResults || isLoadingResults || !containerElement || pollResultsViewportFetchDoneRef.current ) { return } const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setTimeout(() => { if (isPartiallyInViewport(containerElement)) { void fetchResults() } }, 200) } }, { threshold: 0.1 } ) observer.observe(containerElement) return () => { observer.unobserve(containerElement) } }, [isExpired, pollResults, isLoadingResults, containerElement, fetchResults]) useEffect(() => { if (!poll || !isExpired) return pollSessionRevealResultIds.add(event.id) setResultsRevealed(true) void fetchResults() }, [poll, isExpired, fetchResults, event.id]) if (!poll) { return null } const handleOptionClick = (optionId: string) => { if (isExpired) return if (isMultipleChoice) { setSelectedOptionIds((prev) => prev.includes(optionId) ? prev.filter((id) => id !== optionId) : [...prev, optionId] ) } else { setSelectedOptionIds((prev) => (prev.includes(optionId) ? [] : [optionId])) } } const handleVote = async () => { if (selectedOptionIds.length === 0) return if (!pubkey) { startLogin() return } setIsVoting(true) try { if (!pollResults) { const _pollResults = await fetchResults() if (_pollResults && _pollResults.voters.has(pubkey)) { return } } const additionalRelayUrls = await ensurePollRelays(event.pubkey, poll) const draftEvent = createPollResponseDraftEvent(event, selectedOptionIds) const publishedEvent = await publish(draftEvent, { additionalRelayUrls }) // Show publishing feedback if ((publishedEvent as any)?.relayStatuses) { showPublishingFeedback({ success: true, relayStatuses: (publishedEvent as any).relayStatuses, successCount: (publishedEvent as any).relayStatuses.filter((s: any) => s.success).length, totalCount: (publishedEvent as any).relayStatuses.length }, { message: t('Vote published'), duration: 4000 }) } else { showSimplePublishSuccess(t('Vote published')) } setSelectedOptionIds([]) pollResultsService.addPollResponse(event.id, pubkey, selectedOptionIds) } catch (error) { logger.error('Failed to vote', { error, eventId: event.id }) toast.error('Failed to vote: ' + (error as Error).message) } finally { setIsVoting(false) } } return (
{!isExpired && poll.pollType === POLL_TYPE.MULTIPLE_CHOICE && (

{t('Multiple choice (select one or more)')}

)}

{!!poll.endsAt && (isExpired ? t('Poll has ended') : t('Poll ends at {{time}}', { time: new Date(poll.endsAt * 1000).toLocaleString() }))}

{/* Results rows (read-only when ended or already voted) */}
{poll.options.map((option) => { const votes = pollResults?.results?.[option.id]?.size ?? 0 const totalVotes = pollResults?.totalVotes ?? 0 const percentage = showResults && totalVotes > 0 ? (votes / totalVotes) * 100 : showResults ? 0 : 0 const isMax = pollResults && pollResults.totalVotes > 0 && showResults ? Object.values(pollResults.results).every((res) => res.size <= votes) : false const optionVisual = parsePollOptionVisualParts(option.label) const optionHasImages = optionVisual.images.length > 0 const rowClass = cn( 'relative w-full px-4 py-3 rounded-lg border flex gap-2 overflow-hidden', optionHasImages ? 'items-start' : 'items-center', canVote && 'transition-all', canVote ? 'cursor-pointer' : 'cursor-default', canVote && (selectedOptionIds.includes(option.id) ? 'border-primary bg-primary/20' : 'hover:border-primary/40 hover:bg-primary/5') ) const inner = ( <>
{votedOptionIds.includes(option.id) && ( )}
{showResults && (
{isExpired ? t('{{votes}} ยท {{pct}}%', { votes, pct: totalVotes > 0 ? percentage.toFixed(1) : '0' }) : totalVotes > 0 ? `${percentage.toFixed(1)}%` : '0%'}
)} {showResults && (
)} ) return canVote ? ( ) : (
{inner}
) })}
{canVote && !resultsRevealed && (
)} {/* Results Summary */}
{t('{{number}} votes', { number: pollResults?.totalVotes ?? 0 })}
{isLoadingResults && t('Loading...')} {!isLoadingResults && showResults && (
{ e.stopPropagation() fetchResults() }} > {!pollResults ? t('Load results') : t('Refresh results')}
)}
{/* Vote Button */} {canVote && !!selectedOptionIds.length && ( )}
) } async function ensurePollRelays(_creator: string, poll: { relayUrls: string[] }) { const relays = poll.relayUrls.slice(0, 4) // Privacy: Use defaults instead of fetching creator's relays if (!relays.length) { relays.push(...FAST_READ_RELAY_URLS.slice(0, 4)) } return relays }