about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/settings/migrations_controller.rb6
-rw-r--r--app/javascript/flavours/glitch/actions/domain_blocks.js66
-rw-r--r--app/javascript/flavours/glitch/actions/dropdown_menu.js10
-rw-r--r--app/javascript/flavours/glitch/components/domain.js42
-rw-r--r--app/javascript/flavours/glitch/components/dropdown_menu.js62
-rw-r--r--app/javascript/flavours/glitch/containers/domain_container.js33
-rw-r--r--app/javascript/flavours/glitch/containers/dropdown_menu_container.js19
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/header.js4
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js8
-rw-r--r--app/javascript/flavours/glitch/features/domain_blocks/index.js66
-rw-r--r--app/javascript/flavours/glitch/features/getting_started_misc/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js8
-rw-r--r--app/javascript/flavours/glitch/reducers/domain_lists.js23
-rw-r--r--app/javascript/flavours/glitch/reducers/dropdown_menu.js18
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/relationships.js12
-rw-r--r--app/javascript/flavours/glitch/styles/components/domains.scss23
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss1
-rw-r--r--app/javascript/flavours/glitch/util/async-components.js4
19 files changed, 351 insertions, 60 deletions
diff --git a/app/controllers/settings/migrations_controller.rb b/app/controllers/settings/migrations_controller.rb
index bc6436b87..89b3f7246 100644
--- a/app/controllers/settings/migrations_controller.rb
+++ b/app/controllers/settings/migrations_controller.rb
@@ -1,10 +1,6 @@
 # frozen_string_literal: true
 
-class Settings::MigrationsController < ApplicationController
-  layout 'admin'
-
-  before_action :authenticate_user!
-
+class Settings::MigrationsController < Settings::BaseController
   def show
     @migration = Form::Migration.new(account: current_account.moved_to_account)
   end
