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.

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