Poll.tsx raw
1 import { Button } from '@/components/ui/button'
2 import { POLL_TYPE } from '@/constants'
3 import { useFetchPollResults } from '@/hooks/useFetchPollResults'
4 import { createPollResponseDraftEvent } from '@/lib/draft-event'
5 import { getPollMetadataFromEvent } from '@/lib/event-metadata'
6 import { cn, isPartiallyInViewport } from '@/lib/utils'
7 import { useNostr } from '@/providers/NostrProvider'
8 import client from '@/services/client.service'
9 import pollResultsService from '@/services/poll-results.service'
10 import dayjs from 'dayjs'
11 import { CheckCircle2, Loader2 } from 'lucide-react'
12 import { Event } from 'nostr-tools'
13 import { useEffect, useMemo, useState } from 'react'
14 import { useTranslation } from 'react-i18next'
15 import { toast } from 'sonner'
16
17 export default function Poll({ event, className }: { event: Event; className?: string }) {
18 const { t } = useTranslation()
19 const { pubkey, publish, startLogin } = useNostr()
20 const [isVoting, setIsVoting] = useState(false)
21 const [selectedOptionIds, setSelectedOptionIds] = useState<string[]>([])
22 const pollResults = useFetchPollResults(event.id)
23 const [isLoadingResults, setIsLoadingResults] = useState(false)
24 const poll = useMemo(() => getPollMetadataFromEvent(event), [event])
25 const votedOptionIds = useMemo(() => {
26 if (!pollResults || !pubkey) return []
27 return Object.entries(pollResults.results)
28 .filter(([, voters]) => voters.has(pubkey))
29 .map(([optionId]) => optionId)
30 }, [pollResults, pubkey])
31 const validPollOptionIds = useMemo(() => poll?.options.map((option) => option.id) || [], [poll])
32 const isExpired = useMemo(() => poll?.endsAt && dayjs().unix() > poll.endsAt, [poll])
33 const isMultipleChoice = useMemo(() => poll?.pollType === POLL_TYPE.MULTIPLE_CHOICE, [poll])
34 const canVote = useMemo(() => !isExpired && !votedOptionIds.length, [isExpired, votedOptionIds])
35 const showResults = useMemo(() => {
36 return event.pubkey === pubkey || !canVote
37 }, [event, pubkey, canVote])
38 const [containerElement, setContainerElement] = useState<HTMLDivElement | null>(null)
39
40 useEffect(() => {
41 if (pollResults || isLoadingResults || !containerElement) return
42
43 const observer = new IntersectionObserver(
44 ([entry]) => {
45 if (entry.isIntersecting) {
46 setTimeout(() => {
47 if (isPartiallyInViewport(containerElement)) {
48 fetchResults()
49 }
50 }, 200)
51 }
52 },
53 { threshold: 0.1 }
54 )
55
56 observer.observe(containerElement)
57
58 return () => {
59 observer.unobserve(containerElement)
60 }
61 }, [pollResults, isLoadingResults, containerElement])
62
63 if (!poll) {
64 return null
65 }
66
67 const fetchResults = async () => {
68 setIsLoadingResults(true)
69 try {
70 const relays = await ensurePollRelays(event.pubkey, poll)
71 return await pollResultsService.fetchResults(
72 event.id,
73 relays,
74 validPollOptionIds,
75 isMultipleChoice,
76 poll.endsAt
77 )
78 } catch (error) {
79 console.error('Failed to fetch poll results:', error)
80 toast.error('Failed to fetch poll results: ' + (error as Error).message)
81 } finally {
82 setIsLoadingResults(false)
83 }
84 }
85
86 const handleOptionClick = (optionId: string) => {
87 if (isExpired) return
88
89 if (isMultipleChoice) {
90 setSelectedOptionIds((prev) =>
91 prev.includes(optionId) ? prev.filter((id) => id !== optionId) : [...prev, optionId]
92 )
93 } else {
94 setSelectedOptionIds((prev) => (prev.includes(optionId) ? [] : [optionId]))
95 }
96 }
97
98 const handleVote = async () => {
99 if (selectedOptionIds.length === 0) return
100 if (!pubkey) {
101 startLogin()
102 return
103 }
104
105 setIsVoting(true)
106 try {
107 if (!pollResults) {
108 const _pollResults = await fetchResults()
109 if (_pollResults && _pollResults.voters.has(pubkey)) {
110 return
111 }
112 }
113
114 const additionalRelayUrls = await ensurePollRelays(event.pubkey, poll)
115
116 const draftEvent = createPollResponseDraftEvent(event, selectedOptionIds)
117 await publish(draftEvent, {
118 additionalRelayUrls
119 })
120
121 setSelectedOptionIds([])
122 pollResultsService.addPollResponse(event.id, pubkey, selectedOptionIds)
123 } catch (error) {
124 console.error('Failed to vote:', error)
125 toast.error('Failed to vote: ' + (error as Error).message)
126 } finally {
127 setIsVoting(false)
128 }
129 }
130
131 return (
132 <div className={className} ref={setContainerElement}>
133 <div className="space-y-2">
134 <div className="text-sm text-muted-foreground">
135 <p>
136 {poll.pollType === POLL_TYPE.MULTIPLE_CHOICE &&
137 t('Multiple choice (select one or more)')}
138 </p>
139 <p>
140 {!!poll.endsAt &&
141 (isExpired
142 ? t('Poll has ended')
143 : t('Poll ends at {{time}}', {
144 time: new Date(poll.endsAt * 1000).toLocaleString()
145 }))}
146 </p>
147 </div>
148
149 {/* Poll Options */}
150 <div className="grid gap-2">
151 {poll.options.map((option) => {
152 const votes = pollResults?.results?.[option.id]?.size ?? 0
153 const totalVotes = pollResults?.totalVotes ?? 0
154 const percentage = showResults && totalVotes > 0 ? (votes / totalVotes) * 100 : 0
155 const isMax =
156 pollResults && pollResults.totalVotes > 0 && showResults
157 ? Object.values(pollResults.results).every((res) => res.size <= votes)
158 : false
159
160 return (
161 <button
162 key={option.id}
163 title={option.label}
164 className={cn(
165 'relative w-full px-4 py-3 rounded-lg border transition-all flex items-center gap-2 overflow-hidden',
166 canVote ? 'cursor-pointer' : 'cursor-not-allowed',
167 canVote &&
168 (selectedOptionIds.includes(option.id)
169 ? 'border-primary bg-primary/20'
170 : 'hover:border-primary/40 hover:bg-primary/5')
171 )}
172 onClick={(e) => {
173 e.stopPropagation()
174 handleOptionClick(option.id)
175 }}
176 disabled={!canVote}
177 >
178 {/* Content */}
179 <div className="flex items-center gap-2 flex-1 w-0 z-10">
180 <div className={cn('line-clamp-2 text-left', isMax ? 'font-semibold' : '')}>
181 {option.label}
182 </div>
183 {votedOptionIds.includes(option.id) && (
184 <CheckCircle2 className="size-4 shrink-0" />
185 )}
186 </div>
187 {showResults && (
188 <div
189 className={cn(
190 'text-muted-foreground shrink-0 z-10',
191 isMax ? 'font-semibold text-foreground' : ''
192 )}
193 >
194 {percentage.toFixed(1)}%
195 </div>
196 )}
197
198 {/* Progress Bar Background */}
199 <div
200 className={cn(
201 'absolute inset-0 rounded-r-sm transition-all duration-700 ease-out',
202 isMax ? 'bg-primary/60' : 'bg-muted/90'
203 )}
204 style={{ width: `${percentage}%` }}
205 />
206 </button>
207 )
208 })}
209 </div>
210
211 {/* Results Summary */}
212 <div className="flex justify-between items-center text-sm text-muted-foreground">
213 <div>{t('{{number}} votes', { number: pollResults?.totalVotes ?? 0 })}</div>
214
215 {isLoadingResults && t('Loading...')}
216 {!isLoadingResults && showResults && (
217 <div
218 className="hover:underline cursor-pointer"
219 onClick={(e) => {
220 e.stopPropagation()
221 fetchResults()
222 }}
223 >
224 {!pollResults ? t('Load results') : t('Refresh results')}
225 </div>
226 )}
227 </div>
228
229 {/* Vote Button */}
230 {canVote && !!selectedOptionIds.length && (
231 <Button
232 onClick={(e) => {
233 e.stopPropagation()
234 if (selectedOptionIds.length === 0) return
235 handleVote()
236 }}
237 disabled={!selectedOptionIds.length || isVoting}
238 className="w-full"
239 >
240 {isVoting && <Loader2 className="animate-spin" />}
241 {t('Vote')}
242 </Button>
243 )}
244 </div>
245 </div>
246 )
247 }
248
249 async function ensurePollRelays(creator: string, poll: { relayUrls: string[] }) {
250 const relays = poll.relayUrls.slice(0, 4)
251 if (!relays.length) {
252 const relayList = await client.fetchRelayList(creator)
253 relays.push(...relayList.read.slice(0, 4))
254 }
255 return relays
256 }
257