about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/fluffy-elephant-friend.pngbin0 -> 60667 bytes
-rw-r--r--app/assets/images/mastodon-not-found.pngbin0 -> 19560 bytes
-rw-r--r--app/assets/javascripts/components/actions/accounts.jsx102
-rw-r--r--app/assets/javascripts/components/actions/cards.jsx7
-rw-r--r--app/assets/javascripts/components/actions/compose.jsx46
-rw-r--r--app/assets/javascripts/components/actions/modal.jsx25
-rw-r--r--app/assets/javascripts/components/actions/notifications.jsx10
-rw-r--r--app/assets/javascripts/components/actions/reports.jsx64
-rw-r--r--app/assets/javascripts/components/actions/search.jsx66
-rw-r--r--app/assets/javascripts/components/actions/statuses.jsx20
-rw-r--r--app/assets/javascripts/components/actions/timelines.jsx86
-rw-r--r--app/assets/javascripts/components/components/autosuggest_textarea.jsx54
-rw-r--r--app/assets/javascripts/components/components/collapsable.jsx19
-rw-r--r--app/assets/javascripts/components/components/column_back_button.jsx3
-rw-r--r--app/assets/javascripts/components/components/column_collapsable.jsx6
-rw-r--r--app/assets/javascripts/components/components/display_name.jsx2
-rw-r--r--app/assets/javascripts/components/components/dropdown_menu.jsx96
-rw-r--r--app/assets/javascripts/components/components/extended_video_player.jsx21
-rw-r--r--app/assets/javascripts/components/components/icon_button.jsx7
-rw-r--r--app/assets/javascripts/components/components/lightbox.jsx82
-rw-r--r--app/assets/javascripts/components/components/loading_indicator.jsx3
-rw-r--r--app/assets/javascripts/components/components/media_gallery.jsx235
-rw-r--r--app/assets/javascripts/components/components/status.jsx2
-rw-r--r--app/assets/javascripts/components/components/status_action_bar.jsx33
-rw-r--r--app/assets/javascripts/components/components/status_content.jsx12
-rw-r--r--app/assets/javascripts/components/components/status_list.jsx47
-rw-r--r--app/assets/javascripts/components/components/video_player.jsx72
-rw-r--r--app/assets/javascripts/components/containers/account_container.jsx12
-rw-r--r--app/assets/javascripts/components/containers/mastodon.jsx20
-rw-r--r--app/assets/javascripts/components/containers/status_container.jsx69
-rw-r--r--app/assets/javascripts/components/emoji.jsx34
-rw-r--r--app/assets/javascripts/components/features/account/components/action_bar.jsx48
-rw-r--r--app/assets/javascripts/components/features/account/components/header.jsx61
-rw-r--r--app/assets/javascripts/components/features/account_timeline/components/header.jsx22
-rw-r--r--app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx17
-rw-r--r--app/assets/javascripts/components/features/account_timeline/index.jsx5
-rw-r--r--app/assets/javascripts/components/features/community_timeline/index.jsx95
-rw-r--r--app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx7
-rw-r--r--app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx15
-rw-r--r--app/assets/javascripts/components/features/compose/components/character_counter.jsx2
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx198
-rw-r--r--app/assets/javascripts/components/features/compose/components/drawer.jsx42
-rw-r--r--app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx58
-rw-r--r--app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx101
-rw-r--r--app/assets/javascripts/components/features/compose/components/reply_indicator.jsx17
-rw-r--r--app/assets/javascripts/components/features/compose/components/search.jsx110
-rw-r--r--app/assets/javascripts/components/features/compose/components/search_results.jsx68
-rw-r--r--app/assets/javascripts/components/features/compose/components/text_icon_button.jsx31
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_button.jsx7
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_form.jsx28
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_progress.jsx44
-rw-r--r--app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx15
-rw-r--r--app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx117
-rw-r--r--app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx17
-rw-r--r--app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx24
-rw-r--r--app/assets/javascripts/components/features/compose/containers/search_container.jsx20
-rw-r--r--app/assets/javascripts/components/features/compose/containers/search_results_container.jsx8
-rw-r--r--app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx49
-rw-r--r--app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx24
-rw-r--r--app/assets/javascripts/components/features/compose/containers/upload_progress_container.jsx9
-rw-r--r--app/assets/javascripts/components/features/compose/index.jsx65
-rw-r--r--app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx7
-rw-r--r--app/assets/javascripts/components/features/getting_started/index.jsx11
-rw-r--r--app/assets/javascripts/components/features/hashtag_timeline/index.jsx11
-rw-r--r--app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx12
-rw-r--r--app/assets/javascripts/components/features/home_timeline/index.jsx19
-rw-r--r--app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx4
-rw-r--r--app/assets/javascripts/components/features/notifications/components/column_settings.jsx12
-rw-r--r--app/assets/javascripts/components/features/notifications/components/notification.jsx22
-rw-r--r--app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx5
-rw-r--r--app/assets/javascripts/components/features/notifications/index.jsx59
-rw-r--r--app/assets/javascripts/components/features/public_timeline/index.jsx46
-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.jsx17
-rw-r--r--app/assets/javascripts/components/features/status/components/card.jsx43
-rw-r--r--app/assets/javascripts/components/features/status/components/detailed_status.jsx6
-rw-r--r--app/assets/javascripts/components/features/status/index.jsx65
-rw-r--r--app/assets/javascripts/components/features/ui/components/column.jsx7
-rw-r--r--app/assets/javascripts/components/features/ui/components/column_header.jsx7
-rw-r--r--app/assets/javascripts/components/features/ui/components/column_link.jsx1
-rw-r--r--app/assets/javascripts/components/features/ui/components/media_modal.jsx133
-rw-r--r--app/assets/javascripts/components/features/ui/components/modal_root.jsx80
-rw-r--r--app/assets/javascripts/components/features/ui/components/tabs_bar.jsx28
-rw-r--r--app/assets/javascripts/components/features/ui/components/upload_area.jsx32
-rw-r--r--app/assets/javascripts/components/features/ui/containers/modal_container.jsx166
-rw-r--r--app/assets/javascripts/components/features/ui/containers/status_list_container.jsx24
-rw-r--r--app/assets/javascripts/components/features/ui/index.jsx71
-rw-r--r--app/assets/javascripts/components/is_mobile.jsx6
-rw-r--r--app/assets/javascripts/components/locales/de.jsx2
-rw-r--r--app/assets/javascripts/components/locales/en.jsx23
-rw-r--r--app/assets/javascripts/components/locales/fi.jsx68
-rw-r--r--app/assets/javascripts/components/locales/fr.jsx52
-rw-r--r--app/assets/javascripts/components/locales/index.jsx4
-rw-r--r--app/assets/javascripts/components/middleware/errors.jsx2
-rw-r--r--app/assets/javascripts/components/middleware/sounds.jsx22
-rw-r--r--app/assets/javascripts/components/reducers/accounts.jsx4
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx65
-rw-r--r--app/assets/javascripts/components/reducers/index.jsx4
-rw-r--r--app/assets/javascripts/components/reducers/modal.jsx30
-rw-r--r--app/assets/javascripts/components/reducers/notifications.jsx24
-rw-r--r--app/assets/javascripts/components/reducers/relationships.jsx22
-rw-r--r--app/assets/javascripts/components/reducers/reports.jsx57
-rw-r--r--app/assets/javascripts/components/reducers/search.jsx94
-rw-r--r--app/assets/javascripts/components/reducers/statuses.jsx11
-rw-r--r--app/assets/javascripts/components/reducers/timelines.jsx65
-rw-r--r--app/assets/javascripts/components/rtl.jsx27
-rw-r--r--app/assets/javascripts/components/selectors/index.jsx57
-rw-r--r--app/assets/javascripts/components/store/configureStore.jsx11
-rw-r--r--app/assets/javascripts/extras.jsx13
-rw-r--r--app/assets/stylesheets/about.scss77
-rw-r--r--app/assets/stylesheets/accounts.scss1
-rw-r--r--app/assets/stylesheets/admin.scss52
-rw-r--r--app/assets/stylesheets/application.scss47
-rw-r--r--app/assets/stylesheets/components.scss966
-rw-r--r--app/assets/stylesheets/fonts/roboto-mono.scss7
-rw-r--r--app/assets/stylesheets/fonts/roboto.scss11
-rw-r--r--app/assets/stylesheets/forms.scss7
-rw-r--r--app/assets/stylesheets/stream_entries.scss59
-rw-r--r--app/controllers/about_controller.rb7
-rw-r--r--app/controllers/accounts_controller.rb4
-rw-r--r--app/controllers/admin/accounts_controller.rb23
-rw-r--r--app/controllers/admin/domain_blocks_controller.rb18
-rw-r--r--app/controllers/admin/reports_controller.rb45
-rw-r--r--app/controllers/admin/settings_controller.rb14
-rw-r--r--app/controllers/api/v1/accounts_controller.rb34
-rw-r--r--app/controllers/api/v1/apps_controller.rb8
-rw-r--r--app/controllers/api/v1/blocks_controller.rb2
-rw-r--r--app/controllers/api/v1/favourites_controller.rb2
-rw-r--r--app/controllers/api/v1/follow_requests_controller.rb6
-rw-r--r--app/controllers/api/v1/follows_controller.rb8
-rw-r--r--app/controllers/api/v1/instances_controller.rb7
-rw-r--r--app/controllers/api/v1/media_controller.rb8
-rw-r--r--app/controllers/api/v1/mutes_controller.rb21
-rw-r--r--app/controllers/api/v1/notifications_controller.rb6
-rw-r--r--app/controllers/api/v1/reports_controller.rb30
-rw-r--r--app/controllers/api/v1/search_controller.rb9
-rw-r--r--app/controllers/api/v1/statuses_controller.rb21
-rw-r--r--app/controllers/api/v1/timelines_controller.rb18
-rw-r--r--app/controllers/api_controller.rb5
-rw-r--r--app/controllers/application_controller.rb15
-rw-r--r--app/controllers/auth/registrations_controller.rb10
-rw-r--r--app/controllers/authorize_follow_controller.rb2
-rw-r--r--app/controllers/concerns/obfuscate_filename.rb7
-rw-r--r--app/controllers/oauth/authorizations_controller.rb7
-rw-r--r--app/controllers/remote_follow_controller.rb3
-rw-r--r--app/controllers/settings/exports_controller.rb46
-rw-r--r--app/controllers/settings/imports_controller.rb34
-rw-r--r--app/controllers/settings/preferences_controller.rb3
-rw-r--r--app/controllers/settings/two_factor_auths_controller.rb3
-rw-r--r--app/controllers/statuses_controller.rb39
-rw-r--r--app/controllers/stream_entries_controller.rb4
-rw-r--r--app/controllers/xrd_controller.rb11
-rw-r--r--app/helpers/atom_builder_helper.rb103
-rw-r--r--app/helpers/settings_helper.rb2
-rw-r--r--app/helpers/stream_entries_helper.rb13
-rw-r--r--app/lib/exceptions.rb4
-rw-r--r--app/lib/feed_manager.rb118
-rw-r--r--app/lib/formatter.rb29
-rw-r--r--app/lib/inline_rabl_scope.rb17
-rw-r--r--app/lib/tag_manager.rb32
-rw-r--r--app/mailers/notification_mailer.rb13
-rw-r--r--app/models/account.rb105
-rw-r--r--app/models/block.rb21
-rw-r--r--app/models/concerns/streamable.rb6
-rw-r--r--app/models/domain_block.rb2
-rw-r--r--app/models/favourite.rb25
-rw-r--r--app/models/feed.rb12
-rw-r--r--app/models/follow.rb21
-rw-r--r--app/models/follow_request.rb1
-rw-r--r--app/models/import.rb14
-rw-r--r--app/models/media_attachment.rb73
-rw-r--r--app/models/mute.rb11
-rw-r--r--app/models/report.rb10
-rw-r--r--app/models/setting.rb1
-rw-r--r--app/models/status.rb51
-rw-r--r--app/models/stream_entry.rb11
-rw-r--r--app/models/tag.rb22
-rw-r--r--app/models/user.rb7
-rw-r--r--app/services/account_search_service.rb26
-rw-r--r--app/services/authorize_follow_service.rb40
-rw-r--r--app/services/block_domain_service.rb10
-rw-r--r--app/services/block_service.rb26
-rw-r--r--app/services/concerns/stream_entry_renderer.rb8
-rw-r--r--app/services/fan_out_on_write_service.rb30
-rw-r--r--app/services/favourite_service.rb32
-rw-r--r--app/services/fetch_atom_service.rb4
-rw-r--r--app/services/fetch_remote_account_service.rb13
-rw-r--r--app/services/fetch_remote_resource_service.rb18
-rw-r--r--app/services/fetch_remote_status_service.rb9
-rw-r--r--app/services/follow_service.rb68
-rw-r--r--app/services/mute_service.rb23
-rw-r--r--app/services/notify_service.rb2
-rw-r--r--app/services/post_status_service.rb15
-rw-r--r--app/services/precompute_feed_service.rb10
-rw-r--r--app/services/process_feed_service.rb41
-rw-r--r--app/services/process_interaction_service.rb32
-rw-r--r--app/services/process_mentions_service.rb8
-rw-r--r--app/services/pubsubhubbub/subscribe_service.rb5
-rw-r--r--app/services/reblog_service.rb12
-rw-r--r--app/services/reject_follow_service.rb40
-rw-r--r--app/services/remove_status_service.rb10
-rw-r--r--app/services/search_service.rb25
-rw-r--r--app/services/send_interaction_service.rb19
-rw-r--r--app/services/unblock_service.rb24
-rw-r--r--app/services/unfavourite_service.rb30
-rw-r--r--app/services/unfollow_service.rb27
-rw-r--r--app/services/unmute_service.rb11
-rw-r--r--app/services/update_remote_profile_service.rb2
-rw-r--r--app/views/about/index.html.haml75
-rw-r--r--app/views/accounts/_header.html.haml10
-rw-r--r--app/views/accounts/show.atom.ruby3
-rw-r--r--app/views/accounts/show.html.haml5
-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/domain_blocks/index.html.haml1
-rw-r--r--app/views/admin/domain_blocks/new.html.haml18
-rw-r--r--app/views/admin/reports/index.html.haml32
-rw-r--r--app/views/admin/reports/show.html.haml44
-rw-r--r--app/views/admin/settings/index.html.haml18
-rw-r--r--app/views/api/v1/accounts/relationship.rabl1
-rw-r--r--app/views/api/v1/accounts/show.rabl8
-rw-r--r--app/views/api/v1/instances/show.rabl6
-rw-r--r--app/views/api/v1/media/create.rabl4
-rw-r--r--app/views/api/v1/mutes/index.rabl2
-rw-r--r--app/views/api/v1/notifications/show.rabl2
-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/api/v1/search/index.rabl13
-rw-r--r--app/views/api/v1/statuses/_show.rabl4
-rw-r--r--app/views/doorkeeper/authorized_applications/index.html.haml23
-rw-r--r--app/views/layouts/admin.html.haml9
-rw-r--r--app/views/layouts/application.html.haml2
-rw-r--r--app/views/layouts/mailer.text.erb2
-rw-r--r--app/views/notification_mailer/_status.text.erb4
-rw-r--r--app/views/notification_mailer/digest.text.erb15
-rw-r--r--app/views/notification_mailer/favourite.text.erb4
-rw-r--r--app/views/notification_mailer/follow.text.erb4
-rw-r--r--app/views/notification_mailer/follow_request.text.erb4
-rw-r--r--app/views/notification_mailer/mention.text.erb4
-rw-r--r--app/views/notification_mailer/reblog.text.erb4
-rw-r--r--app/views/settings/exports/show.html.haml17
-rw-r--r--app/views/settings/imports/show.html.haml11
-rw-r--r--app/views/settings/preferences/show.html.haml3
-rw-r--r--app/views/settings/two_factor_auths/show.html.haml8
-rw-r--r--app/views/shared/_landing_strip.html.haml2
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml16
-rw-r--r--app/views/stream_entries/_favourite.html.haml5
-rw-r--r--app/views/stream_entries/_follow.html.haml5
-rw-r--r--app/views/stream_entries/_media.html.haml4
-rw-r--r--app/views/stream_entries/_simple_status.html.haml21
-rw-r--r--app/views/stream_entries/_status.html.haml4
-rw-r--r--app/views/stream_entries/show.html.haml5
-rw-r--r--app/views/tags/show.html.haml14
-rw-r--r--app/workers/after_remote_follow_request_worker.rb17
-rw-r--r--app/workers/after_remote_follow_worker.rb17
-rw-r--r--app/workers/digest_mailer_worker.rb14
-rw-r--r--app/workers/domain_block_worker.rb11
-rw-r--r--app/workers/feed_insert_worker.rb15
-rw-r--r--app/workers/import_worker.rb54
-rw-r--r--app/workers/link_crawl_worker.rb2
-rw-r--r--app/workers/merge_worker.rb2
-rw-r--r--app/workers/notification_worker.rb6
-rw-r--r--app/workers/pubsubhubbub/delivery_worker.rb1
-rw-r--r--app/workers/pubsubhubbub/distribution_worker.rb5
-rw-r--r--app/workers/push_notification_worker.rb11
-rw-r--r--app/workers/regeneration_worker.rb6
-rw-r--r--app/workers/thread_resolve_worker.rb2
-rw-r--r--app/workers/unmerge_worker.rb2
270 files changed, 6135 insertions, 1906 deletions
diff --git a/app/assets/images/fluffy-elephant-friend.png b/app/assets/images/fluffy-elephant-friend.png
new file mode 100644
index 000000000..f0df29927
--- /dev/null
+++ b/app/assets/images/fluffy-elephant-friend.png
Binary files differdiff --git a/app/assets/images/mastodon-not-found.png b/app/assets/images/mastodon-not-found.png
new file mode 100644
index 000000000..76108d41f
--- /dev/null
+++ b/app/assets/images/mastodon-not-found.png
Binary files differdiff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx
index 0be05034e..37ebb9969 100644
--- a/app/assets/javascripts/components/actions/accounts.jsx
+++ b/app/assets/javascripts/components/actions/accounts.jsx
@@ -21,6 +21,14 @@ export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
 export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
 export const ACCOUNT_UNBLOCK_FAIL    = 'ACCOUNT_UNBLOCK_FAIL';
 
+export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
+export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
+export const ACCOUNT_MUTE_FAIL    = 'ACCOUNT_MUTE_FAIL';
+
+export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
+export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
+export const ACCOUNT_UNMUTE_FAIL    = 'ACCOUNT_UNMUTE_FAIL';
+
 export const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST';
 export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS';
 export const ACCOUNT_TIMELINE_FETCH_FAIL    = 'ACCOUNT_TIMELINE_FETCH_FAIL';
@@ -67,11 +75,16 @@ export const FOLLOW_REQUEST_REJECT_FAIL    = 'FOLLOW_REQUEST_REJECT_FAIL';
 
 export function fetchAccount(id) {
   return (dispatch, getState) => {
+    dispatch(fetchRelationships([id]));
+
+    if (getState().getIn(['accounts', id], null) !== null) {
+      return;
+    }
+
     dispatch(fetchAccountRequest(id));
 
     api(getState).get(`/api/v1/accounts/${id}`).then(response => {
       dispatch(fetchAccountSuccess(response.data));
-      dispatch(fetchRelationships([id]));
     }).catch(error => {
       dispatch(fetchAccountFail(id, error));
     });
@@ -138,7 +151,8 @@ export function fetchAccountFail(id, error) {
   return {
     type: ACCOUNT_FETCH_FAIL,
     id,
-    error
+    error,
+    skipAlert: true
   };
 };
 
@@ -231,7 +245,8 @@ export function fetchAccountTimelineFail(id, error, skipLoading) {
     type: ACCOUNT_TIMELINE_FETCH_FAIL,
     id,
     error,
-    skipLoading
+    skipLoading,
+    skipAlert: error.response.status === 404
   };
 };
 
@@ -326,6 +341,76 @@ export function unblockAccountFail(error) {
   };
 };
 
+
+export function muteAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(muteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => {
+      // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+      dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
+    }).catch(error => {
+      dispatch(muteAccountFail(id, error));
+    });
+  };
+};
+
+export function unmuteAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unmuteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
+      dispatch(unmuteAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(unmuteAccountFail(id, error));
+    });
+  };
+};
+
+export function muteAccountRequest(id) {
+  return {
+    type: ACCOUNT_MUTE_REQUEST,
+    id
+  };
+};
+
+export function muteAccountSuccess(relationship, statuses) {
+  return {
+    type: ACCOUNT_MUTE_SUCCESS,
+    relationship,
+    statuses
+  };
+};
+
+export function muteAccountFail(error) {
+  return {
+    type: ACCOUNT_MUTE_FAIL,
+    error
+  };
+};
+
+export function unmuteAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNMUTE_REQUEST,
+    id
+  };
+};
+
+export function unmuteAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_UNMUTE_SUCCESS,
+    relationship
+  };
+};
+
+export function unmuteAccountFail(error) {
+  return {
+    type: ACCOUNT_UNMUTE_FAIL,
+    error
+  };
+};
+
+
 export function fetchFollowers(id) {
   return (dispatch, getState) => {
     dispatch(fetchFollowersRequest(id));
@@ -494,15 +579,18 @@ export function expandFollowingFail(id, error) {
   };
 };
 
-export function fetchRelationships(account_ids) {
+export function fetchRelationships(accountIds) {
   return (dispatch, getState) => {
-    if (account_ids.length === 0) {
+    const loadedRelationships = getState().get('relationships');
+    const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null);
+
+    if (newAccountIds.length === 0) {
       return;
     }
 
-    dispatch(fetchRelationshipsRequest(account_ids));
+    dispatch(fetchRelationshipsRequest(newAccountIds));
 
-    api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
+    api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
       dispatch(fetchRelationshipsSuccess(response.data));
     }).catch(error => {
       dispatch(fetchRelationshipsFail(error));
diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx
index 503c2bfeb..d4c1eda60 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 => {
@@ -42,6 +46,7 @@ export function fetchStatusCardFail(id, error) {
     type: STATUS_CARD_FETCH_FAIL,
     id,
     error,
-    skipLoading: true
+    skipLoading: true,
+    skipAlert: true
   };
 };
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx
index f87518751..1b3cc60dc 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';
 
@@ -28,6 +28,8 @@ export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
 export const COMPOSE_VISIBILITY_CHANGE  = 'COMPOSE_VISIBILITY_CHANGE';
 export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
 
+export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
+
 export function changeCompose(text) {
   return {
     type: COMPOSE_CHANGE,
@@ -77,7 +79,7 @@ export function submitCompose() {
       media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
       sensitive: getState().getIn(['compose', 'sensitive']),
       spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
-      visibility: getState().getIn(['compose', 'private']) ? 'private' : (getState().getIn(['compose', 'unlisted']) ? 'unlisted' : 'public')
+      visibility: getState().getIn(['compose', 'privacy'])
     }).then(function (response) {
       dispatch(submitComposeSuccess({ ...response.data }));
 
@@ -85,7 +87,13 @@ export function submitCompose() {
       dispatch(updateTimeline('home', { ...response.data }));
 
       if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
-        dispatch(updateTimeline('public', { ...response.data }));
+        if (getState().getIn(['timelines', 'community', 'loaded'])) {
+          dispatch(updateTimeline('community', { ...response.data }));
+        }
+
+        if (getState().getIn(['timelines', 'public', 'loaded'])) {
+          dispatch(updateTimeline('public', { ...response.data }));
+        }
       }
     }).catch(function (error) {
       dispatch(submitComposeFail(error));
@@ -115,6 +123,10 @@ export function submitComposeFail(error) {
 
 export function uploadCompose(files) {
   return function (dispatch, getState) {
+    if (getState().getIn(['compose', 'media_attachments']).size > 3) {
+      return;
+    }
+
     dispatch(uploadComposeRequest());
 
     let data = new FormData();
@@ -134,7 +146,8 @@ export function uploadCompose(files) {
 
 export function uploadComposeRequest() {
   return {
-    type: COMPOSE_UPLOAD_REQUEST
+    type: COMPOSE_UPLOAD_REQUEST,
+    skipLoading: true
   };
 };
 
@@ -149,14 +162,16 @@ export function uploadComposeProgress(loaded, total) {
 export function uploadComposeSuccess(media) {
   return {
     type: COMPOSE_UPLOAD_SUCCESS,
-    media: media
+    media: media,
+    skipLoading: true
   };
 };
 
 export function uploadComposeFail(error) {
   return {
     type: COMPOSE_UPLOAD_FAIL,
-    error: error
+    error: error,
+    skipLoading: true
   };
 };
 
@@ -220,17 +235,15 @@ export function unmountCompose() {
   };
 };
 
-export function changeComposeSensitivity(checked) {
+export function changeComposeSensitivity() {
   return {
     type: COMPOSE_SENSITIVITY_CHANGE,
-    checked
   };
 };
 
-export function changeComposeSpoilerness(checked) {
+export function changeComposeSpoilerness() {
   return {
-    type: COMPOSE_SPOILERNESS_CHANGE,
-    checked
+    type: COMPOSE_SPOILERNESS_CHANGE
   };
 };
 
@@ -241,16 +254,17 @@ export function changeComposeSpoilerText(text) {
   };
 };
 
-export function changeComposeVisibility(checked) {
+export function changeComposeVisibility(value) {
   return {
     type: COMPOSE_VISIBILITY_CHANGE,
-    checked
+    value
   };
 };
 
-export function changeComposeListability(checked) {
+export function insertEmojiCompose(position, emoji) {
   return {
-    type: COMPOSE_LISTABILITY_CHANGE,
-    checked
+    type: COMPOSE_EMOJI_INSERT,
+    position,
+    emoji
   };
 };
diff --git a/app/assets/javascripts/components/actions/modal.jsx b/app/assets/javascripts/components/actions/modal.jsx
index d19218c48..615cd6bfe 100644
--- a/app/assets/javascripts/components/actions/modal.jsx
+++ b/app/assets/javascripts/components/actions/modal.jsx
@@ -1,14 +1,11 @@
-export const MEDIA_OPEN  = 'MEDIA_OPEN';
+export const MODAL_OPEN  = 'MODAL_OPEN';
 export const MODAL_CLOSE = 'MODAL_CLOSE';
 
-export const MODAL_INDEX_DECREASE = 'MODAL_INDEX_DECREASE';
-export const MODAL_INDEX_INCREASE = 'MODAL_INDEX_INCREASE';
-
-export function openMedia(media, index) {
+export function openModal(type, props) {
   return {
-    type: MEDIA_OPEN,
-    media,
-    index
+    type: MODAL_OPEN,
+    modalType: type,
+    modalProps: props
   };
 };
 
@@ -17,15 +14,3 @@ export function closeModal() {
     type: MODAL_CLOSE
   };
 };
-
-export function decreaseIndexInModal() {
-  return {
-    type: MODAL_INDEX_DECREASE
-  };
-};
-
-export function increaseIndexInModal() {
-  return {
-    type: MODAL_INDEX_INCREASE
-  };
-};
diff --git a/app/assets/javascripts/components/actions/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx
index df82e73fc..980b7d63e 100644
--- a/app/assets/javascripts/components/actions/notifications.jsx
+++ b/app/assets/javascripts/components/actions/notifications.jsx
@@ -14,7 +14,8 @@ export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
 export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
 export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL';
 
-export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
+export const NOTIFICATIONS_CLEAR      = 'NOTIFICATIONS_CLEAR';
+export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
 
 const fetchRelatedRelationships = (dispatch, notifications) => {
   const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
@@ -151,3 +152,10 @@ export function clearNotifications() {
     api(getState).post('/api/v1/notifications/clear');
   };
 };
+
+export function scrollTopNotifications(top) {
+  return {
+    type: NOTIFICATIONS_SCROLL_TOP,
+    top
+  };
+};
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/search.jsx b/app/assets/javascripts/components/actions/search.jsx
index ceb0e4a0c..df3ae0db1 100644
--- a/app/assets/javascripts/components/actions/search.jsx
+++ b/app/assets/javascripts/components/actions/search.jsx
@@ -1,9 +1,12 @@
 import api from '../api'
 
-export const SEARCH_CHANGE            = 'SEARCH_CHANGE';
-export const SEARCH_SUGGESTIONS_CLEAR = 'SEARCH_SUGGESTIONS_CLEAR';
-export const SEARCH_SUGGESTIONS_READY = 'SEARCH_SUGGESTIONS_READY';
-export const SEARCH_RESET             = 'SEARCH_RESET';
+export const SEARCH_CHANGE = 'SEARCH_CHANGE';
+export const SEARCH_CLEAR  = 'SEARCH_CLEAR';
+export const SEARCH_SHOW   = 'SEARCH_SHOW';
+
+export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
+export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
+export const SEARCH_FETCH_FAIL    = 'SEARCH_FETCH_FAIL';
 
 export function changeSearch(value) {
   return {
@@ -12,40 +15,59 @@ export function changeSearch(value) {
   };
 };
 
-export function clearSearchSuggestions() {
-  return {
-    type: SEARCH_SUGGESTIONS_CLEAR
-  };
-};
-
-export function readySearchSuggestions(value, accounts) {
+export function clearSearch() {
   return {
-    type: SEARCH_SUGGESTIONS_READY,
-    value,
-    accounts
+    type: SEARCH_CLEAR
   };
 };
 
-export function fetchSearchSuggestions(value) {
+export function submitSearch() {
   return (dispatch, getState) => {
-    if (getState().getIn(['search', 'loaded_value']) === value) {
+    const value = getState().getIn(['search', 'value']);
+
+    if (value.length === 0) {
       return;
     }
 
-    api(getState).get('/api/v1/accounts/search', {
+    dispatch(fetchSearchRequest());
+
+    api(getState).get('/api/v1/search', {
       params: {
         q: value,
-        resolve: true,
-        limit: 4
+        resolve: true
       }
     }).then(response => {
-      dispatch(readySearchSuggestions(value, response.data));
+      dispatch(fetchSearchSuccess(response.data));
+    }).catch(error => {
+      dispatch(fetchSearchFail(error));
     });
   };
 };
 
-export function resetSearch() {
+export function fetchSearchRequest() {
+  return {
+    type: SEARCH_FETCH_REQUEST
+  };
+};
+
+export function fetchSearchSuccess(results) {
+  return {
+    type: SEARCH_FETCH_SUCCESS,
+    results,
+    accounts: results.accounts,
+    statuses: results.statuses
+  };
+};
+
+export function fetchSearchFail(error) {
+  return {
+    type: SEARCH_FETCH_FAIL,
+    error
+  };
+};
+
+export function showSearch() {
   return {
-    type: SEARCH_RESET
+    type: SEARCH_SHOW
   };
 };
diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx
index 9ac215727..19df2c36c 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));
     });
@@ -52,7 +57,8 @@ export function fetchStatusFail(id, error, skipLoading) {
     type: STATUS_FETCH_FAIL,
     id,
     error,
-    skipLoading
+    skipLoading,
+    skipAlert: true
   };
 };
 
@@ -97,7 +103,12 @@ export function fetchContext(id) {
 
     api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
       dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
+
     }).catch(error => {
+      if (error.response.status === 404) {
+        dispatch(deleteFromTimelines(id));
+      }
+
       dispatch(fetchContextFail(id, error));
     });
   };
@@ -124,6 +135,7 @@ export function fetchContextFail(id, error) {
   return {
     type: CONTEXT_FETCH_FAIL,
     id,
-    error
+    error,
+    skipAlert: true
   };
 };
diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx
index 1531b89a3..6cd1f04b3 100644
--- a/app/assets/javascripts/components/actions/timelines.jsx
+++ b/app/assets/javascripts/components/actions/timelines.jsx
@@ -1,4 +1,4 @@
-import api from '../api'
+import api, { getLinks } from '../api'
 import Immutable from 'immutable';
 
 export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE';
@@ -14,12 +14,16 @@ export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL';
 
 export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 
-export function refreshTimelineSuccess(timeline, statuses, skipLoading) {
+export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT';
+export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
+
+export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
   return {
     type: TIMELINE_REFRESH_SUCCESS,
     timeline,
     statuses,
-    skipLoading
+    skipLoading,
+    next
   };
 };
 
@@ -69,25 +73,27 @@ export function refreshTimeline(timeline, id = null) {
 
     const ids      = getState().getIn(['timelines', timeline, 'items'], Immutable.List());
     const newestId = ids.size > 0 ? ids.first() : null;
+    let params     = getState().getIn(['timelines', timeline, 'params'], {});
+    const path     = getState().getIn(['timelines', timeline, 'path'])(id);
 
-    let params      = '';
-    let path        = timeline;
     let skipLoading = false;
 
     if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) {
-      params      = `?since_id=${newestId}`;
-      skipLoading = true;
-    }
+      if (id === null && getState().getIn(['timelines', timeline, 'online'])) {
+        // Skip refreshing when timeline is live anyway
+        return;
+      }
 
-    if (id) {
-      path = `${path}/${id}`
+      params          = { ...params, since_id: newestId };
+      skipLoading     = true;
     }
 
     dispatch(refreshTimelineRequest(timeline, id, skipLoading));
 
-    api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) {
-      dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading));
-    }).catch(function (error) {
+    api(getState).get(path, { params }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading, next ? next.uri : null));
+    }).catch(error => {
       dispatch(refreshTimelineFail(timeline, error, skipLoading));
     });
   };
@@ -102,50 +108,50 @@ export function refreshTimelineFail(timeline, error, skipLoading) {
   };
 };
 
-export function expandTimeline(timeline, id = null) {
+export function expandTimeline(timeline) {
   return (dispatch, getState) => {
-    const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last();
-
-    if (!lastId || getState().getIn(['timelines', timeline, 'isLoading'])) {
-      // If timeline is empty, don't try to load older posts since there are none
-      // Also if already loading
+    if (getState().getIn(['timelines', timeline, 'isLoading'])) {
       return;
     }
 
-    dispatch(expandTimelineRequest(timeline, id));
+    if (getState().getIn(['timelines', timeline, 'items']).size === 0) {
+      return;
+    }
 
-    let path = timeline;
+    const path   = getState().getIn(['timelines', timeline, 'path'])(getState().getIn(['timelines', timeline, 'id']));
+    const params = getState().getIn(['timelines', timeline, 'params'], {});
+    const lastId = getState().getIn(['timelines', timeline, 'items']).last();
 
-    if (id) {
-      path = `${path}/${id}`
-    }
+    dispatch(expandTimelineRequest(timeline));
 
-    api(getState).get(`/api/v1/timelines/${path}`, {
+    api(getState).get(path, {
       params: {
-        limit: 10,
-        max_id: lastId
+        ...params,
+        max_id: lastId,
+        limit: 10
       }
     }).then(response => {
-      dispatch(expandTimelineSuccess(timeline, response.data));
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandTimelineSuccess(timeline, response.data, next ? next.uri : null));
     }).catch(error => {
       dispatch(expandTimelineFail(timeline, error));
     });
   };
 };
 
-export function expandTimelineRequest(timeline, id) {
+export function expandTimelineRequest(timeline) {
   return {
     type: TIMELINE_EXPAND_REQUEST,
-    timeline,
-    id
+    timeline
   };
 };
 
-export function expandTimelineSuccess(timeline, statuses) {
+export function expandTimelineSuccess(timeline, statuses, next) {
   return {
     type: TIMELINE_EXPAND_SUCCESS,
     timeline,
-    statuses
+    statuses,
+    next
   };
 };
 
@@ -164,3 +170,17 @@ export function scrollTopTimeline(timeline, top) {
     top
   };
 };
+
+export function connectTimeline(timeline) {
+  return {
+    type: TIMELINE_CONNECT,
+    timeline
+  };
+};
+
+export function disconnectTimeline(timeline) {
+  return {
+    type: TIMELINE_DISCONNECT,
+    timeline
+  };
+};
diff --git a/app/assets/javascripts/components/components/autosuggest_textarea.jsx b/app/assets/javascripts/components/components/autosuggest_textarea.jsx
index 4e4c2090c..744424661 100644
--- a/app/assets/javascripts/components/components/autosuggest_textarea.jsx
+++ b/app/assets/javascripts/components/components/autosuggest_textarea.jsx
@@ -1,5 +1,6 @@
 import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
 import ImmutablePropTypes from 'react-immutable-proptypes';
+import { isRtl } from '../rtl';
 
 const textAtCursorMatchesToken = (str, caretPosition) => {
   let word;
@@ -32,20 +33,18 @@ const AutosuggestTextarea = React.createClass({
     value: React.PropTypes.string,
     suggestions: ImmutablePropTypes.list,
     disabled: React.PropTypes.bool,
-    fileDropDate: React.PropTypes.instanceOf(Date),
     placeholder: React.PropTypes.string,
     onSuggestionSelected: React.PropTypes.func.isRequired,
     onSuggestionsClearRequested: React.PropTypes.func.isRequired,
     onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
     onChange: React.PropTypes.func.isRequired,
     onKeyUp: React.PropTypes.func,
-    onKeyDown: React.PropTypes.func
+    onKeyDown: React.PropTypes.func,
+    onPaste: React.PropTypes.func.isRequired,
   },
 
   getInitialState () {
     return {
-      isFileDragging: false,
-      fileDraggingDate: undefined,
       suggestionsHidden: false,
       selectedSuggestion: 0,
       lastToken: null,
@@ -137,45 +136,28 @@ const AutosuggestTextarea = React.createClass({
     if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
       this.setState({ suggestionsHidden: false });
     }
-
-    const fileDropDate = nextProps.fileDropDate;
-    const { isFileDragging, fileDraggingDate } = this.state;
-
-    /*
-     * We can't detect drop events, because they might not be on the textarea (the app allows dropping anywhere in the
-     * window). Instead, on-drop, we notify this textarea to stop its hover effect by passing in a prop with the
-     * drop-date.
-     */
-    if (isFileDragging && fileDraggingDate && fileDropDate // if dragging when props updated, and dates aren't undefined
-      && fileDropDate > fileDraggingDate) { // and if the drop date is now greater than when we started dragging
-      // then we should stop dragging
-      this.setState({
-        isFileDragging: false
-      });
-    }
   },
 
   setTextarea (c) {
     this.textarea = c;
   },
 
-  onDragEnter () {
-    this.setState({
-      isFileDragging: true,
-      fileDraggingDate: new Date()
-    })
-  },
-
-  onDragExit () {
-    this.setState({
-      isFileDragging: false
-    })
+  onPaste (e) {
+    if (e.clipboardData && e.clipboardData.files.length === 1) {
+      this.props.onPaste(e.clipboardData.files)
+      e.preventDefault();
+    }
   },
 
   render () {
-    const { value, suggestions, fileDropDate, disabled, placeholder, onKeyUp } = this.props;
-    const { isFileDragging, suggestionsHidden, selectedSuggestion } = this.state;
-    const className = isFileDragging ? 'autosuggest-textarea__textarea file-drop' : 'autosuggest-textarea__textarea';
+    const { value, suggestions, disabled, placeholder, onKeyUp } = this.props;
+    const { suggestionsHidden, selectedSuggestion } = this.state;
+    const className = 'autosuggest-textarea__textarea';
+    const style     = { direction: 'ltr' };
+
+    if (isRtl(value)) {
+      style.direction = 'rtl';
+    }
 
     return (
       <div className='autosuggest-textarea'>
@@ -190,8 +172,8 @@ const AutosuggestTextarea = React.createClass({
           onKeyDown={this.onKeyDown}
           onKeyUp={onKeyUp}
           onBlur={this.onBlur}
-          onDragEnter={this.onDragEnter}
-          onDragExit={this.onDragExit}
+          onPaste={this.onPaste}
+          style={style}
         />
 
         <div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
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/column_back_button.jsx b/app/assets/javascripts/components/components/column_back_button.jsx
index 6abf11239..6b5ffee53 100644
--- a/app/assets/javascripts/components/components/column_back_button.jsx
+++ b/app/assets/javascripts/components/components/column_back_button.jsx
@@ -15,7 +15,8 @@ const ColumnBackButton = React.createClass({
   mixins: [PureRenderMixin],
 
   handleClick () {
-    this.context.router.goBack();
+    if (window.history && window.history.length == 1) this.context.router.push("/");
+    else this.context.router.goBack();
   },
 
   render () {
diff --git a/app/assets/javascripts/components/components/column_collapsable.jsx b/app/assets/javascripts/components/components/column_collapsable.jsx
index 90c561bce..44added8a 100644
--- a/app/assets/javascripts/components/components/column_collapsable.jsx
+++ b/app/assets/javascripts/components/components/column_collapsable.jsx
@@ -7,7 +7,8 @@ const iconStyle = {
   position: 'absolute',
   right: '0',
   top: '-48px',
-  cursor: 'pointer'
+  cursor: 'pointer',
+  zIndex: '3'
 };
 
 const ColumnCollapsable = React.createClass({
@@ -40,10 +41,11 @@ const ColumnCollapsable = React.createClass({
   render () {
     const { icon, fullHeight, children } = this.props;
     const { collapsed } = this.state;
+    const collapsedClassName = collapsed ? 'collapsable-collapsed' : 'collapsable';
 
     return (
       <div style={{ position: 'relative' }}>
-        <div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className={`fa fa-${icon}`} /></div>
+        <div style={{...iconStyle }} className={`column-icon ${collapsedClassName}`} onClick={this.handleToggleCollapsed}><i className={`fa fa-${icon}`} /></div>
 
         <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}>
           {({ opacity, height }) =>
diff --git a/app/assets/javascripts/components/components/display_name.jsx b/app/assets/javascripts/components/components/display_name.jsx
index 053b5290c..aa48608d3 100644
--- a/app/assets/javascripts/components/components/display_name.jsx
+++ b/app/assets/javascripts/components/components/display_name.jsx
@@ -1,6 +1,6 @@
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
+import escapeTextContentForBrowser from 'escape-html';
 import emojify from '../emoji';
 
 const DisplayName = React.createClass({
diff --git a/app/assets/javascripts/components/components/dropdown_menu.jsx b/app/assets/javascripts/components/components/dropdown_menu.jsx
index ffef29c00..2b42eaa60 100644
--- a/app/assets/javascripts/components/components/dropdown_menu.jsx
+++ b/app/assets/javascripts/components/components/dropdown_menu.jsx
@@ -1,32 +1,72 @@
 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
+  },
+
+  getDefaultProps () {
+    return {
+      direction: 'left'
+    };
+  },
+
+  mixins: [PureRenderMixin],
+
+  setRef (c) {
+    this.dropdown = c;
+  },
+
+  handleClick (i, e) {
+    const { action } = this.props.items[i];
+
+    if (typeof action === 'function') {
+      e.preventDefault();
+      action();
+      this.dropdown.hide();
+    }
+  },
+
+  renderItem (item, i) {
+    if (item === null) {
+      return <li key={i} className='dropdown__sep' />;
+    }
+
+    const { text, action, href = '#' } = item;
+
+    return (
+      <li key={i}>
+        <a href={href} target='_blank' rel='noopener' onClick={this.handleClick.bind(this, i)}>
+          {text}
+        </a>
+      </li>
+    );
+  },
+
+  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(this.renderItem)}
+          </ul>
+        </DropdownContent>
+      </Dropdown>
+    );
+  }
+
+});
 
 export default DropdownMenu;
diff --git a/app/assets/javascripts/components/components/extended_video_player.jsx b/app/assets/javascripts/components/components/extended_video_player.jsx
new file mode 100644
index 000000000..66e5dee16
--- /dev/null
+++ b/app/assets/javascripts/components/components/extended_video_player.jsx
@@ -0,0 +1,21 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const ExtendedVideoPlayer = React.createClass({
+
+  propTypes: {
+    src: React.PropTypes.string.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    return (
+      <div>
+        <video src={this.props.src} autoPlay muted loop />
+      </div>
+    );
+  },
+
+});
+
+export default ExtendedVideoPlayer;
diff --git a/app/assets/javascripts/components/components/icon_button.jsx b/app/assets/javascripts/components/components/icon_button.jsx
index f9b6192c0..a08b1159b 100644
--- a/app/assets/javascripts/components/components/icon_button.jsx
+++ b/app/assets/javascripts/components/components/icon_button.jsx
@@ -12,6 +12,7 @@ const IconButton = React.createClass({
     style: React.PropTypes.object,
     activeStyle: React.PropTypes.object,
     disabled: React.PropTypes.bool,
+    inverted: React.PropTypes.bool,
     animate: React.PropTypes.bool
   },
 
@@ -36,10 +37,6 @@ const IconButton = React.createClass({
 
   render () {
     let style = {
-      display: 'inline-block',
-      border: 'none',
-      padding: '0',
-      background: 'transparent',
       fontSize: `${this.props.size}px`,
       width: `${this.props.size * 1.28571429}px`,
       height: `${this.props.size}px`,
@@ -57,7 +54,7 @@ const IconButton = React.createClass({
           <button
             aria-label={this.props.title}
             title={this.props.title}
-            className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`}
+            className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''} ${this.props.inverted ? 'inverted' : ''}`}
             onClick={this.handleClick}
             style={style}>
             <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
diff --git a/app/assets/javascripts/components/components/lightbox.jsx b/app/assets/javascripts/components/components/lightbox.jsx
deleted file mode 100644
index f04ca47ba..000000000
--- a/app/assets/javascripts/components/components/lightbox.jsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import PureRenderMixin from 'react-addons-pure-render-mixin';
-import IconButton from './icon_button';
-import { Motion, spring } from 'react-motion';
-import { injectIntl } from 'react-intl';
-
-const overlayStyle = {
-  position: 'fixed',
-  top: '0',
-  left: '0',
-  width: '100%',
-  height: '100%',
-  background: 'rgba(0, 0, 0, 0.5)',
-  display: 'flex',
-  justifyContent: 'center',
-  alignContent: 'center',
-  flexDirection: 'row',
-  zIndex: '9999'
-};
-
-const dialogStyle = {
-  color: '#282c37',
-  boxShadow: '0 0 30px rgba(0, 0, 0, 0.8)',
-  margin: 'auto',
-  position: 'relative'
-};
-
-const closeStyle = {
-  position: 'absolute',
-  top: '4px',
-  right: '4px'
-};
-
-const Lightbox = React.createClass({
-
-  propTypes: {
-    isVisible: React.PropTypes.bool,
-    onOverlayClicked: React.PropTypes.func,
-    onCloseClicked: React.PropTypes.func,
-    intl: React.PropTypes.object.isRequired,
-    children: React.PropTypes.node
-  },
-
-  mixins: [PureRenderMixin],
-
-  componentDidMount () {
-    this._listener = e => {
-      if (this.props.isVisible && e.key === 'Escape') {
-        this.props.onCloseClicked();
-      }
-    };
-
-    window.addEventListener('keyup', this._listener);
-  },
-
-  componentWillUnmount () {
-    window.removeEventListener('keyup', this._listener);
-  },
-
-  stopPropagation (e) {
-    e.stopPropagation();
-  },
-
-  render () {
-    const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props;
-
-    return (
-      <Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}>
-        {({ backgroundOpacity, opacity, y }) =>
-          <div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex', pointerEvents: !isVisible ? 'none' : 'auto'}} onClick={onOverlayClicked}>
-            <div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }} onClick={this.stopPropagation}>
-              <IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
-              {children}
-            </div>
-          </div>
-        }
-      </Motion>
-    );
-  }
-
-});
-
-export default injectIntl(Lightbox);
diff --git a/app/assets/javascripts/components/components/loading_indicator.jsx b/app/assets/javascripts/components/components/loading_indicator.jsx
index c8a263924..913a4bf99 100644
--- a/app/assets/javascripts/components/components/loading_indicator.jsx
+++ b/app/assets/javascripts/components/components/loading_indicator.jsx
@@ -4,12 +4,11 @@ const style = {
   textAlign: 'center',
   fontSize: '16px',
   fontWeight: '500',
-  color: '#616b86',
   paddingTop: '120px'
 };
 
 const LoadingIndicator = () => (
-  <div style={style}>
+  <div className='loading-indicator' style={style}>
     <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
   </div>
 );
diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx
index a13448d0b..72b5e977f 100644
--- a/app/assets/javascripts/components/components/media_gallery.jsx
+++ b/app/assets/javascripts/components/components/media_gallery.jsx
@@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import IconButton from './icon_button';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { isIOS } from '../is_mobile';
 
 const messages = defineMessages({
   toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
@@ -16,8 +17,6 @@ const outerStyle = {
 };
 
 const spoilerStyle = {
-  background: '#000',
-  color: '#fff',
   textAlign: 'center',
   height: '100%',
   cursor: 'pointer',
@@ -45,6 +44,141 @@ const spoilerButtonStyle = {
   zIndex: '100'
 };
 
+const itemStyle = {
+  boxSizing: 'border-box',
+  position: 'relative',
+  float: 'left',
+  border: 'none',
+  display: 'block'
+};
+
+const thumbStyle = {
+  display: 'block',
+  width: '100%',
+  height: '100%',
+  textDecoration: 'none',
+  backgroundSize: 'cover',
+  cursor: 'zoom-in'
+};
+
+const gifvThumbStyle = {
+  position: 'relative',
+  zIndex: '1',
+  width: '100%',
+  height: '100%',
+  objectFit: 'cover',
+  top: '50%',
+  transform: 'translateY(-50%)',
+  cursor: 'zoom-in'
+};
+
+const Item = React.createClass({
+
+  propTypes: {
+    attachment: ImmutablePropTypes.map.isRequired,
+    index: React.PropTypes.number.isRequired,
+    size: React.PropTypes.number.isRequired,
+    onClick: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleClick (e) {
+    const { index, onClick } = this.props;
+
+    if (e.button === 0) {
+      e.preventDefault();
+      onClick(index);
+    }
+
+    e.stopPropagation();
+  },
+
+  render () {
+    const { attachment, index, size } = this.props;
+
+    let width  = 50;
+    let height = 100;
+    let top    = 'auto';
+    let left   = 'auto';
+    let bottom = 'auto';
+    let right  = 'auto';
+
+    if (size === 1) {
+      width = 100;
+    }
+
+    if (size === 4 || (size === 3 && index > 0)) {
+      height = 50;
+    }
+
+    if (size === 2) {
+      if (index === 0) {
+        right = '2px';
+      } else {
+        left = '2px';
+      }
+    } else if (size === 3) {
+      if (index === 0) {
+        right = '2px';
+      } else if (index > 0) {
+        left = '2px';
+      }
+
+      if (index === 1) {
+        bottom = '2px';
+      } else if (index > 1) {
+        top = '2px';
+      }
+    } else if (size === 4) {
+      if (index === 0 || index === 2) {
+        right = '2px';
+      }
+
+      if (index === 1 || index === 3) {
+        left = '2px';
+      }
+
+      if (index < 2) {
+        bottom = '2px';
+      } else {
+        top = '2px';
+      }
+    }
+
+    let thumbnail = '';
+
+    if (attachment.get('type') === 'image') {
+      thumbnail = (
+        <a
+          href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')}
+          onClick={this.handleClick}
+          target='_blank'
+          style={{ background: `url(${attachment.get('preview_url')}) no-repeat center`, ...thumbStyle }}
+        />
+      );
+    } else if (attachment.get('type') === 'gifv') {
+      thumbnail = (
+        <video
+          src={attachment.get('url')}
+          onClick={this.handleClick}
+          autoPlay={!isIOS()}
+          loop={true}
+          muted={true}
+          style={gifvThumbStyle}
+        />
+      );
+    }
+
+    return (
+      <div key={attachment.get('id')} style={{ ...itemStyle, left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+        {thumbnail}
+      </div>
+    );
+  }
+
+});
+
 const MediaGallery = React.createClass({
 
   getInitialState () {
@@ -63,17 +197,12 @@ const MediaGallery = React.createClass({
 
   mixins: [PureRenderMixin],
 
-  handleClick (index, e) {
-    if (e.button === 0) {
-      e.preventDefault();
-      this.props.onOpenMedia(this.props.media, index);
-    }
-
-    e.stopPropagation();
+  handleOpen (e) {
+    this.setState({ visible: !this.state.visible });
   },
 
-  handleOpen () {
-    this.setState({ visible: !this.state.visible });
+  handleClick (index) {
+    this.props.onOpenMedia(this.props.media, index);
   },
 
   render () {
@@ -82,87 +211,31 @@ const MediaGallery = React.createClass({
     let children;
 
     if (!this.state.visible) {
+      let warning;
+
       if (sensitive) {
-        children = (
-          <div style={spoilerStyle} onClick={this.handleOpen}>
-            <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
-            <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-          </div>
-        );
+        warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
       } else {
-        children = (
-          <div style={spoilerStyle} onClick={this.handleOpen}>
-            <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
-            <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-          </div>
-        );
+        warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
       }
+
+      children = (
+        <div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
+          <span style={spoilerSpanStyle}>{warning}</span>
+          <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+        </div>
+      );
     } else {
       const size = media.take(4).size;
-
-      children = media.take(4).map((attachment, i) => {
-        let width  = 50;
-        let height = 100;
-        let top    = 'auto';
-        let left   = 'auto';
-        let bottom = 'auto';
-        let right  = 'auto';
-
-        if (size === 1) {
-          width = 100;
-        }
-
-        if (size === 4 || (size === 3 && i > 0)) {
-          height = 50;
-        }
-
-        if (size === 2) {
-          if (i === 0) {
-            right = '2px';
-          } else {
-            left = '2px';
-          }
-        } else if (size === 3) {
-          if (i === 0) {
-            right = '2px';
-          } else if (i > 0) {
-            left = '2px';
-          }
-
-          if (i === 1) {
-            bottom = '2px';
-          } else if (i > 1) {
-            top = '2px';
-          }
-        } else if (size === 4) {
-          if (i === 0 || i === 2) {
-            right = '2px';
-          }
-
-          if (i === 1 || i === 3) {
-            left = '2px';
-          }
-
-          if (i < 2) {
-            bottom = '2px';
-          } else {
-            top = '2px';
-          }
-        }
-
-        return (
-          <div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}>
-            <a href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')} onClick={this.handleClick.bind(this, i)} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
-          </div>
-        );
-      });
+      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
     }
 
     return (
       <div style={{ ...outerStyle, height: `${this.props.height}px` }}>
-        <div style={spoilerButtonStyle} >
+        <div style={spoilerButtonStyle}>
           <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} />
         </div>
+
         {children}
       </div>
     );
diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx
index 66c41b5f7..110d26c6d 100644
--- a/app/assets/javascripts/components/components/status.jsx
+++ b/app/assets/javascripts/components/components/status.jsx
@@ -9,7 +9,7 @@ import StatusContent from './status_content';
 import StatusActionBar from './status_action_bar';
 import { FormattedMessage } from 'react-intl';
 import emojify from '../emoji';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
+import escapeTextContentForBrowser from 'escape-html';
 
 const Status = React.createClass({
 
diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx
index f2cc1fb12..4ebb76ea7 100644
--- a/app/assets/javascripts/components/components/status_action_bar.jsx
+++ b/app/assets/javascripts/components/components/status_action_bar.jsx
@@ -6,12 +6,14 @@ import { defineMessages, injectIntl } from 'react-intl';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
-  mention: { id: 'status.mention', defaultMessage: 'Mention' },
-  block: { id: 'account.block', defaultMessage: 'Block' },
+  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
   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 this status' },
+  report: { id: 'status.report', defaultMessage: 'Report @{name}' }
 });
 
 const StatusActionBar = React.createClass({
@@ -27,7 +29,11 @@ const StatusActionBar = React.createClass({
     onReblog: React.PropTypes.func,
     onDelete: React.PropTypes.func,
     onMention: React.PropTypes.func,
-    onBlock: React.PropTypes.func
+    onMute: React.PropTypes.func,
+    onBlock: React.PropTypes.func,
+    onReport: React.PropTypes.func,
+    me: React.PropTypes.number.isRequired,
+    intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -52,6 +58,10 @@ const StatusActionBar = React.createClass({
     this.props.onMention(this.props.status.get('account'), this.context.router);
   },
 
+  handleMuteClick () {
+    this.props.onMute(this.props.status.get('account'));
+  },
+
   handleBlockClick () {
     this.props.onBlock(this.props.status.get('account'));
   },
@@ -60,23 +70,32 @@ 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 = [];
 
     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+    menu.push(null);
 
     if (status.getIn(['account', 'id']) === me) {
       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.block), action: this.handleBlockClick });
+      menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
+      menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
+      menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
     }
 
     return (
       <div style={{ marginTop: '10px', overflow: 'hidden' }}>
         <div style={{ float: 'left', marginRight: '18px'}}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
-        <div style={{ float: 'left', marginRight: '18px'}}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div>
+        <div style={{ float: 'left', marginRight: '18px'}}><IconButton disabled={status.get('visibility') === 'private' || status.get('visibility') === 'direct'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'direct' ? 'envelope' : (status.get('visibility') === 'private' ? 'lock' : 'retweet')} onClick={this.handleReblogClick} /></div>
         <div style={{ float: 'left', marginRight: '18px'}}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
 
         <div style={{ width: '18px', height: '18px', float: 'left' }}>
diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx
index c0397e81c..6c25afdea 100644
--- a/app/assets/javascripts/components/components/status_content.jsx
+++ b/app/assets/javascripts/components/components/status_content.jsx
@@ -1,7 +1,8 @@
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
+import escapeTextContentForBrowser from 'escape-html';
 import emojify from '../emoji';
+import { isRtl } from '../rtl';
 import { FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
 
@@ -92,6 +93,11 @@ const StatusContent = React.createClass({
 
     const content = { __html: emojify(status.get('content')) };
     const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
+    const directionStyle = { direction: 'ltr' };
+
+    if (isRtl(status.get('content'))) {
+      directionStyle.direction = 'rtl';
+    }
 
     if (status.get('spoiler_text').length > 0) {
       let mentionsPlaceholder = '';
@@ -116,14 +122,14 @@ const StatusContent = React.createClass({
 
           {mentionsPlaceholder}
 
-          <div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} />
+          <div style={{ display: hidden ? 'none' : 'block', ...directionStyle }} dangerouslySetInnerHTML={content} />
         </div>
       );
     } else {
       return (
         <div
           className='status__content'
-          style={{ cursor: 'pointer' }}
+          style={{ cursor: 'pointer', ...directionStyle }}
           onMouseDown={this.handleMouseDown}
           onMouseUp={this.handleMouseUp}
           dangerouslySetInnerHTML={content}
diff --git a/app/assets/javascripts/components/components/status_list.jsx b/app/assets/javascripts/components/components/status_list.jsx
index 0e64f0ee6..345944e4d 100644
--- a/app/assets/javascripts/components/components/status_list.jsx
+++ b/app/assets/javascripts/components/components/status_list.jsx
@@ -14,7 +14,10 @@ const StatusList = React.createClass({
     onScroll: React.PropTypes.func,
     trackScroll: React.PropTypes.bool,
     isLoading: React.PropTypes.bool,
-    prepend: React.PropTypes.node
+    isUnread: React.PropTypes.bool,
+    hasMore: React.PropTypes.bool,
+    prepend: React.PropTypes.node,
+    emptyMessage: React.PropTypes.node
   },
 
   getDefaultProps () {
@@ -71,27 +74,43 @@ const StatusList = React.createClass({
   },
 
   render () {
-    const { statusIds, onScrollToBottom, trackScroll, isLoading, prepend } = this.props;
+    const { statusIds, onScrollToBottom, trackScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
 
-    let loadMore = '';
+    let loadMore       = '';
+    let scrollableArea = '';
+    let unread         = '';
 
-    if (!isLoading && statusIds.size > 0) {
+    if (!isLoading && statusIds.size > 0 && hasMore) {
       loadMore = <LoadMore onClick={this.handleLoadMore} />;
     }
 
-    const scrollableArea = (
-      <div className='scrollable' ref={this.setRef}>
-        <div>
-          {prepend}
+    if (isUnread) {
+      unread = <div className='status-list__unread-indicator' />;
+    }
+
+    if (isLoading || statusIds.size > 0 || !emptyMessage) {
+      scrollableArea = (
+        <div className='scrollable' ref={this.setRef}>
+          {unread}
 
-          {statusIds.map((statusId) => {
-            return <StatusContainer key={statusId} id={statusId} />;
-          })}
+          <div>
+            {prepend}
 
-          {loadMore}
+            {statusIds.map((statusId) => {
+              return <StatusContainer key={statusId} id={statusId} />;
+            })}
+
+            {loadMore}
+          </div>
+        </div>
+      );
+    } else {
+      scrollableArea = (
+        <div className='empty-column-indicator' ref={this.setRef}>
+          {emptyMessage}
         </div>
-      </div>
-    );
+      );
+    }
 
     if (trackScroll) {
       return (
diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx
index 3edc8f672..ab21ca9cd 100644
--- a/app/assets/javascripts/components/components/video_player.jsx
+++ b/app/assets/javascripts/components/components/video_player.jsx
@@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import IconButton from './icon_button';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { isIOS } from '../is_mobile';
 
 const messages = defineMessages({
   toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
@@ -22,14 +23,14 @@ const muteStyle = {
   position: 'absolute',
   top: '10px',
   right: '10px',
+  color: 'white',
+  textShadow: "0px 1px 1px black, 1px 0px 1px black",
   opacity: '0.8',
   zIndex: '5'
 };
 
 const spoilerStyle = {
   marginTop: '8px',
-  background: '#000',
-  color: '#fff',
   textAlign: 'center',
   height: '100%',
   cursor: 'pointer',
@@ -55,6 +56,8 @@ const spoilerButtonStyle = {
   position: 'absolute',
   top: '6px',
   left: '8px',
+  color: 'white',
+  textShadow: "0px 1px 1px black, 1px 0px 1px black",
   zIndex: '100'
 };
 
@@ -63,12 +66,14 @@ const VideoPlayer = React.createClass({
     media: ImmutablePropTypes.map.isRequired,
     width: React.PropTypes.number,
     height: React.PropTypes.number,
-    sensitive: React.PropTypes.bool
+    sensitive: React.PropTypes.bool,
+    intl: React.PropTypes.object.isRequired,
+    autoplay: React.PropTypes.bool
   },
 
   getDefaultProps () {
     return {
-      width: 196,
+      width: 239,
       height: 110
     };
   },
@@ -77,7 +82,8 @@ const VideoPlayer = React.createClass({
     return {
       visible: !this.props.sensitive,
       preview: true,
-      muted: true
+      muted: true,
+      hasAudio: true
     };
   },
 
@@ -110,8 +116,42 @@ const VideoPlayer = React.createClass({
     });
   },
 
+  setRef (c) {
+    this.video = c;
+  },
+
+  handleLoadedData () {
+    if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
+      this.setState({ hasAudio: false });
+    }
+  },
+
+  componentDidMount () {
+    if (!this.video) {
+      return;
+    }
+
+    this.video.addEventListener('loadeddata', this.handleLoadedData);
+  },
+
+  componentDidUpdate () {
+    if (!this.video) {
+      return;
+    }
+
+    this.video.addEventListener('loadeddata', this.handleLoadedData);
+  },
+
+  componentWillUnmount () {
+    if (!this.video) {
+      return;
+    }
+
+    this.video.removeEventListener('loadeddata', this.handleLoadedData);
+  },
+
   render () {
-    const { media, intl, width, height, sensitive } = this.props;
+    const { media, intl, width, height, sensitive, autoplay } = this.props;
 
     let spoilerButton = (
       <div style={spoilerButtonStyle} >
@@ -119,10 +159,20 @@ const VideoPlayer = React.createClass({
       </div>
     );
 
+    let muteButton = '';
+
+    if (this.state.hasAudio) {
+      muteButton = (
+        <div style={muteStyle}>
+          <IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
+        </div>
+      );
+    }
+
     if (!this.state.visible) {
       if (sensitive) {
         return (
-          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleVisibility}>
+          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
             {spoilerButton}
             <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
             <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
@@ -130,7 +180,7 @@ const VideoPlayer = React.createClass({
         );
       } else {
         return (
-          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}>
+          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
             {spoilerButton}
             <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
             <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
@@ -139,7 +189,7 @@ const VideoPlayer = React.createClass({
       }
     }
 
-    if (this.state.preview) {
+    if (this.state.preview && !autoplay) {
       return (
         <div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}>
           {spoilerButton}
@@ -151,8 +201,8 @@ const VideoPlayer = React.createClass({
     return (
       <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
         {spoilerButton}
-        <div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /></div>
-        <video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
+        {muteButton}
+        <video ref={this.setRef} src={media.get('url')} autoPlay={!isIOS()} loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/containers/account_container.jsx b/app/assets/javascripts/components/containers/account_container.jsx
index 889c0ac4c..3c30be715 100644
--- a/app/assets/javascripts/components/containers/account_container.jsx
+++ b/app/assets/javascripts/components/containers/account_container.jsx
@@ -5,7 +5,9 @@ import {
   followAccount,
   unfollowAccount,
   blockAccount,
-  unblockAccount
+  unblockAccount,
+  muteAccount,
+  unmuteAccount,
 } from '../actions/accounts';
 
 const makeMapStateToProps = () => {
@@ -34,6 +36,14 @@ const mapDispatchToProps = (dispatch) => ({
     } else {
       dispatch(blockAccount(account.get('id')));
     }
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(muteAccount(account.get('id')));
+    }
   }
 });
 
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index e23c65121..cbb7b85bc 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -4,7 +4,9 @@ import {
   refreshTimelineSuccess,
   updateTimeline,
   deleteFromTimelines,
-  refreshTimeline
+  refreshTimeline,
+  connectTimeline,
+  disconnectTimeline
 } from '../actions/timelines';
 import { updateNotifications, refreshNotifications } from '../actions/notifications';
 import createBrowserHistory from 'history/lib/createBrowserHistory';
@@ -21,6 +23,7 @@ import UI from '../features/ui';
 import Status from '../features/status';
 import GettingStarted from '../features/getting_started';
 import PublicTimeline from '../features/public_timeline';
+import CommunityTimeline from '../features/community_timeline';
 import AccountTimeline from '../features/account_timeline';
 import HomeTimeline from '../features/home_timeline';
 import Compose from '../features/compose';
@@ -34,6 +37,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';
@@ -42,6 +46,7 @@ import fr from 'react-intl/locale-data/fr';
 import pt from 'react-intl/locale-data/pt';
 import hu from 'react-intl/locale-data/hu';
 import uk from 'react-intl/locale-data/uk';
+import fi from 'react-intl/locale-data/fi';
 import getMessagesForLocale from '../locales';
 import { hydrateStore } from '../actions/store';
 import createStream from '../stream';
@@ -54,7 +59,7 @@ const browserHistory = useRouterHistory(createBrowserHistory)({
   basename: '/web'
 });
 
-addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]);
+addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk, ...fi]);
 
 const Mastodon = React.createClass({
 
@@ -68,6 +73,14 @@ const Mastodon = React.createClass({
 
     this.subscription = createStream(accessToken, 'user', {
 
+      connected () {
+        store.dispatch(connectTimeline('home'));
+      },
+
+      disconnected () {
+        store.dispatch(disconnectTimeline('home'));
+      },
+
       received (data) {
         switch(data.event) {
         case 'update':
@@ -83,6 +96,7 @@ const Mastodon = React.createClass({
       },
 
       reconnected () {
+        store.dispatch(connectTimeline('home'));
         store.dispatch(refreshTimeline('home'));
         store.dispatch(refreshNotifications());
       }
@@ -115,6 +129,7 @@ const Mastodon = React.createClass({
               <Route path='getting-started' component={GettingStarted} />
               <Route path='timelines/home' component={HomeTimeline} />
               <Route path='timelines/public' component={PublicTimeline} />
+              <Route path='timelines/public/local' component={CommunityTimeline} />
               <Route path='timelines/tag/:id' component={HashtagTimeline} />
 
               <Route path='notifications' component={Notifications} />
@@ -131,6 +146,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..fd3fbe4c3 100644
--- a/app/assets/javascripts/components/containers/status_container.jsx
+++ b/app/assets/javascripts/components/containers/status_container.jsx
@@ -11,51 +11,22 @@ import {
   unreblog,
   unfavourite
 } from '../actions/interactions';
-import { blockAccount } from '../actions/accounts';
+import {
+  blockAccount,
+  muteAccount
+} from '../actions/accounts';
 import { deleteStatus } from '../actions/statuses';
-import { openMedia } from '../actions/modal';
+import { initReport } from '../actions/reports';
+import { openModal } from '../actions/modal';
 import { createSelector } from 'reselect'
 import { isMobile } from '../is_mobile'
 
-const mapStateToProps = (state, props) => ({
-  statusBase: state.getIn(['statuses', props.id]),
-  me: state.getIn(['meta', 'me'])
-});
-
-const makeMapStateToPropsInner = () => {
-  const getStatus = (() => {
-    return createSelector(
-      [
-        (_, base)     => base,
-        (state, base) => (base ? state.getIn(['accounts', base.get('account')]) : null),
-        (state, base) => (base ? state.getIn(['statuses', base.get('reblog')], null) : null)
-      ],
-
-      (base, account, reblog) => (base ? base.set('account', account).set('reblog', reblog) : null)
-    );
-  })();
-
-  const mapStateToProps = (state, { statusBase }) => ({
-    status: getStatus(state, statusBase)
-  });
-
-  return mapStateToProps;
-};
-
-const makeMapStateToPropsLast = () => {
-  const getStatus = (() => {
-    return createSelector(
-      [
-        (_, status)     => status,
-        (state, status) => (status ? state.getIn(['accounts', status.getIn(['reblog', 'account'])], null) : null)
-      ],
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
 
-      (status, reblogAccount) => (status && status.get('reblog') ? status.setIn(['reblog', 'account'], reblogAccount) : status)
-    );
-  })();
-
-  const mapStateToProps = (state, { status }) => ({
-    status: getStatus(state, status)
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, props.id),
+    me: state.getIn(['meta', 'me'])
   });
 
   return mapStateToProps;
@@ -92,17 +63,21 @@ const mapDispatchToProps = (dispatch) => ({
   },
 
   onOpenMedia (media, index) {
-    dispatch(openMedia(media, index));
+    dispatch(openModal('MEDIA', { media, index }));
   },
 
   onBlock (account) {
     dispatch(blockAccount(account.get('id')));
-  }
+  },
+
+  onReport (status) {
+    dispatch(initReport(status.get('account'), status));
+  },
+
+  onMute (account) {
+    dispatch(muteAccount(account.get('id')));
+  },
 
 });
 
-export default connect(mapStateToProps, mapDispatchToProps)(
-  connect(makeMapStateToPropsInner)(
-    connect(makeMapStateToPropsLast)(Status)
-  )
-);
+export default connect(makeMapStateToProps, mapDispatchToProps)(Status);
diff --git a/app/assets/javascripts/components/emoji.jsx b/app/assets/javascripts/components/emoji.jsx
index c93c07c74..eee657b86 100644
--- a/app/assets/javascripts/components/emoji.jsx
+++ b/app/assets/javascripts/components/emoji.jsx
@@ -1,9 +1,35 @@
 import emojione from 'emojione';
 
-emojione.imageType    = 'png';
-emojione.sprites      = false;
-emojione.imagePathPNG = '/emoji/';
+const toImage = str => shortnameToImage(unicodeToImage(str));
+
+const unicodeToImage = str => {
+  const mappedUnicode = emojione.mapUnicodeToShort();
+
+  return str.replace(emojione.regUnicode, unicodeChar => {
+    if (typeof unicodeChar === 'undefined' || unicodeChar === '' || !(unicodeChar in emojione.jsEscapeMap)) {
+      return unicodeChar;
+    }
+
+    const unicode  = emojione.jsEscapeMap[unicodeChar];
+    const short    = mappedUnicode[unicode];
+    const filename = emojione.emojioneList[short].fname;
+    const alt      = emojione.convert(unicode.toUpperCase());
+
+    return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${filename}.svg" />`;
+  });
+};
+
+const shortnameToImage = str => str.replace(emojione.regShortNames, shortname => {
+  if (typeof shortname === 'undefined' || shortname === '' || !(shortname in emojione.emojioneList)) {
+    return shortname;
+  }
+
+  const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1];
+  const alt     = emojione.convert(unicode.toUpperCase());
+
+  return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${unicode}.svg" />`;
+});
 
 export default function emojify(text) {
-  return emojione.toImage(text);
+  return toImage(text);
 };
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..80a32d3e2 100644
--- a/app/assets/javascripts/components/features/account/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx
@@ -5,13 +5,16 @@ import { Link } from 'react-router';
 import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
 
 const messages = defineMessages({
-  mention: { id: 'account.mention', defaultMessage: 'Mention' },
+  mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
   edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
-  unblock: { id: 'account.unblock', defaultMessage: 'Unblock' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
-  block: { id: 'account.block', defaultMessage: 'Block' },
+  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  block: { id: 'account.block', defaultMessage: 'Block' }
+  report: { id: 'account.report', defaultMessage: 'Report @{name}' },
+  disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' }
 });
 
 const outerDropdownStyle = {
@@ -32,7 +35,10 @@ 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,
+    onMute: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -41,17 +47,31 @@ const ActionBar = React.createClass({
     const { account, me, intl } = this.props;
 
     let menu = [];
+    let extraInfo = '';
 
-    menu.push({ text: intl.formatMessage(messages.mention), action: this.props.onMention });
+    menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+    menu.push(null);
 
     if (account.get('id') === me) {
       menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
-    } else if (account.getIn(['relationship', 'blocking'])) {
-      menu.push({ text: intl.formatMessage(messages.unblock), action: this.props.onBlock });
-    } else if (account.getIn(['relationship', 'following'])) {
-      menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
     } else {
-      menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
+      if (account.getIn(['relationship', 'muting'])) {
+        menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute });
+      }
+
+      if (account.getIn(['relationship', 'blocking'])) {
+        menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock });
+      }
+
+      menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
+    }
+
+    if (account.get('acct') !== account.get('username')) {
+      extraInfo = <abbr title={intl.formatMessage(messages.disclaimer)}>*</abbr>;
     }
 
     return (
@@ -63,17 +83,17 @@ const ActionBar = React.createClass({
         <div style={outerLinksStyle}>
           <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
             <span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span>
-            <strong><FormattedNumber value={account.get('statuses_count')} /></strong>
+            <strong><FormattedNumber value={account.get('statuses_count')} /> {extraInfo}</strong>
           </Link>
 
           <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
             <span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span>
-            <strong><FormattedNumber value={account.get('following_count')} /></strong>
+            <strong><FormattedNumber value={account.get('following_count')} /> {extraInfo}</strong>
           </Link>
 
           <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
             <span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span>
-            <strong><FormattedNumber value={account.get('followers_count')} /></strong>
+            <strong><FormattedNumber value={account.get('followers_count')} /> {extraInfo}</strong>
           </Link>
         </div>
       </div>
diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx
index b2d943c1c..a359963c4 100644
--- a/app/assets/javascripts/components/features/account/components/header.jsx
+++ b/app/assets/javascripts/components/features/account/components/header.jsx
@@ -1,9 +1,10 @@
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import emojify from '../../../emoji';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
+import escapeTextContentForBrowser from 'escape-html';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import IconButton from '../../../components/icon_button';
+import { Motion, spring } from 'react-motion';
 
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -11,10 +12,51 @@ const messages = defineMessages({
   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
 });
 
+const Avatar = React.createClass({
+
+  propTypes: {
+    account: ImmutablePropTypes.map.isRequired
+  },
+
+  getInitialState () {
+    return {
+      isHovered: false
+    };
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleMouseOver () {
+    if (this.state.isHovered) return;
+    this.setState({ isHovered: true });
+  },
+
+  handleMouseOut () {
+    if (!this.state.isHovered) return;
+    this.setState({ isHovered: false });
+  },
+
+  render () {
+    const { account }   = this.props;
+    const { isHovered } = this.state;
+
+    return (
+      <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
+        {({ radius }) =>
+          <a href={account.get('url')} className='account__header__avatar' target='_blank' rel='noopener' style={{ display: 'block', width: '90px', height: '90px', margin: '0 auto', marginBottom: '10px', borderRadius: `${radius}px`, overflow: 'hidden' }} onMouseOver={this.handleMouseOver} onMouseOut={this.handleMouseOut}>
+            <img src={account.get('avatar')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} />
+          </a>
+        }
+      </Motion>
+    );
+  }
+
+});
+
 const Header = React.createClass({
 
   propTypes: {
-    account: ImmutablePropTypes.map.isRequired,
+    account: ImmutablePropTypes.map,
     me: React.PropTypes.number.isRequired,
     onFollow: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired
@@ -25,6 +67,10 @@ const Header = React.createClass({
   render () {
     const { account, me, intl } = this.props;
 
+    if (!account) {
+      return null;
+    }
+
     let displayName = account.get('display_name');
     let info        = '';
     let actionBtn   = '';
@@ -35,7 +81,7 @@ const Header = React.createClass({
     }
 
     if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
-      info = <span style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', color: '#fff', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>
+      info = <span className='account--follows-info' style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>
     }
 
     if (me !== account.get('id')) {
@@ -64,14 +110,9 @@ const Header = React.createClass({
     return (
       <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
         <div style={{ padding: '20px 10px' }}>
-          <a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
-            <div className='account__header__avatar' style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}>
-              <img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
-            </div>
-
-            <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
-          </a>
+          <Avatar account={account} />
 
+          <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
           <span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
           <div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
 
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..99a10562e 100644
--- a/app/assets/javascripts/components/features/account_timeline/components/header.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/components/header.jsx
@@ -2,6 +2,7 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import InnerHeader from '../../account/components/header';
 import ActionBar from '../../account/components/action_bar';
+import MissingIndicator from '../../../components/missing_indicator';
 
 const Header = React.createClass({
   contextTypes: {
@@ -9,11 +10,13 @@ const Header = React.createClass({
   },
 
   propTypes: {
-    account: ImmutablePropTypes.map.isRequired,
+    account: ImmutablePropTypes.map,
     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,
+    onMute: React.PropTypes.func.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -30,11 +33,20 @@ 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');
+  },
+
+  handleMute() {
+    this.props.onMute(this.props.account);
+  },
+
   render () {
     const { account, me } = this.props;
 
-    if (!account) {
-      return null;
+    if (account === null) {
+      return <MissingIndicator />;
     }
 
     return (
@@ -50,6 +62,8 @@ const Header = React.createClass({
           me={me}
           onBlock={this.handleBlock}
           onMention={this.handleMention}
+          onReport={this.handleReport}
+          onMute={this.handleMute}
         />
       </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..8472d25a5 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
@@ -5,9 +5,12 @@ import {
   followAccount,
   unfollowAccount,
   blockAccount,
-  unblockAccount
+  unblockAccount,
+  muteAccount,
+  unmuteAccount
 } from '../../../actions/accounts';
 import { mentionCompose } from '../../../actions/compose';
+import { initReport } from '../../../actions/reports';
 
 const makeMapStateToProps = () => {
   const getAccount = makeGetAccount();
@@ -39,6 +42,18 @@ const mapDispatchToProps = dispatch => ({
 
   onMention (account, router) {
     dispatch(mentionCompose(account, router));
+  },
+
+  onReport (account) {
+    dispatch(initReport(account));
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(muteAccount(account.get('id')));
+    }
   }
 });
 
diff --git a/app/assets/javascripts/components/features/account_timeline/index.jsx b/app/assets/javascripts/components/features/account_timeline/index.jsx
index 349510295..f92e1b49c 100644
--- a/app/assets/javascripts/components/features/account_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/index.jsx
@@ -16,6 +16,7 @@ import Immutable from 'immutable';
 const mapStateToProps = (state, props) => ({
   statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items'], Immutable.List()),
   isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']),
+  hasMore: !!state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'next']),
   me: state.getIn(['meta', 'me'])
 });
 
@@ -26,6 +27,7 @@ const AccountTimeline = React.createClass({
     dispatch: React.PropTypes.func.isRequired,
     statusIds: ImmutablePropTypes.list,
     isLoading: React.PropTypes.bool,
+    hasMore: React.PropTypes.bool,
     me: React.PropTypes.number.isRequired
   },
 
@@ -48,7 +50,7 @@ const AccountTimeline = React.createClass({
   },
 
   render () {
-    const { statusIds, isLoading, me } = this.props;
+    const { statusIds, isLoading, hasMore, me } = this.props;
 
     if (!statusIds && isLoading) {
       return (
@@ -66,6 +68,7 @@ const AccountTimeline = React.createClass({
           prepend={<HeaderContainer accountId={this.props.params.accountId} />}
           statusIds={statusIds}
           isLoading={isLoading}
+          hasMore={hasMore}
           me={me}
           onScrollToBottom={this.handleScrollToBottom}
         />
diff --git a/app/assets/javascripts/components/features/community_timeline/index.jsx b/app/assets/javascripts/components/features/community_timeline/index.jsx
new file mode 100644
index 000000000..0957338cf
--- /dev/null
+++ b/app/assets/javascripts/components/features/community_timeline/index.jsx
@@ -0,0 +1,95 @@
+import { connect } from 'react-redux';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../ui/components/column';
+import {
+  refreshTimeline,
+  updateTimeline,
+  deleteFromTimelines,
+  connectTimeline,
+  disconnectTimeline
+} from '../../actions/timelines';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import createStream from '../../stream';
+
+const messages = defineMessages({
+  title: { id: 'column.community', defaultMessage: 'Local' }
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
+  accessToken: state.getIn(['meta', 'access_token'])
+});
+
+let subscription;
+
+const CommunityTimeline = React.createClass({
+
+  propTypes: {
+    dispatch: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired,
+    accessToken: React.PropTypes.string.isRequired,
+    hasUnread: React.PropTypes.bool
+  },
+
+  mixins: [PureRenderMixin],
+
+  componentDidMount () {
+    const { dispatch, accessToken } = this.props;
+
+    dispatch(refreshTimeline('community'));
+
+    if (typeof subscription !== 'undefined') {
+      return;
+    }
+
+    subscription = createStream(accessToken, 'public:local', {
+
+      connected () {
+        dispatch(connectTimeline('community'));
+      },
+
+      reconnected () {
+        dispatch(connectTimeline('community'));
+      },
+
+      disconnected () {
+        dispatch(disconnectTimeline('community'));
+      },
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline('community', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
+        }
+      }
+
+    });
+  },
+
+  componentWillUnmount () {
+    // if (typeof subscription !== 'undefined') {
+    //   subscription.close();
+    //   subscription = null;
+    // }
+  },
+
+  render () {
+    const { intl, hasUnread } = this.props;
+
+    return (
+      <Column icon='users' active={hasUnread} heading={intl.formatMessage(messages.title)}>
+        <ColumnBackButtonSlim />
+        <StatusListContainer type='community' emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} />
+      </Column>
+    );
+  },
+
+});
+
+export default connect(mapStateToProps)(injectIntl(CommunityTimeline));
diff --git a/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx b/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx
index 9ea7f190f..5591b45cf 100644
--- a/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx
+++ b/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx
@@ -1,11 +1,16 @@
 import Avatar from '../../../components/avatar';
 import DisplayName from '../../../components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
 
 const AutosuggestAccount = ({ account }) => (
-  <div style={{ overflow: 'hidden' }}>
+  <div style={{ overflow: 'hidden' }} className='autosuggest-account'>
     <div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} size={18} /></div>
     <DisplayName account={account} />
   </div>
 );
 
+AutosuggestAccount.propTypes = {
+  account: ImmutablePropTypes.map.isRequired
+};
+
 export default AutosuggestAccount;
diff --git a/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx b/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx
new file mode 100644
index 000000000..086488649
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx
@@ -0,0 +1,15 @@
+import { FormattedMessage } from 'react-intl';
+import DisplayName from '../../../components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const AutosuggestStatus = ({ status }) => (
+  <div style={{ overflow: 'hidden' }} className='autosuggest-status'>
+    <FormattedMessage id='search.status_by' defaultMessage='Status by {name}' values={{ name: <strong>@{status.getIn(['account', 'acct'])}</strong> }} />
+  </div>
+);
+
+AutosuggestStatus.propTypes = {
+  status: ImmutablePropTypes.map.isRequired
+};
+
+export default AutosuggestStatus;
diff --git a/app/assets/javascripts/components/features/compose/components/character_counter.jsx b/app/assets/javascripts/components/features/compose/components/character_counter.jsx
index f0c1b7c8d..e6b675354 100644
--- a/app/assets/javascripts/components/features/compose/components/character_counter.jsx
+++ b/app/assets/javascripts/components/features/compose/components/character_counter.jsx
@@ -10,7 +10,7 @@ const CharacterCounter = React.createClass({
   mixins: [PureRenderMixin],
 
   render () {
-    const diff = this.props.max - this.props.text.length;
+    const diff = this.props.max - this.props.text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "_").length;
 
     return (
       <span style={{ fontSize: '16px', cursor: 'default' }}>
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..b016d3f28 100644
--- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -2,15 +2,19 @@ import CharacterCounter from './character_counter';
 import Button from '../../../components/button';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import ReplyIndicator from './reply_indicator';
-import UploadButton from './upload_button';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
 import AutosuggestTextarea from '../../../components/autosuggest_textarea';
-import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container';
 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';
+import SpoilerButtonContainer from '../containers/spoiler_button_container';
+import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
+import SensitiveButtonContainer from '../containers/sensitive_button_container';
+import EmojiPickerDropdown from './emoji_picker_dropdown';
+import UploadFormContainer from '../containers/upload_form_container';
+import TextIconButton from './text_icon_button';
 
 const messages = defineMessages({
   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@@ -25,28 +29,24 @@ const ComposeForm = React.createClass({
     text: React.PropTypes.string.isRequired,
     suggestion_token: React.PropTypes.string,
     suggestions: ImmutablePropTypes.list,
-    sensitive: React.PropTypes.bool,
     spoiler: React.PropTypes.bool,
+    privacy: React.PropTypes.string,
     spoiler_text: React.PropTypes.string,
-    unlisted: React.PropTypes.bool,
-    private: React.PropTypes.bool,
-    fileDropDate: React.PropTypes.instanceOf(Date),
+    focusDate: React.PropTypes.instanceOf(Date),
+    preselectDate: React.PropTypes.instanceOf(Date),
     is_submitting: React.PropTypes.bool,
     is_uploading: React.PropTypes.bool,
-    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,
     onClearSuggestions: React.PropTypes.func.isRequired,
     onFetchSuggestions: React.PropTypes.func.isRequired,
     onSuggestionSelected: React.PropTypes.func.isRequired,
-    onChangeSensitivity: React.PropTypes.func.isRequired,
-    onChangeSpoilerness: React.PropTypes.func.isRequired,
     onChangeSpoilerText: React.PropTypes.func.isRequired,
-    onChangeVisibility: React.PropTypes.func.isRequired,
-    onChangeListability: React.PropTypes.func.isRequired,
+    onPaste: React.PropTypes.func.isRequired,
+    onPickEmoji: React.PropTypes.func.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -75,37 +75,31 @@ const ComposeForm = React.createClass({
   },
 
   onSuggestionSelected (tokenStart, token, value) {
+    this._restoreCaret = null;
     this.props.onSuggestionSelected(tokenStart, token, value);
   },
 
-  handleChangeSensitivity (e) {
-    this.props.onChangeSensitivity(e.target.checked);
-  },
-
-  handleChangeSpoilerness (e) {
-    this.props.onChangeSpoilerness(e.target.checked);
-    this.props.onChangeSpoilerText('');
-  },
-
   handleChangeSpoilerText (e) {
     this.props.onChangeSpoilerText(e.target.value);
   },
 
-  handleChangeVisibility (e) {
-    this.props.onChangeVisibility(e.target.checked);
-  },
-
-  handleChangeListability (e) {
-    this.props.onChangeListability(e.target.checked);
-  },
-
   componentDidUpdate (prevProps) {
-    if ((prevProps.in_reply_to === null && this.props.in_reply_to !== null) || (prevProps.in_reply_to !== null && this.props.in_reply_to !== null && prevProps.in_reply_to.get('id') !== this.props.in_reply_to.get('id'))) {
+    if (this.props.focusDate !== prevProps.focusDate) {
       // If replying to zero or one users, places the cursor at the end of the textbox.
       // If replying to more than one user, selects any usernames past the first;
       // this provides a convenient shortcut to drop everyone else from the conversation.
-      const selectionStart = this.props.text.search(/\s/) + 1;
-      const selectionEnd   = this.props.text.length;
+      let selectionEnd, selectionStart;
+
+      if (this.props.preselectDate !== prevProps.preselectDate) {
+        selectionEnd   = this.props.text.length;
+        selectionStart = this.props.text.search(/\s/) + 1;
+      } else if (typeof this._restoreCaret === 'number') {
+        selectionStart = this._restoreCaret;
+        selectionEnd   = this._restoreCaret;
+      } else {
+        selectionEnd   = this.props.text.length;
+        selectionStart = selectionEnd;
+      }
 
       this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
       this.autosuggestTextarea.textarea.focus();
@@ -116,83 +110,85 @@ const ComposeForm = React.createClass({
     this.autosuggestTextarea = c;
   },
 
-  render () {
-    const { intl }  = this.props;
-    let replyArea   = '';
-    let publishText = '';
-    const disabled  = this.props.is_submitting || this.props.is_uploading;
+  handleEmojiPick (data) {
+    const position     = this.autosuggestTextarea.textarea.selectionStart;
+    this._restoreCaret = position + data.shortname.length + 1;
+    this.props.onPickEmoji(position, data);
+  },
 
-    if (this.props.in_reply_to) {
-      replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
+  render () {
+    const { intl, needsPrivacyWarning, mentionedDomains, onPaste } = this.props;
+    const disabled = this.props.is_submitting || this.props.is_uploading;
+
+    let publishText    = '';
+    let privacyWarning = '';
+    let reply_to_other = false;
+
+    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>
+      );
     }
 
-    let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me);
-
-    if (this.props.private) {
+    if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
       publishText = <span><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
     } else {
-      publishText = intl.formatMessage(messages.publish) + (!this.props.unlisted ? '!' : '');
+      publishText = intl.formatMessage(messages.publish) + (this.props.privacy !== 'unlisted' ? '!' : '');
     }
 
     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>
-
-        {replyArea}
-
-        <AutosuggestTextarea
-          ref={this.setAutosuggestTextarea}
-          placeholder={intl.formatMessage(messages.placeholder)}
-          disabled={disabled}
-          fileDropDate={this.props.fileDropDate}
-          value={this.props.text}
-          onChange={this.handleChange}
-          suggestions={this.props.suggestions}
-          onKeyDown={this.handleKeyDown}
-          onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
-          onSuggestionsClearRequested={this.onSuggestionsClearRequested}
-          onSuggestionSelected={this.onSuggestionSelected}
-        />
-
-        <div style={{ marginTop: '10px', overflow: 'hidden' }}>
-          <div style={{ float: 'right' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div>
-          <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div>
-          <UploadButtonContainer style={{ paddingTop: '4px' }} />
+        <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} onKeyDown={this.handleKeyDown} type="text" className="spoiler-input__input" />
+          </div>
+        </Collapsable>
+
+        {privacyWarning}
+
+        <ReplyIndicatorContainer />
+
+        <div style={{ position: 'relative' }}>
+          <AutosuggestTextarea
+            ref={this.setAutosuggestTextarea}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            disabled={disabled}
+            value={this.props.text}
+            onChange={this.handleChange}
+            suggestions={this.props.suggestions}
+            onKeyDown={this.handleKeyDown}
+            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+            onSuggestionSelected={this.onSuggestionSelected}
+            onPaste={onPaste}
+          />
+
+          <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
+        </div>
+
+        <div className='compose-form__modifiers'>
+          <UploadFormContainer />
         </div>
 
-        <label className='compose-form__label with-border' style={{ marginTop: '10px' }}>
-          <Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} />
-          <span className='compose-form__label__text'><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span>
-        </label>
-
-        <label className='compose-form__label with-border'>
-          <Toggle checked={this.props.private} onChange={this.handleChangeVisibility} />
-          <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>
+        <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+          <div className='compose-form__buttons'>
+            <UploadButtonContainer />
+            <PrivacyDropdownContainer />
+            <SensitiveButtonContainer />
+            <SpoilerButtonContainer />
+          </div>
+
+          <div style={{ display: 'flex' }}>
+            <div style={{ paddingTop: '10px', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div>
+            <div style={{ paddingTop: '10px' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div>
+          </div>
+        </div>
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/compose/components/drawer.jsx b/app/assets/javascripts/components/features/compose/components/drawer.jsx
deleted file mode 100644
index 83f3fa27d..000000000
--- a/app/assets/javascripts/components/features/compose/components/drawer.jsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { Link } from 'react-router';
-import { injectIntl, defineMessages } from 'react-intl';
-
-const messages = defineMessages({
-  start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
-  public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
-  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
-  logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
-});
-
-const Drawer = ({ children, withHeader, intl }) => {
-  let header = '';
-
-  if (withHeader) {
-    header = (
-      <div className='drawer__header'>
-        <Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
-        <Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
-        <a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
-        <a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
-      </div>
-    );
-  }
-
-  return (
-    <div className='drawer'>
-      {header}
-
-      <div className='drawer__inner'>
-        {children}
-      </div>
-    </div>
-  );
-};
-
-Drawer.propTypes = {
-  withHeader: React.PropTypes.bool,
-  children: React.PropTypes.node,
-  intl: React.PropTypes.object
-};
-
-export default injectIntl(Drawer);
diff --git a/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx
new file mode 100644
index 000000000..1920b29bf
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx
@@ -0,0 +1,58 @@
+import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
+import EmojiPicker from 'emojione-picker';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }
+});
+
+const settings = {
+  imageType: 'png',
+  sprites: false,
+  imagePathPNG: '/emoji/'
+};
+
+const style = {
+  position: 'absolute',
+  right: '5px',
+  top: '5px'
+};
+
+const EmojiPickerDropdown = React.createClass({
+
+  propTypes: {
+    intl: React.PropTypes.object.isRequired,
+    onPickEmoji: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  setRef (c) {
+    this.dropdown = c;
+  },
+
+  handleChange (data) {
+    this.dropdown.hide();
+    this.props.onPickEmoji(data);
+  },
+
+  render () {
+    const { intl } = this.props;
+
+    return (
+      <Dropdown ref={this.setRef} style={style}>
+        <DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)} style={{ fontSize: `24px`, width: `24px`, lineHeight: `24px`, display: 'block', marginLeft: '2px' }}>
+          <img draggable="false" className="emojione" alt="🙂" src="/emoji/1f602.svg" />
+        </DropdownTrigger>
+
+        <DropdownContent className='dropdown__left'>
+          <EmojiPicker emojione={settings} onChange={this.handleChange} />
+        </DropdownContent>
+      </Dropdown>
+    );
+  }
+
+});
+
+export default injectIntl(EmojiPickerDropdown);
diff --git a/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx
new file mode 100644
index 000000000..e54fa4d28
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx
@@ -0,0 +1,101 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { injectIntl, defineMessages } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+
+const messages = defineMessages({
+  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+  public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
+  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
+  private_short: { id: 'privacy.private.short', defaultMessage: 'Private' },
+  private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
+  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
+  change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }
+});
+
+const iconStyle = {
+  lineHeight: '27px',
+  height: null
+};
+
+const PrivacyDropdown = React.createClass({
+
+  propTypes: {
+    value: React.PropTypes.string.isRequired,
+    onChange: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
+  },
+
+  getInitialState () {
+    return {
+      open: false
+    };
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleToggle () {
+    this.setState({ open: !this.state.open });
+  },
+
+  handleClick (value, e) {
+    e.preventDefault();
+    this.setState({ open: false });
+    this.props.onChange(value);
+  },
+
+  onGlobalClick (e) {
+    if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
+      this.setState({ open: false });
+    }
+  },
+
+  componentDidMount () {
+    window.addEventListener('click', this.onGlobalClick);
+    window.addEventListener('touchstart', this.onGlobalClick);
+  },
+
+  componentWillUnmount () {
+    window.removeEventListener('click', this.onGlobalClick);
+    window.removeEventListener('touchstart', this.onGlobalClick);
+  },
+
+  setRef (c) {
+    this.node = c;
+  },
+
+  render () {
+    const { value, onChange, intl } = this.props;
+    const { open } = this.state;
+
+    const options = [
+      { icon: 'globe', value: 'public', shortText: intl.formatMessage(messages.public_short), longText: intl.formatMessage(messages.public_long) },
+      { icon: 'unlock-alt', value: 'unlisted', shortText: intl.formatMessage(messages.unlisted_short), longText: intl.formatMessage(messages.unlisted_long) },
+      { icon: 'lock', value: 'private', shortText: intl.formatMessage(messages.private_short), longText: intl.formatMessage(messages.private_long) },
+      { icon: 'envelope', value: 'direct', shortText: intl.formatMessage(messages.direct_short), longText: intl.formatMessage(messages.direct_long) }
+    ];
+
+    const valueOption = options.find(item => item.value === value);
+
+    return (
+      <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}>
+        <div className='privacy-dropdown__value'><IconButton icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div>
+        <div className='privacy-dropdown__dropdown'>
+          {options.map(item =>
+            <div key={item.value} onClick={this.handleClick.bind(this, item.value)} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}>
+              <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div>
+              <div className='privacy-dropdown__option__content'>
+                <strong>{item.shortText}</strong>
+                {item.longText}
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+    );
+  }
+
+});
+
+export default injectIntl(PrivacyDropdown);
diff --git a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx
index 73e5ee99e..a72bd32c2 100644
--- a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx
+++ b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx
@@ -17,7 +17,7 @@ const ReplyIndicator = React.createClass({
   },
 
   propTypes: {
-    status: ImmutablePropTypes.map.isRequired,
+    status: ImmutablePropTypes.map,
     onCancel: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired
   },
@@ -36,17 +36,22 @@ const ReplyIndicator = React.createClass({
   },
 
   render () {
-    const { intl } = this.props;
-    const content  = { __html: emojify(this.props.status.get('content')) };
+    const { status, intl } = this.props;
+
+    if (!status) {
+      return null;
+    }
+
+    const content  = { __html: emojify(status.get('content')) };
 
     return (
       <div className='reply-indicator'>
         <div style={{ overflow: 'hidden', marginBottom: '5px' }}>
           <div style={{ float: 'right', lineHeight: '24px' }}><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
 
-          <a href={this.props.status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}>
-            <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={this.props.status.getIn(['account', 'avatar'])} /></div>
-            <DisplayName account={this.props.status.get('account')} />
+          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}>
+            <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={status.getIn(['account', 'avatar'])} /></div>
+            <DisplayName account={status.get('account')} />
           </a>
         </div>
 
diff --git a/app/assets/javascripts/components/features/compose/components/search.jsx b/app/assets/javascripts/components/features/compose/components/search.jsx
index c1f23939d..936e003f2 100644
--- a/app/assets/javascripts/components/features/compose/components/search.jsx
+++ b/app/assets/javascripts/components/features/compose/components/search.jsx
@@ -1,118 +1,68 @@
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import Autosuggest from 'react-autosuggest';
-import AutosuggestAccountContainer from '../containers/autosuggest_account_container';
-import { debounce } from 'react-decoration';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 
 const messages = defineMessages({
   placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }
 });
 
-const getSuggestionValue = suggestion => suggestion.value;
-
-const renderSuggestion = suggestion => {
-  if (suggestion.type === 'account') {
-    return <AutosuggestAccountContainer id={suggestion.id} />;
-  } else {
-    return <span>#{suggestion.id}</span>
-  }
-};
-
-const renderSectionTitle = section => (
-  <strong><FormattedMessage id={`search.${section.title}`} defaultMessage={section.title} /></strong>
-);
-
-const getSectionSuggestions = section => section.items;
-
-const outerStyle = {
-  padding: '10px',
-  lineHeight: '20px',
-  position: 'relative'
-};
-
-const iconStyle = {
-  position: 'absolute',
-  top: '18px',
-  right: '20px',
-  fontSize: '18px',
-  pointerEvents: 'none'
-};
-
 const Search = React.createClass({
 
-  contextTypes: {
-    router: React.PropTypes.object
-  },
-
   propTypes: {
-    suggestions: React.PropTypes.array.isRequired,
     value: React.PropTypes.string.isRequired,
+    submitted: React.PropTypes.bool,
     onChange: React.PropTypes.func.isRequired,
+    onSubmit: React.PropTypes.func.isRequired,
     onClear: React.PropTypes.func.isRequired,
-    onFetch: React.PropTypes.func.isRequired,
-    onReset: React.PropTypes.func.isRequired,
+    onShow: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
 
-  onChange (_, { newValue }) {
-    if (typeof newValue !== 'string') {
-      return;
-    }
-
-    this.props.onChange(newValue);
+  handleChange (e) {
+    this.props.onChange(e.target.value);
   },
 
-  onSuggestionsClearRequested () {
+  handleClear (e) {
+    e.preventDefault();
     this.props.onClear();
   },
 
-  @debounce(500)
-  onSuggestionsFetchRequested ({ value }) {
-    value = value.replace('#', '');
-    this.props.onFetch(value.trim());
+  handleKeyDown (e) {
+    if (e.key === 'Enter') {
+      e.preventDefault();
+      this.props.onSubmit();
+    }
   },
 
-  onSuggestionSelected (_, { suggestion }) {
-    if (suggestion.type === 'account') {
-      this.context.router.push(`/accounts/${suggestion.id}`);
-    } else {
-      this.context.router.push(`/timelines/tag/${suggestion.id}`);
-    }
+  handleFocus () {
+    this.props.onShow();
   },
 
   render () {
-    const inputProps = {
-      placeholder: this.props.intl.formatMessage(messages.placeholder),
-      value: this.props.value,
-      onChange: this.onChange,
-      className: 'search__input'
-    };
+    const { intl, value, submitted } = this.props;
+    const hasValue = value.length > 0 || submitted;
 
     return (
-      <div className='search' style={outerStyle}>
-        <Autosuggest
-          multiSection={true}
-          suggestions={this.props.suggestions}
-          focusFirstSuggestion={true}
-          focusInputOnSuggestionClick={false}
-          alwaysRenderSuggestions={false}
-          onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
-          onSuggestionsClearRequested={this.onSuggestionsClearRequested}
-          onSuggestionSelected={this.onSuggestionSelected}
-          getSuggestionValue={getSuggestionValue}
-          renderSuggestion={renderSuggestion}
-          renderSectionTitle={renderSectionTitle}
-          getSectionSuggestions={getSectionSuggestions}
-          inputProps={inputProps}
+      <div className='search'>
+        <input
+          className='search__input'
+          type='text'
+          placeholder={intl.formatMessage(messages.placeholder)}
+          value={value}
+          onChange={this.handleChange}
+          onKeyUp={this.handleKeyDown}
+          onFocus={this.handleFocus}
         />
 
-        <div style={iconStyle}><i className='fa fa-search' /></div>
+        <div className='search__icon'>
+          <i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
+          <i className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} onClick={this.handleClear} />
+        </div>
       </div>
     );
-  },
+  }
 
 });
 
diff --git a/app/assets/javascripts/components/features/compose/components/search_results.jsx b/app/assets/javascripts/components/features/compose/components/search_results.jsx
new file mode 100644
index 000000000..fd05e7f7e
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/search_results.jsx
@@ -0,0 +1,68 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import AccountContainer from '../../../containers/account_container';
+import StatusContainer from '../../../containers/status_container';
+import { Link } from 'react-router';
+
+const SearchResults = React.createClass({
+
+  propTypes: {
+    results: ImmutablePropTypes.map.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { results } = this.props;
+
+    let accounts, statuses, hashtags;
+    let count = 0;
+
+    if (results.get('accounts') && results.get('accounts').size > 0) {
+      count   += results.get('accounts').size;
+      accounts = (
+        <div className='search-results__section'>
+          {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
+        </div>
+      );
+    }
+
+    if (results.get('statuses') && results.get('statuses').size > 0) {
+      count   += results.get('statuses').size;
+      statuses = (
+        <div className='search-results__section'>
+          {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
+        </div>
+      );
+    }
+
+    if (results.get('hashtags') && results.get('hashtags').size > 0) {
+      count += results.get('hashtags').size;
+      hashtags = (
+        <div className='search-results__section'>
+          {results.get('hashtags').map(hashtag =>
+            <Link className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
+              #{hashtag}
+            </Link>
+          )}
+        </div>
+      );
+    }
+
+    return (
+      <div className='search-results'>
+        <div className='search-results__header'>
+          <FormattedMessage id='search_results.total' defaultMessage='{count} {count, plural, one {result} other {results}}' values={{ count }} />
+        </div>
+
+        {accounts}
+        {statuses}
+        {hashtags}
+      </div>
+    );
+  }
+
+});
+
+export default SearchResults;
diff --git a/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx b/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx
new file mode 100644
index 000000000..e3ac63d87
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx
@@ -0,0 +1,31 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const TextIconButton = React.createClass({
+
+  propTypes: {
+    label: React.PropTypes.string.isRequired,
+    title: React.PropTypes.string,
+    active: React.PropTypes.bool,
+    onClick: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleClick (e) {
+    e.preventDefault();
+    this.props.onClick();
+  },
+
+  render () {
+    const { label, title, active } = this.props;
+
+    return (
+      <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} onClick={this.handleClick}>
+        {label}
+      </button>
+    );
+  }
+
+});
+
+export default TextIconButton;
diff --git a/app/assets/javascripts/components/features/compose/components/upload_button.jsx b/app/assets/javascripts/components/features/compose/components/upload_button.jsx
index 4c8181aa1..2ba0e8fd2 100644
--- a/app/assets/javascripts/components/features/compose/components/upload_button.jsx
+++ b/app/assets/javascripts/components/features/compose/components/upload_button.jsx
@@ -6,6 +6,11 @@ const messages = defineMessages({
   upload: { id: 'upload_button.label', defaultMessage: 'Add media' }
 });
 
+const iconStyle = {
+  lineHeight: '27px',
+  height: null
+};
+
 const UploadButton = React.createClass({
 
   propTypes: {
@@ -37,7 +42,7 @@ const UploadButton = React.createClass({
 
     return (
       <div style={this.props.style}>
-        <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} />
+        <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} style={iconStyle} size={18} inverted />
         <input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} />
       </div>
     );
diff --git a/app/assets/javascripts/components/features/compose/components/upload_form.jsx b/app/assets/javascripts/components/features/compose/components/upload_form.jsx
index 94c94b4b7..77590d90d 100644
--- a/app/assets/javascripts/components/features/compose/components/upload_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/upload_form.jsx
@@ -2,6 +2,8 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import IconButton from '../../../components/icon_button';
 import { defineMessages, injectIntl } from 'react-intl';
+import UploadProgressContainer from '../containers/upload_progress_container';
+import { Motion, spring } from 'react-motion';
 
 const messages = defineMessages({
   undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }
@@ -11,7 +13,6 @@ const UploadForm = React.createClass({
 
   propTypes: {
     media: ImmutablePropTypes.list.isRequired,
-    is_uploading: React.PropTypes.bool,
     onRemoveFile: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired
   },
@@ -21,21 +22,22 @@ const UploadForm = React.createClass({
   render () {
     const { intl, media } = this.props;
 
-    if (!media.size) {
-      return null;
-    }
-
-    const uploads = media.map(attachment => (
-      <div key={attachment.get('id')} style={{ borderRadius: '4px', marginBottom: '10px' }} className='transparent-background'>
-        <div style={{ width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}>
-          <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} />
-        </div>
+    const uploads = media.map(attachment =>
+      <div key={attachment.get('id')} style={{ margin: '5px', flex: '1 1 0' }}>
+        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
+          {({ scale }) =>
+            <div style={{ transform: `translateZ(0) scale(${scale})`, width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}>
+              <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} />
+            </div>
+          }
+        </Motion>
       </div>
-    ));
+    );
 
     return (
-      <div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden', flexShrink: '0' }}>
-        {uploads}
+      <div style={{ overflow: 'hidden' }}>
+        <UploadProgressContainer />
+        <div style={{ display: 'flex', padding: '5px' }}>{uploads}</div>
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/compose/components/upload_progress.jsx b/app/assets/javascripts/components/features/compose/components/upload_progress.jsx
new file mode 100644
index 000000000..86ffbf936
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/upload_progress.jsx
@@ -0,0 +1,44 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { Motion, spring } from 'react-motion';
+import { FormattedMessage } from 'react-intl';
+
+const UploadProgress = React.createClass({
+
+  propTypes: {
+    active: React.PropTypes.bool,
+    progress: React.PropTypes.number
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { active, progress } = this.props;
+
+    if (!active) {
+      return null;
+    }
+
+    return (
+      <div className='upload-progress'>
+        <div>
+          <i className='fa fa-upload' />
+        </div>
+
+        <div style={{ flex: '1 1 auto' }}>
+          <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
+
+          <div className='upload-progress__backdrop'>
+            <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
+              {({ width }) =>
+                <div className='upload-progress__tracker' style={{ width: `${width}%` }} />
+              }
+            </Motion>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+});
+
+export default UploadProgress;
diff --git a/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx b/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx
new file mode 100644
index 000000000..ef46eb09c
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import AutosuggestStatus from '../components/autosuggest_status';
+import { makeGetStatus } from '../../../selectors';
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, { id }) => ({
+    status: getStatus(state, id)
+  });
+
+  return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(AutosuggestStatus);
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..604e1182f 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
@@ -1,91 +1,78 @@
 import { connect } from 'react-redux';
 import ComposeForm from '../components/compose_form';
+import { uploadCompose } from '../../../actions/compose';
+import { createSelector } from 'reselect';
 import {
   changeCompose,
   submitCompose,
-  cancelReplyCompose,
   clearComposeSuggestions,
   fetchComposeSuggestions,
   selectComposeSuggestion,
-  changeComposeSensitivity,
-  changeComposeSpoilerness,
   changeComposeSpoilerText,
-  changeComposeVisibility,
-  changeComposeListability
+  insertEmojiCompose
 } from '../../../actions/compose';
-import { makeGetStatus } from '../../../selectors';
 
-const makeMapStateToProps = () => {
-  const getStatus = makeGetStatus();
+const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig));
 
-  const mapStateToProps = function (state, props) {
-    return {
-      text: state.getIn(['compose', 'text']),
-      suggestion_token: state.getIn(['compose', 'suggestion_token']),
-      suggestions: state.getIn(['compose', 'suggestions']),
-      sensitive: state.getIn(['compose', 'sensitive']),
-      spoiler: state.getIn(['compose', 'spoiler']),
-      spoiler_text: state.getIn(['compose', 'spoiler_text']),
-      unlisted: state.getIn(['compose', 'unlisted'], ),
-      private: state.getIn(['compose', 'private']),
-      fileDropDate: state.getIn(['compose', 'fileDropDate']),
-      is_submitting: state.getIn(['compose', 'is_submitting']),
-      is_uploading: state.getIn(['compose', 'is_uploading']),
-      in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
-      media_count: state.getIn(['compose', 'media_attachments']).size,
-      me: state.getIn(['compose', 'me']),
-    };
-  };
+const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => {
+  return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : [];
+});
 
-  return mapStateToProps;
-};
+const mapStateToProps = (state, props) => {
+  const mentionedUsernames = getMentionedUsernames(state);
+  const mentionedUsernamesWithDomains = getMentionedDomains(state);
 
-const mapDispatchToProps = function (dispatch) {
   return {
-    onChange (text) {
-      dispatch(changeCompose(text));
-    },
+    text: state.getIn(['compose', 'text']),
+    suggestion_token: state.getIn(['compose', 'suggestion_token']),
+    suggestions: state.getIn(['compose', 'suggestions']),
+    spoiler: state.getIn(['compose', 'spoiler']),
+    spoiler_text: state.getIn(['compose', 'spoiler_text']),
+    privacy: state.getIn(['compose', 'privacy']),
+    focusDate: state.getIn(['compose', 'focusDate']),
+    preselectDate: state.getIn(['compose', 'preselectDate']),
+    is_submitting: state.getIn(['compose', 'is_submitting']),
+    is_uploading: state.getIn(['compose', 'is_uploading']),
+    me: state.getIn(['compose', 'me']),
+    needsPrivacyWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null,
+    mentionedDomains: mentionedUsernamesWithDomains
+  };
+};
 
-    onSubmit () {
-      dispatch(submitCompose());
-    },
+const mapDispatchToProps = (dispatch) => ({
 
-    onCancelReply () {
-      dispatch(cancelReplyCompose());
-    },
+  onChange (text) {
+    dispatch(changeCompose(text));
+  },
 
-    onClearSuggestions () {
-      dispatch(clearComposeSuggestions());
-    },
+  onSubmit () {
+    dispatch(submitCompose());
+  },
 
-    onFetchSuggestions (token) {
-      dispatch(fetchComposeSuggestions(token));
-    },
+  onClearSuggestions () {
+    dispatch(clearComposeSuggestions());
+  },
 
-    onSuggestionSelected (position, token, accountId) {
-      dispatch(selectComposeSuggestion(position, token, accountId));
-    },
+  onFetchSuggestions (token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
 
-    onChangeSensitivity (checked) {
-      dispatch(changeComposeSensitivity(checked));
-    },
+  onSuggestionSelected (position, token, accountId) {
+    dispatch(selectComposeSuggestion(position, token, accountId));
+  },
 
-    onChangeSpoilerness (checked) {
-      dispatch(changeComposeSpoilerness(checked));
-    },
+  onChangeSpoilerText (checked) {
+    dispatch(changeComposeSpoilerText(checked));
+  },
 
-    onChangeSpoilerText (checked) {
-      dispatch(changeComposeSpoilerText(checked));
-    },
+  onPaste (files) {
+    dispatch(uploadCompose(files));
+  },
 
-    onChangeVisibility (checked) {
-      dispatch(changeComposeVisibility(checked));
-    },
+  onPickEmoji (position, data) {
+    dispatch(insertEmojiCompose(position, data));
+  },
 
-    onChangeListability (checked) {
-      dispatch(changeComposeListability(checked));
-    }
-  }
-};
+});
 
-export default connect(makeMapStateToProps, mapDispatchToProps)(ComposeForm);
+export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx b/app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx
new file mode 100644
index 000000000..1eee8f84c
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import PrivacyDropdown from '../components/privacy_dropdown';
+import { changeComposeVisibility } from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['compose', 'privacy'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeComposeVisibility(value));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
diff --git a/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx b/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx
new file mode 100644
index 000000000..39b48f3b6
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { cancelReplyCompose } from '../../../actions/compose';
+import { makeGetStatus } from '../../../selectors';
+import ReplyIndicator from '../components/reply_indicator';
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onCancel () {
+    dispatch(cancelReplyCompose());
+  }
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
diff --git a/app/assets/javascripts/components/features/compose/containers/search_container.jsx b/app/assets/javascripts/components/features/compose/containers/search_container.jsx
index 17a68f2fc..906c0c28c 100644
--- a/app/assets/javascripts/components/features/compose/containers/search_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/search_container.jsx
@@ -1,15 +1,15 @@
 import { connect } from 'react-redux';
 import {
   changeSearch,
-  clearSearchSuggestions,
-  fetchSearchSuggestions,
-  resetSearch
+  clearSearch,
+  submitSearch,
+  showSearch
 } from '../../../actions/search';
 import Search from '../components/search';
 
 const mapStateToProps = state => ({
-  suggestions: state.getIn(['search', 'suggestions']),
-  value: state.getIn(['search', 'value'])
+  value: state.getIn(['search', 'value']),
+  submitted: state.getIn(['search', 'submitted'])
 });
 
 const mapDispatchToProps = dispatch => ({
@@ -19,15 +19,15 @@ const mapDispatchToProps = dispatch => ({
   },
 
   onClear () {
-    dispatch(clearSearchSuggestions());
+    dispatch(clearSearch());
   },
 
-  onFetch (value) {
-    dispatch(fetchSearchSuggestions(value));
+  onSubmit () {
+    dispatch(submitSearch());
   },
 
-  onReset () {
-    dispatch(resetSearch());
+  onShow () {
+    dispatch(showSearch());
   }
 
 });
diff --git a/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx b/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx
new file mode 100644
index 000000000..e5911fd38
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import SearchResults from '../components/search_results';
+
+const mapStateToProps = state => ({
+  results: state.getIn(['search', 'results'])
+});
+
+export default connect(mapStateToProps)(SearchResults);
diff --git a/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx
new file mode 100644
index 000000000..074b568f4
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx
@@ -0,0 +1,49 @@
+import { connect } from 'react-redux';
+import TextIconButton from '../components/text_icon_button';
+import { changeComposeSensitivity } from '../../../actions/compose';
+import { Motion, spring } from 'react-motion';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' }
+});
+
+const mapStateToProps = state => ({
+  visible: state.getIn(['compose', 'media_attachments']).size > 0,
+  active: state.getIn(['compose', 'sensitive'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSensitivity());
+  }
+
+});
+
+const SensitiveButton = React.createClass({
+
+  propTypes: {
+    visible: React.PropTypes.bool,
+    active: React.PropTypes.bool,
+    onClick: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
+  },
+
+  render () {
+    const { visible, active, onClick, intl } = this.props;
+
+    return (
+      <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
+        {({ scale }) =>
+          <div style={{ display: visible ? 'block' : 'none', transform: `translateZ(0) scale(${scale})` }}>
+            <TextIconButton onClick={onClick} label='NSFW' title={intl.formatMessage(messages.title)} active={active} />
+          </div>
+        }
+      </Motion>
+    );
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx
new file mode 100644
index 000000000..61ac32b85
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import TextIconButton from '../components/text_icon_button';
+import { changeComposeSpoilerness } from '../../../actions/compose';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind content warning' }
+});
+
+const mapStateToProps = (state, { intl }) => ({
+  label: 'CW',
+  title: intl.formatMessage(messages.title),
+  active: state.getIn(['compose', 'spoiler'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSpoilerness());
+  }
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));
diff --git a/app/assets/javascripts/components/features/compose/containers/upload_progress_container.jsx b/app/assets/javascripts/components/features/compose/containers/upload_progress_container.jsx
new file mode 100644
index 000000000..b0f1d4d19
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/upload_progress_container.jsx
@@ -0,0 +1,9 @@
+import { connect } from 'react-redux';
+import UploadProgress from '../components/upload_progress';
+
+const mapStateToProps = (state, props) => ({
+  active: state.getIn(['compose', 'is_uploading']),
+  progress: state.getIn(['compose', 'progress'])
+});
+
+export default connect(mapStateToProps)(UploadProgress);
diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx
index f6095c0c6..9421de3ff 100644
--- a/app/assets/javascripts/components/features/compose/index.jsx
+++ b/app/assets/javascripts/components/features/compose/index.jsx
@@ -1,17 +1,34 @@
-import Drawer from './components/drawer';
 import ComposeFormContainer from './containers/compose_form_container';
 import UploadFormContainer from './containers/upload_form_container';
 import NavigationContainer from './containers/navigation_container';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
-import SearchContainer from './containers/search_container';
 import { connect } from 'react-redux';
 import { mountCompose, unmountCompose } from '../../actions/compose';
+import { Link } from 'react-router';
+import { injectIntl, defineMessages } from 'react-intl';
+import SearchContainer from './containers/search_container';
+import { Motion, spring } from 'react-motion';
+import SearchResultsContainer from './containers/search_results_container';
+
+const messages = defineMessages({
+  start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+  public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' },
+  community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
+});
+
+const mapStateToProps = state => ({
+  showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden'])
+});
 
 const Compose = React.createClass({
 
   propTypes: {
     dispatch: React.PropTypes.func.isRequired,
-    withHeader: React.PropTypes.bool
+    withHeader: React.PropTypes.bool,
+    showSearch: React.PropTypes.bool,
+    intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -25,16 +42,46 @@ const Compose = React.createClass({
   },
 
   render () {
+    const { withHeader, showSearch, intl } = this.props;
+
+    let header = '';
+
+    if (withHeader) {
+      header = (
+        <div className='drawer__header'>
+          <Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
+          <Link title={intl.formatMessage(messages.community)} className='drawer__tab' to='/timelines/public/local'><i className='fa fa-fw fa-users' /></Link>
+          <Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
+          <a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
+          <a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
+        </div>
+      );
+    }
+
     return (
-      <Drawer withHeader={this.props.withHeader}>
+      <div className='drawer'>
+        {header}
+
         <SearchContainer />
-        <NavigationContainer />
-        <ComposeFormContainer />
-        <UploadFormContainer />
-      </Drawer>
+
+        <div className='drawer__pager'>
+          <div className='drawer__inner'>
+            <NavigationContainer />
+            <ComposeFormContainer />
+          </div>
+
+          <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
+            {({ x }) =>
+              <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
+                <SearchResultsContainer />
+              </div>
+            }
+          </Motion>
+        </div>
+      </div>
     );
   }
 
 });
 
-export default connect()(Compose);
+export default connect(mapStateToProps)(injectIntl(Compose));
diff --git a/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx b/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx
index 0d41d192f..1766655c2 100644
--- a/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx
+++ b/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx
@@ -16,11 +16,8 @@ const outerStyle = {
 };
 
 const panelStyle = {
-  background: '#2f3441',
   display: 'flex',
   flexDirection: 'row',
-  borderTop: '1px solid #363c4b',
-  borderBottom: '1px solid #363c4b',
   padding: '10px 0'
 };
 
@@ -40,10 +37,10 @@ const AccountAuthorize = ({ intl, account, onAuthorize, onReject }) => {
           <DisplayName account={account} />
         </Permalink>
 
-        <div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
+        <div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
       </div>
 
-      <div style={panelStyle}>
+      <div className='account--panel' style={panelStyle}>
         <div style={btnStyle}><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div>
         <div style={btnStyle}><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div>
       </div>
diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx
index 0e1937b43..d7a78d9cc 100644
--- a/app/assets/javascripts/components/features/getting_started/index.jsx
+++ b/app/assets/javascripts/components/features/getting_started/index.jsx
@@ -7,7 +7,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 
 const messages = defineMessages({
   heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
-  public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
+  public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' },
+  community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
   sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' },
@@ -30,6 +31,7 @@ const GettingStarted = ({ intl, me }) => {
   return (
     <Column icon='asterisk' heading={intl.formatMessage(messages.heading)}>
       <div style={{ position: 'relative' }}>
+        <ColumnLink icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />
         <ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />
         <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
         <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
@@ -39,12 +41,9 @@ const GettingStarted = ({ intl, me }) => {
         <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
       </div>
 
-      <div className='scrollable optionally-scrollable'>
+      <div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}>
         <div className='static-content getting-started'>
-          <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
-          <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
-          <p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p>
-          <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}' values={{ github: <a href="https://github.com/tootsuite/mastodon">tootsuite/mastodon</a> }} /></p>
+          <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p>
         </div>
       </div>
     </Column>
diff --git a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
index 4a0e7684d..7fb413336 100644
--- a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
@@ -8,9 +8,11 @@ import {
   deleteFromTimelines
 } from '../../actions/timelines';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import { FormattedMessage } from 'react-intl';
 import createStream from '../../stream';
 
 const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0,
   accessToken: state.getIn(['meta', 'access_token'])
 });
 
@@ -19,7 +21,8 @@ const HashtagTimeline = React.createClass({
   propTypes: {
     params: React.PropTypes.object.isRequired,
     dispatch: React.PropTypes.func.isRequired,
-    accessToken: React.PropTypes.string.isRequired
+    accessToken: React.PropTypes.string.isRequired,
+    hasUnread: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
@@ -71,12 +74,12 @@ const HashtagTimeline = React.createClass({
   },
 
   render () {
-    const { id } = this.props.params;
+    const { id, hasUnread } = this.props.params;
 
     return (
-      <Column icon='hashtag' heading={id}>
+      <Column icon='hashtag' active={hasUnread} heading={id}>
         <ColumnBackButtonSlim />
-        <StatusListContainer type='tag' id={id} />
+        <StatusListContainer type='tag' id={id} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} />
       </Column>
     );
   },
diff --git a/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx
index 714be309b..92e700874 100644
--- a/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx
+++ b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx
@@ -6,11 +6,10 @@ import SettingToggle from '../../notifications/components/setting_toggle';
 import SettingText from './setting_text';
 
 const messages = defineMessages({
-  filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter by regular expressions' }
+  filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }
 });
 
 const outerStyle = {
-  background: '#373b4a',
   padding: '15px'
 };
 
@@ -18,7 +17,6 @@ const sectionStyle = {
   cursor: 'default',
   display: 'block',
   fontWeight: '500',
-  color: '#9baec8',
   marginBottom: '10px'
 };
 
@@ -42,18 +40,18 @@ const ColumnSettings = React.createClass({
 
     return (
       <ColumnCollapsable icon='sliders' fullHeight={209} onCollapse={onSave}>
-        <div style={outerStyle}>
-          <span style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
+        <div className='column-settings--outer' style={outerStyle}>
+          <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
 
           <div style={rowStyle}>
-            <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reblogs' />} />
+            <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} />
           </div>
 
           <div style={rowStyle}>
             <SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
           </div>
 
-          <span style={sectionStyle}><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
+          <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
 
           <div style={rowStyle}>
             <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
diff --git a/app/assets/javascripts/components/features/home_timeline/index.jsx b/app/assets/javascripts/components/features/home_timeline/index.jsx
index 5d2263f15..a2b775764 100644
--- a/app/assets/javascripts/components/features/home_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/home_timeline/index.jsx
@@ -1,32 +1,39 @@
+import { connect } from 'react-redux';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import StatusListContainer from '../ui/containers/status_list_container';
 import Column from '../ui/components/column';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
+import { Link } from 'react-router';
 
 const messages = defineMessages({
   title: { id: 'column.home', defaultMessage: 'Home' }
 });
 
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0
+});
+
 const HomeTimeline = React.createClass({
 
   propTypes: {
-    intl: React.PropTypes.object.isRequired
+    intl: React.PropTypes.object.isRequired,
+    hasUnread: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
 
   render () {
-    const { intl } = this.props;
+    const { intl, hasUnread } = this.props;
 
     return (
-      <Column icon='home' heading={intl.formatMessage(messages.title)}>
+      <Column icon='home' active={hasUnread} heading={intl.formatMessage(messages.title)}>
         <ColumnSettingsContainer />
-        <StatusListContainer {...this.props} type='home' />
+        <StatusListContainer {...this.props} type='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage="You aren't following anyone yet. Visit {public} or use search to get started and meet other users." values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} />
       </Column>
     );
   },
 
 });
 
-export default injectIntl(HomeTimeline);
+export default connect(mapStateToProps)(injectIntl(HomeTimeline));
diff --git a/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx
index d20a4d170..6aa9d1efa 100644
--- a/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx
+++ b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx
@@ -5,11 +5,11 @@ const iconStyle = {
   right: '48px',
   top: '0',
   cursor: 'pointer',
-  background: '#2f3441'
+  zIndex: '2'
 };
 
 const ClearColumnButton = ({ onClick }) => (
-  <div className='column-icon' style={iconStyle} onClick={onClick}>
+  <div className='column-icon' tabindex='0' style={iconStyle} onClick={onClick}>
     <i className='fa fa-trash' />
   </div>
 );
diff --git a/app/assets/javascripts/components/features/notifications/components/column_settings.jsx b/app/assets/javascripts/components/features/notifications/components/column_settings.jsx
index b63c1881a..f1b8ef57f 100644
--- a/app/assets/javascripts/components/features/notifications/components/column_settings.jsx
+++ b/app/assets/javascripts/components/features/notifications/components/column_settings.jsx
@@ -5,7 +5,6 @@ import ColumnCollapsable from '../../../components/column_collapsable';
 import SettingToggle from './setting_toggle';
 
 const outerStyle = {
-  background: '#373b4a',
   padding: '15px'
 };
 
@@ -13,7 +12,6 @@ const sectionStyle = {
   cursor: 'default',
   display: 'block',
   fontWeight: '500',
-  color: '#9baec8',
   marginBottom: '10px'
 };
 
@@ -40,8 +38,8 @@ const ColumnSettings = React.createClass({
 
     return (
       <ColumnCollapsable icon='sliders' fullHeight={616} onCollapse={onSave}>
-        <div style={outerStyle}>
-          <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
+        <div className='column-settings--outer' style={outerStyle}>
+          <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
 
           <div style={rowStyle}>
             <SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
@@ -49,7 +47,7 @@ const ColumnSettings = React.createClass({
             <SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
           </div>
 
-          <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
+          <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
 
           <div style={rowStyle}>
             <SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
@@ -57,7 +55,7 @@ const ColumnSettings = React.createClass({
             <SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
           </div>
 
-          <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
+          <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
 
           <div style={rowStyle}>
             <SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
@@ -65,7 +63,7 @@ const ColumnSettings = React.createClass({
             <SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
           </div>
 
-          <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
+          <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
 
           <div style={rowStyle}>
             <SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
diff --git a/app/assets/javascripts/components/features/notifications/components/notification.jsx b/app/assets/javascripts/components/features/notifications/components/notification.jsx
index 140ba9134..0de4df52e 100644
--- a/app/assets/javascripts/components/features/notifications/components/notification.jsx
+++ b/app/assets/javascripts/components/features/notifications/components/notification.jsx
@@ -5,17 +5,7 @@ import AccountContainer from '../../../containers/account_container';
 import { FormattedMessage } from 'react-intl';
 import Permalink from '../../../components/permalink';
 import emojify from '../../../emoji';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
-
-const messageStyle = {
-  marginLeft: '68px',
-  padding: '8px 0',
-  paddingBottom: '0',
-  cursor: 'default',
-  color: '#d9e1e8',
-  fontSize: '15px',
-  position: 'relative'
-};
+import escapeTextContentForBrowser from 'escape-html';
 
 const linkStyle = {
   fontWeight: '500'
@@ -32,9 +22,9 @@ const Notification = React.createClass({
   renderFollow (account, link) {
     return (
       <div className='notification'>
-        <div style={messageStyle}>
+        <div className='notification__message'>
           <div style={{ position: 'absolute', 'left': '-26px'}}>
-            <i className='fa fa-fw fa-user-plus' style={{ color: '#2b90d9' }} />
+            <i className='fa fa-fw fa-user-plus' />
           </div>
 
           <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
@@ -52,7 +42,7 @@ const Notification = React.createClass({
   renderFavourite (notification, link) {
     return (
       <div className='notification'>
-        <div style={messageStyle}>
+        <div className='notification__message'>
           <div style={{ position: 'absolute', 'left': '-26px'}}>
             <i className='fa fa-fw fa-star' style={{ color: '#ca8f04' }} />
           </div>
@@ -68,9 +58,9 @@ const Notification = React.createClass({
   renderReblog (notification, link) {
     return (
       <div className='notification'>
-        <div style={messageStyle}>
+        <div className='notification__message'>
           <div style={{ position: 'absolute', 'left': '-26px'}}>
-            <i className='fa fa-fw fa-retweet' style={{ color: '#2b90d9' }} />
+            <i className='fa fa-fw fa-retweet' />
           </div>
 
           <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
diff --git a/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx b/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx
index c2438f716..eae3c2be2 100644
--- a/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx
+++ b/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx
@@ -11,14 +11,13 @@ const labelSpanStyle = {
   display: 'inline-block',
   verticalAlign: 'middle',
   marginBottom: '14px',
-  marginLeft: '8px',
-  color: '#9baec8'
+  marginLeft: '8px'
 };
 
 const SettingToggle = ({ settings, settingKey, label, onChange }) => (
   <label style={labelStyle}>
     <Toggle checked={settings.getIn(settingKey)} onChange={(e) => onChange(settingKey, e.target.checked)} />
-    <span style={labelSpanStyle}>{label}</span>
+    <span className='setting-toggle' style={labelSpanStyle}>{label}</span>
   </label>
 );
 
diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx
index 6d10768de..74b914ffd 100644
--- a/app/assets/javascripts/components/features/notifications/index.jsx
+++ b/app/assets/javascripts/components/features/notifications/index.jsx
@@ -2,10 +2,10 @@ import { connect } from 'react-redux';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import Column from '../ui/components/column';
-import { expandNotifications, clearNotifications } from '../../actions/notifications';
+import { expandNotifications, clearNotifications, scrollTopNotifications } from '../../actions/notifications';
 import NotificationContainer from './containers/notification_container';
 import { ScrollContainer } from 'react-router-scroll';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
 import { createSelector } from 'reselect';
 import Immutable from 'immutable';
@@ -13,7 +13,8 @@ import LoadMore from '../../components/load_more';
 import ClearColumnButton from './components/clear_column_button';
 
 const messages = defineMessages({
-  title: { id: 'column.notifications', defaultMessage: 'Notifications' }
+  title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+  confirm: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to clear all your notifications?' }
 });
 
 const getNotifications = createSelector([
@@ -23,7 +24,8 @@ const getNotifications = createSelector([
 
 const mapStateToProps = state => ({
   notifications: getNotifications(state),
-  isLoading: state.getIn(['notifications', 'isLoading'], true)
+  isLoading: state.getIn(['notifications', 'isLoading'], true),
+  isUnread: state.getIn(['notifications', 'unread']) > 0
 });
 
 const Notifications = React.createClass({
@@ -33,7 +35,8 @@ const Notifications = React.createClass({
     dispatch: React.PropTypes.func.isRequired,
     trackScroll: React.PropTypes.bool,
     intl: React.PropTypes.object.isRequired,
-    isLoading: React.PropTypes.bool
+    isLoading: React.PropTypes.bool,
+    isUnread: React.PropTypes.bool
   },
 
   getDefaultProps () {
@@ -51,6 +54,10 @@ const Notifications = React.createClass({
 
     if (250 > offset && !this.props.isLoading) {
       this.props.dispatch(expandNotifications());
+    } else if (scrollTop < 100) {
+      this.props.dispatch(scrollTopNotifications(true));
+    } else {
+      this.props.dispatch(scrollTopNotifications(false));
     }
   },
 
@@ -66,7 +73,9 @@ const Notifications = React.createClass({
   },
 
   handleClear () {
-    this.props.dispatch(clearNotifications());
+    if (window.confirm(this.props.intl.formatMessage(messages.confirm))) {
+      this.props.dispatch(clearNotifications());
+    }
   },
 
   setRef (c) {
@@ -74,26 +83,42 @@ const Notifications = React.createClass({
   },
 
   render () {
-    const { intl, notifications, trackScroll, isLoading } = this.props;
+    const { intl, notifications, trackScroll, isLoading, isUnread } = this.props;
 
-    let loadMore = '';
+    let loadMore       = '';
+    let scrollableArea = '';
+    let unread         = '';
 
     if (!isLoading && notifications.size > 0) {
       loadMore = <LoadMore onClick={this.handleLoadMore} />;
     }
 
-    const scrollableArea = (
-      <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}>
-        <div>
-          {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)}
-          {loadMore}
+    if (isUnread) {
+      unread = <div className='notifications__unread-indicator' />;
+    }
+
+    if (isLoading || notifications.size > 0) {
+      scrollableArea = (
+        <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}>
+          {unread}
+
+          <div>
+            {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)}
+            {loadMore}
+          </div>
         </div>
-      </div>
-    );
+      );
+    } else {
+      scrollableArea = (
+        <div className='empty-column-indicator' ref={this.setRef}>
+          <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
+        </div>
+      );
+    }
 
     if (trackScroll) {
       return (
-        <Column icon='bell' heading={intl.formatMessage(messages.title)}>
+        <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}>
           <ColumnSettingsContainer />
           <ClearColumnButton onClick={this.handleClear} />
           <ScrollContainer scrollKey='notifications'>
@@ -103,7 +128,7 @@ const Notifications = React.createClass({
       );
     } else {
       return (
-        <Column icon='bell' heading={intl.formatMessage(messages.title)}>
+        <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}>
           <ColumnSettingsContainer />
           <ClearColumnButton onClick={this.handleClear} />
           {scrollableArea}
diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx
index 36d68dbbb..6d766a83b 100644
--- a/app/assets/javascripts/components/features/public_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/public_timeline/index.jsx
@@ -5,26 +5,32 @@ import Column from '../ui/components/column';
 import {
   refreshTimeline,
   updateTimeline,
-  deleteFromTimelines
+  deleteFromTimelines,
+  connectTimeline,
+  disconnectTimeline
 } from '../../actions/timelines';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
 import createStream from '../../stream';
 
 const messages = defineMessages({
-  title: { id: 'column.public', defaultMessage: 'Public' }
+  title: { id: 'column.public', defaultMessage: 'Whole Known Network' }
 });
 
 const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
   accessToken: state.getIn(['meta', 'access_token'])
 });
 
+let subscription;
+
 const PublicTimeline = React.createClass({
 
   propTypes: {
     dispatch: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired,
-    accessToken: React.PropTypes.string.isRequired
+    accessToken: React.PropTypes.string.isRequired,
+    hasUnread: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
@@ -34,7 +40,23 @@ const PublicTimeline = React.createClass({
 
     dispatch(refreshTimeline('public'));
 
-    this.subscription = createStream(accessToken, 'public', {
+    if (typeof subscription !== 'undefined') {
+      return;
+    }
+
+    subscription = createStream(accessToken, 'public', {
+
+      connected () {
+        dispatch(connectTimeline('public'));
+      },
+
+      reconnected () {
+        dispatch(connectTimeline('public'));
+      },
+
+      disconnected () {
+        dispatch(disconnectTimeline('public'));
+      },
 
       received (data) {
         switch(data.event) {
@@ -51,19 +73,19 @@ const PublicTimeline = React.createClass({
   },
 
   componentWillUnmount () {
-    if (typeof this.subscription !== 'undefined') {
-      this.subscription.close();
-      this.subscription = null;
-    }
+    // if (typeof subscription !== 'undefined') {
+    //   subscription.close();
+    //   subscription = null;
+    // }
   },
 
   render () {
-    const { intl } = this.props;
+    const { intl, hasUnread } = this.props;
 
     return (
-      <Column icon='globe' heading={intl.formatMessage(messages.title)}>
+      <Column icon='globe' active={hasUnread} heading={intl.formatMessage(messages.title)}>
         <ColumnBackButtonSlim />
-        <StatusListContainer type='public' />
+        <StatusListContainer type='public' emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} />
       </Column>
     );
   },
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..2aebcd709 100644
--- a/app/assets/javascripts/components/features/status/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/status/components/action_bar.jsx
@@ -6,10 +6,11 @@ import { defineMessages, injectIntl } from 'react-intl';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
-  mention: { id: 'status.mention', defaultMessage: 'Mention' },
+  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   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 @{name}' }
 });
 
 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;
 
@@ -59,13 +66,15 @@ const ActionBar = React.createClass({
     if (me === status.getIn(['account', 'id'])) {
       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.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
     }
 
     return (
       <div className='detailed-status__action-bar'>
         <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
-        <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div>
+        <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton disabled={status.get('visibility') === 'direct' || status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'direct' ? 'envelope' : (status.get('visibility') === 'private' ? 'lock' : 'retweet')} onClick={this.handleReblogClick} /></div>
         <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
         <div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" /></div>
       </div>
diff --git a/app/assets/javascripts/components/features/status/components/card.jsx b/app/assets/javascripts/components/features/status/components/card.jsx
index ccb06dfd5..d016212fd 100644
--- a/app/assets/javascripts/components/features/status/components/card.jsx
+++ b/app/assets/javascripts/components/features/status/components/card.jsx
@@ -1,18 +1,6 @@
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 
-const outerStyle = {
-  display: 'flex',
-  cursor: 'pointer',
-  fontSize: '14px',
-  border: '1px solid #363c4b',
-  borderRadius: '4px',
-  color: '#616b86',
-  marginTop: '14px',
-  textDecoration: 'none',
-  overflow: 'hidden'
-};
-
 const contentStyle = {
   flex: '1 1 auto',
   padding: '8px',
@@ -20,25 +8,6 @@ const contentStyle = {
   overflow: 'hidden'
 };
 
-const titleStyle = {
-  display: 'block',
-  fontWeight: '500',
-  marginBottom: '5px',
-  color: '#d9e1e8',
-  overflow: 'hidden',
-  textOverflow: 'ellipsis',
-  whiteSpace: 'nowrap'
-};
-
-const descriptionStyle = {
-  color: '#d9e1e8'
-};
-
-const imageOuterStyle = {
-  flex: '0 0 100px',
-  background: '#373b4a'
-};
-
 const imageStyle = {
   display: 'block',
   width: '100%',
@@ -77,20 +46,20 @@ const Card = React.createClass({
 
     if (card.get('image')) {
       image = (
-        <div style={imageOuterStyle}>
+        <div className='status-card__image'>
           <img src={card.get('image')} alt={card.get('title')} style={imageStyle} />
         </div>
       );
     }
 
     return (
-      <a style={outerStyle} href={card.get('url')} className='status-card'>
+      <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'>
         {image}
 
-        <div style={contentStyle}>
-          <strong style={titleStyle} title={card.get('title')}>{card.get('title')}</strong>
-          <p style={descriptionStyle}>{card.get('description').substring(0, 50)}</p>
-          <span style={hostStyle}>{getHostname(card.get('url'))}</span>
+        <div className='status-card__content' style={contentStyle}>
+          <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>
+          <p className='status-card__description'>{card.get('description').substring(0, 50)}</p>
+          <span className='status-card__host' style={hostStyle}>{getHostname(card.get('url'))}</span>
         </div>
       </a>
     );
diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
index f2d6ae48a..caa46ff3c 100644
--- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx
+++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
@@ -39,7 +39,7 @@ const DetailedStatus = React.createClass({
 
     if (status.get('media_attachments').size > 0) {
       if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
-        media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={317} height={178} />;
+        media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} autoplay />;
       } else {
         media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
       }
@@ -52,7 +52,7 @@ const DetailedStatus = React.createClass({
     }
 
     return (
-      <div style={{ background: '#2f3441', padding: '14px 10px' }} className='detailed-status'>
+      <div style={{ padding: '14px 10px' }} className='detailed-status'>
         <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}>
           <div style={{ float: 'left', marginRight: '10px' }}><Avatar src={status.getIn(['account', 'avatar'])} size={48} /></div>
           <DisplayName account={status.get('account')} />
@@ -62,7 +62,7 @@ const DetailedStatus = React.createClass({
 
         {media}
 
-        <div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}>
+        <div className='detailed-status__meta'>
           <a className='detailed-status__datetime' style={{ color: 'inherit' }} href={status.get('url')} target='_blank' rel='noopener'><FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /></a>{applicationLink} · <Link to={`/statuses/${status.get('id')}/reblogs`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-retweet' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('reblogs_count')} /></span></Link> · <Link to={`/statuses/${status.get('id')}/favourites`} style={{ color: 'inherit', textDecoration: 'none' }}><i className='fa fa-star' /><span style={{ fontWeight: '500', fontSize: '12px', marginLeft: '6px', display: 'inline-block' }}><FormattedNumber value={status.get('favourites_count')} /></span></Link>
         </div>
       </div>
diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx
index 894fa3176..f98fe1b01 100644
--- a/app/assets/javascripts/components/features/status/index.jsx
+++ b/app/assets/javascripts/components/features/status/index.jsx
@@ -1,28 +1,34 @@
-import { connect }           from 'react-redux';
-import PureRenderMixin       from 'react-addons-pure-render-mixin';
-import ImmutablePropTypes    from 'react-immutable-proptypes';
-import { fetchStatus }       from '../../actions/statuses';
-import Immutable             from 'immutable';
-import EmbeddedStatus        from '../../components/status';
-import LoadingIndicator      from '../../components/loading_indicator';
-import DetailedStatus        from './components/detailed_status';
-import ActionBar             from './components/action_bar';
-import Column                from '../ui/components/column';
-import { favourite, reblog } from '../../actions/interactions';
+import { connect } from 'react-redux';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchStatus } from '../../actions/statuses';
+import Immutable from 'immutable';
+import EmbeddedStatus from '../../components/status';
+import MissingIndicator from '../../components/missing_indicator';
+import DetailedStatus from './components/detailed_status';
+import ActionBar from './components/action_bar';
+import Column from '../ui/components/column';
+import {
+  favourite,
+  unfavourite,
+  reblog,
+  unreblog
+} from '../../actions/interactions';
 import {
   replyCompose,
   mentionCompose
-}                            from '../../actions/compose';
-import { deleteStatus }      from '../../actions/statuses';
+} from '../../actions/compose';
+import { deleteStatus } from '../../actions/statuses';
+import { initReport } from '../../actions/reports';
 import {
   makeGetStatus,
   getStatusAncestors,
   getStatusDescendants
-}                            from '../../selectors';
-import { ScrollContainer }   from 'react-router-scroll';
-import ColumnBackButton      from '../../components/column_back_button';
-import StatusContainer       from '../../containers/status_container';
-import { openMedia }         from '../../actions/modal';
+} from '../../selectors';
+import { ScrollContainer } from 'react-router-scroll';
+import ColumnBackButton from '../../components/column_back_button';
+import StatusContainer from '../../containers/status_container';
+import { openModal } from '../../actions/modal';
 import { isMobile } from '../../is_mobile'
 
 const makeMapStateToProps = () => {
@@ -65,7 +71,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 +83,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) {
@@ -85,7 +99,11 @@ const Status = React.createClass({
   },
 
   handleOpenMedia (media, index) {
-    this.props.dispatch(openMedia(media, index));
+    this.props.dispatch(openModal('MEDIA', { media, index }));
+  },
+
+  handleReport (status) {
+    this.props.dispatch(initReport(status.get('account'), status));
   },
 
   renderChildren (list) {
@@ -99,7 +117,8 @@ const Status = React.createClass({
     if (status === null) {
       return (
         <Column>
-          <LoadingIndicator />
+          <ColumnBackButton />
+          <MissingIndicator />
         </Column>
       );
     }
@@ -123,7 +142,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/features/ui/components/column.jsx b/app/assets/javascripts/components/features/ui/components/column.jsx
index 5b0603ee9..2b7e11bf1 100644
--- a/app/assets/javascripts/components/features/ui/components/column.jsx
+++ b/app/assets/javascripts/components/features/ui/components/column.jsx
@@ -34,7 +34,8 @@ const Column = React.createClass({
   propTypes: {
     heading: React.PropTypes.string,
     icon: React.PropTypes.string,
-    children: React.PropTypes.node
+    children: React.PropTypes.node,
+    active: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
@@ -51,12 +52,12 @@ const Column = React.createClass({
   },
 
   render () {
-    const { heading, icon, children } = this.props;
+    const { heading, icon, children, active } = this.props;
 
     let header = '';
 
     if (heading) {
-      header = <ColumnHeader icon={icon} type={heading} onClick={this.handleHeaderClick} />;
+      header = <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} />;
     }
 
     return (
diff --git a/app/assets/javascripts/components/features/ui/components/column_header.jsx b/app/assets/javascripts/components/features/ui/components/column_header.jsx
index 8b072d723..de55fa748 100644
--- a/app/assets/javascripts/components/features/ui/components/column_header.jsx
+++ b/app/assets/javascripts/components/features/ui/components/column_header.jsx
@@ -5,6 +5,7 @@ const ColumnHeader = React.createClass({
   propTypes: {
     icon: React.PropTypes.string,
     type: React.PropTypes.string,
+    active: React.PropTypes.bool,
     onClick: React.PropTypes.func
   },
 
@@ -15,6 +16,8 @@ const ColumnHeader = React.createClass({
   },
 
   render () {
+    const { type, active } = this.props;
+
     let icon = '';
 
     if (this.props.icon) {
@@ -22,9 +25,9 @@ const ColumnHeader = React.createClass({
     }
 
     return (
-      <div className='column-header' onClick={this.handleClick}>
+      <div className={`column-header ${active ? 'active' : ''}`} onClick={this.handleClick}>
         {icon}
-        {this.props.type}
+        {type}
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/ui/components/column_link.jsx b/app/assets/javascripts/components/features/ui/components/column_link.jsx
index 901a29f5c..2bd1e1017 100644
--- a/app/assets/javascripts/components/features/ui/components/column_link.jsx
+++ b/app/assets/javascripts/components/features/ui/components/column_link.jsx
@@ -4,7 +4,6 @@ const outerStyle = {
   display: 'block',
   padding: '15px',
   fontSize: '16px',
-  color: '#fff',
   textDecoration: 'none'
 };
 
diff --git a/app/assets/javascripts/components/features/ui/components/media_modal.jsx b/app/assets/javascripts/components/features/ui/components/media_modal.jsx
new file mode 100644
index 000000000..35eb2cb0c
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/media_modal.jsx
@@ -0,0 +1,133 @@
+import LoadingIndicator from '../../../components/loading_indicator';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ExtendedVideoPlayer from '../../../components/extended_video_player';
+import ImageLoader from 'react-imageloader';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' }
+});
+
+const leftNavStyle = {
+  position: 'absolute',
+  background: 'rgba(0, 0, 0, 0.5)',
+  padding: '30px 15px',
+  cursor: 'pointer',
+  fontSize: '24px',
+  top: '0',
+  left: '-61px',
+  boxSizing: 'border-box',
+  height: '100%',
+  display: 'flex',
+  alignItems: 'center'
+};
+
+const rightNavStyle = {
+  position: 'absolute',
+  background: 'rgba(0, 0, 0, 0.5)',
+  padding: '30px 15px',
+  cursor: 'pointer',
+  fontSize: '24px',
+  top: '0',
+  right: '-61px',
+  boxSizing: 'border-box',
+  height: '100%',
+  display: 'flex',
+  alignItems: 'center'
+};
+
+const closeStyle = {
+  position: 'absolute',
+  top: '4px',
+  right: '4px'
+};
+
+const MediaModal = React.createClass({
+
+  propTypes: {
+    media: ImmutablePropTypes.list.isRequired,
+    index: React.PropTypes.number.isRequired,
+    onClose: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
+  },
+
+  getInitialState () {
+    return {
+      index: null
+    };
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleNextClick () {
+    this.setState({ index: (this.getIndex() + 1) % this.props.media.size});
+  },
+
+  handlePrevClick () {
+    this.setState({ index: (this.getIndex() - 1) % this.props.media.size});
+  },
+
+  handleKeyUp (e) {
+    switch(e.key) {
+    case 'ArrowLeft':
+      this.handlePrevClick();
+      break;
+    case 'ArrowRight':
+      this.handleNextClick();
+      break;
+    }
+  },
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  },
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  },
+
+  getIndex () {
+    return this.state.index !== null ? this.state.index : this.props.index;
+  },
+
+  render () {
+    const { media, intl, onClose } = this.props;
+
+    const index = this.getIndex();
+    const attachment = media.get(index);
+    const url = attachment.get('url');
+
+    let leftNav, rightNav, content;
+
+    leftNav = rightNav = content = '';
+
+    if (media.size > 1) {
+      leftNav  = <div style={leftNavStyle} className='modal-container__nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
+      rightNav = <div style={rightNavStyle} className='modal-container__nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
+    }
+
+    if (attachment.get('type') === 'image') {
+      content = <ImageLoader src={url} imgProps={{ style: { display: 'block' } }} />;
+    } else if (attachment.get('type') === 'gifv') {
+      content = <ExtendedVideoPlayer src={url} />;
+    }
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        {leftNav}
+
+        <div>
+          <IconButton title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} style={closeStyle} />
+          {content}
+        </div>
+
+        {rightNav}
+      </div>
+    );
+  }
+
+});
+
+export default injectIntl(MediaModal);
diff --git a/app/assets/javascripts/components/features/ui/components/modal_root.jsx b/app/assets/javascripts/components/features/ui/components/modal_root.jsx
new file mode 100644
index 000000000..d2ae5e145
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/modal_root.jsx
@@ -0,0 +1,80 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import MediaModal from './media_modal';
+import { TransitionMotion, spring } from 'react-motion';
+
+const MODAL_COMPONENTS = {
+  'MEDIA': MediaModal
+};
+
+const ModalRoot = React.createClass({
+
+  propTypes: {
+    type: React.PropTypes.string,
+    props: React.PropTypes.object,
+    onClose: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleKeyUp (e) {
+    if (e.key === 'Escape' && !!this.props.type) {
+      this.props.onClose();
+    }
+  },
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  },
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  },
+
+  willEnter () {
+    return { opacity: 0, scale: 0.98 };
+  },
+
+  willLeave () {
+    return { opacity: spring(0), scale: spring(0.98) };
+  },
+
+  render () {
+    const { type, props, onClose } = this.props;
+    const items = [];
+
+    if (!!type) {
+      items.push({
+        key: type,
+        data: { type, props },
+        style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) }
+      });
+    }
+
+    return (
+      <TransitionMotion
+        styles={items}
+        willEnter={this.willEnter}
+        willLeave={this.willLeave}>
+        {interpolatedStyles =>
+          <div className='modal-root'>
+            {interpolatedStyles.map(({ key, data: { type, props }, style }) => {
+              const SpecificComponent = MODAL_COMPONENTS[type];
+
+              return (
+                <div key={key}>
+                  <div className='modal-root__overlay' style={{ opacity: style.opacity, transform: `translateZ(0px)` }} onClick={onClose} />
+                  <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
+                    <SpecificComponent {...props} onClose={onClose} />
+                  </div>
+                </div>
+              );
+            })}
+          </div>
+        }
+      </TransitionMotion>
+    );
+  }
+
+});
+
+export default ModalRoot;
diff --git a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
index 225a6a5fc..6cdb29dbf 100644
--- a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
+++ b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
@@ -1,15 +1,23 @@
 import { Link } from 'react-router';
 import { FormattedMessage } from 'react-intl';
 
-const TabsBar = () => {
-  return (
-    <div className='tabs-bar'>
-      <Link className='tabs-bar__link' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
-      <Link className='tabs-bar__link' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
-      <Link className='tabs-bar__link' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
-      <Link className='tabs-bar__link' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link>
-    </div>
-  );
-};
+const TabsBar = React.createClass({
+
+  render () {
+    return (
+      <div className='tabs-bar'>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
+
+        <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public/local'><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></Link>
+        <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public'><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></Link>
+
+        <Link className='tabs-bar__link primary' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link>
+      </div>
+    );
+  }
+
+});
 
 export default TabsBar;
diff --git a/app/assets/javascripts/components/features/ui/components/upload_area.jsx b/app/assets/javascripts/components/features/ui/components/upload_area.jsx
new file mode 100644
index 000000000..70b687019
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/upload_area.jsx
@@ -0,0 +1,32 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { Motion, spring } from 'react-motion';
+import { FormattedMessage } from 'react-intl';
+
+const UploadArea = React.createClass({
+
+  propTypes: {
+    active: React.PropTypes.bool
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { active } = this.props;
+
+    return (
+      <Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}>
+        {({ backgroundOpacity, backgroundScale }) =>
+          <div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}>
+            <div className='upload-area__drop'>
+              <div className='upload-area__background' style={{ transform: `translateZ(0) scale(${backgroundScale})` }} />
+              <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div>
+            </div>
+          </div>
+        }
+      </Motion>
+    );
+  }
+
+});
+
+export default UploadArea;
diff --git a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
index 334e5c199..26d77818c 100644
--- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
@@ -1,170 +1,16 @@
 import { connect } from 'react-redux';
-import {
-  closeModal,
-  decreaseIndexInModal,
-  increaseIndexInModal
-} from '../../../actions/modal';
-import Lightbox from '../../../components/lightbox';
-import ImageLoader from 'react-imageloader';
-import LoadingIndicator from '../../../components/loading_indicator';
-import PureRenderMixin from 'react-addons-pure-render-mixin';
-import ImmutablePropTypes from 'react-immutable-proptypes';
+import { closeModal } from '../../../actions/modal';
+import ModalRoot from '../components/modal_root';
 
 const mapStateToProps = state => ({
-  media: state.getIn(['modal', 'media']),
-  index: state.getIn(['modal', 'index']),
-  isVisible: state.getIn(['modal', 'open'])
+  type: state.get('modal').modalType,
+  props: state.get('modal').modalProps
 });
 
 const mapDispatchToProps = dispatch => ({
-  onCloseClicked () {
+  onClose () {
     dispatch(closeModal());
   },
-
-  onOverlayClicked () {
-    dispatch(closeModal());
-  },
-
-  onNextClicked () {
-    dispatch(increaseIndexInModal());
-  },
-
-  onPrevClicked () {
-    dispatch(decreaseIndexInModal());
-  }
-});
-
-const imageStyle = {
-  display: 'block',
-  maxWidth: '80vw',
-  maxHeight: '80vh'
-};
-
-const loadingStyle = {
-  background: '#373b4a',
-  width: '400px',
-  paddingBottom: '120px'
-};
-
-const preloader = () => (
-  <div style={loadingStyle}>
-    <LoadingIndicator />
-  </div>
-);
-
-const leftNavStyle = {
-  position: 'absolute',
-  background: 'rgba(0, 0, 0, 0.5)',
-  padding: '30px 15px',
-  cursor: 'pointer',
-  color: '#fff',
-  fontSize: '24px',
-  top: '0',
-  left: '-61px',
-  boxSizing: 'border-box',
-  height: '100%',
-  display: 'flex',
-  alignItems: 'center'
-};
-
-const rightNavStyle = {
-  position: 'absolute',
-  background: 'rgba(0, 0, 0, 0.5)',
-  padding: '30px 15px',
-  cursor: 'pointer',
-  color: '#fff',
-  fontSize: '24px',
-  top: '0',
-  right: '-61px',
-  boxSizing: 'border-box',
-  height: '100%',
-  display: 'flex',
-  alignItems: 'center'
-};
-
-const Modal = React.createClass({
-
-  propTypes: {
-    media: ImmutablePropTypes.list,
-    index: React.PropTypes.number.isRequired,
-    isVisible: React.PropTypes.bool,
-    onCloseClicked: React.PropTypes.func,
-    onOverlayClicked: React.PropTypes.func,
-    onNextClicked: React.PropTypes.func,
-    onPrevClicked: React.PropTypes.func
-  },
-
-  mixins: [PureRenderMixin],
-
-  handleNextClick () {
-    this.props.onNextClicked();
-  },
-
-  handlePrevClick () {
-    this.props.onPrevClicked();
-  },
-
-  componentDidMount () {
-    this._listener = e => {
-      if (!this.props.isVisible) {
-        return;
-      }
-
-      switch(e.key) {
-      case 'ArrowLeft':
-        this.props.onPrevClicked();
-        break;
-      case 'ArrowRight':
-        this.props.onNextClicked();
-        break;
-      }
-    };
-
-    window.addEventListener('keyup', this._listener);
-  },
-
-  componentWillUnmount () {
-    window.removeEventListener('keyup', this._listener);
-  },
-
-  render () {
-    const { media, index, ...other } = this.props;
-
-    if (!media) {
-      return null;
-    }
-
-    const url      = media.get(index).get('url');
-    const hasLeft  = index > 0;
-    const hasRight = index + 1 < media.size;
-
-    let leftNav, rightNav;
-
-    leftNav = rightNav = '';
-
-    if (hasLeft) {
-      leftNav = <div style={leftNavStyle} onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
-    }
-
-    if (hasRight) {
-      rightNav = <div style={rightNavStyle} onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
-    }
-
-    return (
-      <Lightbox {...other}>
-        {leftNav}
-
-        <ImageLoader
-          src={url}
-          preloader={preloader}
-          imgProps={{ style: imageStyle }}
-        />
-
-        {rightNav}
-      </Lightbox>
-    );
-  }
-
 });
 
-export default connect(mapStateToProps, mapDispatchToProps)(Modal);
+export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);
diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
index 100989d22..f249240d8 100644
--- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
@@ -3,8 +3,9 @@ import StatusList from '../../../components/status_list';
 import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines';
 import Immutable from 'immutable';
 import { createSelector } from 'reselect';
+import { debounce } from 'react-decoration';
 
-const getStatusIds = createSelector([
+const makeGetStatusIds = () => createSelector([
   (state, { type }) => state.getIn(['settings', type], Immutable.Map()),
   (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()),
   (state)           => state.get('statuses'),
@@ -33,26 +34,37 @@ const getStatusIds = createSelector([
   return showStatus;
 }));
 
-const mapStateToProps = (state, props) => ({
-  statusIds: getStatusIds(state, props),
-  isLoading: state.getIn(['timelines', props.type, 'isLoading'], true)
-});
+const makeMapStateToProps = () => {
+  const getStatusIds = makeGetStatusIds();
+
+  const mapStateToProps = (state, props) => ({
+    statusIds: getStatusIds(state, props),
+    isLoading: state.getIn(['timelines', props.type, 'isLoading'], true),
+    isUnread: state.getIn(['timelines', props.type, 'unread']) > 0,
+    hasMore: !!state.getIn(['timelines', props.type, 'next'])
+  });
+
+  return mapStateToProps;
+};
 
 const mapDispatchToProps = (dispatch, { type, id }) => ({
 
+  @debounce(300, true)
   onScrollToBottom () {
     dispatch(scrollTopTimeline(type, false));
     dispatch(expandTimeline(type, id));
   },
 
+  @debounce(100)
   onScrollToTop () {
     dispatch(scrollTopTimeline(type, true));
   },
 
+  @debounce(100)
   onScroll () {
     dispatch(scrollTopTimeline(type, false));
   }
 
 });
 
-export default connect(mapStateToProps, mapDispatchToProps)(StatusList);
+export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx
index 900d83dba..89fb82568 100644
--- a/app/assets/javascripts/components/features/ui/index.jsx
+++ b/app/assets/javascripts/components/features/ui/index.jsx
@@ -13,6 +13,7 @@ import { debounce } from 'react-decoration';
 import { uploadCompose } from '../../actions/compose';
 import { refreshTimeline } from '../../actions/timelines';
 import { refreshNotifications } from '../../actions/notifications';
+import UploadArea from './components/upload_area';
 
 const UI = React.createClass({
 
@@ -23,7 +24,8 @@ const UI = React.createClass({
 
   getInitialState () {
     return {
-      width: window.innerWidth
+      width: window.innerWidth,
+      draggingOver: false
     };
   },
 
@@ -34,29 +36,64 @@ const UI = React.createClass({
     this.setState({ width: window.innerWidth });
   },
 
+  handleDragEnter (e) {
+    e.preventDefault();
+
+    if (!this.dragTargets) {
+      this.dragTargets = [];
+    }
+
+    if (this.dragTargets.indexOf(e.target) === -1) {
+      this.dragTargets.push(e.target);
+    }
+
+    if (e.dataTransfer && e.dataTransfer.files.length > 0) {
+      this.setState({ draggingOver: true });
+    }
+  },
+
   handleDragOver (e) {
     e.preventDefault();
     e.stopPropagation();
 
-    e.dataTransfer.dropEffect = 'copy';
+    try {
+      e.dataTransfer.dropEffect = 'copy';
+    } catch (err) {
 
-    if (e.dataTransfer.effectAllowed === 'all' || e.dataTransfer.effectAllowed === 'uninitialized') {
-      //
     }
+
+    return false;
   },
 
   handleDrop (e) {
     e.preventDefault();
 
+    this.setState({ draggingOver: false });
+
     if (e.dataTransfer && e.dataTransfer.files.length === 1) {
       this.props.dispatch(uploadCompose(e.dataTransfer.files));
     }
   },
 
+  handleDragLeave (e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
+
+    if (this.dragTargets.length > 0) {
+      return;
+    }
+
+    this.setState({ draggingOver: false });
+  },
+
   componentWillMount () {
     window.addEventListener('resize', this.handleResize, { passive: true });
-    window.addEventListener('dragover', this.handleDragOver);
-    window.addEventListener('drop', this.handleDrop);
+    document.addEventListener('dragenter', this.handleDragEnter, false);
+    document.addEventListener('dragover', this.handleDragOver, false);
+    document.addEventListener('drop', this.handleDrop, false);
+    document.addEventListener('dragleave', this.handleDragLeave, false);
 
     this.props.dispatch(refreshTimeline('home'));
     this.props.dispatch(refreshNotifications());
@@ -64,17 +101,26 @@ const UI = React.createClass({
 
   componentWillUnmount () {
     window.removeEventListener('resize', this.handleResize);
-    window.removeEventListener('dragover', this.handleDragOver);
-    window.removeEventListener('drop', this.handleDrop);
+    document.removeEventListener('dragenter', this.handleDragEnter);
+    document.removeEventListener('dragover', this.handleDragOver);
+    document.removeEventListener('drop', this.handleDrop);
+    document.removeEventListener('dragleave', this.handleDragLeave);
+  },
+
+  setRef (c) {
+    this.node = c;
   },
 
   render () {
+    const { width, draggingOver } = this.state;
+    const { children } = this.props;
+
     let mountedColumns;
 
-    if (isMobile(this.state.width)) {
+    if (isMobile(width)) {
       mountedColumns = (
         <ColumnsArea>
-          {this.props.children}
+          {children}
         </ColumnsArea>
       );
     } else {
@@ -83,13 +129,13 @@ const UI = React.createClass({
           <Compose withHeader={true} />
           <HomeTimeline trackScroll={false} />
           <Notifications trackScroll={false} />
-          {this.props.children}
+          {children}
         </ColumnsArea>
       );
     }
 
     return (
-      <div className='ui'>
+      <div className='ui' ref={this.setRef}>
         <TabsBar />
 
         {mountedColumns}
@@ -97,6 +143,7 @@ const UI = React.createClass({
         <NotificationsContainer />
         <LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} />
         <ModalContainer />
+        <UploadArea active={draggingOver} />
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/is_mobile.jsx b/app/assets/javascripts/components/is_mobile.jsx
index eaa6221e4..992e63727 100644
--- a/app/assets/javascripts/components/is_mobile.jsx
+++ b/app/assets/javascripts/components/is_mobile.jsx
@@ -3,3 +3,9 @@ const LAYOUT_BREAKPOINT = 1024;
 export function isMobile(width) {
   return width <= LAYOUT_BREAKPOINT;
 };
+
+const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+
+export function isIOS() {
+  return iOS;
+};
diff --git a/app/assets/javascripts/components/locales/de.jsx b/app/assets/javascripts/components/locales/de.jsx
index 7d32824f1..882c31fa7 100644
--- a/app/assets/javascripts/components/locales/de.jsx
+++ b/app/assets/javascripts/components/locales/de.jsx
@@ -39,7 +39,7 @@ const en = {
   "tabs_bar.public": "Gesamtes Netz",
   "tabs_bar.notifications": "Mitteilungen",
   "compose_form.placeholder": "Worüber möchstest du schreiben?",
-  "compose_form.publish": "Veröffentlichen",
+  "compose_form.publish": "Tröt",
   "compose_form.sensitive": "Medien als sensitiv markieren",
   "compose_form.unlisted": "Öffentlich nicht auflisten",
   "compose_form.private": "Als privat markieren",
diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx
index ac1c1a7d5..53e2898eb 100644
--- a/app/assets/javascripts/components/locales/en.jsx
+++ b/app/assets/javascripts/components/locales/en.jsx
@@ -2,7 +2,7 @@ const en = {
   "column_back_button.label": "Back",
   "lightbox.close": "Close",
   "loading_indicator.label": "Loading...",
-  "status.mention": "Mention",
+  "status.mention": "Mention @{name}",
   "status.delete": "Delete",
   "status.reply": "Reply",
   "status.reblog": "Boost",
@@ -11,11 +11,11 @@ const en = {
   "status.sensitive_warning": "Sensitive content",
   "status.sensitive_toggle": "Click to view",
   "video_player.toggle_sound": "Toggle sound",
-  "account.mention": "Mention",
+  "account.mention": "Mention @{name}",
   "account.edit_profile": "Edit profile",
-  "account.unblock": "Unblock",
+  "account.unblock": "Unblock @{name}",
   "account.unfollow": "Unfollow",
-  "account.block": "Block",
+  "account.block": "Block @{name}",
   "account.follow": "Follow",
   "account.posts": "Posts",
   "account.follows": "Follows",
@@ -25,26 +25,27 @@ const en = {
   "getting_started.heading": "Getting started",
   "getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.",
   "getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.",
-  "getting_started.about_developer": "The developer of this project can be followed as Gargron@mastodon.social",
-  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}.",
+  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.",
   "column.home": "Home",
-  "column.mentions": "Mentions",
-  "column.public": "Public",
+  "column.community": "Local timeline",
+  "column.public": "Federated timeline",
   "column.notifications": "Notifications",
   "tabs_bar.compose": "Compose",
   "tabs_bar.home": "Home",
   "tabs_bar.mentions": "Mentions",
-  "tabs_bar.public": "Public",
+  "tabs_bar.public": "Federated timeline",
   "tabs_bar.notifications": "Notifications",
   "compose_form.placeholder": "What is on your mind?",
   "compose_form.publish": "Toot",
   "compose_form.sensitive": "Mark media as sensitive",
   "compose_form.spoiler": "Hide text behind warning",
   "compose_form.private": "Mark as private",
-  "compose_form.unlisted": "Do not display in public timeline",
+  "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}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
+  "compose_form.unlisted": "Do not display on public timelines",
   "navigation_bar.edit_profile": "Edit profile",
   "navigation_bar.preferences": "Preferences",
-  "navigation_bar.public_timeline": "Public timeline",
+  "navigation_bar.community_timeline": "Local timeline",
+  "navigation_bar.public_timeline": "Federated timeline",
   "navigation_bar.logout": "Logout",
   "reply_indicator.cancel": "Cancel",
   "search.placeholder": "Search",
diff --git a/app/assets/javascripts/components/locales/fi.jsx b/app/assets/javascripts/components/locales/fi.jsx
new file mode 100644
index 000000000..5bef99923
--- /dev/null
+++ b/app/assets/javascripts/components/locales/fi.jsx
@@ -0,0 +1,68 @@
+const fi = {
+  "column_back_button.label": "Takaisin",
+  "lightbox.close": "Sulje",
+  "loading_indicator.label": "Ladataan...",
+  "status.mention": "Mainitse @{name}",
+  "status.delete": "Poista",
+  "status.reply": "Vastaa",
+  "status.reblog": "Boostaa",
+  "status.favourite": "Tykkää",
+  "status.reblogged_by": "{name} boostattu",
+  "status.sensitive_warning": "Arkaluontoista sisältöä",
+  "status.sensitive_toggle": "Klikkaa nähdäksesi",
+  "video_player.toggle_sound": "Äänet päälle/pois",
+  "account.mention": "Mainitse @{name}",
+  "account.edit_profile": "Muokkaa",
+  "account.unblock": "Salli @{name}",
+  "account.unfollow": "Lopeta seuraaminen",
+  "account.block": "Estä @{name}",
+  "account.follow": "Seuraa",
+  "account.posts": "Postit",
+  "account.follows": "Seuraa",
+  "account.followers": "Seuraajia",
+  "account.follows_you": "Seuraa sinua",
+  "account.requested": "Odottaa hyväksyntää",
+  "getting_started.heading": "Päästä alkuun",
+  "getting_started.about_addressing": "Voit seurata ihmisiä jos tiedät heidän käyttäjänimensä ja domainin missä he ovat syöttämällä e-mail-esque osoitteen Etsi kenttään.",
+  "getting_started.about_shortcuts": "Jos etsimäsi henkilö on samassa domainissa kuin sinä, pelkkä käyttäjänimi kelpaa. Sama pätee kun mainitset ihmisiä statuksessasi",
+  "getting_started.open_source_notice": "Mastodon Mastodon on avoimen lähdekoodin ohjelma. Voit avustaa tai raportoida ongelmia githubissa {github}. {apps}.",
+  "column.home": "Koti",
+  "column.community": "Paikallinen aikajana",
+  "column.public": "Yhdistetty aikajana",
+  "column.notifications": "Ilmoitukset",
+  "tabs_bar.compose": "Luo",
+  "tabs_bar.home": "Koti",
+  "tabs_bar.mentions": "Maininnat",
+  "tabs_bar.public": "Yleinen aikajana",
+  "tabs_bar.notifications": "Ilmoitukset",
+  "compose_form.placeholder": "Mitä sinulla on mielessä?",
+  "compose_form.publish": "Toot",
+  "compose_form.sensitive": "Merkitse media herkäksi",
+  "compose_form.spoiler": "Piiloita teksti varoituksen taakse",
+  "compose_form.private": "Merkitse yksityiseksi",
+  "compose_form.privacy_disclaimer": "Sinun yksityinen status toimitetaan mainitsemallesi käyttäjille domaineissa {domains}. Luotatko {domainsCount, plural, one {tähän palvelimeen} other {näihin palvelimiin}}? Postauksen yksityisyys toimii van Mastodon palvelimilla. Jos {domains} {domainsCount, plural, one {ei ole Mastodon palvelin} other {eivät ole Mastodon palvelin}}, viestiin ei tule Yksityinen-merkintää, ja sitä voidaan boostata tai muuten tehdä näkyväksi muille vastaanottajille.",
+  "compose_form.unlisted": "Älä näytä julkisilla aikajanoilla",
+  "navigation_bar.edit_profile": "Muokkaa profiilia",
+  "navigation_bar.preferences": "Ominaisuudet",
+  "navigation_bar.community_timeline": "Paikallinen aikajana",
+  "navigation_bar.public_timeline": "Yleinen aikajana",
+  "navigation_bar.logout": "Kirjaudu ulos",
+  "reply_indicator.cancel": "Peruuta",
+  "search.placeholder": "Hae",
+  "search.account": "Tili",
+  "search.hashtag": "Hashtag",
+  "upload_button.label": "Lisää mediaa",
+  "upload_form.undo": "Peru",
+  "notification.follow": "{name} seurasi sinua",
+  "notification.favourite": "{name} tykkäsi statuksestasi",
+  "notification.reblog": "{name} boostasi statustasi",
+  "notification.mention": "{name} mainitsi sinut",
+  "notifications.column_settings.alert": "Työpöytä ilmoitukset",
+  "notifications.column_settings.show": "Näytä sarakkeessa",
+  "notifications.column_settings.follow": "Uusia seuraajia:",
+  "notifications.column_settings.favourite": "Tykkäyksiä:",
+  "notifications.column_settings.mention": "Mainintoja:",
+  "notifications.column_settings.reblog": "Boosteja:",
+};
+
+export default fi;
diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx
index 183e5d5b5..23fa9349c 100644
--- a/app/assets/javascripts/components/locales/fr.jsx
+++ b/app/assets/javascripts/components/locales/fr.jsx
@@ -16,42 +16,76 @@ const fr = {
   "account.unblock": "Débloquer",
   "account.unfollow": "Ne plus suivre",
   "account.block": "Bloquer",
+  "account.mute": "Masquer",
+  "account.unmute": "Ne plus masquer",
   "account.follow": "Suivre",
   "account.posts": "Statuts",
   "account.follows": "Abonnements",
   "account.followers": "Abonnés",
   "account.follows_you": "Vous suit",
+  "account.requested": "Invitation envoyée",
+  "account.report": "Signaler",
+  "account.disclaimer": "Ce compte est situé sur une autre instance. Les nombres peuvent être plus grands.",
   "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_addressing": "Vous pouvez 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",
+  "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.",
   "column.home": "Accueil",
-  "column.mentions": "Mentions",
-  "column.public": "Fil public",
+  "column.community": "Fil public local",
+  "column.public": "Fil public global",
   "column.notifications": "Notifications",
+  "column.public": "Fil public",
+  "column.blocks": "Utilisateurs bloqués",
+  "column.favourites": "Favoris",
   "tabs_bar.compose": "Composer",
   "tabs_bar.home": "Accueil",
   "tabs_bar.mentions": "Mentions",
-  "tabs_bar.public": "Public",
+  "tabs_bar.public": "Fil public global",
   "tabs_bar.notifications": "Notifications",
   "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.publish": "Pouet ",
+  "compose_form.sensitive": "Marquer le média comme délicat",
+  "compose_form.spoiler": "Masquer le texte par un avertissement",
+  "compose_form.private": "Rendre privé",
+  "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 ? Les statuts privés ne fonctionnent que sur les instances de Mastodons. Si {domains} {domainsCount, plural, one {n'est pas une instance de Mastodon} other {ne sont pas des instances de Mastodon}}, il n'y aura aucune indication que votre statut est privé, et il pourrait être partagé ou rendu visible d'une autre manière à d'autres personnes imprévues",
+  "compose_form.unlisted": "Ne pas afficher dans les fils publics",
+  "emoji_button.label": "Insérer un emoji",
   "navigation_bar.edit_profile": "Modifier le profil",
   "navigation_bar.preferences": "Préférences",
-  "navigation_bar.public_timeline": "Public",
+  "navigation_bar.community_timeline": "Fil public local",
+  "navigation_bar.public_timeline": "Fil public global",
+  "navigation_bar.blocks": "Utilisateurs bloqués",
+  "navigation_bar.favourites": "Favoris",
+  "navigation_bar.info": "Plus d'informations",
+  "notification.favourite": "{name} a ajouté à ses favoris :",
   "navigation_bar.logout": "Déconnexion",
   "reply_indicator.cancel": "Annuler",
   "search.placeholder": "Chercher",
   "search.account": "Compte",
   "search.hashtag": "Mot-clé",
+  "search_results.total": "{count} {count, plural, one {résultat} other {résultats}}",
   "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 :"
+  "notification.mention": "{name} vous a mentionné⋅e :",
+  "notifications.column_settings.alert": "Notifications locales",
+  "notifications.column_settings.show": "Afficher dans la colonne",
+  "notifications.column_settings.follow": "Nouveaux abonnés :",
+  "notifications.column_settings.favourite": "Favoris :",
+  "notifications.column_settings.mention": "Mentions :",
+  "notifications.column_settings.reblog": "Partages :",
+  "privacy.public.short": "Public",
+  "privacy.public.long": "Afficher dans les fils publics",
+  "privacy.unlisted.short": "Non-listé",
+  "privacy.unlisted.long": "Ne pas afficher dans les fils publics",
+  "privacy.private.short": "Privé",
+  "privacy.private.long": "N’afficher que pour vos abonné⋅e⋅s",
+  "privacy.direct.short": "Direct",
+  "privacy.direct.long": "N’afficher que pour les personnes mentionné⋅e⋅s",
+  "privacy.change": "Ajuster la confidentialité du message",
 };
 
 export default fr;
diff --git a/app/assets/javascripts/components/locales/index.jsx b/app/assets/javascripts/components/locales/index.jsx
index 203929d66..72b8a5df5 100644
--- a/app/assets/javascripts/components/locales/index.jsx
+++ b/app/assets/javascripts/components/locales/index.jsx
@@ -5,6 +5,7 @@ import hu from './hu';
 import fr from './fr';
 import pt from './pt';
 import uk from './uk';
+import fi from './fi';
 
 const locales = {
   en,
@@ -13,7 +14,8 @@ const locales = {
   hu,
   fr,
   pt,
-  uk
+  uk,
+  fi
 };
 
 export default function getMessagesForLocale (locale) {
diff --git a/app/assets/javascripts/components/middleware/errors.jsx b/app/assets/javascripts/components/middleware/errors.jsx
index 74d77f0f9..4aca75f1e 100644
--- a/app/assets/javascripts/components/middleware/errors.jsx
+++ b/app/assets/javascripts/components/middleware/errors.jsx
@@ -5,7 +5,7 @@ const defaultFailSuffix = 'FAIL';
 
 export default function errorsMiddleware() {
   return ({ dispatch }) => next => action => {
-    if (action.type) {
+    if (action.type && !action.skipAlert) {
       const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');
       const isSuccess = new RegExp(`${defaultSuccessSuffix}$`, 'g');
 
diff --git a/app/assets/javascripts/components/middleware/sounds.jsx b/app/assets/javascripts/components/middleware/sounds.jsx
new file mode 100644
index 000000000..200efa3d7
--- /dev/null
+++ b/app/assets/javascripts/components/middleware/sounds.jsx
@@ -0,0 +1,22 @@
+const play = audio => {
+  if (!audio.paused) {
+    audio.pause();
+    audio.fastSeek(0);
+  }
+
+  audio.play();
+};
+
+export default function soundsMiddleware() {
+  const soundCache = {
+    boop: new Audio(['/sounds/boop.mp3'])
+  };
+
+  return ({ dispatch }) => next => (action) => {
+    if (action.meta && action.meta.sound && soundCache[action.meta.sound]) {
+      play(soundCache[action.meta.sound]);
+    }
+
+    return next(action);
+  };
+};
diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx
index f3938cee1..df9440093 100644
--- a/app/assets/javascripts/components/reducers/accounts.jsx
+++ b/app/assets/javascripts/components/reducers/accounts.jsx
@@ -33,7 +33,7 @@ import {
   STATUS_FETCH_SUCCESS,
   CONTEXT_FETCH_SUCCESS
 } from '../actions/statuses';
-import { SEARCH_SUGGESTIONS_READY } from '../actions/search';
+import { SEARCH_FETCH_SUCCESS } from '../actions/search';
 import {
   NOTIFICATIONS_UPDATE,
   NOTIFICATIONS_REFRESH_SUCCESS,
@@ -90,7 +90,6 @@ export default function accounts(state = initialState, action) {
   case REBLOGS_FETCH_SUCCESS:
   case FAVOURITES_FETCH_SUCCESS:
   case COMPOSE_SUGGESTIONS_READY:
-  case SEARCH_SUGGESTIONS_READY:
   case FOLLOW_REQUESTS_FETCH_SUCCESS:
   case FOLLOW_REQUESTS_EXPAND_SUCCESS:
   case BLOCKS_FETCH_SUCCESS:
@@ -98,6 +97,7 @@ export default function accounts(state = initialState, action) {
     return normalizeAccounts(state, action.accounts);
   case NOTIFICATIONS_REFRESH_SUCCESS:
   case NOTIFICATIONS_EXPAND_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
     return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
   case TIMELINE_REFRESH_SUCCESS:
   case TIMELINE_EXPAND_SUCCESS:
diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx
index 042a2c67d..4470ad643 100644
--- a/app/assets/javascripts/components/reducers/compose.jsx
+++ b/app/assets/javascripts/components/reducers/compose.jsx
@@ -20,7 +20,8 @@ import {
   COMPOSE_SPOILERNESS_CHANGE,
   COMPOSE_SPOILER_TEXT_CHANGE,
   COMPOSE_VISIBILITY_CHANGE,
-  COMPOSE_LISTABILITY_CHANGE
+  COMPOSE_LISTABILITY_CHANGE,
+  COMPOSE_EMOJI_INSERT
 } from '../actions/compose';
 import { TIMELINE_DELETE } from '../actions/timelines';
 import { STORE_HYDRATE } from '../actions/store';
@@ -31,10 +32,10 @@ const initialState = Immutable.Map({
   sensitive: false,
   spoiler: false,
   spoiler_text: '',
-  unlisted: false,
-  private: false,
+  privacy: null,
   text: '',
-  fileDropDate: null,
+  focusDate: null,
+  preselectDate: null,
   in_reply_to: null,
   is_submitting: false,
   is_uploading: false,
@@ -65,8 +66,7 @@ function clearAll(state) {
     map.set('spoiler_text', '');
     map.set('is_submitting', false);
     map.set('in_reply_to', null);
-    map.set('unlisted', state.get('default_privacy') === 'unlisted');
-    map.set('private', state.get('default_privacy') === 'private');
+    map.set('privacy', state.get('default_privacy'));
     map.update('media_attachments', list => list.clear());
   });
 };
@@ -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);
     }
   });
 };
@@ -99,9 +99,31 @@ const insertSuggestion = (state, position, token, completion) => {
     map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
     map.set('suggestion_token', null);
     map.update('suggestions', Immutable.List(), list => list.clear());
+    map.set('focusDate', new Date());
   });
 };
 
+const insertEmoji = (state, position, emojiData) => {
+  const emoji = emojiData.shortname;
+
+  return state.withMutations(map => {
+    map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);
+    map.set('focusDate', new Date());
+  });
+};
+
+const privacyPreference = (a, b) => {
+  if (a === 'direct' || b === 'direct') {
+    return 'direct';
+  } else if (a === 'private' || b === 'private') {
+    return 'private';
+  } else if (a === 'unlisted' || b === 'unlisted') {
+    return 'unlisted';
+  } else {
+    return 'public';
+  }
+};
+
 export default function compose(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
@@ -111,28 +133,38 @@ export default function compose(state = initialState, action) {
   case COMPOSE_UNMOUNT:
     return state.set('mounted', false);
   case COMPOSE_SENSITIVITY_CHANGE:
-    return state.set('sensitive', action.checked);
+    return state.set('sensitive', !state.get('sensitive'));
   case COMPOSE_SPOILERNESS_CHANGE:
-    return (action.checked ? state : state.set('spoiler_text', '')).set('spoiler', action.checked);
+    return state.withMutations(map => {
+      map.set('spoiler_text', '');
+      map.set('spoiler', !state.get('spoiler'));
+    });
   case COMPOSE_SPOILER_TEXT_CHANGE:
     return state.set('spoiler_text', action.text);
   case COMPOSE_VISIBILITY_CHANGE:
-    return state.set('private', action.checked);
-  case COMPOSE_LISTABILITY_CHANGE:
-    return state.set('unlisted', action.checked);
+    return state.set('privacy', action.value);
   case COMPOSE_CHANGE:
     return state.set('text', action.text);
   case COMPOSE_REPLY:
     return state.withMutations(map => {
       map.set('in_reply_to', action.status.get('id'));
       map.set('text', statusToTextMentions(state, action.status));
+      map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
+      map.set('focusDate', new Date());
+      map.set('preselectDate', new Date());
+
+      if (action.status.get('spoiler_text').length > 0) {
+        map.set('spoiler', true);
+        map.set('spoiler_text', action.status.get('spoiler_text'));
+      }
     });
   case COMPOSE_REPLY_CANCEL:
     return state.withMutations(map => {
       map.set('in_reply_to', null);
       map.set('text', '');
-      map.set('unlisted', state.get('default_privacy') === 'unlisted');
-      map.set('private', state.get('default_privacy') === 'private');
+      map.set('spoiler', false);
+      map.set('spoiler_text', '');
+      map.set('privacy', state.get('default_privacy'));
     });
   case COMPOSE_SUBMIT_REQUEST:
     return state.set('is_submitting', true);
@@ -143,7 +175,6 @@ export default function compose(state = initialState, action) {
   case COMPOSE_UPLOAD_REQUEST:
     return state.withMutations(map => {
       map.set('is_uploading', true);
-      map.set('fileDropDate', new Date());
     });
   case COMPOSE_UPLOAD_SUCCESS:
     return appendMedia(state, Immutable.fromJS(action.media));
@@ -154,7 +185,7 @@ export default function compose(state = initialState, action) {
   case COMPOSE_UPLOAD_PROGRESS:
     return state.set('progress', Math.round((action.loaded / action.total) * 100));
   case COMPOSE_MENTION:
-    return state.update('text', text => `${text}@${action.account.get('acct')} `);
+    return state.update('text', text => `${text}@${action.account.get('acct')} `).set('focusDate', new Date());
   case COMPOSE_SUGGESTIONS_CLEAR:
     return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
   case COMPOSE_SUGGESTIONS_READY:
@@ -167,6 +198,8 @@ export default function compose(state = initialState, action) {
     } else {
       return state;
     }
+  case COMPOSE_EMOJI_INSERT:
+    return insertEmoji(state, action.position, action.emoji);
   default:
     return state;
   }
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/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx
index 07da65771..3566820ef 100644
--- a/app/assets/javascripts/components/reducers/modal.jsx
+++ b/app/assets/javascripts/components/reducers/modal.jsx
@@ -1,31 +1,17 @@
-import {
-  MEDIA_OPEN,
-  MODAL_CLOSE,
-  MODAL_INDEX_DECREASE,
-  MODAL_INDEX_INCREASE
-} from '../actions/modal';
+import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal';
 import Immutable from 'immutable';
 
-const initialState = Immutable.Map({
-  media: null,
-  index: 0,
-  open: false
-});
+const initialState = {
+  modalType: null,
+  modalProps: {}
+};
 
 export default function modal(state = initialState, action) {
   switch(action.type) {
-  case MEDIA_OPEN:
-    return state.withMutations(map => {
-      map.set('media', action.media);
-      map.set('index', action.index);
-      map.set('open', true);
-    });
+  case MODAL_OPEN:
+    return { modalType: action.modalType, modalProps: action.modalProps };
   case MODAL_CLOSE:
-    return state.set('open', false);
-  case MODAL_INDEX_DECREASE:
-    return state.update('index', index => Math.max(index - 1, 0));
-  case MODAL_INDEX_INCREASE:
-    return state.update('index', index => Math.min(index + 1, state.get('media').size - 1));
+    return initialState;
   default:
     return state;
   }
diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx
index 4a7af8856..1406a388a 100644
--- a/app/assets/javascripts/components/reducers/notifications.jsx
+++ b/app/assets/javascripts/components/reducers/notifications.jsx
@@ -6,7 +6,8 @@ import {
   NOTIFICATIONS_EXPAND_REQUEST,
   NOTIFICATIONS_REFRESH_FAIL,
   NOTIFICATIONS_EXPAND_FAIL,
-  NOTIFICATIONS_CLEAR
+  NOTIFICATIONS_CLEAR,
+  NOTIFICATIONS_SCROLL_TOP
 } from '../actions/notifications';
 import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
 import Immutable from 'immutable';
@@ -14,6 +15,8 @@ import Immutable from 'immutable';
 const initialState = Immutable.Map({
   items: Immutable.List(),
   next: null,
+  top: true,
+  unread: 0,
   loaded: false,
   isLoading: true
 });
@@ -26,6 +29,10 @@ const notificationToMap = notification => Immutable.Map({
 });
 
 const normalizeNotification = (state, notification) => {
+  if (!state.get('top')) {
+    state = state.update('unread', unread => unread + 1);
+  }
+
   return state.update('items', list => list.unshift(notificationToMap(notification)));
 };
 
@@ -37,9 +44,12 @@ const normalizeNotifications = (state, notifications, next) => {
     items = items.set(i, notificationToMap(n));
   });
 
+  if (state.get('next') === null) {
+    state = state.set('next', next);
+  }
+
   return state
     .update('items', list => loaded ? list.unshift(...items) : list.push(...items))
-    .set('next', next)
     .set('loaded', true)
     .set('isLoading', false);
 };
@@ -61,6 +71,14 @@ const filterNotifications = (state, relationship) => {
   return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id));
 };
 
+const updateTop = (state, top) => {
+  if (top) {
+    state = state.set('unread', 0);
+  }
+
+  return state.set('top', top);
+};
+
 export default function notifications(state = initialState, action) {
   switch(action.type) {
   case NOTIFICATIONS_REFRESH_REQUEST:
@@ -68,6 +86,8 @@ export default function notifications(state = initialState, action) {
   case NOTIFICATIONS_REFRESH_FAIL:
   case NOTIFICATIONS_EXPAND_FAIL:
     return state.set('isLoading', true);
+  case NOTIFICATIONS_SCROLL_TOP:
+    return updateTop(state, action.top);
   case NOTIFICATIONS_UPDATE:
     return normalizeNotification(state, action.notification);
   case NOTIFICATIONS_REFRESH_SUCCESS:
diff --git a/app/assets/javascripts/components/reducers/relationships.jsx b/app/assets/javascripts/components/reducers/relationships.jsx
index e4af1f028..c65c48b43 100644
--- a/app/assets/javascripts/components/reducers/relationships.jsx
+++ b/app/assets/javascripts/components/reducers/relationships.jsx
@@ -3,6 +3,8 @@ import {
   ACCOUNT_UNFOLLOW_SUCCESS,
   ACCOUNT_BLOCK_SUCCESS,
   ACCOUNT_UNBLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+  ACCOUNT_UNMUTE_SUCCESS,
   RELATIONSHIPS_FETCH_SUCCESS
 } from '../actions/accounts';
 import Immutable from 'immutable';
@@ -21,14 +23,16 @@ const initialState = Immutable.Map();
 
 export default function relationships(state = initialState, action) {
   switch(action.type) {
-    case ACCOUNT_FOLLOW_SUCCESS:
-    case ACCOUNT_UNFOLLOW_SUCCESS:
-    case ACCOUNT_BLOCK_SUCCESS:
-    case ACCOUNT_UNBLOCK_SUCCESS:
-      return normalizeRelationship(state, action.relationship);
-    case RELATIONSHIPS_FETCH_SUCCESS:
-      return normalizeRelationships(state, action.relationships);
-    default:
-      return state;
+  case ACCOUNT_FOLLOW_SUCCESS:
+  case ACCOUNT_UNFOLLOW_SUCCESS:
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_UNBLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+  case ACCOUNT_UNMUTE_SUCCESS:
+    return normalizeRelationship(state, action.relationship);
+  case RELATIONSHIPS_FETCH_SUCCESS:
+    return normalizeRelationships(state, action.relationships);
+  default:
+    return state;
   }
 };
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/javascripts/components/reducers/search.jsx b/app/assets/javascripts/components/reducers/search.jsx
index d835ef268..b3fe6c7be 100644
--- a/app/assets/javascripts/components/reducers/search.jsx
+++ b/app/assets/javascripts/components/reducers/search.jsx
@@ -1,38 +1,64 @@
 import {
   SEARCH_CHANGE,
-  SEARCH_SUGGESTIONS_READY,
-  SEARCH_RESET
+  SEARCH_CLEAR,
+  SEARCH_FETCH_SUCCESS,
+  SEARCH_SHOW
 } from '../actions/search';
+import { COMPOSE_MENTION, COMPOSE_REPLY } from '../actions/compose';
 import Immutable from 'immutable';
 
 const initialState = Immutable.Map({
   value: '',
-  loaded_value: '',
-  suggestions: []
+  submitted: false,
+  hidden: false,
+  results: Immutable.Map()
 });
 
-const normalizeSuggestions = (state, value, accounts) => {
-  let newSuggestions = [
-    {
+const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => {
+  let newSuggestions = [];
+
+  if (accounts.length > 0) {
+    newSuggestions.push({
       title: 'account',
       items: accounts.map(item => ({
         type: 'account',
         id: item.id,
         value: item.acct
       }))
+    });
+  }
+
+  if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 || hashtags.length > 0) {
+    let hashtagItems = hashtags.map(item => ({
+      type: 'hashtag',
+      id: item,
+      value: `#${item}`
+    }));
+
+    if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 && !value.startsWith('http://') && !value.startsWith('https://') && hashtags.indexOf(value) === -1) {
+      hashtagItems.unshift({
+        type: 'hashtag',
+        id: value,
+        value: `#${value}`
+      });
     }
-  ];
 
-  if (value.indexOf('@') === -1 && value.indexOf(' ') === -1) {
+    if (hashtagItems.length > 0) {
+      newSuggestions.push({
+        title: 'hashtag',
+        items: hashtagItems
+      });
+    }
+  }
+
+  if (statuses.length > 0) {
     newSuggestions.push({
-      title: 'hashtag',
-      items: [
-        {
-          type: 'hashtag',
-          id: value,
-          value: `#${value}`
-        }
-      ]
+      title: 'status',
+      items: statuses.map(item => ({
+        type: 'status',
+        id: item.id,
+        value: item.id
+      }))
     });
   }
 
@@ -44,17 +70,27 @@ const normalizeSuggestions = (state, value, accounts) => {
 
 export default function search(state = initialState, action) {
   switch(action.type) {
-    case SEARCH_CHANGE:
-      return state.set('value', action.value);
-    case SEARCH_SUGGESTIONS_READY:
-      return normalizeSuggestions(state, action.value, action.accounts);
-    case SEARCH_RESET:
-      return state.withMutations(map => {
-        map.set('suggestions', []);
-        map.set('value', '');
-        map.set('loaded_value', '');
-      });
-    default:
-      return state;
+  case SEARCH_CHANGE:
+    return state.set('value', action.value);
+  case SEARCH_CLEAR:
+    return state.withMutations(map => {
+      map.set('value', '');
+      map.set('results', Immutable.Map());
+      map.set('submitted', false);
+      map.set('hidden', false);
+    });
+  case SEARCH_SHOW:
+    return state.set('hidden', false);
+  case COMPOSE_REPLY:
+  case COMPOSE_MENTION:
+    return state.set('hidden', true);
+  case SEARCH_FETCH_SUCCESS:
+    return state.set('results', Immutable.Map({
+      accounts: Immutable.List(action.results.accounts.map(item => item.id)),
+      statuses: Immutable.List(action.results.statuses.map(item => item.id)),
+      hashtags: Immutable.List(action.results.hashtags)
+    })).set('submitted', true);
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx
index 6323e0fbe..ca8fa7a01 100644
--- a/app/assets/javascripts/components/reducers/statuses.jsx
+++ b/app/assets/javascripts/components/reducers/statuses.jsx
@@ -32,6 +32,7 @@ import {
   FAVOURITED_STATUSES_FETCH_SUCCESS,
   FAVOURITED_STATUSES_EXPAND_SUCCESS
 } from '../actions/favourites';
+import { SEARCH_FETCH_SUCCESS } from '../actions/search';
 import Immutable from 'immutable';
 
 const normalizeStatus = (state, status) => {
@@ -39,14 +40,15 @@ const normalizeStatus = (state, status) => {
     return state;
   }
 
-  status.account = status.account.id;
+  const normalStatus   = { ...status };
+  normalStatus.account = status.account.id;
 
   if (status.reblog && status.reblog.id) {
-    state         = normalizeStatus(state, status.reblog);
-    status.reblog = status.reblog.id;
+    state               = normalizeStatus(state, status.reblog);
+    normalStatus.reblog = status.reblog.id;
   }
 
-  return state.update(status.id, Immutable.Map(), map => map.mergeDeep(Immutable.fromJS(status)));
+  return state.update(status.id, Immutable.Map(), map => map.mergeDeep(Immutable.fromJS(normalStatus)));
 };
 
 const normalizeStatuses = (state, statuses) => {
@@ -107,6 +109,7 @@ export default function statuses(state = initialState, action) {
   case NOTIFICATIONS_EXPAND_SUCCESS:
   case FAVOURITED_STATUSES_FETCH_SUCCESS:
   case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
     return normalizeStatuses(state, action.statuses);
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.references);
diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx
index 6f2d26dcb..675a52759 100644
--- a/app/assets/javascripts/components/reducers/timelines.jsx
+++ b/app/assets/javascripts/components/reducers/timelines.jsx
@@ -7,7 +7,9 @@ import {
   TIMELINE_EXPAND_SUCCESS,
   TIMELINE_EXPAND_REQUEST,
   TIMELINE_EXPAND_FAIL,
-  TIMELINE_SCROLL_TOP
+  TIMELINE_SCROLL_TOP,
+  TIMELINE_CONNECT,
+  TIMELINE_DISCONNECT
 } from '../actions/timelines';
 import {
   REBLOG_SUCCESS,
@@ -22,7 +24,8 @@ import {
   ACCOUNT_TIMELINE_EXPAND_REQUEST,
   ACCOUNT_TIMELINE_EXPAND_SUCCESS,
   ACCOUNT_TIMELINE_EXPAND_FAIL,
-  ACCOUNT_BLOCK_SUCCESS
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS
 } from '../actions/accounts';
 import {
   CONTEXT_FETCH_SUCCESS
@@ -31,31 +34,47 @@ import Immutable from 'immutable';
 
 const initialState = Immutable.Map({
   home: Immutable.Map({
+    path: () => '/api/v1/timelines/home',
+    next: null,
     isLoading: false,
+    online: false,
     loaded: false,
     top: true,
+    unread: 0,
     items: Immutable.List()
   }),
 
-  mentions: Immutable.Map({
+  public: Immutable.Map({
+    path: () => '/api/v1/timelines/public',
+    next: null,
     isLoading: false,
+    online: false,
     loaded: false,
     top: true,
+    unread: 0,
     items: Immutable.List()
   }),
 
-  public: Immutable.Map({
+  community: Immutable.Map({
+    path: () => '/api/v1/timelines/public',
+    next: null,
+    params: { local: true },
     isLoading: false,
+    online: false,
     loaded: false,
     top: true,
+    unread: 0,
     items: Immutable.List()
   }),
 
   tag: Immutable.Map({
+    path: (id) => `/api/v1/timelines/tag/${id}`,
+    next: null,
     isLoading: false,
     id: null,
     loaded: false,
     top: true,
+    unread: 0,
     items: Immutable.List()
   }),
 
@@ -81,7 +100,7 @@ const normalizeStatus = (state, status) => {
   return state;
 };
 
-const normalizeTimeline = (state, timeline, statuses, replace = false) => {
+const normalizeTimeline = (state, timeline, statuses, next) => {
   let ids      = Immutable.List();
   const loaded = state.getIn([timeline, 'loaded']);
 
@@ -93,10 +112,14 @@ const normalizeTimeline = (state, timeline, statuses, replace = false) => {
   state = state.setIn([timeline, 'loaded'], true);
   state = state.setIn([timeline, 'isLoading'], false);
 
+  if (state.getIn([timeline, 'next']) === null) {
+    state = state.setIn([timeline, 'next'], next);
+  }
+
   return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? list.unshift(...ids) : ids));
 };
 
-const appendNormalizedTimeline = (state, timeline, statuses) => {
+const appendNormalizedTimeline = (state, timeline, statuses, next) => {
   let moreIds = Immutable.List();
 
   statuses.forEach((status, i) => {
@@ -105,6 +128,7 @@ const appendNormalizedTimeline = (state, timeline, statuses) => {
   });
 
   state = state.setIn([timeline, 'isLoading'], false);
+  state = state.setIn([timeline, 'next'], next);
 
   return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds));
 };
@@ -141,6 +165,10 @@ const updateTimeline = (state, timeline, status, references) => {
 
   state = normalizeStatus(state, status);
 
+  if (!top) {
+    state = state.updateIn([timeline, 'unread'], unread => unread + 1);
+  }
+
   state = state.updateIn([timeline, 'items'], Immutable.List(), list => {
     if (top && list.size > 40) {
       list = list.take(20);
@@ -169,7 +197,7 @@ const deleteStatus = (state, id, accountId, references, reblogOf) => {
   }
 
   // Remove references from timelines
-  ['home', 'mentions', 'public', 'tag'].forEach(function (timeline) {
+  ['home', 'public', 'community', 'tag'].forEach(function (timeline) {
     state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
   });
 
@@ -221,11 +249,13 @@ const normalizeContext = (state, id, ancestors, descendants) => {
 };
 
 const resetTimeline = (state, timeline, id) => {
-  if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) {
+  if (timeline === 'tag' && typeof id !== 'undefined' && state.getIn([timeline, 'id']) !== id) {
     state = state.update(timeline, map => map
         .set('id', id)
         .set('isLoading', true)
         .set('loaded', false)
+        .set('next', null)
+        .set('top', true)
         .update('items', list => list.clear()));
   } else {
     state = state.setIn([timeline, 'isLoading'], true);
@@ -234,6 +264,14 @@ const resetTimeline = (state, timeline, id) => {
   return state;
 };
 
+const updateTop = (state, timeline, top) => {
+  if (top) {
+    state = state.setIn([timeline, 'unread'], 0);
+  }
+
+  return state.setIn([timeline, 'top'], top);
+};
+
 export default function timelines(state = initialState, action) {
   switch(action.type) {
   case TIMELINE_REFRESH_REQUEST:
@@ -243,9 +281,9 @@ export default function timelines(state = initialState, action) {
   case TIMELINE_EXPAND_FAIL:
     return state.setIn([action.timeline, 'isLoading'], false);
   case TIMELINE_REFRESH_SUCCESS:
-    return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
+    return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next);
   case TIMELINE_EXPAND_SUCCESS:
-    return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
+    return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next);
   case TIMELINE_UPDATE:
     return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
   case TIMELINE_DELETE:
@@ -263,9 +301,14 @@ export default function timelines(state = initialState, action) {
   case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
     return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
   case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
     return filterTimelines(state, action.relationship, action.statuses);
   case TIMELINE_SCROLL_TOP:
-    return state.setIn([action.timeline, 'top'], action.top);
+    return updateTop(state, action.timeline, action.top);
+  case TIMELINE_CONNECT:
+    return state.setIn([action.timeline, 'online'], true);
+  case TIMELINE_DISCONNECT:
+    return state.setIn([action.timeline, 'online'], false);
   default:
     return state;
   }
diff --git a/app/assets/javascripts/components/rtl.jsx b/app/assets/javascripts/components/rtl.jsx
new file mode 100644
index 000000000..8f14bb338
--- /dev/null
+++ b/app/assets/javascripts/components/rtl.jsx
@@ -0,0 +1,27 @@
+// U+0590  to U+05FF  - Hebrew
+// U+0600  to U+06FF  - Arabic
+// U+0700  to U+074F  - Syriac
+// U+0750  to U+077F  - Arabic Supplement
+// U+0780  to U+07BF  - Thaana
+// U+07C0  to U+07FF  - N'Ko
+// U+0800  to U+083F  - Samaritan
+// U+08A0  to U+08FF  - Arabic Extended-A
+// U+FB1D  to U+FB4F  - Hebrew presentation forms
+// U+FB50  to U+FDFF  - Arabic presentation forms A
+// U+FE70  to U+FEFF  - Arabic presentation forms B
+
+const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
+
+export function isRtl(text) {
+  if (text.length === 0) {
+    return false;
+  }
+
+  const matches = text.match(rtlChars);
+
+  if (!matches) {
+    return false;
+  }
+
+  return matches.length / text.trim().length > 0.3;
+};
diff --git a/app/assets/javascripts/components/selectors/index.jsx b/app/assets/javascripts/components/selectors/index.jsx
index 20debe604..01a6cb264 100644
--- a/app/assets/javascripts/components/selectors/index.jsx
+++ b/app/assets/javascripts/components/selectors/index.jsx
@@ -1,11 +1,11 @@
-import { createSelector } from 'reselect'
+import { createSelector } from 'reselect';
 import Immutable from 'immutable';
 
 const getStatuses = state => state.get('statuses');
 const getAccounts = state => state.get('accounts');
 
 const getAccountBase         = (state, id) => state.getIn(['accounts', id], null);
-const getAccountRelationship = (state, id) => state.getIn(['relationships', id]);
+const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null);
 
 export const makeGetAccount = () => {
   return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {
@@ -17,37 +17,32 @@ export const makeGetAccount = () => {
   });
 };
 
-const getStatusBase = (state, id) => state.getIn(['statuses', id], null);
-
 export const makeGetStatus = () => {
-  return createSelector([getStatusBase, getStatuses, getAccounts], (base, statuses, accounts) => {
-    if (base === null) {
-      return null;
+  return createSelector(
+    [
+      (state, id) => state.getIn(['statuses', id]),
+      (state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
+      (state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
+      (state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
+    ],
+
+    (statusBase, statusReblog, accountBase, accountReblog) => {
+      if (!statusBase) {
+        return null;
+      }
+
+      if (statusReblog) {
+        statusReblog = statusReblog.set('account', accountReblog);
+      } else {
+        statusReblog = null;
+      }
+
+      return statusBase.withMutations(map => {
+        map.set('reblog', statusReblog);
+        map.set('account', accountBase);
+      });
     }
-
-    return assembleStatus(base.get('id'), statuses, accounts);
-  });
-};
-
-const assembleStatus = (id, statuses, accounts) => {
-  let status = statuses.get(id, null);
-  let reblog = null;
-
-  if (status === null) {
-    return null;
-  }
-
-  if (status.get('reblog', null) !== null) {
-    reblog = statuses.get(status.get('reblog'), null);
-
-    if (reblog !== null) {
-      reblog = reblog.set('account', accounts.get(reblog.get('account')));
-    } else {
-      return null;
-    }
-  }
-
-  return status.set('reblog', reblog).set('account', accounts.get(status.get('account')));
+  );
 };
 
 const getAlertsBase = state => state.get('alerts');
diff --git a/app/assets/javascripts/components/store/configureStore.jsx b/app/assets/javascripts/components/store/configureStore.jsx
index ad0427b52..a92d756f5 100644
--- a/app/assets/javascripts/components/store/configureStore.jsx
+++ b/app/assets/javascripts/components/store/configureStore.jsx
@@ -3,21 +3,14 @@ import thunk from 'redux-thunk';
 import appReducer from '../reducers';
 import loadingBarMiddleware from '../middleware/loading_bar';
 import errorsMiddleware from '../middleware/errors';
-import soundsMiddleware from 'redux-sounds';
-import Howler from 'howler';
+import soundsMiddleware from '../middleware/sounds';
 import Immutable from 'immutable';
 
-Howler.mobileAutoEnable = false;
-
-const soundsData = {
-  boop: '/sounds/boop.mp3'
-};
-
 export default function configureStore() {
   return createStore(appReducer, compose(applyMiddleware(
     thunk,
     loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
     errorsMiddleware(),
-    soundsMiddleware(soundsData)
+    soundsMiddleware()
   ), window.devToolsExtension ? window.devToolsExtension() : f => f));
 };
diff --git a/app/assets/javascripts/extras.jsx b/app/assets/javascripts/extras.jsx
index 5738863dd..c13feceff 100644
--- a/app/assets/javascripts/extras.jsx
+++ b/app/assets/javascripts/extras.jsx
@@ -24,4 +24,17 @@ $(() => {
       window.location.href = $(e.target).attr('href');
     }
   });
+
+  $('.status__content__spoiler-link').on('click', e => {
+    e.preventDefault();
+    const contentEl = $(e.target).parent().parent().find('div');
+
+    if (contentEl.is(':visible')) {
+      contentEl.hide();
+      $(e.target).parent().attr('style', 'margin-bottom: 0');
+    } else {
+      contentEl.show();
+      $(e.target).parent().attr('style', null);
+    }
+  });
 });
diff --git a/app/assets/stylesheets/about.scss b/app/assets/stylesheets/about.scss
index f29090f1a..c9d9dc5d5 100644
--- a/app/assets/stylesheets/about.scss
+++ b/app/assets/stylesheets/about.scss
@@ -95,6 +95,7 @@
 
   .actions {
     overflow: hidden;
+    margin-bottom: 20px;
 
     .info {
       float: right;
@@ -108,10 +109,18 @@
     }
   }
 
-  @media screen and (max-width: 360px) {
+  @media screen and (max-width: 625px) {
     .wrapper {
       padding: 20px;
     }
+
+    .screenshot-with-signup .mascot {
+      display: none;
+    }
+
+    .features-list {
+      display: block;
+    }
   }
 }
 
@@ -273,3 +282,69 @@
     }
   }
 }
+
+.features-list {
+  display: flex;
+  margin-bottom: 20px;
+
+  .features-list__column {
+    flex: 1 1 0;
+
+    ul {
+      list-style: none;
+    }
+
+    li {
+      margin: 0;
+    }
+  }
+}
+
+.screenshot-with-signup {
+  display: flex;
+  margin-bottom: 20px;
+
+  .mascot {
+    flex: 1 1 auto;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex-direction: column;
+
+    img {
+      display: block;
+      margin: 0 auto;
+      max-width: 100%;
+      height: auto;
+    }
+  }
+
+  .simple_form, .closed-registrations-message {
+    width: 300px;
+    flex: 0 0 auto;
+    background: rgba(darken($color1, 7%), 0.5);
+    padding: 14px;
+    border-radius: 4px;
+    box-shadow: 0 0 15px rgba($color8, 0.4);
+
+    .actions {
+      margin-bottom: 0;
+    }
+
+    .info {
+      text-align: center;
+
+      a {
+        color: $color2;
+      }
+    }
+  }
+}
+
+.closed-registrations-message {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+}
diff --git a/app/assets/stylesheets/accounts.scss b/app/assets/stylesheets/accounts.scss
index 7c48c91f3..25e24a95a 100644
--- a/app/assets/stylesheets/accounts.scss
+++ b/app/assets/stylesheets/accounts.scss
@@ -311,6 +311,7 @@
       padding: 10px;
       padding-top: 15px;
       color: $color3;
+      word-wrap: break-word;
     }
   }
 }
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/application.scss b/app/assets/stylesheets/application.scss
index c4c876e30..ba16d4a21 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -256,6 +256,53 @@ button:focus {
   }
 }
 
+.compact-header {
+  h1 {
+    font-size: 24px;
+    line-height: 28px;
+    color: $color3;
+    overflow: hidden;
+    font-weight: 500;
+    margin-bottom: 20px;
+
+    a {
+      color: inherit;
+      text-decoration: none;
+    }
+
+    small {
+      font-weight: 400;
+      color: $color2;
+    }
+
+    img {
+      display: inline-block;
+      margin-bottom: -5px;
+      margin-right: 15px;
+      width: 36px;
+      height: 36px;
+    }
+  }
+}
+
+.landing-strip {
+  background: rgba(darken($color1, 7%), 0.8);
+  color: $color3;
+  font-weight: 400;
+  padding: 14px;
+  border-radius: 4px;
+  margin-bottom: 20px;
+
+  strong, a {
+    font-weight: 500;
+  }
+
+  a {
+    color: inherit;
+    text-decoration: underline;
+  }
+}
+
 @import 'forms';
 @import 'accounts';
 @import 'stream_entries';
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index 6bb683f17..d233b3471 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -1,3 +1,5 @@
+@import 'variables';
+
 .button {
   background-color: darken($color4, 3%);
   font-family: inherit;
@@ -17,9 +19,11 @@
   line-height: 36px;
   border-radius: 4px;
   text-decoration: none;
+  transition: all 100ms ease-in;
 
-  &:hover {
+  &:hover, &:active, &:focus {
     background-color: lighten($color4, 7%);
+    transition: all 200ms ease-out;
   }
 
   &:disabled {
@@ -34,6 +38,7 @@
 
 .column-icon {
   color: $color3;
+  background: lighten($color1, 4%);
 
   &:hover {
     color: lighten($color3, 7%);
@@ -41,13 +46,17 @@
 }
 
 .icon-button {
+  display: inline-block;
+  padding: 0;
   color: lighten($color1, 26%);
   border: none;
   background: transparent;
   cursor: pointer;
+  transition: all 100ms ease-in;
 
-  &:hover {
+  &:hover, &:active, &:focus {
     color: lighten($color1, 33%);
+    transition: all 200ms ease-out;
   }
 
   &.disabled {
@@ -58,6 +67,69 @@
   &.active {
     color: $color4;
   }
+
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner, &:focus, &:active {
+    outline: 0 !important;
+  }
+
+  &.inverted {
+    color: lighten($color1, 33%);
+
+    &:hover, &:active, &:focus {
+      color: lighten($color1, 26%);
+    }
+
+    &.active {
+      color: $color4;
+    }
+
+    &.disabled {
+      color: $color3;
+    }
+  }
+}
+
+.text-icon-button {
+  color: lighten($color1, 33%);
+  border: none;
+  background: transparent;
+  cursor: pointer;
+  font-weight: 600;
+  font-size: 11px;
+  padding: 0 3px;
+  line-height: 27px;
+  outline: 0;
+  transition: all 100ms ease-in;
+
+  &:hover, &:active, &:focus {
+    color: lighten($color1, 26%);
+    transition: all 200ms ease-out;
+  }
+
+  &.disabled {
+    color: lighten($color1, 13%);
+    cursor: default;
+  }
+
+  &.active {
+    color: $color4;
+  }
+
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner, &:focus, &:active {
+    outline: 0 !important;
+  }
+}
+
+.dropdown--active .icon-button {
+  color: $color4;
 }
 
 .invisible {
@@ -77,6 +149,42 @@
   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__modifiers {
+  color: $color1;
+  font-family: inherit;
+  font-size: 14px;
+  background: $color5;
+  border-radius: 0 0 4px 0;
+}
+
+.compose-form__buttons {
+  padding: 10px;
+  background: darken($color5, 8%);
+  box-shadow: inset 0 5px 5px rgba($color8, 0.05);
+  border-radius: 0 0 4px 4px;
+  display: flex;
+
+  .icon-button {
+    box-sizing: content-box;
+    padding: 0 3px;
+  }
+}
+
 .compose-form__label {
   display: block;
   line-height: 24px;
@@ -118,6 +226,9 @@
 }
 
 .reply-indicator {
+  border-radius: 4px 4px 0 0;
+  position: relative;
+  bottom: -2px;
   background: $color3;
   padding: 10px;
 
@@ -187,7 +298,7 @@
 a.status__content__spoiler-link {
   display: inline-block;
   border-radius: 2px;
-  color: lighten($color1, 6%);
+  color: lighten($color1, 8%);
   font-weight: 500;
   font-size: 11px;
   padding: 0px 6px;
@@ -200,7 +311,7 @@ a.status__content__spoiler-link {
   padding-left: 68px;
   position: relative;
   min-height: 48px;
-  border-bottom: 1px solid lighten($color1, 6%);
+  border-bottom: 1px solid lighten($color1, 8%);
   cursor: default;
 
   .status__relative-time {
@@ -212,6 +323,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%);
@@ -226,6 +345,8 @@ a.status__content__spoiler-link {
 }
 
 .detailed-status {
+  background: lighten($color1, 4%);
+
   .status__content {
     font-size: 19px;
     line-height: 24px;
@@ -237,12 +358,19 @@ a.status__content__spoiler-link {
   }
 }
 
+.detailed-status__meta {
+  margin-top: 15px;
+  color: lighten($color1, 26%);
+  font-size: 14px;
+  line-height: 18px;
+}
+
 .detailed-status__action-bar {
   background: lighten($color1, 4%);
   display: flex;
   flex-direction: row;
-  border-top: 1px solid lighten($color1, 6%);
-  border-bottom: 1px solid lighten($color1, 6%);
+  border-top: 1px solid lighten($color1, 8%);
+  border-bottom: 1px solid lighten($color1, 8%);
   padding: 10px 0;
 }
 
@@ -257,7 +385,7 @@ a.status__content__spoiler-link {
 
 .account {
   padding: 10px;
-  border-bottom: 1px solid lighten($color1, 6%);
+  border-bottom: 1px solid lighten($color1, 8%);
 
   .account__display-name {
     flex: 1 1 auto;
@@ -296,8 +424,10 @@ a.status__content__spoiler-link {
 
 .account__header__content {
   word-wrap: break-word;
+  word-break: normal;
   font-weight: 400;
   overflow: hidden;
+  color: $color3;
 
   p {
     margin-bottom: 20px;
@@ -325,8 +455,8 @@ a.status__content__spoiler-link {
 }
 
 .account__action-bar {
-  border-top: 1px solid lighten($color1, 6%);
-  border-bottom: 1px solid lighten($color1, 6%);
+  border-top: 1px solid lighten($color1, 8%);
+  border-bottom: 1px solid lighten($color1, 8%);
   line-height: 36px;
   overflow: hidden;
   flex: 0 0 auto;
@@ -337,7 +467,7 @@ a.status__content__spoiler-link {
   text-decoration: none;
   overflow: hidden;
   width: 80px;
-  border-left: 1px solid lighten($color1, 6%);
+  border-left: 1px solid lighten($color1, 8%);
   padding: 10px 5px;
 
   & > span {
@@ -353,6 +483,10 @@ a.status__content__spoiler-link {
     font-weight: 500;
     color: $color5;
   }
+
+  abbr {
+    color: lighten($color1, 26%);
+  }
 }
 
 .status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .detailed-status__application, .account__display-name {
@@ -412,8 +546,9 @@ a.status__content__spoiler-link {
     opacity: 0.5;
   }
 
-  .status__content__spoiler-link {
+  a.status__content__spoiler-link {
     background: lighten($color1, 26%);
+    color: lighten($color1, 4%);
 
     &:hover {
       background: lighten($color1, 29%);
@@ -422,6 +557,20 @@ a.status__content__spoiler-link {
   }
 }
 
+.notification__message {
+  margin-left: 68px;
+  padding: 8px 0;
+  padding-bottom: 0;
+  cursor: default;
+  color: $color3;
+  font-size: 15px;
+  position: relative;
+
+  .fa {
+    color: $color4;
+  }
+}
+
 .notification__display-name {
   color: inherit;
   text-decoration: none;
@@ -467,6 +616,12 @@ a.status__content__spoiler-link {
   position: absolute;
 }
 
+.dropdown__sep {
+  border-bottom: 1px solid darken($color2, 8%);
+  margin: 5px 7px 6px;
+  padding-top: 1px;
+}
+
 .dropdown--active .dropdown__content {
   display: block;
   z-index: 9999;
@@ -484,23 +639,44 @@ a.status__content__spoiler-link {
     left: 8px;
   }
 
-  ul {
+  & > ul {
     list-style: none;
     background: $color2;
     padding: 4px 0;
     border-radius: 4px;
     box-shadow: 0 0 15px rgba($color8, 0.4);
-    min-width: 100px;
+    min-width: 140px;
+    position: relative;
+    left: -10px;
   }
 
-  a {
+  &.dropdown__left {
+    & > ul {
+      left: -98px;
+    }
+
+    & > .emoji-dialog {
+      left: -249px;
+    }
+  }
+
+  & > ul > li > a {
     font-size: 13px;
+    line-height: 18px;
     display: block;
-    padding: 6px 16px;
-    width: 100px;
+    padding: 4px 14px;
+    box-sizing: border-box;
+    width: 140px;
     text-decoration: none;
     background: $color2;
     color: $color1;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+
+    &:focus {
+      outline: 0;
+    }
 
     &:hover {
       background: $color4;
@@ -557,7 +733,7 @@ a.status__content__spoiler-link {
 }
 
 .drawer {
-  width: 280px;
+  width: 300px;
   box-sizing: border-box;
   display: flex;
   flex-direction: column;
@@ -589,14 +765,32 @@ a.status__content__spoiler-link {
   }
 }
 
+.drawer__pager {
+  box-sizing: border-box;
+  padding: 0;
+  flex-grow: 1;
+  position: relative;
+  overflow: hidden;
+  display: flex;
+}
+
 .drawer__inner {
-  background: linear-gradient(rgba(lighten($color1, 13%), 1), rgba(lighten($color1, 13%), 0.65));
+  position: absolute;
+  top: 0;
+  left: 0;
+  background: lighten($color1, 13%);
   box-sizing: border-box;
   padding: 0;
   display: flex;
   flex-direction: column;
+  overflow: hidden;
   overflow-y: auto;
-  flex-grow: 1;
+  width: 100%;
+  height: 100%;
+
+  &.darker {
+    background: $color1;
+  }
 }
 
 .drawer__header {
@@ -642,11 +836,15 @@ a.status__content__spoiler-link {
   .columns-area {
     flex-direction: column;
   }
+
+  .search__input, .autosuggest-textarea__textarea {
+    font-size: 16px;
+  }
 }
 
 .tabs-bar {
   display: flex;
-  background: lighten($color1, 6%);
+  background: lighten($color1, 8%);
   flex: 0 0 auto;
   overflow-y: auto;
 }
@@ -660,12 +858,26 @@ a.status__content__spoiler-link {
   text-align: center;
   font-size:12px;
   font-weight: 500;
-  border-bottom: 2px solid lighten($color1, 6%);
+  border-bottom: 2px solid lighten($color1, 8%);
+  transition: all 200ms linear;
+
+  .fa {
+    font-weight: 400;
+  }
 
   &.active {
     border-bottom: 2px solid $color4;
     color: $color4;
   }
+
+  &:hover, &:focus, &:active {
+    background: lighten($color1, 14%);
+    transition: all 100ms linear;
+  }
+
+  span {
+    display: none;
+  }
 }
 
 @media screen and (min-width: 360px) {
@@ -673,6 +885,22 @@ a.status__content__spoiler-link {
     margin: 10px;
     margin-bottom: 0;
   }
+
+  .search {
+    margin-bottom: 10px;
+  }
+}
+
+@media screen and (min-width: 600px) {
+  .tabs-bar__link {
+    .fa {
+      margin-right: 5px;
+    }
+
+    span {
+      display: inline;
+    }
+  }
 }
 
 @media screen and (min-width: 1025px) {
@@ -737,6 +965,7 @@ a.status__content__spoiler-link {
   flex: 0 0 auto;
   cursor: pointer;
   color: $color4;
+  z-index: 3;
 
   &:hover {
     text-decoration: underline;
@@ -850,7 +1079,8 @@ a.status__content__spoiler-link {
 }
 
 .column-link {
-  background: lighten($color1, 6%);
+  background: lighten($color1, 8%);
+  color: $color5;
 
   &:hover {
     background: lighten($color1, 11%);
@@ -868,21 +1098,28 @@ a.status__content__spoiler-link {
   resize: none;
   margin: 0;
   color: $color1;
-  padding: 7px;
+  padding: 10px;
   font-family: inherit;
   font-size: 14px;
   resize: vertical;
+  border: 0;
+  outline: 0;
 
-  border: 3px dashed transparent;
-  transition: border-color 0.3s ease;
-
-  &.file-drop {
-    border-color: darken($color5, 33%);
+  &:focus {
+    outline: 0;
   }
 }
 
+.spoiler-input__input {
+  border-radius: 4px;
+}
+
 .autosuggest-textarea__textarea {
   height: 100px;
+  background: $color5;
+  border-radius: 4px 4px 0 0;
+  padding-bottom: 0;
+  padding-right: 10px + 22px;
 }
 
 .autosuggest-textarea__suggestions {
@@ -912,11 +1149,9 @@ a.status__content__spoiler-link {
 
 .getting-started {
   box-sizing: border-box;
-  overflow-y: auto;
   padding-bottom: 235px;
-  background: image-url('mastodon-getting-started.png') no-repeat bottom left;
-  height: auto;
-  min-height: 100%;
+  background: image-url('mastodon-getting-started.png') no-repeat 0 100% local;
+  flex: 1 0 auto;
 
   p {
     color: $color2;
@@ -927,15 +1162,6 @@ a.status__content__spoiler-link {
   }
 }
 
-.dropdown__content.dropdown__left {
-  transform: translateX(-108px);
-
-  &::before {
-    right: 8px !important;
-    left: initial !important;
-  }
-}
-
 .setting-text {
   color: $color3;
   background: transparent;
@@ -968,11 +1194,40 @@ button.active i.fa-retweet {
 }
 
 .status-card {
+  display: flex;
+  cursor: pointer;
+  font-size: 14px;
+  border: 1px solid lighten($color1, 8%);
+  border-radius: 4px;
+  color: lighten($color1, 26%);
+  margin-top: 14px;
+  text-decoration: none;
+  overflow: hidden;
+
   &:hover {
-    background: lighten($color1, 6%);
+    background: lighten($color1, 8%);
   }
 }
 
+.status-card__title {
+  display: block;
+  font-weight: 500;
+  margin-bottom: 5px;
+  color: $color3;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.status-card__description {
+  color: $color3;
+}
+
+.status-card__image {
+  flex: 0 0 100px;
+  background: lighten($color1, 8%);
+}
+
 .load-more {
   display: block;
   color: lighten($color1, 26%);
@@ -981,7 +1236,7 @@ button.active i.fa-retweet {
   text-decoration: none;
 
   &:hover {
-    background: lighten($color1, 6%);
+    background: lighten($color1, 2%);
   }
 }
 
@@ -989,8 +1244,10 @@ button.active i.fa-retweet {
   text-align: center;
   font-size: 16px;
   font-weight: 500;
-  color: lighten($color1, 26%);
-  padding-top: 120px;
+  color: lighten($color1, 16%);
+  padding-top: 210px;
+  background: image-url('mastodon-not-found.png') no-repeat center -50px;
+  cursor: default;
 }
 
 .column-header {
@@ -999,15 +1256,507 @@ button.active i.fa-retweet {
   background: lighten($color1, 4%);
   flex: 0 0 auto;
   cursor: pointer;
+  position: relative;
+  z-index: 2;
+
+  &.active {
+    box-shadow: 0 1px 0 rgba($color4, 0.3);
+  }
+
+  &.active .fa {
+    color: $color4;
+    text-shadow: 0 0 10px rgba($color4, 0.4);
+  }
 }
 
-.search {
+.loading-indicator {
+  color: $color2;
+}
+
+.collapsable-collapsed {
+  color: $color3;
+  background: lighten($color1, 4%);
+}
+
+.collapsable {
+  color: $color5;
+  background: lighten($color1, 8%);
+
+  &:hover {
+    color: $color5;
+    background: lighten($color1, 8%);
+  }
+}
+
+.media-spoiler {
+  background: $color8;
+  color: $color5;
+}
+
+.modal-container--preloader {
+  background: lighten($color1, 8%);
+}
+
+.account--panel {
+  background: lighten($color1, 4%);
+  border-top: 1px solid lighten($color1, 8%);
+  border-bottom: 1px solid lighten($color1, 8%);
+}
+
+.column-settings--outer {
+  background: lighten($color1, 8%);
+}
+
+.column-settings--section {
+  color: $color3;
+}
+
+.modal-container__nav {
+  color: $color5;
+}
+
+.account--follows-info {
+  color: $color5;
+}
+
+.setting-toggle {
+  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);
+  }
+}
+
+.empty-column-indicator {
+  color: lighten($color1, 20%);
+  text-align: center;
+  padding: 20px;
+  padding-top: 100px;
+  font-size: 15px;
+  font-weight: 400;
+  cursor: default;
+
+  a {
+    color: $color4;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+}
+
+.status-list__unread-indicator, .notifications__unread-indicator {
+  position: absolute;
+  top: 35px;
+  left: 0;
+  right: 0;
+  margin: 0 auto;
+  width: 60%;
+  pointer-events: none;
+  height: 28px;
+  z-index: 1;
+  background: radial-gradient(ellipse, rgba($color4, 0.23) 0%, rgba($color4, 0) 60%);
+}
+
+.emoji-dialog {
+  width: 280px;
+  height: 220px;
+  background: $color2;
+  box-sizing: border-box;
+  border-radius: 2px;
+  overflow: hidden;
+  position: relative;
+  box-shadow: 0 0 15px rgba($color8, 0.4);
+
+  .emojione {
+    margin: 0;
+  }
+
+  .emoji-dialog-header {
+    padding: 0 10px;
+    background-color: $color3;
+
+    ul {
+      padding: 0;
+      margin: 0;
+      list-style: none;
+    }
+
+    li {
+      display: inline-block;
+      box-sizing: border-box;
+      height: 42px;
+      padding: 9px 5px;
+      cursor: pointer;
+
+      img, svg {
+        width: 22px;
+        height: 22px;
+        filter: grayscale(100%);
+      }
+
+      &.active {
+        background: lighten($color3, 6%);
+
+        img, svg {
+          filter: grayscale(0);
+        }
+      }
+    }
+  }
+
+  .emoji-row {
+    box-sizing: border-box;
+    overflow-y: hidden;
+    padding-left: 10px;
+
+    .emoji {
+      display: inline-block;
+      padding: 5px;
+      border-radius: 4px;
+    }
+  }
+
+  .emoji-category-header {
+    box-sizing: border-box;
+    overflow-y: hidden;
+    padding: 8px 16px 0;
+    display: table;
+
+    > * {
+      display: table-cell;
+      vertical-align: middle;
+    }
+  }
+
+  .emoji-category-title {
+    font-size: 14px;
+    font-family: sans-serif;
+    font-weight: normal;
+    color: $color1;
+    cursor: default;
+  }
+
+  .emoji-category-heading-decoration {
+    text-align: right;
+  }
+
+  .modifiers {
+    list-style: none;
+    padding: 0;
+    margin: 0;
+    vertical-align: middle;
+    white-space: nowrap;
+    margin-top: 4px;
+
+    li {
+      display: inline-block;
+      padding: 0 2px;
+
+      &:last-of-type {
+        padding-right: 0;
+      }
+    }
+
+    .modifier {
+      display: inline-block;
+      border-radius: 10px;
+      width: 15px;
+      height: 15px;
+      position: relative;
+      cursor: pointer;
+
+      &.active:after {
+        content: "";
+        display: block;
+        position: absolute;
+        width: 7px;
+        height: 7px;
+        border-radius: 10px;
+        border: 2px solid $color1;
+        top: 2px;
+        left: 2px;
+      }
+    }
+  }
+
+  .emoji-search-wrapper {
+    padding: 6px 16px;
+  }
+
+  .emoji-search {
+    font-size: 12px;
+    padding: 6px 4px;
+    width: 100%;
+    border: 1px solid #ddd;
+    border-radius: 4px;
+  }
+
+  .emoji-categories-wrapper {
+    position: absolute;
+    top: 42px;
+    bottom: 0;
+    left: 0;
+    right: 0;
+  }
+
+  .emoji-search-wrapper + .emoji-categories-wrapper {
+    top: 83px;
+  }
+
+  .emoji-row .emoji:hover {
+    background: lighten($color2, 3%);
+  }
+
+  .emoji {
+    width: 22px;
+    height: 22px;
+    cursor: pointer;
+
+    &:focus {
+      outline: 0;
+    }
+  }
+}
+
+.autosuggest-status {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+
+  strong {
+    font-weight: 500;
+  }
+}
+
+.upload-area {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  visibility: hidden;
+  background: rgba($color8, 0.8);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  opacity: 0;
+  z-index: 2000;
+
+  * {
+    pointer-events: none;
+  }
+}
+
+.upload-area__drop {
+  width: 320px;
+  height: 160px;
+  display: flex;
+  box-sizing: border-box;
+  position: relative;
+  padding: 8px;
+}
+
+.upload-area__background {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: -1;
+  border-radius: 4px;
+  background: $color1;
+  box-shadow: 0 0 5px rgba($color8, 0.2);
+}
+
+.upload-area__content {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: $color2;
+  font-size: 18px;
+  font-weight: 500;
+  border: 2px dashed lighten($color1, 26%);
+  border-radius: 4px;
+}
+
+.upload-progress {
+  padding: 10px;
+  color: lighten($color1, 26%);
+  overflow: hidden;
+  display: flex;
+
   .fa {
-    color: $color3;
+    font-size: 34px;
+    margin-right: 10px;
+  }
+
+  span {
+    font-size: 12px;
+    text-transform: uppercase;
+    font-weight: 500;
+    display: block;
+  }
+}
+
+.upload-progress__backdrop {
+  width: 100%;
+  height: 6px;
+  border-radius: 6px;
+  background: lighten($color1, 26%);
+  position: relative;
+  margin-top: 5px;
+}
+
+.upload-progress__tracker {
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 6px;
+  background: $color4;
+  border-radius: 6px;
+}
+
+.emoji-button {
+  outline: 0;
+
+  &:active, &:focus {
+    outline: 0 !important;
+  }
+
+  img {
+    filter: grayscale(100%);
+    opacity: 0.8;
+    display: block;
+    margin: 0;
+    width: 22px;
+    height: 22px;
+    margin-top: 2px;
   }
+
+  &:hover, &:active, &:focus {
+    img {
+      opacity: 1;
+      filter: none;
+    }
+  }
+}
+
+.dropdown--active .emoji-button img {
+  opacity: 1;
+  filter: none;
+}
+
+.privacy-dropdown {
+  position: relative;
+}
+
+.privacy-dropdown__dropdown {
+  display: none;
+  position: absolute;
+  left: 0;
+  top: 27px;
+  width: 230px;
+  background: $color5;
+  border-radius: 0 4px 4px 4px;
+  z-index: 2;
+  overflow: hidden;
+}
+
+.privacy-dropdown__option {
+  color: $color1;
+  padding: 10px;
+  cursor: pointer;
+  display: flex;
+
+  &:hover, &.active {
+    background: $color4;
+    color: $color5;
+
+    .privacy-dropdown__option__content {
+      color: $color5;
+
+      strong {
+        color: $color5;
+      }
+    }
+  }
+
+  &.active:hover {
+    background: lighten($color4, 4%);
+  }
+}
+
+.privacy-dropdown__option__icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 10px;
+}
+
+.privacy-dropdown__option__content {
+  flex: 1 1 auto;
+  color: darken($color3, 24%);
+
+  strong {
+    font-weight: 500;
+    display: block;
+    color: $color1;
+  }
+}
+
+.privacy-dropdown.active {
+  .privacy-dropdown__value {
+    background: $color5;
+    border-radius: 4px 4px 0 0;
+    box-shadow: 0 -4px 4px rgba($color8, 0.1);
+  }
+
+  .privacy-dropdown__dropdown {
+    display: block;
+    box-shadow: 2px 4px 6px rgba($color8, 0.1);
+  }
+}
+
+.search {
+  position: relative;
 }
 
 .search__input {
+  padding-right: 30px;
+  color: $color2;
+  outline: 0;
   box-sizing: border-box;
   display: block;
   width: 100%;
@@ -1019,4 +1768,127 @@ button.active i.fa-retweet {
   color: $color3;
   font-size: 14px;
   margin: 0;
+
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner, &:focus, &:active {
+    outline: 0 !important;
+  }
+
+  &:focus {
+    background: lighten($color1, 4%);
+  }
+}
+
+.search__icon {
+  .fa {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    z-index: 2;
+    display: inline-block;
+    opacity: 0;
+    transition: all 100ms linear;
+    font-size: 18px;
+    width: 18px;
+    height: 18px;
+    color: $color2;
+    cursor: default;
+    pointer-events: none;
+
+    &.active {
+      pointer-events: auto;
+      opacity: 0.3;
+    }
+  }
+
+  .fa-search {
+    transform: translateZ(0) rotate(90deg);
+
+    &.active {
+      pointer-events: none;
+      transform: translateZ(0) rotate(0deg);
+    }
+  }
+
+  .fa-times-circle {
+    top: 11px;
+    transform: translateZ(0) rotate(0deg);
+    cursor: pointer;
+
+    &.active {
+      transform: translateZ(0) rotate(90deg);
+    }
+
+    &:hover {
+      color: $color5;
+    }
+  }
+}
+
+.search-results__header {
+  color: lighten($color1, 26%);
+  background: lighten($color1, 2%);
+  border-bottom: 1px solid darken($color1, 4%);
+  padding: 15px 10px;
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.search-results__hashtag {
+  display: block;
+  padding: 10px;
+  color: $color2;
+  text-decoration: none;
+
+  &:hover, &:active, &:focus {
+    color: lighten($color2, 4%);
+    text-decoration: underline;
+  }
+}
+
+.modal-root__overlay {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 9999;
+  opacity: 0;
+  background: rgba($color8, 0.7);
+}
+
+.modal-root__container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  align-content: space-around;
+  z-index: 9999;
+  opacity: 0;
+  pointer-events: none;
+  user-select: none;
+}
+
+.modal-root__modal {
+  pointer-events: auto;
+  display: flex;
+}
+
+.media-modal {
+  max-width: 80vw;
+  max-height: 80vh;
+  position: relative;
+
+  img, video {
+    max-width: 80vw;
+    max-height: 80vh;
+  }
 }
diff --git a/app/assets/stylesheets/fonts/roboto-mono.scss b/app/assets/stylesheets/fonts/roboto-mono.scss
index ca64649de..319ecb08e 100644
--- a/app/assets/stylesheets/fonts/roboto-mono.scss
+++ b/app/assets/stylesheets/fonts/roboto-mono.scss
@@ -1,3 +1,4 @@
+/*
 @font-face {
   font-family: 'Roboto Mono';
   src: font-url('roboto-mono/robotomono-bold-webfont.eot');
@@ -105,7 +106,7 @@
 }
 
 
-
+*/
 
 @font-face {
   font-family: 'Roboto Mono';
@@ -121,7 +122,7 @@
 }
 
 
-
+/*
 
 @font-face {
   font-family: 'Roboto Mono';
@@ -150,4 +151,4 @@
   font-weight: 200;
   font-style: italic;
 
-}
\ No newline at end of file
+}*/
diff --git a/app/assets/stylesheets/fonts/roboto.scss b/app/assets/stylesheets/fonts/roboto.scss
index aa91efe6d..5c0d14043 100644
--- a/app/assets/stylesheets/fonts/roboto.scss
+++ b/app/assets/stylesheets/fonts/roboto.scss
@@ -1,3 +1,4 @@
+/*
 @font-face {
 	font-family: 'Roboto';
 	src: font-url('roboto/roboto-lightitalic-webfont.eot');
@@ -8,7 +9,7 @@
 		font-url('roboto/roboto-lightitalic-webfont.svg#roboto-lightitalic-webfont') format('svg');
 	font-weight: 300;
 	font-style: italic;
-}
+}*/
 
 @font-face {
 	font-family: 'Roboto';
@@ -46,7 +47,7 @@
 	font-weight: 500;
 	font-style: normal;
 }
-
+/*
 @font-face {
 	font-family: 'Roboto';
 	src: font-url('roboto/roboto-thin-webfont.eot');
@@ -57,7 +58,7 @@
 		font-url('roboto/roboto-thin-webfont.svg#roboto-thin-webfont') format('svg');
 	font-weight: 100;
 	font-style: normal;
-}
+}*/
 
 @font-face {
 	font-family: 'Roboto';
@@ -70,7 +71,7 @@
 	font-weight: normal;
 	font-style: normal;
 }
-
+/*
 @font-face {
 	font-family: 'Roboto';
 	src: font-url('roboto/roboto-mediumitalic-webfont.eot');
@@ -141,4 +142,4 @@
 		font-url('roboto/roboto-black-webfont.svg#roboto-black-webfont') format('svg');
 	font-weight: 900;
 	font-style: normal;
-}
+}*/
diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss
index a97a767e0..ceccc14cd 100644
--- a/app/assets/stylesheets/forms.scss
+++ b/app/assets/stylesheets/forms.scss
@@ -14,13 +14,17 @@ code {
     margin-bottom: 15px;
   }
 
-  .hint {
+  span.hint {
     display: block;
     color: $color3;
     font-size: 12px;
     margin-top: 4px;
   }
 
+  p.hint {
+    margin-bottom: 15px;
+  }
+
   .label_input {
     display: flex;
 
@@ -93,6 +97,7 @@ code {
     width: 100%;
     outline: 0;
     font-family: inherit;
+    resize: vertical;
 
     &:invalid {
       box-shadow: none;
diff --git a/app/assets/stylesheets/stream_entries.scss b/app/assets/stylesheets/stream_entries.scss
index 595275527..4a6dc6aa4 100644
--- a/app/assets/stylesheets/stream_entries.scss
+++ b/app/assets/stylesheets/stream_entries.scss
@@ -3,26 +3,26 @@
   box-shadow: 0 0 15px rgba($color8, 0.2);
 
   .entry {
-    background: lighten($color2, 8%);
+    background: $color5;
 
-    &, .detailed-status.light {
+    .detailed-status.light, .status.light {
       border-bottom: 1px solid $color2;
     }
 
     &:last-child {
-      &, .detailed-status.light {
+      &, .detailed-status.light, .status.light {
         border-bottom: 0;
         border-radius: 0 0 4px 4px;
       }
     }
 
     &:first-child {
-      &, .detailed-status.light {
+      &, .detailed-status.light, .status.light {
         border-radius: 4px 4px 0 0;
       }
 
       &:last-child {
-        &, .detailed-status.light {
+        &, .detailed-status.light, .status.light {
           border-radius: 4px;
         }
       }
@@ -97,6 +97,15 @@
       a {
         color: $color4;
       }
+
+      a.status__content__spoiler-link {
+        color: $color5;
+        background: $color3;
+
+        &:hover {
+          background: lighten($color3, 8%);
+        }
+      }
     }
 
     .status__attachments {
@@ -104,8 +113,12 @@
       overflow: hidden;
       width: 100%;
       box-sizing: border-box;
-      height: 110px;
-      display: flex;
+      position: relative;
+
+      .status__attachments__inner {
+        display: flex;
+        height: 214px;
+      }
     }
   }
 
@@ -159,6 +172,15 @@
       a {
         color: $color4;
       }
+
+      a.status__content__spoiler-link {
+        color: $color5;
+        background: $color3;
+
+        &:hover {
+          background: lighten($color3, 8%);
+        }
+      }
     }
 
     .detailed-status__meta {
@@ -184,8 +206,12 @@
       overflow: hidden;
       width: 100%;
       box-sizing: border-box;
-      height: 300px;
-      display: flex;
+      position: relative;
+
+      .status__attachments__inner {
+        display: flex;
+        height: 360px;
+      }
     }
 
     .video-player {
@@ -231,11 +257,19 @@
       text-decoration: none;
       cursor: zoom-in;
     }
+
+    video {
+      position: relative;
+      z-index: 1;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      top: 50%;
+      transform: translateY(-50%);
+    }
   }
 
   .video-item {
-    max-width: 196px;
-
     a {
       cursor: pointer;
     }
@@ -258,6 +292,9 @@
     width: 100%;
     height: 100%;
     cursor: pointer;
+    position: absolute;
+    top: 0;
+    left: 0;
     display: flex;
     align-items: center;
     justify-content: center;
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb
index 491036db2..7fd43489f 100644
--- a/app/controllers/about_controller.rb
+++ b/app/controllers/about_controller.rb
@@ -4,7 +4,12 @@ class AboutController < ApplicationController
   before_action :set_body_classes
 
   def index
-    @description = Setting.site_description
+    @description                  = Setting.site_description
+    @open_registrations           = Setting.open_registrations
+    @closed_registrations_message = Setting.closed_registrations_message
+
+    @user = User.new
+    @user.build_account
   end
 
   def more
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index b837f006e..dc1aeb5ea 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -16,7 +16,7 @@ class AccountsController < ApplicationController
       end
 
       format.atom do
-        @entries = @account.stream_entries.order('id desc').where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id])
+        @entries = @account.stream_entries.order('id desc').where(activity_type: 'Status').where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id])
       end
 
       format.activitystreams2
@@ -56,6 +56,6 @@ class AccountsController < ApplicationController
   end
 
   def check_account_suspension
-    head 410 if @account.suspended?
+    gone if @account.suspended?
   end
 end
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/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb
index e362957e7..1f4432847 100644
--- a/app/controllers/admin/domain_blocks_controller.rb
+++ b/app/controllers/admin/domain_blocks_controller.rb
@@ -9,6 +9,24 @@ class Admin::DomainBlocksController < ApplicationController
     @blocks = DomainBlock.paginate(page: params[:page], per_page: 40)
   end
 
+  def new
+    @domain_block = DomainBlock.new
+  end
+
   def create
+    @domain_block = DomainBlock.new(resource_params)
+
+    if @domain_block.save
+      DomainBlockWorker.perform_async(@domain_block.id)
+      redirect_to admin_domain_blocks_path, notice: 'Domain block is now being processed'
+    else
+      render action: :new
+    end
+  end
+
+  private
+
+  def resource_params
+    params.require(:domain_block).permit(:domain, :severity)
   end
 end
diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb
new file mode 100644
index 000000000..2b3b1809f
--- /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).order('id desc').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, action_taken_by_account_id: current_account.id)
+    redirect_to admin_report_path(@report)
+  end
+
+  def suspend
+    Admin::SuspensionWorker.perform_async(@report.target_account.id)
+    Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id)
+    redirect_to admin_report_path(@report)
+  end
+
+  def silence
+    @report.target_account.update(silenced: true)
+    Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id)
+    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/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb
index af0be8823..7615c781d 100644
--- a/app/controllers/admin/settings_controller.rb
+++ b/app/controllers/admin/settings_controller.rb
@@ -11,9 +11,13 @@ class Admin::SettingsController < ApplicationController
 
   def update
     @setting = Setting.where(var: params[:id]).first_or_initialize(var: params[:id])
+    value    = settings_params[:value]
 
-    if @setting.value != params[:setting][:value]
-      @setting.value = params[:setting][:value]
+    # Special cases
+    value = value == 'true' if @setting.var == 'open_registrations'
+
+    if @setting.value != value
+      @setting.value = value
       @setting.save
     end
 
@@ -22,4 +26,10 @@ class Admin::SettingsController < ApplicationController
       format.json { respond_with_bip(@setting) }
     end
   end
+
+  private
+
+  def settings_params
+    params.require(:setting).permit(:value)
+  end
 end
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index d97010c0e..da18474cb 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -1,8 +1,8 @@
 # frozen_string_literal: true
 
 class Api::V1::AccountsController < ApiController
-  before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock]
-  before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock]
+  before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock, :mute, :unmute]
+  before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock, :mute, :unmute]
   before_action :require_user!, except: [:show, :following, :followers, :statuses]
   before_action :set_account, except: [:verify_credentials, :suggestions, :search]
 
@@ -20,7 +20,7 @@ class Api::V1::AccountsController < ApiController
     accounts  = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |f| accounts[f.target_account_id] }
 
-    set_account_counters_maps(@accounts)
+    # set_account_counters_maps(@accounts)
 
     next_path = following_api_v1_account_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = following_api_v1_account_url(since_id: results.first.id) unless results.empty?
@@ -35,7 +35,7 @@ class Api::V1::AccountsController < ApiController
     accounts  = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |f| accounts[f.account_id] }
 
-    set_account_counters_maps(@accounts)
+    # set_account_counters_maps(@accounts)
 
     next_path = followers_api_v1_account_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = followers_api_v1_account_url(since_id: results.first.id) unless results.empty?
@@ -47,12 +47,15 @@ class Api::V1::AccountsController < ApiController
 
   def statuses
     @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
+    @statuses = @statuses.where(id: MediaAttachment.where(account: @account).where.not(status_id: nil).reorder('').select('distinct status_id')) if params[:only_media]
+    @statuses = @statuses.without_replies if params[:exclude_replies]
     @statuses = cache_collection(@statuses, Status)
 
     set_maps(@statuses)
-    set_counters_maps(@statuses)
+    # set_counters_maps(@statuses)
+    # set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
-    next_path = statuses_api_v1_account_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
+    next_path = statuses_api_v1_account_url(max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -71,10 +74,17 @@ class Api::V1::AccountsController < ApiController
     @followed_by = { @account.id => false }
     @blocking    = { @account.id => true }
     @requested   = { @account.id => false }
+    @muting      = { @account.id => current_user.account.muting?(@account.id) }
 
     render action: :relationship
   end
 
+  def mute
+    MuteService.new.call(current_user.account, @account)
+    set_relationship
+    render action: :relationship
+  end
+
   def unfollow
     UnfollowService.new.call(current_user.account, @account)
     set_relationship
@@ -87,6 +97,12 @@ class Api::V1::AccountsController < ApiController
     render action: :relationship
   end
 
+  def unmute
+    UnmuteService.new.call(current_user.account, @account)
+    set_relationship
+    render action: :relationship
+  end
+
   def relationships
     ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i]
 
@@ -94,13 +110,14 @@ class Api::V1::AccountsController < ApiController
     @following   = Account.following_map(ids, current_user.account_id)
     @followed_by = Account.followed_by_map(ids, current_user.account_id)
     @blocking    = Account.blocking_map(ids, current_user.account_id)
+    @muting      = Account.muting_map(ids, current_user.account_id)
     @requested   = Account.requested_map(ids, current_user.account_id)
   end
 
   def search
-    @accounts = SearchService.new.call(params[:q], limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:resolve] == 'true')
+    @accounts = AccountSearchService.new.call(params[:q], limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:resolve] == 'true', current_account)
 
-    set_account_counters_maps(@accounts) unless @accounts.nil?
+    # set_account_counters_maps(@accounts) unless @accounts.nil?
 
     render action: :index
   end
@@ -115,6 +132,7 @@ class Api::V1::AccountsController < ApiController
     @following   = Account.following_map([@account.id], current_user.account_id)
     @followed_by = Account.followed_by_map([@account.id], current_user.account_id)
     @blocking    = Account.blocking_map([@account.id], current_user.account_id)
+    @muting      = Account.muting_map([@account.id], current_user.account_id)
     @requested   = Account.requested_map([@account.id], current_user.account_id)
   end
 end
diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb
index ca9dd0b7e..2ec7280af 100644
--- a/app/controllers/api/v1/apps_controller.rb
+++ b/app/controllers/api/v1/apps_controller.rb
@@ -4,6 +4,12 @@ class Api::V1::AppsController < ApiController
   respond_to :json
 
   def create
-    @app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes), website: params[:website])
+    @app = Doorkeeper::Application.create!(name: app_params[:client_name], redirect_uri: app_params[:redirect_uris], scopes: (app_params[:scopes] || Doorkeeper.configuration.default_scopes), website: app_params[:website])
+  end
+
+  private
+
+  def app_params
+    params.permit(:client_name, :redirect_uris, :scopes, :website)
   end
 end
diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb
index 08aefc175..dadf21265 100644
--- a/app/controllers/api/v1/blocks_controller.rb
+++ b/app/controllers/api/v1/blocks_controller.rb
@@ -11,7 +11,7 @@ class Api::V1::BlocksController < ApiController
     accounts  = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |f| accounts[f.target_account_id] }.compact
 
-    set_account_counters_maps(@accounts)
+    # set_account_counters_maps(@accounts)
 
     next_path = api_v1_blocks_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = api_v1_blocks_url(since_id: results.first.id) unless results.empty?
diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb
index ef0a4854a..8a5b81e63 100644
--- a/app/controllers/api/v1/favourites_controller.rb
+++ b/app/controllers/api/v1/favourites_controller.rb
@@ -11,7 +11,7 @@ class Api::V1::FavouritesController < ApiController
     @statuses = cache_collection(Status.where(id: results.map(&:status_id)), Status)
 
     set_maps(@statuses)
-    set_counters_maps(@statuses)
+    # set_counters_maps(@statuses)
 
     next_path = api_v1_favourites_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_STATUSES_LIMIT)
     prev_path = api_v1_favourites_url(since_id: results.first.id) unless results.empty?
diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb
index a30e97e71..3b8e8c078 100644
--- a/app/controllers/api/v1/follow_requests_controller.rb
+++ b/app/controllers/api/v1/follow_requests_controller.rb
@@ -9,7 +9,7 @@ class Api::V1::FollowRequestsController < ApiController
     accounts  = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |f| accounts[f.account_id] }
 
-    set_account_counters_maps(@accounts)
+    # set_account_counters_maps(@accounts)
 
     next_path = api_v1_follow_requests_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
     prev_path = api_v1_follow_requests_url(since_id: results.first.id) unless results.empty?
@@ -18,12 +18,12 @@ class Api::V1::FollowRequestsController < ApiController
   end
 
   def authorize
-    FollowRequest.find_by!(account_id: params[:id], target_account: current_account).authorize!
+    AuthorizeFollowService.new.call(Account.find(params[:id]), current_account)
     render_empty
   end
 
   def reject
-    FollowRequest.find_by!(account_id: params[:id], target_account: current_account).reject!
+    RejectFollowService.new.call(Account.find(params[:id]), current_account)
     render_empty
   end
 end
diff --git a/app/controllers/api/v1/follows_controller.rb b/app/controllers/api/v1/follows_controller.rb
index c22dacbaa..7c0f44f03 100644
--- a/app/controllers/api/v1/follows_controller.rb
+++ b/app/controllers/api/v1/follows_controller.rb
@@ -7,7 +7,7 @@ class Api::V1::FollowsController < ApiController
   respond_to :json
 
   def create
-    raise ActiveRecord::RecordNotFound if params[:uri].blank?
+    raise ActiveRecord::RecordNotFound if follow_params[:uri].blank?
 
     @account = FollowService.new.call(current_user.account, target_uri).try(:target_account)
     render action: :show
@@ -16,6 +16,10 @@ class Api::V1::FollowsController < ApiController
   private
 
   def target_uri
-    params[:uri].strip.gsub(/\A@/, '')
+    follow_params[:uri].strip.gsub(/\A@/, '')
+  end
+
+  def follow_params
+    params.permit(:uri)
   end
 end
diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb
new file mode 100644
index 000000000..51d92838a
--- /dev/null
+++ b/app/controllers/api/v1/instances_controller.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class Api::V1::InstancesController < ApiController
+  respond_to :json
+
+  def show; end
+end
diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb
index f8139ade7..aed3578d7 100644
--- a/app/controllers/api/v1/media_controller.rb
+++ b/app/controllers/api/v1/media_controller.rb
@@ -10,10 +10,16 @@ class Api::V1::MediaController < ApiController
   respond_to :json
 
   def create
-    @media = MediaAttachment.create!(account: current_user.account, file: params[:file])
+    @media = MediaAttachment.create!(account: current_user.account, file: media_params[:file])
   rescue Paperclip::Errors::NotIdentifiedByImageMagickError
     render json: { error: 'File type of uploaded media could not be verified' }, status: 422
   rescue Paperclip::Error
     render json: { error: 'Error processing thumbnail for uploaded media' }, status: 500
   end
+
+  private
+
+  def media_params
+    params.permit(:file)
+  end
 end
diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb
new file mode 100644
index 000000000..6f48de040
--- /dev/null
+++ b/app/controllers/api/v1/mutes_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Api::V1::MutesController < ApiController
+  before_action -> { doorkeeper_authorize! :follow }
+  before_action :require_user!
+
+  respond_to :json
+
+  def index
+    results   = Mute.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
+    accounts  = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h
+    @accounts = results.map { |f| accounts[f.target_account_id] }
+
+    # set_account_counters_maps(@accounts)
+
+    next_path = api_v1_mutes_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
+    prev_path = api_v1_mutes_url(since_id: results.first.id) unless results.empty?
+
+    set_pagination_headers(next_path, prev_path)
+  end
+end
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index 877356a75..7bbc5419c 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -14,10 +14,10 @@ class Api::V1::NotificationsController < ApiController
     statuses       = @notifications.select { |n| !n.target_status.nil? }.map(&:target_status)
 
     set_maps(statuses)
-    set_counters_maps(statuses)
-    set_account_counters_maps(@notifications.map(&:from_account))
+    # set_counters_maps(statuses)
+    # set_account_counters_maps(@notifications.map(&:from_account))
 
-    next_path = api_v1_notifications_url(max_id: @notifications.last.id)    if @notifications.size == limit_param(DEFAULT_NOTIFICATIONS_LIMIT)
+    next_path = api_v1_notifications_url(max_id: @notifications.last.id)    unless @notifications.empty?
     prev_path = api_v1_notifications_url(since_id: @notifications.first.id) unless @notifications.empty?
 
     set_pagination_headers(next_path, prev_path)
diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb
new file mode 100644
index 000000000..f83c573cb
--- /dev/null
+++ b/app/controllers/api/v1/reports_controller.rb
@@ -0,0 +1,30 @@
+# 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 = report_params[:status_ids].is_a?(Enumerable) ? report_params[:status_ids] : [report_params[:status_ids]]
+
+    @report = Report.create!(account: current_account,
+                             target_account: Account.find(report_params[:account_id]),
+                             status_ids: Status.find(status_ids).pluck(:id),
+                             comment: report_params[:comment])
+
+    render :show
+  end
+
+  private
+
+  def report_params
+    params.permit(:account_id, :comment, status_ids: [])
+  end
+end
diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb
new file mode 100644
index 000000000..6b1292458
--- /dev/null
+++ b/app/controllers/api/v1/search_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class Api::V1::SearchController < ApiController
+  respond_to :json
+
+  def index
+    @search = OpenStruct.new(SearchService.new.call(params[:q], 5, params[:resolve] == 'true', current_account))
+  end
+end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 69cbdce5d..4ece7e702 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -23,7 +23,7 @@ class Api::V1::StatusesController < ApiController
     statuses = [@status] + @context[:ancestors] + @context[:descendants]
 
     set_maps(statuses)
-    set_counters_maps(statuses)
+    # set_counters_maps(statuses)
   end
 
   def card
@@ -36,7 +36,7 @@ class Api::V1::StatusesController < ApiController
     accounts  = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |r| accounts[r.account_id] }
 
-    set_account_counters_maps(@accounts)
+    # set_account_counters_maps(@accounts)
 
     next_path = reblogged_by_api_v1_status_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = reblogged_by_api_v1_status_url(since_id: results.first.id) unless results.empty?
@@ -51,7 +51,7 @@ class Api::V1::StatusesController < ApiController
     accounts  = Account.where(id: results.map(&:account_id)).map { |a| [a.id, a] }.to_h
     @accounts = results.map { |f| accounts[f.account_id] }
 
-    set_account_counters_maps(@accounts)
+    # set_account_counters_maps(@accounts)
 
     next_path = favourited_by_api_v1_status_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
     prev_path = favourited_by_api_v1_status_url(since_id: results.first.id) unless results.empty?
@@ -62,12 +62,11 @@ class Api::V1::StatusesController < ApiController
   end
 
   def create
-    @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids],
-                                                                                                                                                             sensitive: params[:sensitive],
-                                                                                                                                                             spoiler_text: params[:spoiler_text],
-                                                                                                                                                             visibility: params[:visibility],
-                                                                                                                                                             application: doorkeeper_token.application)
-
+    @status = PostStatusService.new.call(current_user.account, status_params[:status], status_params[:in_reply_to_id].blank? ? nil : Status.find(status_params[:in_reply_to_id]), media_ids: status_params[:media_ids],
+                                                                                                                                                                                  sensitive: status_params[:sensitive],
+                                                                                                                                                                                  spoiler_text: status_params[:spoiler_text],
+                                                                                                                                                                                  visibility: status_params[:visibility],
+                                                                                                                                                                                  application: doorkeeper_token.application)
     render action: :show
   end
 
@@ -112,4 +111,8 @@ class Api::V1::StatusesController < ApiController
     @status = Status.find(params[:id])
     raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account)
   end
+
+  def status_params
+    params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, media_ids: [])
+  end
 end
diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb
index a8cc2b288..0446b9e4d 100644
--- a/app/controllers/api/v1/timelines_controller.rb
+++ b/app/controllers/api/v1/timelines_controller.rb
@@ -11,10 +11,10 @@ class Api::V1::TimelinesController < ApiController
     @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
-    set_counters_maps(@statuses)
-    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
+    # set_counters_maps(@statuses)
+    # set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
-    next_path = api_v1_home_timeline_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
+    next_path = api_v1_home_timeline_url(max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -27,10 +27,10 @@ class Api::V1::TimelinesController < ApiController
     @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
-    set_counters_maps(@statuses)
-    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
+    # set_counters_maps(@statuses)
+    # set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
-    next_path = api_v1_public_timeline_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
+    next_path = api_v1_public_timeline_url(max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -44,10 +44,10 @@ class Api::V1::TimelinesController < ApiController
     @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
-    set_counters_maps(@statuses)
-    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
+    # set_counters_maps(@statuses)
+    # set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
-    next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
+    next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb
index 5d2bd9a22..db16f82e5 100644
--- a/app/controllers/api_controller.rb
+++ b/app/controllers/api_controller.rb
@@ -10,7 +10,7 @@ class ApiController < ApplicationController
 
   before_action :set_rate_limit_headers
 
-  rescue_from ActiveRecord::RecordInvalid do |e|
+  rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
     render json: { error: e.to_s }, status: 422
   end
 
@@ -30,7 +30,7 @@ class ApiController < ApplicationController
     render json: { error: 'Remote SSL certificate could not be verified' }, status: 503
   end
 
-  rescue_from Mastodon::NotPermitted do
+  rescue_from Mastodon::NotPermittedError do
     render json: { error: 'This action is not allowed' }, status: 403
   end
 
@@ -79,6 +79,7 @@ class ApiController < ApplicationController
 
   def require_user!
     current_resource_owner
+    set_user_activity
   rescue ActiveRecord::RecordNotFound
     render json: { error: 'This method requires an authenticated user' }, status: 422
   end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index e4b6d0faf..c06142fd4 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -39,7 +39,14 @@ class ApplicationController < ActionController::Base
   end
 
   def set_user_activity
-    current_user.touch(:current_sign_in_at) if !current_user.nil? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < 24.hours.ago)
+    return unless !current_user.nil? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < 24.hours.ago)
+
+    # Mark user as signed-in today
+    current_user.update_tracked_fields(request)
+
+    # If the sign in is after a two week break, we need to regenerate their feed
+    RegenerationWorker.perform_async(current_user.account_id) if current_user.last_sign_in_at < 14.days.ago
+    return
   end
 
   def check_suspension
@@ -51,21 +58,21 @@ class ApplicationController < ActionController::Base
   def not_found
     respond_to do |format|
       format.any  { head 404 }
-      format.html { render 'errors/404', layout: 'error' }
+      format.html { render 'errors/404', layout: 'error', status: 404 }
     end
   end
 
   def gone
     respond_to do |format|
       format.any  { head 410 }
-      format.html { render 'errors/410', layout: 'error' }
+      format.html { render 'errors/410', layout: 'error', status: 410 }
     end
   end
 
   def unprocessable_entity
     respond_to do |format|
       format.any  { head 422 }
-      format.html { render 'errors/422', layout: 'error' }
+      format.html { render 'errors/422', layout: 'error', status: 422 }
     end
   end
 
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 501e66807..4881c074a 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -3,7 +3,7 @@
 class Auth::RegistrationsController < Devise::RegistrationsController
   layout :determine_layout
 
-  before_action :check_single_user_mode
+  before_action :check_enabled_registrations, only: [:new, :create]
   before_action :configure_sign_up_params, only: [:create]
 
   protected
@@ -27,12 +27,12 @@ class Auth::RegistrationsController < Devise::RegistrationsController
     new_user_session_path
   end
 
-  def check_single_user_mode
-    redirect_to root_path if Rails.configuration.x.single_user_mode
+  def check_enabled_registrations
+    redirect_to root_path if Rails.configuration.x.single_user_mode || !Setting.open_registrations
   end
-  
+
   private
-  
+
   def determine_layout
     %w(edit update).include?(action_name) ? 'admin' : 'auth'
   end
diff --git a/app/controllers/authorize_follow_controller.rb b/app/controllers/authorize_follow_controller.rb
index e866b5599..c98a5f45f 100644
--- a/app/controllers/authorize_follow_controller.rb
+++ b/app/controllers/authorize_follow_controller.rb
@@ -25,7 +25,7 @@ class AuthorizeFollowController < ApplicationController
     else
       redirect_to web_url("accounts/#{@account.id}")
     end
-  rescue ActiveRecord::RecordNotFound, Mastodon::NotPermitted
+  rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
     render :error
   end
 
diff --git a/app/controllers/concerns/obfuscate_filename.rb b/app/controllers/concerns/obfuscate_filename.rb
index 9f12cb7e9..9c896fb09 100644
--- a/app/controllers/concerns/obfuscate_filename.rb
+++ b/app/controllers/concerns/obfuscate_filename.rb
@@ -1,4 +1,5 @@
 # frozen_string_literal: true
+
 module ObfuscateFilename
   extend ActiveSupport::Concern
 
@@ -12,6 +13,10 @@ module ObfuscateFilename
     file = params.dig(*path)
     return if file.nil?
 
-    file.original_filename = 'media' + File.extname(file.original_filename)
+    file.original_filename = secure_token + File.extname(file.original_filename)
+  end
+
+  def secure_token(length = 16)
+    SecureRandom.hex(length / 2)
   end
 end
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
index feaad04f6..7c25266d8 100644
--- a/app/controllers/oauth/authorizations_controller.rb
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -3,6 +3,7 @@
 class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
   skip_before_action :authenticate_resource_owner!
 
+  before_action :set_locale
   before_action :store_current_location
   before_action :authenticate_resource_owner!
 
@@ -11,4 +12,10 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
   def store_current_location
     store_location_for(:user, request.url)
   end
+
+  def set_locale
+    I18n.locale = current_user.try(:locale) || I18n.default_locale
+  rescue I18n::InvalidLocale
+    I18n.locale = I18n.default_locale
+  end
 end
diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb
index 7d4bfe6ce..1e3f786ec 100644
--- a/app/controllers/remote_follow_controller.rb
+++ b/app/controllers/remote_follow_controller.rb
@@ -8,6 +8,7 @@ class RemoteFollowController < ApplicationController
 
   def new
     @remote_follow = RemoteFollow.new
+    @remote_follow.acct = session[:remote_follow] if session.key?(:remote_follow)
   end
 
   def create
@@ -22,6 +23,8 @@ class RemoteFollowController < ApplicationController
         render(:new) && return
       end
 
+      session[:remote_follow] = @remote_follow.acct
+
       redirect_to Addressable::Template.new(redirect_url_link.template).expand(uri: "#{@account.username}@#{Rails.configuration.x.local_domain}").to_s
     else
       render :new
diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb
new file mode 100644
index 000000000..4fcec5322
--- /dev/null
+++ b/app/controllers/settings/exports_controller.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'csv'
+
+class Settings::ExportsController < ApplicationController
+  layout 'admin'
+
+  before_action :authenticate_user!
+  before_action :set_account
+
+  def show
+    @total_storage = current_account.media_attachments.sum(:file_file_size)
+    @total_follows = current_account.following.count
+    @total_blocks  = current_account.blocking.count
+  end
+
+  def download_following_list
+    @accounts = current_account.following
+
+    respond_to do |format|
+      format.csv { render text: accounts_list_to_csv(@accounts) }
+    end
+  end
+
+  def download_blocking_list
+    @accounts = current_account.blocking
+
+    respond_to do |format|
+      format.csv { render text: accounts_list_to_csv(@accounts) }
+    end
+  end
+
+  private
+
+  def set_account
+    @account = current_user.account
+  end
+
+  def accounts_list_to_csv(list)
+    CSV.generate do |csv|
+      list.each do |account|
+        csv << [(account.local? ? "#{account.username}@#{Rails.configuration.x.local_domain}" : account.acct)]
+      end
+    end
+  end
+end
diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb
new file mode 100644
index 000000000..cbb5e65da
--- /dev/null
+++ b/app/controllers/settings/imports_controller.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class Settings::ImportsController < ApplicationController
+  layout 'admin'
+
+  before_action :authenticate_user!
+  before_action :set_account
+
+  def show
+    @import = Import.new
+  end
+
+  def create
+    @import = Import.new(import_params)
+    @import.account = @account
+
+    if @import.save
+      ImportWorker.perform_async(@import.id)
+      redirect_to settings_import_path, notice: I18n.t('imports.success')
+    else
+      render action: :show
+    end
+  end
+
+  private
+
+  def set_account
+    @account = current_user.account
+  end
+
+  def import_params
+    params.require(:import).permit(:data, :type)
+  end
+end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index b7479bf8c..60400e465 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -14,6 +14,7 @@ class Settings::PreferencesController < ApplicationController
       reblog:         user_params[:notification_emails][:reblog]         == '1',
       favourite:      user_params[:notification_emails][:favourite]      == '1',
       mention:        user_params[:notification_emails][:mention]        == '1',
+      digest:         user_params[:notification_emails][:digest]         == '1',
     }
 
     current_user.settings['interactions'] = {
@@ -33,6 +34,6 @@ class Settings::PreferencesController < ApplicationController
   private
 
   def user_params
-    params.require(:user).permit(:locale, :setting_default_privacy, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention], interactions: [:must_be_follower, :must_be_following])
+    params.require(:user).permit(:locale, :setting_default_privacy, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention, :digest], interactions: [:must_be_follower, :must_be_following])
   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/statuses_controller.rb b/app/controllers/statuses_controller.rb
new file mode 100644
index 000000000..696bb4f52
--- /dev/null
+++ b/app/controllers/statuses_controller.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class StatusesController < ApplicationController
+  layout 'public'
+
+  before_action :set_account
+  before_action :set_status
+  before_action :set_link_headers
+  before_action :check_account_suspension
+
+  def show
+    @ancestors   = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : []
+    @descendants = cache_collection(@status.descendants(current_account), Status)
+
+    render 'stream_entries/show'
+  end
+
+  private
+
+  def set_account
+    @account = Account.find_local!(params[:account_username])
+  end
+
+  def set_link_headers
+    response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]])
+  end
+
+  def set_status
+    @status       = @account.statuses.find(params[:id])
+    @stream_entry = @status.stream_entry
+    @type         = @stream_entry.activity_type.downcase
+
+    raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account)
+  end
+
+  def check_account_suspension
+    gone if @account.suspended?
+  end
+end
diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb
index da284d80e..de38b3602 100644
--- a/app/controllers/stream_entries_controller.rb
+++ b/app/controllers/stream_entries_controller.rb
@@ -43,13 +43,13 @@ 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))))
   end
 
   def check_account_suspension
-    head 410 if @account.suspended?
+    gone if @account.suspended?
   end
 end
diff --git a/app/controllers/xrd_controller.rb b/app/controllers/xrd_controller.rb
index 9e0277860..6db87cefc 100644
--- a/app/controllers/xrd_controller.rb
+++ b/app/controllers/xrd_controller.rb
@@ -36,11 +36,14 @@ class XrdController < ApplicationController
   end
 
   def username_from_resource
-    if resource_param.start_with?('acct:') || resource_param.include?('@')
-      resource_param.split('@').first.gsub('acct:', '')
+    if resource_param =~ /\Ahttps?:\/\//
+      path_params = Rails.application.routes.recognize_path(resource_param)
+      raise ActiveRecord::RecordNotFound unless path_params[:controller] == 'users' && path_params[:action] == 'show'
+      path_params[:username]
     else
-      url = Addressable::URI.parse(resource_param)
-      url.path.gsub('/users/', '')
+      username, domain = resource_param.gsub(/\Aacct:/, '').split('@')
+      raise ActiveRecord::RecordNotFound unless TagManager.instance.local_domain?(domain)
+      username
     end
   end
 
diff --git a/app/helpers/atom_builder_helper.rb b/app/helpers/atom_builder_helper.rb
index fb8f0976c..b750eeb07 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
@@ -120,6 +124,10 @@ module AtomBuilderHelper
     single_link_avatar(xml, account, :original, 120)
   end
 
+  def link_header(xml, account)
+    xml.link('rel' => 'header', 'type' => account.header_content_type, 'media:width' => 700, 'media:height' => 335, 'href' => full_asset_url(account.header.url(:original)))
+  end
+
   def logo(xml, url)
     xml.logo url
   end
@@ -143,7 +151,12 @@ module AtomBuilderHelper
     xml.link(:rel => 'mentioned', :href => TagManager::COLLECTIONS[:public], 'ostatus:object-type' => TagManager::TYPES[:collection])
   end
 
+  def privacy_scope(xml, level)
+    xml['mastodon'].scope(level)
+  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
@@ -151,7 +164,9 @@ module AtomBuilderHelper
     summary          xml, account.note
     link_alternate   xml, TagManager.instance.url_for(account)
     link_avatar      xml, account
+    link_header      xml, account
     portable_contact xml, account
+    privacy_scope    xml, account.locked? ? :private : :public
   end
 
   def rich_content(xml, activity)
@@ -162,6 +177,52 @@ module AtomBuilderHelper
     end
   end
 
+  def include_target(xml, target)
+    simple_id xml, TagManager.instance.uri_for(target)
+
+    if target.object_type == :person
+      include_author xml, target
+    else
+      object_type    xml, target.object_type
+      verb           xml, target.verb
+      title          xml, target.title
+      link_alternate xml, TagManager.instance.url_for(target)
+    end
+
+    # Statuses have content and author
+    return unless target.is_a?(Status)
+
+    rich_content xml, target
+    verb         xml, target.verb
+    published_at xml, target.created_at
+    updated_at   xml, target.updated_at
+
+    author(xml) do
+      include_author xml, target.account
+    end
+
+    if target.reply?
+      in_reply_to xml, TagManager.instance.uri_for(target.thread), TagManager.instance.url_for(target.thread)
+    end
+
+    link_visibility xml, target
+
+    target.mentions.each do |mention|
+      link_mention xml, mention.account
+    end
+
+    target.media_attachments.each do |media|
+      link_enclosure xml, media
+    end
+
+    target.tags.each do |tag|
+      category xml, tag.name
+    end
+
+    category(xml, 'nsfw') if target.sensitive?
+    privacy_scope(xml, target.visibility)
+  end
+
   def include_entry(xml, stream_entry)
     unique_id      xml, stream_entry.created_at, stream_entry.activity_id, stream_entry.activity_type
     published_at   xml, stream_entry.created_at
@@ -180,43 +241,7 @@ module AtomBuilderHelper
 
     if stream_entry.targeted?
       target(xml) do
-        simple_id xml, TagManager.instance.uri_for(stream_entry.target)
-
-        if stream_entry.target.object_type == :person
-          include_author xml, stream_entry.target
-        else
-          object_type    xml, stream_entry.target.object_type
-          title          xml, stream_entry.target.title
-          link_alternate xml, TagManager.instance.url_for(stream_entry.target)
-        end
-
-        # Statuses have content and author
-        if stream_entry.target.is_a?(Status)
-          rich_content xml, stream_entry.target
-          verb         xml, stream_entry.target.verb
-          published_at xml, stream_entry.target.created_at
-          updated_at   xml, stream_entry.target.updated_at
-
-          author(xml) do
-            include_author xml, stream_entry.target.account
-          end
-
-          link_visibility xml, stream_entry.target
-
-          stream_entry.target.mentions.each do |mention|
-            link_mention xml, mention.account
-          end
-
-          stream_entry.target.media_attachments.each do |media|
-            link_enclosure xml, media
-          end
-
-          stream_entry.target.tags.each do |tag|
-            category xml, tag.name
-          end
-
-          category(xml, 'nsfw') if stream_entry.target.sensitive?
-        end
+        include_target(xml, stream_entry.target)
       end
     end
 
@@ -237,6 +262,7 @@ module AtomBuilderHelper
     end
 
     category(xml, 'nsfw') if stream_entry.activity.sensitive?
+    privacy_scope(xml, stream_entry.activity.visibility)
   end
 
   private
@@ -249,10 +275,11 @@ module AtomBuilderHelper
                'xmlns:poco'     => TagManager::POCO_XMLNS,
                'xmlns:media'    => TagManager::MEDIA_XMLNS,
                'xmlns:ostatus'  => TagManager::OS_XMLNS,
+               'xmlns:mastodon' => TagManager::MTDN_XMLNS,
              }, &block)
   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/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index aed8770c8..e01f7d0cc 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -9,6 +9,8 @@ module SettingsHelper
     fr: 'Français',
     hu: 'Magyar',
     uk: 'Українська',
+    'zh-CN': '简体中文',
+    fi: 'Suomi',
   }.freeze
 
   def human_locale(locale)
diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb
index 15601a079..a26e912a3 100644
--- a/app/helpers/stream_entries_helper.rb
+++ b/app/helpers/stream_entries_helper.rb
@@ -37,4 +37,17 @@ module StreamEntriesHelper
   def proper_status(status)
     status.reblog? ? status.reblog : status
   end
+
+  def rtl?(text)
+    return false if text.empty?
+
+    matches = /[\p{Hebrew}|\p{Arabic}|\p{Syriac}|\p{Thaana}|\p{Nko}]+/m.match(text)
+
+    return false unless matches
+
+    rtl_size = matches.to_a.reduce(0) { |acc, elem| acc + elem.size }.to_f
+    ltr_size = text.strip.size.to_f
+
+    rtl_size / ltr_size > 0.3
+  end
 end
diff --git a/app/lib/exceptions.rb b/app/lib/exceptions.rb
index 359228c29..9bc802c12 100644
--- a/app/lib/exceptions.rb
+++ b/app/lib/exceptions.rb
@@ -2,5 +2,7 @@
 
 module Mastodon
   class Error < StandardError; end
-  class NotPermitted < Error; end
+  class NotPermittedError < Error; end
+  class ValidationError < Error; end
+  class RaceConditionError < Error; end
 end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index f0928a945..2cca1cefe 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -5,25 +5,35 @@ require 'singleton'
 class FeedManager
   include Singleton
 
-  MAX_ITEMS = 800
+  MAX_ITEMS = 400
 
   def key(type, id)
     "feed:#{type}:#{id}"
   end
 
-  def filter?(timeline_type, status, receiver)
+  def filter?(timeline_type, status, receiver_id)
     if timeline_type == :home
-      filter_from_home?(status, receiver)
+      filter_from_home?(status, receiver_id)
     elsif timeline_type == :mentions
-      filter_from_mentions?(status, receiver)
+      filter_from_mentions?(status, receiver_id)
     else
       false
     end
   end
 
   def push(timeline_type, account, status)
-    redis.zadd(key(timeline_type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
-    trim(timeline_type, account.id)
+    timeline_key = key(timeline_type, account.id)
+
+    if status.reblog?
+      # If the original status is within 40 statuses from top, do not re-insert it into the feed
+      rank = redis.zrevrank(timeline_key, status.reblog_of_id)
+      return if !rank.nil? && rank < 40
+      redis.zadd(timeline_key, status.id, status.reblog_of_id)
+    else
+      redis.zadd(timeline_key, status.id, status.id)
+      trim(timeline_type, account.id)
+    end
+
     broadcast(account.id, event: 'update', payload: inline_render(account, 'api/v1/statuses/show', status))
   end
 
@@ -40,10 +50,18 @@ class FeedManager
 
   def merge_into_timeline(from_account, into_account)
     timeline_key = key(:home, into_account.id)
+    query        = from_account.statuses.limit(FeedManager::MAX_ITEMS / 4)
 
-    from_account.statuses.limit(MAX_ITEMS).each do |status|
-      next if filter?(:home, status, into_account)
-      redis.zadd(timeline_key, status.id, status.id)
+    if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4
+      oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
+      query = query.where('id > ?', oldest_home_score)
+    end
+
+    redis.pipelined do
+      query.each do |status|
+        next if status.direct_visibility? || filter?(:home, status, into_account)
+        redis.zadd(timeline_key, status.id, status.id)
+      end
     end
 
     trim(:home, into_account.id)
@@ -51,31 +69,20 @@ class FeedManager
 
   def unmerge_from_timeline(from_account, into_account)
     timeline_key = key(:home, into_account.id)
-
-    from_account.statuses.select('id').find_each do |status|
-      redis.zrem(timeline_key, status.id)
-      redis.zremrangebyscore(timeline_key, status.id, status.id)
+    oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
+
+    from_account.statuses.select('id').where('id > ?', oldest_home_score).find_in_batches do |statuses|
+      redis.pipelined do
+        statuses.each do |status|
+          redis.zrem(timeline_key, status.id)
+          redis.zremrangebyscore(timeline_key, status.id, status.id)
+        end
+      end
     end
   end
 
   def inline_render(target_account, template, object)
-    rabl_scope = Class.new do
-      include RoutingHelper
-
-      def initialize(account)
-        @account = account
-      end
-
-      def current_user
-        @account.try(:user)
-      end
-
-      def current_account
-        @account
-      end
-    end
-
-    Rabl::Renderer.new(template, object, view_path: 'app/views', format: :json, scope: rabl_scope.new(target_account)).render
+    Rabl::Renderer.new(template, object, view_path: 'app/views', format: :json, scope: InlineRablScope.new(target_account)).render
   end
 
   private
@@ -84,35 +91,40 @@ class FeedManager
     Redis.current
   end
 
-  def filter_from_home?(status, receiver)
-    should_filter = false
-
-    if status.reply? && status.in_reply_to_id.nil?
-      should_filter = true
-    elsif status.reply? && !status.in_reply_to_account_id.nil?                # Filter out if it's a reply
-      should_filter   = !receiver.following?(status.in_reply_to_account)      # and I'm not following the person it's a reply to
-      should_filter &&= !(receiver.id == status.in_reply_to_account_id)       # and it's not a reply to me
-      should_filter &&= !(status.account_id == status.in_reply_to_account_id) # and it's not a self-reply
-    elsif status.reblog?                                                      # Filter out a reblog
-      should_filter = receiver.blocking?(status.reblog.account)               # if I'm blocking the reblogged person
-    end
+  def filter_from_home?(status, receiver_id)
+    return true if status.reply? && status.in_reply_to_id.nil?
 
-    should_filter ||= receiver.blocking?(status.mentions.map(&:account_id))   # or if it mentions someone I blocked
+    check_for_mutes = [status.account_id]
+    check_for_mutes.concat([status.reblog.account_id]) if status.reblog?
 
-    should_filter
-  end
+    return true if Mute.where(account_id: receiver_id, target_account_id: check_for_mutes).any?
+
+    check_for_blocks = status.mentions.map(&:account_id)
+    check_for_blocks.concat([status.reblog.account_id]) if status.reblog?
 
-  def filter_from_mentions?(status, receiver)
-    should_filter   = receiver.id == status.account_id                                      # Filter if I'm mentioning myself
-    should_filter ||= receiver.blocking?(status.account)                                    # or it's from someone I blocked
-    should_filter ||= receiver.blocking?(status.mentions.includes(:account).map(&:account)) # or if it mentions someone I blocked
-    should_filter ||= (status.account.silenced? && !receiver.following?(status.account))    # of if the account is silenced and I'm not following them
-    should_filter ||= (status.private_visibility? && !receiver.following?(status.account))  # or if the mentioned account is not permitted to see the private status
+    return true if Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?
 
-    if status.reply? && !status.in_reply_to_account_id.nil?                                 # or it's a reply
-      should_filter ||= receiver.blocking?(status.in_reply_to_account)                      # to a user I blocked
+    if status.reply? && !status.in_reply_to_account_id.nil?                                                              # Filter out if it's a reply
+      should_filter   = !Follow.where(account_id: receiver_id, target_account_id: status.in_reply_to_account_id).exists? # and I'm not following the person it's a reply to
+      should_filter &&= !(receiver_id == status.in_reply_to_account_id)                                                  # and it's not a reply to me
+      should_filter &&= !(status.account_id == status.in_reply_to_account_id)                                            # and it's not a self-reply
+      return should_filter
+    elsif status.reblog?                                                                                                 # Filter out a reblog
+      return Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists?                   # or if the author of the reblogged status is blocking me
     end
 
+    false
+  end
+
+  def filter_from_mentions?(status, receiver_id)
+    check_for_blocks = [status.account_id]
+    check_for_blocks.concat(status.mentions.pluck(:account_id))
+    check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
+
+    should_filter   = receiver_id == status.account_id                                                                                   # Filter if I'm mentioning myself
+    should_filter ||= Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?                                     # or it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
+    should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
+
     should_filter
   end
 end
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 044407a6c..da7ad2027 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -24,7 +24,12 @@ class Formatter
   end
 
   def reformat(html)
-    sanitize(html, tags: %w(a br p), attributes: %w(href rel))
+    sanitize(html, tags: %w(a br p span), attributes: %w(href rel class))
+  end
+
+  def plaintext(status)
+    return status.text if status.local?
+    strip_tags(status.text)
   end
 
   def simplified_format(account)
@@ -32,6 +37,7 @@ class Formatter
 
     html = encode(account.note)
     html = link_urls(html)
+    html = link_accounts(html)
     html = link_hashtags(html)
 
     html.html_safe # rubocop:disable Rails/OutputSafety
@@ -44,20 +50,31 @@ class Formatter
   end
 
   def link_urls(html)
-    html.gsub(URI.regexp(%w(http https))) do |match|
-      link_html(match)
-    end
+    Twitter::Autolink.auto_link_urls(html, url_target: '_blank',
+                                           link_attribute_block: lambda { |_, a| a[:rel] << ' noopener' },
+                                           link_text_block: lambda { |_, text| link_html(text) })
   end
 
   def link_mentions(html, mentions)
     html.gsub(Account::MENTION_RE) do |match|
       acct    = Account::MENTION_RE.match(match)[1]
-      mention = mentions.find { |item| item.account.acct.casecmp(acct).zero? }
+      mention = mentions.find { |item| TagManager.instance.same_acct?(item.account.acct, acct) }
 
       mention.nil? ? match : mention_html(match, mention.account)
     end
   end
 
+  def link_accounts(html)
+    html.gsub(Account::MENTION_RE) do |match|
+      acct = Account::MENTION_RE.match(match)[1]
+      username, domain = acct.split('@')
+      domain = nil if TagManager.instance.local_domain?(domain)
+      account = Account.find_remote(username, domain)
+
+      account.nil? ? match : mention_html(match, account)
+    end
+  end
+
   def link_hashtags(html)
     html.gsub(Tag::HASHTAG_RE) do |match|
       hashtag_html(match)
@@ -70,7 +87,7 @@ class Formatter
     suffix = url[prefix.length + 30..-1]
     cutoff = url[prefix.length..-1].length > 30
 
-    "<a rel=\"nofollow noopener\" target=\"_blank\" href=\"#{url}\"><span class=\"invisible\">#{prefix}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{text}</span><span class=\"invisible\">#{suffix}</span></a>"
+    "<span class=\"invisible\">#{prefix}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{text}</span><span class=\"invisible\">#{suffix}</span>"
   end
 
   def hashtag_html(match)
diff --git a/app/lib/inline_rabl_scope.rb b/app/lib/inline_rabl_scope.rb
new file mode 100644
index 000000000..26adcb03a
--- /dev/null
+++ b/app/lib/inline_rabl_scope.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class InlineRablScope
+  include RoutingHelper
+
+  def initialize(account)
+    @account = account
+  end
+
+  def current_user
+    @account.try(:user)
+  end
+
+  def current_account
+    @account
+  end
+end
diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb
index 2508eea97..2a5e7a409 100644
--- a/app/lib/tag_manager.rb
+++ b/app/lib/tag_manager.rb
@@ -7,15 +7,18 @@ class TagManager
   include RoutingHelper
 
   VERBS = {
-    post:       'http://activitystrea.ms/schema/1.0/post',
-    share:      'http://activitystrea.ms/schema/1.0/share',
-    favorite:   'http://activitystrea.ms/schema/1.0/favorite',
-    unfavorite: 'http://activitystrea.ms/schema/1.0/unfavorite',
-    delete:     'http://activitystrea.ms/schema/1.0/delete',
-    follow:     'http://activitystrea.ms/schema/1.0/follow',
-    unfollow:   'http://ostatus.org/schema/1.0/unfollow',
-    block:      'http://mastodon.social/schema/1.0/block',
-    unblock:    'http://mastodon.social/schema/1.0/unblock',
+    post:           'http://activitystrea.ms/schema/1.0/post',
+    share:          'http://activitystrea.ms/schema/1.0/share',
+    favorite:       'http://activitystrea.ms/schema/1.0/favorite',
+    unfavorite:     'http://activitystrea.ms/schema/1.0/unfavorite',
+    delete:         'http://activitystrea.ms/schema/1.0/delete',
+    follow:         'http://activitystrea.ms/schema/1.0/follow',
+    request_friend: 'http://activitystrea.ms/schema/1.0/request-friend',
+    authorize:      'http://activitystrea.ms/schema/1.0/authorize',
+    reject:         'http://activitystrea.ms/schema/1.0/reject',
+    unfollow:       'http://ostatus.org/schema/1.0/unfollow',
+    block:          'http://mastodon.social/schema/1.0/block',
+    unblock:        'http://mastodon.social/schema/1.0/unblock',
   }.freeze
 
   TYPES = {
@@ -38,6 +41,7 @@ class TagManager
   POCO_XMLNS  = 'http://portablecontacts.net/spec/1.0'
   DFRN_XMLNS  = 'http://purl.org/macgirvin/dfrn/1.0'
   OS_XMLNS    = 'http://ostatus.org/schema/1.0'
+  MTDN_XMLNS  = 'http://mastodon.social/schema/1.0'
 
   def unique_tag(date, id, type)
     "tag:#{Rails.configuration.x.local_domain},#{date.strftime('%Y-%m-%d')}:objectId=#{id}:objectType=#{type}"
@@ -56,6 +60,12 @@ class TagManager
     domain.nil? || domain.gsub(/[\/]/, '').casecmp(Rails.configuration.x.local_domain).zero?
   end
 
+  def same_acct?(canonical, needle)
+    return true if canonical.casecmp(needle).zero?
+    username, domain = needle.split('@')
+    local_domain?(domain) && canonical.casecmp(username).zero?
+  end
+
   def local_url?(url)
     uri    = Addressable::URI.parse(url)
     domain = uri.host + (uri.port ? ":#{uri.port}" : '')
@@ -78,7 +88,9 @@ class TagManager
 
     case target.object_type
     when :person
-      account_url(target)
+      short_account_url(target)
+    when :note, :comment, :activity
+      short_account_status_url(target.account, target)
     else
       account_stream_entry_url(target.account, target.stream_entry)
     end
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index a1b084682..bf4c16e43 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -49,4 +49,17 @@ class NotificationMailer < ApplicationMailer
       mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
     end
   end
+
+  def digest(recipient, opts = {})
+    @me            = recipient
+    @since         = opts[:since] || @me.user.last_emailed_at || @me.user.current_sign_in_at
+    @notifications = Notification.where(account: @me, activity_type: 'Mention').where('created_at > ?', @since)
+    @follows_since = Notification.where(account: @me, activity_type: 'Follow').where('created_at > ?', @since).count
+
+    return if @notifications.empty?
+
+    I18n.with_locale(@me.user.locale || I18n.default_locale) do
+      mail to: @me.user.email, subject: I18n.t('notification_mailer.digest.subject', count: @notifications.size)
+    end
+  end
 end
diff --git a/app/models/account.rb b/app/models/account.rb
index c2a41c4c6..6968607a2 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -2,9 +2,8 @@
 
 class Account < ApplicationRecord
   include Targetable
-  include PgSearch
 
-  MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i
+  MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
 
   # Local users
@@ -46,15 +45,16 @@ class Account < ApplicationRecord
   has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
   has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account
 
+  # Mute relationships
+  has_many :mute_relationships, class_name: 'Mute', foreign_key: 'account_id', dependent: :destroy
+  has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account
+
   # Media
   has_many :media_attachments, dependent: :destroy
 
   # PuSH subscriptions
   has_many :subscriptions, dependent: :destroy
 
-  pg_search_scope :search_for, against: { username: 'A', domain: 'B' },
-                               using: { tsearch: { prefix: true } }
-
   scope :remote, -> { where.not(domain: nil) }
   scope :local, -> { where(domain: nil) }
   scope :without_followers, -> { where('(select count(f.id) from follows as f where f.target_account_id = accounts.id) = 0') }
@@ -73,6 +73,10 @@ class Account < ApplicationRecord
     block_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
   end
 
+  def mute!(other_account)
+    mute_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
+  end
+
   def unfollow!(other_account)
     follow = active_relationships.find_by(target_account: other_account)
     follow&.destroy
@@ -83,6 +87,11 @@ class Account < ApplicationRecord
     block&.destroy
   end
 
+  def unmute!(other_account)
+    mute = mute_relationships.find_by(target_account: other_account)
+    mute&.destroy
+  end
+
   def following?(other_account)
     following.include?(other_account)
   end
@@ -91,10 +100,18 @@ class Account < ApplicationRecord
     blocking.include?(other_account)
   end
 
+  def muting?(other_account)
+    muting.include?(other_account)
+  end
+
   def requested?(other_account)
     follow_requests.where(target_account: other_account).exists?
   end
 
+  def followers_domains
+    followers.reorder('').select('DISTINCT accounts.domain').map(&:domain)
+  end
+
   def local?
     domain.nil?
   end
@@ -127,14 +144,16 @@ class Account < ApplicationRecord
     save!
   rescue ActiveRecord::RecordInvalid
     self.avatar              = nil
+    self.header              = nil
     self[:avatar_remote_url] = ''
+    self[:header_remote_url] = ''
     save!
   end
 
   def avatar_remote_url=(url)
     parsed_url = URI.parse(url)
 
-    return if !%w(http https).include?(parsed_url.scheme) || self[:avatar_remote_url] == url
+    return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[:avatar_remote_url] == url
 
     self.avatar              = parsed_url
     self[:avatar_remote_url] = url
@@ -142,6 +161,17 @@ class Account < ApplicationRecord
     Rails.logger.debug "Error fetching remote avatar: #{e}"
   end
 
+  def header_remote_url=(url)
+    parsed_url = URI.parse(url)
+
+    return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[:header_remote_url] == url
+
+    self.header              = parsed_url
+    self[:header_remote_url] = url
+  rescue OpenURI::HTTPError => e
+    Rails.logger.debug "Error fetching remote header: #{e}"
+  end
+
   def object_type
     :person
   end
@@ -157,7 +187,7 @@ class Account < ApplicationRecord
 
     def find_remote!(username, domain)
       return if username.blank?
-      where(arel_table[:username].matches(username.gsub(/[%_]/, '\\\\\0'))).where(domain.nil? ? { domain: nil } : arel_table[:domain].matches(domain.gsub(/[%_]/, '\\\\\0'))).take!
+      where('lower(accounts.username) = ?', username.downcase).where(domain.nil? ? { domain: nil } : 'lower(accounts.domain) = ?', domain&.downcase).take!
     end
 
     def find_local(username)
@@ -172,6 +202,63 @@ class Account < ApplicationRecord
       nil
     end
 
+    def triadic_closures(account, limit = 5)
+      sql = <<SQL
+        WITH first_degree AS (
+            SELECT target_account_id
+            FROM follows
+            WHERE account_id = ?
+          )
+        SELECT accounts.*
+        FROM follows
+        INNER JOIN accounts ON follows.target_account_id = accounts.id
+        WHERE account_id IN (SELECT * FROM first_degree) AND target_account_id NOT IN (SELECT * FROM first_degree) AND target_account_id <> ?
+        GROUP BY target_account_id, accounts.id
+        ORDER BY count(account_id) DESC
+        LIMIT ?
+SQL
+
+      Account.find_by_sql([sql, account.id, account.id, limit])
+    end
+
+    def search_for(terms, limit = 10)
+      terms      = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' ')))
+      textsearch = '(setweight(to_tsvector(\'simple\', accounts.display_name), \'A\') || setweight(to_tsvector(\'simple\', accounts.username), \'B\') || setweight(to_tsvector(\'simple\', coalesce(accounts.domain, \'\')), \'C\'))'
+      query      = 'to_tsquery(\'simple\', \'\'\' \' || ' + terms + ' || \' \'\'\' || \':*\')'
+
+      sql = <<SQL
+        SELECT
+          accounts.*,
+          ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
+        FROM accounts
+        WHERE #{query} @@ #{textsearch}
+        ORDER BY rank DESC
+        LIMIT ?
+SQL
+
+      Account.find_by_sql([sql, limit])
+    end
+
+    def advanced_search_for(terms, account, limit = 10)
+      terms      = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' ')))
+      textsearch = '(setweight(to_tsvector(\'simple\', accounts.display_name), \'A\') || setweight(to_tsvector(\'simple\', accounts.username), \'B\') || setweight(to_tsvector(\'simple\', coalesce(accounts.domain, \'\')), \'C\'))'
+      query      = 'to_tsquery(\'simple\', \'\'\' \' || ' + terms + ' || \' \'\'\' || \':*\')'
+
+      sql = <<SQL
+        SELECT
+          accounts.*,
+          (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
+        FROM accounts
+        LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)
+        WHERE #{query} @@ #{textsearch}
+        GROUP BY accounts.id
+        ORDER BY rank DESC
+        LIMIT ?
+SQL
+
+      Account.find_by_sql([sql, account.id, account.id, limit])
+    end
+
     def following_map(target_account_ids, account_id)
       follow_mapping(Follow.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
     end
@@ -184,6 +271,10 @@ class Account < ApplicationRecord
       follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
     end
 
+    def muting_map(target_account_ids, account_id)
+      follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
+    end
+
     def requested_map(target_account_ids, account_id)
       follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
     end
diff --git a/app/models/block.rb b/app/models/block.rb
index c2067c5b8..9c55703c9 100644
--- a/app/models/block.rb
+++ b/app/models/block.rb
@@ -2,31 +2,10 @@
 
 class Block < ApplicationRecord
   include Paginable
-  include Streamable
 
   belongs_to :account
   belongs_to :target_account, class_name: 'Account'
 
   validates :account, :target_account, presence: true
   validates :account_id, uniqueness: { scope: :target_account_id }
-
-  def verb
-    destroyed? ? :unblock : :block
-  end
-
-  def target
-    target_account
-  end
-
-  def object_type
-    :person
-  end
-
-  def hidden?
-    true
-  end
-
-  def title
-    destroyed? ? "#{account.acct} is no longer blocking #{target_account.acct}" : "#{account.acct} blocked #{target_account.acct}"
-  end
 end
diff --git a/app/models/concerns/streamable.rb b/app/models/concerns/streamable.rb
index 58c15cfbc..910736dac 100644
--- a/app/models/concerns/streamable.rb
+++ b/app/models/concerns/streamable.rb
@@ -30,8 +30,12 @@ module Streamable
       false
     end
 
+    def needs_stream_entry?
+      account.local?
+    end
+
     after_create do
-      account.stream_entries.create!(activity: self, hidden: hidden?) if account.local?
+      account.stream_entries.create!(activity: self, hidden: hidden?) if needs_stream_entry?
     end
   end
 end
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index b4606da60..3548ccd69 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -6,6 +6,6 @@ class DomainBlock < ApplicationRecord
   validates :domain, presence: true, uniqueness: true
 
   def self.blocked?(domain)
-    where(domain: domain).exists?
+    where(domain: domain, severity: :suspend).exists?
   end
 end
diff --git a/app/models/favourite.rb b/app/models/favourite.rb
index 3f3616dce..41d06e734 100644
--- a/app/models/favourite.rb
+++ b/app/models/favourite.rb
@@ -2,37 +2,14 @@
 
 class Favourite < ApplicationRecord
   include Paginable
-  include Streamable
 
   belongs_to :account, inverse_of: :favourites
-  belongs_to :status,  inverse_of: :favourites
+  belongs_to :status,  inverse_of: :favourites, counter_cache: true
 
   has_one :notification, as: :activity, dependent: :destroy
 
   validates :status_id, uniqueness: { scope: :account_id }
 
-  def verb
-    :favorite
-  end
-
-  def title
-    "#{account.acct} favourited a status by #{status.account.acct}"
-  end
-
-  delegate :object_type, to: :target
-
-  def thread
-    status
-  end
-
-  def target
-    thread
-  end
-
-  def hidden?
-    status.private_visibility?
-  end
-
   before_validation do
     self.status = status.reblog if status.reblog?
   end
diff --git a/app/models/feed.rb b/app/models/feed.rb
index 5e1905e15..3cbc160a0 100644
--- a/app/models/feed.rb
+++ b/app/models/feed.rb
@@ -10,17 +10,9 @@ class Feed
     max_id     = '+inf' if max_id.blank?
     since_id   = '-inf' if since_id.blank?
     unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i)
+    status_map = Status.where(id: unhydrated).cache_ids.map { |s| [s.id, s] }.to_h
 
-    # If we're after most recent items and none are there, we need to precompute the feed
-    if unhydrated.empty? && max_id == '+inf' && since_id == '-inf'
-      RegenerationWorker.perform_async(@account.id, @type)
-      @statuses = Status.send("as_#{@type}_timeline", @account).cache_ids.paginate_by_max_id(limit, nil, nil)
-    else
-      status_map = Status.where(id: unhydrated).cache_ids.map { |s| [s.id, s] }.to_h
-      @statuses  = unhydrated.map { |id| status_map[id] }.compact
-    end
-
-    @statuses
+    unhydrated.map { |id| status_map[id] }.compact
   end
 
   private
diff --git a/app/models/follow.rb b/app/models/follow.rb
index f83490caa..8bfe8b2f6 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -2,29 +2,12 @@
 
 class Follow < ApplicationRecord
   include Paginable
-  include Streamable
 
-  belongs_to :account
-  belongs_to :target_account, class_name: 'Account'
+  belongs_to :account, counter_cache: :following_count
+  belongs_to :target_account, class_name: 'Account', counter_cache: :followers_count
 
   has_one :notification, as: :activity, dependent: :destroy
 
   validates :account, :target_account, presence: true
   validates :account_id, uniqueness: { scope: :target_account_id }
-
-  def verb
-    destroyed? ? :unfollow : :follow
-  end
-
-  def target
-    target_account
-  end
-
-  def object_type
-    :person
-  end
-
-  def title
-    destroyed? ? "#{account.acct} is no longer following #{target_account.acct}" : "#{account.acct} started following #{target_account.acct}"
-  end
 end
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index 936ad0691..4224ab15d 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -14,6 +14,7 @@ class FollowRequest < ApplicationRecord
   def authorize!
     account.follow!(target_account)
     MergeWorker.perform_async(target_account.id, account.id)
+
     destroy!
   end
 
diff --git a/app/models/import.rb b/app/models/import.rb
new file mode 100644
index 000000000..5384986d8
--- /dev/null
+++ b/app/models/import.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Import < ApplicationRecord
+  self.inheritance_column = false
+
+  enum type: [:following, :blocking]
+
+  belongs_to :account
+
+  FILE_TYPES = ['text/plain', 'text/csv'].freeze
+
+  has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET']
+  validates_attachment_content_type :data, content_type: FILE_TYPES
+end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 6925f9b0d..818190214 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -1,15 +1,32 @@
 # frozen_string_literal: true
 
 class MediaAttachment < ApplicationRecord
+  self.inheritance_column = nil
+
+  enum type: [:image, :gifv, :video]
+
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
   VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
 
+  IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze
+  VIDEO_STYLES = {
+    small: {
+      convert_options: {
+        output: {
+          vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
+        },
+      },
+      format: 'png',
+      time: 0,
+    },
+  }.freeze
+
   belongs_to :account, inverse_of: :media_attachments
   belongs_to :status,  inverse_of: :media_attachments
 
   has_attached_file :file,
-                    styles: -> (f) { file_styles f },
-                    processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] },
+                    styles: ->(f) { file_styles f },
+                    processors: ->(f) { file_processors f },
                     convert_options: { all: '-quality 90 -strip' }
   validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
   validates_attachment_size :file, less_than: 8.megabytes
@@ -27,45 +44,49 @@ class MediaAttachment < ApplicationRecord
     self.file = URI.parse(url)
   end
 
-  def image?
-    IMAGE_MIME_TYPES.include? file_content_type
-  end
-
-  def video?
-    VIDEO_MIME_TYPES.include? file_content_type
-  end
-
-  def type
-    image? ? 'image' : 'video'
-  end
-
   def to_param
     shortcode
   end
 
   before_create :set_shortcode
+  before_post_process :set_type
 
   class << self
     private
 
     def file_styles(f)
-      if f.instance.image?
+      if f.instance.file_content_type == 'image/gif'
         {
-          original: '1280x1280>',
-          small: '400x400>',
-        }
-      else
-        {
-          small: {
+          small: IMAGE_STYLES[:small],
+          original: {
+            format: 'mp4',
             convert_options: {
               output: {
-                vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
+                'movflags' => 'faststart',
+                'pix_fmt'  => 'yuv420p',
+                'vf'       => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
+                'vsync'    => 'cfr',
+                'b:v'      => '1300K',
+                'maxrate'  => '500K',
+                'crf'      => 6,
               },
             },
-            format: 'png',
-            time: 1,
           },
         }
+      elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type
+        IMAGE_STYLES
+      else
+        VIDEO_STYLES
+      end
+    end
+
+    def file_processors(f)
+      if f.file_content_type == 'image/gif'
+        [:gif_transcoder]
+      elsif VIDEO_MIME_TYPES.include? f.file_content_type
+        [:video_transcoder]
+      else
+        [:thumbnail]
       end
     end
   end
@@ -80,4 +101,8 @@ class MediaAttachment < ApplicationRecord
       break if MediaAttachment.find_by(shortcode: shortcode).nil?
     end
   end
+
+  def set_type
+    self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image
+  end
 end
diff --git a/app/models/mute.rb b/app/models/mute.rb
new file mode 100644
index 000000000..a5b334c85
--- /dev/null
+++ b/app/models/mute.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Mute < ApplicationRecord
+  include Paginable
+
+  belongs_to :account
+  belongs_to :target_account, class_name: 'Account'
+
+  validates :account, :target_account, presence: true
+  validates :account_id, uniqueness: { scope: :target_account_id }
+end
diff --git a/app/models/report.rb b/app/models/report.rb
new file mode 100644
index 000000000..fd8e46aac
--- /dev/null
+++ b/app/models/report.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class Report < ApplicationRecord
+  belongs_to :account
+  belongs_to :target_account, class_name: 'Account'
+  belongs_to :action_taken_by_account, class_name: 'Account'
+
+  scope :unresolved, -> { where(action_taken: false) }
+  scope :resolved,   -> { where(action_taken: true) }
+end
diff --git a/app/models/setting.rb b/app/models/setting.rb
index 3796253d4..31e1ee198 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -2,7 +2,6 @@
 
 class Setting < RailsSettings::Base
   source Rails.root.join('config/settings.yml')
-  namespace Rails.env
 
   def to_param
     var
diff --git a/app/models/status.rb b/app/models/status.rb
index d2be72308..daf128572 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -6,15 +6,15 @@ class Status < ApplicationRecord
   include Streamable
   include Cacheable
 
-  enum visibility: [:public, :unlisted, :private], _suffix: :visibility
+  enum visibility: [:public, :unlisted, :private, :direct], _suffix: :visibility
 
   belongs_to :application, class_name: 'Doorkeeper::Application'
 
-  belongs_to :account, inverse_of: :statuses
+  belongs_to :account, inverse_of: :statuses, counter_cache: true
   belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account'
 
   belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies
-  belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs
+  belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, counter_cache: :reblogs_count
 
   has_many :favourites, inverse_of: :status, dependent: :destroy
   has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
@@ -37,6 +37,9 @@ class Status < ApplicationRecord
   scope :remote, -> { where.not(uri: nil) }
   scope :local, -> { where(uri: nil) }
 
+  scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
+  scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
+
   cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
 
   def reply?
@@ -72,11 +75,17 @@ class Status < ApplicationRecord
   end
 
   def hidden?
-    private_visibility?
+    private_visibility? || direct_visibility?
   end
 
   def permitted?(other_account = nil)
-    private_visibility? ? (account.id == other_account&.id || other_account&.following?(account)) : other_account.nil? || !account.blocking?(other_account)
+    if direct_visibility?
+      account.id == other_account&.id || mentions.where(account: other_account).exists?
+    elsif private_visibility?
+      account.id == other_account&.id || other_account&.following?(account) || mentions.where(account: other_account).exists?
+    else
+      other_account.nil? || !account.blocking?(other_account)
+    end
   end
 
   def ancestors(account = nil)
@@ -105,8 +114,8 @@ class Status < ApplicationRecord
     def as_public_timeline(account = nil, local_only = false)
       query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
               .where(visibility: :public)
-              .where('(statuses.reply = false OR statuses.in_reply_to_account_id = statuses.account_id)')
-              .where('statuses.reblog_of_id IS NULL')
+              .without_replies
+              .without_reblogs
 
       query = query.where('accounts.domain IS NULL') if local_only
 
@@ -117,7 +126,7 @@ class Status < ApplicationRecord
       query = tag.statuses
                  .joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
                  .where(visibility: :public)
-                 .where('statuses.reblog_of_id IS NULL')
+                 .without_reblogs
 
       query = query.where('accounts.domain IS NULL') if local_only
 
@@ -149,21 +158,27 @@ class Status < ApplicationRecord
     end
 
     def permitted_for(target_account, account)
-      if account&.id == target_account.id || account&.following?(target_account)
-        where('1 = 1')
-      elsif !account.nil? && target_account.blocking?(account)
+      return where.not(visibility: [:private, :direct]) if account.nil?
+
+      if target_account.blocking?(account) # get rid of blocked peeps
         where('1 = 0')
-      else
-        where.not(visibility: :private)
+      elsif account.id == target_account.id # author can see own stuff
+        where('1 = 1')
+      elsif account.following?(target_account) # followers can see followers-only stuff, but also things they are mentioned in
+        joins('LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = ' + account.id.to_s)
+          .where('statuses.visibility != ? OR mentions.id IS NOT NULL', Status.visibilities[:direct])
+      else # non-followers can see everything that isn't private/direct, but can see stuff they are mentioned in
+        joins('LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = ' + account.id.to_s)
+          .where('statuses.visibility NOT IN (?) OR mentions.id IS NOT NULL', [Status.visibilities[:direct], Status.visibilities[:private]])
       end
     end
 
     private
 
     def filter_timeline(query, account)
-      blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id)
-      query   = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty?
-      query   = query.where('accounts.silenced = TRUE') if account.silenced?
+      blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id) + Mute.where(account: account).pluck(:target_account_id)
+      query   = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty?  # Only give us statuses from people we haven't blocked, or muted, or that have blocked us
+      query   = query.where('accounts.silenced = TRUE') if account.silenced?                  # and if we're hellbanned, only people who are also hellbanned
       query
     end
 
@@ -173,7 +188,7 @@ class Status < ApplicationRecord
   end
 
   before_validation do
-    text.strip!
+    text&.strip!
     spoiler_text&.strip!
 
     self.reply                  = !(in_reply_to_id.nil? && thread.nil?) unless reply
@@ -185,6 +200,6 @@ class Status < ApplicationRecord
   private
 
   def filter_from_context?(status, account)
-    account&.blocking?(status.account_id) || !status.permitted?(account)
+    account&.blocking?(status.account_id) || account&.muting?(status.account_id) || (status.account.silenced? && !account&.following?(status.account_id)) || !status.permitted?(account)
   end
 end
diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb
index fcc691bef..ae7ae446e 100644
--- a/app/models/stream_entry.rb
+++ b/app/models/stream_entry.rb
@@ -6,16 +6,13 @@ class StreamEntry < ApplicationRecord
   belongs_to :account, inverse_of: :stream_entries
   belongs_to :activity, polymorphic: true
 
-  belongs_to :status,    foreign_type: 'Status',    foreign_key: 'activity_id'
-  belongs_to :follow,    foreign_type: 'Follow',    foreign_key: 'activity_id'
-  belongs_to :favourite, foreign_type: 'Favourite', foreign_key: 'activity_id'
-  belongs_to :block,     foreign_type: 'Block',     foreign_key: 'activity_id'
+  belongs_to :status, foreign_type: 'Status', foreign_key: 'activity_id', inverse_of: :stream_entry
 
   validates :account, :activity, presence: true
 
   STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, mentions: :account], thread: [:stream_entry, :account]].freeze
 
-  scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES, favourite: [:account, :stream_entry, status: STATUS_INCLUDES], follow: [:target_account, :stream_entry]) }
+  scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES) }
 
   def object_type
     if orphaned?
@@ -30,7 +27,7 @@ class StreamEntry < ApplicationRecord
   end
 
   def targeted?
-    [:follow, :unfollow, :block, :unblock, :share, :favorite].include? verb
+    [:follow, :request_friend, :authorize, :reject, :unfollow, :block, :unblock, :share, :favorite].include? verb
   end
 
   def target
@@ -58,7 +55,7 @@ class StreamEntry < ApplicationRecord
   end
 
   def activity
-    !new_record? ? send(activity_type.downcase) : super
+    !new_record? ? send(activity_type.underscore) || super : super
   end
 
   private
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 77a73cce8..15625ca43 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -3,11 +3,31 @@
 class Tag < ApplicationRecord
   has_and_belongs_to_many :statuses
 
-  HASHTAG_RE = /(?:^|[^\/\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i
+  HASHTAG_RE = /(?:^|[^\/\)\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i
 
   validates :name, presence: true, uniqueness: true
 
   def to_param
     name
   end
+
+  class << self
+    def search_for(terms, limit = 5)
+      terms      = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' ')))
+      textsearch = 'to_tsvector(\'simple\', tags.name)'
+      query      = 'to_tsquery(\'simple\', \'\'\' \' || ' + terms + ' || \' \'\'\' || \':*\')'
+
+      sql = <<SQL
+        SELECT
+          tags.*,
+          ts_rank_cd(#{textsearch}, #{query}) AS rank
+        FROM tags
+        WHERE #{query} @@ #{textsearch}
+        ORDER BY rank DESC
+        LIMIT ?
+SQL
+
+      Tag.find_by_sql([sql, limit])
+    end
+  end
 end
diff --git a/app/models/user.rb b/app/models/user.rb
index 08aac2679..bf2916d90 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -14,9 +14,10 @@ class User < ApplicationRecord
   validates :locale, inclusion: I18n.available_locales.map(&:to_s), unless: 'locale.nil?'
   validates :email, email: true
 
-  scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
-  scope :recent,   -> { order('id desc') }
-  scope :admins,   -> { where(admin: true) }
+  scope :prolific,  -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
+  scope :recent,    -> { order('id desc') }
+  scope :admins,    -> { where(admin: true) }
+  scope :confirmed, -> { where.not(confirmed_at: nil) }
 
   def send_devise_notification(notification, *args)
     devise_mailer.send(notification, self, *args).deliver_later
diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb
new file mode 100644
index 000000000..f55439dcb
--- /dev/null
+++ b/app/services/account_search_service.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class AccountSearchService < BaseService
+  def call(query, limit, resolve = false, account = nil)
+    return [] if query.blank? || query.start_with?('#')
+
+    username, domain = query.gsub(/\A@/, '').split('@')
+    domain = nil if TagManager.instance.local_domain?(domain)
+
+    if domain.nil?
+      exact_match = Account.find_local(username)
+      results     = account.nil? ? Account.search_for(username, limit) : Account.advanced_search_for(username, account, limit)
+    else
+      exact_match = Account.find_remote(username, domain)
+      results     = account.nil? ? Account.search_for("#{username} #{domain}", limit) : Account.advanced_search_for("#{username} #{domain}", account, limit)
+    end
+
+    results = [exact_match] + results.reject { |a| a.id == exact_match.id } if exact_match
+
+    if resolve && !exact_match && !domain.nil?
+      results = [FollowRemoteAccountService.new.call("#{username}@#{domain}")]
+    end
+
+    results
+  end
+end
diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb
new file mode 100644
index 000000000..ac465bdb2
--- /dev/null
+++ b/app/services/authorize_follow_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class AuthorizeFollowService < BaseService
+  def call(source_account, target_account)
+    follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
+    follow_request.authorize!
+    NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local?
+  end
+
+  private
+
+  def build_xml(follow_request)
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, Time.now.utc, follow_request.id, 'FollowRequest'
+        title xml, "#{follow_request.target_account.acct} authorizes follow request by #{follow_request.account.acct}"
+
+        author(xml) do
+          include_author xml, follow_request.target_account
+        end
+
+        object_type xml, :activity
+        verb xml, :authorize
+
+        target(xml) do
+          author(xml) do
+            include_author xml, follow_request.account
+          end
+
+          object_type xml, :activity
+          verb xml, :request_friend
+
+          target(xml) do
+            include_author xml, follow_request.target_account
+          end
+        end
+      end
+    end.to_xml
+  end
+end
diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb
index 9518b1fcf..6c131bd34 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -1,13 +1,11 @@
 # frozen_string_literal: true
 
 class BlockDomainService < BaseService
-  def call(domain, severity)
-    DomainBlock.where(domain: domain).first_or_create!(domain: domain, severity: severity)
-
-    if severity == :silence
-      Account.where(domain: domain).update_all(silenced: true)
+  def call(domain_block)
+    if domain_block.silence?
+      Account.where(domain: domain_block.domain).update_all(silenced: true)
     else
-      Account.where(domain: domain).find_each do |account|
+      Account.where(domain: domain_block.domain).find_each do |account|
         account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed?
         SuspendAccountService.new.call(account)
       end
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index e04b6cc39..bd914d8be 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class BlockService < BaseService
+  include StreamEntryRenderer
+
   def call(account, target_account)
     return if account.id == target_account.id
 
@@ -10,6 +12,28 @@ class BlockService < BaseService
     block = account.block!(target_account)
 
     BlockWorker.perform_async(account.id, target_account.id)
-    NotificationWorker.perform_async(block.stream_entry.id, target_account.id) unless target_account.local?
+    NotificationWorker.perform_async(build_xml(block), account.id, target_account.id) unless target_account.local?
+  end
+
+  private
+
+  def build_xml(block)
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, block.created_at, block.id, 'Block'
+        title xml, "#{block.account.acct} no longer wishes to interact with #{block.target_account.acct}"
+
+        author(xml) do
+          include_author xml, block.account
+        end
+
+        object_type xml, :activity
+        verb xml, :block
+
+        target(xml) do
+          include_author xml, block.target_account
+        end
+      end
+    end.to_xml
   end
 end
diff --git a/app/services/concerns/stream_entry_renderer.rb b/app/services/concerns/stream_entry_renderer.rb
new file mode 100644
index 000000000..a4255daea
--- /dev/null
+++ b/app/services/concerns/stream_entry_renderer.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module StreamEntryRenderer
+  def stream_entry_to_xml(stream_entry)
+    renderer = StreamEntriesController.renderer.new(method: 'get', http_host: Rails.configuration.x.local_domain, https: Rails.configuration.x.use_https)
+    renderer.render(:show, assigns: { stream_entry: stream_entry }, formats: [:atom])
+  end
+end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 71f6cbca1..42222c25b 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -4,8 +4,15 @@ class FanOutOnWriteService < BaseService
   # Push a status into home and mentions feeds
   # @param [Status] status
   def call(status)
+    raise Mastodon::RaceConditionError if status.visibility.nil?
+
     deliver_to_self(status) if status.account.local?
-    deliver_to_followers(status)
+
+    if status.direct_visibility?
+      deliver_to_mentioned_followers(status)
+    else
+      deliver_to_followers(status)
+    end
 
     return if status.account.silenced? || !status.public_visibility? || status.reblog?
 
@@ -26,9 +33,18 @@ class FanOutOnWriteService < BaseService
   def deliver_to_followers(status)
     Rails.logger.debug "Delivering status #{status.id} to followers"
 
-    status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).find_each do |follower|
-      next if FeedManager.instance.filter?(:home, status, follower)
-      FeedManager.instance.push(:home, follower, status)
+    status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).select(:id).find_each do |follower|
+      FeedInsertWorker.perform_async(status.id, follower.id)
+    end
+  end
+
+  def deliver_to_mentioned_followers(status)
+    Rails.logger.debug "Delivering status #{status.id} to mentioned followers"
+
+    status.mentions.includes(:account).each do |mention|
+      mentioned_account = mention.account
+      next if !mentioned_account.local? || !mentioned_account.following?(status.account) || FeedManager.instance.filter?(:home, status, mention.account_id)
+      FeedManager.instance.push(:home, mentioned_account, status)
     end
   end
 
@@ -37,9 +53,9 @@ class FanOutOnWriteService < BaseService
 
     payload = FeedManager.instance.inline_render(nil, 'api/v1/statuses/show', status)
 
-    status.tags.find_each do |tag|
-      FeedManager.instance.broadcast("hashtag:#{tag.name}", event: 'update', payload: payload)
-      FeedManager.instance.broadcast("hashtag:#{tag.name}:local", event: 'update', payload: payload) if status.account.local?
+    status.tags.pluck(:name).each do |hashtag|
+      FeedManager.instance.broadcast("hashtag:#{hashtag}", event: 'update', payload: payload)
+      FeedManager.instance.broadcast("hashtag:#{hashtag}:local", event: 'update', payload: payload) if status.account.local?
     end
   end
 
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index d5fbd29e9..5cc96403c 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -6,18 +6,42 @@ class FavouriteService < BaseService
   # @param [Status] status
   # @return [Favourite]
   def call(account, status)
-    raise Mastodon::NotPermitted unless status.permitted?(account)
+    raise Mastodon::NotPermittedError unless status.permitted?(account)
 
     favourite = Favourite.create!(account: account, status: status)
 
-    Pubsubhubbub::DistributionWorker.perform_async(favourite.stream_entry.id)
-
     if status.local?
       NotifyService.new.call(favourite.status.account, favourite)
     else
-      NotificationWorker.perform_async(favourite.stream_entry.id, status.account_id)
+      NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id)
     end
 
     favourite
   end
+
+  private
+
+  def build_xml(favourite)
+    description = "#{favourite.account.acct} favourited a status by #{favourite.status.account.acct}"
+
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, favourite.created_at, favourite.id, 'Favourite'
+        title xml, description
+        content xml, description
+
+        author(xml) do
+          include_author xml, favourite.account
+        end
+
+        object_type xml, :activity
+        verb xml, :favorite
+        in_reply_to xml, TagManager.instance.uri_for(favourite.status), TagManager.instance.url_for(favourite.status)
+
+        target(xml) do
+          include_target xml, favourite.status
+        end
+      end
+    end.to_xml
+  end
 end
diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb
index 98ee1db84..c3dad1eb9 100644
--- a/app/services/fetch_atom_service.rb
+++ b/app/services/fetch_atom_service.rb
@@ -2,6 +2,8 @@
 
 class FetchAtomService < BaseService
   def call(url)
+    return if url.blank?
+
     response = http_client.head(url)
 
     Rails.logger.debug "Remote status HEAD request returned code #{response.code}"
@@ -45,6 +47,6 @@ class FetchAtomService < BaseService
   end
 
   def http_client
-    HTTP.timeout(:per_operation, write: 20, connect: 20, read: 50).follow
+    HTTP.timeout(:per_operation, write: 10, connect: 10, read: 10).follow
   end
 end
diff --git a/app/services/fetch_remote_account_service.rb b/app/services/fetch_remote_account_service.rb
index 3c3694a65..6a6a696d6 100644
--- a/app/services/fetch_remote_account_service.rb
+++ b/app/services/fetch_remote_account_service.rb
@@ -1,8 +1,13 @@
 # frozen_string_literal: true
 
 class FetchRemoteAccountService < BaseService
-  def call(url)
-    atom_url, body = FetchAtomService.new.call(url)
+  def call(url, prefetched_body = nil)
+    if prefetched_body.nil?
+      atom_url, body = FetchAtomService.new.call(url)
+    else
+      atom_url = url
+      body     = prefetched_body
+    end
 
     return nil if atom_url.nil?
     process_atom(atom_url, body)
@@ -22,7 +27,9 @@ class FetchRemoteAccountService < BaseService
 
     Rails.logger.debug "Going to webfinger #{username}@#{domain}"
 
-    return FollowRemoteAccountService.new.call("#{username}@#{domain}")
+    account = FollowRemoteAccountService.new.call("#{username}@#{domain}")
+    UpdateRemoteProfileService.new.call(xml, account) unless account.nil?
+    account
   rescue TypeError
     Rails.logger.debug "Unparseable URL given: #{url}"
     nil
diff --git a/app/services/fetch_remote_resource_service.rb b/app/services/fetch_remote_resource_service.rb
new file mode 100644
index 000000000..2185ceb20
--- /dev/null
+++ b/app/services/fetch_remote_resource_service.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class FetchRemoteResourceService < BaseService
+  def call(url)
+    atom_url, body = FetchAtomService.new.call(url)
+
+    return nil if atom_url.nil?
+
+    xml = Nokogiri::XML(body)
+    xml.encoding = 'utf-8'
+
+    if xml.root.name == 'feed'
+      FetchRemoteAccountService.new.call(atom_url, body)
+    elsif xml.root.name == 'entry'
+      FetchRemoteStatusService.new.call(atom_url, body)
+    end
+  end
+end
diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb
index 7063231e4..e2d185723 100644
--- a/app/services/fetch_remote_status_service.rb
+++ b/app/services/fetch_remote_status_service.rb
@@ -1,8 +1,13 @@
 # frozen_string_literal: true
 
 class FetchRemoteStatusService < BaseService
-  def call(url)
-    atom_url, body = FetchAtomService.new.call(url)
+  def call(url, prefetched_body = nil)
+    if prefetched_body.nil?
+      atom_url, body = FetchAtomService.new.call(url)
+    else
+      atom_url = url
+      body     = prefetched_body
+    end
 
     return nil if atom_url.nil?
     process_atom(atom_url, body)
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 9f34cb6ac..17b3b2542 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -1,14 +1,16 @@
 # frozen_string_literal: true
 
 class FollowService < BaseService
+  include StreamEntryRenderer
+
   # Follow a remote user, notify remote user about the follow
   # @param [Account] source_account From which to follow
   # @param [String] uri User URI to follow in the form of username@domain
   def call(source_account, uri)
-    target_account = follow_remote_account_service.call(uri)
+    target_account = FollowRemoteAccountService.new.call(uri)
 
     raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
-    raise Mastodon::NotPermitted       if target_account.blocking?(source_account) || source_account.blocking?(target_account)
+    raise Mastodon::NotPermittedError       if target_account.blocking?(source_account) || source_account.blocking?(target_account)
 
     if target_account.locked?
       request_follow(source_account, target_account)
@@ -20,10 +22,14 @@ class FollowService < BaseService
   private
 
   def request_follow(source_account, target_account)
-    return unless target_account.local?
-
     follow_request = FollowRequest.create!(account: source_account, target_account: target_account)
-    NotifyService.new.call(target_account, follow_request)
+
+    if target_account.local?
+      NotifyService.new.call(target_account, follow_request)
+    else
+      NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id)
+      AfterRemoteFollowRequestWorker.perform_async(follow_request.id)
+    end
 
     follow_request
   end
@@ -34,12 +40,12 @@ class FollowService < BaseService
     if target_account.local?
       NotifyService.new.call(target_account, follow)
     else
-      subscribe_service.call(target_account)
-      NotificationWorker.perform_async(follow.stream_entry.id, target_account.id)
+      SubscribeService.new.call(target_account) unless target_account.subscribed?
+      NotificationWorker.perform_async(build_follow_xml(follow), source_account.id, target_account.id)
+      AfterRemoteFollowWorker.perform_async(follow.id)
     end
 
     MergeWorker.perform_async(target_account.id, source_account.id)
-    Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id)
 
     follow
   end
@@ -48,11 +54,49 @@ class FollowService < BaseService
     Redis.current
   end
 
-  def follow_remote_account_service
-    @follow_remote_account_service ||= FollowRemoteAccountService.new
+  def build_follow_request_xml(follow_request)
+    description = "#{follow_request.account.acct} requested to follow #{follow_request.target_account.acct}"
+
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, follow_request.created_at, follow_request.id, 'FollowRequest'
+        title xml, description
+        content xml, description
+
+        author(xml) do
+          include_author xml, follow_request.account
+        end
+
+        object_type xml, :activity
+        verb xml, :request_friend
+
+        target(xml) do
+          include_author xml, follow_request.target_account
+        end
+      end
+    end.to_xml
   end
 
-  def subscribe_service
-    @subscribe_service ||= SubscribeService.new
+  def build_follow_xml(follow)
+    description = "#{follow.account.acct} started following #{follow.target_account.acct}"
+
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, follow.created_at, follow.id, 'Follow'
+        title xml, description
+        content xml, description
+
+        author(xml) do
+          include_author xml, follow.account
+        end
+
+        object_type xml, :activity
+        verb xml, :follow
+
+        target(xml) do
+          include_author xml, follow.target_account
+        end
+      end
+    end.to_xml
   end
 end
diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb
new file mode 100644
index 000000000..0050cfc8d
--- /dev/null
+++ b/app/services/mute_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class MuteService < BaseService
+  def call(account, target_account)
+    return if account.id == target_account.id
+    clear_home_timeline(account, target_account)
+    account.mute!(target_account)
+  end
+
+  private
+
+  def clear_home_timeline(account, target_account)
+    home_key = FeedManager.instance.key(:home, account.id)
+
+    target_account.statuses.select('id').find_each do |status|
+      redis.zrem(home_key, status.id)
+    end
+  end
+
+  def redis
+    Redis.current
+  end
+end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 942cd9d21..24486f220 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -17,7 +17,7 @@ class NotifyService < BaseService
   private
 
   def blocked_mention?
-    FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient)
+    FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient.id)
   end
 
   def blocked_favourite?
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 979941c84..b8179f7dc 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -13,6 +13,7 @@ class PostStatusService < BaseService
   # @option [Doorkeeper::Application] :application
   # @return [Status]
   def call(account, text, in_reply_to = nil, options = {})
+    media  = validate_media!(options[:media_ids])
     status = account.statuses.create!(text: text,
                                       thread: in_reply_to,
                                       sensitive: options[:sensitive],
@@ -20,7 +21,7 @@ class PostStatusService < BaseService
                                       visibility: options[:visibility],
                                       application: options[:application])
 
-    attach_media(status, options[:media_ids])
+    attach_media(status, media)
     process_mentions_service.call(status)
     process_hashtags_service.call(status)
 
@@ -33,10 +34,20 @@ class PostStatusService < BaseService
 
   private
 
-  def attach_media(status, media_ids)
+  def validate_media!(media_ids)
     return if media_ids.nil? || !media_ids.is_a?(Enumerable)
 
+    raise Mastodon::ValidationError, 'Cannot attach more than 4 files' if media_ids.size > 4
+
     media = MediaAttachment.where(status_id: nil).where(id: media_ids.take(4).map(&:to_i))
+
+    raise Mastodon::ValidationError, 'Cannot attach a video to a toot that already contains images' if media.size > 1 && media.find(&:video?)
+
+    media
+  end
+
+  def attach_media(status, media)
+    return if media.nil?
     media.update(status_id: status.id)
   end
 
diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb
index 54d11b631..07dcb81da 100644
--- a/app/services/precompute_feed_service.rb
+++ b/app/services/precompute_feed_service.rb
@@ -4,10 +4,12 @@ class PrecomputeFeedService < BaseService
   # Fill up a user's home/mentions feed from DB and return a subset
   # @param [Symbol] type :home or :mentions
   # @param [Account] account
-  def call(type, account)
-    Status.send("as_#{type}_timeline", account).limit(FeedManager::MAX_ITEMS).each do |status|
-      next if FeedManager.instance.filter?(type, status, account)
-      redis.zadd(FeedManager.instance.key(type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
+  def call(_, account)
+    redis.pipelined do
+      Status.as_home_timeline(account).limit(FeedManager::MAX_ITEMS / 4).each do |status|
+        next if status.direct_visibility? || FeedManager.instance.filter?(:home, status, account.id)
+        redis.zadd(FeedManager.instance.key(:home, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
+      end
     end
   end
 
diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb
index c411e3e82..69911abc5 100644
--- a/app/services/process_feed_service.rb
+++ b/app/services/process_feed_service.rb
@@ -61,12 +61,25 @@ class ProcessFeedService < BaseService
 
       status.save!
 
-      NotifyService.new.call(status.reblog.account, status) if status.reblog? && status.reblog.account.local?
+      notify_about_mentions!(status) unless status.reblog?
+      notify_about_reblog!(status) if status.reblog? && status.reblog.account.local?
       Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution"
       DistributionWorker.perform_async(status.id)
       status
     end
 
+    def notify_about_mentions!(status)
+      status.mentions.includes(:account).each do |mention|
+        mentioned_account = mention.account
+        next unless mentioned_account.local?
+        NotifyService.new.call(mentioned_account, mention)
+      end
+    end
+
+    def notify_about_reblog!(status)
+      NotifyService.new.call(status.reblog.account, status)
+    end
+
     def delete_status
       Rails.logger.debug "Deleting remote status #{id}"
       status = Status.find_by(uri: id)
@@ -106,7 +119,8 @@ class ProcessFeedService < BaseService
         text: content(entry),
         spoiler_text: content_warning(entry),
         created_at: published(entry),
-        reply: thread?(entry)
+        reply: thread?(entry),
+        visibility: visibility_scope(entry)
       )
 
       if thread?(entry)
@@ -144,15 +158,9 @@ class ProcessFeedService < BaseService
 
     def mentions_from_xml(parent, xml)
       processed_account_ids = []
-      public_visibility     = false
 
       xml.xpath('./xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each do |link|
-        if link['ostatus:object-type'] == TagManager::TYPES[:collection] && link['href'] == TagManager::COLLECTIONS[:public]
-          public_visibility = true
-          next
-        elsif link['ostatus:object-type'] == TagManager::TYPES[:group]
-          next
-        end
+        next if [TagManager::TYPES[:group], TagManager::TYPES[:collection]].include? link['ostatus:object-type']
 
         url = Addressable::URI.parse(link['href'])
 
@@ -164,17 +172,11 @@ class ProcessFeedService < BaseService
 
         next if mentioned_account.nil? || processed_account_ids.include?(mentioned_account.id)
 
-        mention = mentioned_account.mentions.where(status: parent).first_or_create(status: parent)
-
-        # Notify local user
-        NotifyService.new.call(mentioned_account, mention) if mentioned_account.local?
+        mentioned_account.mentions.where(status: parent).first_or_create(status: parent)
 
         # So we can skip duplicate mentions
         processed_account_ids << mentioned_account.id
       end
-
-      parent.visibility = public_visibility ? :public : :unlisted
-      parent.save!
     end
 
     def hashtags_from_xml(parent, xml)
@@ -189,6 +191,9 @@ class ProcessFeedService < BaseService
         next unless link['href']
 
         media = MediaAttachment.where(status: parent, remote_url: link['href']).first_or_initialize(account: parent.account, status: parent, remote_url: link['href'])
+        parsed_url = URI.parse(link['href'])
+
+        next if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty?
 
         begin
           media.file_remote_url = link['href']
@@ -230,6 +235,10 @@ class ProcessFeedService < BaseService
       xml.at_xpath('./xmlns:summary', xmlns: TagManager::XMLNS)&.content || ''
     end
 
+    def visibility_scope(xml = @xml)
+      xml.at_xpath('./mastodon:scope', mastodon: TagManager::MTDN_XMLNS)&.content&.to_sym || :public
+    end
+
     def published(xml = @xml)
       xml.at_xpath('./xmlns:published', xmlns: TagManager::XMLNS).content
     end
diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb
index 5f91e3127..d5f7b4b3c 100644
--- a/app/services/process_interaction_service.rb
+++ b/app/services/process_interaction_service.rb
@@ -29,10 +29,18 @@ class ProcessInteractionService < BaseService
       case verb(xml)
       when :follow
         follow!(account, target_account) unless target_account.locked? || target_account.blocking?(account)
+      when :request_friend
+        follow_request!(account, target_account) unless !target_account.locked? || target_account.blocking?(account)
+      when :authorize
+        authorize_follow_request!(account, target_account)
+      when :reject
+        reject_follow_request!(account, target_account)
       when :unfollow
         unfollow!(account, target_account)
       when :favorite
         favourite!(xml, account)
+      when :unfavorite
+        unfavourite!(xml, account)
       when :post
         add_post!(body, account) if mentions_account?(xml, target_account)
       when :share
@@ -56,7 +64,7 @@ class ProcessInteractionService < BaseService
   end
 
   def mentions_account?(xml, account)
-    xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each { |mention_link| return true if mention_link.attribute('href').value == TagManager.instance.url_for(account) }
+    xml.xpath('/xmlns:entry/xmlns:link[@rel="mentioned"]', xmlns: TagManager::XMLNS).each { |mention_link| return true if [TagManager.instance.uri_for(account), TagManager.instance.url_for(account)].include?(mention_link.attribute('href').value) }
     false
   end
 
@@ -72,6 +80,22 @@ class ProcessInteractionService < BaseService
     NotifyService.new.call(target_account, follow)
   end
 
+  def follow_request!(account, target_account)
+    follow_request = FollowRequest.create!(account: account, target_account: target_account)
+    NotifyService.new.call(target_account, follow_request)
+  end
+
+  def authorize_follow_request!(account, target_account)
+    follow_request = FollowRequest.find_by(account: target_account, target_account: account)
+    follow_request&.authorize!
+    SubscribeService.new.call(account) unless account.subscribed?
+  end
+
+  def reject_follow_request!(account, target_account)
+    follow_request = FollowRequest.find_by(account: target_account, target_account: account)
+    follow_request&.reject!
+  end
+
   def unfollow!(account, target_account)
     account.unfollow!(target_account)
   end
@@ -99,6 +123,12 @@ class ProcessInteractionService < BaseService
     NotifyService.new.call(current_status.account, favourite)
   end
 
+  def unfavourite!(xml, from_account)
+    current_status = status(xml)
+    favourite = current_status.favourites.where(account: from_account).first
+    favourite&.destroy
+  end
+
   def add_post!(body, account)
     process_feed_service.call(body, account)
   end
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 72568e702..aa0a4d71b 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class ProcessMentionsService < BaseService
+  include StreamEntryRenderer
+
   # Scan status for mentions and fetch remote mentioned users, create
   # local mention pointers, send Salmon notifications to mentioned
   # remote users
@@ -25,15 +27,13 @@ class ProcessMentionsService < BaseService
       mentioned_account.mentions.where(status: status).first_or_create(status: status)
     end
 
-    status.mentions.each do |mention|
+    status.mentions.includes(:account).each do |mention|
       mentioned_account = mention.account
 
-      next if status.private_visibility? && (!mentioned_account.following?(status.account) || !mentioned_account.local?)
-
       if mentioned_account.local?
         NotifyService.new.call(mentioned_account, mention)
       else
-        NotificationWorker.perform_async(status.stream_entry.id, mentioned_account.id)
+        NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id)
       end
     end
   end
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/services/reblog_service.rb b/app/services/reblog_service.rb
index 4ea0dbf6c..11446ce28 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class ReblogService < BaseService
+  include StreamEntryRenderer
+
   # Reblog a status and notify its remote author
   # @param [Account] account Account to reblog from
   # @param [Status] reblogged_status Status to be reblogged
@@ -8,7 +10,7 @@ class ReblogService < BaseService
   def call(account, reblogged_status)
     reblogged_status = reblogged_status.reblog if reblogged_status.reblog?
 
-    raise Mastodon::NotPermitted if reblogged_status.private_visibility? || !reblogged_status.permitted?(account)
+    raise Mastodon::NotPermittedError if reblogged_status.direct_visibility? || reblogged_status.private_visibility? || !reblogged_status.permitted?(account)
 
     reblog = account.statuses.create!(reblog: reblogged_status, text: '')
 
@@ -18,15 +20,9 @@ class ReblogService < BaseService
     if reblogged_status.local?
       NotifyService.new.call(reblog.reblog.account, reblog)
     else
-      NotificationWorker.perform_async(reblog.stream_entry.id, reblog.reblog.account_id)
+      NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), account.id, reblog.reblog.account_id)
     end
 
     reblog
   end
-
-  private
-
-  def send_interaction_service
-    @send_interaction_service ||= SendInteractionService.new
-  end
 end
diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb
new file mode 100644
index 000000000..1b03d62e6
--- /dev/null
+++ b/app/services/reject_follow_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class RejectFollowService < BaseService
+  def call(source_account, target_account)
+    follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
+    follow_request.reject!
+    NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local?
+  end
+
+  private
+
+  def build_xml(follow_request)
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, Time.now.utc, follow_request.id, 'FollowRequest'
+        title xml, "#{follow_request.target_account.acct} rejects follow request by #{follow_request.account.acct}"
+
+        author(xml) do
+          include_author xml, follow_request.target_account
+        end
+
+        object_type xml, :activity
+        verb xml, :reject
+
+        target(xml) do
+          author(xml) do
+            include_author xml, follow_request.account
+          end
+
+          object_type xml, :activity
+          verb xml, :request_friend
+
+          target(xml) do
+            include_author xml, follow_request.target_account
+          end
+        end
+      end
+    end.to_xml
+  end
+end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 48e8dd3b8..cf1f432e4 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class RemoveStatusService < BaseService
+  include StreamEntryRenderer
+
   def call(status)
     remove_from_self(status) if status.account.local?
     remove_from_followers(status)
@@ -30,12 +32,16 @@ class RemoveStatusService < BaseService
   end
 
   def remove_from_mentioned(status)
+    notified_domains = []
+
     status.mentions.each do |mention|
       mentioned_account = mention.account
 
       if mentioned_account.local?
         unpush(:mentions, mentioned_account, status)
       else
+        next if notified_domains.include?(mentioned_account.domain)
+        notified_domains << mentioned_account.domain
         send_delete_salmon(mentioned_account, status)
       end
     end
@@ -43,7 +49,7 @@ class RemoveStatusService < BaseService
 
   def send_delete_salmon(account, status)
     return unless status.local?
-    NotificationWorker.perform_async(status.stream_entry.id, account.id)
+    NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, account.id)
   end
 
   def remove_reblogs(status)
@@ -53,7 +59,7 @@ class RemoveStatusService < BaseService
   end
 
   def unpush(type, receiver, status)
-    if status.reblog?
+    if status.reblog? && !redis.zscore(FeedManager.instance.key(type, receiver.id), status.reblog_of_id).nil?
       redis.zadd(FeedManager.instance.key(type, receiver.id), status.reblog_of_id, status.reblog_of_id)
     else
       redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id)
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 04de8a134..e9745010b 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -1,24 +1,19 @@
 # frozen_string_literal: true
 
 class SearchService < BaseService
-  def call(query, limit, resolve = false)
-    return if query.blank? || query.start_with?('#')
+  def call(query, limit, resolve = false, account = nil)
+    results = { accounts: [], hashtags: [], statuses: [] }
 
-    username, domain = query.gsub(/\A@/, '').split('@')
+    return results if query.blank?
 
-    if domain.nil?
-      exact_match = Account.find_local(username)
-      results     = Account.search_for(username)
-    else
-      exact_match = Account.find_remote(username, domain)
-      results     = Account.search_for("#{username} #{domain}")
-    end
+    if query =~ /\Ahttps?:\/\//
+      resource = FetchRemoteResourceService.new.call(query)
 
-    results = results.limit(limit).to_a
-    results = [exact_match] + results.reject { |a| a.id == exact_match.id } if exact_match
-
-    if resolve && !exact_match && !domain.nil?
-      results = [FollowRemoteAccountService.new.call("#{username}@#{domain}")]
+      results[:accounts] << resource if resource.is_a?(Account)
+      results[:statuses] << resource if resource.is_a?(Status)
+    else
+      results[:accounts] = AccountSearchService.new.call(query, limit, resolve, account)
+      results[:hashtags] = Tag.search_for(query.gsub(/\A#/, ''), limit) unless query.start_with?('@')
     end
 
     results
diff --git a/app/services/send_interaction_service.rb b/app/services/send_interaction_service.rb
index 05a1e77e3..99113eeca 100644
--- a/app/services/send_interaction_service.rb
+++ b/app/services/send_interaction_service.rb
@@ -2,27 +2,16 @@
 
 class SendInteractionService < BaseService
   # Send an Atom representation of an interaction to a remote Salmon endpoint
-  # @param [StreamEntry] stream_entry
+  # @param [String] Entry XML
+  # @param [Account] source_account
   # @param [Account] target_account
-  def call(stream_entry, target_account)
-    envelope = salmon.pack(entry_xml(stream_entry), stream_entry.account.keypair)
+  def call(xml, source_account, target_account)
+    envelope = salmon.pack(xml, source_account.keypair)
     salmon.post(target_account.salmon_url, envelope)
   end
 
   private
 
-  def entry_xml(stream_entry)
-    Nokogiri::XML::Builder.new do |xml|
-      entry(xml, true) do
-        author(xml) do
-          include_author xml, stream_entry.account
-        end
-
-        include_entry xml, stream_entry
-      end
-    end.to_xml
-  end
-
   def salmon
     @salmon ||= OStatus2::Salmon.new
   end
diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb
index f389364f9..c4f789f74 100644
--- a/app/services/unblock_service.rb
+++ b/app/services/unblock_service.rb
@@ -5,6 +5,28 @@ class UnblockService < BaseService
     return unless account.blocking?(target_account)
 
     unblock = account.unblock!(target_account)
-    NotificationWorker.perform_async(unblock.stream_entry.id, target_account.id) unless target_account.local?
+    NotificationWorker.perform_async(build_xml(unblock), account.id, target_account.id) unless target_account.local?
+  end
+
+  private
+
+  def build_xml(block)
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, Time.now.utc, block.id, 'Block'
+        title xml, "#{block.account.acct} no longer blocks #{block.target_account.acct}"
+
+        author(xml) do
+          include_author xml, block.account
+        end
+
+        object_type xml, :activity
+        verb xml, :unblock
+
+        target(xml) do
+          include_author xml, block.target_account
+        end
+      end
+    end.to_xml
   end
 end
diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb
index de6e84e7d..5f0ba4254 100644
--- a/app/services/unfavourite_service.rb
+++ b/app/services/unfavourite_service.rb
@@ -5,10 +5,34 @@ class UnfavouriteService < BaseService
     favourite = Favourite.find_by!(account: account, status: status)
     favourite.destroy!
 
-    unless status.local?
-      NotificationWorker.perform_async(favourite.stream_entry.id, status.account_id)
-    end
+    NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id) unless status.local?
 
     favourite
   end
+
+  private
+
+  def build_xml(favourite)
+    description = "#{favourite.account.acct} no longer favourites a status by #{favourite.status.account.acct}"
+
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, Time.now.utc, favourite.id, 'Favourite'
+        title xml, description
+        content xml, description
+
+        author(xml) do
+          include_author xml, favourite.account
+        end
+
+        object_type xml, :activity
+        verb xml, :unfavorite
+        in_reply_to xml, TagManager.instance.uri_for(favourite.status), TagManager.instance.url_for(favourite.status)
+
+        target(xml) do
+          include_target xml, favourite.status
+        end
+      end
+    end.to_xml
+  end
 end
diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb
index f469793c1..3440da364 100644
--- a/app/services/unfollow_service.rb
+++ b/app/services/unfollow_service.rb
@@ -6,7 +6,32 @@ class UnfollowService < BaseService
   # @param [Account] target_account Which to unfollow
   def call(source_account, target_account)
     follow = source_account.unfollow!(target_account)
-    NotificationWorker.perform_async(follow.stream_entry.id, target_account.id) unless target_account.local?
+    NotificationWorker.perform_async(build_xml(follow), source_account.id, target_account.id) unless target_account.local?
     UnmergeWorker.perform_async(target_account.id, source_account.id)
   end
+
+  private
+
+  def build_xml(follow)
+    description = "#{follow.account.acct} is no longer following #{follow.target_account.acct}"
+
+    Nokogiri::XML::Builder.new do |xml|
+      entry(xml, true) do
+        unique_id xml, Time.now.utc, follow.id, 'Follow'
+        title xml, description
+        content xml, description
+
+        author(xml) do
+          include_author xml, follow.account
+        end
+
+        object_type xml, :activity
+        verb xml, :unfollow
+
+        target(xml) do
+          include_author xml, follow.target_account
+        end
+      end
+    end.to_xml
+  end
 end
diff --git a/app/services/unmute_service.rb b/app/services/unmute_service.rb
new file mode 100644
index 000000000..6aeea358f
--- /dev/null
+++ b/app/services/unmute_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class UnmuteService < BaseService
+  def call(account, target_account)
+    return unless account.muting?(target_account)
+
+    account.unmute!(target_account)
+
+    MergeWorker.perform_async(target_account.id, account.id) if account.following?(target_account)
+  end
+end
diff --git a/app/services/update_remote_profile_service.rb b/app/services/update_remote_profile_service.rb
index ad9c56540..74baa1cc5 100644
--- a/app/services/update_remote_profile_service.rb
+++ b/app/services/update_remote_profile_service.rb
@@ -10,9 +10,11 @@ class UpdateRemoteProfileService < BaseService
     unless author_xml.nil?
       account.display_name = author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS).nil?
       account.note         = author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).content unless author_xml.at_xpath('./poco:note', poco: TagManager::POCO_XMLNS).nil?
+      account.locked       = author_xml.at_xpath('./mastodon:scope', mastodon: TagManager::MTDN_XMLNS)&.content == 'private'
 
       unless account.suspended? || DomainBlock.find_by(domain: account.domain)&.reject_media?
         account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'] unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS).nil? || author_xml.at_xpath('./xmlns:link[@rel="avatar"]', xmlns: TagManager::XMLNS)['href'].blank?
+        account.header_remote_url = author_xml.at_xpath('./xmlns:link[@rel="header"]', xmlns: TagManager::XMLNS)['href'] unless author_xml.at_xpath('./xmlns:link[@rel="header"]', xmlns: TagManager::XMLNS).nil? || author_xml.at_xpath('./xmlns:link[@rel="header"]', xmlns: TagManager::XMLNS)['href'].blank?
       end
     end
 
diff --git a/app/views/about/index.html.haml b/app/views/about/index.html.haml
index 88bfe3d61..ebca4213a 100644
--- a/app/views/about/index.html.haml
+++ b/app/views/about/index.html.haml
@@ -20,18 +20,81 @@
     Mastodon
 
   %p= t('about.about_mastodon').html_safe
-  %p= t('about.about_instance', instance: Rails.configuration.x.local_domain).html_safe
 
-  .screenshot= image_tag 'screenshot.png'
+  .screenshot-with-signup
+    .mascot= image_tag 'fluffy-elephant-friend.png'
+
+    - if @open_registrations
+      = simple_form_for(@user, url: user_registration_path) do |f|
+        = f.simple_fields_for :account do |ff|
+          = ff.input :username, autofocus: true, placeholder: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username') }
+
+        = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
+        = f.input :password, autocomplete: "off", placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }
+        = f.input :password_confirmation, autocomplete: "off", placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password') }
+
+        .actions
+          = f.button :button, t('about.get_started'), type: :submit
+
+        .info
+          = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn'
+          ·
+          = link_to t('about.about_this'), about_more_path
+    - else
+      .closed-registrations-message
+        - if @closed_registrations_message.blank?
+          %p= t('about.closed_registrations')
+        - else
+          = @closed_registrations_message.html_safe
+        .info
+          = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn'
+          ·
+          = link_to t('about.other_instances'), 'https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/List-of-Mastodon-instances.md'
+          ·
+          = link_to t('about.about_this'), about_more_path
+
+  %h3= t('about.features_headline')
+
+  .features-list
+    .features-list__column
+      %ul.fa-ul
+        %li
+          = fa_icon('li check-square')
+          = t 'about.features.chronology'
+        %li
+          = fa_icon('li check-square')
+          = t 'about.features.public'
+        %li
+          = fa_icon('li check-square')
+          = t 'about.features.characters'
+        %li
+          = fa_icon('li check-square')
+          = t 'about.features.gifv'
+    .features-list__column
+      %ul.fa-ul
+        %li
+          = fa_icon('li check-square')
+          = t 'about.features.privacy'
+        %li
+          = fa_icon('li check-square')
+          = t 'about.features.blocks'
+        %li
+          = fa_icon('li check-square')
+          = t 'about.features.ethics'
+        %li
+          = fa_icon('li check-square')
+          = t 'about.features.api'
 
   - unless @description.blank?
+    %h3= t('about.description_headline', domain: Rails.configuration.x.local_domain)
     %p= @description.html_safe
 
   .actions
     .info
-      = link_to t('about.learn_more'), about_more_path
       = link_to t('about.terms'), terms_path
+      ·
+      = link_to t('about.apps'), 'https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md'
+      ·
       = link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon'
-
-    = link_to t('about.get_started'), new_user_registration_path, class: 'button webapp-btn'
-    = link_to t('auth.login'), new_user_session_path, class: 'button webapp-btn'
+      ·
+      = link_to t('about.other_instances'), 'https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/List-of-Mastodon-instances.md'
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index f575e855e..0d43fba30 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -20,15 +20,15 @@
       .account__header__content.p-note.emojify= Formatter.instance.simplified_format(@account)
 
     .details-counters
-      .counter{ class: active_nav_class(account_url(@account)) }
-        = link_to account_url(@account), class: 'u-url u-uid' do
+      .counter{ class: active_nav_class(short_account_url(@account)) }
+        = link_to short_account_url(@account), class: 'u-url u-uid' do
           %span.counter-label= t('accounts.posts')
-          %span.counter-number= number_with_delimiter @account.statuses.count
+          %span.counter-number= number_with_delimiter @account.statuses_count
       .counter{ class: active_nav_class(following_account_url(@account)) }
         = link_to following_account_url(@account) do
           %span.counter-label= t('accounts.following')
-          %span.counter-number= number_with_delimiter @account.following.count
+          %span.counter-number= number_with_delimiter @account.following_count
       .counter{ class: active_nav_class(followers_account_url(@account)) }
         = link_to followers_account_url(@account) do
           %span.counter-label= t('accounts.followers')
-          %span.counter-number= number_with_delimiter @account.followers.count
+          %span.counter-number= number_with_delimiter @account.followers_count
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/accounts/show.html.haml b/app/views/accounts/show.html.haml
index c194ce33d..3b8c67b45 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -14,6 +14,9 @@
   %meta{ property: 'og:image:height', content: '120' }/
   %meta{ property: 'twitter:card', content: 'summary' }/
 
+- if !user_signed_in? && !Rails.configuration.x.single_user_mode
+  = render partial: 'shared/landing_strip', locals: { account: @account }
+
 .h-feed
   %data.p-name{ value: "#{@account.username} on #{Rails.configuration.x.local_domain}" }/
 
@@ -28,4 +31,4 @@
 
   .pagination
     - if @statuses.size == 20
-      = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), account_url(@account, max_id: @statuses.last.id), class: 'next_page', rel: 'next'
+      = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), short_account_url(@account, max_id: @statuses.last.id), class: 'next_page', rel: 'next'
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..ba1c3bae7 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/domain_blocks/index.html.haml b/app/views/admin/domain_blocks/index.html.haml
index dbaeb4716..eb7894b86 100644
--- a/app/views/admin/domain_blocks/index.html.haml
+++ b/app/views/admin/domain_blocks/index.html.haml
@@ -14,3 +14,4 @@
         %td= block.severity
 
 = will_paginate @blocks, pagination_options
+= link_to 'Add new', new_admin_domain_block_path, class: 'button'
diff --git a/app/views/admin/domain_blocks/new.html.haml b/app/views/admin/domain_blocks/new.html.haml
new file mode 100644
index 000000000..fbd39d6cf
--- /dev/null
+++ b/app/views/admin/domain_blocks/new.html.haml
@@ -0,0 +1,18 @@
+- content_for :page_title do
+  New domain block
+
+= simple_form_for @domain_block, url: admin_domain_blocks_path do |f|
+  = render 'shared/error_messages', object: @domain_block
+
+  %p.hint The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts.
+
+  = f.input :domain, placeholder: 'Domain'
+  = f.input :severity, collection: DomainBlock.severities.keys, wrapper: :with_label, include_blank: false
+
+  %p.hint
+    %strong Silence
+    will make the account's posts invisible to anyone who isn't following them.
+    %strong Suspend
+    will remove all of the account's content, media, and profile data.
+  .actions
+    = f.button :button, 'Create block', type: :submit
diff --git a/app/views/admin/reports/index.html.haml b/app/views/admin/reports/index.html.haml
new file mode 100644
index 000000000..839259dc2
--- /dev/null
+++ b/app/views/admin/reports/index.html.haml
@@ -0,0 +1,32 @@
+- 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'
+
+= form_tag do
+
+  %table.table
+    %thead
+      %tr
+        %th
+        %th ID
+        %th Target
+        %th Reported by
+        %th Comment
+        %th
+    %tbody
+      - @reports.each do |report|
+        %tr
+          %td= check_box_tag 'select', report.id
+          %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..caa8415df
--- /dev/null
+++ b/app/views/admin/reports/show.html.haml
@@ -0,0 +1,44 @@
+- 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'
+
+- if !@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'
+- elsif !@report.action_taken_by_account.nil?
+  %hr/
+
+  %p
+    %strong Action taken by:
+    = @report.action_taken_by_account.acct
diff --git a/app/views/admin/settings/index.html.haml b/app/views/admin/settings/index.html.haml
index 5b482213b..02faac8c2 100644
--- a/app/views/admin/settings/index.html.haml
+++ b/app/views/admin/settings/index.html.haml
@@ -17,6 +17,10 @@
       %td= best_in_place @settings['site_contact_email'], :value, url: admin_setting_path(@settings['site_contact_email']), place_holder: 'Enter a public e-mail address'
     %tr
       %td
+        %strong Site title
+      %td= best_in_place @settings['site_title'], :value, url: admin_setting_path(@settings['site_title'])
+    %tr
+      %td
         %strong Site description
         %br/
         Displayed as a paragraph on the frontpage and used as a meta tag.
@@ -33,4 +37,16 @@
         Displayed on extended information page
         %br/
         You can use HTML tags
-      %td= best_in_place @settings['site_extended_description'], :value, as: :textarea, url: admin_setting_path(@settings['site_extended_description'])
\ No newline at end of file
+      %td= best_in_place @settings['site_extended_description'], :value, as: :textarea, url: admin_setting_path(@settings['site_extended_description'])
+    %tr
+      %td
+        %strong Open registration
+      %td= best_in_place @settings['open_registrations'], :value, as: :checkbox, collection: { false: 'Disabled', true: 'Enabled'}, url: admin_setting_path(@settings['open_registrations'])
+    %tr
+      %td
+        %strong Closed registration message
+        %br/
+        Displayed on frontpage when registrations are closed
+        %br/
+        You can use HTML tags
+      %td= best_in_place @settings['closed_registrations_message'], :value, as: :textarea, url: admin_setting_path(@settings['closed_registrations_message'])
diff --git a/app/views/api/v1/accounts/relationship.rabl b/app/views/api/v1/accounts/relationship.rabl
index 22b37586e..d6f1dd48a 100644
--- a/app/views/api/v1/accounts/relationship.rabl
+++ b/app/views/api/v1/accounts/relationship.rabl
@@ -4,4 +4,5 @@ attribute :id
 node(:following)   { |account| @following[account.id]   || false }
 node(:followed_by) { |account| @followed_by[account.id] || false }
 node(:blocking)    { |account| @blocking[account.id]    || false }
+node(:muting)      { |account| @muting[account.id]      || false }
 node(:requested)   { |account| @requested[account.id]   || false }
diff --git a/app/views/api/v1/accounts/show.rabl b/app/views/api/v1/accounts/show.rabl
index 151a5080d..32df0457a 100644
--- a/app/views/api/v1/accounts/show.rabl
+++ b/app/views/api/v1/accounts/show.rabl
@@ -1,11 +1,11 @@
 object @account
 
-attributes :id, :username, :acct, :display_name, :locked
+attributes :id, :username, :acct, :display_name, :locked, :created_at
 
 node(:note)            { |account| Formatter.instance.simplified_format(account) }
 node(:url)             { |account| TagManager.instance.url_for(account) }
 node(:avatar)          { |account| full_asset_url(account.avatar.url(:original)) }
 node(:header)          { |account| full_asset_url(account.header.url(:original)) }
-node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : (account.try(:followers_count) || account.followers.count) }
-node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : (account.try(:following_count) || account.following.count) }
-node(:statuses_count)  { |account| defined?(@statuses_counts_map)  ? (@statuses_counts_map[account.id]  || 0) : (account.try(:statuses_count)  || account.statuses.count) }
+node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : account.followers_count }
+node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : account.following_count }
+node(:statuses_count)  { |account| defined?(@statuses_counts_map)  ? (@statuses_counts_map[account.id]  || 0) : account.statuses_count }
diff --git a/app/views/api/v1/instances/show.rabl b/app/views/api/v1/instances/show.rabl
new file mode 100644
index 000000000..88eb08a9e
--- /dev/null
+++ b/app/views/api/v1/instances/show.rabl
@@ -0,0 +1,6 @@
+object false
+
+node(:uri)         { Rails.configuration.x.local_domain }
+node(:title)       { Setting.site_title }
+node(:description) { Setting.site_description }
+node(:email)       { Setting.site_contact_email }
diff --git a/app/views/api/v1/media/create.rabl b/app/views/api/v1/media/create.rabl
index 0b42e6e3d..916217cbd 100644
--- a/app/views/api/v1/media/create.rabl
+++ b/app/views/api/v1/media/create.rabl
@@ -1,5 +1,5 @@
 object @media
 attribute :id, :type
-node(:url) { |media| full_asset_url(media.file.url( :original)) }
-node(:preview_url) { |media| full_asset_url(media.file.url( :small)) }
+node(:url) { |media| full_asset_url(media.file.url(:original)) }
+node(:preview_url) { |media| full_asset_url(media.file.url(:small)) }
 node(:text_url) { |media| medium_url(media) }
diff --git a/app/views/api/v1/mutes/index.rabl b/app/views/api/v1/mutes/index.rabl
new file mode 100644
index 000000000..9f3b13a53
--- /dev/null
+++ b/app/views/api/v1/mutes/index.rabl
@@ -0,0 +1,2 @@
+collection @accounts
+extends 'api/v1/accounts/show'
diff --git a/app/views/api/v1/notifications/show.rabl b/app/views/api/v1/notifications/show.rabl
index fe2218ed7..ca34f2d5d 100644
--- a/app/views/api/v1/notifications/show.rabl
+++ b/app/views/api/v1/notifications/show.rabl
@@ -1,6 +1,6 @@
 object @notification
 
-attributes :id, :type
+attributes :id, :type, :created_at
 
 child from_account: :account do
   extends 'api/v1/accounts/show'
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/api/v1/search/index.rabl b/app/views/api/v1/search/index.rabl
new file mode 100644
index 000000000..8d1640f2d
--- /dev/null
+++ b/app/views/api/v1/search/index.rabl
@@ -0,0 +1,13 @@
+object @search
+
+child :accounts, object_root: false do
+  extends 'api/v1/accounts/show'
+end
+
+node(:hashtags) do |search|
+  search.hashtags.map(&:name)
+end
+
+child :statuses, object_root: false do
+  extends 'api/v1/statuses/show'
+end
diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl
index 059e0d13f..54e8a86d8 100644
--- a/app/views/api/v1/statuses/_show.rabl
+++ b/app/views/api/v1/statuses/_show.rabl
@@ -3,8 +3,8 @@ attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitiv
 node(:uri)              { |status| TagManager.instance.uri_for(status) }
 node(:content)          { |status| Formatter.instance.format(status) }
 node(:url)              { |status| TagManager.instance.url_for(status) }
-node(:reblogs_count)    { |status| defined?(@reblogs_counts_map)    ? (@reblogs_counts_map[status.id]    || 0) : status.reblogs.count }
-node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites.count }
+node(:reblogs_count)    { |status| defined?(@reblogs_counts_map)    ? (@reblogs_counts_map[status.id]    || 0) : status.reblogs_count }
+node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites_count }
 
 child :application do
   extends 'api/v1/apps/show'
diff --git a/app/views/doorkeeper/authorized_applications/index.html.haml b/app/views/doorkeeper/authorized_applications/index.html.haml
new file mode 100644
index 000000000..d4719881c
--- /dev/null
+++ b/app/views/doorkeeper/authorized_applications/index.html.haml
@@ -0,0 +1,23 @@
+- content_for :page_title do
+  = t('doorkeeper.authorized_applications.index.title')
+
+%table.table
+  %thead
+    %tr
+      %th= t('doorkeeper.authorized_applications.index.application')
+      %th= t('doorkeeper.authorized_applications.index.scopes')
+      %th= t('doorkeeper.authorized_applications.index.created_at')
+      %th
+  %tbody
+    - @applications.each do |application|
+      %tr
+        %td
+          - if application.website.blank?
+            = application.name
+          - else
+            = link_to application.name, application.website
+        %th= application.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.join('<br />').html_safe
+        %td= l application.created_at
+        %td
+          - unless application.superapp?
+            = table_link_to 'times', t('doorkeeper.authorized_applications.buttons.revoke'), oauth_authorized_application_path(application), method: :delete, data: { confirm: t('doorkeeper.authorized_applications.confirmations.revoke') }
diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml
index 750d6036f..59fe078df 100644
--- a/app/views/layouts/admin.html.haml
+++ b/app/views/layouts/admin.html.haml
@@ -12,6 +12,15 @@
     .content-wrapper
       .content
         %h2= yield :page_title
+
+        - if flash[:notice]
+          .flash-message.notice
+            %strong= flash[:notice]
+
+        - if flash[:alert]
+          .flash-message.alert
+            %strong= flash[:alert]
+
         = yield
 
 = render template: "layouts/application", locals: { body_classes: 'admin' }
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index e122e1c55..7eae6982b 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -13,7 +13,7 @@
 
     %title
       = "#{yield(:page_title)} - " if content_for?(:page_title)
-      Mastodon
+      = Setting.site_title
 
     = stylesheet_link_tag 'application', media: 'all'
     = csrf_meta_tags
diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb
index ae52173b5..21bf444c3 100644
--- a/app/views/layouts/mailer.text.erb
+++ b/app/views/layouts/mailer.text.erb
@@ -1,5 +1,5 @@
 <%= yield %>
-
 ---
 
 <%= t('application_mailer.signature', instance: Rails.configuration.x.local_domain) %>
+<%= t('application_mailer.settings', link: settings_preferences_url) %>
diff --git a/app/views/notification_mailer/_status.text.erb b/app/views/notification_mailer/_status.text.erb
index b089a7b73..85a0136b7 100644
--- a/app/views/notification_mailer/_status.text.erb
+++ b/app/views/notification_mailer/_status.text.erb
@@ -1,3 +1,3 @@
-<%= strip_tags(@status.content) %>
+<%= raw Formatter.instance.plaintext(status) %>
 
-<%= web_url("statuses/#{@status.id}") %>
+<%= raw t('application_mailer.view')%> <%= web_url("statuses/#{status.id}") %>
diff --git a/app/views/notification_mailer/digest.text.erb b/app/views/notification_mailer/digest.text.erb
new file mode 100644
index 000000000..95aed6793
--- /dev/null
+++ b/app/views/notification_mailer/digest.text.erb
@@ -0,0 +1,15 @@
+<%= display_name(@me) %>,
+
+<%= raw t('notification_mailer.digest.body', since: @since, instance: root_url) %>
+<% @notifications.each do |notification| %>
+
+* <%= raw t('notification_mailer.digest.mention', name: notification.from_account.acct) %>
+
+  <%= raw Formatter.instance.plaintext(notification.target_status) %>
+
+  <%= raw t('application_mailer.view')%> <%= web_url("statuses/#{notification.target_status.id}") %>
+<% end %>
+<% if @follows_since > 0 %>
+
+<%= raw t('notification_mailer.digest.new_followers_summary', count: @follows_since) %>
+<% end %>
diff --git a/app/views/notification_mailer/favourite.text.erb b/app/views/notification_mailer/favourite.text.erb
index b2e1e3e9e..99852592f 100644
--- a/app/views/notification_mailer/favourite.text.erb
+++ b/app/views/notification_mailer/favourite.text.erb
@@ -1,5 +1,5 @@
 <%= display_name(@me) %>,
 
-<%= t('notification_mailer.favourite.body', name: @account.acct) %>
+<%= raw t('notification_mailer.favourite.body', name: @account.acct) %>
 
-<%= render partial: 'status' %>
+<%= render partial: 'status', locals: { status: @status } %>
diff --git a/app/views/notification_mailer/follow.text.erb b/app/views/notification_mailer/follow.text.erb
index 4b2ec142c..af41a3080 100644
--- a/app/views/notification_mailer/follow.text.erb
+++ b/app/views/notification_mailer/follow.text.erb
@@ -1,5 +1,5 @@
 <%= display_name(@me) %>,
 
-<%= t('notification_mailer.follow.body', name: @account.acct) %>
+<%= raw t('notification_mailer.follow.body', name: @account.acct) %>
 
-<%= web_url("accounts/#{@account.id}") %>
+<%= raw t('application_mailer.view')%> <%= web_url("accounts/#{@account.id}") %>
diff --git a/app/views/notification_mailer/follow_request.text.erb b/app/views/notification_mailer/follow_request.text.erb
index c0d38ec67..49087a575 100644
--- a/app/views/notification_mailer/follow_request.text.erb
+++ b/app/views/notification_mailer/follow_request.text.erb
@@ -1,5 +1,5 @@
 <%= display_name(@me) %>,
 
-<%= t('notification_mailer.follow_request.body', name: @account.acct) %>
+<%= raw t('notification_mailer.follow_request.body', name: @account.acct) %>
 
-<%= web_url("follow_requests") %>
+<%= raw t('application_mailer.view')%> <%= web_url("follow_requests") %>
diff --git a/app/views/notification_mailer/mention.text.erb b/app/views/notification_mailer/mention.text.erb
index 31a294bb9..c0d4be1d8 100644
--- a/app/views/notification_mailer/mention.text.erb
+++ b/app/views/notification_mailer/mention.text.erb
@@ -1,5 +1,5 @@
 <%= display_name(@me) %>,
 
-<%= t('notification_mailer.mention.body', name: @status.account.acct) %>
+<%= raw t('notification_mailer.mention.body', name: @status.account.acct) %>
 
-<%= render partial: 'status' %>
+<%= render partial: 'status', locals: { status: @status } %>
diff --git a/app/views/notification_mailer/reblog.text.erb b/app/views/notification_mailer/reblog.text.erb
index 7af8052ca..c32b48650 100644
--- a/app/views/notification_mailer/reblog.text.erb
+++ b/app/views/notification_mailer/reblog.text.erb
@@ -1,5 +1,5 @@
 <%= display_name(@me) %>,
 
-<%= t('notification_mailer.reblog.body', name: @account.acct) %>
+<%= raw t('notification_mailer.reblog.body', name: @account.acct) %>
 
-<%= render partial: 'status' %>
+<%= render partial: 'status', locals: { status: @status } %>
diff --git a/app/views/settings/exports/show.html.haml b/app/views/settings/exports/show.html.haml
new file mode 100644
index 000000000..0a0ff8633
--- /dev/null
+++ b/app/views/settings/exports/show.html.haml
@@ -0,0 +1,17 @@
+- content_for :page_title do
+  = t('settings.export')
+
+%table.table
+  %tbody
+    %tr
+      %th= t('exports.storage')
+      %td= number_to_human_size @total_storage
+      %td
+    %tr
+      %th= t('exports.follows')
+      %td= @total_follows
+      %td= table_link_to 'download', t('exports.csv'), follows_settings_export_path(format: :csv)
+    %tr
+      %th= t('exports.blocks')
+      %td= @total_blocks
+      %td= table_link_to 'download', t('exports.csv'), blocks_settings_export_path(format: :csv)
diff --git a/app/views/settings/imports/show.html.haml b/app/views/settings/imports/show.html.haml
new file mode 100644
index 000000000..8502913dc
--- /dev/null
+++ b/app/views/settings/imports/show.html.haml
@@ -0,0 +1,11 @@
+- content_for :page_title do
+  = t('settings.import')
+
+%p.hint= t('imports.preface')
+
+= simple_form_for @import, url: settings_import_path do |f|
+  = f.input :type, collection: Import.types.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| I18n.t("imports.types.#{type}") }
+  = f.input :data, wrapper: :with_label, hint: t('simple_form.hints.imports.data')
+
+  .actions
+    = f.button :button, t('imports.upload'), type: :submit
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index aee0540d2..64cf32c3a 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -7,7 +7,7 @@
   .fields-group
     = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }
 
-    = f.input :setting_default_privacy, collection: Status.visibilities.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| I18n.t("statuses.visibilities.#{visibility}") }, required: false
+    = f.input :setting_default_privacy, collection: Status.visibilities.keys - ['direct'], wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| I18n.t("statuses.visibilities.#{visibility}") }, required: false
 
   .fields-group
     = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff|
@@ -16,6 +16,7 @@
       = ff.input :reblog, as: :boolean, wrapper: :with_label
       = ff.input :favourite, as: :boolean, wrapper: :with_label
       = ff.input :mention, as: :boolean, wrapper: :with_label
+      = ff.input :digest, as: :boolean, wrapper: :with_label
 
   = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff|
     = ff.input :must_be_follower, as: :boolean, wrapper: :with_label
diff --git a/app/views/settings/two_factor_auths/show.html.haml b/app/views/settings/two_factor_auths/show.html.haml
index bad359f8f..87bfadc69 100644
--- a/app/views/settings/two_factor_auths/show.html.haml
+++ b/app/views/settings/two_factor_auths/show.html.haml
@@ -3,11 +3,15 @@
 
 .simple_form
   - if current_user.otp_required_for_login
-    %p= t('two_factor_auth.instructions_html')
+    %p.hint= t('two_factor_auth.instructions_html')
 
     .qr-code= raw @qrcode.as_svg(padding: 0, module_size: 5)
 
+    %p.hint= t('two_factor_auth.plaintext_secret_html', secret: current_user.otp_secret)
+
+    %p.hint= 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')
+    %p.hint= t('two_factor_auth.description_html')
     = link_to t('two_factor_auth.enable'), enable_settings_two_factor_auth_path, data: { method: 'POST' }, class: 'block-button'
diff --git a/app/views/shared/_landing_strip.html.haml b/app/views/shared/_landing_strip.html.haml
new file mode 100644
index 000000000..bb081e544
--- /dev/null
+++ b/app/views/shared/_landing_strip.html.haml
@@ -0,0 +1,2 @@
+.landing-strip
+  = t('landing_strip_html', name: display_name(account), domain: Rails.configuration.x.local_domain, sign_up_path: new_user_registration_path)
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index 235dc6086..8495f28b9 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -9,8 +9,10 @@
 
   .status__content.e-content.p-name.emojify<
     - unless status.spoiler_text.blank?
-      %p= status.spoiler_text
-    = Formatter.instance.format(status)
+      %p{ style: 'margin-bottom: 0' }<
+        %span>= "#{status.spoiler_text} "
+        %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
+    %div{ style: "display: #{status.spoiler_text.blank? ? 'block' : 'none'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
 
   - unless status.media_attachments.empty?
     - if status.media_attachments.first.video?
@@ -22,9 +24,9 @@
       .detailed-status__attachments
         - if status.sensitive?
           = render partial: 'stream_entries/content_spoiler'
-        - status.media_attachments.each do |media|
-          .media-item
-            = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}"
+        .status__attachments__inner
+          - status.media_attachments.each do |media|
+            = render partial: 'stream_entries/media', locals: { media: media }
 
   %div.detailed-status__meta
     %data.dt-published{ value: status.created_at.to_time.iso8601 }
@@ -39,11 +41,11 @@
       ·
     %span<
       = fa_icon('retweet')
-      %span= status.reblogs.count
+      %span= status.reblogs_count
     ·
     %span<
       = fa_icon('star')
-      %span= status.favourites.count
+      %span= status.favourites_count
 
     - if user_signed_in?
       ·
diff --git a/app/views/stream_entries/_favourite.html.haml b/app/views/stream_entries/_favourite.html.haml
deleted file mode 100644
index ea4879328..000000000
--- a/app/views/stream_entries/_favourite.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-.entry.entry-favourite
-  .content.emojify
-    %strong= favourite.account.acct
-    = t('stream_entries.favourited')
-    %strong= favourite.status.account.acct
diff --git a/app/views/stream_entries/_follow.html.haml b/app/views/stream_entries/_follow.html.haml
deleted file mode 100644
index da6d062f0..000000000
--- a/app/views/stream_entries/_follow.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-.entry.entry-follow
-  .content.emojify
-    %strong= link_to follow.account.acct, account_path(follow.account)
-    = t('stream_entries.is_now_following')
-    %strong= link_to follow.target_account.acct, TagManager.instance.url_for(follow.target_account)
diff --git a/app/views/stream_entries/_media.html.haml b/app/views/stream_entries/_media.html.haml
new file mode 100644
index 000000000..cd7faa700
--- /dev/null
+++ b/app/views/stream_entries/_media.html.haml
@@ -0,0 +1,4 @@
+.media-item
+  = link_to media.remote_url.blank? ? media.file.url(:original) : media.remote_url, style: media.image? ? "background-image: url(#{media.file.url(:original)})" : "", target: '_blank', rel: 'noopener', class: "u-#{media.video? || media.gifv? ? 'video' : 'photo'}" do
+    - unless media.image?
+      %video{ src: media.file.url(:original), autoplay: true, loop: true }/
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index 95f90abd9..2eb9bf166 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -14,19 +14,22 @@
 
   .status__content.e-content.p-name.emojify<
     - unless status.spoiler_text.blank?
-      %p= status.spoiler_text
-    = Formatter.instance.format(status)
+      %p{ style: 'margin-bottom: 0' }<
+        %span>= "#{status.spoiler_text} "
+        %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
+    %div{ style: "display: #{status.spoiler_text.blank? ? 'block' : 'none'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
 
   - unless status.media_attachments.empty?
     .status__attachments
       - if status.sensitive?
         = render partial: 'stream_entries/content_spoiler'
       - if status.media_attachments.first.video?
-        .video-item
-          = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do
-            .video-item__play
-              = fa_icon('play')
+        .status__attachments__inner
+          .video-item
+            = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do
+              .video-item__play
+                = fa_icon('play')
       - else
-        - status.media_attachments.each do |media|
-          .media-item
-            = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}"
+        .status__attachments__inner
+          - status.media_attachments.each do |media|
+            = render partial: 'stream_entries/media', locals: { media: media }
diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml
index f70e2c890..cdd0dde3b 100644
--- a/app/views/stream_entries/_status.html.haml
+++ b/app/views/stream_entries/_status.html.haml
@@ -4,7 +4,7 @@
 - centered        ||= include_threads && !is_predecessor && !is_successor
 
 - if status.reply? && include_threads
-  = render partial: 'status', collection: @ancestors, as: :status, locals: { is_predecessor: true }
+  = render partial: 'stream_entries/status', collection: @ancestors, as: :status, locals: { is_predecessor: true }
 
 .entry{ class: entry_classes(status, is_predecessor, is_successor, include_threads) }
   - if status.reblog?
@@ -19,4 +19,4 @@
   = render partial: centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status', locals: { status: proper_status(status) }
 
 - if include_threads
-  = render partial: 'status', collection: @descendants, as: :status, locals: { is_successor: true }
+  = render partial: 'stream_entries/status', collection: @descendants, as: :status, locals: { is_successor: true }
diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml
index 6bad45705..c109ff4b8 100644
--- a/app/views/stream_entries/show.html.haml
+++ b/app/views/stream_entries/show.html.haml
@@ -20,5 +20,8 @@
 
   %meta{ property: 'twitter:card', content: 'summary' }/
 
+- if !user_signed_in? && !Rails.configuration.x.single_user_mode
+  = render partial: 'shared/landing_strip', locals: { account: @stream_entry.account }
+
 .activity-stream.activity-stream-headless
-  = render partial: @type, locals: { @type.to_sym => @stream_entry.activity, include_threads: true }
+  = render partial: "stream_entries/#{@type}", locals: { @type.to_sym => @stream_entry.activity, include_threads: true }
diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml
index 412ec4fa5..32a50e158 100644
--- a/app/views/tags/show.html.haml
+++ b/app/views/tags/show.html.haml
@@ -1,10 +1,18 @@
+- content_for :page_title do
+  = "##{@tag.name}"
+
+.compact-header
+  %h1<
+    = link_to 'Mastodon', root_path
+    %small= "##{@tag.name}"
+
 - if @statuses.empty?
   .accounts-grid
     = render partial: 'accounts/nothing_here'
 - else
   .activity-stream.h-feed
-    = render partial: 'stream_entries/status', collection: @statuses, as: :status, cached: true
+    = render partial: 'stream_entries/status', collection: @statuses, as: :status
 
-.pagination
-  - if @statuses.size == 20
+- if @statuses.size == 20
+  .pagination
     = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), tag_url(@tag, max_id: @statuses.last.id), class: 'next_page', rel: 'next'
diff --git a/app/workers/after_remote_follow_request_worker.rb b/app/workers/after_remote_follow_request_worker.rb
new file mode 100644
index 000000000..1f2db3061
--- /dev/null
+++ b/app/workers/after_remote_follow_request_worker.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AfterRemoteFollowRequestWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull', retry: 5
+
+  def perform(follow_request_id)
+    follow_request  = FollowRequest.find(follow_request_id)
+    updated_account = FetchRemoteAccountService.new.call(follow_request.target_account.remote_url)
+
+    return if updated_account.nil? || updated_account.locked?
+
+    follow_request.destroy
+    FollowService.new.call(follow_request.account, updated_account.acct)
+  end
+end
diff --git a/app/workers/after_remote_follow_worker.rb b/app/workers/after_remote_follow_worker.rb
new file mode 100644
index 000000000..bdd2c2a91
--- /dev/null
+++ b/app/workers/after_remote_follow_worker.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AfterRemoteFollowWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull', retry: 5
+
+  def perform(follow_id)
+    follow          = Follow.find(follow_id)
+    updated_account = FetchRemoteAccountService.new.call(follow.target_account.remote_url)
+
+    return if updated_account.nil? || !updated_account.locked?
+
+    follow.destroy
+    FollowService.new.call(follow.account, updated_account.acct)
+  end
+end
diff --git a/app/workers/digest_mailer_worker.rb b/app/workers/digest_mailer_worker.rb
new file mode 100644
index 000000000..dedb21e4e
--- /dev/null
+++ b/app/workers/digest_mailer_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class DigestMailerWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'mailers'
+
+  def perform(user_id)
+    user = User.find(user_id)
+    return unless user.settings.notification_emails['digest']
+    NotificationMailer.digest(user.account).deliver_now!
+    user.touch(:last_emailed_at)
+  end
+end
diff --git a/app/workers/domain_block_worker.rb b/app/workers/domain_block_worker.rb
new file mode 100644
index 000000000..884477829
--- /dev/null
+++ b/app/workers/domain_block_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class DomainBlockWorker
+  include Sidekiq::Worker
+
+  def perform(domain_block_id)
+    BlockDomainService.new.call(DomainBlock.find(domain_block_id))
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb
new file mode 100644
index 000000000..a58dfaa74
--- /dev/null
+++ b/app/workers/feed_insert_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class FeedInsertWorker
+  include Sidekiq::Worker
+
+  def perform(status_id, follower_id)
+    status   = Status.find(status_id)
+    follower = Account.find(follower_id)
+
+    return if FeedManager.instance.filter?(:home, status, follower.id)
+    FeedManager.instance.push(:home, follower, status)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/import_worker.rb b/app/workers/import_worker.rb
new file mode 100644
index 000000000..7cf29fb53
--- /dev/null
+++ b/app/workers/import_worker.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'csv'
+
+class ImportWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull', retry: false
+
+  def perform(import_id)
+    import = Import.find(import_id)
+
+    case import.type
+    when 'blocking'
+      process_blocks(import)
+    when 'following'
+      process_follows(import)
+    end
+
+    import.destroy
+  end
+
+  private
+
+  def process_blocks(import)
+    from_account = import.account
+
+    CSV.foreach(import.data.path) do |row|
+      next if row.size != 1
+
+      begin
+        target_account = FollowRemoteAccountService.new.call(row[0])
+        next if target_account.nil?
+        BlockService.new.call(from_account, target_account)
+      rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError
+        next
+      end
+    end
+  end
+
+  def process_follows(import)
+    from_account = import.account
+
+    CSV.foreach(import.data.path) do |row|
+      next if row.size != 1
+
+      begin
+        FollowService.new.call(from_account, row[0])
+      rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError
+        next
+      end
+    end
+  end
+end
diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb
index af3394b8b..834b0088b 100644
--- a/app/workers/link_crawl_worker.rb
+++ b/app/workers/link_crawl_worker.rb
@@ -3,7 +3,7 @@
 class LinkCrawlWorker
   include Sidekiq::Worker
 
-  sidekiq_options retry: false
+  sidekiq_options queue: 'pull', retry: false
 
   def perform(status_id)
     FetchLinkCardService.new.call(Status.find(status_id))
diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb
index 0f288f43f..d745cb99c 100644
--- a/app/workers/merge_worker.rb
+++ b/app/workers/merge_worker.rb
@@ -3,6 +3,8 @@
 class MergeWorker
   include Sidekiq::Worker
 
+  sidekiq_options queue: 'pull'
+
   def perform(from_account_id, into_account_id)
     FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id))
   end
diff --git a/app/workers/notification_worker.rb b/app/workers/notification_worker.rb
index e4c38d384..da1d6ab45 100644
--- a/app/workers/notification_worker.rb
+++ b/app/workers/notification_worker.rb
@@ -3,9 +3,9 @@
 class NotificationWorker
   include Sidekiq::Worker
 
-  sidekiq_options retry: 5
+  sidekiq_options queue: 'push', retry: 5
 
-  def perform(stream_entry_id, target_account_id)
-    SendInteractionService.new.call(StreamEntry.find(stream_entry_id), Account.find(target_account_id))
+  def perform(xml, source_account_id, target_account_id)
+    SendInteractionService.new.call(xml, Account.find(source_account_id), Account.find(target_account_id))
   end
 end
diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb
index 15005bc80..466def3a8 100644
--- a/app/workers/pubsubhubbub/delivery_worker.rb
+++ b/app/workers/pubsubhubbub/delivery_worker.rb
@@ -22,6 +22,7 @@ class Pubsubhubbub::DeliveryWorker
                    .headers(headers)
                    .post(subscription.callback_url, body: payload)
 
+    return subscription.destroy! if response.code > 299 && response.code < 500 && response.code != 429 # HTTP 4xx means error is not temporary, except for 429 (throttling)
     raise "Delivery failed for #{subscription.callback_url}: HTTP #{response.code}" unless response.code > 199 && response.code < 300
 
     subscription.touch(:last_successful_delivery_at)
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/app/workers/push_notification_worker.rb b/app/workers/push_notification_worker.rb
deleted file mode 100644
index a61d0e349..000000000
--- a/app/workers/push_notification_worker.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-class PushNotificationWorker
-  include Sidekiq::Worker
-
-  def perform(notification_id)
-    SendPushNotificationService.new.call(Notification.find(notification_id))
-  rescue ActiveRecord::RecordNotFound
-    true
-  end
-end
diff --git a/app/workers/regeneration_worker.rb b/app/workers/regeneration_worker.rb
index 3aece0ba2..da8b845f6 100644
--- a/app/workers/regeneration_worker.rb
+++ b/app/workers/regeneration_worker.rb
@@ -3,7 +3,9 @@
 class RegenerationWorker
   include Sidekiq::Worker
 
-  def perform(account_id, timeline_type)
-    PrecomputeFeedService.new.call(timeline_type, Account.find(account_id))
+  sidekiq_options queue: 'pull', backtrace: true, unique: :until_executed
+
+  def perform(account_id, _ = :home)
+    PrecomputeFeedService.new.call(:home, Account.find(account_id))
   end
 end
diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb
index 593edd032..38287e8e6 100644
--- a/app/workers/thread_resolve_worker.rb
+++ b/app/workers/thread_resolve_worker.rb
@@ -3,7 +3,7 @@
 class ThreadResolveWorker
   include Sidekiq::Worker
 
-  sidekiq_options retry: false
+  sidekiq_options queue: 'pull', retry: false
 
   def perform(child_status_id, parent_url)
     child_status  = Status.find(child_status_id)
diff --git a/app/workers/unmerge_worker.rb b/app/workers/unmerge_worker.rb
index dbf7243de..ea6aacebf 100644
--- a/app/workers/unmerge_worker.rb
+++ b/app/workers/unmerge_worker.rb
@@ -3,6 +3,8 @@
 class UnmergeWorker
   include Sidekiq::Worker
 
+  sidekiq_options queue: 'pull'
+
   def perform(from_account_id, into_account_id)
     FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id))
   end