// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { memo, useState, useEffect, useRef } from 'react'; import { Checkbox } from 'radix-ui'; import { AnimatePresence, motion } from 'framer-motion'; import { type TailwindStyles, tw } from '../../../axo/tw.dom.js'; import { AxoButton } from '../../../axo/AxoButton.dom.js'; import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js'; import type { DirectionType } from '../Message.dom.js'; import type { PollWithResolvedVotersType } from '../../../state/selectors/message.preload.js'; import type { LocalizerType } from '../../../types/Util.std.js'; import { PollVotesModal } from './PollVotesModal.dom.js'; import { SpinnerV2 } from '../../SpinnerV2.dom.js'; import { usePrevious } from '../../../hooks/usePrevious.std.js'; import { UserText } from '../../UserText.dom.js'; function VotedCheckmark({ isIncoming, i18n, }: { isIncoming: boolean; i18n: LocalizerType; }): JSX.Element { return (
); } type PollCheckboxProps = { checked: boolean; onCheckedChange: (nextChecked: boolean) => void; isIncoming: boolean; isPending: boolean; }; const PollCheckbox = memo((props: PollCheckboxProps) => { const { isIncoming, isPending, checked } = props; let bgColor: TailwindStyles; let borderColor: TailwindStyles; let strokeColor: TailwindStyles | undefined; let checkmarkColor: TailwindStyles | undefined; if (isPending || !checked) { bgColor = tw('bg-transparent'); borderColor = isIncoming ? tw('border-label-placeholder') : tw('border-label-primary-on-color'); strokeColor = isIncoming ? tw('stroke-label-placeholder') : tw('stroke-label-primary-on-color'); checkmarkColor = isIncoming ? tw('text-label-placeholder') : tw('text-label-primary-on-color'); } else { bgColor = isIncoming ? tw('bg-color-fill-primary') : tw('bg-label-primary-on-color'); borderColor = isIncoming ? tw('border-color-fill-primary') : tw('border-label-primary-on-color'); strokeColor = isIncoming ? tw('stroke-color-fill-primary') : tw('stroke-label-primary-on-color'); checkmarkColor = isIncoming ? tw('text-label-primary-on-color') : tw('text-color-fill-primary'); } return ( <> {isPending && ( )} ); }); PollCheckbox.displayName = 'PollCheckbox'; export type PollMessageContentsProps = { poll: PollWithResolvedVotersType; direction: DirectionType; i18n: LocalizerType; messageId: string; sendPollVote: (params: { messageId: string; optionIndexes: ReadonlyArray; }) => void; endPoll: (messageId: string) => void; canEndPoll?: boolean; }; const DELAY_BEFORE_SHOWING_PENDING_ANIMATION = 500; export function PollMessageContents({ poll, direction, i18n, messageId, sendPollVote, endPoll, canEndPoll, }: PollMessageContentsProps): JSX.Element { const [showVotesModal, setShowVotesModal] = useState(false); const [isPending, setIsPending] = useState(false); const hasPendingVotes = poll.pendingVoteDiff && poll.pendingVoteDiff.size > 0; const hadPendingVotesInLastRender = usePrevious(hasPendingVotes, undefined); const pendingCheckTimer = useRef(null); const isIncoming = direction === 'incoming'; const { totalNumVotes: totalVotes, uniqueVoters } = poll; // Handle pending vote state changes useEffect(() => { if (!hasPendingVotes) { // Vote completed, clear pending state setIsPending(false); clearTimeout(pendingCheckTimer.current ?? undefined); pendingCheckTimer.current = null; } else if (!hadPendingVotesInLastRender) { pendingCheckTimer.current = setTimeout(() => { setIsPending(true); }, DELAY_BEFORE_SHOWING_PENDING_ANIMATION); } }, [hadPendingVotesInLastRender, hasPendingVotes]); useEffect(() => { return () => { clearTimeout(pendingCheckTimer.current ?? undefined); }; }, []); let pollStatusText: string; if (poll.terminatedAt) { pollStatusText = i18n('icu:PollMessage--FinalResults'); } else if (poll.allowMultiple) { pollStatusText = i18n('icu:PollMessage--SelectMultiple'); } else { pollStatusText = i18n('icu:PollMessage--SelectOne'); } function handlePollOptionClicked(index: number, nextChecked: boolean): void { const existingSelections = Array.from( poll.votesByOption .entries() .filter(([_, voters]) => (voters ?? []).some(v => v.isMe)) .map(([optionIndex]) => optionIndex) ); const optionIndexes = new Set(existingSelections); if (poll.pendingVoteDiff) { for (const [idx, pendingVoteOrUnvote] of poll.pendingVoteDiff.entries()) { if (pendingVoteOrUnvote === 'PENDING_VOTE') { optionIndexes.add(idx); } else if (pendingVoteOrUnvote === 'PENDING_UNVOTE') { optionIndexes.delete(idx); } } } if (nextChecked) { if (!poll.allowMultiple) { // Single-select: clear existing selections first optionIndexes.clear(); } optionIndexes.add(index); } else { // Removing a selection - same for both modes optionIndexes.delete(index); } sendPollVote({ messageId, optionIndexes: [...optionIndexes], }); } return (
{pollStatusText}
{/* Poll Options */}
{poll.options.map((option, index) => { const pollVoteEntries = poll.votesByOption.get(index); const optionVotes = pollVoteEntries?.length ?? 0; const percentage = uniqueVoters > 0 ? (optionVotes / uniqueVoters) * 100 : 0; const weVotedForThis = (pollVoteEntries ?? []).some(v => v.isMe); const pendingVoteOrUnvote = poll.pendingVoteDiff?.get(index); const isVotePending = isPending && pendingVoteOrUnvote != null; const shouldShowCheckmark = isVotePending ? pendingVoteOrUnvote === 'PENDING_VOTE' : weVotedForThis; return ( // eslint-disable-next-line react/no-array-index-key
{poll.terminatedAt == null && ( // 3px offset: type-body-large has 14px font-size and 20px line-height, // creating 3px space above text. This aligns checkbox with text baseline.
handlePollOptionClicked(index, Boolean(next)) } isIncoming={isIncoming} isPending={isVotePending} />
)}
0 ? 'opacity-100' : 'invisible opacity-0' )} > {poll.terminatedAt != null && weVotedForThis && ( )} {optionVotes}
); })}
{totalVotes > 0 ? ( setShowVotesModal(true)} > {i18n('icu:PollMessage__ViewVotesButton')} ) : ( {i18n('icu:PollVotesModal__noVotes')} )}
{showVotesModal && ( setShowVotesModal(false)} endPoll={endPoll} canEndPoll={canEndPoll} messageId={messageId} /> )}
); }