about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/components/actions/cards.jsx4
-rw-r--r--app/assets/javascripts/components/actions/compose.jsx2
-rw-r--r--app/assets/javascripts/components/actions/reports.jsx64
-rw-r--r--app/assets/javascripts/components/actions/statuses.jsx9
-rw-r--r--app/assets/javascripts/components/components/collapsable.jsx19
-rw-r--r--app/assets/javascripts/components/components/dropdown_menu.jsx70
-rw-r--r--app/assets/javascripts/components/components/status_action_bar.jsx14
-rw-r--r--app/assets/javascripts/components/containers/mastodon.jsx2
-rw-r--r--app/assets/javascripts/components/containers/status_container.jsx5
-rw-r--r--app/assets/javascripts/components/features/account/components/action_bar.jsx11
-rw-r--r--app/assets/javascripts/components/features/account_timeline/components/header.jsx9
-rw-r--r--app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx5
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx70
-rw-r--r--app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx4
-rw-r--r--app/assets/javascripts/components/features/report/components/status_check_box.jsx42
-rw-r--r--app/assets/javascripts/components/features/report/containers/status_check_box_container.jsx19
-rw-r--r--app/assets/javascripts/components/features/report/index.jsx130
-rw-r--r--app/assets/javascripts/components/features/status/components/action_bar.jsx10
-rw-r--r--app/assets/javascripts/components/features/status/components/card.jsx2
-rw-r--r--app/assets/javascripts/components/features/status/index.jsx19
-rw-r--r--app/assets/javascripts/components/locales/en.jsx1
-rw-r--r--app/assets/javascripts/components/locales/fr.jsx89
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx4
-rw-r--r--app/assets/javascripts/components/reducers/index.jsx4
-rw-r--r--app/assets/javascripts/components/reducers/reports.jsx57
-rw-r--r--app/assets/stylesheets/admin.scss52
-rw-r--r--app/assets/stylesheets/components.scss55
-rw-r--r--app/assets/stylesheets/forms.scss1
-rw-r--r--app/controllers/admin/accounts_controller.rb23
-rw-r--r--app/controllers/admin/reports_controller.rb45
-rw-r--r--app/controllers/api/v1/accounts_controller.rb15
-rw-r--r--app/controllers/api/v1/reports_controller.rb24
-rw-r--r--app/controllers/settings/two_factor_auths_controller.rb3
-rw-r--r--app/controllers/stream_entries_controller.rb2
-rw-r--r--app/helpers/atom_builder_helper.rb7
-rw-r--r--app/models/report.rb9
-rw-r--r--app/models/status.rb2
-rw-r--r--app/models/stream_entry.rb2
-rw-r--r--app/services/pubsubhubbub/subscribe_service.rb5
-rw-r--r--app/views/accounts/show.atom.ruby3
-rw-r--r--app/views/admin/accounts/index.html.haml14
-rw-r--r--app/views/admin/accounts/show.html.haml50
-rw-r--r--app/views/admin/reports/index.html.haml27
-rw-r--r--app/views/admin/reports/show.html.haml38
-rw-r--r--app/views/api/v1/reports/index.rabl2
-rw-r--r--app/views/api/v1/reports/show.rabl2
-rw-r--r--app/views/settings/two_factor_auths/show.html.haml4
-rw-r--r--app/workers/pubsubhubbub/distribution_worker.rb5
48 files changed, 903 insertions, 152 deletions
diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx
index 503c2bfeb..cc7baf376 100644
--- a/app/assets/javascripts/components/actions/cards.jsx
+++ b/app/assets/javascripts/components/actions/cards.jsx
@@ -6,6 +6,10 @@ export const STATUS_CARD_FETCH_FAIL    = 'STATUS_CARD_FETCH_FAIL';
 
 export function fetchStatusCard(id) {
   return (dispatch, getState) => {
+    if (getState().getIn(['cards', id], null) !== null) {
+      return;
+    }
+
     dispatch(fetchStatusCardRequest(id));
 
     api(getState).get(`/api/v1/statuses/${id}/card`).then(response => {
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx
index f87518751..03aae885e 100644
--- a/app/assets/javascripts/components/actions/compose.jsx
+++ b/app/assets/javascripts/components/actions/compose.jsx
@@ -1,4 +1,4 @@
-import api from '../api'
+import api from '../api';
 
 import { updateTimeline } from './timelines';
 
diff --git a/app/assets/javascripts/components/actions/reports.jsx b/app/assets/javascripts/components/actions/reports.jsx
new file mode 100644
index 000000000..2c1245dc4
--- /dev/null
+++ b/app/assets/javascripts/components/actions/reports.jsx
@@ -0,0 +1,64 @@
+import api from '../api';
+
+export const REPORT_INIT   = 'REPORT_INIT';
+export const REPORT_CANCEL = 'REPORT_CANCEL';
+
+export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST';
+export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS';
+export const REPORT_SUBMIT_FAIL    = 'REPORT_SUBMIT_FAIL';
+
+export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE';
+
+export function initReport(account, status) {
+  return {
+    type: REPORT_INIT,
+    account,
+    status
+  };
+};
+
+export function cancelReport() {
+  return {
+    type: REPORT_CANCEL
+  };
+};
+
+export function toggleStatusReport(statusId, checked) {
+  return {
+    type: REPORT_STATUS_TOGGLE,
+    statusId,
+    checked,
+  };
+};
+
+export function submitReport() {
+  return (dispatch, getState) => {
+    dispatch(submitReportRequest());
+
+    api(getState).post('/api/v1/reports', {
+      account_id: getState().getIn(['reports', 'new', 'account_id']),
+      status_ids: getState().getIn(['reports', 'new', 'status_ids']),
+      comment: getState().getIn(['reports', 'new', 'comment'])
+    }).then(response => dispatch(submitReportSuccess(response.data))).catch(error => dispatch(submitReportFail(error)));
+  };
+};
+
+export function submitReportRequest() {
+  return {
+    type: REPORT_SUBMIT_REQUEST
+  };
+};
+
+export function submitReportSuccess(report) {
+  return {
+    type: REPORT_SUBMIT_SUCCESS,
+    report
+  };
+};
+
+export function submitReportFail(error) {
+  return {
+    type: REPORT_SUBMIT_FAIL,
+    error
+  };
+};
diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx
index 9ac215727..ee662fe79 100644
--- a/app/assets/javascripts/components/actions/statuses.jsx
+++ b/app/assets/javascripts/components/actions/statuses.jsx
@@ -27,12 +27,17 @@ export function fetchStatus(id) {
   return (dispatch, getState) => {
     const skipLoading = getState().getIn(['statuses', id], null) !== null;
 
+    dispatch(fetchContext(id));
+    dispatch(fetchStatusCard(id));
+
+    if (skipLoading) {
+      return;
+    }
+
     dispatch(fetchStatusRequest(id, skipLoading));
 
     api(getState).get(`/api/v1/statuses/${id}`).then(response => {
       dispatch(fetchStatusSuccess(response.data, skipLoading));
-      dispatch(fetchContext(id));
-      dispatch(fetchStatusCard(id));
     }).catch(error => {
       dispatch(fetchStatusFail(id, error, skipLoading));
     });
diff --git a/app/assets/javascripts/components/components/collapsable.jsx b/app/assets/javascripts/components/components/collapsable.jsx
new file mode 100644
index 000000000..aeebb4b0f
--- /dev/null
+++ b/app/assets/javascripts/components/components/collapsable.jsx
@@ -0,0 +1,19 @@
+import { Motion, spring } from 'react-motion';
+
+const Collapsable = ({ fullHeight, isVisible, children }) => (
+  <Motion defaultStyle={{ opacity: !isVisible ? 0 : 100, height: isVisible ? fullHeight : 0 }} style={{ opacity: spring(!isVisible ? 0 : 100), height: spring(!isVisible ? 0 : fullHeight) }}>
+    {({ opacity, height }) =>
+      <div style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100, display: Math.floor(opacity) === 0 ? 'none' : 'block' }}>
+        {children}
+      </div>
+    }
+  </Motion>
+);
+
+Collapsable.propTypes = {
+  fullHeight: React.PropTypes.number.isRequired,
+  isVisible: React.PropTypes.bool.isRequired,
+  children: React.PropTypes.node.isRequired
+};
+
+export default Collapsable;
diff --git a/app/assets/javascripts/components/components/dropdown_menu.jsx b/app/assets/javascripts/components/components/dropdown_menu.jsx
index ffef29c00..0a8492b56 100644
--- a/app/assets/javascripts/components/components/dropdown_menu.jsx
+++ b/app/assets/javascripts/components/components/dropdown_menu.jsx
@@ -1,32 +1,46 @@
 import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
 
-const DropdownMenu = ({ icon, items, size, direction }) => {
-  const directionClass = (direction == "left") ? "dropdown__left" : "dropdown__right";
-
-  return (
-    <Dropdown>
-      <DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }}>
-        <i className={`fa fa-fw fa-${icon}`} style={{ verticalAlign: 'middle' }} />
-      </DropdownTrigger>
-
-      <DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}>
-        <ul>
-          {items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => {
-            if (typeof action === 'function') {
-              e.preventDefault();
-              action();
-            }
-          }}>{text}</a></li>)}
-        </ul>
-      </DropdownContent>
-    </Dropdown>
-  );
-};
-
-DropdownMenu.propTypes = {
-  icon: React.PropTypes.string.isRequired,
-  items: React.PropTypes.array.isRequired,
-  size: React.PropTypes.number.isRequired
-};
+const DropdownMenu = React.createClass({
+
+  propTypes: {
+    icon: React.PropTypes.string.isRequired,
+    items: React.PropTypes.array.isRequired,
+    size: React.PropTypes.number.isRequired,
+    direction: React.PropTypes.string
+  },
+
+  mixins: [PureRenderMixin],
+
+  setRef (c) {
+    this.dropdown = c;
+  },
+
+  render () {
+    const { icon, items, size, direction } = this.props;
+    const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right";
+
+    return (
+      <Dropdown ref={this.setRef}>
+        <DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }}>
+          <i className={`fa fa-fw fa-${icon}`} style={{ verticalAlign: 'middle' }} />
+        </DropdownTrigger>
+
+        <DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}>
+          <ul>
+            {items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => {
+              if (typeof action === 'function') {
+                e.preventDefault();
+                action();
+                this.dropdown.hide();
+              }
+            }}>{text}</a></li>)}
+          </ul>
+        </DropdownContent>
+      </Dropdown>
+    );
+  }
+
+});
 
 export default DropdownMenu;
diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx
index f2cc1fb12..35c458b5e 100644
--- a/app/assets/javascripts/components/components/status_action_bar.jsx
+++ b/app/assets/javascripts/components/components/status_action_bar.jsx
@@ -11,7 +11,8 @@ const messages = defineMessages({
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
   reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
-  open: { id: 'status.open', defaultMessage: 'Expand' }
+  open: { id: 'status.open', defaultMessage: 'Expand' },
+  report: { id: 'status.report', defaultMessage: 'Report' }
 });
 
 const StatusActionBar = React.createClass({
@@ -27,7 +28,10 @@ const StatusActionBar = React.createClass({
     onReblog: React.PropTypes.func,
     onDelete: React.PropTypes.func,
     onMention: React.PropTypes.func,
-    onBlock: React.PropTypes.func
+    onBlock: React.PropTypes.func,
+    onReport: React.PropTypes.func,
+    me: React.PropTypes.number.isRequired,
+    intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -60,6 +64,11 @@ const StatusActionBar = React.createClass({
     this.context.router.push(`/statuses/${this.props.status.get('id')}`);
   },
 
+  handleReport () {
+    this.props.onReport(this.props.status);
+    this.context.router.push('/report');
+  },
+
   render () {
     const { status, me, intl } = this.props;
     let menu = [];
@@ -71,6 +80,7 @@ const StatusActionBar = React.createClass({
     } else {
       menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
       menu.push({ text: intl.formatMessage(messages.block), action: this.handleBlockClick });
+      menu.push({ text: intl.formatMessage(messages.report), action: this.handleReport });
     }
 
     return (
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index e23c65121..ebef5c81b 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -34,6 +34,7 @@ import FollowRequests from '../features/follow_requests';
 import GenericNotFound from '../features/generic_not_found';
 import FavouritedStatuses from '../features/favourited_statuses';
 import Blocks from '../features/blocks';
+import Report from '../features/report';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import en from 'react-intl/locale-data/en';
 import de from 'react-intl/locale-data/de';
@@ -131,6 +132,7 @@ const Mastodon = React.createClass({
 
               <Route path='follow_requests' component={FollowRequests} />
               <Route path='blocks' component={Blocks} />
+              <Route path='report' component={Report} />
 
               <Route path='*' component={GenericNotFound} />
             </Route>
diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx
index f5fb09d52..fc096a375 100644
--- a/app/assets/javascripts/components/containers/status_container.jsx
+++ b/app/assets/javascripts/components/containers/status_container.jsx
@@ -13,6 +13,7 @@ import {
 } from '../actions/interactions';
 import { blockAccount } from '../actions/accounts';
 import { deleteStatus } from '../actions/statuses';
+import { initReport } from '../actions/reports';
 import { openMedia } from '../actions/modal';
 import { createSelector } from 'reselect'
 import { isMobile } from '../is_mobile'
@@ -97,6 +98,10 @@ const mapDispatchToProps = (dispatch) => ({
 
   onBlock (account) {
     dispatch(blockAccount(account.get('id')));
+  },
+
+  onReport (status) {
+    dispatch(initReport(status.get('account'), status));
   }
 
 });
diff --git a/app/assets/javascripts/components/features/account/components/action_bar.jsx b/app/assets/javascripts/components/features/account/components/action_bar.jsx
index fe110954d..a2ab8172b 100644
--- a/app/assets/javascripts/components/features/account/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx
@@ -11,7 +11,8 @@ const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
   block: { id: 'account.block', defaultMessage: 'Block' },
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  block: { id: 'account.block', defaultMessage: 'Block' }
+  block: { id: 'account.block', defaultMessage: 'Block' },
+  report: { id: 'account.report', defaultMessage: 'Report' }
 });
 
 const outerDropdownStyle = {
@@ -32,7 +33,9 @@ const ActionBar = React.createClass({
     me: React.PropTypes.number.isRequired,
     onFollow: React.PropTypes.func,
     onBlock: React.PropTypes.func.isRequired,
-    onMention: React.PropTypes.func.isRequired
+    onMention: React.PropTypes.func.isRequired,
+    onReport: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -54,6 +57,10 @@ const ActionBar = React.createClass({
       menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
     }
 
+    if (account.get('id') !== me) {
+      menu.push({ text: intl.formatMessage(messages.report), action: this.props.onReport });
+    }
+
     return (
       <div className='account__action-bar'>
         <div style={outerDropdownStyle}>
diff --git a/app/assets/javascripts/components/features/account_timeline/components/header.jsx b/app/assets/javascripts/components/features/account_timeline/components/header.jsx
index ff3e8af2d..0cdfc8b02 100644
--- a/app/assets/javascripts/components/features/account_timeline/components/header.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/components/header.jsx
@@ -13,7 +13,8 @@ const Header = React.createClass({
     me: React.PropTypes.number.isRequired,
     onFollow: React.PropTypes.func.isRequired,
     onBlock: React.PropTypes.func.isRequired,
-    onMention: React.PropTypes.func.isRequired
+    onMention: React.PropTypes.func.isRequired,
+    onReport: React.PropTypes.func.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -30,6 +31,11 @@ const Header = React.createClass({
     this.props.onMention(this.props.account, this.context.router);
   },
 
+  handleReport () {
+    this.props.onReport(this.props.account);
+    this.context.router.push('/report');
+  },
+
   render () {
     const { account, me } = this.props;
 
@@ -50,6 +56,7 @@ const Header = React.createClass({
           me={me}
           onBlock={this.handleBlock}
           onMention={this.handleMention}
+          onReport={this.handleReport}
         />
       </div>
     );
diff --git a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx
index dca826596..e4ce905fe 100644
--- a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx
@@ -8,6 +8,7 @@ import {
   unblockAccount
 } from '../../../actions/accounts';
 import { mentionCompose } from '../../../actions/compose';
+import { initReport } from '../../../actions/reports';
 
 const makeMapStateToProps = () => {
   const getAccount = makeGetAccount();
@@ -39,6 +40,10 @@ const mapDispatchToProps = dispatch => ({
 
   onMention (account, router) {
     dispatch(mentionCompose(account, router));
+  },
+
+  onReport (account) {
+    dispatch(initReport(account));
   }
 });
 
diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
index 46b62964a..9edc01ed7 100644
--- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -10,7 +10,7 @@ import { debounce } from 'react-decoration';
 import UploadButtonContainer from '../containers/upload_button_container';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import Toggle from 'react-toggle';
-import { Motion, spring } from 'react-motion';
+import Collapsable from '../../../components/collapsable';
 
 const messages = defineMessages({
   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@@ -36,6 +36,8 @@ const ComposeForm = React.createClass({
     in_reply_to: ImmutablePropTypes.map,
     media_count: React.PropTypes.number,
     me: React.PropTypes.number,
+    needsPrivacyWarning: React.PropTypes.bool,
+    mentionedDomains: React.PropTypes.array.isRequired,
     onChange: React.PropTypes.func.isRequired,
     onSubmit: React.PropTypes.func.isRequired,
     onCancelReply: React.PropTypes.func.isRequired,
@@ -117,16 +119,29 @@ const ComposeForm = React.createClass({
   },
 
   render () {
-    const { intl }  = this.props;
-    let replyArea   = '';
-    let publishText = '';
-    const disabled  = this.props.is_submitting || this.props.is_uploading;
+    const { intl, needsPrivacyWarning, mentionedDomains } = this.props;
+    const disabled = this.props.is_submitting || this.props.is_uploading;
+
+    let replyArea      = '';
+    let publishText    = '';
+    let privacyWarning = '';
+    let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me);
 
     if (this.props.in_reply_to) {
       replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
     }
 
-    let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me);
+    if (needsPrivacyWarning) {
+      privacyWarning = (
+        <div className='compose-form__warning'>
+          <FormattedMessage
+            id='compose_form.privacy_disclaimer'
+            defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}} to not leak your status?'
+            values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.length }}
+          />
+        </div>
+      );
+    }
 
     if (this.props.private) {
       publishText = <span><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
@@ -136,14 +151,13 @@ const ComposeForm = React.createClass({
 
     return (
       <div style={{ padding: '10px' }}>
-        <Motion defaultStyle={{ opacity: !this.props.spoiler ? 0 : 100, height: !this.props.spoiler ? 50 : 0 }} style={{ opacity: spring(!this.props.spoiler ? 0 : 100), height: spring(!this.props.spoiler ? 0 : 50) }}>
-          {({ opacity, height }) =>
-            <div className="spoiler-input" style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
-              <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} type="text" className="spoiler-input__input" />
-            </div>
-          }
-        </Motion>
+        <Collapsable isVisible={this.props.spoiler} fullHeight={50}>
+          <div className="spoiler-input">
+            <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} type="text" className="spoiler-input__input" />
+          </div>
+        </Collapsable>
 
+        {privacyWarning}
         {replyArea}
 
         <AutosuggestTextarea
@@ -176,23 +190,19 @@ const ComposeForm = React.createClass({
           <span className='compose-form__label__text'><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
         </label>
 
-        <Motion defaultStyle={{ opacity: (this.props.private || reply_to_other) ? 0 : 100, height: (this.props.private || reply_to_other) ? 39.5 : 0 }} style={{ opacity: spring((this.props.private || reply_to_other) ? 0 : 100), height: spring((this.props.private || reply_to_other) ? 0 : 39.5) }}>
-          {({ opacity, height }) =>
-            <label className='compose-form__label' style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
-              <Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} />
-              <span className='compose-form__label__text'><FormattedMessage id='compose_form.unlisted' defaultMessage='Do not display in public timeline' /></span>
-            </label>
-          }
-        </Motion>
-
-        <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(this.props.media_count === 0 ? 0 : 100), height: spring(this.props.media_count === 0 ? 0 : 39.5) }}>
-          {({ opacity, height }) =>
-            <label className='compose-form__label' style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
-              <Toggle checked={this.props.sensitive} onChange={this.handleChangeSensitivity} />
-              <span className='compose-form__label__text'><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark media as sensitive' /></span>
-            </label>
-          }
-        </Motion>
+        <Collapsable isVisible={!(this.props.private || reply_to_other)} fullHeight={39.5}>
+          <label className='compose-form__label'>
+            <Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} />
+            <span className='compose-form__label__text'><FormattedMessage id='compose_form.unlisted' defaultMessage='Do not display in public timeline' /></span>
+          </label>
+        </Collapsable>
+
+        <Collapsable isVisible={this.props.media_count > 0} fullHeight={39.5}>
+          <label className='compose-form__label'>
+            <Toggle checked={this.props.sensitive} onChange={this.handleChangeSensitivity} />
+            <span className='compose-form__label__text'><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark media as sensitive' /></span>
+          </label>
+        </Collapsable>
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
index c027875cd..2671ea618 100644
--- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
@@ -19,6 +19,8 @@ const makeMapStateToProps = () => {
   const getStatus = makeGetStatus();
 
   const mapStateToProps = function (state, props) {
+    const mentionedUsernamesWithDomains = state.getIn(['compose', 'text']).match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig);
+
     return {
       text: state.getIn(['compose', 'text']),
       suggestion_token: state.getIn(['compose', 'suggestion_token']),
@@ -34,6 +36,8 @@ const makeMapStateToProps = () => {
       in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
       media_count: state.getIn(['compose', 'media_attachments']).size,
       me: state.getIn(['compose', 'me']),
+      needsPrivacyWarning: state.getIn(['compose', 'private']) && mentionedUsernamesWithDomains !== null,
+      mentionedDomains: mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : []
     };
   };
 
diff --git a/app/assets/javascripts/components/features/report/components/status_check_box.jsx b/app/assets/javascripts/components/features/report/components/status_check_box.jsx
new file mode 100644
index 000000000..6d976582b
--- /dev/null
+++ b/app/assets/javascripts/components/features/report/components/status_check_box.jsx
@@ -0,0 +1,42 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import emojify from '../../../emoji';
+import Toggle from 'react-toggle';
+
+const StatusCheckBox = React.createClass({
+
+  propTypes: {
+    status: ImmutablePropTypes.map.isRequired,
+    checked: React.PropTypes.bool,
+    onToggle: React.PropTypes.func.isRequired,
+    disabled: React.PropTypes.bool
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { status, checked, onToggle, disabled } = this.props;
+    const content = { __html: emojify(status.get('content')) };
+
+    if (status.get('reblog')) {
+      return null;
+    }
+
+    return (
+      <div className='status-check-box' style={{ display: 'flex' }}>
+        <div
+          className='status__content'
+          style={{ flex: '1 1 auto', padding: '10px' }}
+          dangerouslySetInnerHTML={content}
+        />
+
+        <div style={{ flex: '0 0 auto', padding: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
+          <Toggle checked={checked} onChange={onToggle} disabled={disabled} />
+        </div>
+      </div>
+    );
+  }
+
+});
+
+export default StatusCheckBox;
diff --git a/app/assets/javascripts/components/features/report/containers/status_check_box_container.jsx b/app/assets/javascripts/components/features/report/containers/status_check_box_container.jsx
new file mode 100644
index 000000000..67ce9d9f3
--- /dev/null
+++ b/app/assets/javascripts/components/features/report/containers/status_check_box_container.jsx
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+import StatusCheckBox from '../components/status_check_box';
+import { toggleStatusReport } from '../../../actions/reports';
+import Immutable from 'immutable';
+
+const mapStateToProps = (state, { id }) => ({
+  status: state.getIn(['statuses', id]),
+  checked: state.getIn(['reports', 'new', 'status_ids'], Immutable.Set()).includes(id)
+});
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+
+  onToggle (e) {
+    dispatch(toggleStatusReport(id, e.target.checked));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox);
diff --git a/app/assets/javascripts/components/features/report/index.jsx b/app/assets/javascripts/components/features/report/index.jsx
new file mode 100644
index 000000000..3177d28b1
--- /dev/null
+++ b/app/assets/javascripts/components/features/report/index.jsx
@@ -0,0 +1,130 @@
+import { connect } from 'react-redux';
+import { cancelReport, changeReportComment, submitReport } from '../../actions/reports';
+import { fetchAccountTimeline } from '../../actions/accounts';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from '../ui/components/column';
+import Button from '../../components/button';
+import { makeGetAccount } from '../../selectors';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import StatusCheckBox from './containers/status_check_box_container';
+import Immutable from 'immutable';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+
+const messages = defineMessages({
+  heading: { id: 'report.heading', defaultMessage: 'New report' },
+  placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
+  submit: { id: 'report.submit', defaultMessage: 'Submit' }
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = state => {
+    const accountId = state.getIn(['reports', 'new', 'account_id']);
+
+    return {
+      isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
+      account: getAccount(state, accountId),
+      comment: state.getIn(['reports', 'new', 'comment']),
+      statusIds: Immutable.OrderedSet(state.getIn(['timelines', 'accounts_timelines', accountId, 'items'])).union(state.getIn(['reports', 'new', 'status_ids']))
+    };
+  };
+
+  return mapStateToProps;
+};
+
+const textareaStyle = {
+  marginBottom: '10px'
+};
+
+const Report = React.createClass({
+
+  contextTypes: {
+    router: React.PropTypes.object
+  },
+
+  propTypes: {
+    isSubmitting: React.PropTypes.bool,
+    account: ImmutablePropTypes.map,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    comment: React.PropTypes.string.isRequired,
+    dispatch: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  componentWillMount () {
+    if (!this.props.account) {
+      this.context.router.replace('/');
+    }
+  },
+
+  componentDidMount () {
+    if (!this.props.account) {
+      return;
+    }
+
+    this.props.dispatch(fetchAccountTimeline(this.props.account.get('id')));
+  },
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.account !== nextProps.account && nextProps.account) {
+      this.props.dispatch(fetchAccountTimeline(nextProps.account.get('id')));
+    }
+  },
+
+  handleCommentChange (e) {
+    this.props.dispatch(changeReportComment(e.target.value));
+  },
+
+  handleSubmit () {
+    this.props.dispatch(submitReport());
+    this.context.router.replace('/');
+  },
+
+  render () {
+    const { account, comment, intl, statusIds, isSubmitting } = this.props;
+
+    if (!account) {
+      return null;
+    }
+
+    return (
+      <Column heading={intl.formatMessage(messages.heading)} icon='flag'>
+        <ColumnBackButtonSlim />
+        <div className='report' style={{ display: 'flex', flexDirection: 'column', maxHeight: '100%', boxSizing: 'border-box' }}>
+          <div className='report__target' style={{ flex: '0 0 auto', padding: '10px' }}>
+            <FormattedMessage id='report.target' defaultMessage='Reporting' />
+            <strong>{account.get('acct')}</strong>
+          </div>
+
+          <div style={{ flex: '1 1 auto' }} className='scrollable'>
+            <div>
+              {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)}
+            </div>
+          </div>
+
+          <div style={{ flex: '0 0 160px', padding: '10px' }}>
+            <textarea
+              className='report__textarea'
+              placeholder={intl.formatMessage(messages.placeholder)}
+              value={comment}
+              onChange={this.handleCommentChange}
+              style={textareaStyle}
+              disabled={isSubmitting}
+            />
+
+            <div style={{ marginTop: '10px', overflow: 'hidden' }}>
+              <div style={{ float: 'right' }}><Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /></div>
+            </div>
+          </div>
+        </div>
+      </Column>
+    );
+  }
+
+});
+
+export default connect(makeMapStateToProps)(injectIntl(Report));
diff --git a/app/assets/javascripts/components/features/status/components/action_bar.jsx b/app/assets/javascripts/components/features/status/components/action_bar.jsx
index 0e92acf55..cc4d5cca4 100644
--- a/app/assets/javascripts/components/features/status/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/status/components/action_bar.jsx
@@ -9,7 +9,8 @@ const messages = defineMessages({
   mention: { id: 'status.mention', defaultMessage: 'Mention' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
   reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
-  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+  report: { id: 'status.report', defaultMessage: 'Report' }
 });
 
 const ActionBar = React.createClass({
@@ -25,6 +26,7 @@ const ActionBar = React.createClass({
     onFavourite: React.PropTypes.func.isRequired,
     onDelete: React.PropTypes.func.isRequired,
     onMention: React.PropTypes.func.isRequired,
+    onReport: React.PropTypes.func,
     me: React.PropTypes.number.isRequired,
     intl: React.PropTypes.object.isRequired
   },
@@ -51,6 +53,11 @@ const ActionBar = React.createClass({
     this.props.onMention(this.props.status.get('account'), this.context.router);
   },
 
+  handleReport () {
+    this.props.onReport(this.props.status);
+    this.context.router.push('/report');
+  },
+
   render () {
     const { status, me, intl } = this.props;
 
@@ -60,6 +67,7 @@ const ActionBar = React.createClass({
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
       menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
+      menu.push({ text: intl.formatMessage(messages.report), action: this.handleReport });
     }
 
     return (
diff --git a/app/assets/javascripts/components/features/status/components/card.jsx b/app/assets/javascripts/components/features/status/components/card.jsx
index 1bb281c70..d016212fd 100644
--- a/app/assets/javascripts/components/features/status/components/card.jsx
+++ b/app/assets/javascripts/components/features/status/components/card.jsx
@@ -53,7 +53,7 @@ const Card = React.createClass({
     }
 
     return (
-      <a href={card.get('url')} className='status-card'>
+      <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'>
         {image}
 
         <div className='status-card__content' style={contentStyle}>
diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx
index 894fa3176..40c0460a5 100644
--- a/app/assets/javascripts/components/features/status/index.jsx
+++ b/app/assets/javascripts/components/features/status/index.jsx
@@ -14,6 +14,7 @@ import {
   mentionCompose
 }                            from '../../actions/compose';
 import { deleteStatus }      from '../../actions/statuses';
+import { initReport } from '../../actions/reports';
 import {
   makeGetStatus,
   getStatusAncestors,
@@ -65,7 +66,11 @@ const Status = React.createClass({
   },
 
   handleFavouriteClick (status) {
-    this.props.dispatch(favourite(status));
+    if (status.get('favourited')) {
+      this.props.dispatch(unfavourite(status));
+    } else {
+      this.props.dispatch(favourite(status));
+    }
   },
 
   handleReplyClick (status) {
@@ -73,7 +78,11 @@ const Status = React.createClass({
   },
 
   handleReblogClick (status) {
-    this.props.dispatch(reblog(status));
+    if (status.get('reblogged')) {
+      this.props.dispatch(unreblog(status));
+    } else {
+      this.props.dispatch(reblog(status));
+    }
   },
 
   handleDeleteClick (status) {
@@ -88,6 +97,10 @@ const Status = React.createClass({
     this.props.dispatch(openMedia(media, index));
   },
 
+  handleReport (status) {
+    this.props.dispatch(initReport(status.get('account'), status));
+  },
+
   renderChildren (list) {
     return list.map(id => <StatusContainer key={id} id={id} />);
   },
@@ -123,7 +136,7 @@ const Status = React.createClass({
             {ancestors}
 
             <DetailedStatus status={status} me={me} onOpenMedia={this.handleOpenMedia} />
-            <ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} />
+            <ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} />
 
             {descendants}
           </div>
diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx
index ac1c1a7d5..95962fd73 100644
--- a/app/assets/javascripts/components/locales/en.jsx
+++ b/app/assets/javascripts/components/locales/en.jsx
@@ -41,6 +41,7 @@ const en = {
   "compose_form.sensitive": "Mark media as sensitive",
   "compose_form.spoiler": "Hide text behind warning",
   "compose_form.private": "Mark as private",
+  "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}} to not leak your status?",
   "compose_form.unlisted": "Do not display in public timeline",
   "navigation_bar.edit_profile": "Edit profile",
   "navigation_bar.preferences": "Preferences",
diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx
index 183e5d5b5..2f5dd182f 100644
--- a/app/assets/javascripts/components/locales/fr.jsx
+++ b/app/assets/javascripts/components/locales/fr.jsx
@@ -1,57 +1,68 @@
 const fr = {
-  "column_back_button.label": "Retour",
-  "lightbox.close": "Fermer",
-  "loading_indicator.label": "Chargement…",
-  "status.mention": "Mentionner",
-  "status.delete": "Effacer",
-  "status.reply": "Répondre",
-  "status.reblog": "Partager",
-  "status.favourite": "Ajouter aux favoris",
-  "status.reblogged_by": "{name} a partagé :",
-  "status.sensitive_warning": "Contenu délicat",
-  "status.sensitive_toggle": "Cliquer pour dévoiler",
-  "video_player.toggle_sound": "Mettre/Couper le son",
-  "account.mention": "Mentionner",
-  "account.edit_profile": "Modifier le profil",
-  "account.unblock": "Débloquer",
-  "account.unfollow": "Ne plus suivre",
   "account.block": "Bloquer",
-  "account.follow": "Suivre",
-  "account.posts": "Statuts",
-  "account.follows": "Abonnements",
+  "account.edit_profile": "Modifier le profil",
   "account.followers": "Abonnés",
+  "account.follows": "Abonnements",
+  "account.follow": "Suivre",
   "account.follows_you": "Vous suit",
-  "getting_started.heading": "Pour commencer",
-  "getting_started.about_addressing": "Vous pouvez vous suivre les statuts de quelqu’un en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.",
-  "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, l’identifiant suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.",
-  "getting_started.about_developer": "Pour suivre le développeur de ce projet, c’est Gargron@mastodon.social",
+  "account.mention": "Mentionner",
+  "account.posts": "Statuts",
+  "account.requested": "Invitation envoyée",
+  "account.unblock": "Débloquer",
+  "account.unfollow": "Ne plus suivre",
+  "column_back_button.label": "Retour",
   "column.home": "Accueil",
   "column.mentions": "Mentions",
-  "column.public": "Fil public",
   "column.notifications": "Notifications",
-  "tabs_bar.compose": "Composer",
-  "tabs_bar.home": "Accueil",
-  "tabs_bar.mentions": "Mentions",
-  "tabs_bar.public": "Public",
-  "tabs_bar.notifications": "Notifications",
+  "column.public": "Fil public",
   "compose_form.placeholder": "Qu’avez-vous en tête ?",
-  "compose_form.publish": "Pouet",
-  "compose_form.sensitive": "Marquer le contenu comme délicat",
-  "compose_form.unlisted": "Ne pas apparaître dans le fil public",
+  "compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ?",
+  "compose_form.private": "Rendre privé",
+  "compose_form.publish": "Pouet ",
+  "compose_form.sensitive": "Marquer le média comme délicat",
+  "compose_form.spoiler": "Masque le texte par un avertissement",
+  "compose_form.unlisted": "Ne pas afficher dans le fil public",
+  "getting_started.about_addressing": "Vous pouvez vous suivre les statuts de quelqu’un en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.",
+  "getting_started.about_developer": "Pour suivre le développeur de ce projet, c’est Gargron@mastodon.social",
+  "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, l’identifiant suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.",
+  "getting_started.heading": "Pour commencer",
+  "getting_started.open_source_notice": "Mastodon est un logiciel libre. Vous pouvez contribuer et envoyer vos commentaires et rapports de bogues via {github} sur GitHub.",
+  "lightbox.close": "Fermer",
+  "loading_indicator.label": "Chargement…",
   "navigation_bar.edit_profile": "Modifier le profil",
-  "navigation_bar.preferences": "Préférences",
-  "navigation_bar.public_timeline": "Public",
   "navigation_bar.logout": "Déconnexion",
+  "navigation_bar.preferences": "Préférences",
+  "navigation_bar.public_timeline": "Fil public",
+  "notification.favourite": "{name} a ajouté à ses favoris :",
+  "notification.follow": "{name} vous suit.",
+  "notification.mention": "{name} vous a mentionné⋅e :",
+  "notification.reblog": "{name} a partagé votre statut :",
+  "notifications.column_settings.alert": "Notifications locales",
+  "notifications.column_settings.favourite": "Favoris :",
+  "notifications.column_settings.follow": "Nouveaux abonnés :",
+  "notifications.column_settings.mention": "Mentions :",
+  "notifications.column_settings.reblog": "Partages :",
+  "notifications.column_settings.show": "Afficher dans la colonne",
   "reply_indicator.cancel": "Annuler",
-  "search.placeholder": "Chercher",
   "search.account": "Compte",
   "search.hashtag": "Mot-clé",
+  "search.placeholder": "Chercher",
+  "status.delete": "Effacer",
+  "status.favourite": "Ajouter aux favoris",
+  "status.mention": "Mentionner",
+  "status.reblogged_by": "{name} a partagé :",
+  "status.reblog": "Partager",
+  "status.reply": "Répondre",
+  "status.sensitive_toggle": "Cliquer pour dévoiler",
+  "status.sensitive_warning": "Contenu délicat",
+  "tabs_bar.compose": "Composer",
+  "tabs_bar.home": "Accueil",
+  "tabs_bar.mentions": "Mentions",
+  "tabs_bar.notifications": "Notifications",
+  "tabs_bar.public": "Public",
   "upload_button.label": "Joindre un média",
   "upload_form.undo": "Annuler",
-  "notification.follow": "{name} vous suit.",
-  "notification.favourite": "{name} a ajouté à ses favoris :",
-  "notification.reblog": "{name} a partagé votre statut :",
-  "notification.mention": "{name} vous a mentionné⋅e :"
+  "video_player.toggle_sound": "Mettre/Couper le son",
 };
 
 export default fr;
diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx
index 042a2c67d..77ec2705f 100644
--- a/app/assets/javascripts/components/reducers/compose.jsx
+++ b/app/assets/javascripts/components/reducers/compose.jsx
@@ -89,7 +89,7 @@ function removeMedia(state, mediaId) {
     map.update('text', text => text.replace(media.get('text_url'), '').trim());
 
     if (prevSize === 1) {
-      map.update('sensitive', false);
+      map.set('sensitive', false);
     }
   });
 };
@@ -126,6 +126,8 @@ export default function compose(state = initialState, action) {
     return state.withMutations(map => {
       map.set('in_reply_to', action.status.get('id'));
       map.set('text', statusToTextMentions(state, action.status));
+      map.set('unlisted', action.status.get('visibility') === 'unlisted');
+      map.set('private', action.status.get('visibility') === 'private');
     });
   case COMPOSE_REPLY_CANCEL:
     return state.withMutations(map => {
diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx
index 0798116c4..147030cca 100644
--- a/app/assets/javascripts/components/reducers/index.jsx
+++ b/app/assets/javascripts/components/reducers/index.jsx
@@ -14,6 +14,7 @@ import notifications from './notifications';
 import settings from './settings';
 import status_lists from './status_lists';
 import cards from './cards';
+import reports from './reports';
 
 export default combineReducers({
   timelines,
@@ -30,5 +31,6 @@ export default combineReducers({
   search,
   notifications,
   settings,
-  cards
+  cards,
+  reports
 });
diff --git a/app/assets/javascripts/components/reducers/reports.jsx b/app/assets/javascripts/components/reducers/reports.jsx
new file mode 100644
index 000000000..e1cce1c5f
--- /dev/null
+++ b/app/assets/javascripts/components/reducers/reports.jsx
@@ -0,0 +1,57 @@
+import {
+  REPORT_INIT,
+  REPORT_SUBMIT_REQUEST,
+  REPORT_SUBMIT_SUCCESS,
+  REPORT_SUBMIT_FAIL,
+  REPORT_CANCEL,
+  REPORT_STATUS_TOGGLE
+} from '../actions/reports';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  new: Immutable.Map({
+    isSubmitting: false,
+    account_id: null,
+    status_ids: Immutable.Set(),
+    comment: ''
+  })
+});
+
+export default function reports(state = initialState, action) {
+  switch(action.type) {
+  case REPORT_INIT:
+    return state.withMutations(map => {
+      map.setIn(['new', 'isSubmitting'], false);
+      map.setIn(['new', 'account_id'], action.account.get('id'));
+
+      if (state.getIn(['new', 'account_id']) !== action.account.get('id')) {
+        map.setIn(['new', 'status_ids'], action.status ? Immutable.Set([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : Immutable.Set());
+        map.setIn(['new', 'comment'], '');
+      } else {
+        map.updateIn(['new', 'status_ids'], Immutable.Set(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id'))));
+      }
+    });
+  case REPORT_STATUS_TOGGLE:
+    return state.updateIn(['new', 'status_ids'], Immutable.Set(), set => {
+      if (action.checked) {
+        return set.add(action.statusId);
+      }
+
+      return set.remove(action.statusId);
+    });
+  case REPORT_SUBMIT_REQUEST:
+    return state.setIn(['new', 'isSubmitting'], true);
+  case REPORT_SUBMIT_FAIL:
+    return state.setIn(['new', 'isSubmitting'], false);
+  case REPORT_CANCEL:
+  case REPORT_SUBMIT_SUCCESS:
+    return state.withMutations(map => {
+      map.setIn(['new', 'account_id'], null);
+      map.setIn(['new', 'status_ids'], Immutable.Set());
+      map.setIn(['new', 'comment'], '');
+      map.setIn(['new', 'isSubmitting'], false);
+    });
+  default:
+    return state;
+  }
+};
diff --git a/app/assets/stylesheets/admin.scss b/app/assets/stylesheets/admin.scss
index d834096f4..e27b88e5f 100644
--- a/app/assets/stylesheets/admin.scss
+++ b/app/assets/stylesheets/admin.scss
@@ -76,6 +76,7 @@
 
   .content-wrapper {
     flex: 2;
+    overflow: auto;
   }
 
   .content {
@@ -92,7 +93,7 @@
       margin-bottom: 40px;
     }
 
-    p {
+    & > p {
       font-size: 14px;
       line-height: 18px;
       color: $color2;
@@ -103,6 +104,13 @@
         font-weight: 500;
       }
     }
+
+    hr {
+      margin: 20px 0;
+      border: 0;
+      background: transparent;
+      border-bottom: 1px solid $color1;
+    }
   }
 
   .simple_form {
@@ -179,3 +187,45 @@
     }
   }
 }
+
+.report-accounts {
+  display: flex;
+  margin-bottom: 20px;
+}
+
+.report-accounts__item {
+  flex: 1 1 0;
+  display: flex;
+  flex-direction: column;
+
+  & > strong {
+    display: block;
+    margin-bottom: 10px;
+    font-weight: 500;
+    font-size: 14px;
+    line-height: 18px;
+    color: $color2;
+  }
+
+  &:first-child {
+    margin-right: 10px;
+  }
+
+  .account-card {
+    flex: 1 1 auto;
+  }
+}
+
+.report-status {
+  display: flex;
+  margin-bottom: 10px;
+
+  .activity-stream {
+    flex: 2 0 0;
+    margin-right: 20px;
+  }
+}
+
+.report-status__actions {
+  flex: 0 0 auto;
+}
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index f0948b0f3..912405a9f 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -78,6 +78,21 @@
   color: $color1;
 }
 
+.compose-form__warning {
+  color: $color2;
+  margin-bottom: 15px;
+  border: 1px solid $color3;
+  padding: 8px 10px;
+  border-radius: 4px;
+  font-size: 12px;
+  font-weight: 400;
+
+  strong {
+    color: $color5;
+    font-weight: 500;
+  }
+}
+
 .compose-form__label {
   display: block;
   line-height: 24px;
@@ -213,6 +228,14 @@ a.status__content__spoiler-link {
   }
 }
 
+.status-check-box {
+  border-bottom: 1px solid lighten($color1, 8%);
+
+  .status__content {
+    background: lighten($color1, 4%);
+  }
+}
+
 .status__prepend {
   margin-left: 68px;
   color: lighten($color1, 26%);
@@ -1127,3 +1150,35 @@ button.active i.fa-retweet {
   color: $color3;
 }
 
+.report__target {
+  border-bottom: 1px solid lighten($color1, 4%);
+  color: $color2;
+  padding-bottom: 10px;
+
+  strong {
+    display: block;
+    color: $color5;
+    font-weight: 500;
+  }
+}
+
+.report__textarea {
+  background: transparent;
+  box-sizing: border-box;
+  border: 0;
+  border-bottom: 2px solid $color3;
+  border-radius: 2px 2px 0 0;
+  padding: 7px 4px;
+  font-size: 14px;
+  color: $color5;
+  display: block;
+  width: 100%;
+  outline: 0;
+  font-family: inherit;
+  resize: vertical;
+
+  &:active, &:focus {
+    border-bottom-color: $color4;
+    background: rgba($color8, 0.1);
+  }
+}
diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss
index a97a767e0..bc99b36a6 100644
--- a/app/assets/stylesheets/forms.scss
+++ b/app/assets/stylesheets/forms.scss
@@ -93,6 +93,7 @@ code {
     width: 100%;
     outline: 0;
     font-family: inherit;
+    resize: vertical;
 
     &:invalid {
       box-shadow: none;
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 95107b3dc..df2c7bebf 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -19,19 +19,26 @@ class Admin::AccountsController < ApplicationController
 
   def show; end
 
-  def update
-    if @account.update(account_params)
-      redirect_to admin_accounts_path
-    else
-      render :show
-    end
-  end
-
   def suspend
     Admin::SuspensionWorker.perform_async(@account.id)
     redirect_to admin_accounts_path
   end
 
+  def unsuspend
+    @account.update(suspended: false)
+    redirect_to admin_accounts_path
+  end
+
+  def silence
+    @account.update(silenced: true)
+    redirect_to admin_accounts_path
+  end
+
+  def unsilence
+    @account.update(silenced: false)
+    redirect_to admin_accounts_path
+  end
+
   private
 
   def set_account
diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb
new file mode 100644
index 000000000..67d57e4eb
--- /dev/null
+++ b/app/controllers/admin/reports_controller.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class Admin::ReportsController < ApplicationController
+  before_action :require_admin!
+  before_action :set_report, except: [:index]
+
+  layout 'admin'
+
+  def index
+    @reports = Report.includes(:account, :target_account).paginate(page: params[:page], per_page: 40)
+    @reports = params[:action_taken].present? ? @reports.resolved : @reports.unresolved
+  end
+
+  def show
+    @statuses = Status.where(id: @report.status_ids)
+  end
+
+  def resolve
+    @report.update(action_taken: true)
+    redirect_to admin_report_path(@report)
+  end
+
+  def suspend
+    Admin::SuspensionWorker.perform_async(@report.target_account.id)
+    @report.update(action_taken: true)
+    redirect_to admin_report_path(@report)
+  end
+
+  def silence
+    @report.target_account.update(silenced: true)
+    @report.update(action_taken: true)
+    redirect_to admin_report_path(@report)
+  end
+
+  def remove
+    RemovalWorker.perform_async(params[:status_id])
+    redirect_to admin_report_path(@report)
+  end
+
+  private
+
+  def set_report
+    @report = Report.find(params[:id])
+  end
+end
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index d97010c0e..0d02294eb 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -58,6 +58,21 @@ class Api::V1::AccountsController < ApiController
     set_pagination_headers(next_path, prev_path)
   end
 
+  def media_statuses
+    media_ids = MediaAttachment.where(account: @account).where.not(status_id: nil).reorder('').select('distinct status_id')
+    @statuses = @account.statuses.where(id: media_ids).permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
+    @statuses = cache_collection(@statuses, Status)
+
+    set_maps(@statuses)
+    set_counters_maps(@statuses)
+
+    next_path = media_statuses_api_v1_account_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
+    prev_path = media_statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty?
+
+    set_pagination_headers(next_path, prev_path)
+    render action: :statuses
+  end
+
   def follow
     FollowService.new.call(current_user.account, @account.acct)
     set_relationship
diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb
new file mode 100644
index 000000000..46bdddbc1
--- /dev/null
+++ b/app/controllers/api/v1/reports_controller.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class Api::V1::ReportsController < ApiController
+  before_action -> { doorkeeper_authorize! :read }, except: [:create]
+  before_action -> { doorkeeper_authorize! :write }, only:  [:create]
+  before_action :require_user!
+
+  respond_to :json
+
+  def index
+    @reports = Report.where(account: current_account)
+  end
+
+  def create
+    status_ids = params[:status_ids].is_a?(Enumerable) ? params[:status_ids] : [params[:status_ids]]
+
+    @report = Report.create!(account: current_account,
+                             target_account: Account.find(params[:account_id]),
+                             status_ids: Status.find(status_ids).pluck(:id),
+                             comment: params[:comment])
+
+    render :show
+  end
+end
diff --git a/app/controllers/settings/two_factor_auths_controller.rb b/app/controllers/settings/two_factor_auths_controller.rb
index f34295cb9..cfee92391 100644
--- a/app/controllers/settings/two_factor_auths_controller.rb
+++ b/app/controllers/settings/two_factor_auths_controller.rb
@@ -8,7 +8,8 @@ class Settings::TwoFactorAuthsController < ApplicationController
   def show
     return unless current_user.otp_required_for_login
 
-    @qrcode = RQRCode::QRCode.new(current_user.otp_provisioning_uri(current_user.email, issuer: Rails.configuration.x.local_domain))
+    @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Rails.configuration.x.local_domain)
+    @qrcode        = RQRCode::QRCode.new(@provision_url)
   end
 
   def enable
diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb
index da284d80e..c43d372ed 100644
--- a/app/controllers/stream_entries_controller.rb
+++ b/app/controllers/stream_entries_controller.rb
@@ -43,7 +43,7 @@ class StreamEntriesController < ApplicationController
   end
 
   def set_stream_entry
-    @stream_entry = @account.stream_entries.find(params[:id])
+    @stream_entry = @account.stream_entries.where(activity_type: 'Status').find(params[:id])
     @type         = @stream_entry.activity_type.downcase
 
     raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil? || (@stream_entry.hidden? && (@stream_entry.activity_type != 'Status' || (@stream_entry.activity_type == 'Status' && !@stream_entry.activity.permitted?(current_account))))
diff --git a/app/helpers/atom_builder_helper.rb b/app/helpers/atom_builder_helper.rb
index 484cf0793..8ca3cde26 100644
--- a/app/helpers/atom_builder_helper.rb
+++ b/app/helpers/atom_builder_helper.rb
@@ -90,6 +90,10 @@ module AtomBuilderHelper
     xml.link(rel: 'self', type: 'application/atom+xml', href: url)
   end
 
+  def link_next(xml, url)
+    xml.link(rel: 'next', type: 'application/atom+xml', href: url)
+  end
+
   def link_hub(xml, url)
     xml.link(rel: 'hub', href: url)
   end
@@ -148,6 +152,7 @@ module AtomBuilderHelper
   end
 
   def include_author(xml, account)
+    simple_id        xml, TagManager.instance.uri_for(account)
     object_type      xml, :person
     uri              xml, TagManager.instance.uri_for(account)
     name             xml, account.username
@@ -270,6 +275,6 @@ module AtomBuilderHelper
   end
 
   def single_link_avatar(xml, account, size, px)
-    xml.link('rel' => 'avatar', 'type' => account.avatar_content_type, 'media:width' => px, 'media:height' => px, 'href' => full_asset_url(account.avatar.url(size, false)))
+    xml.link('rel' => 'avatar', 'type' => account.avatar_content_type, 'media:width' => px, 'media:height' => px, 'href' => full_asset_url(account.avatar.url(size)))
   end
 end
diff --git a/app/models/report.rb b/app/models/report.rb
new file mode 100644
index 000000000..05dc8cff1
--- /dev/null
+++ b/app/models/report.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class Report < ApplicationRecord
+  belongs_to :account
+  belongs_to :target_account, class_name: 'Account'
+
+  scope :unresolved, -> { where(action_taken: false) }
+  scope :resolved,   -> { where(action_taken: true) }
+end
diff --git a/app/models/status.rb b/app/models/status.rb
index e440bbaca..46d92ea33 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -77,7 +77,7 @@ class Status < ApplicationRecord
 
   def permitted?(other_account = nil)
     if private_visibility?
-      (account.id == other_account&.id || other_account&.following?(account) || mentions.include?(other_account))
+      (account.id == other_account&.id || other_account&.following?(account) || mentions.where(account: other_account).exists?)
     else
       other_account.nil? || !account.blocking?(other_account)
     end
diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb
index 8b41c8c39..ae7ae446e 100644
--- a/app/models/stream_entry.rb
+++ b/app/models/stream_entry.rb
@@ -55,7 +55,7 @@ class StreamEntry < ApplicationRecord
   end
 
   def activity
-    !new_record? ? send(activity_type.underscore) : super
+    !new_record? ? send(activity_type.underscore) || super : super
   end
 
   private
diff --git a/app/services/pubsubhubbub/subscribe_service.rb b/app/services/pubsubhubbub/subscribe_service.rb
index 343376d77..bf36e3fa6 100644
--- a/app/services/pubsubhubbub/subscribe_service.rb
+++ b/app/services/pubsubhubbub/subscribe_service.rb
@@ -2,8 +2,9 @@
 
 class Pubsubhubbub::SubscribeService < BaseService
   def call(account, callback, secret, lease_seconds)
-    return ['Invalid topic URL', 422] if account.nil?
-    return ['Invalid callback URL', 422] unless !callback.blank? && callback =~ /\A#{URI.regexp(%w(http https))}\z/
+    return ['Invalid topic URL',        422] if account.nil?
+    return ['Invalid callback URL',     422] unless !callback.blank? && callback =~ /\A#{URI.regexp(%w(http https))}\z/
+    return ['Callback URL not allowed', 403] if DomainBlock.blocked?(Addressable::URI.parse(callback).host)
 
     subscription = Subscription.where(account: account, callback_url: callback).first_or_create!(account: account, callback_url: callback)
     Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'subscribe', secret, lease_seconds)
diff --git a/app/views/accounts/show.atom.ruby b/app/views/accounts/show.atom.ruby
index a22568396..e15021178 100644
--- a/app/views/accounts/show.atom.ruby
+++ b/app/views/accounts/show.atom.ruby
@@ -6,7 +6,7 @@ Nokogiri::XML::Builder.new do |xml|
     title      xml, @account.display_name
     subtitle   xml, @account.note
     updated_at xml, stream_updated_at
-    logo       xml, full_asset_url(@account.avatar.url( :original))
+    logo       xml, full_asset_url(@account.avatar.url(:original))
 
     author(xml) do
       include_author xml, @account
@@ -14,6 +14,7 @@ Nokogiri::XML::Builder.new do |xml|
 
     link_alternate xml, TagManager.instance.url_for(@account)
     link_self      xml, account_url(@account, format: 'atom')
+    link_next      xml, account_url(@account, format: 'atom', max_id: @entries.last.id) if @entries.size == 20
     link_hub       xml, api_push_url
     link_salmon    xml, api_salmon_url(@account.id)
 
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index a93aa9143..f8ed4ef97 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -25,9 +25,7 @@
     %tr
       %th Username
       %th Domain
-      %th Subscribed
-      %th Silenced
-      %th Suspended
+      %th= fa_icon 'paper-plane-o'
       %th
   %tbody
     - @accounts.each do |account|
@@ -44,16 +42,6 @@
           - else
             %i.fa.fa-times
         %td
-          - if account.silenced?
-            %i.fa.fa-check
-          - else
-            %i.fa.fa-times
-        %td
-          - if account.suspended?
-            %i.fa.fa-check
-          - else
-            %i.fa.fa-times
-        %td
           = table_link_to 'circle', 'Web', web_path("accounts/#{account.id}")
           = table_link_to 'globe', 'Public', TagManager.instance.url_for(account)
           = table_link_to 'pencil', 'Edit', admin_account_path(account.id)
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index 7d3f449e5..b528e161e 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -18,8 +18,15 @@
         %th E-mail
         %td= @account.user.email
       %tr
-        %th Current IP
+        %th Most recent IP
         %td= @account.user.current_sign_in_ip
+      %tr
+        %th Most recent activity
+        %td
+          - if @account.user.current_sign_in_at
+            = l @account.user.current_sign_in_at
+          - else
+            Never
     - else
       %tr
         %th Profile URL
@@ -27,14 +34,39 @@
       %tr
         %th Feed URL
         %td= link_to @account.remote_url
+      %tr
+        %th PuSH subscription expires
+        %td
+          - if @account.subscribed?
+            = l @account.subscription_expires_at
+          - else
+            Not subscribed
+      %tr
+        %th Salmon URL
+        %td= link_to @account.salmon_url
 
-= simple_form_for @account, url: admin_account_path(@account.id) do |f|
-  = render 'shared/error_messages', object: @account
-
-  = f.input :silenced, as: :boolean, wrapper: :with_label
-  = f.input :suspended, as: :boolean, wrapper: :with_label
+    %tr
+      %th Follows
+      %td= @account.following.count
+    %tr
+      %th Followers
+      %td= @account.followers.count
+    %tr
+      %th Statuses
+      %td= @account.statuses.count
+    %tr
+      %th Media attachments
+      %td
+        = @account.media_attachments.count
+        = surround '(', ')' do
+          = number_to_human_size @account.media_attachments.sum('file_file_size')
 
-  .actions
-    = f.button :button, t('generic.save_changes'), type: :submit
+- if @account.silenced?
+  = link_to 'Undo silence', unsilence_admin_account_path(@account.id), method: :post, class: 'button'
+- else
+  = link_to 'Silence', silence_admin_account_path(@account.id), method: :post, class: 'button'
 
-= link_to 'Perform full suspension', suspend_admin_account_path(@account.id), method: :post, data: { confirm: 'Are you sure?' }, class: 'button'
+- if @account.suspended?
+  = link_to 'Undo suspension', unsuspend_admin_account_path(@account.id), method: :post, class: 'button'
+- else
+  = link_to 'Perform full suspension', suspend_admin_account_path(@account.id), method: :post, data: { confirm: 'Are you sure?' }, class: 'button'
diff --git a/app/views/admin/reports/index.html.haml b/app/views/admin/reports/index.html.haml
new file mode 100644
index 000000000..8a5414cef
--- /dev/null
+++ b/app/views/admin/reports/index.html.haml
@@ -0,0 +1,27 @@
+- content_for :page_title do
+  Reports
+
+.filters
+  .filter-subset
+    %strong Status
+    %ul
+      %li= filter_link_to 'Unresolved', action_taken: nil
+      %li= filter_link_to 'Resolved', action_taken: '1'
+
+%table.table
+  %thead
+    %tr
+      %th ID
+      %th Target
+      %th Reported by
+      %th Comment
+      %th
+  %tbody
+    - @reports.each do |report|
+      %tr
+        %td= "##{report.id}"
+        %td= link_to report.target_account.acct, admin_account_path(report.target_account.id)
+        %td= link_to report.account.acct, admin_account_path(report.account.id)
+        %td= truncate(report.comment, length: 30, separator: ' ')
+        %td= table_link_to 'circle', 'View', admin_report_path(report)
+= will_paginate @reports, pagination_options
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
new file mode 100644
index 000000000..74cac016d
--- /dev/null
+++ b/app/views/admin/reports/show.html.haml
@@ -0,0 +1,38 @@
+- content_for :page_title do
+  = "Report ##{@report.id}"
+
+.report-accounts
+  .report-accounts__item
+    %strong Reported account:
+    = render partial: 'authorize_follow/card', locals: { account: @report.target_account }
+  .report-accounts__item
+    %strong Reported by:
+    = render partial: 'authorize_follow/card', locals: { account: @report.account }
+
+%p
+  %strong Comment:
+  - if @report.comment.blank?
+    None
+  - else
+    = @report.comment
+
+- unless @statuses.empty?
+  %hr/
+
+  - @statuses.each do |status|
+    .report-status
+      .activity-stream.activity-stream-headless
+        .entry= render partial: 'stream_entries/simple_status', locals: { status: status }
+      .report-status__actions
+        = link_to remove_admin_report_path(@report, status_id: status.id), method: :post, class: 'icon-button', style: 'font-size: 24px; width: 24px; height: 24px', title: 'Delete' do
+          = fa_icon 'trash'
+
+- unless @report.action_taken?
+  %hr/
+
+  %div{ style: 'overflow: hidden' }
+    %div{ style: 'float: right' }
+      = link_to 'Silence account', silence_admin_report_path(@report), method: :post, class: 'button'
+      = link_to 'Suspend account', suspend_admin_report_path(@report), method: :post, class: 'button'
+    %div{ style: 'float: left' }
+      = link_to 'Mark as resolved', resolve_admin_report_path(@report), method: :post, class: 'button'
diff --git a/app/views/api/v1/reports/index.rabl b/app/views/api/v1/reports/index.rabl
new file mode 100644
index 000000000..4f0794027
--- /dev/null
+++ b/app/views/api/v1/reports/index.rabl
@@ -0,0 +1,2 @@
+collection @reports
+extends 'api/v1/reports/show'
diff --git a/app/views/api/v1/reports/show.rabl b/app/views/api/v1/reports/show.rabl
new file mode 100644
index 000000000..006db51e3
--- /dev/null
+++ b/app/views/api/v1/reports/show.rabl
@@ -0,0 +1,2 @@
+object @report
+attributes :id, :action_taken
diff --git a/app/views/settings/two_factor_auths/show.html.haml b/app/views/settings/two_factor_auths/show.html.haml
index bad359f8f..646369a97 100644
--- a/app/views/settings/two_factor_auths/show.html.haml
+++ b/app/views/settings/two_factor_auths/show.html.haml
@@ -7,6 +7,10 @@
 
     .qr-code= raw @qrcode.as_svg(padding: 0, module_size: 5)
 
+    %p= t('two_factor_auth.plaintext_secret_html', secret: current_user.otp_secret)
+
+    %p= t('two_factor_auth.warning')
+
     = link_to t('two_factor_auth.disable'), disable_settings_two_factor_auth_path, data: { method: 'POST' }, class: 'block-button'
   - else
     %p= t('two_factor_auth.description_html')
diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb
index d5437bf6b..82ff257af 100644
--- a/app/workers/pubsubhubbub/distribution_worker.rb
+++ b/app/workers/pubsubhubbub/distribution_worker.rb
@@ -13,8 +13,11 @@ class Pubsubhubbub::DistributionWorker
     account  = stream_entry.account
     renderer = AccountsController.renderer.new(method: 'get', http_host: Rails.configuration.x.local_domain, https: Rails.configuration.x.use_https)
     payload  = renderer.render(:show, assigns: { account: account, entries: [stream_entry] }, formats: [:atom])
+    # domains  = account.followers_domains
 
-    Subscription.where(account: account).active.select('id').find_each do |subscription|
+    Subscription.where(account: account).active.select('id, callback_url').find_each do |subscription|
+      host = Addressable::URI.parse(subscription.callback_url).host
+      next if DomainBlock.blocked?(host) # || !domains.include?(host)
       Pubsubhubbub::DeliveryWorker.perform_async(subscription.id, payload)
     end
   rescue ActiveRecord::RecordNotFound