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 { debounce } from 'lodash'; 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 = (props) => { const { pollId, status } = props; // Third party hooks const poll = useAppSelector((state) => state.polls.get(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.get('expires_at'); return poll.get('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.get('voters_count')) { return ( ); } return ( ); }, [poll]); const disabled = props.disabled || Object.values(selected).every((item) => !item); // Event handlers const handleVote = useCallback(() => { if (disabled) { 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'), }, }), ); } }, [disabled, dispatch, identity, pollId, selected, status]); const handleReveal = useCallback(() => { setRevealed(true); }, []); const handleRefresh = useCallback(() => { if (disabled) { return; } debounce( () => { void dispatch(fetchPoll({ pollId })); }, 1000, { leading: true }, ); }, [disabled, dispatch, pollId]); const handleOptionChange = useCallback( (choiceIndex: number) => { if (!poll) { return; } if (poll.get('multiple')) { setSelected((prev) => ({ ...prev, [choiceIndex]: !prev[choiceIndex], })); } else { setSelected({ [choiceIndex]: true }); } }, [poll], ); if (!poll) { return null; } const showResults = poll.get('voted') || revealed || expired; return (
    {poll.get('options').map((option, i) => ( ))}
{!showResults && ( )} {!showResults && ( <> {' '} ·{' '} )} {showResults && !disabled && ( <> {' '} ·{' '} )} {votesCount} {poll.get('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.get('voted') || poll.get('own_votes')?.includes(index); const title = (option.getIn(['translation', 'title']) as string) || option.get('title'); const intl = useIntl(); // Derived values const percent = useMemo(() => { const pollVotesCount = poll.get('voters_count') || poll.get('votes_count'); return pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100; }, [option, poll]); const isLeading = useMemo( () => poll .get('options') .filterNot((other) => other.get('title') === option.get('title')) .every( (other) => option.get('votes_count') >= other.get('votes_count'), ), [poll, option], ); const titleHtml = useMemo(() => { let titleHtml = (option.getIn(['translation', 'titleHtml']) as string) || option.get('titleHtml'); if (!titleHtml) { const emojiMap = makeEmojiMap(poll.get('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 && ( )}
  • ); };