import type { KeyboardEventHandler } from 'react'; import { useCallback, useMemo, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import classNames from 'classnames'; import { animated, useSpring } from '@react-spring/web'; import escapeTextContentForBrowser from 'escape-html'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import { openModal } from 'mastodon/actions/modal'; import { fetchPoll, vote } from 'mastodon/actions/polls'; import { Icon } from 'mastodon/components/icon'; import emojify from 'mastodon/features/emoji/emoji'; import { useIdentity } from 'mastodon/identity_context'; import { reduceMotion } from 'mastodon/initial_state'; import { makeEmojiMap } from 'mastodon/models/custom_emoji'; import type * as Model from 'mastodon/models/poll'; import type { Status } from 'mastodon/models/status'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { RelativeTimestamp } from './relative_timestamp'; const messages = defineMessages({ closed: { id: 'poll.closed', defaultMessage: 'Closed', }, voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', }, votes: { id: 'poll.votes', defaultMessage: '{votes, plural, one {# vote} other {# votes}}', }, }); interface PollProps { pollId: string; status: Status; lang?: string; disabled?: boolean; } export const Poll: React.FC = ({ pollId, disabled, status }) => { // Third party hooks const poll = useAppSelector((state) => state.polls[pollId]); const identity = useIdentity(); const intl = useIntl(); const dispatch = useAppDispatch(); // State const [revealed, setRevealed] = useState(false); const [selected, setSelected] = useState>({}); // Derived values const expired = useMemo(() => { if (!poll) { return false; } const expiresAt = poll.expires_at; return poll.expired || new Date(expiresAt).getTime() < Date.now(); }, [poll]); const timeRemaining = useMemo(() => { if (!poll) { return null; } if (expired) { return intl.formatMessage(messages.closed); } return ; }, [expired, intl, poll]); const votesCount = useMemo(() => { if (!poll) { return null; } if (poll.voters_count) { return ( ); } return ( ); }, [poll]); const voteDisabled = disabled || Object.values(selected).every((item) => !item); // Event handlers const handleVote = useCallback(() => { if (voteDisabled) { return; } if (identity.signedIn) { void dispatch(vote({ pollId, choices: Object.keys(selected) })); } else { dispatch( openModal({ modalType: 'INTERACTION', modalProps: { type: 'vote', accountId: status.getIn(['account', 'id']), url: status.get('uri'), }, }), ); } }, [voteDisabled, dispatch, identity, pollId, selected, status]); const handleReveal = useCallback(() => { setRevealed(true); }, []); const handleRefresh = useCallback(() => { if (disabled) { return; } void dispatch(fetchPoll({ pollId })); }, [disabled, dispatch, pollId]); const handleOptionChange = useCallback( (choiceIndex: number) => { if (!poll) { return; } if (poll.multiple) { setSelected((prev) => ({ ...prev, [choiceIndex]: !prev[choiceIndex], })); } else { setSelected({ [choiceIndex]: true }); } }, [poll], ); if (!poll) { return null; } const showResults = poll.voted || revealed || expired; return (
    {poll.options.map((option, i) => ( ))}
{!showResults && ( )} {!showResults && ( <> {' '} ·{' '} )} {showResults && !disabled && ( <> {' '} ·{' '} )} {votesCount} {poll.expires_at && <> · {timeRemaining}}
); }; type PollOptionProps = Pick & { active: boolean; onChange: (index: number) => void; poll: Model.Poll; option: Model.PollOption; index: number; showResults?: boolean; }; const PollOption: React.FC = (props) => { const { active, lang, disabled, poll, option, index, showResults, onChange } = props; const voted = option.voted || poll.own_votes?.includes(index); const title = option.translation?.title ?? option.title; const intl = useIntl(); // Derived values const percent = useMemo(() => { const pollVotesCount = poll.voters_count ?? poll.votes_count; return pollVotesCount === 0 ? 0 : (option.votes_count / pollVotesCount) * 100; }, [option, poll]); const isLeading = useMemo( () => poll.options .filter((other) => other.title !== option.title) .every((other) => option.votes_count >= other.votes_count), [poll, option], ); const titleHtml = useMemo(() => { let titleHtml = option.translation?.titleHtml ?? option.titleHtml; if (!titleHtml) { const emojiMap = makeEmojiMap(poll.emojis); titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); } return titleHtml; }, [option, poll, title]); // Handlers const handleOptionChange = useCallback(() => { onChange(index); }, [index, onChange]); const handleOptionKeyPress: KeyboardEventHandler = useCallback( (event) => { if (event.key === 'Enter' || event.key === ' ') { onChange(index); event.stopPropagation(); event.preventDefault(); } }, [index, onChange], ); const widthSpring = useSpring({ from: { width: '0%', }, to: { width: `${percent}%`, }, immediate: reduceMotion, }); return (
  • {showResults && ( )}
  • ); };