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';
14 const messages
= defineMessages({
15 closed: { id: 'poll.closed', defaultMessage: 'Closed' },
16 voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' },
19 const makeEmojiMap
= record
=> record
.get('emojis').reduce((obj
, emoji
) => {
20 obj
[`:${emoji.get('shortcode')}:`] = emoji
.toJS();
24 export default @injectIntl
25 class Poll
extends ImmutablePureComponent
{
28 poll: ImmutablePropTypes
.map
,
29 intl: PropTypes
.object
.isRequired
,
30 disabled: PropTypes
.bool
,
31 refresh: PropTypes
.func
,
32 onVote: PropTypes
.func
,
40 static getDerivedStateFromProps (props
, state
) {
41 const { poll
, intl
} = props
;
42 const expires_at
= poll
.get('expires_at');
43 const expired
= poll
.get('expired') || expires_at
!== null && (new Date(expires_at
)).getTime() < intl
.now();
44 return (expired
=== state
.expired
) ? null : { expired
};
47 componentDidMount () {
51 componentDidUpdate () {
55 componentWillUnmount () {
56 clearTimeout(this._timer
);
60 const { poll
, intl
} = this.props
;
61 clearTimeout(this._timer
);
62 if (!this.state
.expired
) {
63 const delay
= (new Date(poll
.get('expires_at'))).getTime() - intl
.now();
64 this._timer
= setTimeout(() => {
65 this.setState({ expired: true });
70 _toggleOption
= value
=> {
71 if (this.props
.poll
.get('multiple')) {
72 const tmp
= { ...this.state
.selected
};
78 this.setState({ selected: tmp
});
82 this.setState({ selected: tmp
});
86 handleOptionChange
= ({ target: { value
} }) => {
87 this._toggleOption(value
);
90 handleOptionKeyPress
= (e
) => {
91 if (e
.key
=== 'Enter' || e
.key
=== ' ') {
92 this._toggleOption(e
.target
.getAttribute('data-index'));
99 if (this.props
.disabled
) {
103 this.props
.onVote(Object
.keys(this.state
.selected
));
106 handleRefresh
= () => {
107 if (this.props
.disabled
) {
111 this.props
.refresh();
114 renderOption (option
, optionIndex
, showResults
) {
115 const { poll
, disabled
, intl
} = this.props
;
116 const pollVotesCount
= poll
.get('voters_count') || poll
.get('votes_count');
117 const percent
= pollVotesCount
=== 0 ? 0 : (option
.get('votes_count') / pollVotesCount
) * 100;
118 const leading
= poll
.get('options').filterNot(other
=> other
.get('title') === option
.get('title')).every(other
=> option
.get('votes_count') >= other
.get('votes_count'));
119 const active
= !!this.state
.selected
[`${optionIndex}`];
120 const voted
= option
.get('voted') || (poll
.get('own_votes') && poll
.get('own_votes').includes(optionIndex
));
122 let titleEmojified
= option
.get('title_emojified');
123 if (!titleEmojified
) {
124 const emojiMap
= makeEmojiMap(poll
);
125 titleEmojified
= emojify(escapeTextContentForBrowser(option
.get('title')), emojiMap
);
129 <li key
={option
.get('title')}>
130 <label className
={classNames('poll__option', { selectable: !showResults
})}>
133 type
={poll
.get('multiple') ? 'checkbox' : 'radio'}
136 onChange
={this.handleOptionChange
}
142 className
={classNames('poll__input', { checkbox: poll
.get('multiple'), active
})}
144 role
={poll
.get('multiple') ? 'checkbox' : 'radio'}
145 onKeyPress
={this.handleOptionKeyPress
}
146 aria
-checked
={active
}
147 aria
-label
={option
.get('title')}
148 data
-index
={optionIndex
}
151 {showResults
&& <span className
='poll__number'>
152 {Math
.round(percent
)}%
156 className
='poll__option__text translate'
157 dangerouslySetInnerHTML
={{ __html: titleEmojified
}}
160 {!!voted
&& <span className
='poll__voted'>
161 <Icon id
='check' className
='poll__voted__mark' title
={intl
.formatMessage(messages
.voted
)} />
166 <Motion defaultStyle
={{ width: 0 }} style
={{ width: spring(percent
, { stiffness: 180, damping: 12 }) }}>
168 <span className
={classNames('poll__chart', { leading
})} style
={{ width: `${width}%` }} />
177 const { poll
, intl
} = this.props
;
178 const { expired
} = this.state
;
184 const timeRemaining
= expired
? intl
.formatMessage(messages
.closed
) : <RelativeTimestamp timestamp
={poll
.get('expires_at')} futureDate
/>;
185 const showResults
= poll
.get('voted') || expired
;
186 const disabled
= this.props
.disabled
|| Object
.entries(this.state
.selected
).every(item
=> !item
);
188 let votesCount
= null;
190 if (poll
.get('voters_count') !== null && poll
.get('voters_count') !== undefined) {
191 votesCount
= <FormattedMessage id
='poll.total_people' defaultMessage
='{count, plural, one {# person} other {# people}}' values
={{ count: poll
.get('voters_count') }} />;
193 votesCount
= <FormattedMessage id
='poll.total_votes' defaultMessage
='{count, plural, one {# vote} other {# votes}}' values
={{ count: poll
.get('votes_count') }} />;
197 <div className
='poll'>
199 {poll
.get('options').map((option
, i
) => this.renderOption(option
, i
, showResults
))}
202 <div className
='poll__footer'>
203 {!showResults
&& <button className
='button button-secondary' disabled
={disabled
} onClick
={this.handleVote
}><FormattedMessage id
='poll.vote' defaultMessage
='Vote' /></button
>}
204 {showResults
&& !this.props
.disabled
&& <span
><button className
='poll__link' onClick
={this.handleRefresh
}><FormattedMessage id
='poll.refresh' defaultMessage
='Refresh' /></button
> · </span
>}
206 {poll
.get('expires_at') && <span
> · {timeRemaining
}</span
>}