about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
authorJenkins <jenkins@jenkins.ninjawedding.org>2018-03-30 19:17:17 +0000
committerJenkins <jenkins@jenkins.ninjawedding.org>2018-03-30 19:17:17 +0000
commitf0bb3ff53307aac7b9c64ec8a48d4ba1d2019513 (patch)
tree42a5d4f630f5afe0c2aee7e53fbdbe5751cb1fb6 /app/javascript
parent01bad04efd09ede8c31c578259468be329e4fc3c (diff)
parente573bb0990ece4b1a521ccf8a4c7bec5972d3538 (diff)
Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master
Diffstat (limited to 'app/javascript')
-rw-r--r--app/javascript/mastodon/actions/compose.js14
-rw-r--r--app/javascript/mastodon/actions/domain_blocks.js66
-rw-r--r--app/javascript/mastodon/actions/notifications.js4
-rw-r--r--app/javascript/mastodon/components/domain.js42
-rw-r--r--app/javascript/mastodon/containers/domain_container.js33
-rw-r--r--app/javascript/mastodon/features/account/components/action_bar.js3
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js10
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js17
-rw-r--r--app/javascript/mastodon/features/compose/containers/warning_container.js7
-rw-r--r--app/javascript/mastodon/features/domain_blocks/index.js66
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/zoomable_image.js165
-rw-r--r--app/javascript/mastodon/features/ui/index.js2
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js4
-rw-r--r--app/javascript/mastodon/locales/ar.json2
-rw-r--r--app/javascript/mastodon/locales/bg.json2
-rw-r--r--app/javascript/mastodon/locales/ca.json2
-rw-r--r--app/javascript/mastodon/locales/de.json2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json8
-rw-r--r--app/javascript/mastodon/locales/en.json2
-rw-r--r--app/javascript/mastodon/locales/eo.json2
-rw-r--r--app/javascript/mastodon/locales/es.json2
-rw-r--r--app/javascript/mastodon/locales/fa.json2
-rw-r--r--app/javascript/mastodon/locales/fi.json2
-rw-r--r--app/javascript/mastodon/locales/fr.json2
-rw-r--r--app/javascript/mastodon/locales/gl.json2
-rw-r--r--app/javascript/mastodon/locales/he.json2
-rw-r--r--app/javascript/mastodon/locales/hr.json2
-rw-r--r--app/javascript/mastodon/locales/hu.json2
-rw-r--r--app/javascript/mastodon/locales/hy.json2
-rw-r--r--app/javascript/mastodon/locales/id.json2
-rw-r--r--app/javascript/mastodon/locales/io.json2
-rw-r--r--app/javascript/mastodon/locales/it.json2
-rw-r--r--app/javascript/mastodon/locales/ja.json2
-rw-r--r--app/javascript/mastodon/locales/ko.json2
-rw-r--r--app/javascript/mastodon/locales/nl.json2
-rw-r--r--app/javascript/mastodon/locales/no.json2
-rw-r--r--app/javascript/mastodon/locales/oc.json2
-rw-r--r--app/javascript/mastodon/locales/pl.json2
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json2
-rw-r--r--app/javascript/mastodon/locales/pt.json2
-rw-r--r--app/javascript/mastodon/locales/ru.json2
-rw-r--r--app/javascript/mastodon/locales/sk.json2
-rw-r--r--app/javascript/mastodon/locales/sr-Latn.json2
-rw-r--r--app/javascript/mastodon/locales/sr.json2
-rw-r--r--app/javascript/mastodon/locales/sv.json2
-rw-r--r--app/javascript/mastodon/locales/th.json2
-rw-r--r--app/javascript/mastodon/locales/tr.json2
-rw-r--r--app/javascript/mastodon/locales/uk.json2
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json2
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json2
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json2
-rw-r--r--app/javascript/mastodon/reducers/compose.js7
-rw-r--r--app/javascript/mastodon/reducers/domain_lists.js23
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/reducers/relationships.js12
-rw-r--r--app/javascript/mastodon/storage/modifier.js5
-rw-r--r--app/javascript/styles/mastodon/components.scss27
58 files changed, 497 insertions, 96 deletions
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 5e7cdd270..2138f9426 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -15,6 +15,7 @@ export const COMPOSE_SUBMIT_SUCCESS  = 'COMPOSE_SUBMIT_SUCCESS';
 export const COMPOSE_SUBMIT_FAIL     = 'COMPOSE_SUBMIT_FAIL';
 export const COMPOSE_REPLY           = 'COMPOSE_REPLY';
 export const COMPOSE_REPLY_CANCEL    = 'COMPOSE_REPLY_CANCEL';
+export const COMPOSE_DIRECT          = 'COMPOSE_DIRECT';
 export const COMPOSE_MENTION         = 'COMPOSE_MENTION';
 export const COMPOSE_RESET           = 'COMPOSE_RESET';
 export const COMPOSE_UPLOAD_REQUEST  = 'COMPOSE_UPLOAD_REQUEST';
@@ -91,6 +92,19 @@ export function mentionCompose(account, router) {
   };
 };
 
