Add dropdown for boost privacy in boost confirmation modal (#15704)
* Various dropdown code quality fixes * Prepare support for privacy selection in boost modal * Add dropdown for boost privacy in boost confirmation modal
This commit is contained in:
		
							parent
							
								
									8b8c6726ce
								
							
						
					
					
						commit
						07b46cb332
					
				
					 15 changed files with 137 additions and 31 deletions
				
			
		
							
								
								
									
										29
									
								
								app/javascript/mastodon/actions/boosts.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								app/javascript/mastodon/actions/boosts.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| import { openModal } from './modal'; | ||||
| 
 | ||||
| export const BOOSTS_INIT_MODAL = 'BOOSTS_INIT_MODAL'; | ||||
| export const BOOSTS_CHANGE_PRIVACY = 'BOOSTS_CHANGE_PRIVACY'; | ||||
| 
 | ||||
| export function initBoostModal(props) { | ||||
|   return (dispatch, getState) => { | ||||
|     const default_privacy = getState().getIn(['compose', 'default_privacy']); | ||||
| 
 | ||||
|     const privacy = props.status.get('visibility') === 'private' ? 'private' : default_privacy; | ||||
| 
 | ||||
|     dispatch({ | ||||
|       type: BOOSTS_INIT_MODAL, | ||||
|       privacy | ||||
|     }); | ||||
| 
 | ||||
|     dispatch(openModal('BOOST', props)); | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| export function changeBoostPrivacy(privacy) { | ||||
|   return dispatch => { | ||||
|     dispatch({ | ||||
|       type: BOOSTS_CHANGE_PRIVACY, | ||||
|       privacy, | ||||
|     }); | ||||
|   }; | ||||
| } | ||||
|  | @ -41,11 +41,11 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; | |||
| export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; | ||||
| export const UNBOOKMARK_FAIL    = 'UNBOOKMARKED_FAIL'; | ||||
| 
 | ||||
| export function reblog(status) { | ||||
| export function reblog(status, visibility) { | ||||
|   return function (dispatch, getState) { | ||||
|     dispatch(reblogRequest(status)); | ||||
| 
 | ||||
|     api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) { | ||||
|     api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`, { visibility }).then(function (response) { | ||||
|       // The reblog API method returns a new status wrapped around the original. In this case we are only
 | ||||
|       // interested in how the original is modified, hence passing it skipping the wrapper
 | ||||
|       dispatch(importFetchedStatus(response.data.reblog)); | ||||
|  |  | |||
|  | @ -177,7 +177,6 @@ export default class Dropdown extends React.PureComponent { | |||
|     disabled: PropTypes.bool, | ||||
|     status: ImmutablePropTypes.map, | ||||
|     isUserTouching: PropTypes.func, | ||||
|     isModalOpen: PropTypes.bool.isRequired, | ||||
|     onOpen: PropTypes.func.isRequired, | ||||
|     onClose: PropTypes.func.isRequired, | ||||
|     dropdownPlacement: PropTypes.string, | ||||
|  |  | |||
|  | @ -6,7 +6,6 @@ import DropdownMenu from '../components/dropdown_menu'; | |||
| import { isUserTouching } from '../is_mobile'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   isModalOpen: state.get('modal').modalType === 'ACTIONS', | ||||
|   dropdownPlacement: state.getIn(['dropdown_menu', 'placement']), | ||||
|   openDropdownId: state.getIn(['dropdown_menu', 'openId']), | ||||
|   openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), | ||||
|  |  | |||
|  | @ -35,6 +35,7 @@ import { | |||
| } from '../actions/domain_blocks'; | ||||
| import { initMuteModal } from '../actions/mutes'; | ||||
| import { initBlockModal } from '../actions/blocks'; | ||||
| import { initBoostModal } from '../actions/boosts'; | ||||
| import { initReport } from '../actions/reports'; | ||||
| import { openModal } from '../actions/modal'; | ||||
| import { deployPictureInPicture } from '../actions/picture_in_picture'; | ||||
|  | @ -82,11 +83,11 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | |||
|     }); | ||||
|   }, | ||||
| 
 | ||||
|   onModalReblog (status) { | ||||
|   onModalReblog (status, privacy) { | ||||
|     if (status.get('reblogged')) { | ||||
|       dispatch(unreblog(status)); | ||||
|     } else { | ||||
|       dispatch(reblog(status)); | ||||
|       dispatch(reblog(status, privacy)); | ||||
|     } | ||||
|   }, | ||||
| 
 | ||||
|  | @ -94,7 +95,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | |||
|     if ((e && e.shiftKey) || !boostModal) { | ||||
|       this.onModalReblog(status); | ||||
|     } else { | ||||
|       dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); | ||||
|       dispatch(initBoostModal({ status, onReblog: this.onModalReblog })); | ||||
|     } | ||||
|   }, | ||||
| 
 | ||||
|  |  | |||
|  | @ -127,7 +127,7 @@ class PrivacyDropdownMenu extends React.PureComponent { | |||
|           // It should not be transformed when mounting because the resulting
 | ||||
|           // size will be used to determine the coordinate of the menu by
 | ||||
|           // react-overlays
 | ||||
|           <div className={`privacy-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null, zIndex: 2 }} role='listbox' ref={this.setRef}> | ||||
|           <div className={`privacy-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}> | ||||
|             {items.map(item => ( | ||||
|               <div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}> | ||||
|                 <div className='privacy-dropdown__option__icon'> | ||||
|  | @ -153,11 +153,12 @@ class PrivacyDropdown extends React.PureComponent { | |||
| 
 | ||||
|   static propTypes = { | ||||
|     isUserTouching: PropTypes.func, | ||||
|     isModalOpen: PropTypes.bool.isRequired, | ||||
|     onModalOpen: PropTypes.func, | ||||
|     onModalClose: PropTypes.func, | ||||
|     value: PropTypes.string.isRequired, | ||||
|     onChange: PropTypes.func.isRequired, | ||||
|     noDirect: PropTpes.bool, | ||||
|     container: PropTypes.func, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|  | @ -167,7 +168,7 @@ class PrivacyDropdown extends React.PureComponent { | |||
|   }; | ||||
| 
 | ||||
|   handleToggle = ({ target }) => { | ||||
|     if (this.props.isUserTouching()) { | ||||
|     if (this.props.isUserTouching && this.props.isUserTouching()) { | ||||
|       if (this.state.open) { | ||||
|         this.props.onModalClose(); | ||||
|       } else { | ||||
|  | @ -236,12 +237,17 @@ class PrivacyDropdown extends React.PureComponent { | |||
|       { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, | ||||
|       { icon: 'unlock', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, | ||||
|       { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, | ||||
|       { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, | ||||
|     ]; | ||||
| 
 | ||||
|     if (!this.props.noDirect) { | ||||
|       this.options.push( | ||||
|         { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { value, intl } = this.props; | ||||
|     const { value, container, intl } = this.props; | ||||
|     const { open, placement } = this.state; | ||||
| 
 | ||||
|     const valueOption = this.options.find(item => item.value === value); | ||||
|  | @ -264,7 +270,7 @@ class PrivacyDropdown extends React.PureComponent { | |||
|           /> | ||||
|         </div> | ||||
| 
 | ||||
|         <Overlay show={open} placement={placement} target={this}> | ||||
|         <Overlay show={open} placement={placement} target={this} container={container}> | ||||
|           <PrivacyDropdownMenu | ||||
|             items={this.options} | ||||
|             value={value} | ||||
|  |  | |||
|  | @ -5,7 +5,6 @@ import { openModal, closeModal } from '../../../actions/modal'; | |||
| import { isUserTouching } from '../../../is_mobile'; | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   isModalOpen: state.get('modal').modalType === 'ACTIONS', | ||||
|   value: state.getIn(['compose', 'privacy']), | ||||
| }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,6 +1,7 @@ | |||
| import { connect } from 'react-redux'; | ||||
| import { makeGetNotification, makeGetStatus } from '../../../selectors'; | ||||
| import Notification from '../components/notification'; | ||||
| import { initBoostModal } from '../../../actions/boosts'; | ||||
| import { openModal } from '../../../actions/modal'; | ||||
| import { mentionCompose } from '../../../actions/compose'; | ||||
| import { | ||||
|  | @ -35,8 +36,8 @@ const mapDispatchToProps = dispatch => ({ | |||
|     dispatch(mentionCompose(account, router)); | ||||
|   }, | ||||
| 
 | ||||
|   onModalReblog (status) { | ||||
|     dispatch(reblog(status)); | ||||
|   onModalReblog (status, privacy) { | ||||
|     dispatch(reblog(status, privacy)); | ||||
|   }, | ||||
| 
 | ||||
|   onReblog (status, e) { | ||||
|  | @ -46,7 +47,7 @@ const mapDispatchToProps = dispatch => ({ | |||
|       if (e.shiftKey || !boostModal) { | ||||
|         this.onModalReblog(status); | ||||
|       } else { | ||||
|         dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); | ||||
|         dispatch(initBoostModal({ status, onReblog: this.onModalReblog })); | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ import { defineMessages, injectIntl } from 'react-intl'; | |||
| import { replyCompose } from 'mastodon/actions/compose'; | ||||
| import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions'; | ||||
| import { makeGetStatus } from 'mastodon/selectors'; | ||||
| import { initBoostModal } from 'mastodon/actions/boosts'; | ||||
| import { openModal } from 'mastodon/actions/modal'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|  | @ -89,9 +90,9 @@ class Footer extends ImmutablePureComponent { | |||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   _performReblog = () => { | ||||
|     const { dispatch, status } = this.props; | ||||
|     dispatch(reblog(status)); | ||||
|   _performReblog = (status, privacy) => { | ||||
|     const { dispatch } = this.props; | ||||
|     dispatch(reblog(status, privacy)); | ||||
|   } | ||||
| 
 | ||||
|   handleReblogClick = e => { | ||||
|  | @ -100,9 +101,9 @@ class Footer extends ImmutablePureComponent { | |||
|     if (status.get('reblogged')) { | ||||
|       dispatch(unreblog(status)); | ||||
|     } else if ((e && e.shiftKey) || !boostModal) { | ||||
|       this._performReblog(); | ||||
|       this._performReblog(status); | ||||
|     } else { | ||||
|       dispatch(openModal('BOOST', { status, onReblog: this._performReblog })); | ||||
|       dispatch(initBoostModal({ status, onReblog: this._performReblog })); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -23,6 +23,7 @@ import { | |||
| } from '../../../actions/statuses'; | ||||
| import { initMuteModal } from '../../../actions/mutes'; | ||||
| import { initBlockModal } from '../../../actions/blocks'; | ||||
| import { initBoostModal } from '../../../actions/boosts'; | ||||
| import { initReport } from '../../../actions/reports'; | ||||
| import { openModal } from '../../../actions/modal'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
|  | @ -68,8 +69,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | |||
|     }); | ||||
|   }, | ||||
| 
 | ||||
|   onModalReblog (status) { | ||||
|     dispatch(reblog(status)); | ||||
|   onModalReblog (status, privacy) { | ||||
|     dispatch(reblog(status, privacy)); | ||||
|   }, | ||||
| 
 | ||||
|   onReblog (status, e) { | ||||
|  | @ -79,7 +80,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | |||
|       if (e.shiftKey || !boostModal) { | ||||
|         this.onModalReblog(status); | ||||
|       } else { | ||||
|         dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); | ||||
|         dispatch(initBoostModal({ status, onReblog: this.onModalReblog })); | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|  |  | |||
|  | @ -42,6 +42,7 @@ import { | |||
| } from '../../actions/domain_blocks'; | ||||
| import { initMuteModal } from '../../actions/mutes'; | ||||
| import { initBlockModal } from '../../actions/blocks'; | ||||
| import { initBoostModal } from '../../actions/boosts'; | ||||
| import { initReport } from '../../actions/reports'; | ||||
| import { makeGetStatus, makeGetPictureInPicture } from '../../selectors'; | ||||
| import { ScrollContainer } from 'react-router-scroll-4'; | ||||
|  | @ -234,8 +235,8 @@ class Status extends ImmutablePureComponent { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   handleModalReblog = (status) => { | ||||
|     this.props.dispatch(reblog(status)); | ||||
|   handleModalReblog = (status, privacy) => { | ||||
|     this.props.dispatch(reblog(status, privacy)); | ||||
|   } | ||||
| 
 | ||||
|   handleReblogClick = (status, e) => { | ||||
|  | @ -245,7 +246,7 @@ class Status extends ImmutablePureComponent { | |||
|       if ((e && e.shiftKey) || !boostModal) { | ||||
|         this.handleModalReblog(status); | ||||
|       } else { | ||||
|         this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog })); | ||||
|         this.props.dispatch(initBoostModal({ status, onReblog: this.handleModalReblog })); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  |  | |||
|  | @ -1,4 +1,5 @@ | |||
| import React from 'react'; | ||||
| import { connect } from 'react-redux'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
|  | @ -10,7 +11,9 @@ import DisplayName from '../../../components/display_name'; | |||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import Icon from 'mastodon/components/icon'; | ||||
| import AttachmentList from 'mastodon/components/attachment_list'; | ||||
| import PrivacyDropdown from 'mastodon/features/compose/components/privacy_dropdown'; | ||||
| import classNames from 'classnames'; | ||||
| import { changeBoostPrivacy } from 'mastodon/actions/boosts'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, | ||||
|  | @ -21,7 +24,22 @@ const messages = defineMessages({ | |||
|   direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, | ||||
| }); | ||||
| 
 | ||||
| export default @injectIntl | ||||
| const mapStateToProps = state => { | ||||
|   return { | ||||
|     privacy: state.getIn(['boosts', 'new', 'privacy']), | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| const mapDispatchToProps = dispatch => { | ||||
|   return { | ||||
|     onChangeBoostPrivacy(value) { | ||||
|       dispatch(changeBoostPrivacy(value)); | ||||
|     }, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export default @connect(mapStateToProps, mapDispatchToProps) | ||||
| @injectIntl | ||||
| class BoostModal extends ImmutablePureComponent { | ||||
| 
 | ||||
|   static contextTypes = { | ||||
|  | @ -32,6 +50,8 @@ class BoostModal extends ImmutablePureComponent { | |||
|     status: ImmutablePropTypes.map.isRequired, | ||||
|     onReblog: PropTypes.func.isRequired, | ||||
|     onClose: PropTypes.func.isRequired, | ||||
|     onChangeBoostPrivacy: PropTypes.func.isRequired, | ||||
|     privacy: PropTypes.string.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|  | @ -40,7 +60,7 @@ class BoostModal extends ImmutablePureComponent { | |||
|   } | ||||
| 
 | ||||
|   handleReblog = () => { | ||||
|     this.props.onReblog(this.props.status); | ||||
|     this.props.onReblog(this.props.status, this.props.privacy); | ||||
|     this.props.onClose(); | ||||
|   } | ||||
| 
 | ||||
|  | @ -52,12 +72,16 @@ class BoostModal extends ImmutablePureComponent { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   _findContainer = () => { | ||||
|     return document.getElementsByClassName('modal-root__container')[0]; | ||||
|   }; | ||||
| 
 | ||||
|   setRef = (c) => { | ||||
|     this.button = c; | ||||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { status, intl } = this.props; | ||||
|     const { status, privacy, intl } = this.props; | ||||
|     const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog; | ||||
| 
 | ||||
|     const visibilityIconInfo = { | ||||
|  | @ -102,6 +126,14 @@ class BoostModal extends ImmutablePureComponent { | |||
| 
 | ||||
|         <div className='boost-modal__action-bar'> | ||||
|           <div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} /></div> | ||||
|           {status.get('visibility') !== 'private' && !status.get('reblogged') && ( | ||||
|             <PrivacyDropdown | ||||
|               noDirect | ||||
|               value={privacy} | ||||
|               container={this._findContainer} | ||||
|               onChange={this.props.onChangeBoostPrivacy} | ||||
|             /> | ||||
|           )} | ||||
|           <Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} ref={this.setRef} /> | ||||
|         </div> | ||||
|       </div> | ||||
|  |  | |||
							
								
								
									
										25
									
								
								app/javascript/mastodon/reducers/boosts.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/javascript/mastodon/reducers/boosts.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | |||
| import Immutable from 'immutable'; | ||||
| 
 | ||||
| import { | ||||
|   BOOSTS_INIT_MODAL, | ||||
|   BOOSTS_CHANGE_PRIVACY, | ||||
| } from 'mastodon/actions/boosts'; | ||||
| 
 | ||||
| const initialState = Immutable.Map({ | ||||
|   new: Immutable.Map({ | ||||
|     privacy: 'public', | ||||
|   }), | ||||
| }); | ||||
| 
 | ||||
| export default function mutes(state = initialState, action) { | ||||
|   switch (action.type) { | ||||
|   case BOOSTS_INIT_MODAL: | ||||
|     return state.withMutations((state) => { | ||||
|       state.setIn(['new', 'privacy'], action.privacy); | ||||
|     }); | ||||
|   case BOOSTS_CHANGE_PRIVACY: | ||||
|     return state.setIn(['new', 'privacy'], action.privacy); | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
| } | ||||
|  | @ -16,6 +16,7 @@ import push_notifications from './push_notifications'; | |||
| import status_lists from './status_lists'; | ||||
| import mutes from './mutes'; | ||||
| import blocks from './blocks'; | ||||
| import boosts from './boosts'; | ||||
| import reports from './reports'; | ||||
| import contexts from './contexts'; | ||||
| import compose from './compose'; | ||||
|  | @ -57,6 +58,7 @@ const reducers = { | |||
|   push_notifications, | ||||
|   mutes, | ||||
|   blocks, | ||||
|   boosts, | ||||
|   reports, | ||||
|   contexts, | ||||
|   compose, | ||||
|  |  | |||
|  | @ -4209,6 +4209,7 @@ a.status-card.compact:hover { | |||
|   border-radius: 4px; | ||||
|   margin-left: 40px; | ||||
|   overflow: hidden; | ||||
|   z-index: 2; | ||||
| 
 | ||||
|   &.top { | ||||
|     transform-origin: 50% 100%; | ||||
|  | @ -4219,6 +4220,15 @@ a.status-card.compact:hover { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| .modal-root__container .privacy-dropdown { | ||||
|   flex-grow: 0; | ||||
| } | ||||
| 
 | ||||
| .modal-root__container .privacy-dropdown__dropdown { | ||||
|   pointer-events: auto; | ||||
|   z-index: 9999; | ||||
| } | ||||
| 
 | ||||
| .privacy-dropdown__option { | ||||
|   color: $inverted-text-color; | ||||
|   padding: 10px; | ||||
|  |  | |||
		Reference in a new issue