about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--Capfile1
-rw-r--r--Dockerfile1
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock3
-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
-rw-r--r--config/application.rb2
-rw-r--r--config/initializers/timeout.rb2
-rw-r--r--config/locales/en.yml2
-rw-r--r--config/navigation.rb1
-rw-r--r--config/routes.rb16
-rw-r--r--db/migrate/20170214110202_create_reports.rb13
-rw-r--r--db/migrate/20170217012631_add_reblog_of_id_foreign_key_to_statuses.rb5
-rw-r--r--db/schema.rb13
-rw-r--r--docs/Extensions.md6
-rw-r--r--docs/Running-Mastodon/Administration-guide.md13
-rw-r--r--docs/Running-Mastodon/Heroku-guide.md4
-rw-r--r--docs/Using-Mastodon/List-of-Mastodon-instances.md3
-rw-r--r--lib/tasks/mastodon.rake9
-rw-r--r--spec/fabricators/report_fabricator.rb4
-rw-r--r--spec/models/report_spec.rb5
67 files changed, 992 insertions, 167 deletions
diff --git a/Capfile b/Capfile
index 4a3b6d675..5bbf6933b 100644
--- a/Capfile
+++ b/Capfile
@@ -8,6 +8,7 @@ require 'capistrano/rbenv'
 require 'capistrano/bundler'
 require 'capistrano/yarn'
 require 'capistrano/rails/assets'
+require 'capistrano/faster_assets'
 require 'capistrano/rails/migrations'
 
 Dir.glob('lib/capistrano/tasks/*.rake').each { |r| import r }
diff --git a/Dockerfile b/Dockerfile
index dfc0ad5b7..1f95f4f49 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,7 @@
 FROM ruby:2.3.1
 
 ENV RAILS_ENV=production
+ENV NODE_ENV=production
 
 RUN echo 'deb http://httpredir.debian.org/debian jessie-backports main contrib non-free' >> /etc/apt/sources.list
 RUN curl -sL https://deb.nodesource.com/setup_4.x | bash -
diff --git a/Gemfile b/Gemfile
index 423560bb6..55c1de693 100644
--- a/Gemfile
+++ b/Gemfile
@@ -82,6 +82,7 @@ group :development do
   gem 'capistrano-rails'
   gem 'capistrano-rbenv'
   gem 'capistrano-yarn'
+  gem 'capistrano-faster-assets', '~> 1.0'
 end
 
 group :production do
diff --git a/Gemfile.lock b/Gemfile.lock
index 4f54a621c..f50edaf95 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -89,6 +89,8 @@ GEM
     capistrano-bundler (1.2.0)
       capistrano (~> 3.1)
       sshkit (~> 1.2)
+    capistrano-faster-assets (1.0.2)
+      capistrano (>= 3.1)
     capistrano-harrow (0.5.3)
     capistrano-rails (1.2.2)
       capistrano (~> 3.1)
@@ -454,6 +456,7 @@ DEPENDENCIES
   browserify-rails
   bullet
   capistrano
+  capistrano-faster-assets (~> 1.0)
   capistrano-rails
   capistrano-rbenv
   capistrano-yarn
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
diff --git a/config/application.rb b/config/application.rb
index d44835957..8da5ade3c 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -34,7 +34,7 @@ module Mastodon
       allow do
         origins  '*'
 
-        resource '/api/*',       headers: :any, methods: [:post, :put, :delete, :get, :options], credentials: false
+        resource '/api/*',       headers: :any, methods: [:post, :put, :delete, :get, :options], credentials: false, expose: ['Link', 'X-RateLimit-Reset', 'X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-Request-Id']
         resource '/oauth/token', headers: :any, methods: [:post], credentials: false
       end
     end
diff --git a/config/initializers/timeout.rb b/config/initializers/timeout.rb
index 643780884..06a29492e 100644
--- a/config/initializers/timeout.rb
+++ b/config/initializers/timeout.rb
@@ -1,4 +1,4 @@
 if Rails.env.production?
