555 lines
20 KiB
TypeScript
555 lines
20 KiB
TypeScript
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<string, NostrEvent>();
|
|
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<string, number> {
|
|
const counts = new Map<string, number>();
|
|
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<string>();
|
|
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<string, { pubkey: string; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
|
|
onClick: () => void;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<button onClick={onClick} className={cn('flex items-center gap-1.5 group', className)}>
|
|
<div className="flex -space-x-1.5">
|
|
{votes.slice(0, 6).map((vote) => {
|
|
const authorData = authorsMap?.get(vote.pubkey);
|
|
const metadata = authorData?.metadata;
|
|
const name = metadata?.name || genUserName(vote.pubkey);
|
|
return (
|
|
<Avatar key={vote.pubkey} className="size-5 ring-1 ring-background">
|
|
<AvatarImage src={metadata?.picture} alt={name} />
|
|
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
|
{name[0]?.toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
);
|
|
})}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground group-hover:text-foreground transition-colors">
|
|
{totalVotes} {totalVotes === 1 ? 'vote' : 'votes'}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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<string | null>(null);
|
|
|
|
// Fetch vote events
|
|
const { data: votes } = useQuery<NostrEvent[]>({
|
|
queryKey: ['poll-votes', event.id],
|
|
queryFn: async ({ signal }) => {
|
|
const filter: Record<string, unknown> = {
|
|
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<string | null>(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 (
|
|
<div className="mt-2" onClick={(e) => e.stopPropagation()}>
|
|
{/* Question */}
|
|
<div className="text-[15px] leading-relaxed font-medium break-words">
|
|
<NoteContent event={event} />
|
|
</div>
|
|
|
|
{/* Poll type + expiry badges + voter avatars + vote count */}
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground bg-secondary/60 px-2 py-0.5 rounded-full">
|
|
<BarChart3 className="size-3" />
|
|
{pollType === 'multiplechoice' ? 'Multiple choice' : 'Single choice'}
|
|
</span>
|
|
{isExpired && (
|
|
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground bg-secondary/60 px-2 py-0.5 rounded-full">
|
|
<Clock className="size-3" />
|
|
Ended
|
|
</span>
|
|
)}
|
|
|
|
{/* Voter avatars + count pushed to the right */}
|
|
{showResults && totalVotes > 0 && (
|
|
<VoterAvatarsButton
|
|
votes={votes ?? []}
|
|
totalVotes={totalVotes}
|
|
authorsMap={authorsMap}
|
|
onClick={() => openVotersModal(null)}
|
|
className="ml-auto"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Options */}
|
|
<div className="mt-3 space-y-2">
|
|
{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 ? (
|
|
<div key={opt.id} className="relative overflow-hidden rounded-lg border border-border">
|
|
{/* Background fill bar */}
|
|
<div
|
|
className={cn(
|
|
'absolute inset-0 transition-all duration-500',
|
|
isMyVote ? 'bg-primary/15' : 'bg-secondary/40',
|
|
)}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
<div className="relative flex items-center justify-between px-3 py-2.5">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
{isMyVote && <CheckCircle2 className="size-4 text-primary shrink-0" />}
|
|
<span className={cn('text-sm break-words', isMyVote && 'font-semibold')}>{opt.label}</span>
|
|
</div>
|
|
<span className="text-sm font-medium tabular-nums text-muted-foreground shrink-0 ml-3">
|
|
{pct}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
key={opt.id}
|
|
onClick={() => setSelectedOption(opt.id)}
|
|
className={cn(
|
|
'w-full text-left rounded-lg border px-3 py-2.5 text-sm transition-colors',
|
|
isSelected
|
|
? 'border-primary bg-primary/10 font-semibold'
|
|
: 'border-border hover:bg-secondary/40',
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Vote button + voter avatars (voting mode only) */}
|
|
{!showResults && (
|
|
<div className="flex items-center justify-between mt-3">
|
|
{totalVotes > 0 ? (
|
|
<VoterAvatarsButton
|
|
votes={votes ?? []}
|
|
totalVotes={totalVotes}
|
|
authorsMap={authorsMap}
|
|
onClick={() => openVotersModal(null)}
|
|
/>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">0 votes</span>
|
|
)}
|
|
{user && (
|
|
<button
|
|
onClick={handleVote}
|
|
disabled={!selectedOption}
|
|
className={cn(
|
|
'text-sm font-semibold px-4 py-1.5 rounded-full transition-colors',
|
|
selectedOption
|
|
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
|
: 'bg-secondary text-muted-foreground cursor-not-allowed',
|
|
)}
|
|
>
|
|
Vote
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Voters Modal */}
|
|
<PollVotersModal
|
|
open={votersModalOpen}
|
|
onOpenChange={setVotersModalOpen}
|
|
allVotes={votes ?? []}
|
|
options={options}
|
|
pollType={pollType}
|
|
initialOptionId={votersModalOptionId}
|
|
authorsMap={authorsMap}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ──── Poll Voters Modal ──── */
|
|
|
|
interface PollVotersModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
allVotes: NostrEvent[];
|
|
options: PollOption[];
|
|
pollType: string;
|
|
initialOptionId?: string | null;
|
|
authorsMap?: Map<string, { pubkey: string; event?: NostrEvent; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
|
|
}
|
|
|
|
function PollVotersModal({ open, onOpenChange, allVotes, options, pollType, initialOptionId, authorsMap }: PollVotersModalProps) {
|
|
const [activeFilter, setActiveFilter] = useState<string | null>(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<string, string>();
|
|
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 (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-[460px] rounded-2xl p-0 gap-0 border-border overflow-hidden [&>button]:hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 h-12">
|
|
<DialogTitle className="text-base font-semibold">Voters</DialogTitle>
|
|
<button
|
|
onClick={() => onOpenChange(false)}
|
|
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
|
|
>
|
|
<X className="size-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Option filter bars — scrollable when more than 3 */}
|
|
<ScrollArea className={cn('px-4', options.length > 2 && 'max-h-[120px]')}>
|
|
<div className="space-y-1.5">
|
|
{/* "All" bar */}
|
|
<button
|
|
onClick={() => setActiveFilter(null)}
|
|
className={cn(
|
|
'relative w-full overflow-hidden rounded-lg border transition-colors text-left',
|
|
activeFilter === null ? 'border-primary' : 'border-border hover:border-muted-foreground/40',
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'absolute inset-0 transition-all duration-500',
|
|
activeFilter === null ? 'bg-primary/15' : 'bg-secondary/40',
|
|
)}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
<div className="relative flex items-center justify-between px-3 py-2">
|
|
<span className={cn('text-sm', activeFilter === null && 'font-semibold')}>All</span>
|
|
<span className="text-sm font-medium tabular-nums text-muted-foreground shrink-0 ml-3">
|
|
{allVotes.length}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
|
|
{/* 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 (
|
|
<button
|
|
key={opt.id}
|
|
onClick={() => setActiveFilter(opt.id)}
|
|
className={cn(
|
|
'relative w-full overflow-hidden rounded-lg border transition-colors text-left',
|
|
isActive ? 'border-primary' : 'border-border hover:border-muted-foreground/40',
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'absolute inset-0 transition-all duration-500',
|
|
isActive ? 'bg-primary/15' : 'bg-secondary/40',
|
|
)}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
<div className="relative flex items-center justify-between px-3 py-2">
|
|
<span className={cn('text-sm break-words min-w-0', isActive && 'font-semibold')}>{opt.label}</span>
|
|
<span className="text-sm font-medium tabular-nums text-muted-foreground shrink-0 ml-3">
|
|
{count}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* Primary accent divider — only when scrollbox is active */}
|
|
{options.length > 2 && <div className="mx-4 h-1 bg-primary rounded-full" />}
|
|
|
|
{/* Voter list */}
|
|
<ScrollArea className="max-h-[60vh]">
|
|
{filteredVoters.length === 0 ? (
|
|
<div className="py-12 text-center text-muted-foreground text-sm">
|
|
No votes yet
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border">
|
|
{filteredVoters.map((vote) => (
|
|
<VoterRow
|
|
key={vote.id}
|
|
vote={vote}
|
|
optionLabelMap={optionLabelMap}
|
|
pollType={pollType}
|
|
authorsMap={authorsMap}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
/* ──── Voter Row ──── */
|
|
|
|
interface VoterRowProps {
|
|
vote: NostrEvent;
|
|
optionLabelMap: Map<string, string>;
|
|
pollType: string;
|
|
authorsMap?: Map<string, { pubkey: string; event?: NostrEvent; metadata?: import('@nostrify/nostrify').NostrMetadata }>;
|
|
}
|
|
|
|
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<string>();
|
|
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 (
|
|
<Link
|
|
to={`/${nevent}`}
|
|
onClick={() => {
|
|
// 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"
|
|
>
|
|
<Avatar className="size-10 shrink-0">
|
|
<AvatarImage src={metadata?.picture} alt={displayName} />
|
|
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
|
{displayName[0].toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="font-bold text-sm truncate">
|
|
{authorData?.event ? (
|
|
<EmojifiedText tags={authorData.event.tags}>{displayName}</EmojifiedText>
|
|
) : displayName}
|
|
</span>
|
|
{metadata?.nip05 && (
|
|
<VerifiedNip05Text nip05={metadata.nip05} pubkey={vote.pubkey} className="text-xs text-muted-foreground truncate" />
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{votedOptions.length > 0 && (
|
|
<span className="text-xs text-muted-foreground truncate">
|
|
{votedOptions.join(', ')}
|
|
</span>
|
|
)}
|
|
<span className="text-xs text-muted-foreground shrink-0">{timeAgo(vote.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
|