+export function directCompose(account, router) {
+  return (dispatch, getState) => {
+    dispatch({
+      type: COMPOSE_DIRECT,
+      account: account,
+    });
+
+    if (!getState().getIn(['compose', 'mounted'])) {
+      router.push('/statuses/new');
+    }
+  };
+};
+
 export function submitCompose() {
   return function (dispatch, getState) {
     const status = getState().getIn(['compose', 'text'], '');
diff --git a/app/javascript/mastodon/actions/domain_blocks.js b/app/javascript/mastodon/actions/domain_blocks.js
index 44363697a..47e2df76b 100644
--- a/app/javascript/mastodon/actions/domain_blocks.js
+++ b/app/javascript/mastodon/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/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 7267b85bd..da77afbe0 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -43,7 +43,9 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
     const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
 
     dispatch(importFetchedAccount(notification.account));
-    dispatch(importFetchedStatus(notification.status));
+    if (notification.status) {
+      dispatch(importFetchedStatus(notification.status));
+    }
 
     dispatch({
       type: NOTIFICATIONS_UPDATE,
diff --git a/app/javascript/mastodon/components/domain.js b/app/javascript/mastodon/components/domain.js
new file mode 100644
index 000000000..f657cb8d2
--- /dev/null
+++ b/app/javascript/mastodon/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/mastodon/containers/domain_container.js b/app/javascript/mastodon/containers/domain_container.js
new file mode 100644
index 000000000..52d5c1613
--- /dev/null
+++ b/app/javascript/mastodon/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/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js
index b538fa5fc..23dbf32bc 100644
--- a/app/javascript/mastodon/features/account/components/action_bar.js
+++ b/app/javascript/mastodon/features/account/components/action_bar.js
@@ -8,6 +8,7 @@ import { me } from '../../../initial_state';
 
 const messages = defineMessages({
   mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
+  direct: { id: 'account.direct', defaultMessage: 'Direct message @{name}' },
   edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -32,6 +33,7 @@ export default class ActionBar extends React.PureComponent {
     onFollow: PropTypes.func,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
+    onDirect: PropTypes.func.isRequired,
     onReblogToggle: PropTypes.func.isRequired,
     onReport: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
@@ -53,6 +55,7 @@ export default class ActionBar extends React.PureComponent {
     let extraInfo = '';
 
     menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+    menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
 
     if ('share' in navigator) {
       menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 6b88a7a0c..1ae5126e6 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -16,6 +16,7 @@ export default class Header extends ImmutablePureComponent {
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
+    onDirect: PropTypes.func.isRequired,
     onReblogToggle: PropTypes.func.isRequired,
     onReport: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
@@ -40,6 +41,10 @@ export default class Header extends ImmutablePureComponent {
     this.props.onMention(this.props.account, this.context.router.history);
   }
 
+  handleDirect = () => {
+    this.props.onDirect(this.props.account, this.context.router.history);
+  }
+
   handleReport = () => {
     this.props.onReport(this.props.account);
   }
@@ -57,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 = () => {
@@ -65,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 () {
@@ -89,6 +94,7 @@ export default class Header extends ImmutablePureComponent {
           account={account}
           onBlock={this.handleBlock}
           onMention={this.handleMention}
+          onDirect={this.handleDirect}
           onReblogToggle={this.handleReblogToggle}
           onReport={this.handleReport}
           onMute={this.handleMute}
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index b5e0e9a3f..4d5308219 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -9,7 +9,10 @@ import {
   unblockAccount,
   unmuteAccount,
 } from '../../../actions/accounts';
-import { mentionCompose } from '../../../actions/compose';
+import {
+  mentionCompose,
+  directCompose,
+} from '../../../actions/compose';
 import { initMuteModal } from '../../../actions/mutes';
 import { initReport } from '../../../actions/reports';
 import { openModal } from '../../../actions/modal';
@@ -67,6 +70,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(mentionCompose(account, router));
   },
 
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
   onReblogToggle (account) {
     if (account.getIn(['relationship', 'showing_reblogs'])) {
       dispatch(followAccount(account.get('id'), false));
@@ -87,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));
   },
 
 });
diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.js b/app/javascript/mastodon/features/compose/containers/warning_container.js
index 8ee8ea190..efaa02e9e 100644
--- a/app/javascript/mastodon/features/compose/containers/warning_container.js
+++ b/app/javascript/mastodon/features/compose/containers/warning_container.js
@@ -10,15 +10,19 @@ const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i;
 const mapStateToProps = state => ({
   needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
   hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
+  directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
 });
 
-const WarningWrapper = ({ needsLockWarning, hashtagWarning }) => {
+const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
   if (needsLockWarning) {
     return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
   }
   if (hashtagWarning) {
     return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag." />} />;
   }
+  if (directMessageWarning) {
+    return <Warning message={<FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be visible to all the mentioned users.' />} />;
+  }
 
   return null;
 };
@@ -26,6 +30,7 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning }) => {
 WarningWrapper.propTypes = {
   needsLockWarning: PropTypes.bool,
   hashtagWarning: PropTypes.bool,
+  directMessageWarning: PropTypes.bool,
 };
 
 export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/mastodon/features/domain_blocks/index.js b/app/javascript/mastodon/features/domain_blocks/index.js
new file mode 100644
index 000000000..b17c47e91
--- /dev/null
+++ b/app/javascript/mastodon/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/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index 3a875169e..1a71cff94 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -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>
diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.js b/app/javascript/mastodon/features/ui/components/zoomable_image.js
index 0a0a4d41a..0cae0862d 100644
--- a/app/javascript/mastodon/features/ui/components/zoomable_image.js
+++ b/app/javascript/mastodon/features/ui/components/zoomable_image.js
@@ -1,16 +1,10 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import Hammer from 'hammerjs';
 
 const MIN_SCALE = 1;
 const MAX_SCALE = 4;
-
-const getMidpoint = (p1, p2) => ({
-  x: (p1.clientX + p2.clientX) / 2,
-  y: (p1.clientY + p2.clientY) / 2,
-});
-
-const getDistance = (p1, p2) =>
-  Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
+const DOUBLE_TAP_SCALE = 2;
 
 const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
 
@@ -37,83 +31,97 @@ export default class ZoomableImage extends React.PureComponent {
   removers = [];
   container = null;
   image = null;
-  lastTouchEndTime = 0;
-  lastDistance = 0;
+  lastScale = null;
+  zoomCenter = null;
 
   componentDidMount () {
-    let handler = this.handleTouchStart;
-    this.container.addEventListener('touchstart', handler);
-    this.removers.push(() => this.container.removeEventListener('touchstart', handler));
-    handler = this.handleTouchMove;
-    // on Chrome 56+, touch event listeners will default to passive
-    // https://www.chromestatus.com/features/5093566007214080
-    this.container.addEventListener('touchmove', handler, { passive: false });
-    this.removers.push(() => this.container.removeEventListener('touchend', handler));
+    // register pinch event handlers to the container
+    let hammer = new Hammer.Manager(this.container, {
+      // required to make container scrollable by touch
+      touchAction: 'pan-x pan-y',
+    });
+    hammer.add(new Hammer.Pinch());
+    hammer.on('pinchstart', this.handlePinchStart);
+    hammer.on('pinchmove', this.handlePinchMove);
+    this.removers.push(() => hammer.off('pinchstart pinchmove'));
+
+    // register tap event handlers
+    hammer = new Hammer.Manager(this.image);
+    // NOTE the order of adding is also the order of gesture recognition
+    hammer.add(new Hammer.Tap({ event: 'doubletap', taps: 2 }));
+    hammer.add(new Hammer.Tap());
+    // prevent the 'tap' event handler be fired on double tap
+    hammer.get('tap').requireFailure('doubletap');
+    // NOTE 'tap' and 'doubletap' events are fired by touch and *mouse*
+    hammer.on('tap', this.handleTap);
+    hammer.on('doubletap', this.handleDoubleTap);
+    this.removers.push(() => hammer.off('tap doubletap'));
   }
 
   componentWillUnmount () {
     this.removeEventListeners();
   }
 
-  removeEventListeners () {
-    this.removers.forEach(listeners => listeners());
-    this.removers = [];
-  }
+  componentDidUpdate (prevProps, prevState) {
+    if (!this.zoomCenter) return;
 
-  handleTouchStart = e => {
-    if (e.touches.length !== 2) return;
+    const { x: cx, y: cy } = this.zoomCenter;
+    const { scale: prevScale } = prevState;
+    const { scale: nextScale } = this.state;
+    const { scrollLeft, scrollTop } = this.container;
 
-    this.lastDistance = getDistance(...e.touches);
+    // math memo:
+    // x = (scrollLeft + cx) / scrollWidth
+    // x' = (nextScrollLeft + cx) / nextScrollWidth
+    // scrollWidth = clientWidth * prevScale
+    // scrollWidth' = clientWidth * nextScale
+    // Solve x = x' for nextScrollLeft
+    const nextScrollLeft = (scrollLeft + cx) * nextScale / prevScale - cx;
+    const nextScrollTop = (scrollTop + cy) * nextScale / prevScale - cy;
+
+    this.container.scrollLeft = nextScrollLeft;
+    this.container.scrollTop = nextScrollTop;
   }
 
-  handleTouchMove = e => {
-    const { scrollTop, scrollHeight, clientHeight } = this.container;
-    if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
-      // prevent propagating event to MediaModal
-      e.stopPropagation();
-      return;
-    }
-    if (e.touches.length !== 2) return;
+  removeEventListeners () {
+    this.removers.forEach(listeners => listeners());
+    this.removers = [];
+  }
 
-    e.preventDefault();
+  handleClick = e => {
+    // prevent the click event propagated to parent
     e.stopPropagation();
 
-    const distance = getDistance(...e.touches);
-    const midpoint = getMidpoint(...e.touches);
-    const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance);
-
-    this.zoom(scale, midpoint);
-
-    this.lastMidpoint = midpoint;
-    this.lastDistance = distance;
+    // the tap event handler is executed at the same time by touch and mouse,
+    // so we don't need to execute the onClick handler here
   }
 
-  zoom(nextScale, midpoint) {
-    const { scale } = this.state;
-    const { scrollLeft, scrollTop } = this.container;
-
-    // math memo:
-    // x = (scrollLeft + midpoint.x) / scrollWidth
-    // x' = (nextScrollLeft + midpoint.x) / nextScrollWidth
-    // scrollWidth = clientWidth * scale
-    // scrollWidth' = clientWidth * nextScale
-    // Solve x = x' for nextScrollLeft
-    const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
-    const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
+  handlePinchStart = () => {
+    this.lastScale = this.state.scale;
+  }
 
-    this.setState({ scale: nextScale }, () => {
-      this.container.scrollLeft = nextScrollLeft;
-      this.container.scrollTop = nextScrollTop;
-    });
+  handlePinchMove = e => {
+    const scale = clamp(MIN_SCALE, MAX_SCALE, this.lastScale * e.scale);
+    this.zoom(scale, e.center);
   }
 
-  handleClick = e => {
-    // don't propagate event to MediaModal
-    e.stopPropagation();
+  handleTap = () => {
     const handler = this.props.onClick;
     if (handler) handler();
   }
 
+  handleDoubleTap = e => {
+    if (this.state.scale === MIN_SCALE)
+      this.zoom(DOUBLE_TAP_SCALE, e.center);
+    else
+      this.zoom(MIN_SCALE, e.center);
+  }
+
+  zoom (scale, center) {
+    this.zoomCenter = center;
+    this.setState({ scale });
+  }
+
   setContainerRef = c => {
     this.container = c;
   }
@@ -126,6 +134,18 @@ export default class ZoomableImage extends React.PureComponent {
     const { alt, src } = this.props;
     const { scale } = this.state;
     const overflow = scale === 1 ? 'hidden' : 'scroll';
+    const marginStyle = {
+      position: 'absolute',
+      top: 0,
+      bottom: 0,
+      left: 0,
+      right: 0,
+      display: 'flex',
+      alignItems: 'center',
+      justifyContent: 'center',
+      transform: `scale(${scale})`,
+      transformOrigin: '0 0',
+    };
 
     return (
       <div
@@ -133,17 +153,18 @@ export default class ZoomableImage extends React.PureComponent {
         ref={this.setContainerRef}
         style={{ overflow }}
       >
-        <img
-          role='presentation'
-          ref={this.setImageRef}
-          alt={alt}
-          src={src}
-          style={{
-            transform: `scale(${scale})`,
-            transformOrigin: '0 0',
-          }}
-          onClick={this.handleClick}
-        />
+        <div
+          className='zoomable-image__margin'
+          style={marginStyle}
+        >
+          <img
+            ref={this.setImageRef}
+            role='presentation'
+            alt={alt}
+            src={src}
+            onClick={this.handleClick}
+          />
+        </div>
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index b6a2a6cfc..8894eb4e6 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -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} />
 
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index d6586680b..19957208f 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -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');
 }
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index 3d9620793..f9af062d0 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -2,6 +2,7 @@
   "account.block": "حظر @{name}",
   "account.block_domain": "إخفاء كل شيئ قادم من إسم النطاق {domain}",
   "account.blocked": "محظور",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "قد لا تعكس المعلومات أدناه الملف الشخصي الكامل للمستخدم.",
   "account.domain_blocked": "النطاق مخفي",
   "account.edit_profile": "تعديل الملف الشخصي",
@@ -56,6 +57,7 @@
   "column_header.unpin": "فك التدبيس",
   "column_subheading.navigation": "التصفح",
   "column_subheading.settings": "الإعدادات",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "هذا التبويق لن يُدرَج تحت أي وسم كان بما أنه غير مُدرَج. لا يُسمح بالبحث إلّا عن التبويقات العمومية عن طريق الوسوم.",
   "compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.",
   "compose_form.lock_disclaimer.lock": "مقفل",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index 39eb05f2a..58795ca37 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -2,6 +2,7 @@
   "account.block": "Блокирай",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Редактирай профила си",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 33545d86f..b0ce34c6b 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -2,6 +2,7 @@
   "account.block": "Bloca @{name}",
   "account.block_domain": "Amaga-ho tot de {domain}",
   "account.blocked": "Bloquejat",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "La informació següent pot reflectir incompleta el perfil de l'usuari.",
   "account.domain_blocked": "Domini ocult",
   "account.edit_profile": "Edita el perfil",
@@ -56,6 +57,7 @@
   "column_header.unpin": "No fixis",
   "column_subheading.navigation": "Navegació",
   "column_subheading.settings": "Configuració",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Aquest toot no es mostrarà en cap etiqueta ja que no està llistat. Només els toots públics poden ser cercats per etiqueta.",
   "compose_form.lock_disclaimer": "El teu compte no està bloquejat {locked}. Tothom pot seguir-te i veure els teus missatges a seguidors.",
   "compose_form.lock_disclaimer.lock": "blocat",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 7bdb6a3c6..eb0c5056a 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -2,6 +2,7 @@
   "account.block": "@{name} blocken",
   "account.block_domain": "Alles von {domain} verstecken",
   "account.blocked": "Blockiert",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Das Profil wird möglicherweise unvollständig wiedergegeben.",
   "account.domain_blocked": "Domain versteckt",
   "account.edit_profile": "Profil bearbeiten",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Lösen",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Einstellungen",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Dieser Beitrag wird nicht unter einen dieser Hashtags sichtbar sein, solange er ungelistet ist. Bei einer Suche kann er nicht gefunden werden.",
   "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.",
   "compose_form.lock_disclaimer.lock": "gesperrt",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 2120009ac..ac02c6af3 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -381,6 +381,10 @@
         "id": "account.mention"
       },
       {
+        "defaultMessage": "Direct message @{name}",
+        "id": "account.direct"
+      },
+      {
         "defaultMessage": "Edit profile",
         "id": "account.edit_profile"
       },
@@ -804,6 +808,10 @@
       {
         "defaultMessage": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
         "id": "compose_form.hashtag_warning"
+      },
+      {
+        "defaultMessage": "This toot will only be visible to all the mentioned users.",
+        "id": "compose_form.direct_message_warning"
       }
     ],
     "path": "app/javascript/mastodon/features/compose/containers/warning_container.json"
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index d2133b1f6..da75f5fe2 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -2,6 +2,7 @@
   "account.block": "Block @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct message @{name}",
   "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Edit profile",
@@ -60,6 +61,7 @@
   "column_subheading.lists": "Lists",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 35d9edf2b..9b00edb00 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -2,6 +2,7 @@
   "account.block": "Bloki @{name}",
   "account.block_domain": "Kaŝi ĉion de {domain}",
   "account.blocked": "Blokita",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Subaj informoj povas reflekti la profilon de la uzanto nekomplete.",
   "account.domain_blocked": "Domajno kaŝita",
   "account.edit_profile": "Redakti profilon",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Depingli",
   "column_subheading.navigation": "Navigado",
   "column_subheading.settings": "Agordado",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Ĉi tiu mesaĝo ne estos listigita per ajna kradvorto. Nur publikaj mesaĝoj estas serĉeblaj per kradvortoj.",
   "compose_form.lock_disclaimer": "Via konta ne estas {locked}. Iu ajn povas sekvi vin por vidi viajn mesaĝojn nur por sekvantoj.",
   "compose_form.lock_disclaimer.lock": "ŝlosita",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index e69938b0f..9f03b31c1 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -2,6 +2,7 @@
   "account.block": "Bloquear",
   "account.block_domain": "Ocultar todo de {domain}",
   "account.blocked": "Bloqueado",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "La siguiente información del usuario puede estar incompleta.",
   "account.domain_blocked": "Dominio oculto",
   "account.edit_profile": "Editar perfil",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Dejar de fijar",
   "column_subheading.navigation": "Navegación",
   "column_subheading.settings": "Ajustes",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Este toot no se mostrará bajo hashtags porque no es público. Sólo los toots públicos se pueden buscar por hashtag.",
   "compose_form.lock_disclaimer": "Tu cuenta no está bloqueada. Todos pueden seguirte para ver tus toots solo para seguidores.",
   "compose_form.lock_disclaimer.lock": "bloqueado",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index c9695d0a4..9421746b1 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -2,6 +2,7 @@
   "account.block": "مسدودسازی @{name}",
   "account.block_domain": "پنهان‌سازی همه چیز از سرور {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "اطلاعات زیر ممکن است نمایهٔ این کاربر را به تمامی نشان ندهد.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "ویرایش نمایه",
@@ -56,6 +57,7 @@
   "column_header.unpin": "رهاکردن",
   "column_subheading.navigation": "گشت و گذار",
   "column_subheading.settings": "تنظیمات",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "حساب شما {locked} نیست. هر کسی می‌تواند پیگیر شما شود و نوشته‌های ویژهٔ پیگیران شما را ببیند.",
   "compose_form.lock_disclaimer.lock": "قفل",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index cbdffec10..fce441df4 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -2,6 +2,7 @@
   "account.block": "Estä @{name}",
   "account.block_domain": "Piilota kaikki sisältö verkkotunnuksesta {domain}",
   "account.blocked": "Estetty",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Alla olevat käyttäjän profiilitiedot saattavat olla epätäydellisiä.",
   "account.domain_blocked": "Verkko-osoite piilotettu",
   "account.edit_profile": "Muokkaa",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Poista kiinnitys",
   "column_subheading.navigation": "Navigaatio",
   "column_subheading.settings": "Asetukset",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Tämä töötti ei tule näkymään hashtag-hauissa, koska se ei näy julkisilla aikajanoilla. Vain julkisia tööttejä voi hakea hashtageilla.",
   "compose_form.lock_disclaimer": "Tilisi ei ole {locked}. Kuka tahansa voi seurata tiliäsi ja nähdä vain seuraajille -postauksesi.",
   "compose_form.lock_disclaimer.lock": "lukittu",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 8c56a7558..6eb34e644 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -2,6 +2,7 @@
   "account.block": "Bloquer @{name}",
   "account.block_domain": "Tout masquer venant de {domain}",
   "account.blocked": "Bloqué",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Les données ci-dessous peuvent ne pas refléter ce profil dans sa totalité.",
   "account.domain_blocked": "Domaine caché",
   "account.edit_profile": "Modifier le profil",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Retirer",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Paramètres",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Ce pouet ne sera pas listé dans les recherches par hashtag car sa visibilité est réglée sur \"non-listé\". Seuls les pouets avec une visibilité \"publique\" peuvent être recherchés par hashtag.",
   "compose_form.lock_disclaimer": "Votre compte n’est pas {locked}. Tout le monde peut vous suivre et voir vos pouets privés.",
   "compose_form.lock_disclaimer.lock": "verrouillé",
diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json
index c5cedd60a..a0823b93f 100644
--- a/app/javascript/mastodon/locales/gl.json
+++ b/app/javascript/mastodon/locales/gl.json
@@ -2,6 +2,7 @@
   "account.block": "Bloquear @{name}",
   "account.block_domain": "Ocultar calquer contido de {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "A información inferior podería mostrar un perfil incompleto da usuaria.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Editar perfil",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Soltar",
   "column_subheading.navigation": "Navegación",
   "column_subheading.settings": "Axustes",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Esta mensaxe non será listada baixo ningunha etiqueta xa que está marcada como non listada. Só os toots públicos poden buscarse por etiquetas.",
   "compose_form.lock_disclaimer": "A súa conta non está {locked}. Calquera pode seguila para ver as súas mensaxes só-para-seguidoras.",
   "compose_form.lock_disclaimer.lock": "bloqueado",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index fe6f9bbb1..0e2ee8da4 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -2,6 +2,7 @@
   "account.block": "חסימת @{name}",
   "account.block_domain": "להסתיר הכל מהקהילה {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "המידע להלן עשוי להיות לא עדכני או לא שלם.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "עריכת פרופיל",
@@ -56,6 +57,7 @@
   "column_header.unpin": "שחרור קיבוע",
   "column_subheading.navigation": "ניווט",
   "column_subheading.settings": "אפשרויות",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "חשבונך אינו {locked}. כל אחד יוכל לעקוב אחריך כדי לקרוא את הודעותיך המיועדות לעוקבים בלבד.",
   "compose_form.lock_disclaimer.lock": "נעול",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index 11cd1bff2..1e8ce8e29 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -2,6 +2,7 @@
   "account.block": "Blokiraj @{name}",
   "account.block_domain": "Sakrij sve sa {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Ovaj korisnik je sa druge instance. Ovaj broj bi mogao biti veći.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Uredi profil",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigacija",
   "column_subheading.settings": "Postavke",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Tvoj račun nije {locked}. Svatko te može slijediti kako bi vidio postove namijenjene samo tvojim sljedbenicima.",
   "compose_form.lock_disclaimer.lock": "zaključan",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index 1ea65768a..deb17c6f4 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -2,6 +2,7 @@
   "account.block": "@{name} letiltása",
   "account.block_domain": "Minden elrejtése innen: {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Az alul található információk hiányosan mutathatják be a felhasználót.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Profil szerkesztése",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Kitűzés eltávolítása",
   "column_subheading.navigation": "Navigáció",
   "column_subheading.settings": "Beállítások",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Ezen tülkölés nem fog megjelenni semmilyen hashtag alatt mivel listázatlan. Csak a publikus tülkölések kereshetőek hashtag-el.",
   "compose_form.lock_disclaimer": "Az ön fiókja nincs {locked}. Bárki követni tud, hogy megtekintse a kizárt követőknek szánt üzeneteid.",
   "compose_form.lock_disclaimer.lock": "lezárva",
diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json
index e9638bf96..ee2055397 100644
--- a/app/javascript/mastodon/locales/hy.json
+++ b/app/javascript/mastodon/locales/hy.json
@@ -2,6 +2,7 @@
   "account.block": "Արգելափակել @{name}֊ին",
   "account.block_domain": "Թաքցնել ամենը հետեւյալ տիրույթից՝ {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Ներքոհիշյալը կարող է ոչ ամբողջությամբ արտացոլել օգտատիրոջ էջի տվյալները։",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Խմբագրել անձնական էջը",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Հանել",
   "column_subheading.navigation": "Նավարկություն",
   "column_subheading.settings": "Կարգավորումներ",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Այս թութը չի հաշվառվի որեւէ պիտակի տակ, քանզի այն ծածուկ է։ Միայն հրապարակային թթերը հնարավոր է որոնել պիտակներով։",
   "compose_form.lock_disclaimer": "Քո հաշիվը {locked} չէ։ Յուրաքանչյուր ոք կարող է հետեւել քեզ եւ տեսնել միայն հետեւողների համար նախատեսված գրառումները։",
   "compose_form.lock_disclaimer.lock": "փակ",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index c8d8ebe76..cae3211ee 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -2,6 +2,7 @@
   "account.block": "Blokir @{name}",
   "account.block_domain": "Sembunyikan segalanya dari {domain}",
   "account.blocked": "Terblokir",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Informasi di bawah mungkin tidak mencerminkan profil user secara lengkap.",
   "account.domain_blocked": "Domain disembunyikan",
   "account.edit_profile": "Ubah profil",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Lepaskan",
   "column_subheading.navigation": "Navigasi",
   "column_subheading.settings": "Pengaturan",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Toot ini tidak akan ada dalam daftar tagar manapun karena telah di set sebagai tidak terdaftar. Hanya postingan publik yang bisa dicari dengan tagar.",
   "compose_form.lock_disclaimer": "Akun anda tidak {locked}. Semua orang dapat mengikuti anda untuk melihat postingan khusus untuk pengikut anda.",
   "compose_form.lock_disclaimer.lock": "terkunci",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index a2e9af8ef..121d745ca 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -2,6 +2,7 @@
   "account.block": "Blokusar @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Modifikar profilo",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 40ea9b26d..5e57143a9 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -2,6 +2,7 @@
   "account.block": "Blocca @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Modifica profilo",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 2e55af510..e6f2bbcff 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -2,6 +2,7 @@
   "account.block": "@{name}さんをブロック",
   "account.block_domain": "{domain}全体を非表示",
   "account.blocked": "ブロック済み",
+  "account.direct": "@{name}さんにダイレクトメッセージ",
   "account.disclaimer_full": "以下の情報は不正確な可能性があります。",
   "account.domain_blocked": "ドメイン非表示中",
   "account.edit_profile": "プロフィールを編集",
@@ -60,6 +61,7 @@
   "column_subheading.lists": "リスト",
   "column_subheading.navigation": "ナビゲーション",
   "column_subheading.settings": "設定",
+  "compose_form.direct_message_warning": "このトゥートはメンションされた人だけが見ることができます。",
   "compose_form.hashtag_warning": "このトゥートは未収載なのでハッシュタグの一覧に表示されません。公開トゥートだけがハッシュタグで検索できます。",
   "compose_form.lock_disclaimer": "あなたのアカウントは{locked}になっていません。誰でもあなたをフォローすることができ、フォロワー限定の投稿を見ることができます。",
   "compose_form.lock_disclaimer.lock": "非公開",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index bde4397f3..fa15214c9 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -2,6 +2,7 @@
   "account.block": "@{name}을 차단",
   "account.block_domain": "{domain} 전체를 숨김",
   "account.blocked": "차단 됨",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "여기 있는 정보는 유저의 프로파일을 정확히 반영하지 못 할 수도 있습니다.",
   "account.domain_blocked": "도메인 숨겨짐",
   "account.edit_profile": "프로필 편집",
@@ -56,6 +57,7 @@
   "column_header.unpin": "고정 해제",
   "column_subheading.navigation": "내비게이션",
   "column_subheading.settings": "설정",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "이 툿은 어떤 해시태그로도 검색 되지 않습니다. 전체공개로 게시 된 툿만이 해시태그로 검색 될 수 있습니다.",
   "compose_form.lock_disclaimer": "이 계정은 {locked}로 설정 되어 있지 않습니다. 누구나 이 계정을 팔로우 할 수 있으며, 팔로워 공개의 포스팅을 볼 수 있습니다.",
   "compose_form.lock_disclaimer.lock": "비공개",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index 140be0dca..ff827991d 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -2,6 +2,7 @@
   "account.block": "Blokkeer @{name}",
   "account.block_domain": "Negeer alles van {domain}",
   "account.blocked": "Geblokkeerd",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "De informatie hieronder kan mogelijk een incompleet beeld geven van dit gebruikersprofiel.",
   "account.domain_blocked": "Domein verborgen",
   "account.edit_profile": "Profiel bewerken",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Losmaken",
   "column_subheading.navigation": "Navigatie",
   "column_subheading.settings": "Instellingen",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Deze toot valt niet onder een hashtag te bekijken, omdat deze niet op openbare tijdlijnen wordt getoond. Alleen openbare toots kunnen via hashtags gevonden worden.",
   "compose_form.lock_disclaimer": "Jouw account is niet {locked}. Iedereen kan jou volgen en toots zien die je alleen aan volgers hebt gericht.",
   "compose_form.lock_disclaimer.lock": "besloten",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index 4d6ac133e..d3bc75708 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -2,6 +2,7 @@
   "account.block": "Blokkér @{name}",
   "account.block_domain": "Skjul alt fra {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Informasjonen nedenfor kan gi et ufullstendig bilde av brukerens profil.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Rediger profil",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Løsne",
   "column_subheading.navigation": "Navigasjon",
   "column_subheading.settings": "Innstillinger",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Denne tuten blir ikke listet under noen emneknagger da den er ulistet. Kun offentlige tuter kan søktes etter med emneknagg.",
   "compose_form.lock_disclaimer": "Din konto er ikke {locked}. Hvem som helst kan følge deg og se dine private poster.",
   "compose_form.lock_disclaimer.lock": "låst",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index 24dfa9375..39ba31de3 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -2,6 +2,7 @@
   "account.block": "Blocar @{name}",
   "account.block_domain": "Tot amagar del domeni {domain}",
   "account.blocked": "Blocat",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Aquelas informacions de perfil pòdon èsser incomplètas.",
   "account.domain_blocked": "Domeni amagat",
   "account.edit_profile": "Modificar lo perfil",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Despenjar",
   "column_subheading.navigation": "Navigacion",
   "column_subheading.settings": "Paramètres",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Aqueste tut serà pas ligat a cap etiqueta estant qu’es pas listat. Òm pas cercar que los tuts publics per etiqueta.",
   "compose_form.lock_disclaimer": "Vòstre compte es pas {locked}. Tot lo mond pòt vos sègre e veire los estatuts reservats als seguidors.",
   "compose_form.lock_disclaimer.lock": "clavat",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index dcd2d12b3..fa25192e6 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -2,6 +2,7 @@
   "account.block": "Blokuj @{name}",
   "account.block_domain": "Blokuj wszystko z {domain}",
   "account.blocked": "Zablokowany",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Poniższe informacje mogą nie odwzorowywać bezbłędnie profilu użytkownika.",
   "account.domain_blocked": "Ukryto domenę",
   "account.edit_profile": "Edytuj profil",
@@ -60,6 +61,7 @@
   "column_subheading.lists": "Listy",
   "column_subheading.navigation": "Nawigacja",
   "column_subheading.settings": "Ustawienia",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Ten wpis nie będzie widoczny pod podanymi hashtagami, ponieważ jest oznaczony jako niewidoczny. Tylko publiczne wpisy mogą zostać znalezione z użyciem hashtagów.",
   "compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię śledzi, może wyświetlać Twoje wpisy przeznaczone tylko dla śledzących.",
   "compose_form.lock_disclaimer.lock": "zablokowane",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index dcaeaced9..3d42eedb3 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -2,6 +2,7 @@
   "account.block": "Bloquear @{name}",
   "account.block_domain": "Esconder tudo de {domain}",
   "account.blocked": "Bloqueado",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "As informações abaixo podem refletir o perfil do usuário de maneira incompleta.",
   "account.domain_blocked": "Domínio escondido",
   "account.edit_profile": "Editar perfil",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Desafixar",
   "column_subheading.navigation": "Navegação",
   "column_subheading.settings": "Configurações",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Esse toot não será listado em nenhuma hashtag por ser não listado. Somente toots públicos podem ser pesquisados por hashtag.",
   "compose_form.lock_disclaimer": "A sua conta não está {locked}. Qualquer pessoa pode te seguir e visualizar postagens direcionadas a apenas seguidores.",
   "compose_form.lock_disclaimer.lock": "trancada",
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index 4725a82da..5c93614a9 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -2,6 +2,7 @@
   "account.block": "Bloquear @{name}",
   "account.block_domain": "Esconder tudo do domínio {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "As informações abaixo podem refletir o perfil do usuário de forma incompleta.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Editar perfil",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Desafixar",
   "column_subheading.navigation": "Navegação",
   "column_subheading.settings": "Preferências",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Esta pulbicacção não será listada em nenhuma hashtag por ser não listada. Somente publicações públicas podem ser pesquisadas por hashtag.",
   "compose_form.lock_disclaimer": "A tua conta não está {locked}. Qualquer pessoa pode seguir-te e ver as publicações direcionadas apenas a seguidores.",
   "compose_form.lock_disclaimer.lock": "bloqueada",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index 8e7d36659..7dffbb210 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -2,6 +2,7 @@
   "account.block": "Блокировать",
   "account.block_domain": "Блокировать все с {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Нижеуказанная информация может не полностью отражать профиль пользователя.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Изменить профиль",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Открепить",
   "column_subheading.navigation": "Навигация",
   "column_subheading.settings": "Настройки",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Этот пост не будет показывается в поиске по хэштегу, т.к. он непубличный. Только публичные посты можно найти в поиске по хэштегу.",
   "compose_form.lock_disclaimer": "Ваш аккаунт не {locked}. Любой человек может подписаться на Вас и просматривать посты для подписчиков.",
   "compose_form.lock_disclaimer.lock": "закрыт",
diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json
index e3b323943..0a248d261 100644
--- a/app/javascript/mastodon/locales/sk.json
+++ b/app/javascript/mastodon/locales/sk.json
@@ -2,6 +2,7 @@
   "account.block": "Blokovať @{name}",
   "account.block_domain": "Ukryť všetko z {domain}",
   "account.blocked": "Blokovaný/á",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Inofrmácie nižšie nemusia byť úplným odrazom uživateľovho účtu.",
   "account.domain_blocked": "Doména ukrytá",
   "account.edit_profile": "Upraviť profil",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Odopnúť",
   "column_subheading.navigation": "Navigácia",
   "column_subheading.settings": "Nastavenia",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Tento toot nebude zobrazený pod žiadným haštagom lebo nieje listovaný. Iba verejné tooty môžu byť nájdené podľa haštagu.",
   "compose_form.lock_disclaimer": "Váš účet nie je zamknutý. Ktokoľvek ťa môže nasledovať a vidieť tvoje správy pre sledujúcich.",
   "compose_form.lock_disclaimer.lock": "zamknutý",
diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json
index d38e8e3af..b9effce96 100644
--- a/app/javascript/mastodon/locales/sr-Latn.json
+++ b/app/javascript/mastodon/locales/sr-Latn.json
@@ -2,6 +2,7 @@
   "account.block": "Blokiraj korisnika @{name}",
   "account.block_domain": "Sakrij sve sa domena {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Navedene informacije možda ne odslikavaju korisnički profil u potpunosti.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Izmeni profil",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Otkači",
   "column_subheading.navigation": "Navigacija",
   "column_subheading.settings": "Postavke",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Vaš nalog nije {locked}. Svako može da Vas zaprati i da vidi objave namenjene samo Vašim pratiocima.",
   "compose_form.lock_disclaimer.lock": "zaključan",
diff --git a/app/javascript/mastodon/locales/sr.json b/app/javascript/mastodon/locales/sr.json
index 3be0c89ee..a6c5f220e 100644
--- a/app/javascript/mastodon/locales/sr.json
+++ b/app/javascript/mastodon/locales/sr.json
@@ -2,6 +2,7 @@
   "account.block": "Блокирај корисника @{name}",
   "account.block_domain": "Сакриј све са домена {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Наведене информације можда не одсликавају кориснички профил у потпуности.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Измени профил",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Откачи",
   "column_subheading.navigation": "Навигација",
   "column_subheading.settings": "Поставке",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Ваш налог није {locked}. Свако може да Вас запрати и да види објаве намењене само Вашим пратиоцима.",
   "compose_form.lock_disclaimer.lock": "закључан",
diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json
index a13ba9847..6dc3d7a98 100644
--- a/app/javascript/mastodon/locales/sv.json
+++ b/app/javascript/mastodon/locales/sv.json
@@ -2,6 +2,7 @@
   "account.block": "Blockera @{name}",
   "account.block_domain": "Dölj allt från {domain}",
   "account.blocked": "Blockerad",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Informationen nedan kan spegla användarens profil ofullständigt.",
   "account.domain_blocked": "Domän gömd",
   "account.edit_profile": "Redigera profil",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Ångra fäst",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Inställningar",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "Denna toot kommer inte att listas under någon hashtag eftersom den är onoterad. Endast offentliga toots kan sökas med hashtag.",
   "compose_form.lock_disclaimer": "Ditt konto är inte {locked}. Vemsomhelst kan följa dig och även se dina inlägg skrivna för endast dina följare.",
   "compose_form.lock_disclaimer.lock": "låst",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index 59ff10b46..4de354007 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -2,6 +2,7 @@
   "account.block": "Block @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Edit profile",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index e83af319e..9d0affea4 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -2,6 +2,7 @@
   "account.block": "Engelle @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Profili düzenle",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigasyon",
   "column_subheading.settings": "Ayarlar",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Hesabınız {locked} değil. Sadece takipçilerle paylaştığınız gönderileri görebilmek için sizi herhangi bir kullanıcı takip edebilir.",
   "compose_form.lock_disclaimer.lock": "kilitli",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index accc2d027..c49d3c7ae 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -2,6 +2,7 @@
   "account.block": "Заблокувати",
   "account.block_domain": "Заглушити {domain}",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "Налаштування профілю",
@@ -56,6 +57,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Навігація",
   "column_subheading.settings": "Налаштування",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Ваш акаунт не {locked}. Кожен може підписатися на Вас та бачити Ваші приватні пости.",
   "compose_form.lock_disclaimer.lock": "приватний",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index b9a912fb0..e95cf81f4 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -2,6 +2,7 @@
   "account.block": "屏蔽 @{name}",
   "account.block_domain": "隐藏来自 {domain} 的内容",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "此处显示的信息可能不是全部内容。",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "修改个人资料",
@@ -56,6 +57,7 @@
   "column_header.unpin": "取消固定",
   "column_subheading.navigation": "导航",
   "column_subheading.settings": "设置",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "这条嘟文被设置为“不公开”,因此它不会出现在任何话题标签的列表下。只有公开的嘟文才能通过话题标签进行搜索。",
   "compose_form.lock_disclaimer": "你的帐户没有{locked}。任何人都可以在关注你后立即查看仅关注者可见的嘟文。",
   "compose_form.lock_disclaimer.lock": "开启保护",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index 91b1d00af..1801c838d 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -2,6 +2,7 @@
   "account.block": "封鎖 @{name}",
   "account.block_domain": "隱藏來自 {domain} 的一切文章",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "下列資料不一定完整。",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "修改個人資料",
@@ -56,6 +57,7 @@
   "column_header.unpin": "取下",
   "column_subheading.navigation": "瀏覽",
   "column_subheading.settings": "設定",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "你的用戶狀態為「{locked}」,任何人都能立即關注你,然後看到「只有關注者能看」的文章。",
   "compose_form.lock_disclaimer.lock": "公共",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 7e845c650..acbe6eb8e 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -2,6 +2,7 @@
   "account.block": "封鎖 @{name}",
   "account.block_domain": "隱藏來自 {domain} 的一切貼文",
   "account.blocked": "Blocked",
+  "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "下列資料不一定完整。",
   "account.domain_blocked": "Domain hidden",
   "account.edit_profile": "編輯用者資訊",
@@ -56,6 +57,7 @@
   "column_header.unpin": "取下",
   "column_subheading.navigation": "瀏覽",
   "column_subheading.settings": "設定",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
   "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "你的帳號沒有{locked}。任何人都可以關注你,看到發給關注者的貼文。",
   "compose_form.lock_disclaimer.lock": "上鎖",
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 5eadebb81..a48c46941 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -4,6 +4,7 @@ import {
   COMPOSE_CHANGE,
   COMPOSE_REPLY,
   COMPOSE_REPLY_CANCEL,
+  COMPOSE_DIRECT,
   COMPOSE_MENTION,
   COMPOSE_SUBMIT_REQUEST,
   COMPOSE_SUBMIT_SUCCESS,
@@ -262,6 +263,12 @@ export default function compose(state = initialState, action) {
       .update('text', text => `${text}@${action.account.get('acct')} `)
       .set('focusDate', new Date())
       .set('idempotencyKey', uuid());
+  case COMPOSE_DIRECT:
+    return state
+      .update('text', text => `${text}@${action.account.get('acct')} `)
+      .set('privacy', 'direct')
+      .set('focusDate', new Date())
+      .set('idempotencyKey', uuid());
   case COMPOSE_SUGGESTIONS_CLEAR:
     return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
   case COMPOSE_SUGGESTIONS_READY:
diff --git a/app/javascript/mastodon/reducers/domain_lists.js b/app/javascript/mastodon/reducers/domain_lists.js
new file mode 100644
index 000000000..a9e3519f3
--- /dev/null
+++ b/app/javascript/mastodon/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/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index b84b2d18a..3d9a6a132 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -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,
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
index c7b04a668..d1caabc1c 100644
--- a/app/javascript/mastodon/reducers/relationships.js
+++ b/app/javascript/mastodon/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/mastodon/storage/modifier.js b/app/javascript/mastodon/storage/modifier.js
index 63e49fe6e..1bec04d0f 100644
--- a/app/javascript/mastodon/storage/modifier.js
+++ b/app/javascript/mastodon/storage/modifier.js
@@ -4,7 +4,10 @@ import { autoPlayGif } from '../initial_state';
 const accountAssetKeys = ['avatar', 'avatar_static', 'header', 'header_static'];
 const avatarKey = autoPlayGif ? 'avatar' : 'avatar_static';
 const limit = 1024;
-const asyncCache = caches.open('mastodon-system');
+
+// ServiceWorker and Cache API is not available on iOS 11
+// https://webkit.org/status/#specification-service-workers
+const asyncCache = window.caches ? caches.open('mastodon-system') : Promise.reject();
 
 function put(name, objects, onupdate, oncreate) {
   return asyncDB.then(db => new Promise((resolve, reject) => {
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 042a84742..31089301c 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -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%);
@@ -1459,9 +1483,6 @@
   position: relative;
   width: 100%;
   height: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
 
   img {
     max-width: $media-modal-media-max-width;