-  Rack::Timeout.service_timeout = 15
+  Rack::Timeout.service_timeout = 90
   Rack::Timeout::Logger.disable
 end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index c6c7c236e..e7d39327e 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -118,6 +118,8 @@ en:
     disable: Disable
     enable: Enable
     instructions_html: "<strong>Scan this QR code into Google Authenticator or a similiar app on your phone</strong>. From now on, that app will generate tokens that you will have to enter when logging in."
+    plaintext_secret_html: "Plain-text secret: <samp>%{secret}</samp>"
+    warning: If you cannot configure an authenticator app right now, you should click "disable" or you won't be able to login.
   users:
     invalid_email: The e-mail address is invalid
     invalid_otp_token: Invalid two-factor code
diff --git a/config/navigation.rb b/config/navigation.rb
index b2930f62f..0d43a9f73 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -13,6 +13,7 @@ SimpleNavigation::Configuration.run do |navigation|
     end
 
     primary.item :admin, safe_join([fa_icon('cogs fw'), 'Administration']), admin_accounts_url, if: proc { current_user.admin? } do |admin|
+      admin.item :reports, safe_join([fa_icon('flag fw'), 'Reports']), admin_reports_url, highlights_on: %r{/admin/reports}
       admin.item :accounts, safe_join([fa_icon('users fw'), 'Accounts']), admin_accounts_url, highlights_on: %r{/admin/accounts}
       admin.item :pubsubhubbubs, safe_join([fa_icon('paper-plane-o fw'), 'PubSubHubbub']), admin_pubsubhubbub_index_url
       admin.item :domain_blocks, safe_join([fa_icon('lock fw'), 'Domain Blocks']), admin_domain_blocks_url
diff --git a/config/routes.rb b/config/routes.rb
index 3da7563fd..70e252409 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -67,9 +67,21 @@ Rails.application.routes.draw do
     resources :domain_blocks, only: [:index, :create]
     resources :settings, only: [:index, :update]
 
-    resources :accounts, only: [:index, :show, :update] do
+    resources :reports, only: [:index, :show] do
       member do
+        post :resolve
+        post :silence
         post :suspend
+        post :remove
+      end
+    end
+
+    resources :accounts, only: [:index, :show] do
+      member do
+        post :silence
+        post :unsilence
+        post :suspend
+        post :unsuspend
       end
     end
   end
@@ -115,6 +127,7 @@ Rails.application.routes.draw do
       resources :apps,       only: [:create]
       resources :blocks,     only: [:index]
       resources :favourites, only: [:index]
+      resources :reports,    only: [:index, :create]
 
       resources :follow_requests, only: [:index] do
         member do
@@ -138,6 +151,7 @@ Rails.application.routes.draw do
 
         member do
           get :statuses
+          get 'statuses/media', to: 'accounts#media_statuses', as: :media_statuses
           get :followers
           get :following
 
diff --git a/db/migrate/20170214110202_create_reports.rb b/db/migrate/20170214110202_create_reports.rb
new file mode 100644
index 000000000..aa772803b
--- /dev/null
+++ b/db/migrate/20170214110202_create_reports.rb
@@ -0,0 +1,13 @@
+class CreateReports < ActiveRecord::Migration[5.0]
+  def change
+    create_table :reports do |t|
+      t.integer :account_id, null: false
+      t.integer :target_account_id, null: false
+      t.integer :status_ids, array: true, null: false, default: []
+      t.text :comment, null: false, default: ''
+      t.boolean :action_taken, null: false, default: false
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20170217012631_add_reblog_of_id_foreign_key_to_statuses.rb b/db/migrate/20170217012631_add_reblog_of_id_foreign_key_to_statuses.rb
new file mode 100644
index 000000000..175d4048f
--- /dev/null
+++ b/db/migrate/20170217012631_add_reblog_of_id_foreign_key_to_statuses.rb
@@ -0,0 +1,5 @@
+class AddReblogOfIdForeignKeyToStatuses < ActiveRecord::Migration[5.0]
+  def change
+    add_foreign_key :statuses, :statuses, column: :reblog_of_id, on_delete: :cascade
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 86a05ebe6..fa5c40774 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20170209184350) do
+ActiveRecord::Schema.define(version: 20170217012631) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -173,6 +173,16 @@ ActiveRecord::Schema.define(version: 20170209184350) do
     t.index ["status_id"], name: "index_preview_cards_on_status_id", unique: true, using: :btree
   end
 
