diff options
Diffstat (limited to 'app/javascript/flavours/glitch/components/poll.jsx')
-rw-r--r-- | app/javascript/flavours/glitch/components/poll.jsx | 237 |
1 files changed, 237 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/components/poll.jsx b/app/javascript/flavours/glitch/components/poll.jsx new file mode 100644 index 000000000..2ccc1761e --- /dev/null +++ b/app/javascript/flavours/glitch/components/poll.jsx @@ -0,0 +1,237 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import Motion from 'flavours/glitch/features/ui/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import escapeTextContentForBrowser from 'escape-html'; +import emojify from 'flavours/glitch/features/emoji/emoji'; +import RelativeTimestamp from './relative_timestamp'; +import Icon from 'flavours/glitch/components/icon'; + +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 makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => { + obj[`:${emoji.get('shortcode')}:`] = emoji.toJS(); + return obj; +}, {}); + +class Poll extends ImmutablePureComponent { + + static contextTypes = { + identity: PropTypes.object, + }; + + static propTypes = { + poll: ImmutablePropTypes.map, + lang: PropTypes.string, + intl: PropTypes.object.isRequired, + disabled: PropTypes.bool, + refresh: PropTypes.func, + onVote: PropTypes.func, + }; + + state = { + selected: {}, + expired: null, + }; + + static getDerivedStateFromProps (props, state) { + const { poll, intl } = props; + const expires_at = poll.get('expires_at'); + const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now(); + return (expired === state.expired) ? null : { expired }; + } + + componentDidMount () { + this._setupTimer(); + } + + componentDidUpdate () { + this._setupTimer(); + } + + componentWillUnmount () { + clearTimeout(this._timer); + } + + _setupTimer () { + const { poll, intl } = this.props; + clearTimeout(this._timer); + if (!this.state.expired) { + const delay = (new Date(poll.get('expires_at'))).getTime() - intl.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; + } + + this.props.onVote(Object.keys(this.state.selected)); + }; + + handleRefresh = () => { + if (this.props.disabled) { + return; + } + + this.props.refresh(); + }; + + 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)); + + let titleEmojified = option.get('title_emojified'); + if (!titleEmojified) { + const emojiMap = makeEmojiMap(poll); + titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap); + } + + return ( + <li key={option.get('title')}> + <label className={classNames('poll__option', { selectable: !showResults })}> + <input + name='vote-options' + type={poll.get('multiple') ? 'checkbox' : 'radio'} + value={optionIndex} + checked={active} + onChange={this.handleOptionChange} + disabled={disabled} + /> + + {!showResults && ( + <span + className={classNames('poll__input', { checkbox: poll.get('multiple'), active })} + tabIndex='0' + role={poll.get('multiple') ? 'checkbox' : 'radio'} + onKeyPress={this.handleOptionKeyPress} + aria-checked={active} + aria-label={option.get('title')} + lang={lang} + data-index={optionIndex} + /> + )} + {showResults && ( + <span + className='poll__number' + title={intl.formatMessage(messages.votes, { + votes: option.get('votes_count'), + })} + > + {Math.round(percent)}% + </span> + )} + + <span + className='poll__option__text translate' + lang={lang} + dangerouslySetInnerHTML={{ __html: titleEmojified }} + /> + + {!!voted && <span className='poll__voted'> + <Icon id='check' className='poll__voted__mark' title={intl.formatMessage(messages.voted)} /> + </span>} + </label> + + {showResults && ( + <Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}> + {({ width }) => + <span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} /> + } + </Motion> + )} + </li> + ); + } + + render () { + const { poll, intl } = this.props; + const { expired } = this.state; + + if (!poll) { + return null; + } + + const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />; + const showResults = poll.get('voted') || 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 = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />; + } else { + votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />; + } + + return ( + <div className='poll'> + <ul> + {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))} + </ul> + + <div className='poll__footer'> + {!showResults && <button className='button button-secondary' disabled={disabled || !this.context.identity.signedIn} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>} + {showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>} + {votesCount} + {poll.get('expires_at') && <span> · {timeRemaining}</span>} + </div> + </div> + ); + } + +} + +export default injectIntl(Poll); |