* Add actions and reducers for polls * Add poll button * Disable media upload if poll enabled * Add poll form * Make delete & redraft work with pollsmaster
@@ -51,6 +51,13 @@ export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST' | |||
export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; | |||
export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; | |||
export const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD'; | |||
export const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE'; | |||
export const COMPOSE_POLL_OPTION_ADD = 'COMPOSE_POLL_OPTION_ADD'; | |||
export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE'; | |||
export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE'; | |||
export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE'; | |||
const messages = defineMessages({ | |||
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, | |||
}); | |||
@@ -131,6 +138,7 @@ export function submitCompose(routerHistory) { | |||
sensitive: getState().getIn(['compose', 'sensitive']), | |||
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), | |||
visibility: getState().getIn(['compose', 'privacy']), | |||
poll: getState().getIn(['compose', 'poll'], null), | |||
}, { | |||
headers: { | |||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), | |||
@@ -484,4 +492,46 @@ export function changeComposing(value) { | |||
type: COMPOSE_COMPOSING_CHANGE, | |||
value, | |||
}; | |||
} | |||
}; | |||
export function addPoll() { | |||
return { | |||
type: COMPOSE_POLL_ADD, | |||
}; | |||
}; | |||
export function removePoll() { | |||
return { | |||
type: COMPOSE_POLL_REMOVE, | |||
}; | |||
}; | |||
export function addPollOption(title) { | |||
return { | |||
type: COMPOSE_POLL_OPTION_ADD, | |||
title, | |||
}; | |||
}; | |||
export function changePollOption(index, title) { | |||
return { | |||
type: COMPOSE_POLL_OPTION_CHANGE, | |||
index, | |||
title, | |||
}; | |||
}; | |||
export function removePollOption(index) { | |||
return { | |||
type: COMPOSE_POLL_OPTION_REMOVE, | |||
index, | |||
}; | |||
}; | |||
export function changePollSettings(expiresIn, isMultiple) { | |||
return { | |||
type: COMPOSE_POLL_SETTINGS_CHANGE, | |||
expiresIn, | |||
isMultiple, | |||
}; | |||
}; |
@@ -140,7 +140,11 @@ export function redraft(status) { | |||
export function deleteStatus(id, router, withRedraft = false) { | |||
return (dispatch, getState) => { | |||
const status = getState().getIn(['statuses', id]); | |||
let status = getState().getIn(['statuses', id]); | |||
if (status.get('poll')) { | |||
status = status.set('poll', getState().getIn(['polls', status.get('poll')])); | |||
} | |||
dispatch(deleteStatusRequest(id)); | |||
@@ -5,12 +5,14 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import ReplyIndicatorContainer from '../containers/reply_indicator_container'; | |||
import AutosuggestTextarea from '../../../components/autosuggest_textarea'; | |||
import PollButtonContainer from '../containers/poll_button_container'; | |||
import UploadButtonContainer from '../containers/upload_button_container'; | |||
import { defineMessages, injectIntl } from 'react-intl'; | |||
import SpoilerButtonContainer from '../containers/spoiler_button_container'; | |||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; | |||
import SensitiveButtonContainer from '../containers/sensitive_button_container'; | |||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; | |||
import PollFormContainer from '../containers/poll_form_container'; | |||
import UploadFormContainer from '../containers/upload_form_container'; | |||
import WarningContainer from '../containers/warning_container'; | |||
import { isMobile } from '../../../is_mobile'; | |||
@@ -205,11 +207,13 @@ class ComposeForm extends ImmutablePureComponent { | |||
<div className='compose-form__modifiers'> | |||
<UploadFormContainer /> | |||
<PollFormContainer /> | |||
</div> | |||
<div className='compose-form__buttons-wrapper'> | |||
<div className='compose-form__buttons'> | |||
<UploadButtonContainer /> | |||
<PollButtonContainer /> | |||
<PrivacyDropdownContainer /> | |||
<SensitiveButtonContainer /> | |||
<SpoilerButtonContainer /> | |||
@@ -0,0 +1,55 @@ | |||
import React from 'react'; | |||
import IconButton from '../../../components/icon_button'; | |||
import PropTypes from 'prop-types'; | |||
import { defineMessages, injectIntl } from 'react-intl'; | |||
const messages = defineMessages({ | |||
add_poll: { id: 'poll_button.add_poll', defaultMessage: 'Add a poll' }, | |||
remove_poll: { id: 'poll_button.remove_poll', defaultMessage: 'Remove poll' }, | |||
}); | |||
const iconStyle = { | |||
height: null, | |||
lineHeight: '27px', | |||
}; | |||
export default | |||
@injectIntl | |||
class PollButton extends React.PureComponent { | |||
static propTypes = { | |||
disabled: PropTypes.bool, | |||
unavailable: PropTypes.bool, | |||
active: PropTypes.bool, | |||
onClick: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
}; | |||
handleClick = () => { | |||
this.props.onClick(); | |||
} | |||
render () { | |||
const { intl, active, unavailable, disabled } = this.props; | |||
if (unavailable) { | |||
return null; | |||
} | |||
return ( | |||
<div className='compose-form__poll-button'> | |||
<IconButton | |||
icon='tasks' | |||
title={intl.formatMessage(active ? messages.remove_poll : messages.add_poll)} | |||
disabled={disabled} | |||
onClick={this.handleClick} | |||
className={`compose-form__poll-button-icon ${active ? 'active' : ''}`} | |||
size={18} | |||
inverted | |||
style={iconStyle} | |||
/> | |||
</div> | |||
); | |||
} | |||
} |
@@ -0,0 +1,121 @@ | |||
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 IconButton from 'mastodon/components/icon_button'; | |||
import Icon from 'mastodon/components/icon'; | |||
import classNames from 'classnames'; | |||
const messages = defineMessages({ | |||
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' }, | |||
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' }, | |||
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' }, | |||
poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' }, | |||
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, | |||
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, | |||
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, | |||
}); | |||
@injectIntl | |||
class Option extends React.PureComponent { | |||
static propTypes = { | |||
title: PropTypes.string.isRequired, | |||
index: PropTypes.number.isRequired, | |||
isPollMultiple: PropTypes.bool, | |||
onChange: PropTypes.func.isRequired, | |||
onRemove: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
}; | |||
handleOptionTitleChange = e => { | |||
this.props.onChange(this.props.index, e.target.value); | |||
}; | |||
handleOptionRemove = () => { | |||
this.props.onRemove(this.props.index); | |||
}; | |||
render () { | |||
const { isPollMultiple, title, index, intl } = this.props; | |||
return ( | |||
<li> | |||
<label className='poll__text editable'> | |||
<span className={classNames('poll__input', { checkbox: isPollMultiple })} /> | |||
<input | |||
type='text' | |||
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })} | |||
maxlength={25} | |||
value={title} | |||
onChange={this.handleOptionTitleChange} | |||
/> | |||
</label> | |||
<div className='poll__cancel'> | |||
<IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' onClick={this.handleOptionRemove} /> | |||
</div> | |||
</li> | |||
); | |||
} | |||
} | |||
export default | |||
@injectIntl | |||
class PollForm extends ImmutablePureComponent { | |||
static propTypes = { | |||
options: ImmutablePropTypes.list, | |||
expiresIn: PropTypes.number, | |||
isMultiple: PropTypes.bool, | |||
onChangeOption: PropTypes.func.isRequired, | |||
onAddOption: PropTypes.func.isRequired, | |||
onRemoveOption: PropTypes.func.isRequired, | |||
onChangeSettings: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
}; | |||
handleAddOption = () => { | |||
this.props.onAddOption(''); | |||
}; | |||
handleSelectDuration = e => { | |||
this.props.onChangeSettings(e.target.value, this.props.isMultiple); | |||
}; | |||
render () { | |||
const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl } = this.props; | |||
if (!options) { | |||
return null; | |||
} | |||
return ( | |||
<div className='compose-form__poll-wrapper'> | |||
<ul> | |||
{options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} />)} | |||
</ul> | |||
<div className='poll__footer'> | |||
{options.size < 4 && ( | |||
<button className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button> | |||
)} | |||
<select value={expiresIn} onChange={this.handleSelectDuration}> | |||
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> | |||
<option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> | |||
<option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> | |||
<option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> | |||
<option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> | |||
<option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> | |||
<option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> | |||
</select> | |||
</div> | |||
</div> | |||
); | |||
} | |||
} |
@@ -29,6 +29,7 @@ class UploadButton extends ImmutablePureComponent { | |||
static propTypes = { | |||
disabled: PropTypes.bool, | |||
unavailable: PropTypes.bool, | |||
onSelectFile: PropTypes.func.isRequired, | |||
style: PropTypes.object, | |||
resetFileKey: PropTypes.number, | |||
@@ -51,8 +52,11 @@ class UploadButton extends ImmutablePureComponent { | |||
} | |||
render () { | |||
const { intl, resetFileKey, unavailable, disabled, acceptContentTypes } = this.props; | |||
const { intl, resetFileKey, disabled, acceptContentTypes } = this.props; | |||
if (unavailable) { | |||
return null; | |||
} | |||
return ( | |||
<div className='compose-form__upload-button'> | |||
@@ -0,0 +1,24 @@ | |||
import { connect } from 'react-redux'; | |||
import PollButton from '../components/poll_button'; | |||
import { addPoll, removePoll } from '../../../actions/compose'; | |||
const mapStateToProps = state => ({ | |||
unavailable: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 0), | |||
active: state.getIn(['compose', 'poll']) !== null, | |||
}); | |||
const mapDispatchToProps = dispatch => ({ | |||
onClick () { | |||
dispatch((_, getState) => { | |||
if (getState().getIn(['compose', 'poll'])) { | |||
dispatch(removePoll()); | |||
} else { | |||
dispatch(addPoll()); | |||
} | |||
}); | |||
}, | |||
}); | |||
export default connect(mapStateToProps, mapDispatchToProps)(PollButton); |
@@ -0,0 +1,29 @@ | |||
import { connect } from 'react-redux'; | |||
import PollForm from '../components/poll_form'; | |||
import { addPollOption, removePollOption, changePollOption, changePollSettings } from '../../../actions/compose'; | |||
const mapStateToProps = state => ({ | |||
options: state.getIn(['compose', 'poll', 'options']), | |||
expiresIn: state.getIn(['compose', 'poll', 'expires_in']), | |||
isMultiple: state.getIn(['compose', 'poll', 'multiple']), | |||
}); | |||
const mapDispatchToProps = dispatch => ({ | |||
onAddOption(title) { | |||
dispatch(addPollOption(title)); | |||
}, | |||
onRemoveOption(index) { | |||
dispatch(removePollOption(index)); | |||
}, | |||
onChangeOption(index, title) { | |||
dispatch(changePollOption(index, title)); | |||
}, | |||
onChangeSettings(expiresIn, isMultiple) { | |||
dispatch(changePollSettings(expiresIn, isMultiple)); | |||
}, | |||
}); | |||
export default connect(mapStateToProps, mapDispatchToProps)(PollForm); |
@@ -4,6 +4,7 @@ import { uploadCompose } from '../../../actions/compose'; | |||
const mapStateToProps = state => ({ | |||
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), | |||
unavailable: state.getIn(['compose', 'poll']) !== null, | |||
resetFileKey: state.getIn(['compose', 'resetFileKey']), | |||
}); | |||
@@ -29,6 +29,12 @@ import { | |||
COMPOSE_UPLOAD_CHANGE_SUCCESS, | |||
COMPOSE_UPLOAD_CHANGE_FAIL, | |||
COMPOSE_RESET, | |||
COMPOSE_POLL_ADD, | |||
COMPOSE_POLL_REMOVE, | |||
COMPOSE_POLL_OPTION_ADD, | |||
COMPOSE_POLL_OPTION_CHANGE, | |||
COMPOSE_POLL_OPTION_REMOVE, | |||
COMPOSE_POLL_SETTINGS_CHANGE, | |||
} from '../actions/compose'; | |||
import { TIMELINE_DELETE } from '../actions/timelines'; | |||
import { STORE_HYDRATE } from '../actions/store'; | |||
@@ -55,6 +61,7 @@ const initialState = ImmutableMap({ | |||
is_uploading: false, | |||
progress: 0, | |||
media_attachments: ImmutableList(), | |||
poll: null, | |||
suggestion_token: null, | |||
suggestions: ImmutableList(), | |||
default_privacy: 'public', | |||
@@ -64,6 +71,12 @@ const initialState = ImmutableMap({ | |||
tagHistory: ImmutableList(), | |||
}); | |||
const initialPoll = ImmutableMap({ | |||
options: ImmutableList(['', '']), | |||
expires_in: 24 * 3600, | |||
multiple: false, | |||
}); | |||
function statusToTextMentions(state, status) { | |||
let set = ImmutableOrderedSet([]); | |||
@@ -85,6 +98,7 @@ function clearAll(state) { | |||
map.set('privacy', state.get('default_privacy')); | |||
map.set('sensitive', false); | |||
map.update('media_attachments', list => list.clear()); | |||
map.set('poll', null); | |||
map.set('idempotencyKey', uuid()); | |||
}); | |||
}; | |||
@@ -247,6 +261,7 @@ export default function compose(state = initialState, action) { | |||
map.set('spoiler', false); | |||
map.set('spoiler_text', ''); | |||
map.set('privacy', state.get('default_privacy')); | |||
map.set('poll', null); | |||
map.set('idempotencyKey', uuid()); | |||
}); | |||
case COMPOSE_SUBMIT_REQUEST: | |||
@@ -329,7 +344,27 @@ export default function compose(state = initialState, action) { | |||
map.set('spoiler', false); | |||
map.set('spoiler_text', ''); | |||
} | |||
if (action.status.get('poll')) { | |||
map.set('poll', ImmutableMap({ | |||
options: action.status.getIn(['poll', 'options']).map(x => x.get('title')), | |||
multiple: action.status.getIn(['poll', 'multiple']), | |||
expires_in: 24 * 3600, | |||
})); | |||
} | |||
}); | |||
case COMPOSE_POLL_ADD: | |||
return state.set('poll', initialPoll); | |||
case COMPOSE_POLL_REMOVE: | |||
return state.set('poll', null); | |||
case COMPOSE_POLL_OPTION_ADD: | |||
return state.updateIn(['poll', 'options'], options => options.push(action.title)); | |||
case COMPOSE_POLL_OPTION_CHANGE: | |||
return state.setIn(['poll', 'options', action.index], action.title); | |||
case COMPOSE_POLL_OPTION_REMOVE: | |||
return state.updateIn(['poll', 'options'], options => options.delete(action.index)); | |||
case COMPOSE_POLL_SETTINGS_CHANGE: | |||
return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple)); | |||
default: | |||
return state; | |||
} | |||
@@ -33,9 +33,34 @@ | |||
display: none; | |||
} | |||
input[type=text] { | |||
display: block; | |||
box-sizing: border-box; | |||
flex: 1 1 auto; | |||
width: 20px; | |||
font-size: 14px; | |||
color: $inverted-text-color; | |||
display: block; | |||
outline: 0; | |||
font-family: inherit; | |||
background: $simple-background-color; | |||
border: 1px solid darken($simple-background-color, 14%); | |||
border-radius: 4px; | |||
padding: 6px 10px; | |||
&:focus { | |||
border-color: $highlight-text-color; | |||
} | |||
} | |||
&.selectable { | |||
cursor: pointer; | |||
} | |||
&.editable { | |||
display: flex; | |||
align-items: center; | |||
} | |||
} | |||
&__input { | |||
@@ -45,6 +70,7 @@ | |||
box-sizing: border-box; | |||
width: 18px; | |||
height: 18px; | |||
flex: 0 0 auto; | |||
margin-right: 10px; | |||
top: -1px; | |||
border-radius: 50%; | |||
@@ -98,3 +124,65 @@ | |||
font-size: 14px; | |||
} | |||
} | |||
.compose-form__poll-wrapper { | |||
border-top: 1px solid darken($simple-background-color, 8%); | |||
ul { | |||
padding: 10px; | |||
} | |||
.poll__footer { | |||
border-top: 1px solid darken($simple-background-color, 8%); | |||
padding: 10px; | |||
display: flex; | |||
align-items: center; | |||
button, | |||
select { | |||
flex: 1 1 50%; | |||
} | |||
} | |||
.button.button-secondary { | |||
font-size: 14px; | |||
font-weight: 400; | |||
padding: 6px 10px; | |||
height: auto; | |||
line-height: inherit; | |||
color: $action-button-color; | |||
border-color: $action-button-color; | |||
margin-right: 5px; | |||
} | |||
li { | |||
display: flex; | |||
align-items: center; | |||
.poll__text { | |||
flex: 0 0 auto; | |||
width: calc(100% - (23px + 6px)); | |||
margin-right: 6px; | |||
} | |||
} | |||
select { | |||
appearance: none; | |||
box-sizing: border-box; | |||
font-size: 14px; | |||
color: $inverted-text-color; | |||
display: inline-block; | |||
width: auto; | |||
outline: 0; | |||
font-family: inherit; | |||
background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px; | |||
border: 1px solid darken($simple-background-color, 14%); | |||
border-radius: 4px; | |||
padding: 6px 10px; | |||
padding-right: 30px; | |||
} | |||
.icon-button.disabled { | |||
color: darken($simple-background-color, 14%); | |||
} | |||
} |