parent
0d19fcc2fb
commit
8d70a8a19b
@ -0,0 +1,53 @@ |
||||
import api from '../api'; |
||||
|
||||
export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST'; |
||||
export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS'; |
||||
export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL'; |
||||
|
||||
export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST'; |
||||
export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS'; |
||||
export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL'; |
||||
|
||||
export const vote = (pollId, choices) => (dispatch, getState) => { |
||||
dispatch(voteRequest()); |
||||
|
||||
api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices }) |
||||
.then(({ data }) => dispatch(voteSuccess(data))) |
||||
.catch(err => dispatch(voteFail(err))); |
||||
}; |
||||
|
||||
export const fetchPoll = pollId => (dispatch, getState) => { |
||||
dispatch(fetchPollRequest()); |
||||
|
||||
api(getState).get(`/api/v1/polls/${pollId}`) |
||||
.then(({ data }) => dispatch(fetchPollSuccess(data))) |
||||
.catch(err => dispatch(fetchPollFail(err))); |
||||
}; |
||||
|
||||
export const voteRequest = () => ({ |
||||
type: POLL_VOTE_REQUEST, |
||||
}); |
||||
|
||||
export const voteSuccess = poll => ({ |
||||
type: POLL_VOTE_SUCCESS, |
||||
poll, |
||||
}); |
||||
|
||||
export const voteFail = error => ({ |
||||
type: POLL_VOTE_FAIL, |
||||
error, |
||||
}); |
||||
|
||||
export const fetchPollRequest = () => ({ |
||||
type: POLL_FETCH_REQUEST, |
||||
}); |
||||
|
||||
export const fetchPollSuccess = poll => ({ |
||||
type: POLL_FETCH_SUCCESS, |
||||
poll, |
||||
}); |
||||
|
||||
export const fetchPollFail = error => ({ |
||||
type: POLL_FETCH_FAIL, |
||||
error, |
||||
}); |
@ -0,0 +1,144 @@ |
||||
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 { vote, fetchPoll } from 'mastodon/actions/polls'; |
||||
import Motion from 'mastodon/features/ui/util/optional_motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
|
||||
const messages = defineMessages({ |
||||
moments: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' }, |
||||
seconds: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' }, |
||||
minutes: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' }, |
||||
hours: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' }, |
||||
days: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' }, |
||||
}); |
||||
|
||||
const SECOND = 1000; |
||||
const MINUTE = 1000 * 60; |
||||
const HOUR = 1000 * 60 * 60; |
||||
const DAY = 1000 * 60 * 60 * 24; |
||||
|
||||
const timeRemainingString = (intl, date, now) => { |
||||
const delta = date.getTime() - now; |
||||
|
||||
let relativeTime; |
||||
|
||||
if (delta < 10 * SECOND) { |
||||
relativeTime = intl.formatMessage(messages.moments); |
||||
} else if (delta < MINUTE) { |
||||
relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) }); |
||||
} else if (delta < HOUR) { |
||||
relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) }); |
||||
} else if (delta < DAY) { |
||||
relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) }); |
||||
} else { |
||||
relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) }); |
||||
} |
||||
|
||||
return relativeTime; |
||||
}; |
||||
|
||||
export default @injectIntl |
||||
class Poll extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
poll: ImmutablePropTypes.map.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
dispatch: PropTypes.func, |
||||
disabled: PropTypes.bool, |
||||
}; |
||||
|
||||
state = { |
||||
selected: {}, |
||||
}; |
||||
|
||||
handleOptionChange = e => { |
||||
const { target: { value } } = e; |
||||
|
||||
if (this.props.poll.get('multiple')) { |
||||
const tmp = { ...this.state.selected }; |
||||
tmp[value] = true; |
||||
this.setState({ selected: tmp }); |
||||
} else { |
||||
const tmp = {}; |
||||
tmp[value] = true; |
||||
this.setState({ selected: tmp }); |
||||
} |
||||
}; |
||||
|
||||
handleVote = () => { |
||||
if (this.props.disabled) { |
||||
return; |
||||
} |
||||
|
||||
this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected))); |
||||
}; |
||||
|
||||
handleRefresh = () => { |
||||
if (this.props.disabled) { |
||||
return; |
||||
} |
||||
|
||||
this.props.dispatch(fetchPoll(this.props.poll.get('id'))); |
||||
}; |
||||
|
||||
renderOption (option, optionIndex) { |
||||
const { poll } = this.props; |
||||
const percent = (option.get('votes_count') / poll.get('votes_count')) * 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 showResults = poll.get('voted') || poll.get('expired'); |
||||
|
||||
return ( |
||||
<li key={option.get('title')}> |
||||
{showResults && ( |
||||
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}> |
||||
{({ width }) => |
||||
<span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} /> |
||||
} |
||||
</Motion> |
||||
)} |
||||
|
||||
<label className={classNames('poll__text', { selectable: !showResults })}> |
||||
<input |
||||
name='vote-options' |
||||
type={poll.get('multiple') ? 'checkbox' : 'radio'} |
||||
value={optionIndex} |
||||
checked={active} |
||||
onChange={this.handleOptionChange} |
||||
/> |
||||
|
||||
{!showResults && <span className={classNames('poll__input', { active })} />} |
||||
{showResults && <span className='poll__number'>{Math.floor(percent)}%</span>} |
||||
|
||||
{option.get('title')} |
||||
</label> |
||||
</li> |
||||
); |
||||
} |
||||
|
||||
render () { |
||||
const { poll, intl } = this.props; |
||||
const timeRemaining = timeRemainingString(intl, new Date(poll.get('expires_at')), intl.now()); |
||||
const showResults = poll.get('voted') || poll.get('expired'); |
||||
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item); |
||||
|
||||
return ( |
||||
<div className='poll'> |
||||
<ul> |
||||
{poll.get('options').map((option, i) => this.renderOption(option, i))} |
||||
</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>} |
||||
<FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} /> · {timeRemaining} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,8 @@ |
||||
import { connect } from 'react-redux'; |
||||
import Poll from 'mastodon/components/poll'; |
||||
|
||||
const mapStateToProps = (state, { pollId }) => ({ |
||||
poll: state.getIn(['polls', pollId]), |
||||
}); |
||||
|
||||
export default connect(mapStateToProps)(Poll); |
@ -0,0 +1,19 @@ |
||||
import { POLL_VOTE_SUCCESS, POLL_FETCH_SUCCESS } from 'mastodon/actions/polls'; |
||||
import { POLLS_IMPORT } from 'mastodon/actions/importer'; |
||||
import { Map as ImmutableMap, fromJS } from 'immutable'; |
||||
|
||||
const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); |
||||
|
||||
const initialState = ImmutableMap(); |
||||
|
||||
export default function polls(state = initialState, action) { |
||||
switch(action.type) { |
||||
case POLLS_IMPORT: |
||||
return importPolls(state, action.polls); |
||||
case POLL_VOTE_SUCCESS: |
||||
case POLL_FETCH_SUCCESS: |
||||
return importPolls(state, [action.poll]); |
||||
default: |
||||
return state; |
||||
} |
||||
} |
@ -0,0 +1,95 @@ |
||||
.poll { |
||||
margin-top: 16px; |
||||
font-size: 14px; |
||||
|
||||
li { |
||||
margin-bottom: 10px; |
||||
position: relative; |
||||
} |
||||
|
||||
&__chart { |
||||
position: absolute; |
||||
top: 0; |
||||
left: 0; |
||||
height: 100%; |
||||
display: inline-block; |
||||
border-radius: 4px; |
||||
background: darken($ui-primary-color, 14%); |
||||
|
||||
&.leading { |
||||
background: $ui-highlight-color; |
||||
} |
||||
} |
||||
|
||||
&__text { |
||||
position: relative; |
||||
display: inline-block; |
||||
padding: 6px 0; |
||||
line-height: 18px; |
||||
cursor: default; |
||||
|
||||
input[type=radio], |
||||
input[type=checkbox] { |
||||
display: none; |
||||
} |
||||
|
||||
&.selectable { |
||||
cursor: pointer; |
||||
} |
||||
} |
||||
|
||||
&__input { |
||||
display: inline-block; |
||||
position: relative; |
||||
border: 1px solid $ui-primary-color; |
||||
box-sizing: border-box; |
||||
width: 18px; |
||||
height: 18px; |
||||
margin-right: 10px; |
||||
top: -1px; |
||||
border-radius: 4px; |
||||
vertical-align: middle; |
||||
|
||||
&.active { |
||||
border-color: $valid-value-color; |
||||
background: $valid-value-color; |
||||
} |
||||
} |
||||
|
||||
&__number { |
||||
display: inline-block; |
||||
width: 36px; |
||||
font-weight: 700; |
||||
padding: 0 10px; |
||||
text-align: right; |
||||
} |
||||
|
||||
&__footer { |
||||
padding-top: 6px; |
||||
padding-bottom: 5px; |
||||
color: $dark-text-color; |
||||
} |
||||
|
||||
&__link { |
||||
display: inline; |
||||
background: transparent; |
||||
padding: 0; |
||||
margin: 0; |
||||
border: 0; |
||||
color: $dark-text-color; |
||||
text-decoration: underline; |
||||
|
||||
&:hover, |
||||
&:focus, |
||||
&:active { |
||||
text-decoration: none; |
||||
} |
||||
} |
||||
|
||||
.button { |
||||
height: 36px; |
||||
padding: 0 16px; |
||||
margin-right: 10px; |
||||
font-size: 14px; |
||||
} |
||||
} |
Loading…
Reference in new issue