poll.jsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import PropTypes from 'prop-types';
  2. import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
  3. import classNames from 'classnames';
  4. import ImmutablePropTypes from 'react-immutable-proptypes';
  5. import ImmutablePureComponent from 'react-immutable-pure-component';
  6. import escapeTextContentForBrowser from 'escape-html';
  7. import spring from 'react-motion/lib/spring';
  8. import CheckIcon from '@/material-icons/400-24px/check.svg?react';
  9. import { Icon } from 'mastodon/components/icon';
  10. import emojify from 'mastodon/features/emoji/emoji';
  11. import Motion from 'mastodon/features/ui/util/optional_motion';
  12. import { RelativeTimestamp } from './relative_timestamp';
  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 } = props;
  49. const expires_at = poll.get('expires_at');
  50. const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < Date.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 } = this.props;
  64. clearTimeout(this._timer);
  65. if (!this.state.expired) {
  66. const delay = (new Date(poll.get('expires_at'))).getTime() - Date.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. handleReveal = () => {
  110. this.setState({ revealed: true });
  111. };
  112. renderOption (option, optionIndex, showResults) {
  113. const { poll, lang, disabled, intl } = this.props;
  114. const pollVotesCount = poll.get('voters_count') || poll.get('votes_count');
  115. const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100;
  116. const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count'));
  117. const active = !!this.state.selected[`${optionIndex}`];
  118. const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
  119. const title = option.getIn(['translation', 'title']) || option.get('title');
  120. let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml');
  121. if (!titleHtml) {
  122. const emojiMap = makeEmojiMap(poll);
  123. titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
  124. }
  125. return (
  126. <li key={option.get('title')}>
  127. <label className={classNames('poll__option', { selectable: !showResults })}>
  128. <input
  129. name='vote-options'
  130. type={poll.get('multiple') ? 'checkbox' : 'radio'}
  131. value={optionIndex}
  132. checked={active}
  133. onChange={this.handleOptionChange}
  134. disabled={disabled}
  135. />
  136. {!showResults && (
  137. <span
  138. className={classNames('poll__input', { checkbox: poll.get('multiple'), active })}
  139. tabIndex={0}
  140. role={poll.get('multiple') ? 'checkbox' : 'radio'}
  141. onKeyPress={this.handleOptionKeyPress}
  142. aria-checked={active}
  143. aria-label={title}
  144. lang={lang}
  145. data-index={optionIndex}
  146. />
  147. )}
  148. {showResults && (
  149. <span
  150. className='poll__number'
  151. title={intl.formatMessage(messages.votes, {
  152. votes: option.get('votes_count'),
  153. })}
  154. >
  155. {Math.round(percent)}%
  156. </span>
  157. )}
  158. <span
  159. className='poll__option__text translate'
  160. lang={lang}
  161. dangerouslySetInnerHTML={{ __html: titleHtml }}
  162. />
  163. {!!voted && <span className='poll__voted'>
  164. <Icon id='check' icon={CheckIcon} className='poll__voted__mark' title={intl.formatMessage(messages.voted)} />
  165. </span>}
  166. </label>
  167. {showResults && (
  168. <Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
  169. {({ width }) =>
  170. <span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} />
  171. }
  172. </Motion>
  173. )}
  174. </li>
  175. );
  176. }
  177. render () {
  178. const { poll, intl } = this.props;
  179. const { revealed, expired } = this.state;
  180. if (!poll) {
  181. return null;
  182. }
  183. const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
  184. const showResults = poll.get('voted') || revealed || expired;
  185. const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
  186. let votesCount = null;
  187. if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) {
  188. votesCount = <FormattedMessage id='poll.total_people' defaultMessage='{count, plural, one {# person} other {# people}}' values={{ count: poll.get('voters_count') }} />;
  189. } else {
  190. votesCount = <FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} />;
  191. }
  192. return (
  193. <div className='poll'>
  194. <ul>
  195. {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))}
  196. </ul>
  197. <div className='poll__footer'>
  198. {!showResults && <button className='button button-secondary' disabled={disabled || !this.context.identity.signedIn} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
  199. {!showResults && <><button className='poll__link' onClick={this.handleReveal}><FormattedMessage id='poll.reveal' defaultMessage='See results' /></button> · </>}
  200. {showResults && !this.props.disabled && <><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </>}
  201. {votesCount}
  202. {poll.get('expires_at') && <> · {timeRemaining}</>}
  203. </div>
  204. </div>
  205. );
  206. }
  207. }
  208. export default injectIntl(Poll);