You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

212 lines
7.0 KiB

  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import ImmutablePropTypes from 'react-immutable-proptypes';
  4. import ImmutablePureComponent from 'react-immutable-pure-component';
  5. import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
  6. import classNames from 'classnames';
  7. import Motion from 'mastodon/features/ui/util/optional_motion';
  8. import spring from 'react-motion/lib/spring';
  9. import escapeTextContentForBrowser from 'escape-html';
  10. import emojify from 'mastodon/features/emoji/emoji';
  11. import RelativeTimestamp from './relative_timestamp';
  12. import Icon from 'mastodon/components/icon';
  13. const messages = defineMessages({
  14. closed: { id: 'poll.closed', defaultMessage: 'Closed' },
  15. voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' },
  16. });
  17. const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
  18. obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
  19. return obj;
  20. }, {});
  21. export default @injectIntl
  22. class Poll extends ImmutablePureComponent {
  23. static propTypes = {
  24. poll: ImmutablePropTypes.map,
  25. intl: PropTypes.object.isRequired,
  26. disabled: PropTypes.bool,
  27. refresh: PropTypes.func,
  28. onVote: PropTypes.func,
  29. };
  30. state = {
  31. selected: {},
  32. expired: null,
  33. };
  34. static getDerivedStateFromProps (props, state) {
  35. const { poll, intl } = props;
  36. const expires_at = poll.get('expires_at');
  37. const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now();
  38. return (expired === state.expired) ? null : { expired };
  39. }
  40. componentDidMount () {
  41. this._setupTimer();
  42. }
  43. componentDidUpdate () {
  44. this._setupTimer();
  45. }
  46. componentWillUnmount () {
  47. clearTimeout(this._timer);
  48. }
  49. _setupTimer () {
  50. const { poll, intl } = this.props;
  51. clearTimeout(this._timer);
  52. if (!this.state.expired) {
  53. const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now();
  54. this._timer = setTimeout(() => {
  55. this.setState({ expired: true });
  56. }, delay);
  57. }
  58. }
  59. _toggleOption = value => {
  60. if (this.props.poll.get('multiple')) {
  61. const tmp = { ...this.state.selected };
  62. if (tmp[value]) {
  63. delete tmp[value];
  64. } else {
  65. tmp[value] = true;
  66. }
  67. this.setState({ selected: tmp });
  68. } else {
  69. const tmp = {};
  70. tmp[value] = true;
  71. this.setState({ selected: tmp });
  72. }
  73. }
  74. handleOptionChange = ({ target: { value } }) => {
  75. this._toggleOption(value);
  76. };
  77. handleOptionKeyPress = (e) => {
  78. if (e.key === 'Enter' || e.key === ' ') {
  79. this._toggleOption(e.target.getAttribute('data-index'));
  80. e.stopPropagation();
  81. e.preventDefault();
  82. }
  83. }
  84. handleVote = () => {
  85. if (this.props.disabled) {
  86. return;
  87. }
  88. this.props.onVote(Object.keys(this.state.selected));
  89. };
  90. handleRefresh = () => {
  91. if (this.props.disabled) {
  92. return;
  93. }
  94. this.props.refresh();
  95. };
  96. renderOption (option, optionIndex, showResults) {
  97. const { poll, disabled, intl } = this.props;
  98. const pollVotesCount = poll.get('voters_count') || poll.get('votes_count');
  99. const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100;
  100. const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count'));
  101. const active = !!this.state.selected[`${optionIndex}`];
  102. const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
  103. let titleEmojified = option.get('title_emojified');
  104. if (!titleEmojified) {
  105. const emojiMap = makeEmojiMap(poll);
  106. titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
  107. }
  108. return (
  109. <li key={option.get('title')}>
  110. <label className={classNames('poll__option', { selectable: !showResults })}>
  111. <input
  112. name='vote-options'
  113. type={poll.get('multiple') ? 'checkbox' : 'radio'}
  114. value={optionIndex}
  115. checked={active}
  116. onChange={this.handleOptionChange}
  117. disabled={disabled}
  118. />
  119. {!showResults && (
  120. <span
  121. className={classNames('poll__input', { checkbox: poll.get('multiple'), active })}
  122. tabIndex='0'
  123. role={poll.get('multiple') ? 'checkbox' : 'radio'}
  124. onKeyPress={this.handleOptionKeyPress}
  125. aria-checked={active}
  126. aria-label={option.get('title')}
  127. data-index={optionIndex}
  128. />
  129. )}
  130. {showResults && <span className='poll__number'>
  131. {Math.round(percent)}%
  132. </span>}
  133. <span
  134. className='poll__option__text translate'
  135. dangerouslySetInnerHTML={{ __html: titleEmojified }}
  136. />
  137. {!!voted && <span className='poll__voted'>
  138. <Icon id='check' className='poll__voted__mark' title={intl.formatMessage(messages.voted)} />
  139. </span>}
  140. </label>
  141. {showResults && (
  142. <Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
  143. {({ width }) =>
  144. <span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} />
  145. }
  146. </Motion>
  147. )}
  148. </li>
  149. );
  150. }
  151. render () {
  152. const { poll, intl } = this.props;
  153. const { expired } = this.state;
  154. if (!poll) {
  155. return null;
  156. }
  157. const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
  158. const showResults = poll.get('voted') || expired;
  159. const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
  160. let votesCount = null;
  161. if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) {
  162. votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />;
  163. } else {
  164. votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
  165. }
  166. return (
  167. <div className='poll'>
  168. <ul>
  169. {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))}
  170. </ul>
  171. <div className='poll__footer'>
  172. {!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
  173. {showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>}
  174. {votesCount}
  175. {poll.get('expires_at') && <span> · {timeRemaining}</span>}
  176. </div>
  177. </div>
  178. );
  179. }
  180. }