diff --git a/app/soapbox/components/poll.tsx b/app/soapbox/components/poll.tsx deleted file mode 100644 index e8742ab5f..000000000 --- a/app/soapbox/components/poll.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import classNames from 'classnames'; -import React, { useState } from 'react'; -import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; -import { spring } from 'react-motion'; -import { useDispatch } from 'react-redux'; - -import { openModal } from 'soapbox/actions/modals'; -import { vote, fetchPoll } from 'soapbox/actions/polls'; -import { Text, Button, Icon, Stack, HStack } from 'soapbox/components/ui'; -import Motion from 'soapbox/features/ui/util/optional_motion'; -import { useAppSelector } from 'soapbox/hooks'; - -import RelativeTimestamp from './relative_timestamp'; - -import type { Poll as PollEntity, PollOption as PollOptionEntity } from 'soapbox/types/entities'; - -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}}' }, -}); - -const PollPercentageBar: React.FC<{ percent: number, leading: boolean }> = ({ percent, leading }): JSX.Element => { - return ( - - {({ width }) => ( - - )} - - ); -}; - -interface IPollOptionText extends IPollOption { - percent: number, -} - -const PollOptionText: React.FC = ({ poll, option, index, active, percent, showResults, onToggle }) => { - const intl = useIntl(); - const voted = poll.own_votes?.includes(index); - const message = intl.formatMessage(messages.votes, { votes: option.votes_count }); - - const handleOptionChange: React.EventHandler = () => { - onToggle(index); - }; - - const handleOptionKeyPress: React.EventHandler = e => { - if (e.key === 'Enter' || e.key === ' ') { - onToggle(index); - e.stopPropagation(); - e.preventDefault(); - } - }; - - return ( - - - - - - - - - - {showResults ? ( - - {voted ? ( - - ) : ( - - )} - {Math.round(percent)}% - - ) : ( - - {active && ( - - )} - - )} - - - - ); -}; - -interface IPollOption { - poll: PollEntity, - option: PollOptionEntity, - index: number, - showResults?: boolean, - active: boolean, - onToggle: (value: number) => void, -} - -const PollOption: React.FC = (props): JSX.Element | null => { - const { poll, option, showResults } = props; - if (!poll) return null; - - const percent = poll.votes_count === 0 ? 0 : (option.votes_count / poll.votes_count) * 100; - const leading = poll.options.filterNot(other => other.title === option.title).every(other => option.votes_count >= other.votes_count); - - return ( - - {showResults && ( - - )} - - - - ); -}; - -const RefreshButton: React.FC<{ poll: PollEntity }> = ({ poll }): JSX.Element => { - const dispatch = useDispatch(); - - const handleRefresh: React.EventHandler = (e) => { - dispatch(fetchPoll(poll.id)); - e.stopPropagation(); - e.preventDefault(); - }; - - return ( - - - - - - ); -}; - -const VoteButton: React.FC<{ poll: PollEntity, selected: Selected }> = ({ poll, selected }): JSX.Element => { - const dispatch = useDispatch(); - const handleVote = () => dispatch(vote(poll.id, Object.keys(selected))); - - return ( - - - - ); -}; - -interface IPollFooter { - poll: PollEntity, - showResults: boolean, - selected: Selected, -} - -const PollFooter: React.FC = ({ poll, showResults, selected }): JSX.Element => { - const intl = useIntl(); - const timeRemaining = poll.expired ? intl.formatMessage(messages.closed) : ; - - return ( - - {!showResults && } - - - {showResults && ( - <> - - · - > - )} - - - - - - {poll.expires_at && ( - <> - · - {timeRemaining} - > - )} - - - ); -}; - -type Selected = Record; - -interface IPoll { - id: string, - status?: string, -} - -const Poll: React.FC = ({ id, status }): JSX.Element | null => { - const dispatch = useDispatch(); - - const me = useAppSelector((state) => state.me); - const poll = useAppSelector((state) => state.polls.get(id)); - - const [selected, setSelected] = useState({} as Selected); - - const openUnauthorizedModal = () => { - dispatch(openModal('UNAUTHORIZED', { - action: 'POLL_VOTE', - ap_id: status, - })); - }; - - const toggleOption = (value: number) => { - if (me) { - if (poll?.multiple) { - const tmp = { ...selected }; - if (tmp[value]) { - delete tmp[value]; - } else { - tmp[value] = true; - } - setSelected(tmp); - } else { - const tmp: Selected = {}; - tmp[value] = true; - setSelected(tmp); - } - } else { - openUnauthorizedModal(); - } - }; - - if (!poll) return null; - - const showResults = poll.voted || poll.expired; - - return ( - e.stopPropagation()}> - - - {poll.options.map((option, i) => ( - - ))} - - - - - - ); -}; - -export default Poll; diff --git a/app/soapbox/components/polls/poll-footer.tsx b/app/soapbox/components/polls/poll-footer.tsx new file mode 100644 index 000000000..386d25180 --- /dev/null +++ b/app/soapbox/components/polls/poll-footer.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { fetchPoll, vote } from 'soapbox/actions/polls'; +import { useAppDispatch } from 'soapbox/hooks'; + +import RelativeTimestamp from '../relative_timestamp'; +import { Button, HStack, Stack, Text } from '../ui'; + +import type { Selected } from './poll'; +import type { Poll as PollEntity } from 'soapbox/types/entities'; + +const messages = defineMessages({ + closed: { id: 'poll.closed', defaultMessage: 'Closed' }, +}); + +interface IPollFooter { + poll: PollEntity, + showResults: boolean, + selected: Selected, +} + +const PollFooter: React.FC = ({ poll, showResults, selected }): JSX.Element => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + + const handleVote = () => dispatch(vote(poll.id, Object.keys(selected))); + + const handleRefresh: React.EventHandler = (e) => { + dispatch(fetchPoll(poll.id)); + e.stopPropagation(); + e.preventDefault(); + }; + + const timeRemaining = poll.expired ? + intl.formatMessage(messages.closed) : + ; + + return ( + + {(!showResults && poll?.multiple) && ( + + + + )} + + + {showResults && ( + <> + + + + + + + · + > + )} + + + + + + {poll.expires_at && ( + <> + · + {timeRemaining} + > + )} + + + ); +}; + +export default PollFooter; diff --git a/app/soapbox/components/polls/poll-option.tsx b/app/soapbox/components/polls/poll-option.tsx new file mode 100644 index 000000000..6c8523a2d --- /dev/null +++ b/app/soapbox/components/polls/poll-option.tsx @@ -0,0 +1,157 @@ +import classNames from 'classnames'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { Motion, presets, spring } from 'react-motion'; + +import { HStack, Icon, Text } from '../ui'; + +import type { + Poll as PollEntity, + PollOption as PollOptionEntity, +} from 'soapbox/types/entities'; + +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}}' }, +}); + +const PollPercentageBar: React.FC<{ percent: number, leading: boolean }> = ({ percent, leading }): JSX.Element => { + return ( + + {({ width }) => ( + + )} + + ); +}; + +interface IPollOptionText extends IPollOption { + percent: number, +} + +const PollOptionText: React.FC = ({ poll, option, index, active, onToggle }) => { + const handleOptionChange: React.EventHandler = () => onToggle(index); + + const handleOptionKeyPress: React.EventHandler = e => { + if (e.key === 'Enter' || e.key === ' ') { + onToggle(index); + e.stopPropagation(); + e.preventDefault(); + } + }; + + return ( + + + + + + + + + + + {active && ( + + )} + + + + + ); +}; + +interface IPollOption { + poll: PollEntity, + option: PollOptionEntity, + index: number, + showResults?: boolean, + active: boolean, + onToggle: (value: number) => void, +} + +const PollOption: React.FC = (props): JSX.Element | null => { + const { index, poll, option, showResults } = props; + + const intl = useIntl(); + + if (!poll) return null; + + const percent = poll.votes_count === 0 ? 0 : (option.votes_count / poll.votes_count) * 100; + const leading = poll.options.filterNot(other => other.title === option.title).every(other => option.votes_count >= other.votes_count); + const voted = poll.own_votes?.includes(index); + const message = intl.formatMessage(messages.votes, { votes: option.votes_count }); + + return ( + + {showResults ? ( + + + + + + + + {voted ? ( + + ) : ( + + )} + + {Math.round(percent)}% + + + + ) : ( + + )} + + ); +}; + +export default PollOption; diff --git a/app/soapbox/components/polls/poll.tsx b/app/soapbox/components/polls/poll.tsx new file mode 100644 index 000000000..37a2a591a --- /dev/null +++ b/app/soapbox/components/polls/poll.tsx @@ -0,0 +1,89 @@ +import classNames from 'classnames'; +import React, { useState } from 'react'; + +import { openModal } from 'soapbox/actions/modals'; +import { vote } from 'soapbox/actions/polls'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import { Stack } from '../ui'; + +import PollFooter from './poll-footer'; +import PollOption from './poll-option'; + +export type Selected = Record; + +interface IPoll { + id: string, + status?: string, +} + +const Poll: React.FC = ({ id, status }): JSX.Element | null => { + const dispatch = useAppDispatch(); + + const isLoggedIn = useAppSelector((state) => state.me); + const poll = useAppSelector((state) => state.polls.get(id)); + + const [selected, setSelected] = useState({} as Selected); + + const openUnauthorizedModal = () => + dispatch(openModal('UNAUTHORIZED', { + action: 'POLL_VOTE', + ap_id: status, + })); + + const handleVote = (selectedId: number) => dispatch(vote(id, [selectedId])); + + const toggleOption = (value: number) => { + if (isLoggedIn) { + if (poll?.multiple) { + const tmp = { ...selected }; + if (tmp[value]) { + delete tmp[value]; + } else { + tmp[value] = true; + } + setSelected(tmp); + } else { + const tmp: Selected = {}; + tmp[value] = true; + setSelected(tmp); + handleVote(value); + } + } else { + openUnauthorizedModal(); + } + }; + + if (!poll) return null; + + const showResults = poll.voted || poll.expired; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions + e.stopPropagation()}> + + + {poll.options.map((option, i) => ( + + ))} + + + + + + ); +}; + +export default Poll; diff --git a/app/soapbox/components/status_content.tsx b/app/soapbox/components/status_content.tsx index 0420a86c4..40fcf9f56 100644 --- a/app/soapbox/components/status_content.tsx +++ b/app/soapbox/components/status_content.tsx @@ -4,13 +4,14 @@ import { FormattedMessage } from 'react-intl'; import { useHistory } from 'react-router-dom'; import Icon from 'soapbox/components/icon'; -import Poll from 'soapbox/components/poll'; import { useSoapboxConfig } from 'soapbox/hooks'; import { addGreentext } from 'soapbox/utils/greentext'; import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich_content'; import { isRtl } from '../rtl'; +import Poll from './polls/poll'; + import type { Status, Mention } from 'soapbox/types/entities'; const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)