2019-03-04 08:18:23 +11:00
import PropTypes from 'prop-types' ;
2023-05-24 01:15:17 +10:00
2019-03-04 08:18:23 +11:00
import { defineMessages , injectIntl , FormattedMessage } from 'react-intl' ;
2023-05-24 01:15:17 +10:00
2019-03-04 08:18:23 +11:00
import classNames from 'classnames' ;
2023-05-24 01:15:17 +10:00
import ImmutablePropTypes from 'react-immutable-proptypes' ;
import ImmutablePureComponent from 'react-immutable-pure-component' ;
2019-03-06 15:35:52 +11:00
import escapeTextContentForBrowser from 'escape-html' ;
2023-05-24 01:15:17 +10:00
import spring from 'react-motion/lib/spring' ;
import { Icon } from 'mastodon/components/icon' ;
2019-03-06 15:35:52 +11:00
import emojify from 'mastodon/features/emoji/emoji' ;
2023-05-24 01:15:17 +10:00
import Motion from 'mastodon/features/ui/util/optional_motion' ;
2023-05-09 11:11:56 +10:00
import { RelativeTimestamp } from './relative_timestamp' ;
2019-03-04 08:18:23 +11:00
const messages = defineMessages ( {
2021-10-13 13:59:31 +11:00
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}}' ,
} ,
2019-03-04 08:18:23 +11:00
} ) ;
2019-03-21 03:29:12 +11:00
const makeEmojiMap = record => record . get ( 'emojis' ) . reduce ( ( obj , emoji ) => {
obj [ ` : ${ emoji . get ( 'shortcode' ) } : ` ] = emoji . toJS ( ) ;
return obj ;
} , { } ) ;
2019-03-04 08:18:23 +11:00
class Poll extends ImmutablePureComponent {
2022-09-29 14:21:51 +10:00
static contextTypes = {
identity : PropTypes . object ,
} ;
2019-03-04 08:18:23 +11:00
static propTypes = {
2019-03-04 09:45:02 +11:00
poll : ImmutablePropTypes . map ,
2023-02-27 06:13:27 +11:00
lang : PropTypes . string ,
2019-03-04 08:18:23 +11:00
intl : PropTypes . object . isRequired ,
disabled : PropTypes . bool ,
2020-04-17 04:16:20 +10:00
refresh : PropTypes . func ,
2020-04-18 05:54:25 +10:00
onVote : PropTypes . func ,
2019-03-04 08:18:23 +11:00
} ;
state = {
selected : { } ,
2019-09-16 22:32:26 +10:00
expired : null ,
2019-03-04 08:18:23 +11:00
} ;
2019-09-16 22:32:26 +10:00
static getDerivedStateFromProps ( props , state ) {
const { poll , intl } = props ;
2019-10-27 22:45:55 +11:00
const expires _at = poll . get ( 'expires_at' ) ;
const expired = poll . get ( 'expired' ) || expires _at !== null && ( new Date ( expires _at ) ) . getTime ( ) < intl . now ( ) ;
2019-09-16 22:32:26 +10:00
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 ) ;
}
}
2019-12-04 05:53:16 +11:00
_toggleOption = value => {
2019-03-04 08:18:23 +11:00
if ( this . props . poll . get ( 'multiple' ) ) {
const tmp = { ... this . state . selected } ;
2019-03-04 11:54:14 +11:00
if ( tmp [ value ] ) {
delete tmp [ value ] ;
} else {
tmp [ value ] = true ;
}
2019-03-04 08:18:23 +11:00
this . setState ( { selected : tmp } ) ;
} else {
const tmp = { } ;
tmp [ value ] = true ;
this . setState ( { selected : tmp } ) ;
}
2023-01-30 11:45:35 +11:00
} ;
2019-12-04 05:53:16 +11:00
handleOptionChange = ( { target : { value } } ) => {
this . _toggleOption ( value ) ;
2019-03-04 08:18:23 +11:00
} ;
2019-12-04 05:53:16 +11:00
handleOptionKeyPress = ( e ) => {
if ( e . key === 'Enter' || e . key === ' ' ) {
this . _toggleOption ( e . target . getAttribute ( 'data-index' ) ) ;
e . stopPropagation ( ) ;
e . preventDefault ( ) ;
}
2023-01-30 11:45:35 +11:00
} ;
2019-12-04 05:53:16 +11:00
2019-03-04 08:18:23 +11:00
handleVote = ( ) => {
if ( this . props . disabled ) {
return ;
}
2020-04-18 05:54:25 +10:00
this . props . onVote ( Object . keys ( this . state . selected ) ) ;
2019-03-04 08:18:23 +11:00
} ;
handleRefresh = ( ) => {
if ( this . props . disabled ) {
return ;
}
2020-04-17 04:16:20 +10:00
this . props . refresh ( ) ;
2019-03-04 08:18:23 +11:00
} ;
2019-09-16 22:32:26 +10:00
renderOption ( option , optionIndex , showResults ) {
2023-02-27 06:13:27 +11:00
const { poll , lang , disabled , intl } = this . props ;
2019-09-30 06:58:01 +10:00
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 ) ) ;
2019-03-04 08:18:23 +11:00
2023-06-01 08:10:21 +10:00
const title = option . getIn ( [ 'translation' , 'title' ] ) || option . get ( 'title' ) ;
let titleHtml = option . getIn ( [ 'translation' , 'titleHtml' ] ) || option . get ( 'titleHtml' ) ;
if ( ! titleHtml ) {
2019-03-21 03:29:12 +11:00
const emojiMap = makeEmojiMap ( poll ) ;
2023-06-01 08:10:21 +10:00
titleHtml = emojify ( escapeTextContentForBrowser ( title ) , emojiMap ) ;
2019-03-21 03:29:12 +11:00
}
2019-03-04 08:18:23 +11:00
return (
< li key = { option . get ( 'title' ) } >
2020-04-03 02:10:55 +11:00
< label className = { classNames ( 'poll__option' , { selectable : ! showResults } ) } >
2019-03-04 08:18:23 +11:00
< input
name = 'vote-options'
type = { poll . get ( 'multiple' ) ? 'checkbox' : 'radio' }
value = { optionIndex }
checked = { active }
onChange = { this . handleOptionChange }
2019-03-04 11:54:14 +11:00
disabled = { disabled }
2019-03-04 08:18:23 +11:00
/ >
2019-12-04 05:53:16 +11:00
{ ! showResults && (
< span
className = { classNames ( 'poll__input' , { checkbox : poll . get ( 'multiple' ) , active } ) }
2023-04-05 00:33:44 +10:00
tabIndex = { 0 }
2019-12-04 05:53:16 +11:00
role = { poll . get ( 'multiple' ) ? 'checkbox' : 'radio' }
onKeyPress = { this . handleOptionKeyPress }
aria - checked = { active }
2023-06-01 08:10:21 +10:00
aria - label = { title }
2023-02-27 06:13:27 +11:00
lang = { lang }
2019-12-04 05:53:16 +11:00
data - index = { optionIndex }
/ >
) }
2021-10-13 13:59:31 +11:00
{ showResults && (
< span
className = 'poll__number'
title = { intl . formatMessage ( messages . votes , {
votes : option . get ( 'votes_count' ) ,
} ) }
>
{ Math . round ( percent ) } %
< / span >
) }
2019-03-04 08:18:23 +11:00
2020-04-03 02:10:55 +11:00
< span
2021-01-22 20:09:23 +11:00
className = 'poll__option__text translate'
2023-02-27 06:13:27 +11:00
lang = { lang }
2023-06-01 08:10:21 +10:00
dangerouslySetInnerHTML = { { _ _html : titleHtml } }
2020-04-03 02:10:55 +11:00
/ >
{ ! ! voted && < span className = 'poll__voted' >
< Icon id = 'check' className = 'poll__voted__mark' title = { intl . formatMessage ( messages . voted ) } / >
< / span > }
2019-03-04 08:18:23 +11:00
< / label >
2020-04-03 02:10:55 +11:00
{ showResults && (
< Motion defaultStyle = { { width : 0 } } style = { { width : spring ( percent , { stiffness : 180 , damping : 12 } ) } } >
{ ( { width } ) =>
< span className = { classNames ( 'poll__chart' , { leading } ) } style = { { width : ` ${ width } % ` } } / >
}
< / Motion >
) }
2019-03-04 08:18:23 +11:00
< / li >
) ;
}
render ( ) {
const { poll , intl } = this . props ;
2019-09-16 22:32:26 +10:00
const { expired } = this . state ;
2019-03-04 09:45:02 +11:00
if ( ! poll ) {
return null ;
}
2019-09-16 22:32:26 +10:00
const timeRemaining = expired ? intl . formatMessage ( messages . closed ) : < RelativeTimestamp timestamp = { poll . get ( 'expires_at' ) } futureDate / > ;
const showResults = poll . get ( 'voted' ) || expired ;
2019-03-04 09:45:02 +11:00
const disabled = this . props . disabled || Object . entries ( this . state . selected ) . every ( item => ! item ) ;
2019-03-04 08:18:23 +11:00
2019-09-30 06:58:01 +10:00
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' ) } } / > ;
}
2019-03-04 08:18:23 +11:00
return (
< div className = 'poll' >
< ul >
2019-09-16 22:32:26 +10:00
{ poll . get ( 'options' ) . map ( ( option , i ) => this . renderOption ( option , i , showResults ) ) }
2019-03-04 08:18:23 +11:00
< / ul >
< div className = 'poll__footer' >
2022-09-29 14:21:51 +10:00
{ ! showResults && < button className = 'button button-secondary' disabled = { disabled || ! this . context . identity . signedIn } onClick = { this . handleVote } > < FormattedMessage id = 'poll.vote' defaultMessage = 'Vote' / > < / button > }
2019-03-04 08:18:23 +11:00
{ showResults && ! this . props . disabled && < span > < button className = 'poll__link' onClick = { this . handleRefresh } > < FormattedMessage id = 'poll.refresh' defaultMessage = 'Refresh' / > < / button > · < / span > }
2019-09-30 06:58:01 +10:00
{ votesCount }
2019-03-05 13:51:18 +11:00
{ poll . get ( 'expires_at' ) && < span > · { timeRemaining } < / span > }
2019-03-04 08:18:23 +11:00
< / div >
< / div >
) ;
}
}
2023-03-24 13:17:53 +11:00
export default injectIntl ( Poll ) ;