import { useState, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { BarChart3, CheckCircle2, Clock, X, ChevronRight } from 'lucide-react'; import { useNostr } from '@nostrify/react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { nip19 } from 'nostr-tools'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useAuthor } from '@/hooks/useAuthor'; import { useAuthors } from '@/hooks/useAuthors'; import { NoteContent } from '@/components/NoteContent'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { ScrollArea } from '@/components/ui/scroll-area'; import { EmojifiedText } from '@/components/CustomEmoji'; import { VerifiedNip05Text } from '@/components/Nip05Badge'; import { genUserName } from '@/lib/genUserName'; import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; import type { NostrEvent } from '@nostrify/nostrify'; interface PollOption { id: string; label: string; } function getTag(tags: string[][], name: string): string | undefined { return tags.find(([n]) => n === name)?.[1]; } function getOptions(tags: string[][]): PollOption[] { return tags .filter(([n]) => n === 'option') .map(([, id, label]) => ({ id, label })); } /** Deduplicate votes: keep one per pubkey (latest wins). */ function dedupeVotes(events: NostrEvent[]): NostrEvent[] { const map = new Map(); for (const ev of events) { const existing = map.get(ev.pubkey); if (!existing || ev.created_at > existing.created_at) { map.set(ev.pubkey, ev); } } return Array.from(map.values()); } /** Count votes per option ID from deduplicated vote events. */ function tallyVotes( votes: NostrEvent[], pollType: string, ): Map { const counts = new Map(); for (const vote of votes) { const responseTags = vote.tags.filter(([n]) => n === 'response'); if (pollType === 'singlechoice') { // Only first response counts const optionId = responseTags[0]?.[1]; if (optionId) counts.set(optionId, (counts.get(optionId) ?? 0) + 1); } else { // Multiplechoice: first response per option ID const seen = new Set(); for (const [, optionId] of responseTags) { if (optionId && !seen.has(optionId)) { seen.add(optionId); counts.set(optionId, (counts.get(optionId) ?? 0) + 1); } } } } return counts; } /** Get voter events for a specific option ID. */ function getVotersForOption( votes: NostrEvent[], optionId: string, pollType: string, ): NostrEvent[] { return votes.filter((vote) => { const responseTags = vote.tags.filter(([n]) => n === 'response'); if (pollType === 'singlechoice') { return responseTags[0]?.[1] === optionId; } else { return responseTags.some(([, id]) => id === optionId); } }); } /** Clickable avatar stack + "N votes" label. */ function VoterAvatarsButton({ votes, totalVotes, authorsMap, onClick, className, }: { votes: NostrEvent[]; totalVotes: number; authorsMap?: Map; onClick: () => void; className?: string; }) { return ( ); } export function PollContent({ event }: { event: NostrEvent }) { const { nostr } = useNostr(); const { user } = useCurrentUser(); const queryClient = useQueryClient(); const { mutate: publishEvent } = useNostrPublish(); const options = useMemo(() => getOptions(event.tags), [event.tags]); const pollType = getTag(event.tags, 'polltype') ?? 'singlechoice'; const endsAt = getTag(event.tags, 'endsAt'); const isExpired = endsAt ? Number(endsAt) < Math.floor(Date.now() / 1000) : false; // Modal state const [votersModalOpen, setVotersModalOpen] = useState(false); const [votersModalOptionId, setVotersModalOptionId] = useState(null); // Fetch vote events const { data: votes } = useQuery({ queryKey: ['poll-votes', event.id], queryFn: async ({ signal }) => { const filter: Record = { kinds: [1018], '#e': [event.id], limit: 200, }; if (endsAt) filter.until = Number(endsAt); const results = await nostr.query( [filter as { kinds: number[]; '#e': string[]; limit: number; until?: number }], { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, ); return dedupeVotes(results); }, staleTime: 30_000, }); const tally = useMemo(() => tallyVotes(votes ?? [], pollType), [votes, pollType]); const totalVotes = useMemo(() => { let sum = 0; for (const count of tally.values()) sum += count; return sum; }, [tally]); // Check if current user already voted const userVote = useMemo(() => { if (!user || !votes) return undefined; return votes.find((v) => v.pubkey === user.pubkey); }, [user, votes]); const hasVoted = !!userVote; const showResults = hasVoted || isExpired; const [selectedOption, setSelectedOption] = useState(null); const handleVote = (e: React.MouseEvent) => { e.stopPropagation(); if (!selectedOption || !user || hasVoted || isExpired) return; publishEvent({ kind: 1018, content: '', tags: [ ['e', event.id], ['response', selectedOption], ], }, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['poll-votes', event.id] }); }, }); }; // Collect all voter pubkeys for batch profile fetching const allVoterPubkeys = useMemo(() => { if (!votes) return []; return votes.map((v) => v.pubkey); }, [votes]); const { data: authorsMap } = useAuthors(allVoterPubkeys); const openVotersModal = (optionId: string | null) => { setVotersModalOptionId(optionId); setVotersModalOpen(true); }; return (
e.stopPropagation()}> {/* Question */}
{/* Poll type + expiry badges + voter avatars + vote count */}
{pollType === 'multiplechoice' ? 'Multiple choice' : 'Single choice'} {isExpired && ( Ended )} {/* Voter avatars + count pushed to the right */} {showResults && totalVotes > 0 && ( openVotersModal(null)} className="ml-auto" /> )}
{/* Options */}
{options.map((opt) => { const count = tally.get(opt.id) ?? 0; const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0; const isMyVote = userVote?.tags.some(([n, id]) => n === 'response' && id === opt.id); const isSelected = selectedOption === opt.id; return showResults ? (
{/* Background fill bar */}
{isMyVote && } {opt.label}
{pct}%
) : ( ); })}
{/* Vote button + voter avatars (voting mode only) */} {!showResults && (
{totalVotes > 0 ? ( openVotersModal(null)} /> ) : ( 0 votes )} {user && ( )}
)} {/* Voters Modal */}
); } /* ──── Poll Voters Modal ──── */ interface PollVotersModalProps { open: boolean; onOpenChange: (open: boolean) => void; allVotes: NostrEvent[]; options: PollOption[]; pollType: string; initialOptionId?: string | null; authorsMap?: Map; } function PollVotersModal({ open, onOpenChange, allVotes, options, pollType, initialOptionId, authorsMap }: PollVotersModalProps) { const [activeFilter, setActiveFilter] = useState(initialOptionId ?? null); // Sync filter when modal opens with a specific option useMemo(() => { if (open) setActiveFilter(initialOptionId ?? null); }, [open, initialOptionId]); // Build a map from option ID to label for display const optionLabelMap = useMemo(() => { const map = new Map(); for (const opt of options) { map.set(opt.id, opt.label); } return map; }, [options]); // Filter voters based on active filter const filteredVoters = useMemo(() => { if (activeFilter === null) return allVotes; return getVotersForOption(allVotes, activeFilter, pollType); }, [allVotes, activeFilter, pollType]); // Tally per option for the count badges const tally = useMemo(() => tallyVotes(allVotes, pollType), [allVotes, pollType]); return ( {/* Header */}
Voters
{/* Option filter bars — scrollable when more than 3 */} 2 && 'max-h-[120px]')}>
{/* "All" bar */} {/* Per-option bars */} {options.map((opt) => { const count = tally.get(opt.id) ?? 0; const pct = allVotes.length > 0 ? Math.round((count / allVotes.length) * 100) : 0; const isActive = activeFilter === opt.id; return ( ); })}
{/* Primary accent divider — only when scrollbox is active */} {options.length > 2 &&
} {/* Voter list */} {filteredVoters.length === 0 ? (
No votes yet
) : (
{filteredVoters.map((vote) => ( ))}
)}
); } /* ──── Voter Row ──── */ interface VoterRowProps { vote: NostrEvent; optionLabelMap: Map; pollType: string; authorsMap?: Map; } function VoterRow({ vote, optionLabelMap, pollType, authorsMap }: VoterRowProps) { // Use batch-fetched author data if available, fall back to individual fetch const individualAuthor = useAuthor(authorsMap?.has(vote.pubkey) ? undefined : vote.pubkey); const authorData = authorsMap?.get(vote.pubkey) ?? individualAuthor.data; const metadata = authorData?.metadata; const displayName = metadata?.name || genUserName(vote.pubkey); const nevent = useMemo( () => nip19.neventEncode({ id: vote.id, author: vote.pubkey }), [vote.id, vote.pubkey], ); // Resolve which option(s) this person voted for const votedOptions = useMemo(() => { const responseTags = vote.tags.filter(([n]) => n === 'response'); if (pollType === 'singlechoice') { const id = responseTags[0]?.[1]; const label = id ? optionLabelMap.get(id) : undefined; return label ? [label] : []; } const labels: string[] = []; const seen = new Set(); for (const [, id] of responseTags) { if (id && !seen.has(id)) { seen.add(id); const label = optionLabelMap.get(id); if (label) labels.push(label); } } return labels; }, [vote.tags, pollType, optionLabelMap]); return ( { // Close any open dialogs by dispatching escape document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); }} className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors" > {displayName[0].toUpperCase()}
{authorData?.event ? ( {displayName} ) : displayName} {metadata?.nip05 && ( )}
{votedOptions.length > 0 && ( {votedOptions.join(', ')} )} {timeAgo(vote.created_at)}
); }