diff --git a/app/javascript/flavours/glitch/actions/domain_blocks.js b/app/javascript/flavours/glitch/actions/domain_blocks.js
index 8506df91c..46f76710c 100644
--- a/app/javascript/flavours/glitch/actions/domain_blocks.js
+++ b/app/javascript/flavours/glitch/actions/domain_blocks.js
@@ -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,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/dropdown_menu.js b/app/javascript/flavours/glitch/actions/dropdown_menu.js
new file mode 100644
index 000000000..217ba4e74
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/dropdown_menu.js
@@ -0,0 +1,10 @@
+export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
+export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
+
+export function openDropdownMenu(id, placement) {
+  return { type: DROPDOWN_MENU_OPEN, id, placement };
+}
+
+export function closeDropdownMenu(id) {
+  return { type: DROPDOWN_MENU_CLOSE, id };
+}
diff --git a/app/javascript/flavours/glitch/components/domain.js b/app/javascript/flavours/glitch/components/domain.js
new file mode 100644
index 000000000..f657cb8d2
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/domain.js
@@ -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>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js
index 7ba7fb22b..245bebef3 100644
--- a/app/javascript/flavours/glitch/components/dropdown_menu.js
+++ b/app/javascript/flavours/glitch/components/dropdown_menu.js
@@ -8,6 +8,7 @@ import spring from 'react-motion/lib/spring';
 import detectPassiveEvents from 'detect-passive-events';
 
 const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+let id = 0;
 
 class DropdownMenu extends React.PureComponent {
 
@@ -29,6 +30,10 @@ class DropdownMenu extends React.PureComponent {
     placement: 'bottom',
   };
 
+  state = {
+    mounted: false,
+  };
+
   handleDocumentClick = e => {
     if (this.node && !this.node.contains(e.target)) {
       this.props.onClose();
@@ -38,6 +43,7 @@ class DropdownMenu extends React.PureComponent {
   componentDidMount () {
     document.addEventListener('click', this.handleDocumentClick, false);
     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+    this.setState({ mounted: true });
   }
 
   componentWillUnmount () {
@@ -82,11 +88,15 @@ class DropdownMenu extends React.PureComponent {
 
   render () {
     const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
+    const { mounted } = this.state;
 
     return (
       <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
         {({ opacity, scaleX, scaleY }) => (
-          <div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
+          // 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='dropdown-menu' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
             <div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
 
             <ul>
@@ -115,8 +125,10 @@ export default class Dropdown extends React.PureComponent {
     status: ImmutablePropTypes.map,
     isUserTouching: PropTypes.func,
     isModalOpen: PropTypes.bool.isRequired,
-    onModalOpen: PropTypes.func,
-    onModalClose: PropTypes.func,
+    onOpen: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    dropdownPlacement: PropTypes.string,
+    openDropdownId: PropTypes.number,
   };
 
   static defaultProps = {
@@ -124,42 +136,28 @@ export default class Dropdown extends React.PureComponent {
   };
 
   state = {
-    expanded: false,
+    id: id++,
   };
 
-  handleClick = () => {
-    if (!this.state.expanded && this.props.isUserTouching() && this.props.onModalOpen) {
-      const { status, items } = this.props;
-
-      this.props.onModalOpen({
-        status,
-        actions: items.map(
-          (item, i) => item ? {
-            ...item,
-            name: `${item.text}-${i}`,
-            onClick: this.handleItemClick.bind(this, i),
-          } : null
-        ),
-      });
-
-      return;
-    }
+  handleClick = ({ target }) => {
+    if (this.state.id === this.props.openDropdownId) {
+      this.handleClose();
+    } else {
+      const { top } = target.getBoundingClientRect();
+      const placement = top * 2 < innerHeight ? 'bottom' : 'top';
 
-    this.setState({ expanded: !this.state.expanded });
+      this.props.onOpen(this.state.id, this.handleItemClick, placement);
+    }
   }
 
   handleClose = () => {
-    if (this.props.onModalClose) {
-      this.props.onModalClose();
-    }
-
-    this.setState({ expanded: false });
+    this.props.onClose(this.state.id);
   }
 
   handleKeyDown = e => {
     switch(e.key) {
     case 'Enter':
-      this.handleClick();
+      this.handleClick(e);
       break;
     case 'Escape':
       this.handleClose();
@@ -190,22 +188,22 @@ export default class Dropdown extends React.PureComponent {
   }
 
   render () {
-    const { icon, items, size, ariaLabel, disabled } = this.props;
-    const { expanded } = this.state;
+    const { icon, items, size, ariaLabel, disabled, dropdownPlacement, openDropdownId } = this.props;
+    const open = this.state.id === openDropdownId;
 
     return (
       <div onKeyDown={this.handleKeyDown}>
         <IconButton
           icon={icon}
           title={ariaLabel}
-          active={expanded}
+          active={open}
           disabled={disabled}
           size={size}
           ref={this.setTargetRef}
           onClick={this.handleClick}
         />
 
-        <Overlay show={expanded} placement='bottom' target={this.findTarget}>
+        <Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
           <DropdownMenu items={items} onClose={this.handleClose} />
         </Overlay>
       </div>
diff --git a/app/javascript/flavours/glitch/containers/domain_container.js b/app/javascript/flavours/glitch/containers/domain_container.js
new file mode 100644
index 000000000..52d5c1613
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/domain_container.js
@@ -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));
diff --git a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js
index 0b4f72fa1..9d490de17 100644
--- a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js
+++ b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js
@@ -1,3 +1,4 @@
+import { openDropdownMenu, closeDropdownMenu } from 'flavours/glitch/actions/dropdown_menu';
 import { openModal, closeModal } from 'flavours/glitch/actions/modal';
 import { connect } from 'react-redux';
 import DropdownMenu from 'flavours/glitch/components/dropdown_menu';
@@ -5,12 +6,22 @@ import { isUserTouching } from 'flavours/glitch/util/is_mobile';
 
 const mapStateToProps = state => ({
   isModalOpen: state.get('modal').modalType === 'ACTIONS',
+  dropdownPlacement: state.getIn(['dropdown_menu', 'placement']),
+  openDropdownId: state.getIn(['dropdown_menu', 'openId']),
 });
 
-const mapDispatchToProps = dispatch => ({
-  isUserTouching,
-  onModalOpen: props => dispatch(openModal('ACTIONS', props)),
-  onModalClose: () => dispatch(closeModal()),
+const mapDispatchToProps = (dispatch, { status, items }) => ({
+  onOpen(id, onItemClick, dropdownPlacement) {
+    dispatch(isUserTouching() ? openModal('ACTIONS', {
+      status,
+      actions: items,
+      onClick: onItemClick,
+    }) : openDropdownMenu(id, dropdownPlacement));
+  },
+  onClose(id) {
+    dispatch(closeModal());
+    dispatch(closeDropdownMenu(id));
+  },
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.js b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
index e788ea660..39a1850d7 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/components/header.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
@@ -57,7 +57,7 @@ export default class Header extends ImmutablePureComponent {
 
     if (!domain) return;
 
-    this.props.onBlockDomain(domain, this.props.account.get('id'));
+    this.props.onBlockDomain(domain);
   }
 
   handleUnblockDomain = () => {
@@ -65,7 +65,7 @@ export default class Header extends ImmutablePureComponent {
 
     if (!domain) return;
 
-    this.props.onUnblockDomain(domain, this.props.account.get('id'));
+    this.props.onUnblockDomain(domain);
   }
 
   render () {
diff --git a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
index 37ff445b2..848119c63 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
@@ -87,16 +87,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));
   },
 
 });
diff --git a/app/javascript/flavours/glitch/features/domain_blocks/index.js b/app/javascript/flavours/glitch/features/domain_blocks/index.js
new file mode 100644
index 000000000..b17c47e91
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/domain_blocks/index.js
@@ -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>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/getting_started_misc/index.js b/app/javascript/flavours/glitch/features/getting_started_misc/index.js
index 9cf7ddff9..77c44c273 100644
--- a/app/javascript/flavours/glitch/features/getting_started_misc/index.js
+++ b/app/javascript/flavours/glitch/features/getting_started_misc/index.js
@@ -14,6 +14,7 @@ const messages = defineMessages({
   subheading: { id: 'column.subheading', defaultMessage: 'Miscellaneous options' },
   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' },
   show_me_around: { id: 'getting_started.onboarding', defaultMessage: 'Show me around' },
@@ -49,6 +50,7 @@ export default class gettingStartedMisc extends ImmutablePureComponent {
           <ColumnLink key='20' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />
           <ColumnLink key='21' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
           <ColumnLink key='22' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
+          <ColumnLink icon='ban' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />
           <ColumnLink key='23' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />
           <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
           <ColumnLink icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} />
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 0b031a7f0..6d9c14064 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -38,6 +38,7 @@ import {
   FavouritedStatuses,
   ListTimeline,
   Blocks,
+  DomainBlocks,
   Mutes,
   PinnedStatuses,
   Lists,
@@ -60,6 +61,7 @@ const mapStateToProps = state => ({
   layout: state.getIn(['local_settings', 'layout']),
   isWide: state.getIn(['local_settings', 'stretch']),
   navbarUnder: state.getIn(['local_settings', 'navbar_under']),
+  dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
 });
 
 const keyMap = {
@@ -111,6 +113,7 @@ export default class UI extends React.Component {
     hasComposingText: PropTypes.bool,
     location: PropTypes.object,
     intl: PropTypes.object.isRequired,
+    dropdownMenuIsOpen: PropTypes.bool,
   };
 
   state = {
@@ -366,7 +369,7 @@ export default class UI extends React.Component {
 
   render () {
     const { width, draggingOver } = this.state;
-    const { children, layout, isWide, navbarUnder } = this.props;
+    const { children, layout, isWide, navbarUnder, dropdownMenuIsOpen } = this.props;
 
     const columnsClass = layout => {
       switch (layout) {
@@ -407,7 +410,7 @@ export default class UI extends React.Component {
 
     return (
       <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}>
-        <div className={className} ref={this.setRef}>
+        <div className={className} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}>
           {navbarUnder ? null : (<TabsBar />)}
 
           <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
@@ -438,6 +441,7 @@ export default class UI extends React.Component {
 
               <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} />
               <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} />
diff --git a/app/javascript/flavours/glitch/reducers/domain_lists.js b/app/javascript/flavours/glitch/reducers/domain_lists.js
new file mode 100644
index 000000000..a9e3519f3
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/domain_lists.js
@@ -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;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/dropdown_menu.js b/app/javascript/flavours/glitch/reducers/dropdown_menu.js
new file mode 100644
index 000000000..5449884cc
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/dropdown_menu.js
@@ -0,0 +1,18 @@
+import Immutable from 'immutable';
+import {
+  DROPDOWN_MENU_OPEN,
+  DROPDOWN_MENU_CLOSE,
+} from '../actions/dropdown_menu';
+
+const initialState = Immutable.Map({ openId: null, placement: null });
+
+export default function dropdownMenu(state = initialState, action) {
+  switch (action.type) {
+  case DROPDOWN_MENU_OPEN:
+    return state.merge({ openId: action.id, placement: action.placement });
+  case DROPDOWN_MENU_CLOSE:
+    return state.get('openId') === action.id ? state.set('openId', null) : state;
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index e9c8d7c1d..dc8cb6834 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -1,10 +1,12 @@
 import { combineReducers } from 'redux-immutable';
+import dropdown_menu from './dropdown_menu';
 import timelines from './timelines';
 import meta from './meta';
 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';
@@ -27,12 +29,14 @@ import lists from './lists';
 import listEditor from './list_editor';
 
 const reducers = {
+  dropdown_menu,
   timelines,
   meta,
   alerts,
   loadingBar: loadingBarReducer,
   modal,
   user_lists,
+  domain_lists,
   status_lists,
   accounts,
   accounts_counters,
diff --git a/app/javascript/flavours/glitch/reducers/relationships.js b/app/javascript/flavours/glitch/reducers/relationships.js
index 6303089ac..e5ef1d2e3 100644
--- a/app/javascript/flavours/glitch/reducers/relationships.js
+++ b/app/javascript/flavours/glitch/reducers/relationships.js
@@ -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;
   }
diff --git a/app/javascript/flavours/glitch/styles/components/domains.scss b/app/javascript/flavours/glitch/styles/components/domains.scss
new file mode 100644
index 000000000..a99ccd02b
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/domains.scss
@@ -0,0 +1,23 @@
+.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;
+}
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 3d16c856d..aa33c9333 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -1173,6 +1173,7 @@ noscript {
 
 @import 'boost';
 @import 'accounts';
+@import 'domains';
 @import 'status';
 @import 'modal';
 @import 'metadata';
diff --git a/app/javascript/flavours/glitch/util/async-components.js b/app/javascript/flavours/glitch/util/async-components.js
index 2aa9659e8..acda92d54 100644
--- a/app/javascript/flavours/glitch/util/async-components.js
+++ b/app/javascript/flavours/glitch/util/async-components.js
@@ -98,6 +98,10 @@ export function Blocks () {
   return import(/* webpackChunkName: "flavours/glitch/async/blocks" */'flavours/glitch/features/blocks');
 }
 
+export function DomainBlocks () {
+  return import(/* webpackChunkName: "flavours/glitch/async/domain_blocks" */'flavours/glitch/features/domain_blocks');
+}
+
 export function Mutes () {
   return import(/* webpackChunkName: "flavours/glitch/async/mutes" */'flavours/glitch/features/mutes');
 }