/** * PollCard — renders a Convex-backed poll inline in the message * timeline. Single- and multi-selection polls are distinguished by * `poll.allowMultiple` (radio vs checkbox). Disclosed polls show the * live tally; undisclosed polls hide counts until the poll is closed. * * Clicks send (or clear) the viewer's vote via `api.polls.vote` / * `api.polls.clearVote`. Because the parent `useQuery(api.polls.get)` * is reactive, the tally updates live for every viewer whenever * anyone votes. */ import { useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useMutation, useQuery } from 'convex/react'; import { Smiley } from '@phosphor-icons/react'; import { Modal, Button } from '@discord-clone/ui'; import { api } from '../../../../../convex/_generated/api'; import type { Id } from '../../../../../convex/_generated/dataModel'; import { EmojiPicker, type EmojiPickerValue } from './EmojiPicker'; import { TwemojiImg } from './TwemojiImg'; import { resolveReactionKeyToUnicode } from '../../utils/emojiLookup'; import styles from './PollCard.module.css'; interface PollCardProps { pollId: Id<'polls'>; } export function PollCard({ pollId }: PollCardProps) { const myUserId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null; const result = useQuery(api.polls.get, { pollId, userId: (myUserId ?? undefined) as Id<'userProfiles'> | undefined, }); const voteMutation = useMutation(api.polls.vote); const clearVoteMutation = useMutation(api.polls.clearVote); const closeMutation = useMutation(api.polls.close); const addReactionMutation = useMutation(api.polls.addReaction); const removeReactionMutation = useMutation(api.polls.removeReaction); // Reaction picker — anchored off the Add Reaction button. `null` // means the picker is closed. const addReactionButtonRef = useRef(null); const [reactPickerPos, setReactPickerPos] = useState< { top: number; left: number } | null >(null); const [endConfirmOpen, setEndConfirmOpen] = useState(false); const [ending, setEnding] = useState(false); const [endError, setEndError] = useState(null); if (!result) { return
Loading poll…
; } const { poll, totals, totalVotes, myVote, reactions } = result; const isEnded = poll.closed || (poll.closesAt != null && poll.closesAt < Date.now()); const isMultiple = poll.allowMultiple; const countsVisible = poll.disclosed || isEnded; const mySelectionSet = new Set(myVote ?? []); const handleToggleReaction = (emoji: string, me: boolean) => { if (!myUserId) return; if (me) { void removeReactionMutation({ pollId: poll._id, userId: myUserId as Id<'userProfiles'>, emoji, }); } else { void addReactionMutation({ pollId: poll._id, userId: myUserId as Id<'userProfiles'>, emoji, }); } }; const openReactPicker = () => { const btn = addReactionButtonRef.current; if (!btn) return; const rect = btn.getBoundingClientRect(); setReactPickerPos({ // Anchor the picker so its bottom edge sits just above the // button — matches the message reaction picker placement. top: Math.max(8, rect.top - 440), left: Math.max(8, rect.left - 240), }); }; const handlePickReaction = (value: EmojiPickerValue) => { if (!myUserId) return; // GIF picks are meaningless as reactions — silently drop them. if (value.kind === 'gif') { setReactPickerPos(null); return; } const emojiKey = value.kind === 'custom' ? value.shortcode : value.surrogates; // If the viewer already reacted with this emoji, toggle it off // instead of inserting a duplicate row (mutation is idempotent // either way, but this matches chat behaviour). const already = reactions?.some((r) => r.emoji === emojiKey && r.me); if (already) { void removeReactionMutation({ pollId: poll._id, userId: myUserId as Id<'userProfiles'>, emoji: emojiKey, }); } else { void addReactionMutation({ pollId: poll._id, userId: myUserId as Id<'userProfiles'>, emoji: emojiKey, }); } setReactPickerPos(null); }; const handleClick = (optionId: string) => { if (isEnded) return; if (!myUserId) return; let next: string[]; if (isMultiple) { const current = new Set(mySelectionSet); if (current.has(optionId)) current.delete(optionId); else current.add(optionId); next = Array.from(current); } else { next = mySelectionSet.has(optionId) ? [] : [optionId]; } if (next.length === 0) { void clearVoteMutation({ pollId: poll._id, userId: myUserId as Id<'userProfiles'>, }); } else { void voteMutation({ pollId: poll._id, userId: myUserId as Id<'userProfiles'>, optionIds: next, }); } }; // Bars scale to the leading answer so the winner reaches 100% and // the rest scale proportionally — matches the new UI's visuals. let maxCount = 0; for (const opt of poll.options) { const c = totals[opt.id] ?? 0; if (c > maxCount) maxCount = c; } return (
{poll.question || 'Poll'}
{!countsVisible && !isEnded && (
Results hidden until the poll ends
)} {isEnded && (
Poll ended
)}
{poll.options.map((option) => { const count = totals[option.id] ?? 0; const selected = mySelectionSet.has(option.id); const pct = !countsVisible ? 0 : maxCount === 0 ? 0 : (count / maxCount) * 100; return ( ); })}
{totalVotes} {totalVotes === 1 ? 'voter' : 'voters'} {isMultiple ? ' · multi-select' : ''} {myUserId === poll.createdBy && !isEnded && ( )}
{(reactions.length > 0 || myUserId) && (
{reactions.map((r) => ( ))} {myUserId && ( )}
)} {reactPickerPos && createPortal(
e.stopPropagation()} > setReactPickerPos(null)} />
, document.body, )} { if (ending) return; setEndConfirmOpen(false); }} size="small" > { if (ending) return; setEndConfirmOpen(false); }} />

Voting will be locked and the results will be finalised. {!poll.disclosed && ( <> Hidden vote counts will become visible to everyone. )}

{poll.question || 'Poll'}
{endError &&
{endError}
}
); }