+  create_table "reports", force: :cascade do |t|
+    t.integer  "account_id",                        null: false
+    t.integer  "target_account_id",                 null: false
+    t.integer  "status_ids",        default: [],    null: false, array: true
+    t.text     "comment",           default: "",    null: false
+    t.boolean  "action_taken",      default: false, null: false
+    t.datetime "created_at",                        null: false
+    t.datetime "updated_at",                        null: false
+  end
+
   create_table "settings", force: :cascade do |t|
     t.string   "var",        null: false
     t.text     "value"
@@ -279,4 +289,5 @@ ActiveRecord::Schema.define(version: 20170209184350) do
     t.index ["user_id"], name: "index_web_settings_on_user_id", unique: true, using: :btree
   end
 
+  add_foreign_key "statuses", "statuses", column: "reblog_of_id", on_delete: :cascade
 end
diff --git a/docs/Extensions.md b/docs/Extensions.md
index 6a940eebc..cf79379eb 100644
--- a/docs/Extensions.md
+++ b/docs/Extensions.md
@@ -48,9 +48,3 @@ Mastodon uses the following Salmon slaps to signal a follow request, a follow re
 - `http://activitystrea.ms/schema/1.0/reject`
 
 The activity object of the request-friend slap is the account in question. The activity object of the authorize and reject slaps is the original request-friend activity. Request-friend slap is sent to the locked account, when the end-user of that account decides, the authorize/reject decision slap is sent back to the requester.
-
-#### PuSH amendment
-
-Mastodon will only deliver PuSH payloads to callback URLs the domain of which matches at least one follower of the account in question. That means anonymous manual/subscriptions are not possible.
-
-Private statuses do not appear on Atom feeds, but do get delivered in PuSH payloads to the domains of approved followers.
diff --git a/docs/Running-Mastodon/Administration-guide.md b/docs/Running-Mastodon/Administration-guide.md
index 1b9dc8630..af78f6235 100644
--- a/docs/Running-Mastodon/Administration-guide.md
+++ b/docs/Running-Mastodon/Administration-guide.md
@@ -1,8 +1,16 @@
 Administration guide
-=================
+====================
 
 So, you have a working Mastodon instance... now what?
 
+## Turning into an admin
+
+The following rake task:
+
+    rails mastodon:make_admin USERNAME=alice
+
+Would turn the local user "alice" into an admin.
+
 ## Administration web interface
 
 A user that is designated as `admin = TRUE` in the database is able to access a suite of administration tools:
@@ -20,9 +28,10 @@ Your site settings are stored in the `settings` database table, and editable thr
 
 You are able to set the following settings:
 
+- Site title
 - Contact username
 - Contact email
 - Site description
 - Site extended description
 
