// 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}
/>
)}
);
}