diff --git a/app/javascript/mastodon/api_types/polls.ts b/app/javascript/mastodon/api_types/polls.ts index 275ca29fd7..d7c5907736 100644 --- a/app/javascript/mastodon/api_types/polls.ts +++ b/app/javascript/mastodon/api_types/polls.ts @@ -13,7 +13,7 @@ export interface ApiPollJSON { expired: boolean; multiple: boolean; votes_count: number; - voters_count: number; + voters_count: null | number; options: ApiPollOptionJSON[]; emojis: ApiCustomEmojiJSON[]; diff --git a/app/javascript/mastodon/components/poll.jsx b/app/javascript/mastodon/components/poll.jsx deleted file mode 100644 index 1326131009..0000000000 --- a/app/javascript/mastodon/components/poll.jsx +++ /dev/null @@ -1,248 +0,0 @@ -import PropTypes from 'prop-types'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; - -import escapeTextContentForBrowser from 'escape-html'; -import spring from 'react-motion/lib/spring'; - -import CheckIcon from '@/material-icons/400-24px/check.svg?react'; -import { Icon } from 'mastodon/components/icon'; -import emojify from 'mastodon/features/emoji/emoji'; -import Motion from 'mastodon/features/ui/util/optional_motion'; -import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; - -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}}', - }, -}); - -class Poll extends ImmutablePureComponent { - static propTypes = { - identity: identityContextPropShape, - poll: ImmutablePropTypes.record.isRequired, - status: ImmutablePropTypes.map.isRequired, - lang: PropTypes.string, - intl: PropTypes.object.isRequired, - disabled: PropTypes.bool, - refresh: PropTypes.func, - onVote: PropTypes.func, - onInteractionModal: PropTypes.func, - }; - - state = { - selected: {}, - expired: null, - }; - - static getDerivedStateFromProps (props, state) { - const { poll } = props; - const expires_at = poll.get('expires_at'); - const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < Date.now(); - return (expired === state.expired) ? null : { expired }; - } - - componentDidMount () { - this._setupTimer(); - } - - componentDidUpdate () { - this._setupTimer(); - } - - componentWillUnmount () { - clearTimeout(this._timer); - } - - _setupTimer () { - const { poll } = this.props; - clearTimeout(this._timer); - if (!this.state.expired) { - const delay = (new Date(poll.get('expires_at'))).getTime() - Date.now(); - this._timer = setTimeout(() => { - this.setState({ expired: true }); - }, delay); - } - } - - _toggleOption = value => { - if (this.props.poll.get('multiple')) { - const tmp = { ...this.state.selected }; - if (tmp[value]) { - delete tmp[value]; - } else { - tmp[value] = true; - } - this.setState({ selected: tmp }); - } else { - const tmp = {}; - tmp[value] = true; - this.setState({ selected: tmp }); - } - }; - - handleOptionChange = ({ target: { value } }) => { - this._toggleOption(value); - }; - - handleOptionKeyPress = (e) => { - if (e.key === 'Enter' || e.key === ' ') { - this._toggleOption(e.target.getAttribute('data-index')); - e.stopPropagation(); - e.preventDefault(); - } - }; - - handleVote = () => { - if (this.props.disabled) { - return; - } - - if (this.props.identity.signedIn) { - this.props.onVote(Object.keys(this.state.selected)); - } else { - this.props.onInteractionModal('vote', this.props.status); - } - }; - - handleRefresh = () => { - if (this.props.disabled) { - return; - } - - this.props.refresh(); - }; - - handleReveal = () => { - this.setState({ revealed: true }); - }; - - renderOption (option, optionIndex, showResults) { - const { poll, lang, disabled, intl } = this.props; - const pollVotesCount = poll.get('voters_count') || poll.get('votes_count'); - const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100; - const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count')); - const active = !!this.state.selected[`${optionIndex}`]; - const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex)); - - const title = option.getIn(['translation', 'title']) || option.get('title'); - let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml'); - - if (!titleHtml) { - const emojiMap = emojiMap(poll); - titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap); - } - - return ( -
  • - - - {showResults && ( - - {({ width }) => - - } - - )} -
  • - ); - } - - render () { - const { poll, intl } = this.props; - const { revealed, expired } = this.state; - - if (!poll) { - return null; - } - - const timeRemaining = expired ? intl.formatMessage(messages.closed) : ; - const showResults = poll.get('voted') || revealed || expired; - const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item); - - let votesCount = null; - - if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) { - votesCount = ; - } else { - votesCount = ; - } - - return ( -
    -
      - {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))} -
    - -
    - {!showResults && } - {!showResults && <> · } - {showResults && !this.props.disabled && <> · } - {votesCount} - {poll.get('expires_at') && <> · {timeRemaining}} -
    -
    - ); - } - -} - -export default injectIntl(withIdentity(Poll)); diff --git a/app/javascript/mastodon/components/poll.tsx b/app/javascript/mastodon/components/poll.tsx new file mode 100644 index 0000000000..a3664a78d1 --- /dev/null +++ b/app/javascript/mastodon/components/poll.tsx @@ -0,0 +1,333 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { spring } from 'react-motion'; + +import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import { Icon } from 'mastodon/components/icon'; +import Motion from 'mastodon/features/ui/util/optional_motion'; +import { useIdentity } from 'mastodon/identity_context'; +import type { + Poll as PollModel, + PollOption as PollOptionModel, +} from 'mastodon/models/poll'; +import type { Status } from 'mastodon/models/status'; + +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}}', + }, +}); + +export const PollOption: React.FC<{ + option: PollOptionModel; + optionIndex: number; + showResults: boolean; + percent: number; + voted: boolean; + leading: boolean; + multiple: boolean; + lang: string; + disabled: boolean; + active: boolean; + toggleOption: () => void; +}> = ({ + option, + optionIndex, + percent, + leading, + voted, + multiple, + showResults, + lang, + disabled, + active, + toggleOption, +}) => { + const intl = useIntl(); + const title = option.translation?.title ?? option.title; + const titleHtml = option.translation?.titleHtml ?? option.titleHtml; + + const handleOptionKeyPress = useCallback< + React.KeyboardEventHandler + >( + (e) => { + if (e.key === 'Enter' || e.key === ' ') { + toggleOption(); + e.stopPropagation(); + e.preventDefault(); + } + }, + [toggleOption], + ); + + return ( +
  • + + + {showResults && ( + + {({ width }) => ( + + )} + + )} +
  • + ); +}; + +export const Poll: React.FC<{ + poll: PollModel; + status: Status; + lang: string; + disabled?: boolean; + refresh?: () => void; + onVote: (votes: string[]) => void; + onInteractionModal: (interactionType: string, status: Status) => void; +}> = ({ + poll, + lang, + disabled, + refresh, + onVote, + onInteractionModal, + status, +}) => { + const intl = useIntl(); + + const expires_at = poll.expires_at; + const [expired, setExpired] = useState( + poll.expired || + (!!expires_at && new Date(expires_at).getTime() < Date.now()), + ); + + const [revealed, setRevealed] = useState(false); + + const handleReveal = useCallback(() => { + setRevealed(true); + }, []); + + const [selected, setSelected] = useState>(new Set()); + + const toggleOption = useCallback( + (option: string) => { + setSelected((prev) => { + const next = new Set(prev); + + if (poll.multiple) { + if (next.has(option)) next.delete(option); + else next.add(option); + } else { + next.add(option); + } + return next; + }); + }, + [poll.multiple], + ); + + const makeToggleOption = (option: string) => () => { + toggleOption(option); + }; + + const { signedIn } = useIdentity(); + + const handleRefresh = useCallback(() => { + if (disabled) { + return; + } + + refresh?.(); + }, [refresh, disabled]); + + const handleVote = useCallback(() => { + if (disabled) { + return; + } + + if (signedIn) { + onVote(Array.from(selected)); + } else { + onInteractionModal('vote', status); + } + + onVote(Array.from(selected)); + }, [disabled, onVote, selected, signedIn, onInteractionModal, status]); + + useEffect(() => { + if (expired || !expires_at) return () => undefined; + + const delay = new Date(expires_at).getTime() - Date.now(); + const timer = setTimeout(() => { + setExpired(true); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [expired, expires_at]); + + const timeRemaining = + expired || !expires_at ? ( + intl.formatMessage(messages.closed) + ) : ( + + ); + const showResults = poll.voted || revealed || expired; + + let votesCount = null; + + if (poll.voters_count) { + votesCount = ( + + ); + } else { + votesCount = ( + + ); + } + + return ( +
    +
      + {poll.options.map((option, i) => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we want `votes_count` if `voters_count` is 0 + const pollVotesCount = poll.voters_count || poll.votes_count; + const percent = + pollVotesCount === 0 + ? 0 + : (option.votes_count / pollVotesCount) * 100; + + return ( + other.title === option.title) + .every((other) => option.votes_count >= other.votes_count)} + percent={percent} + disabled={disabled || selected.size === 0} + /> + ); + })} +
    + +
    + {!showResults && ( + + )} + {!showResults && ( + <> + {' '} + ·{' '} + + )} + {showResults && !disabled && ( + <> + {' '} + ·{' '} + + )} + {votesCount} + {poll.expires_at && <> · {timeRemaining}} +
    +
    + ); +}; diff --git a/app/javascript/mastodon/features/ui/util/optional_motion.js b/app/javascript/mastodon/features/ui/util/optional_motion.js index 0b6d4d97f7..5d29eb45f6 100644 --- a/app/javascript/mastodon/features/ui/util/optional_motion.js +++ b/app/javascript/mastodon/features/ui/util/optional_motion.js @@ -1,4 +1,4 @@ -import Motion from 'react-motion/lib/Motion'; +import { Motion } from 'react-motion'; import { reduceMotion } from '../../../initial_state'; diff --git a/app/javascript/mastodon/features/ui/util/reduced_motion.jsx b/app/javascript/mastodon/features/ui/util/reduced_motion.jsx index fd044497f8..52add28945 100644 --- a/app/javascript/mastodon/features/ui/util/reduced_motion.jsx +++ b/app/javascript/mastodon/features/ui/util/reduced_motion.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { Component } from 'react'; -import Motion from 'react-motion/lib/Motion'; +import { Motion } from 'react-motion'; const stylesToKeep = ['opacity', 'backgroundOpacity'];