-You may wish to use the extended description (shown at https://yourmastodon.instance/about/more ) to display content guidelines or a user agreement (see https://mastodon.social/about/more for an example).
\ No newline at end of file
+You may wish to use the extended description (shown at https://yourmastodon.instance/about/more ) to display content guidelines or a user agreement (see https://mastodon.social/about/more for an example).
diff --git a/docs/Running-Mastodon/Heroku-guide.md b/docs/Running-Mastodon/Heroku-guide.md
index 6aa8be774..799b8a64c 100644
--- a/docs/Running-Mastodon/Heroku-guide.md
+++ b/docs/Running-Mastodon/Heroku-guide.md
@@ -1,7 +1,7 @@
 Heroku guide
 ============
 
-[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)
+[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://dashboard.heroku.com/new?button-url=https://github.com/tootsuite/mastodon&template=https://github.com/tootsuite/mastodon)
 
 Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.com) app. It should be noted this has limited testing and could have unpredictable results.
 
@@ -10,4 +10,4 @@ Mastodon can theoretically run indefinitely on a free [Heroku](https://heroku.co
   * You can use a .herokuapp.com domain, which will be simple to set up, or you can use a custom domain. If you want a custom domain and HTTPS, you will need to upgrade to a paid plan (to use Heroku's SSL features), or set up [CloudFlare](https://cloudflare.com) who offer free "Flexible SSL" (note: CloudFlare have some undefined limits on WebSockets. So far, no one has reported hitting concurrent connection limits).
   * You will want Amazon S3 for file storage. The only exception is for development purposes, where you may not care if files are not saaved. Follow a guide online for creating a free Amazon S3 bucket and Access Key, then enter the details.
   * If you want your Mastodon to be able to send emails, configure SMTP settings here (or later). Consider using [Mailgun](https://mailgun.com) or similar, who offer free plans that should suit your interests.
-3. Deploy! The app should be set up, with a working web interface and database. You can change settings and manage versions from the Heroku dashboard.
\ No newline at end of file
+3. Deploy! The app should be set up, with a working web interface and database. You can change settings and manage versions from the Heroku dashboard.
diff --git a/docs/Using-Mastodon/List-of-Mastodon-instances.md b/docs/Using-Mastodon/List-of-Mastodon-instances.md
index b79620fc6..ed3c74294 100644
--- a/docs/Using-Mastodon/List-of-Mastodon-instances.md
+++ b/docs/Using-Mastodon/List-of-Mastodon-instances.md
@@ -12,6 +12,7 @@ List of Known Mastodon instances
 | [on.vu](https://on.vu) | Appears defunct|No|
 | [gay.crime.team](https://gay.crime.team) |N/A|Yes(?)|
 | [gnusocial.me](https://gnusocial.me) |Yes, it's a mastodon instance now|Yes|
-
+| [icosahedron.website](https://icosahedron.website/) |Icosahedron-themed (well, visually), open registration.|Yes|
+| [memetastic.space](https://memetastic.space) |Memes|Yes|
 
 Let me know if you start running one so I can add it to the list! (Alternatively, add it yourself as a pull request).
diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake
index 1b60bd1c9..8482d4124 100644
--- a/lib/tasks/mastodon.rake
+++ b/lib/tasks/mastodon.rake
@@ -1,6 +1,15 @@
 # frozen_string_literal: true
 
 namespace :mastodon do
+  task make_admin: :environment do
+    include RoutingHelper
+
+    user = Account.find_local(ENV.fetch('USERNAME')).user
+    user.update(admin: true)
+
+    puts "Congrats! #{user.account.username} is now an admin. \\o/\nNavigate to #{admin_settings_url} to get started"
+  end
+
   namespace :media do
     desc 'Removes media attachments that have not been assigned to any status for longer than a day'
     task clear: :environment do
diff --git a/spec/fabricators/report_fabricator.rb b/spec/fabricators/report_fabricator.rb
new file mode 100644
index 000000000..b9fa360a7
--- /dev/null
+++ b/spec/fabricators/report_fabricator.rb
@@ -0,0 +1,4 @@
+Fabricator(:report) do
+  comment      "You nasty"
+  action_taken false
+end
diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb
new file mode 100644
index 000000000..ade53cffa
--- /dev/null
+++ b/spec/models/report_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe Report, type: :model do
+
+end