89b5071fde
Adds a tooltip when hovering over the percentages in a poll showing how many votes were cast for that option. Also removes descriptions from messages here since they are not picked up by Crowdin anyway and might be confusing to other developers :) Fixes #16820
229 lines
7.3 KiB
JavaScript
229 lines
7.3 KiB
JavaScript
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 'mastodon/features/ui/util/optional_motion';
|
|
import spring from 'react-motion/lib/spring';
|
|
import escapeTextContentForBrowser from 'escape-html';
|
|
import emojify from 'mastodon/features/emoji/emoji';
|
|
import RelativeTimestamp from './relative_timestamp';
|
|
import Icon from 'mastodon/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;
|
|
}, {});
|
|
|
|
export default @injectIntl
|
|
class Poll extends ImmutablePureComponent {
|
|
|
|
static propTypes = {
|
|
poll: ImmutablePropTypes.map,
|
|
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, 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')}
|
|
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'
|
|
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} 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>
|
|
);
|
|
}
|
|
|
|
}
|