Add some UI for user-defined domain blocks (#6628)
* Keep list of blocked domains Might be overkill, but I'm trying to follow the same logic as for blocked users * Add basic domain block UI * Add the domain blocks UI to Getting Started * Fix undefined URL in `fetchDomainBlocks` * Update all known users' domain_blocking relationship instead of just one's
This commit is contained in:
		
							parent
							
								
									47cee7cc8e
								
							
						
					
					
						commit
						a6c129ddbd
					
				
					 13 changed files with 271 additions and 17 deletions
				
			
		|  | @ -12,12 +12,18 @@ export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST'; | |||
| export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS'; | ||||
| export const DOMAIN_BLOCKS_FETCH_FAIL    = 'DOMAIN_BLOCKS_FETCH_FAIL'; | ||||
| 
 | ||||
| export function blockDomain(domain, accountId) { | ||||
| export const DOMAIN_BLOCKS_EXPAND_REQUEST = 'DOMAIN_BLOCKS_EXPAND_REQUEST'; | ||||
| export const DOMAIN_BLOCKS_EXPAND_SUCCESS = 'DOMAIN_BLOCKS_EXPAND_SUCCESS'; | ||||
| export const DOMAIN_BLOCKS_EXPAND_FAIL    = 'DOMAIN_BLOCKS_EXPAND_FAIL'; | ||||
| 
 | ||||
| export function blockDomain(domain) { | ||||
|   return (dispatch, getState) => { | ||||
|     dispatch(blockDomainRequest(domain)); | ||||
| 
 | ||||
|     api(getState).post('/api/v1/domain_blocks', { domain }).then(() => { | ||||
|       dispatch(blockDomainSuccess(domain, accountId)); | ||||
|       const at_domain = '@' + domain; | ||||
|       const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); | ||||
|       dispatch(blockDomainSuccess(domain, accounts)); | ||||
|     }).catch(err => { | ||||
|       dispatch(blockDomainFail(domain, err)); | ||||
|     }); | ||||
|  | @ -31,11 +37,11 @@ export function blockDomainRequest(domain) { | |||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function blockDomainSuccess(domain, accountId) { | ||||
| export function blockDomainSuccess(domain, accounts) { | ||||
|   return { | ||||
|     type: DOMAIN_BLOCK_SUCCESS, | ||||
|     domain, | ||||
|     accountId, | ||||
|     accounts, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
|  | @ -47,12 +53,14 @@ export function blockDomainFail(domain, error) { | |||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function unblockDomain(domain, accountId) { | ||||
| export function unblockDomain(domain) { | ||||
|   return (dispatch, getState) => { | ||||
|     dispatch(unblockDomainRequest(domain)); | ||||
| 
 | ||||
|     api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => { | ||||
|       dispatch(unblockDomainSuccess(domain, accountId)); | ||||
|       const at_domain = '@' + domain; | ||||
|       const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id')); | ||||
|       dispatch(unblockDomainSuccess(domain, accounts)); | ||||
|     }).catch(err => { | ||||
|       dispatch(unblockDomainFail(domain, err)); | ||||
|     }); | ||||
|  | @ -66,11 +74,11 @@ export function unblockDomainRequest(domain) { | |||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function unblockDomainSuccess(domain, accountId) { | ||||
| export function unblockDomainSuccess(domain, accounts) { | ||||
|   return { | ||||
|     type: DOMAIN_UNBLOCK_SUCCESS, | ||||
|     domain, | ||||
|     accountId, | ||||
|     accounts, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
|  | @ -86,7 +94,7 @@ export function fetchDomainBlocks() { | |||
|   return (dispatch, getState) => { | ||||
|     dispatch(fetchDomainBlocksRequest()); | ||||
| 
 | ||||
|     api(getState).get().then(response => { | ||||
|     api(getState).get('/api/v1/domain_blocks').then(response => { | ||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||
|       dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null)); | ||||
|     }).catch(err => { | ||||
|  | @ -115,3 +123,43 @@ export function fetchDomainBlocksFail(error) { | |||
|     error, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function expandDomainBlocks() { | ||||
|   return (dispatch, getState) => { | ||||
|     const url = getState().getIn(['domain_lists', 'blocks', 'next']); | ||||
| 
 | ||||
|     if (url === null) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     dispatch(expandDomainBlocksRequest()); | ||||
| 
 | ||||
|     api(getState).get(url).then(response => { | ||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||
|       dispatch(expandDomainBlocksSuccess(response.data, next ? next.uri : null)); | ||||
|     }).catch(err => { | ||||
|       dispatch(expandDomainBlocksFail(err)); | ||||
|     }); | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function expandDomainBlocksRequest() { | ||||
|   return { | ||||
|     type: DOMAIN_BLOCKS_EXPAND_REQUEST, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function expandDomainBlocksSuccess(domains, next) { | ||||
|   return { | ||||
|     type: DOMAIN_BLOCKS_EXPAND_SUCCESS, | ||||
|     domains, | ||||
|     next, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export function expandDomainBlocksFail(error) { | ||||
|   return { | ||||
|     type: DOMAIN_BLOCKS_EXPAND_FAIL, | ||||
|     error, | ||||
|   }; | ||||
| }; | ||||
|  |  | |||
							
								
								
									
										42
									
								
								app/javascript/mastodon/components/domain.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								app/javascript/mastodon/components/domain.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | |||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import IconButton from './icon_button'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, | ||||
| }); | ||||
| 
 | ||||
| @injectIntl | ||||
| export default class Account extends ImmutablePureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     domain: PropTypes.string, | ||||
|     onUnblockDomain: PropTypes.func.isRequired, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   handleDomainUnblock = () => { | ||||
|     this.props.onUnblockDomain(this.props.domain); | ||||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { domain, intl } = this.props; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='domain'> | ||||
|         <div className='domain__wrapper'> | ||||
|           <span className='domain__domain-name'> | ||||
|             <strong>{domain}</strong> | ||||
|           </span> | ||||
| 
 | ||||
|           <div className='domain__buttons'> | ||||
|             <IconButton active icon='unlock-alt' title={intl.formatMessage(messages.unblockDomain, { domain })} onClick={this.handleDomainUnblock} /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										33
									
								
								app/javascript/mastodon/containers/domain_container.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								app/javascript/mastodon/containers/domain_container.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,33 @@ | |||
| import React from 'react'; | ||||
| import { connect } from 'react-redux'; | ||||
| import { blockDomain, unblockDomain } from '../actions/domain_blocks'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import Domain from '../components/domain'; | ||||
| import { openModal } from '../actions/modal'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, | ||||
| }); | ||||
| 
 | ||||
| const makeMapStateToProps = () => { | ||||
|   const mapStateToProps = (state, { }) => ({ | ||||
|   }); | ||||
| 
 | ||||
|   return mapStateToProps; | ||||
| }; | ||||
| 
 | ||||
| const mapDispatchToProps = (dispatch, { intl }) => ({ | ||||
|   onBlockDomain (domain) { | ||||
|     dispatch(openModal('CONFIRM', { | ||||
|       message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />, | ||||
|       confirm: intl.formatMessage(messages.blockDomainConfirm), | ||||
|       onConfirm: () => dispatch(blockDomain(domain)), | ||||
|     })); | ||||
|   }, | ||||
| 
 | ||||
|   onUnblockDomain (domain) { | ||||
|     dispatch(unblockDomain(domain)); | ||||
|   }, | ||||
| }); | ||||
| 
 | ||||
| export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Domain)); | ||||
|  | @ -62,7 +62,7 @@ export default class Header extends ImmutablePureComponent { | |||
| 
 | ||||
|     if (!domain) return; | ||||
| 
 | ||||
|     this.props.onBlockDomain(domain, this.props.account.get('id')); | ||||
|     this.props.onBlockDomain(domain); | ||||
|   } | ||||
| 
 | ||||
|   handleUnblockDomain = () => { | ||||
|  | @ -70,7 +70,7 @@ export default class Header extends ImmutablePureComponent { | |||
| 
 | ||||
|     if (!domain) return; | ||||
| 
 | ||||
|     this.props.onUnblockDomain(domain, this.props.account.get('id')); | ||||
|     this.props.onUnblockDomain(domain); | ||||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|  |  | |||
|  | @ -94,16 +94,16 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | |||
|     } | ||||
|   }, | ||||
| 
 | ||||
|   onBlockDomain (domain, accountId) { | ||||
|   onBlockDomain (domain) { | ||||
|     dispatch(openModal('CONFIRM', { | ||||
|       message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />, | ||||
|       confirm: intl.formatMessage(messages.blockDomainConfirm), | ||||
|       onConfirm: () => dispatch(blockDomain(domain, accountId)), | ||||
|       onConfirm: () => dispatch(blockDomain(domain)), | ||||
|     })); | ||||
|   }, | ||||
| 
 | ||||
|   onUnblockDomain (domain, accountId) { | ||||
|     dispatch(unblockDomain(domain, accountId)); | ||||
|   onUnblockDomain (domain) { | ||||
|     dispatch(unblockDomain(domain)); | ||||
|   }, | ||||
| 
 | ||||
| }); | ||||
|  |  | |||
							
								
								
									
										66
									
								
								app/javascript/mastodon/features/domain_blocks/index.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								app/javascript/mastodon/features/domain_blocks/index.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,66 @@ | |||
| import React from 'react'; | ||||
| import { connect } from 'react-redux'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import LoadingIndicator from '../../components/loading_indicator'; | ||||
| import Column from '../ui/components/column'; | ||||
| import ColumnBackButtonSlim from '../../components/column_back_button_slim'; | ||||
| import DomainContainer from '../../containers/domain_container'; | ||||
| import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import { debounce } from 'lodash'; | ||||
| import ScrollableList from '../../components/scrollable_list'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   heading: { id: 'column.domain_blocks', defaultMessage: 'Hidden domains' }, | ||||
|   unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, | ||||
| }); | ||||
| 
 | ||||
| const mapStateToProps = state => ({ | ||||
|   domains: state.getIn(['domain_lists', 'blocks', 'items']), | ||||
| }); | ||||
| 
 | ||||
| @connect(mapStateToProps) | ||||
| @injectIntl | ||||
| export default class Blocks extends ImmutablePureComponent { | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     params: PropTypes.object.isRequired, | ||||
|     dispatch: PropTypes.func.isRequired, | ||||
|     domains: ImmutablePropTypes.list, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   componentWillMount () { | ||||
|     this.props.dispatch(fetchDomainBlocks()); | ||||
|   } | ||||
| 
 | ||||
|   handleLoadMore = debounce(() => { | ||||
|     this.props.dispatch(expandDomainBlocks()); | ||||
|   }, 300, { leading: true }); | ||||
| 
 | ||||
|   render () { | ||||
|     const { intl, domains } = this.props; | ||||
| 
 | ||||
|     if (!domains) { | ||||
|       return ( | ||||
|         <Column> | ||||
|           <LoadingIndicator /> | ||||
|         </Column> | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <Column icon='ban' heading={intl.formatMessage(messages.heading)}> | ||||
|         <ColumnBackButtonSlim /> | ||||
|         <ScrollableList scrollKey='domain_blocks' onLoadMore={this.handleLoadMore}> | ||||
|           {domains.map(domain => | ||||
|             <DomainContainer key={domain} domain={domain} /> | ||||
|           )} | ||||
|         </ScrollableList> | ||||
|       </Column> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|  | @ -24,6 +24,7 @@ const messages = defineMessages({ | |||
|   sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, | ||||
|   favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, | ||||
|   blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, | ||||
|   domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' }, | ||||
|   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, | ||||
|   info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' }, | ||||
|   pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, | ||||
|  | @ -121,6 +122,7 @@ export default class GettingStarted extends ImmutablePureComponent { | |||
|           <ColumnLink icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' /> | ||||
|           <ColumnLink icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' /> | ||||
|           <ColumnLink icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' /> | ||||
|           <ColumnLink icon='ban' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' /> | ||||
|           <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> | ||||
|           <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> | ||||
|         </div> | ||||
|  |  | |||
|  | @ -37,6 +37,7 @@ import { | |||
|   FavouritedStatuses, | ||||
|   ListTimeline, | ||||
|   Blocks, | ||||
|   DomainBlocks, | ||||
|   Mutes, | ||||
|   PinnedStatuses, | ||||
|   Lists, | ||||
|  | @ -158,6 +159,7 @@ class SwitchingColumnsArea extends React.PureComponent { | |||
| 
 | ||||
|           <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} /> | ||||
|           <WrappedRoute path='/blocks' component={Blocks} content={children} /> | ||||
|           <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} /> | ||||
|           <WrappedRoute path='/mutes' component={Mutes} content={children} /> | ||||
|           <WrappedRoute path='/lists' component={Lists} content={children} /> | ||||
| 
 | ||||
|  |  | |||
|  | @ -90,6 +90,10 @@ export function Blocks () { | |||
|   return import(/* webpackChunkName: "features/blocks" */'../../blocks'); | ||||
| } | ||||
| 
 | ||||
| export function DomainBlocks () { | ||||
|   return import(/* webpackChunkName: "features/domain_blocks" */'../../domain_blocks'); | ||||
| } | ||||
| 
 | ||||
| export function Mutes () { | ||||
|   return import(/* webpackChunkName: "features/mutes" */'../../mutes'); | ||||
| } | ||||
|  |  | |||
							
								
								
									
										23
									
								
								app/javascript/mastodon/reducers/domain_lists.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/javascript/mastodon/reducers/domain_lists.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,23 @@ | |||
| import { | ||||
|   DOMAIN_BLOCKS_FETCH_SUCCESS, | ||||
|   DOMAIN_BLOCKS_EXPAND_SUCCESS, | ||||
|   DOMAIN_UNBLOCK_SUCCESS, | ||||
| } from '../actions/domain_blocks'; | ||||
| import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; | ||||
| 
 | ||||
| const initialState = ImmutableMap({ | ||||
|   blocks: ImmutableMap(), | ||||
| }); | ||||
| 
 | ||||
| export default function domainLists(state = initialState, action) { | ||||
|   switch(action.type) { | ||||
|   case DOMAIN_BLOCKS_FETCH_SUCCESS: | ||||
|     return state.setIn(['blocks', 'items'], ImmutableOrderedSet(action.domains)).setIn(['blocks', 'next'], action.next); | ||||
|   case DOMAIN_BLOCKS_EXPAND_SUCCESS: | ||||
|     return state.updateIn(['blocks', 'items'], set => set.union(action.domains)).setIn(['blocks', 'next'], action.next); | ||||
|   case DOMAIN_UNBLOCK_SUCCESS: | ||||
|     return state.updateIn(['blocks', 'items'], set => set.delete(action.domain)); | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
| }; | ||||
|  | @ -6,6 +6,7 @@ import alerts from './alerts'; | |||
| import { loadingBarReducer } from 'react-redux-loading-bar'; | ||||
| import modal from './modal'; | ||||
| import user_lists from './user_lists'; | ||||
| import domain_lists from './domain_lists'; | ||||
| import accounts from './accounts'; | ||||
| import accounts_counters from './accounts_counters'; | ||||
| import statuses from './statuses'; | ||||
|  | @ -34,6 +35,7 @@ const reducers = { | |||
|   loadingBar: loadingBarReducer, | ||||
|   modal, | ||||
|   user_lists, | ||||
|   domain_lists, | ||||
|   status_lists, | ||||
|   accounts, | ||||
|   accounts_counters, | ||||
|  |  | |||
|  | @ -23,6 +23,14 @@ const normalizeRelationships = (state, relationships) => { | |||
|   return state; | ||||
| }; | ||||
| 
 | ||||
| const setDomainBlocking = (state, accounts, blocking) => { | ||||
|   return state.withMutations(map => { | ||||
|     accounts.forEach(id => { | ||||
|       map.setIn([id, 'domain_blocking'], blocking); | ||||
|     }); | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| const initialState = ImmutableMap(); | ||||
| 
 | ||||
| export default function relationships(state = initialState, action) { | ||||
|  | @ -37,9 +45,9 @@ export default function relationships(state = initialState, action) { | |||
|   case RELATIONSHIPS_FETCH_SUCCESS: | ||||
|     return normalizeRelationships(state, action.relationships); | ||||
|   case DOMAIN_BLOCK_SUCCESS: | ||||
|     return state.setIn([action.accountId, 'domain_blocking'], true); | ||||
|     return setDomainBlocking(state, action.accounts, true); | ||||
|   case DOMAIN_UNBLOCK_SUCCESS: | ||||
|     return state.setIn([action.accountId, 'domain_blocking'], false); | ||||
|     return setDomainBlocking(state, action.accounts, false); | ||||
|   default: | ||||
|     return state; | ||||
|   } | ||||
|  |  | |||
|  | @ -1001,6 +1001,30 @@ | |||
|   } | ||||
| } | ||||
| 
 | ||||
| .domain { | ||||
|   padding: 10px; | ||||
|   border-bottom: 1px solid lighten($ui-base-color, 8%); | ||||
| 
 | ||||
|   .domain__domain-name { | ||||
|     flex: 1 1 auto; | ||||
|     display: block; | ||||
|     color: $primary-text-color; | ||||
|     text-decoration: none; | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .domain__wrapper { | ||||
|   display: flex; | ||||
| } | ||||
| 
 | ||||
| .domain_buttons { | ||||
|   height: 18px; | ||||
|   padding: 10px; | ||||
|   white-space: nowrap; | ||||
| } | ||||
| 
 | ||||
| .account { | ||||
|   padding: 10px; | ||||
|   border-bottom: 1px solid lighten($ui-base-color, 8%); | ||||
|  |  | |||
		Reference in a new issue