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/background-photo.jpegbin128834 -> 894792 bytes
-rw-r--r--app/assets/images/boost_sprite.pngbin0 -> 1326 bytes
-rw-r--r--app/assets/javascripts/application_public.js5
-rw-r--r--app/assets/javascripts/components/actions/accounts.jsx60
-rw-r--r--app/assets/javascripts/components/actions/cards.jsx47
-rw-r--r--app/assets/javascripts/components/actions/compose.jsx17
-rw-r--r--app/assets/javascripts/components/actions/favourites.jsx83
-rw-r--r--app/assets/javascripts/components/actions/meta.jsx8
-rw-r--r--app/assets/javascripts/components/actions/notifications.jsx28
-rw-r--r--app/assets/javascripts/components/actions/settings.jsx19
-rw-r--r--app/assets/javascripts/components/actions/statuses.jsx28
-rw-r--r--app/assets/javascripts/components/actions/store.jsx17
-rw-r--r--app/assets/javascripts/components/actions/timelines.jsx52
-rw-r--r--app/assets/javascripts/components/components/account.jsx26
-rw-r--r--app/assets/javascripts/components/components/autosuggest_textarea.jsx18
-rw-r--r--app/assets/javascripts/components/components/avatar.jsx33
-rw-r--r--app/assets/javascripts/components/components/button.jsx2
-rw-r--r--app/assets/javascripts/components/components/column_collapsable.jsx60
-rw-r--r--app/assets/javascripts/components/components/dropdown_menu.jsx6
-rw-r--r--app/assets/javascripts/components/components/icon_button.jsx22
-rw-r--r--app/assets/javascripts/components/components/lightbox.jsx22
-rw-r--r--app/assets/javascripts/components/components/loading_indicator.jsx22
-rw-r--r--app/assets/javascripts/components/components/media_gallery.jsx53
-rw-r--r--app/assets/javascripts/components/components/missing_indicator.jsx17
-rw-r--r--app/assets/javascripts/components/components/relative_timestamp.jsx21
-rw-r--r--app/assets/javascripts/components/components/status_action_bar.jsx6
-rw-r--r--app/assets/javascripts/components/components/status_content.jsx62
-rw-r--r--app/assets/javascripts/components/components/status_list.jsx37
-rw-r--r--app/assets/javascripts/components/components/video_player.jsx66
-rw-r--r--app/assets/javascripts/components/containers/account_container.jsx12
-rw-r--r--app/assets/javascripts/components/containers/mastodon.jsx39
-rw-r--r--app/assets/javascripts/components/containers/status_container.jsx6
-rw-r--r--app/assets/javascripts/components/emoji.jsx2
-rw-r--r--app/assets/javascripts/components/features/account/components/action_bar.jsx2
-rw-r--r--app/assets/javascripts/components/features/account/components/header.jsx4
-rw-r--r--app/assets/javascripts/components/features/account/index.jsx11
-rw-r--r--app/assets/javascripts/components/features/account_timeline/index.jsx11
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx47
-rw-r--r--app/assets/javascripts/components/features/compose/components/drawer.jsx73
-rw-r--r--app/assets/javascripts/components/features/compose/components/navigation_bar.jsx4
-rw-r--r--app/assets/javascripts/components/features/compose/components/search.jsx2
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_button.jsx10
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_form.jsx13
-rw-r--r--app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx15
-rw-r--r--app/assets/javascripts/components/features/compose/containers/navigation_container.jsx8
-rw-r--r--app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx1
-rw-r--r--app/assets/javascripts/components/features/compose/index.jsx5
-rw-r--r--app/assets/javascripts/components/features/favourited_statuses/index.jsx63
-rw-r--r--app/assets/javascripts/components/features/generic_not_found/index.jsx10
-rw-r--r--app/assets/javascripts/components/features/getting_started/index.jsx35
-rw-r--r--app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx68
-rw-r--r--app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx41
-rw-r--r--app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx21
-rw-r--r--app/assets/javascripts/components/features/home_timeline/index.jsx12
-rw-r--r--app/assets/javascripts/components/features/notifications/components/column_settings.jsx150
-rw-r--r--app/assets/javascripts/components/features/notifications/components/notification.jsx7
-rw-r--r--app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx32
-rw-r--r--app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx10
-rw-r--r--app/assets/javascripts/components/features/notifications/index.jsx22
-rw-r--r--app/assets/javascripts/components/features/status/components/action_bar.jsx4
-rw-r--r--app/assets/javascripts/components/features/status/components/card.jsx100
-rw-r--r--app/assets/javascripts/components/features/status/components/detailed_status.jsx13
-rw-r--r--app/assets/javascripts/components/features/status/containers/card_container.jsx8
-rw-r--r--app/assets/javascripts/components/features/status/index.jsx8
-rw-r--r--app/assets/javascripts/components/features/ui/components/column_link.jsx4
-rw-r--r--app/assets/javascripts/components/features/ui/components/tabs_bar.jsx7
-rw-r--r--app/assets/javascripts/components/features/ui/containers/modal_container.jsx29
-rw-r--r--app/assets/javascripts/components/features/ui/containers/status_list_container.jsx60
-rw-r--r--app/assets/javascripts/components/features/ui/index.jsx21
-rw-r--r--app/assets/javascripts/components/is_mobile.jsx5
-rw-r--r--app/assets/javascripts/components/locales/de.jsx27
-rw-r--r--app/assets/javascripts/components/locales/en.jsx6
-rw-r--r--app/assets/javascripts/components/locales/es.jsx3
-rw-r--r--app/assets/javascripts/components/locales/fr.jsx3
-rw-r--r--app/assets/javascripts/components/locales/hu.jsx3
-rw-r--r--app/assets/javascripts/components/locales/pt.jsx3
-rw-r--r--app/assets/javascripts/components/locales/uk.jsx3
-rw-r--r--app/assets/javascripts/components/middleware/errors.jsx2
-rw-r--r--app/assets/javascripts/components/middleware/loading_bar.jsx25
-rw-r--r--app/assets/javascripts/components/reducers/accounts.jsx83
-rw-r--r--app/assets/javascripts/components/reducers/cards.jsx14
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx134
-rw-r--r--app/assets/javascripts/components/reducers/index.jsx8
-rw-r--r--app/assets/javascripts/components/reducers/meta.jsx18
-rw-r--r--app/assets/javascripts/components/reducers/modal.jsx18
-rw-r--r--app/assets/javascripts/components/reducers/notifications.jsx60
-rw-r--r--app/assets/javascripts/components/reducers/search.jsx2
-rw-r--r--app/assets/javascripts/components/reducers/settings.jsx46
-rw-r--r--app/assets/javascripts/components/reducers/status_lists.jsx39
-rw-r--r--app/assets/javascripts/components/reducers/statuses.jsx68
-rw-r--r--app/assets/javascripts/components/reducers/timelines.jsx91
-rw-r--r--app/assets/javascripts/components/reducers/user_lists.jsx38
-rw-r--r--app/assets/javascripts/components/store/configureStore.jsx28
-rw-r--r--app/assets/javascripts/extras.jsx4
-rw-r--r--app/assets/stylesheets/about.scss203
-rw-r--r--app/assets/stylesheets/accounts.scss60
-rw-r--r--app/assets/stylesheets/admin.scss28
-rw-r--r--app/assets/stylesheets/application.scss30
-rw-r--r--app/assets/stylesheets/boost.scss7
-rw-r--r--app/assets/stylesheets/components.scss259
-rw-r--r--app/assets/stylesheets/forms.scss50
-rw-r--r--app/assets/stylesheets/stream_entries.scss42
-rw-r--r--app/assets/stylesheets/tables.scss16
-rw-r--r--app/assets/stylesheets/variables.scss8
-rw-r--r--app/controllers/about_controller.rb12
-rw-r--r--app/controllers/admin/settings_controller.rb25
-rw-r--r--app/controllers/api/oembed_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts_controller.rb24
-rw-r--r--app/controllers/api/v1/apps_controller.rb2
-rw-r--r--app/controllers/api/v1/blocks_controller.rb4
-rw-r--r--app/controllers/api/v1/favourites_controller.rb4
-rw-r--r--app/controllers/api/v1/notifications_controller.rb15
-rw-r--r--app/controllers/api/v1/statuses_controller.rb26
-rw-r--r--app/controllers/api/v1/timelines_controller.rb16
-rw-r--r--app/controllers/api/web/settings_controller.rb15
-rw-r--r--app/controllers/api_controller.rb11
-rw-r--r--app/controllers/application_controller.rb10
-rw-r--r--app/controllers/auth/registrations_controller.rb4
-rw-r--r--app/controllers/home_controller.rb1
-rw-r--r--app/controllers/media_controller.rb3
-rw-r--r--app/controllers/settings/preferences_controller.rb20
-rw-r--r--app/controllers/stream_entries_controller.rb2
-rw-r--r--app/helpers/atom_builder_helper.rb13
-rw-r--r--app/helpers/home_helper.rb2
-rw-r--r--app/helpers/settings_helper.rb4
-rw-r--r--app/helpers/stream_entries_helper.rb8
-rw-r--r--app/lib/application_extension.rb9
-rw-r--r--app/lib/feed_manager.rb10
-rw-r--r--app/lib/formatter.rb15
-rw-r--r--app/lib/hash_object.rb10
-rw-r--r--app/lib/settings/extend.rb11
-rw-r--r--app/lib/settings/scoped_settings.rb14
-rw-r--r--app/lib/status_length_validator.rb10
-rw-r--r--app/lib/url_validator.rb14
-rw-r--r--app/models/account.rb32
-rw-r--r--app/models/domain_block.rb2
-rw-r--r--app/models/follow_request.rb2
-rw-r--r--app/models/media_attachment.rb18
-rw-r--r--app/models/notification.rb4
-rw-r--r--app/models/preview_card.rb20
-rw-r--r--app/models/setting.rb50
-rw-r--r--app/models/status.rb11
-rw-r--r--app/models/user.rb7
-rw-r--r--app/models/web.rb7
-rw-r--r--app/models/web/setting.rb7
-rw-r--r--app/services/after_block_service.rb31
-rw-r--r--app/services/block_domain_service.rb15
-rw-r--r--app/services/block_service.rb27
-rw-r--r--app/services/fetch_link_card_service.rb35
-rw-r--r--app/services/follow_remote_account_service.rb5
-rw-r--r--app/services/follow_service.rb2
-rw-r--r--app/services/notify_service.rb18
-rw-r--r--app/services/post_status_service.rb12
-rw-r--r--app/services/process_feed_service.rb10
-rw-r--r--app/services/process_hashtags_service.rb2
-rw-r--r--app/services/process_interaction_service.rb2
-rw-r--r--app/services/process_mentions_service.rb2
-rw-r--r--app/services/reblog_service.rb4
-rw-r--r--app/services/remove_status_service.rb7
-rw-r--r--app/services/suspend_account_service.rb1
-rw-r--r--app/services/unfollow_service.rb17
-rw-r--r--app/services/update_remote_profile_service.rb9
-rw-r--r--app/views/about/index.html.haml12
-rw-r--r--app/views/about/more.html.haml56
-rw-r--r--app/views/accounts/_grid_card.html.haml4
-rw-r--r--app/views/accounts/_header.html.haml14
-rw-r--r--app/views/accounts/show.html.haml24
-rw-r--r--app/views/admin/domain_blocks/index.html.haml2
-rw-r--r--app/views/admin/settings/index.html.haml36
-rw-r--r--app/views/api/v1/apps/show.rabl3
-rw-r--r--app/views/api/v1/statuses/_show.rabl6
-rw-r--r--app/views/api/v1/statuses/card.rabl5
-rw-r--r--app/views/authorize_follow/_card.html.haml4
-rw-r--r--app/views/errors/404.html.haml5
-rw-r--r--app/views/errors/410.html.haml5
-rw-r--r--app/views/errors/422.html.haml5
-rw-r--r--app/views/home/index.html.haml3
-rw-r--r--app/views/home/initial_state.json.rabl24
-rw-r--r--app/views/layouts/error.html.haml36
-rw-r--r--app/views/layouts/public.html.haml2
-rw-r--r--app/views/settings/preferences/show.html.haml4
-rw-r--r--app/views/settings/shared/_links.html.haml1
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml30
-rw-r--r--app/views/stream_entries/_favourite.html.haml2
-rw-r--r--app/views/stream_entries/_follow.html.haml2
-rw-r--r--app/views/stream_entries/_simple_status.html.haml20
-rw-r--r--app/views/stream_entries/show.html.haml8
-rw-r--r--app/views/tags/show.html.haml2
-rw-r--r--app/workers/block_worker.rb9
-rw-r--r--app/workers/link_crawl_worker.rb13
-rw-r--r--app/workers/merge_worker.rb9
-rw-r--r--app/workers/notification_worker.rb2
-rw-r--r--app/workers/pubsubhubbub/confirmation_worker.rb2
-rw-r--r--app/workers/pubsubhubbub/delivery_worker.rb6
-rw-r--r--app/workers/thread_resolve_worker.rb2
-rw-r--r--app/workers/unfavourite_worker.rb2
-rw-r--r--app/workers/unmerge_worker.rb9
197 files changed, 3223 insertions, 1082 deletions
diff --git a/app/assets/images/background-photo.jpeg b/app/assets/images/background-photo.jpeg
index 4390fca66..b0a88ff35 100644
--- a/app/assets/images/background-photo.jpeg
+++ b/app/assets/images/background-photo.jpeg
Binary files differdiff --git a/app/assets/images/boost_sprite.png b/app/assets/images/boost_sprite.png
new file mode 100644
index 000000000..564bf2646
--- /dev/null
+++ b/app/assets/images/boost_sprite.png
Binary files differdiff --git a/app/assets/javascripts/application_public.js b/app/assets/javascripts/application_public.js
index f131a267a..9626c5dae 100644
--- a/app/assets/javascripts/application_public.js
+++ b/app/assets/javascripts/application_public.js
@@ -1,3 +1,8 @@
 //= require jquery
 //= require jquery_ujs
 //= require extras
+//= require best_in_place
+
+$(function () {
+  $(".best_in_place").best_in_place();
+});
diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx
index 8d28b051f..0be05034e 100644
--- a/app/assets/javascripts/components/actions/accounts.jsx
+++ b/app/assets/javascripts/components/actions/accounts.jsx
@@ -1,8 +1,6 @@
 import api, { getLinks } from '../api'
 import Immutable from 'immutable';
 
-export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
-
 export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
 export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
 export const ACCOUNT_FETCH_FAIL    = 'ACCOUNT_FETCH_FAIL';
@@ -67,13 +65,6 @@ export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
 export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
 export const FOLLOW_REQUEST_REJECT_FAIL    = 'FOLLOW_REQUEST_REJECT_FAIL';
 
-export function setAccountSelf(account) {
-  return {
-    type: ACCOUNT_SET_SELF,
-    account
-  };
-};
-
 export function fetchAccount(id) {
   return (dispatch, getState) => {
     dispatch(fetchAccountRequest(id));
@@ -89,32 +80,39 @@ export function fetchAccount(id) {
 
 export function fetchAccountTimeline(id, replace = false) {
   return (dispatch, getState) => {
-    dispatch(fetchAccountTimelineRequest(id));
-
-    const ids      = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List());
+    const ids      = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List());
     const newestId = ids.size > 0 ? ids.first() : null;
 
     let params = '';
+    let skipLoading = false;
 
     if (newestId !== null && !replace) {
-      params = `?since_id=${newestId}`;
+      params      = `?since_id=${newestId}`;
+      skipLoading = true;
     }
 
+    dispatch(fetchAccountTimelineRequest(id, skipLoading));
+
     api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => {
-      dispatch(fetchAccountTimelineSuccess(id, response.data, replace));
+      dispatch(fetchAccountTimelineSuccess(id, response.data, replace, skipLoading));
     }).catch(error => {
-      dispatch(fetchAccountTimelineFail(id, error));
+      dispatch(fetchAccountTimelineFail(id, error, skipLoading));
     });
   };
 };
 
 export function expandAccountTimeline(id) {
   return (dispatch, getState) => {
-    const lastId = getState().getIn(['timelines', 'accounts_timelines', id], Immutable.List()).last();
+    const lastId = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()).last();
 
     dispatch(expandAccountTimelineRequest(id));
 
-    api(getState).get(`/api/v1/accounts/${id}/statuses?max_id=${lastId}`).then(response => {
+    api(getState).get(`/api/v1/accounts/${id}/statuses`, {
+      params: {
+        limit: 10,
+        max_id: lastId
+      }
+    }).then(response => {
       dispatch(expandAccountTimelineSuccess(id, response.data));
     }).catch(error => {
       dispatch(expandAccountTimelineFail(id, error));
@@ -210,27 +208,30 @@ export function unfollowAccountFail(error) {
   };
 };
 
-export function fetchAccountTimelineRequest(id) {
+export function fetchAccountTimelineRequest(id, skipLoading) {
   return {
     type: ACCOUNT_TIMELINE_FETCH_REQUEST,
-    id
+    id,
+    skipLoading
   };
 };
 
-export function fetchAccountTimelineSuccess(id, statuses, replace) {
+export function fetchAccountTimelineSuccess(id, statuses, replace, skipLoading) {
   return {
     type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
     id,
     statuses,
-    replace
+    replace,
+    skipLoading
   };
 };
 
-export function fetchAccountTimelineFail(id, error) {
+export function fetchAccountTimelineFail(id, error, skipLoading) {
   return {
     type: ACCOUNT_TIMELINE_FETCH_FAIL,
     id,
-    error
+    error,
+    skipLoading
   };
 };
 
@@ -495,6 +496,10 @@ export function expandFollowingFail(id, error) {
 
 export function fetchRelationships(account_ids) {
   return (dispatch, getState) => {
+    if (account_ids.length === 0) {
+      return;
+    }
+
     dispatch(fetchRelationshipsRequest(account_ids));
 
     api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
@@ -508,21 +513,24 @@ export function fetchRelationships(account_ids) {
 export function fetchRelationshipsRequest(ids) {
   return {
     type: RELATIONSHIPS_FETCH_REQUEST,
-    ids
+    ids,
+    skipLoading: true
   };
 };
 
 export function fetchRelationshipsSuccess(relationships) {
   return {
     type: RELATIONSHIPS_FETCH_SUCCESS,
-    relationships
+    relationships,
+    skipLoading: true
   };
 };
 
 export function fetchRelationshipsFail(error) {
   return {
     type: RELATIONSHIPS_FETCH_FAIL,
-    error
+    error,
+    skipLoading: true
   };
 };
 
diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx
new file mode 100644
index 000000000..503c2bfeb
--- /dev/null
+++ b/app/assets/javascripts/components/actions/cards.jsx
@@ -0,0 +1,47 @@
+import api from '../api';
+
+export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST';
+export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS';
+export const STATUS_CARD_FETCH_FAIL    = 'STATUS_CARD_FETCH_FAIL';
+
+export function fetchStatusCard(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchStatusCardRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/card`).then(response => {
+      if (!response.data.url || !response.data.title || !response.data.description) {
+        return;
+      }
+
+      dispatch(fetchStatusCardSuccess(id, response.data));
+    }).catch(error => {
+      dispatch(fetchStatusCardFail(id, error));
+    });
+  };
+};
+
+export function fetchStatusCardRequest(id) {
+  return {
+    type: STATUS_CARD_FETCH_REQUEST,
+    id,
+    skipLoading: true
+  };
+};
+
+export function fetchStatusCardSuccess(id, card) {
+  return {
+    type: STATUS_CARD_FETCH_SUCCESS,
+    id,
+    card,
+    skipLoading: true
+  };
+};
+
+export function fetchStatusCardFail(id, error) {
+  return {
+    type: STATUS_CARD_FETCH_FAIL,
+    id,
+    error,
+    skipLoading: true
+  };
+};
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx
index 05674ba89..6d0188166 100644
--- a/app/assets/javascripts/components/actions/compose.jsx
+++ b/app/assets/javascripts/components/actions/compose.jsx
@@ -23,6 +23,8 @@ export const COMPOSE_MOUNT   = 'COMPOSE_MOUNT';
 export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
 
 export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
+export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
+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';
 
@@ -68,6 +70,7 @@ export function submitCompose() {
       in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
       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')
     }).then(function (response) {
       dispatch(submitComposeSuccess({ ...response.data }));
@@ -218,6 +221,20 @@ export function changeComposeSensitivity(checked) {
   };
 };
 
+export function changeComposeSpoilerness(checked) {
+  return {
+    type: COMPOSE_SPOILERNESS_CHANGE,
+    checked
+  };
+};
+
+export function changeComposeSpoilerText(text) {
+  return {
+    type: COMPOSE_SPOILER_TEXT_CHANGE,
+    text
+  };
+};
+
 export function changeComposeVisibility(checked) {
   return {
     type: COMPOSE_VISIBILITY_CHANGE,
diff --git a/app/assets/javascripts/components/actions/favourites.jsx b/app/assets/javascripts/components/actions/favourites.jsx
new file mode 100644
index 000000000..a25c1ae1c
--- /dev/null
+++ b/app/assets/javascripts/components/actions/favourites.jsx
@@ -0,0 +1,83 @@
+import api, { getLinks } from '../api'
+
+export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
+export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
+export const FAVOURITED_STATUSES_FETCH_FAIL    = 'FAVOURITED_STATUSES_FETCH_FAIL';
+
+export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST';
+export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS';
+export const FAVOURITED_STATUSES_EXPAND_FAIL    = 'FAVOURITED_STATUSES_EXPAND_FAIL';
+
+export function fetchFavouritedStatuses() {
+  return (dispatch, getState) => {
+    dispatch(fetchFavouritedStatusesRequest());
+
+    api(getState).get('/api/v1/favourites').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(fetchFavouritedStatusesFail(error));
+    });
+  };
+};
+
+export function fetchFavouritedStatusesRequest() {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_REQUEST
+  };
+};
+
+export function fetchFavouritedStatusesSuccess(statuses, next) {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_SUCCESS,
+    statuses,
+    next
+  };
+};
+
+export function fetchFavouritedStatusesFail(error) {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_FAIL,
+    error
+  };
+};
+
+export function expandFavouritedStatuses() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['status_lists', 'favourites', 'next'], null);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFavouritedStatusesRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandFavouritedStatusesFail(error));
+    });
+  };
+};
+
+export function expandFavouritedStatusesRequest() {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_REQUEST
+  };
+};
+
+export function expandFavouritedStatusesSuccess(statuses, next) {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_SUCCESS,
+    statuses,
+    next
+  };
+};
+
+export function expandFavouritedStatusesFail(error) {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_FAIL,
+    error
+  };
+};
diff --git a/app/assets/javascripts/components/actions/meta.jsx b/app/assets/javascripts/components/actions/meta.jsx
deleted file mode 100644
index d0adbce3f..000000000
--- a/app/assets/javascripts/components/actions/meta.jsx
+++ /dev/null
@@ -1,8 +0,0 @@
-export const ACCESS_TOKEN_SET = 'ACCESS_TOKEN_SET';
-
-export function setAccessToken(token) {
-  return {
-    type: ACCESS_TOKEN_SET,
-    token: token
-  };
-};
diff --git a/app/assets/javascripts/components/actions/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx
index 8bd835406..1731c1857 100644
--- a/app/assets/javascripts/components/actions/notifications.jsx
+++ b/app/assets/javascripts/components/actions/notifications.jsx
@@ -14,8 +14,6 @@ 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_SETTING_CHANGE = 'NOTIFICATIONS_SETTING_CHANGE';
-
 const fetchRelatedRelationships = (dispatch, notifications) => {
   const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
 
@@ -26,21 +24,25 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
 
 export function updateNotifications(notification, intlMessages, intlLocale) {
   return (dispatch, getState) => {
+    const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
+    const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
+
     dispatch({
       type: NOTIFICATIONS_UPDATE,
       notification,
       account: notification.account,
-      status: notification.status
+      status: notification.status,
+      meta: playSound ? { sound: 'boop' } : undefined
     });
 
     fetchRelatedRelationships(dispatch, [notification]);
 
     // Desktop notifications
-    if (typeof window.Notification !== 'undefined' && getState().getIn(['notifications', 'settings', 'alerts', notification.type], false)) {
+    if (typeof window.Notification !== 'undefined' && showAlert) {
       const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
       const body  = $('<p>').html(notification.status ? notification.status.content : '').text();
 
-      new Notification(title, { body, icon: notification.account.avatar });
+      new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
     }
   };
 };
@@ -94,13 +96,17 @@ export function expandNotifications() {
   return (dispatch, getState) => {
     const url = getState().getIn(['notifications', 'next'], null);
 
-    if (url === null) {
+    if (url === null || getState().getIn(['notifications', 'isLoading'])) {
       return;
     }
 
     dispatch(expandNotificationsRequest());
 
-    api(getState).get(url).then(response => {
+    api(getState).get(url, {
+      params: {
+        limit: 5
+      }
+    }).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
 
       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
@@ -133,11 +139,3 @@ export function expandNotificationsFail(error) {
     error
   };
 };
-
-export function changeNotificationsSetting(key, checked) {
-  return {
-    type: NOTIFICATIONS_SETTING_CHANGE,
-    key,
-    checked
-  };
-};
diff --git a/app/assets/javascripts/components/actions/settings.jsx b/app/assets/javascripts/components/actions/settings.jsx
new file mode 100644
index 000000000..c754b30ca
--- /dev/null
+++ b/app/assets/javascripts/components/actions/settings.jsx
@@ -0,0 +1,19 @@
+import axios from 'axios';
+
+export const SETTING_CHANGE = 'SETTING_CHANGE';
+
+export function changeSetting(key, value) {
+  return {
+    type: SETTING_CHANGE,
+    key,
+    value
+  };
+};
+
+export function saveSettings() {
+  return (_, getState) => {
+    axios.put('/api/web/settings', {
+      data: getState().get('settings').toJS()
+    });
+  };
+};
diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx
index cbee94bca..9ac215727 100644
--- a/app/assets/javascripts/components/actions/statuses.jsx
+++ b/app/assets/javascripts/components/actions/statuses.jsx
@@ -1,6 +1,7 @@
 import api from '../api';
 
 import { deleteFromTimelines } from './timelines';
+import { fetchStatusCard } from './cards';
 
 export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
 export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@@ -14,39 +15,44 @@ export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
 export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
 export const CONTEXT_FETCH_FAIL    = 'CONTEXT_FETCH_FAIL';
 
-export function fetchStatusRequest(id) {
+export function fetchStatusRequest(id, skipLoading) {
   return {
     type: STATUS_FETCH_REQUEST,
-    id: id
+    id,
+    skipLoading
   };
 };
 
 export function fetchStatus(id) {
   return (dispatch, getState) => {
-    dispatch(fetchStatusRequest(id));
+    const skipLoading = getState().getIn(['statuses', id], null) !== null;
+
+    dispatch(fetchStatusRequest(id, skipLoading));
 
     api(getState).get(`/api/v1/statuses/${id}`).then(response => {
-      dispatch(fetchStatusSuccess(response.data));
+      dispatch(fetchStatusSuccess(response.data, skipLoading));
       dispatch(fetchContext(id));
+      dispatch(fetchStatusCard(id));
     }).catch(error => {
-      dispatch(fetchStatusFail(id, error));
+      dispatch(fetchStatusFail(id, error, skipLoading));
     });
   };
 };
 
-export function fetchStatusSuccess(status, context) {
+export function fetchStatusSuccess(status, skipLoading) {
   return {
     type: STATUS_FETCH_SUCCESS,
-    status: status,
-    context: context
+    status,
+    skipLoading
   };
 };
 
-export function fetchStatusFail(id, error) {
+export function fetchStatusFail(id, error, skipLoading) {
   return {
     type: STATUS_FETCH_FAIL,
-    id: id,
-    error: error
+    id,
+    error,
+    skipLoading
   };
 };
 
diff --git a/app/assets/javascripts/components/actions/store.jsx b/app/assets/javascripts/components/actions/store.jsx
new file mode 100644
index 000000000..3bba99549
--- /dev/null
+++ b/app/assets/javascripts/components/actions/store.jsx
@@ -0,0 +1,17 @@
+import Immutable from 'immutable';
+
+export const STORE_HYDRATE = 'STORE_HYDRATE';
+
+const convertState = rawState =>
+  Immutable.fromJS(rawState, (k, v) =>
+    Immutable.Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x =>
+      Number.isNaN(x * 1) ? x : x * 1));
+
+export function hydrateStore(rawState) {
+  const state = convertState(rawState);
+
+  return {
+    type: STORE_HYDRATE,
+    state
+  };
+};
diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx
index 0e6f09190..29a060e87 100644
--- a/app/assets/javascripts/components/actions/timelines.jsx
+++ b/app/assets/javascripts/components/actions/timelines.jsx
@@ -14,11 +14,12 @@ export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL';
 
 export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 
-export function refreshTimelineSuccess(timeline, statuses) {
+export function refreshTimelineSuccess(timeline, statuses, skipLoading) {
   return {
     type: TIMELINE_REFRESH_SUCCESS,
-    timeline: timeline,
-    statuses: statuses
+    timeline,
+    statuses,
+    skipLoading
   };
 };
 
@@ -39,55 +40,65 @@ export function deleteFromTimelines(id) {
   return (dispatch, getState) => {
     const accountId  = getState().getIn(['statuses', id, 'account']);
     const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
+    const reblogOf   = getState().getIn(['statuses', id, 'reblog'], null);
 
     dispatch({
       type: TIMELINE_DELETE,
       id,
       accountId,
-      references
+      references,
+      reblogOf
     });
   };
 };
 
-export function refreshTimelineRequest(timeline, id) {
+export function refreshTimelineRequest(timeline, id, skipLoading) {
   return {
     type: TIMELINE_REFRESH_REQUEST,
     timeline,
-    id
+    id,
+    skipLoading
   };
 };
 
 export function refreshTimeline(timeline, id = null) {
   return function (dispatch, getState) {
-    dispatch(refreshTimelineRequest(timeline, id));
+    if (getState().getIn(['timelines', timeline, 'isLoading'])) {
+      return;
+    }
 
     const ids      = getState().getIn(['timelines', timeline, 'items'], Immutable.List());
     const newestId = ids.size > 0 ? ids.first() : null;
 
-    let params = '';
-    let path   = timeline;
+    let params      = '';
+    let path        = timeline;
+    let skipLoading = false;
 
     if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded'])) {
-      params = `?since_id=${newestId}`;
+      params      = `?since_id=${newestId}`;
+      skipLoading = true;
     }
 
     if (id) {
       path = `${path}/${id}`
     }
 
+    dispatch(refreshTimelineRequest(timeline, id, skipLoading));
+
     api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) {
-      dispatch(refreshTimelineSuccess(timeline, response.data));
+      dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading));
     }).catch(function (error) {
-      dispatch(refreshTimelineFail(timeline, error));
+      dispatch(refreshTimelineFail(timeline, error, skipLoading));
     });
   };
 };
 
-export function refreshTimelineFail(timeline, error) {
+export function refreshTimelineFail(timeline, error, skipLoading) {
   return {
     type: TIMELINE_REFRESH_FAIL,
     timeline,
-    error
+    error,
+    skipLoading
   };
 };
 
@@ -95,6 +106,12 @@ export function expandTimeline(timeline, id = null) {
   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
+      return;
+    }
+
     dispatch(expandTimelineRequest(timeline));
 
     let path = timeline;
@@ -103,7 +120,12 @@ export function expandTimeline(timeline, id = null) {
       path = `${path}/${id}`
     }
 
-    api(getState).get(`/api/v1/timelines/${path}?max_id=${lastId}`).then(response => {
+    api(getState).get(`/api/v1/timelines/${path}`, {
+      params: {
+        limit: 10,
+        max_id: lastId
+      }
+    }).then(response => {
       dispatch(expandTimelineSuccess(timeline, response.data));
     }).catch(error => {
       dispatch(expandTimelineFail(timeline, error));
diff --git a/app/assets/javascripts/components/components/account.jsx b/app/assets/javascripts/components/components/account.jsx
index 814d8a9c8..108401b2f 100644
--- a/app/assets/javascripts/components/components/account.jsx
+++ b/app/assets/javascripts/components/components/account.jsx
@@ -8,7 +8,9 @@ import { defineMessages, injectIntl } from 'react-intl';
 
 const messages = defineMessages({
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock' }
 });
 
 const outerStyle = {
@@ -42,7 +44,9 @@ const Account = React.createClass({
     account: ImmutablePropTypes.map.isRequired,
     me: React.PropTypes.number.isRequired,
     onFollow: React.PropTypes.func.isRequired,
-    withNote: React.PropTypes.bool
+    onBlock: React.PropTypes.func.isRequired,
+    withNote: React.PropTypes.bool,
+    intl: React.PropTypes.object.isRequired
   },
 
   getDefaultProps () {
@@ -57,6 +61,10 @@ const Account = React.createClass({
     this.props.onFollow(this.props.account);
   },
 
+  handleBlock () {
+    this.props.onBlock(this.props.account);
+  },
+
   render () {
     const { account, me, withNote, intl } = this.props;
 
@@ -70,10 +78,18 @@ const Account = React.createClass({
       note = <div style={noteStyle}>{account.get('note')}</div>;
     }
 
-    if (account.get('id') !== me && account.get('relationship', null) != null) {
+    if (account.get('id') !== me && account.get('relationship', null) !== null) {
       const following = account.getIn(['relationship', 'following']);
-
-      buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+      const requested = account.getIn(['relationship', 'requested']);
+      const blocking  = account.getIn(['relationship', 'blocking']);
+
+      if (requested) {
+        buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
+      } else if (blocking) {
+        buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
+      } else {
+        buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+      }
     }
 
     return (
diff --git a/app/assets/javascripts/components/components/autosuggest_textarea.jsx b/app/assets/javascripts/components/components/autosuggest_textarea.jsx
index 39ccbcaf9..81ec7a236 100644
--- a/app/assets/javascripts/components/components/autosuggest_textarea.jsx
+++ b/app/assets/javascripts/components/components/autosuggest_textarea.jsx
@@ -38,7 +38,8 @@ const AutosuggestTextarea = React.createClass({
     onSuggestionsClearRequested: React.PropTypes.func.isRequired,
     onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
     onChange: React.PropTypes.func.isRequired,
-    onKeyUp: React.PropTypes.func
+    onKeyUp: React.PropTypes.func,
+    onKeyDown: React.PropTypes.func
   },
 
   getInitialState () {
@@ -108,15 +109,28 @@ const AutosuggestTextarea = React.createClass({
 
         break;
     }
+
+    if (e.defaultPrevented || !this.props.onKeyDown) {
+      return;
+    }
+
+    this.props.onKeyDown(e);
   },
 
   onBlur () {
-    this.setState({ suggestionsHidden: true });
+    // If we hide the suggestions immediately, then this will prevent the
+    // onClick for the suggestions themselves from firing.
+    // Setting a short window for that to take place before hiding the
+    // suggestions ensures that can't happen.
+    setTimeout(() => {
+      this.setState({ suggestionsHidden: true });
+    }, 100);
   },
 
   onSuggestionClick (suggestion, e) {
     e.preventDefault();
     this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
+    this.textarea.focus();
   },
 
   componentWillReceiveProps (nextProps) {
diff --git a/app/assets/javascripts/components/components/avatar.jsx b/app/assets/javascripts/components/components/avatar.jsx
index 687aa7bb9..b8420014b 100644
--- a/app/assets/javascripts/components/components/avatar.jsx
+++ b/app/assets/javascripts/components/components/avatar.jsx
@@ -8,12 +8,41 @@ const Avatar = React.createClass({
     style: React.PropTypes.object
   },
 
+  getInitialState () {
+    return {
+      hovering: false
+    };
+  },
+
   mixins: [PureRenderMixin],
 
+  handleMouseEnter () {
+    this.setState({ hovering: true });
+  },
+
+  handleMouseLeave () {
+    this.setState({ hovering: false });
+  },
+
+  handleLoad () {
+    this.canvas.getContext('2d').drawImage(this.image, 0, 0, this.props.size, this.props.size);
+  },
+
+  setImageRef (c) {
+    this.image = c;
+  },
+
+  setCanvasRef (c) {
+    this.canvas = c;
+  },
+
   render () {
+    const { hovering } = this.state;
+
     return (
-      <div style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px` }}>
-        <img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ display: 'block', borderRadius: '4px' }} />
+      <div onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px`, position: 'relative' }}>
+        <img ref={this.setImageRef} onLoad={this.handleLoad} src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ position: 'absolute', top: '0', left: '0', visibility: hovering ? 'visible' : 'hidden', borderRadius: '4px' }} />
+        <canvas ref={this.setCanvasRef} width={this.props.size} height={this.props.size} style={{ borderRadius: '4px' }} />
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/components/button.jsx b/app/assets/javascripts/components/components/button.jsx
index d63129013..19c52550a 100644
--- a/app/assets/javascripts/components/components/button.jsx
+++ b/app/assets/javascripts/components/components/button.jsx
@@ -27,7 +27,7 @@ const Button = React.createClass({
 
   render () {
     const style = {
-      fontFamily: 'Roboto',
+      fontFamily: 'inherit',
       display: this.props.block ? 'block' : 'inline-block',
       width: this.props.block ? '100%' : 'auto',
       position: 'relative',
diff --git a/app/assets/javascripts/components/components/column_collapsable.jsx b/app/assets/javascripts/components/components/column_collapsable.jsx
new file mode 100644
index 000000000..203dc5e0c
--- /dev/null
+++ b/app/assets/javascripts/components/components/column_collapsable.jsx
@@ -0,0 +1,60 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { Motion, spring } from 'react-motion';
+
+const iconStyle = {
+  fontSize: '16px',
+  padding: '15px',
+  position: 'absolute',
+  right: '0',
+  top: '-48px',
+  cursor: 'pointer'
+};
+
+const ColumnCollapsable = React.createClass({
+
+  propTypes: {
+    icon: React.PropTypes.string.isRequired,
+    fullHeight: React.PropTypes.number.isRequired,
+    children: React.PropTypes.node,
+    onCollapse: React.PropTypes.func
+  },
+
+  getInitialState () {
+    return {
+      collapsed: true
+    };
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleToggleCollapsed () {
+    const currentState = this.state.collapsed;
+
+    this.setState({ collapsed: !currentState });
+
+    if (!currentState && this.props.onCollapse) {
+      this.props.onCollapse();
+    }
+  },
+
+  render () {
+    const { icon, fullHeight, children } = this.props;
+    const { collapsed } = this.state;
+
+    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>
+
+        <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 }) =>
+            <div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}>
+              {children}
+            </div>
+          }
+        </Motion>
+      </div>
+    );
+  }
+});
+
+export default ColumnCollapsable;
diff --git a/app/assets/javascripts/components/components/dropdown_menu.jsx b/app/assets/javascripts/components/components/dropdown_menu.jsx
index 450550d55..ffef29c00 100644
--- a/app/assets/javascripts/components/components/dropdown_menu.jsx
+++ b/app/assets/javascripts/components/components/dropdown_menu.jsx
@@ -1,13 +1,15 @@
 import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
 
-const DropdownMenu = ({ icon, items, size }) => {
+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 style={{ lineHeight: '18px', textAlign: 'left' }}>
+      <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') {
diff --git a/app/assets/javascripts/components/components/icon_button.jsx b/app/assets/javascripts/components/components/icon_button.jsx
index e9a7228e4..f9b6192c0 100644
--- a/app/assets/javascripts/components/components/icon_button.jsx
+++ b/app/assets/javascripts/components/components/icon_button.jsx
@@ -1,4 +1,5 @@
 import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { Motion, spring } from 'react-motion';
 
 const IconButton = React.createClass({
 
@@ -10,14 +11,16 @@ const IconButton = React.createClass({
     active: React.PropTypes.bool,
     style: React.PropTypes.object,
     activeStyle: React.PropTypes.object,
-    disabled: React.PropTypes.bool
+    disabled: React.PropTypes.bool,
+    animate: React.PropTypes.bool
   },
 
   getDefaultProps () {
     return {
       size: 18,
       active: false,
-      disabled: false
+      disabled: false,
+      animate: false
     };
   },
 
@@ -49,9 +52,18 @@ const IconButton = React.createClass({
     }
 
     return (
-      <button aria-label={this.props.title} title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`} onClick={this.handleClick} style={style}>
-        <i className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
-      </button>
+      <Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
+        {({ rotate }) =>
+          <button
+            aria-label={this.props.title}
+            title={this.props.title}
+            className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`}
+            onClick={this.handleClick}
+            style={style}>
+            <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
+          </button>
+        }
+      </Motion>
     );
   }
 
diff --git a/app/assets/javascripts/components/components/lightbox.jsx b/app/assets/javascripts/components/components/lightbox.jsx
index b5c2a69d8..1e3a88955 100644
--- a/app/assets/javascripts/components/components/lightbox.jsx
+++ b/app/assets/javascripts/components/components/lightbox.jsx
@@ -35,7 +35,9 @@ const Lightbox = React.createClass({
   propTypes: {
     isVisible: React.PropTypes.bool,
     onOverlayClicked: React.PropTypes.func,
-    onCloseClicked: React.PropTypes.func
+    onCloseClicked: React.PropTypes.func,
+    intl: React.PropTypes.object.isRequired,
+    children: React.PropTypes.node
   },
 
   mixins: [PureRenderMixin],
@@ -57,19 +59,17 @@ const Lightbox = React.createClass({
   render () {
     const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props;
 
-    const content = isVisible ? children : <div />;
-
     return (
-      <div className='lightbox' style={{...overlayStyle, display: isVisible ? 'flex' : 'none'}} onClick={onOverlayClicked}>
-        <Motion defaultStyle={{ y: -200 }} style={{ y: spring(isVisible ? 0 : -200) }}>
-          {({ y }) =>
-            <div style={{...dialogStyle, transform: `translateY(${y}px)`}}>
+      <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'}} onClick={onOverlayClicked}>
+            <div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }}>
               <IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
-              {content}
+              {children}
             </div>
-          }
-        </Motion>
-      </div>
+          </div>
+        }
+      </Motion>
     );
   }
 
diff --git a/app/assets/javascripts/components/components/loading_indicator.jsx b/app/assets/javascripts/components/components/loading_indicator.jsx
index fd5acae84..c8a263924 100644
--- a/app/assets/javascripts/components/components/loading_indicator.jsx
+++ b/app/assets/javascripts/components/components/loading_indicator.jsx
@@ -1,15 +1,17 @@
 import { FormattedMessage } from 'react-intl';
 
-const LoadingIndicator = () => {
-  const style = {
-    textAlign: 'center',
-    fontSize: '16px',
-    fontWeight: '500',
-    color: '#616b86',
-    paddingTop: '120px'
-  };
-
-  return <div style={style}><FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' /></div>;
+const style = {
+  textAlign: 'center',
+  fontSize: '16px',
+  fontWeight: '500',
+  color: '#616b86',
+  paddingTop: '120px'
 };
 
+const LoadingIndicator = () => (
+  <div style={style}>
+    <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
+  </div>
+);
+
 export default LoadingIndicator;
diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx
index 9aafd8181..7e92abe2d 100644
--- a/app/assets/javascripts/components/components/media_gallery.jsx
+++ b/app/assets/javascripts/components/components/media_gallery.jsx
@@ -1,12 +1,18 @@
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
-import { FormattedMessage } from 'react-intl';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
+});
 
 const outerStyle = {
   marginTop: '8px',
   overflow: 'hidden',
   width: '100%',
-  boxSizing: 'border-box'
+  boxSizing: 'border-box',
+  position: 'relative'
 };
 
 const spoilerStyle = {
@@ -32,11 +38,18 @@ const spoilerSubSpanStyle = {
   fontWeight: '500'
 };
 
+const spoilerButtonStyle = {
+  position: 'absolute',
+  top: '6px',
+  left: '8px',
+  zIndex: '100'
+};
+
 const MediaGallery = React.createClass({
 
   getInitialState () {
     return {
-      visible: false
+      visible: !this.props.sensitive
     };
   },
 
@@ -59,21 +72,30 @@ const MediaGallery = React.createClass({
   },
 
   handleOpen () {
-    this.setState({ visible: true });
+    this.setState({ visible: !this.state.visible });
   },
 
   render () {
-    const { media, sensitive } = this.props;
+    const { media, intl, sensitive } = this.props;
 
     let children;
 
-    if (sensitive && !this.state.visible) {
-      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>
-      );
+    if (!this.state.visible) {
+      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>
+        );
+      } 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>
+        );
+      }
     } else {
       const size = media.take(4).size;
 
@@ -134,9 +156,12 @@ const MediaGallery = React.createClass({
         );
       });
     }
-
+    
     return (
       <div style={{ ...outerStyle, height: `${this.props.height}px` }}>
+        <div style={spoilerButtonStyle} >
+          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} />
+        </div>
         {children}
       </div>
     );
@@ -144,4 +169,4 @@ const MediaGallery = React.createClass({
 
 });
 
-export default MediaGallery;
+export default injectIntl(MediaGallery);
diff --git a/app/assets/javascripts/components/components/missing_indicator.jsx b/app/assets/javascripts/components/components/missing_indicator.jsx
new file mode 100644
index 000000000..ed8b4fe24
--- /dev/null
+++ b/app/assets/javascripts/components/components/missing_indicator.jsx
@@ -0,0 +1,17 @@
+import { FormattedMessage } from 'react-intl';
+
+const style = {
+  textAlign: 'center',
+  fontSize: '16px',
+  fontWeight: '500',
+  color: '#616b86',
+  paddingTop: '120px'
+};
+
+const MissingIndicator = () => (
+  <div style={style}>
+    <FormattedMessage id='missing_indicator.label' defaultMessage='Not found' />
+  </div>
+);
+
+export default MissingIndicator;
diff --git a/app/assets/javascripts/components/components/relative_timestamp.jsx b/app/assets/javascripts/components/components/relative_timestamp.jsx
index 3a5b88523..3b012b184 100644
--- a/app/assets/javascripts/components/components/relative_timestamp.jsx
+++ b/app/assets/javascripts/components/components/relative_timestamp.jsx
@@ -1,15 +1,18 @@
-import {
-  FormattedMessage,
-  FormattedDate,
-  FormattedRelative
-} from 'react-intl';
-
-const RelativeTimestamp = ({ timestamp }) => {
-  return <FormattedRelative value={new Date(timestamp)} />;
+import { injectIntl, FormattedRelative } from 'react-intl';
+
+const RelativeTimestamp = ({ intl, timestamp }) => {
+  const date = new Date(timestamp);
+
+  return (
+    <time dateTime={timestamp} title={intl.formatDate(date, { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}>
+      <FormattedRelative value={date} />
+    </time>
+  );
 };
 
 RelativeTimestamp.propTypes = {
+  intl: React.PropTypes.object.isRequired,
   timestamp: React.PropTypes.string.isRequired
 };
 
-export default RelativeTimestamp;
+export default injectIntl(RelativeTimestamp);
diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx
index afaf82561..f2cc1fb12 100644
--- a/app/assets/javascripts/components/components/status_action_bar.jsx
+++ b/app/assets/javascripts/components/components/status_action_bar.jsx
@@ -49,7 +49,7 @@ const StatusActionBar = React.createClass({
   },
 
   handleMentionClick () {
-    this.props.onMention(this.props.status.get('account'));
+    this.props.onMention(this.props.status.get('account'), this.context.router);
   },
 
   handleBlockClick () {
@@ -77,10 +77,10 @@ const StatusActionBar = React.createClass({
       <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 active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></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' }}>
-          <DropdownMenu items={menu} icon='ellipsis-h' size={18} />
+          <DropdownMenu items={menu} icon='ellipsis-h' size={18} direction="right" />
         </div>
       </div>
     );
diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx
index f2c88cee0..521b557f0 100644
--- a/app/assets/javascripts/components/components/status_content.jsx
+++ b/app/assets/javascripts/components/components/status_content.jsx
@@ -1,6 +1,7 @@
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import emojify from '../emoji';
+import { FormattedMessage } from 'react-intl';
 
 const StatusContent = React.createClass({
 
@@ -13,6 +14,12 @@ const StatusContent = React.createClass({
     onClick: React.PropTypes.func
   },
 
+  getInitialState () {
+    return {
+      hidden: true
+    };
+  },
+
   mixins: [PureRenderMixin],
 
   componentDidMount () {
@@ -31,8 +38,6 @@ const StatusContent = React.createClass({
         link.setAttribute('target', '_blank');
         link.setAttribute('rel', 'noopener');
       }
-
-      link.addEventListener('click', this.onNormalClick, false);
     }
   },
 
@@ -52,16 +57,59 @@ const StatusContent = React.createClass({
     }
   },
 
-  onNormalClick (e) {
-    e.stopPropagation();
+  handleMouseDown (e) {
+    this.startXY = [e.clientX, e.clientY];
+  },
+
+  handleMouseUp (e) {
+    const [ startX, startY ] = this.startXY;
+    const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
+
+    if (e.target.localName === 'a' || (e.target.parentNode && e.target.parentNode.localName === 'a')) {
+      return;
+    }
+
+    if (deltaX + deltaY < 5 && e.button === 0) {
+      this.props.onClick();
+    }
+
+    this.startXY = null;
+  },
+
+  handleSpoilerClick () {
+    this.setState({ hidden: !this.state.hidden });
   },
 
   render () {
-    const { status, onClick } = this.props;
+    const { status } = this.props;
+    const { hidden } = this.state;
 
     const content = { __html: emojify(status.get('content')) };
-
-    return <div className='status__content' style={{ cursor: 'pointer' }} dangerouslySetInnerHTML={content} onClick={onClick} />;
+    const spoilerContent = { __html: emojify(status.get('spoiler_text', '')) };
+
+    if (status.get('spoiler_text').length > 0) {
+      const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
+
+      return (
+        <div className='status__content' style={{ cursor: 'pointer' }} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
+          <p style={{ marginBottom: hidden ? '0px' : '' }} >
+            <span dangerouslySetInnerHTML={spoilerContent} /> <a onClick={this.handleSpoilerClick}>{toggleText}</a>
+          </p>
+
+          <div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} />
+        </div>
+      );
+    } else {
+      return (
+        <div
+          className='status__content'
+          style={{ cursor: 'pointer' }}
+          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 e0a73435f..69cc013f2 100644
--- a/app/assets/javascripts/components/components/status_list.jsx
+++ b/app/assets/javascripts/components/components/status_list.jsx
@@ -11,7 +11,8 @@ const StatusList = React.createClass({
     onScrollToBottom: React.PropTypes.func,
     onScrollToTop: React.PropTypes.func,
     onScroll: React.PropTypes.func,
-    trackScroll: React.PropTypes.bool
+    trackScroll: React.PropTypes.bool,
+    isLoading: React.PropTypes.bool
   },
 
   getDefaultProps () {
@@ -24,10 +25,10 @@ const StatusList = React.createClass({
 
   handleScroll (e) {
     const { scrollTop, scrollHeight, clientHeight } = e.target;
-
+    const offset = scrollHeight - scrollTop - clientHeight;
     this._oldScrollPosition = scrollHeight - scrollTop;
 
-    if (scrollTop === scrollHeight - clientHeight && this.props.onScrollToBottom) {
+    if (250 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
       this.props.onScrollToBottom();
     } else if (scrollTop < 100 && this.props.onScrollToTop) {
       this.props.onScrollToTop();
@@ -36,21 +37,37 @@ const StatusList = React.createClass({
     }
   },
 
-  componentDidUpdate (prevProps) {
-    if (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && this._oldScrollPosition) {
-      const node = ReactDOM.findDOMNode(this);
+  componentDidMount () {
+    this.attachScrollListener();
+  },
 
-      if (node.scrollTop > 0) {
-        node.scrollTop = node.scrollHeight - this._oldScrollPosition;
-      }
+  componentDidUpdate (prevProps) {
+    if (this.node.scrollTop > 0 && (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && !!this._oldScrollPosition)) {
+      this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition;
     }
   },
 
+  componentWillUnmount () {
+    this.detachScrollListener();
+  },
+
+  attachScrollListener () {
+    this.node.addEventListener('scroll', this.handleScroll);
+  },
+
+  detachScrollListener () {
+    this.node.removeEventListener('scroll', this.handleScroll);
+  },
+
+  setRef (c) {
+    this.node = c;
+  },
+
   render () {
     const { statusIds, onScrollToBottom, trackScroll } = this.props;
 
     const scrollableArea = (
-      <div className='scrollable' onScroll={this.handleScroll}>
+      <div className='scrollable' ref={this.setRef}>
         <div>
           {statusIds.map((statusId) => {
             return <StatusContainer key={statusId} id={statusId} />;
diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx
index 8f64ad3cd..3edc8f672 100644
--- a/app/assets/javascripts/components/components/video_player.jsx
+++ b/app/assets/javascripts/components/components/video_player.jsx
@@ -4,7 +4,8 @@ import IconButton from './icon_button';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 
 const messages = defineMessages({
-  toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }
+  toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
+  toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' }
 });
 
 const videoStyle = {
@@ -20,7 +21,7 @@ const videoStyle = {
 const muteStyle = {
   position: 'absolute',
   top: '10px',
-  left: '10px',
+  right: '10px',
   opacity: '0.8',
   zIndex: '5'
 };
@@ -35,7 +36,8 @@ const spoilerStyle = {
   display: 'flex',
   alignItems: 'center',
   justifyContent: 'center',
-  flexDirection: 'column'
+  flexDirection: 'column',
+  position: 'relative'
 };
 
 const spoilerSpanStyle = {
@@ -49,6 +51,13 @@ const spoilerSubSpanStyle = {
   fontWeight: '500'
 };
 
+const spoilerButtonStyle = {
+  position: 'absolute',
+  top: '6px',
+  left: '8px',
+  zIndex: '100'
+};
+
 const VideoPlayer = React.createClass({
   propTypes: {
     media: ImmutablePropTypes.map.isRequired,
@@ -66,7 +75,8 @@ const VideoPlayer = React.createClass({
 
   getInitialState () {
     return {
-      visible: false,
+      visible: !this.props.sensitive,
+      preview: true,
       muted: true
     };
   },
@@ -90,22 +100,49 @@ const VideoPlayer = React.createClass({
   },
 
   handleOpen () {
-    this.setState({ visible: true });
+    this.setState({ preview: !this.state.preview });
+  },
+
+  handleVisibility () {
+    this.setState({
+      visible: !this.state.visible,
+      preview: true
+    });
   },
 
   render () {
     const { media, intl, width, height, sensitive } = this.props;
 
-    if (sensitive && !this.state.visible) {
-      return (
-        <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} 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>
-      );
-    } else if (!sensitive && !this.state.visible) {
+    let spoilerButton = (
+      <div style={spoilerButtonStyle} >
+        <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
+      </div>
+    );
+
+    if (!this.state.visible) {
+      if (sensitive) {
+        return (
+          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} 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>
+          </div>
+        );
+      } else {
+        return (
+          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} onClick={this.handleOpen}>
+            {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>
+          </div>
+        );
+      }
+    }
+
+    if (this.state.preview) {
       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}
           <div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div>
         </div>
       );
@@ -113,7 +150,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' }}>
-        <div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-up' : 'volume-off'} onClick={this.handleClick} /></div>
+        {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} />
       </div>
     );
diff --git a/app/assets/javascripts/components/containers/account_container.jsx b/app/assets/javascripts/components/containers/account_container.jsx
index 1f49f9819..889c0ac4c 100644
--- a/app/assets/javascripts/components/containers/account_container.jsx
+++ b/app/assets/javascripts/components/containers/account_container.jsx
@@ -3,7 +3,9 @@ import { makeGetAccount } from '../selectors';
 import Account from '../components/account';
 import {
   followAccount,
-  unfollowAccount
+  unfollowAccount,
+  blockAccount,
+  unblockAccount
 } from '../actions/accounts';
 
 const makeMapStateToProps = () => {
@@ -24,6 +26,14 @@ const mapDispatchToProps = (dispatch) => ({
     } else {
       dispatch(followAccount(account.get('id')));
     }
+  },
+
+  onBlock (account) {
+    if (account.getIn(['relationship', 'blocking'])) {
+      dispatch(unblockAccount(account.get('id')));
+    } else {
+      dispatch(blockAccount(account.get('id')));
+    }
   }
 });
 
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index 670455376..5f4b2cf79 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -7,15 +7,13 @@ import {
   refreshTimeline
 } from '../actions/timelines';
 import { updateNotifications } from '../actions/notifications';
-import { setAccessToken } from '../actions/meta';
-import { setAccountSelf } from '../actions/accounts';
-import PureRenderMixin from 'react-addons-pure-render-mixin';
 import createBrowserHistory from 'history/lib/createBrowserHistory';
 import {
   applyRouterMiddleware,
   useRouterHistory,
   Router,
   Route,
+  IndexRedirect,
   IndexRoute
 } from 'react-router';
 import { useScroll } from 'react-router-scroll';
@@ -35,6 +33,8 @@ import Favourites from '../features/favourites';
 import HashtagTimeline from '../features/hashtag_timeline';
 import Notifications from '../features/notifications';
 import FollowRequests from '../features/follow_requests';
+import GenericNotFound from '../features/generic_not_found';
+import FavouritedStatuses from '../features/favourited_statuses';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import en from 'react-intl/locale-data/en';
 import de from 'react-intl/locale-data/de';
@@ -44,9 +44,12 @@ 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 getMessagesForLocale from '../locales';
+import { hydrateStore } from '../actions/store';
 
 const store = configureStore();
 
+store.dispatch(hydrateStore(window.INITIAL_STATE));
+
 const browserHistory = useRouterHistory(createBrowserHistory)({
   basename: '/web'
 });
@@ -56,31 +59,26 @@ addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]);
 const Mastodon = React.createClass({
 
   propTypes: {
-    token: React.PropTypes.string.isRequired,
-    timelines: React.PropTypes.object,
-    account: React.PropTypes.string,
     locale: React.PropTypes.string.isRequired
   },
 
-  mixins: [PureRenderMixin],
-
   componentWillMount() {
-    const { token, account, locale } = this.props;
-
-    store.dispatch(setAccessToken(token));
-    store.dispatch(setAccountSelf(JSON.parse(account)));
+    const { locale } = this.props;
 
     if (typeof App !== 'undefined') {
       this.subscription = App.cable.subscriptions.create('TimelineChannel', {
 
         received (data) {
           switch(data.type) {
-            case 'update':
-              return store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
-            case 'delete':
-              return store.dispatch(deleteFromTimelines(data.id));
-            case 'notification':
-              return store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale));
+          case 'update':
+            store.dispatch(updateTimeline(data.timeline, JSON.parse(data.message)));
+            break;
+          case 'delete':
+            store.dispatch(deleteFromTimelines(data.id));
+            break;
+          case 'notification':
+            store.dispatch(updateNotifications(JSON.parse(data.message), getMessagesForLocale(locale), locale));
+            break;
           }
         }
 
@@ -107,14 +105,16 @@ const Mastodon = React.createClass({
         <Provider store={store}>
           <Router history={browserHistory} render={applyRouterMiddleware(useScroll())}>
             <Route path='/' component={UI}>
-              <IndexRoute component={GettingStarted} />
+              <IndexRedirect to="/getting-started" />
 
+              <Route path='getting-started' component={GettingStarted} />
               <Route path='timelines/home' component={HomeTimeline} />
               <Route path='timelines/mentions' component={MentionsTimeline} />
               <Route path='timelines/public' component={PublicTimeline} />
               <Route path='timelines/tag/:id' component={HashtagTimeline} />
 
               <Route path='notifications' component={Notifications} />
+              <Route path='favourites' component={FavouritedStatuses} />
 
               <Route path='statuses/new' component={Compose} />
               <Route path='statuses/:statusId' component={Status} />
@@ -128,6 +128,7 @@ const Mastodon = React.createClass({
               </Route>
 
               <Route path='follow_requests' component={FollowRequests} />
+              <Route path='*' component={GenericNotFound} />
             </Route>
           </Router>
         </Provider>
diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx
index 6a882eab4..ad2be03d1 100644
--- a/app/assets/javascripts/components/containers/status_container.jsx
+++ b/app/assets/javascripts/components/containers/status_container.jsx
@@ -15,6 +15,7 @@ import { blockAccount } from '../actions/accounts';
 import { deleteStatus } from '../actions/statuses';
 import { openMedia } from '../actions/modal';
 import { createSelector } from 'reselect'
+import { isMobile } from '../is_mobile'
 
 const mapStateToProps = (state, props) => ({
   statusBase: state.getIn(['statuses', props.id]),
@@ -86,8 +87,11 @@ const mapDispatchToProps = (dispatch) => ({
     dispatch(deleteStatus(status.get('id')));
   },
 
-  onMention (account) {
+  onMention (account, router) {
     dispatch(mentionCompose(account));
+    if (isMobile(window.innerWidth)) {
+      router.push('/statuses/new');
+    }
   },
 
   onOpenMedia (url) {
diff --git a/app/assets/javascripts/components/emoji.jsx b/app/assets/javascripts/components/emoji.jsx
index a06c75953..c93c07c74 100644
--- a/app/assets/javascripts/components/emoji.jsx
+++ b/app/assets/javascripts/components/emoji.jsx
@@ -5,5 +5,5 @@ emojione.sprites      = false;
 emojione.imagePathPNG = '/emoji/';
 
 export default function emojify(text) {
-  return emojione.unicodeToImage(text);
+  return emojione.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 45de75d97..ab7b08dc7 100644
--- a/app/assets/javascripts/components/features/account/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx
@@ -66,7 +66,7 @@ const ActionBar = React.createClass({
     return (
       <div style={outerStyle}>
         <div style={outerDropdownStyle}>
-          <DropdownMenu items={menu} icon='bars' size={24} />
+          <DropdownMenu items={menu} icon='bars' size={24} direction="right" />
         </div>
 
         <div style={outerLinksStyle}>
diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx
index 6ae5ac002..dead11265 100644
--- a/app/assets/javascripts/components/features/account/components/header.jsx
+++ b/app/assets/javascripts/components/features/account/components/header.jsx
@@ -71,8 +71,8 @@ const Header = React.createClass({
             <span style={{ display: 'inline-block', color: '#fff', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
           </a>
 
-          <span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#2b90d9', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
-          <div style={{ color: '#616b86', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
+          <span style={{ fontSize: '14px', fontWeight: '400', display: 'block', color: '#489fde', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
+          <div style={{ color: '#d9e1e8', fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
 
           {info}
           {actionBtn}
diff --git a/app/assets/javascripts/components/features/account/index.jsx b/app/assets/javascripts/components/features/account/index.jsx
index c2cc58bb2..3a9b48f21 100644
--- a/app/assets/javascripts/components/features/account/index.jsx
+++ b/app/assets/javascripts/components/features/account/index.jsx
@@ -20,6 +20,7 @@ import LoadingIndicator      from '../../components/loading_indicator';
 import ActionBar             from './components/action_bar';
 import Column                from '../ui/components/column';
 import ColumnBackButton      from '../../components/column_back_button';
+import { isMobile } from '../../is_mobile'
 
 const makeMapStateToProps = () => {
   const getAccount = makeGetAccount();
@@ -34,11 +35,16 @@ const makeMapStateToProps = () => {
 
 const Account = React.createClass({
 
+  contextTypes: {
+    router: React.PropTypes.object
+  },
+
   propTypes: {
     params: React.PropTypes.object.isRequired,
     dispatch: React.PropTypes.func.isRequired,
     account: ImmutablePropTypes.map,
-    me: React.PropTypes.number.isRequired
+    me: React.PropTypes.number.isRequired,
+    children: React.PropTypes.node
   },
 
   mixins: [PureRenderMixin],
@@ -71,6 +77,9 @@ const Account = React.createClass({
 
   handleMention () {
     this.props.dispatch(mentionCompose(this.props.account));
+    if (isMobile(window.innerWidth)) {
+      this.context.router.push('/statuses/new');
+    }
   },
 
   render () {
diff --git a/app/assets/javascripts/components/features/account_timeline/index.jsx b/app/assets/javascripts/components/features/account_timeline/index.jsx
index 7a3dbe160..5c09839f7 100644
--- a/app/assets/javascripts/components/features/account_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/index.jsx
@@ -9,7 +9,8 @@ import StatusList from '../../components/status_list';
 import LoadingIndicator from '../../components/loading_indicator';
 
 const mapStateToProps = (state, props) => ({
-  statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId)]),
+  statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items']),
+  isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']),
   me: state.getIn(['meta', 'me'])
 });
 
@@ -18,7 +19,9 @@ const AccountTimeline = React.createClass({
   propTypes: {
     params: React.PropTypes.object.isRequired,
     dispatch: React.PropTypes.func.isRequired,
-    statusIds: ImmutablePropTypes.list
+    statusIds: ImmutablePropTypes.list,
+    isLoading: React.PropTypes.bool,
+    me: React.PropTypes.number.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -38,13 +41,13 @@ const AccountTimeline = React.createClass({
   },
 
   render () {
-    const { statusIds, me } = this.props;
+    const { statusIds, isLoading, me } = this.props;
 
     if (!statusIds) {
       return <LoadingIndicator />;
     }
 
-    return <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
+    return <StatusList statusIds={statusIds} isLoading={isLoading} me={me} onScrollToBottom={this.handleScrollToBottom} />
   }
 
 });
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 55f361b0b..48363a968 100644
--- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -14,6 +14,7 @@ import { Motion, spring } from 'react-motion';
 
 const messages = defineMessages({
   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
+  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' },
   publish: { id: 'compose_form.publish', defaultMessage: 'Publish' }
 });
 
@@ -25,6 +26,8 @@ const ComposeForm = React.createClass({
     suggestion_token: React.PropTypes.string,
     suggestions: ImmutablePropTypes.list,
     sensitive: React.PropTypes.bool,
+    spoiler: React.PropTypes.bool,
+    spoiler_text: React.PropTypes.string,
     unlisted: React.PropTypes.bool,
     private: React.PropTypes.bool,
     fileDropDate: React.PropTypes.instanceOf(Date),
@@ -32,6 +35,7 @@ const ComposeForm = React.createClass({
     is_uploading: React.PropTypes.bool,
     in_reply_to: ImmutablePropTypes.map,
     media_count: React.PropTypes.number,
+    me: React.PropTypes.number,
     onChange: React.PropTypes.func.isRequired,
     onSubmit: React.PropTypes.func.isRequired,
     onCancelReply: React.PropTypes.func.isRequired,
@@ -39,6 +43,8 @@ const ComposeForm = React.createClass({
     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,
   },
@@ -49,7 +55,7 @@ const ComposeForm = React.createClass({
     this.props.onChange(e.target.value);
   },
 
-  handleKeyUp (e) {
+  handleKeyDown (e) {
     if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
       this.props.onSubmit();
     }
@@ -76,6 +82,15 @@ const ComposeForm = React.createClass({
     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);
   },
@@ -85,7 +100,14 @@ const ComposeForm = React.createClass({
   },
 
   componentDidUpdate (prevProps) {
-    if (prevProps.in_reply_to !== this.props.in_reply_to) {
+    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 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;
+
+      this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
       this.autosuggestTextarea.textarea.focus();
     }
   },
@@ -103,8 +125,18 @@ const ComposeForm = React.createClass({
       replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
     }
 
+    let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me);
+
     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
@@ -115,7 +147,7 @@ const ComposeForm = React.createClass({
           value={this.props.text}
           onChange={this.handleChange}
           suggestions={this.props.suggestions}
-          onKeyUp={this.handleKeyUp}
+          onKeyDown={this.handleKeyDown}
           onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
           onSuggestionsClearRequested={this.onSuggestionsClearRequested}
           onSuggestionSelected={this.onSuggestionSelected}
@@ -123,7 +155,7 @@ const ComposeForm = React.createClass({
 
         <div style={{ marginTop: '10px', overflow: 'hidden' }}>
           <div style={{ float: 'right' }}><Button text={intl.formatMessage(messages.publish)} onClick={this.handleSubmit} disabled={disabled} /></div>
-          <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={this.props.text} /></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' }} />
         </div>
 
@@ -132,7 +164,12 @@ const ComposeForm = React.createClass({
           <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
         </label>
 
-        <Motion defaultStyle={{ opacity: this.props.private ? 0 : 100, height: this.props.private ? 39.5 : 0 }} style={{ opacity: spring(this.props.private ? 0 : 100), height: spring(this.props.private ? 0 : 39.5) }}>
+        <label style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle' }}>
+          <Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} />
+          <span style={{ display: 'inline-block', verticalAlign: 'middle', marginBottom: '14px', marginLeft: '8px', color: '#9baec8' }}><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide behind content warning' /></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 style={{ display: 'block', lineHeight: '24px', verticalAlign: 'middle', height: `${height}px`, overflow: 'hidden', opacity: opacity / 100 }}>
               <Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} />
diff --git a/app/assets/javascripts/components/features/compose/components/drawer.jsx b/app/assets/javascripts/components/features/compose/components/drawer.jsx
index d31d0e453..d0e865d29 100644
--- a/app/assets/javascripts/components/features/compose/components/drawer.jsx
+++ b/app/assets/javascripts/components/features/compose/components/drawer.jsx
@@ -1,26 +1,75 @@
-import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { Link } from 'react-router';
+import { injectIntl, defineMessages } from 'react-intl';
 
-const style = {
+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 outerStyle = {
+  boxSizing: 'border-box',
+  display: 'flex',
+  flexDirection: 'column',
+  overflowY: 'hidden'
+};
+
+const innerStyle = {
   boxSizing: 'border-box',
-  background: '#454b5e',
   padding: '0',
   display: 'flex',
   flexDirection: 'column',
-  overflowY: 'auto'
+  overflowY: 'auto',
+  flexGrow: '1'
+};
+
+const tabStyle = {
+  display: 'block',
+  flex: '1 1 auto',
+  padding: '15px',
+  paddingBottom: '13px',
+  color: '#9baec8',
+  textDecoration: 'none',
+  textAlign: 'center',
+  fontSize: '16px',
+  borderBottom: '2px solid transparent'
 };
 
-const Drawer = React.createClass({
+const tabActiveStyle = {
+  color: '#2b90d9',
+  borderBottom: '2px solid #2b90d9'
+};
 
-  mixins: [PureRenderMixin],
+const Drawer = ({ children, withHeader, intl }) => {
+  let header = '';
 
-  render () {
-    return (
-      <div className='drawer' style={style}>
-        {this.props.children}
+  if (withHeader) {
+    header = (
+      <div className='drawer__header'>
+        <Link title={intl.formatMessage(messages.start)} style={tabStyle} to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
+        <Link title={intl.formatMessage(messages.public)} style={tabStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
+        <a title={intl.formatMessage(messages.preferences)} style={tabStyle} href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
+        <a title={intl.formatMessage(messages.logout)} style={tabStyle} href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
       </div>
     );
   }
 
-});
+  return (
+    <div className='drawer' style={outerStyle}>
+      {header}
+
+      <div className='drawer__inner' style={innerStyle}>
+        {children}
+      </div>
+    </div>
+  );
+};
+
+Drawer.propTypes = {
+  withHeader: React.PropTypes.bool,
+  children: React.PropTypes.node,
+  intl: React.PropTypes.object
+};
 
-export default Drawer;
+export default injectIntl(Drawer);
diff --git a/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx b/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx
index df94c30d2..289e2dddf 100644
--- a/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx
+++ b/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx
@@ -16,12 +16,12 @@ const NavigationBar = React.createClass({
 
   render () {
     return (
-      <div style={{ padding: '10px', display: 'flex', cursor: 'default' }}>
+      <div style={{ padding: '10px', display: 'flex', flexShrink: '0', cursor: 'default' }}>
         <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Permalink>
 
         <div style={{ flex: '1 1 auto', marginLeft: '8px', color: '#9baec8' }}>
           <strong style={{ fontWeight: '500', display: 'block', color: '#fff' }}>{this.props.account.get('acct')}</strong>
-          <a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.settings' defaultMessage='Settings' /></a> · <Link to='/timelines/public' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.public_timeline' defaultMessage='Public timeline' /></Link> · <a href='/auth/sign_out' data-method='delete' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a>
+          <a href='/settings/profile' style={{ color: 'inherit', textDecoration: 'none' }}><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
         </div>
       </div>
     );
diff --git a/app/assets/javascripts/components/features/compose/components/search.jsx b/app/assets/javascripts/components/features/compose/components/search.jsx
index b4e618820..e4672216b 100644
--- a/app/assets/javascripts/components/features/compose/components/search.jsx
+++ b/app/assets/javascripts/components/features/compose/components/search.jsx
@@ -38,7 +38,7 @@ const inputStyle = {
   border: 'none',
   padding: '10px',
   paddingRight: '30px',
-  fontFamily: 'Roboto',
+  fontFamily: 'inherit',
   background: '#282c37',
   color: '#9baec8',
   fontSize: '14px',
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 5250ff748..4c8181aa1 100644
--- a/app/assets/javascripts/components/features/compose/components/upload_button.jsx
+++ b/app/assets/javascripts/components/features/compose/components/upload_button.jsx
@@ -11,7 +11,9 @@ const UploadButton = React.createClass({
   propTypes: {
     disabled: React.PropTypes.bool,
     onSelectFile: React.PropTypes.func.isRequired,
-    style: React.PropTypes.object
+    style: React.PropTypes.object,
+    resetFileKey: React.PropTypes.number,
+    intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -31,12 +33,12 @@ const UploadButton = React.createClass({
   },
 
   render () {
-    const { intl } = this.props;
+    const { intl, resetFileKey, disabled } = this.props;
 
     return (
       <div style={this.props.style}>
-        <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={this.props.disabled} onClick={this.handleClick} size={24} />
-        <input ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />
+        <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} />
+        <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 ac548033c..94c94b4b7 100644
--- a/app/assets/javascripts/components/features/compose/components/upload_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/upload_form.jsx
@@ -12,15 +12,20 @@ const UploadForm = React.createClass({
   propTypes: {
     media: ImmutablePropTypes.list.isRequired,
     is_uploading: React.PropTypes.bool,
-    onRemoveFile: React.PropTypes.func.isRequired
+    onRemoveFile: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
 
   render () {
-    const { intl } = this.props;
+    const { intl, media } = this.props;
 
-    const uploads = this.props.media.map(attachment => (
+    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'))} />
@@ -29,7 +34,7 @@ const UploadForm = React.createClass({
     ));
 
     return (
-      <div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden' }}>
+      <div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden', flexShrink: '0' }}>
         {uploads}
       </div>
     );
diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
index 2b6ee1ae7..8ccfce059 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
@@ -8,6 +8,8 @@ import {
   fetchComposeSuggestions,
   selectComposeSuggestion,
   changeComposeSensitivity,
+  changeComposeSpoilerness,
+  changeComposeSpoilerText,
   changeComposeVisibility,
   changeComposeListability
 } from '../../../actions/compose';
@@ -22,13 +24,16 @@ const makeMapStateToProps = () => {
       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
+      media_count: state.getIn(['compose', 'media_attachments']).size,
+      me: state.getIn(['compose', 'me'])
     };
   };
 
@@ -65,6 +70,14 @@ const mapDispatchToProps = function (dispatch) {
       dispatch(changeComposeSensitivity(checked));
     },
 
+    onChangeSpoilerness (checked) {
+      dispatch(changeComposeSpoilerness(checked));
+    },
+
+    onChangeSpoilerText (checked) {
+      dispatch(changeComposeSpoilerText(checked));
+    },
+
     onChangeVisibility (checked) {
       dispatch(changeComposeVisibility(checked));
     },
diff --git a/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx b/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx
index 51e2513d8..0006608da 100644
--- a/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/navigation_container.jsx
@@ -1,8 +1,10 @@
 import { connect }   from 'react-redux';
 import NavigationBar from '../components/navigation_bar';
 
-const mapStateToProps = (state, props) => ({
-  account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
-});
+const mapStateToProps = (state, props) => {
+  return {
+    account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
+  };
+};
 
 export default connect(mapStateToProps)(NavigationBar);
diff --git a/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx
index 4154b0737..78e5312f5 100644
--- a/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/upload_button_container.jsx
@@ -4,6 +4,7 @@ import { uploadCompose } from '../../../actions/compose';
 
 const mapStateToProps = state => ({
   disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
+  resetFileKey: state.getIn(['compose', 'resetFileKey'])
 });
 
 const mapDispatchToProps = dispatch => ({
diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx
index 4017c8949..f6095c0c6 100644
--- a/app/assets/javascripts/components/features/compose/index.jsx
+++ b/app/assets/javascripts/components/features/compose/index.jsx
@@ -10,7 +10,8 @@ import { mountCompose, unmountCompose } from '../../actions/compose';
 const Compose = React.createClass({
 
   propTypes: {
-    dispatch: React.PropTypes.func.isRequired
+    dispatch: React.PropTypes.func.isRequired,
+    withHeader: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
@@ -25,7 +26,7 @@ const Compose = React.createClass({
 
   render () {
     return (
-      <Drawer>
+      <Drawer withHeader={this.props.withHeader}>
         <SearchContainer />
         <NavigationContainer />
         <ComposeFormContainer />
diff --git a/app/assets/javascripts/components/features/favourited_statuses/index.jsx b/app/assets/javascripts/components/features/favourited_statuses/index.jsx
new file mode 100644
index 000000000..a2d521736
--- /dev/null
+++ b/app/assets/javascripts/components/features/favourited_statuses/index.jsx
@@ -0,0 +1,63 @@
+import { connect } from 'react-redux';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
+import Column from '../ui/components/column';
+import StatusList from '../../components/status_list';
+import ColumnBackButton from '../public_timeline/components/column_back_button';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  heading: { id: 'column.favourites', defaultMessage: 'Favourites' }
+});
+
+const mapStateToProps = state => ({
+  statusIds: state.getIn(['status_lists', 'favourites', 'items']),
+  loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
+  me: state.getIn(['meta', 'me'])
+});
+
+const Favourites = React.createClass({
+
+  propTypes: {
+    params: React.PropTypes.object.isRequired,
+    dispatch: React.PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    loaded: React.PropTypes.bool,
+    intl: React.PropTypes.object.isRequired,
+    me: React.PropTypes.number.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  componentWillMount () {
+    this.props.dispatch(fetchFavouritedStatuses());
+  },
+
+  handleScrollToBottom () {
+    this.props.dispatch(expandFavouritedStatuses());
+  },
+
+  render () {
+    const { statusIds, loaded, intl, me } = this.props;
+
+    if (!loaded) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column icon='star' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButton />
+        <StatusList statusIds={statusIds} me={me} onScrollToBottom={this.handleScrollToBottom} />
+      </Column>
+    );
+  }
+
+});
+
+export default connect(mapStateToProps)(injectIntl(Favourites));
diff --git a/app/assets/javascripts/components/features/generic_not_found/index.jsx b/app/assets/javascripts/components/features/generic_not_found/index.jsx
new file mode 100644
index 000000000..a7afe29b0
--- /dev/null
+++ b/app/assets/javascripts/components/features/generic_not_found/index.jsx
@@ -0,0 +1,10 @@
+import Column from '../ui/components/column';
+import MissingIndicator from '../../components/missing_indicator';
+
+const GenericNotFound = () => (
+  <Column>
+    <MissingIndicator />
+  </Column>
+);
+
+export default GenericNotFound;
diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx
index 157bdf8f2..42e0a9e24 100644
--- a/app/assets/javascripts/components/features/getting_started/index.jsx
+++ b/app/assets/javascripts/components/features/getting_started/index.jsx
@@ -8,25 +8,16 @@ 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' },
-  settings: { id: 'navigation_bar.settings', defaultMessage: 'Settings' },
-  follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }
+  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' },
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }
 });
 
 const mapStateToProps = state => ({
   me: state.getIn(['accounts', state.getIn(['meta', 'me'])])
 });
 
-const hamburgerStyle = {
-  background: '#373b4a',
-  color: '#fff',
-  fontSize: '16px',
-  padding: '15px',
-  position: 'absolute',
-  right: '0',
-  top: '-48px',
-  cursor: 'default'
-};
-
 const GettingStarted = ({ intl, me }) => {
   let followRequests = '';
 
@@ -37,19 +28,21 @@ const GettingStarted = ({ intl, me }) => {
   return (
     <Column icon='asterisk' heading={intl.formatMessage(messages.heading)}>
       <div style={{ position: 'relative' }}>
-        <div style={hamburgerStyle}><i className='fa fa-bars' /></div>
         <ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />
-        <ColumnLink icon='cog' text={intl.formatMessage(messages.settings)} href='/settings/profile' />
+        <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
+        <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
         {followRequests}
+        <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
       </div>
 
-      <div className='static-content'>
-        <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>
+      <div className='scrollable optionally-scrollable'>
+        <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 style={{ color: '#616b86'}} href="https://github.com/tootsuite/mastodon">tootsuite/mastodon</a> }} /></p>
+        </div>
       </div>
-
-      <div className='getting-started__illustration' />
     </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
new file mode 100644
index 000000000..714be309b
--- /dev/null
+++ b/app/assets/javascripts/components/features/home_timeline/components/column_settings.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 ColumnCollapsable from '../../../components/column_collapsable';
+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' }
+});
+
+const outerStyle = {
+  background: '#373b4a',
+  padding: '15px'
+};
+
+const sectionStyle = {
+  cursor: 'default',
+  display: 'block',
+  fontWeight: '500',
+  color: '#9baec8',
+  marginBottom: '10px'
+};
+
+const rowStyle = {
+
+};
+
+const ColumnSettings = React.createClass({
+
+  propTypes: {
+    settings: ImmutablePropTypes.map.isRequired,
+    onChange: React.PropTypes.func.isRequired,
+    onSave: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { settings, onChange, onSave, intl } = this.props;
+
+    return (
+      <ColumnCollapsable icon='sliders' fullHeight={209} onCollapse={onSave}>
+        <div style={outerStyle}>
+          <span 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' />} />
+          </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>
+
+          <div style={rowStyle}>
+            <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
+          </div>
+        </div>
+      </ColumnCollapsable>
+    );
+  }
+
+});
+
+export default injectIntl(ColumnSettings);
diff --git a/app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx b/app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx
new file mode 100644
index 000000000..79697e869
--- /dev/null
+++ b/app/assets/javascripts/components/features/home_timeline/components/setting_text.jsx
@@ -0,0 +1,41 @@
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const style = {
+  display: 'block',
+  fontFamily: 'inherit',
+  marginBottom: '10px',
+  padding: '7px 0',
+  boxSizing: 'border-box',
+  width: '100%'
+};
+
+const SettingText = React.createClass({
+
+  propTypes: {
+    settings: ImmutablePropTypes.map.isRequired,
+    settingKey: React.PropTypes.array.isRequired,
+    label: React.PropTypes.string.isRequired,
+    onChange: React.PropTypes.func.isRequired
+  },
+
+  handleChange (e) {
+    this.props.onChange(this.props.settingKey, e.target.value)
+  },
+
+  render () {
+    const { settings, settingKey, label } = this.props;
+
+    return (
+      <input
+        style={style}
+        className='setting-text'
+        value={settings.getIn(settingKey)}
+        onChange={this.handleChange}
+        placeholder={label}
+      />
+    );
+  }
+
+});
+
+export default SettingText;
diff --git a/app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx b/app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx
new file mode 100644
index 000000000..3b3ce19bc
--- /dev/null
+++ b/app/assets/javascripts/components/features/home_timeline/containers/column_settings_container.jsx
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting, saveSettings } from '../../../actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'home'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (key, checked) {
+    dispatch(changeSetting(['home', ...key], checked));
+  },
+
+  onSave () {
+    dispatch(saveSettings());
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/assets/javascripts/components/features/home_timeline/index.jsx b/app/assets/javascripts/components/features/home_timeline/index.jsx
index e4f4fa7c7..5d2263f15 100644
--- a/app/assets/javascripts/components/features/home_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/home_timeline/index.jsx
@@ -1,9 +1,8 @@
-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 } from '../../actions/timelines';
 import { defineMessages, injectIntl } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
 
 const messages = defineMessages({
   title: { id: 'column.home', defaultMessage: 'Home' }
@@ -12,20 +11,17 @@ const messages = defineMessages({
 const HomeTimeline = React.createClass({
 
   propTypes: {
-    dispatch: React.PropTypes.func.isRequired
+    intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
 
-  componentWillMount () {
-    this.props.dispatch(refreshTimeline('home'));
-  },
-
   render () {
     const { intl } = this.props;
 
     return (
       <Column icon='home' heading={intl.formatMessage(messages.title)}>
+        <ColumnSettingsContainer />
         <StatusListContainer {...this.props} type='home' />
       </Column>
     );
@@ -33,4 +29,4 @@ const HomeTimeline = React.createClass({
 
 });
 
-export default connect()(injectIntl(HomeTimeline));
+export default injectIntl(HomeTimeline);
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 b4035c20d..b63c1881a 100644
--- a/app/assets/javascripts/components/features/notifications/components/column_settings.jsx
+++ b/app/assets/javascripts/components/features/notifications/components/column_settings.jsx
@@ -1,37 +1,14 @@
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import Toggle from 'react-toggle';
-import { Motion, spring } from 'react-motion';
 import { FormattedMessage } from 'react-intl';
+import ColumnCollapsable from '../../../components/column_collapsable';
+import SettingToggle from './setting_toggle';
 
 const outerStyle = {
   background: '#373b4a',
   padding: '15px'
 };
 
-const iconStyle = {
-  fontSize: '16px',
-  padding: '15px',
-  position: 'absolute',
-  right: '0',
-  top: '-48px',
-  cursor: 'pointer'
-};
-
-const labelStyle = {
-  display: 'block',
-  lineHeight: '24px',
-  verticalAlign: 'middle'
-};
-
-const labelSpanStyle = {
-  display: 'inline-block',
-  verticalAlign: 'middle',
-  marginBottom: '14px',
-  marginLeft: '8px',
-  color: '#9baec8'
-};
-
 const sectionStyle = {
   cursor: 'default',
   display: 'block',
@@ -48,100 +25,55 @@ const ColumnSettings = React.createClass({
 
   propTypes: {
     settings: ImmutablePropTypes.map.isRequired,
-    onChange: React.PropTypes.func.isRequired
-  },
-
-  getInitialState () {
-    return {
-      collapsed: true
-    };
+    onChange: React.PropTypes.func.isRequired,
+    onSave: React.PropTypes.func.isRequired
   },
 
   mixins: [PureRenderMixin],
 
-  handleToggleCollapsed () {
-    this.setState({ collapsed: !this.state.collapsed });
-  },
-
-  handleChange (key, e) {
-    this.props.onChange(key, e.target.checked);
-  },
-
   render () {
-    const { settings }  = this.props;
-    const { collapsed } = this.state;
+    const { settings, onChange, onSave } = this.props;
 
     const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
     const showStr  = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
+    const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
 
     return (
-      <div style={{ position: 'relative' }}>
-        <div style={{...iconStyle, color: collapsed ? '#9baec8' : '#fff', background: collapsed ? '#2f3441' : '#373b4a' }} onClick={this.handleToggleCollapsed}><i className='fa fa-sliders' /></div>
-
-        <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : 458) }}>
-          {({ opacity, height }) =>
-            <div style={{ overflow: 'hidden', height: `${height}px`, opacity: opacity / 100 }}>
-              <div style={outerStyle}>
-                <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
-
-                <div style={rowStyle}>
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['alerts', 'follow'])} onChange={this.handleChange.bind(this, ['alerts', 'follow'])} />
-                    <span style={labelSpanStyle}>{alertStr}</span>
-                  </label>
-
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['shows', 'follow'])} onChange={this.handleChange.bind(this, ['shows', 'follow'])} />
-                    <span style={labelSpanStyle}>{showStr}</span>
-                  </label>
-                </div>
-
-                <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
-
-                <div style={rowStyle}>
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['alerts', 'favourite'])} onChange={this.handleChange.bind(this, ['alerts', 'favourite'])} />
-                    <span style={labelSpanStyle}>{alertStr}</span>
-                  </label>
-
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['shows', 'favourite'])} onChange={this.handleChange.bind(this, ['shows', 'favourite'])} />
-                    <span style={labelSpanStyle}>{showStr}</span>
-                  </label>
-                </div>
-
-                <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
-
-                <div style={rowStyle}>
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['alerts', 'mention'])} onChange={this.handleChange.bind(this, ['alerts', 'mention'])} />
-                    <span style={labelSpanStyle}>{alertStr}</span>
-                  </label>
-
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['shows', 'mention'])} onChange={this.handleChange.bind(this, ['shows', 'mention'])} />
-                    <span style={labelSpanStyle}>{showStr}</span>
-                  </label>
-                </div>
-
-                <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
-
-                <div style={rowStyle}>
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['alerts', 'reblog'])} onChange={this.handleChange.bind(this, ['alerts', 'reblog'])} />
-                    <span style={labelSpanStyle}>{alertStr}</span>
-                  </label>
-
-                  <label style={labelStyle}>
-                    <Toggle checked={settings.getIn(['shows', 'reblog'])} onChange={this.handleChange.bind(this, ['shows', 'reblog'])} />
-                    <span style={labelSpanStyle}>{showStr}</span>
-                  </label>
-                </div>
-              </div>
-            </div>
-          }
-        </Motion>
-      </div>
+      <ColumnCollapsable icon='sliders' fullHeight={616} onCollapse={onSave}>
+        <div style={outerStyle}>
+          <span 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} />
+            <SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
+          </div>
+
+          <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
+
+          <div style={rowStyle}>
+            <SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+            <SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
+          </div>
+
+          <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
+
+          <div style={rowStyle}>
+            <SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+            <SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
+          </div>
+
+          <span style={sectionStyle}><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
+
+          <div style={rowStyle}>
+            <SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+            <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+      </ColumnCollapsable>
     );
   }
 
diff --git a/app/assets/javascripts/components/features/notifications/components/notification.jsx b/app/assets/javascripts/components/features/notifications/components/notification.jsx
index 9f4cf9e4d..140ba9134 100644
--- a/app/assets/javascripts/components/features/notifications/components/notification.jsx
+++ b/app/assets/javascripts/components/features/notifications/components/notification.jsx
@@ -4,6 +4,8 @@ import StatusContainer from '../../../containers/status_container';
 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',
@@ -71,7 +73,7 @@ const Notification = React.createClass({
             <i className='fa fa-fw fa-retweet' style={{ color: '#2b90d9' }} />
           </div>
 
-          <FormattedMessage id='notification.reblog' defaultMessage='{name} reblogged your status' values={{ name: link }} />
+          <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
         </div>
 
         <StatusContainer id={notification.get('status')} muted={true} />
@@ -83,7 +85,8 @@ const Notification = React.createClass({
     const { notification } = this.props;
     const account          = notification.get('account');
     const displayName      = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
-    const link             = <Permalink className='notification__display-name' style={linkStyle} href={account.get('url')} to={`/accounts/${account.get('id')}`}>{displayName}</Permalink>;
+    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+    const link             = <Permalink className='notification__display-name' style={linkStyle} href={account.get('url')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
 
     switch(notification.get('type')) {
       case 'follow':
diff --git a/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx b/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx
new file mode 100644
index 000000000..c2438f716
--- /dev/null
+++ b/app/assets/javascripts/components/features/notifications/components/setting_toggle.jsx
@@ -0,0 +1,32 @@
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Toggle from 'react-toggle';
+
+const labelStyle = {
+  display: 'block',
+  lineHeight: '24px',
+  verticalAlign: 'middle'
+};
+
+const labelSpanStyle = {
+  display: 'inline-block',
+  verticalAlign: 'middle',
+  marginBottom: '14px',
+  marginLeft: '8px',
+  color: '#9baec8'
+};
+
+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>
+  </label>
+);
+
+SettingToggle.propTypes = {
+  settings: ImmutablePropTypes.map.isRequired,
+  settingKey: React.PropTypes.array.isRequired,
+  label: React.PropTypes.node.isRequired,
+  onChange: React.PropTypes.func.isRequired
+};
+
+export default SettingToggle;
diff --git a/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx b/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx
index 6907fd351..bc24c75e0 100644
--- a/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx
+++ b/app/assets/javascripts/components/features/notifications/containers/column_settings_container.jsx
@@ -1,15 +1,19 @@
 import { connect } from 'react-redux';
 import ColumnSettings from '../components/column_settings';
-import { changeNotificationsSetting } from '../../../actions/notifications';
+import { changeSetting, saveSettings } from '../../../actions/settings';
 
 const mapStateToProps = state => ({
-  settings: state.getIn(['notifications', 'settings'])
+  settings: state.getIn(['settings', 'notifications'])
 });
 
 const mapDispatchToProps = dispatch => ({
 
   onChange (key, checked) {
-    dispatch(changeNotificationsSetting(key, checked));
+    dispatch(changeSetting(['notifications', ...key], checked));
+  },
+
+  onSave () {
+    dispatch(saveSettings());
   }
 
 });
diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx
index 7e706ad6a..b4593aaff 100644
--- a/app/assets/javascripts/components/features/notifications/index.jsx
+++ b/app/assets/javascripts/components/features/notifications/index.jsx
@@ -2,10 +2,7 @@ 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 {
-  refreshNotifications,
-  expandNotifications
-} from '../../actions/notifications';
+import { expandNotifications } from '../../actions/notifications';
 import NotificationContainer from './containers/notification_container';
 import { ScrollContainer } from 'react-router-scroll';
 import { defineMessages, injectIntl } from 'react-intl';
@@ -18,12 +15,13 @@ const messages = defineMessages({
 });
 
 const getNotifications = createSelector([
-  state => Immutable.List(state.getIn(['notifications', 'settings', 'shows']).filter(item => !item).keys()),
+  state => Immutable.List(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
   state => state.getIn(['notifications', 'items'])
 ], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
 
 const mapStateToProps = state => ({
-  notifications: getNotifications(state)
+  notifications: getNotifications(state),
+  isLoading: state.getIn(['notifications', 'isLoading'], true)
 });
 
 const Notifications = React.createClass({
@@ -32,7 +30,8 @@ const Notifications = React.createClass({
     notifications: ImmutablePropTypes.list.isRequired,
     dispatch: React.PropTypes.func.isRequired,
     trackScroll: React.PropTypes.bool,
-    intl: React.PropTypes.object.isRequired
+    intl: React.PropTypes.object.isRequired,
+    isLoading: React.PropTypes.bool
   },
 
   getDefaultProps () {
@@ -43,15 +42,11 @@ const Notifications = React.createClass({
 
   mixins: [PureRenderMixin],
 
-  componentWillMount () {
-    const { dispatch } = this.props;
-    dispatch(refreshNotifications());
-  },
-
   handleScroll (e) {
     const { scrollTop, scrollHeight, clientHeight } = e.target;
+    const offset = scrollHeight - scrollTop - clientHeight;
 
-    if (scrollTop === scrollHeight - clientHeight) {
+    if (250 > offset && !this.props.isLoading) {
       this.props.dispatch(expandNotifications());
     }
   },
@@ -70,6 +65,7 @@ const Notifications = React.createClass({
     if (trackScroll) {
       return (
         <Column icon='bell' heading={intl.formatMessage(messages.title)}>
+          <ColumnSettingsContainer />
           <ScrollContainer scrollKey='notifications'>
             {scrollableArea}
           </ScrollContainer>
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 030428440..3f8a0457d 100644
--- a/app/assets/javascripts/components/features/status/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/status/components/action_bar.jsx
@@ -61,8 +61,8 @@ const ActionBar = React.createClass({
       <div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}>
         <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 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} /></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
new file mode 100644
index 000000000..ccb06dfd5
--- /dev/null
+++ b/app/assets/javascripts/components/features/status/components/card.jsx
@@ -0,0 +1,100 @@
+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',
+  paddingLeft: '14px',
+  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%',
+  height: 'auto',
+  margin: '0',
+  borderRadius: '4px 0 0 4px'
+};
+
+const hostStyle = {
+  display: 'block',
+  marginTop: '5px',
+  fontSize: '13px'
+};
+
+const getHostname = url => {
+  const parser = document.createElement('a');
+  parser.href = url;
+  return parser.hostname;
+};
+
+const Card = React.createClass({
+  propTypes: {
+    card: ImmutablePropTypes.map
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { card } = this.props;
+
+    if (card === null) {
+      return null;
+    }
+
+    let image = '';
+
+    if (card.get('image')) {
+      image = (
+        <div style={imageOuterStyle}>
+          <img src={card.get('image')} alt={card.get('title')} style={imageStyle} />
+        </div>
+      );
+    }
+
+    return (
+      <a style={outerStyle} href={card.get('url')} className='status-card'>
+        {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>
+      </a>
+    );
+  }
+});
+
+export default Card;
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 b967d966f..f2d6ae48a 100644
--- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx
+++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
@@ -7,6 +7,7 @@ import MediaGallery from '../../../components/media_gallery';
 import VideoPlayer from '../../../components/video_player';
 import { Link } from 'react-router';
 import { FormattedDate, FormattedNumber } from 'react-intl';
+import CardContainer from '../containers/card_container';
 
 const DetailedStatus = React.createClass({
 
@@ -32,7 +33,9 @@ const DetailedStatus = React.createClass({
 
   render () {
     const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
-    let media    = '';
+
+    let media           = '';
+    let applicationLink = '';
 
     if (status.get('media_attachments').size > 0) {
       if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
@@ -40,6 +43,12 @@ const DetailedStatus = React.createClass({
       } else {
         media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
       }
+    } else {
+      media = <CardContainer statusId={status.get('id')} />;
+    }
+
+    if (status.get('application')) {
+      applicationLink = <span> · <a className='detailed-status__application' style={{ color: 'inherit' }} href={status.getIn(['application', 'website'])} target='_blank' rel='nooopener'>{status.getIn(['application', 'name'])}</a></span>;
     }
 
     return (
@@ -54,7 +63,7 @@ const DetailedStatus = React.createClass({
         {media}
 
         <div style={{ marginTop: '15px', color: '#616b86', fontSize: '14px', lineHeight: '18px' }}>
-          <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> · <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>
+          <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/containers/card_container.jsx b/app/assets/javascripts/components/features/status/containers/card_container.jsx
new file mode 100644
index 000000000..5c8bfeec2
--- /dev/null
+++ b/app/assets/javascripts/components/features/status/containers/card_container.jsx
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import Card from '../components/card';
+
+const mapStateToProps = (state, { statusId }) => ({
+  card: state.getIn(['cards', statusId], null)
+});
+
+export default connect(mapStateToProps)(Card);
diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx
index 0a1528fe9..389549849 100644
--- a/app/assets/javascripts/components/features/status/index.jsx
+++ b/app/assets/javascripts/components/features/status/index.jsx
@@ -23,6 +23,7 @@ import { ScrollContainer }   from 'react-router-scroll';
 import ColumnBackButton      from '../../components/column_back_button';
 import StatusContainer       from '../../containers/status_container';
 import { openMedia }         from '../../actions/modal';
+import { isMobile } from '../../is_mobile'
 
 const makeMapStateToProps = () => {
   const getStatus = makeGetStatus();
@@ -47,7 +48,8 @@ const Status = React.createClass({
     dispatch: React.PropTypes.func.isRequired,
     status: ImmutablePropTypes.map,
     ancestorsIds: ImmutablePropTypes.list,
-    descendantsIds: ImmutablePropTypes.list
+    descendantsIds: ImmutablePropTypes.list,
+    me: React.PropTypes.number
   },
 
   mixins: [PureRenderMixin],
@@ -80,6 +82,10 @@ const Status = React.createClass({
 
   handleMentionClick (account) {
     this.props.dispatch(mentionCompose(account));
+
+    if (isMobile(window.innerWidth)) {
+      this.context.router.push('/statuses/new');
+    }
   },
 
   handleOpenMedia (url) {
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 a2f7c13a6..901a29f5c 100644
--- a/app/assets/javascripts/components/features/ui/components/column_link.jsx
+++ b/app/assets/javascripts/components/features/ui/components/column_link.jsx
@@ -13,10 +13,10 @@ const iconStyle = {
   marginRight: '5px'
 };
 
-const ColumnLink = ({ icon, text, to, href }) => {
+const ColumnLink = ({ icon, text, to, href, method }) => {
   if (href) {
     return (
-      <a href={href} style={outerStyle} className='column-link'>
+      <a href={href} style={outerStyle} className='column-link' data-method={method}>
         <i className={`fa fa-fw fa-${icon}`} style={iconStyle} />
         {text}
       </a>
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 219979522..2f8a28fad 100644
--- a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
+++ b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
@@ -3,15 +3,14 @@ import { FormattedMessage } from 'react-intl';
 
 const outerStyle = {
   background: '#373b4a',
-  margin: '10px',
   flex: '0 0 auto',
-  marginBottom: '0'
+  overflowY: 'auto'
 };
 
 const tabStyle = {
   display: 'block',
   flex: '1 1 auto',
-  padding: '10px',
+  padding: '10px 5px',
   color: '#fff',
   textDecoration: 'none',
   textAlign: 'center',
@@ -31,7 +30,7 @@ const TabsBar = () => {
       <Link style={tabStyle} activeStyle={tabActiveStyle} to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
       <Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
       <Link style={tabStyle} activeStyle={tabActiveStyle} to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
-      <Link style={tabStyle} activeStyle={tabActiveStyle} to='/timelines/public'><i className='fa fa-fw fa-globe' /> <FormattedMessage id='tabs_bar.public' defaultMessage='Public' /></Link>
+      <Link style={{ ...tabStyle, flexGrow: '0', flexBasis: '30px' }} activeStyle={tabActiveStyle} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link>
     </div>
   );
 };
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 cd7d63a4a..53d162462 100644
--- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
@@ -1,6 +1,9 @@
-import { connect }           from 'react-redux';
-import { closeModal }        from '../../../actions/modal';
-import Lightbox              from '../../../components/lightbox';
+import { connect } from 'react-redux';
+import { closeModal } 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';
 
 const mapStateToProps = state => ({
   url: state.getIn(['modal', 'url']),
@@ -23,6 +26,18 @@ const imageStyle = {
   maxHeight: '80vh'
 };
 
+const loadingStyle = {
+  background: '#373b4a',
+  width: '400px',
+  paddingBottom: '120px'
+};
+
+const preloader = () => (
+  <div style={loadingStyle}>
+    <LoadingIndicator />
+  </div>
+);
+
 const Modal = React.createClass({
 
   propTypes: {
@@ -32,12 +47,18 @@ const Modal = React.createClass({
     onOverlayClicked: React.PropTypes.func
   },
 
+  mixins: [PureRenderMixin],
+
   render () {
     const { url, ...other } = this.props;
 
     return (
       <Lightbox {...other}>
-        <img src={url} style={imageStyle} />
+        <ImageLoader
+          src={url}
+          preloader={preloader}
+          imgProps={{ style: imageStyle }}
+        />
       </Lightbox>
     );
   }
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 1621cec7b..8af7b0c3c 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
@@ -2,26 +2,56 @@ import { connect } from 'react-redux';
 import StatusList from '../../../components/status_list';
 import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines';
 import Immutable from 'immutable';
+import { createSelector } from 'reselect';
+
+const getStatusIds = createSelector([
+  (state, { type }) => state.getIn(['settings', type], Immutable.Map()),
+  (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()),
+  (state)           => state.get('statuses')
+], (columnSettings, statusIds, statuses) => statusIds.filter(id => {
+  const statusForId = statuses.get(id);
+  let showStatus    = true;
+
+  if (columnSettings.getIn(['shows', 'reblog']) === false) {
+    showStatus = showStatus && statusForId.get('reblog') === null;
+  }
+
+  if (columnSettings.getIn(['shows', 'reply']) === false) {
+    showStatus = showStatus && statusForId.get('in_reply_to_id') === null;
+  }
+
+  if (columnSettings.getIn(['regex', 'body'], '').trim().length > 0) {
+    try {
+      const regex = new RegExp(columnSettings.getIn(['regex', 'body']).trim(), 'i');
+      showStatus = showStatus && !regex.test(statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'content']) : statusForId.get('content'));
+    } catch(e) {
+      // Bad regex, don't affect filters
+    }
+  }
+
+  return showStatus;
+}));
 
 const mapStateToProps = (state, props) => ({
-  statusIds: state.getIn(['timelines', props.type, 'items'], Immutable.List())
+  statusIds: getStatusIds(state, props),
+  isLoading: state.getIn(['timelines', props.type, 'isLoading'], true)
 });
 
-const mapDispatchToProps = function (dispatch, props) {
-  return {
-    onScrollToBottom () {
-      dispatch(scrollTopTimeline(props.type, false));
-      dispatch(expandTimeline(props.type, props.id));
-    },
+const mapDispatchToProps = (dispatch, { type, id }) => ({
 
-    onScrollToTop () {
-      dispatch(scrollTopTimeline(props.type, true));
-    },
+  onScrollToBottom () {
+    dispatch(scrollTopTimeline(type, false));
+    dispatch(expandTimeline(type, id));
+  },
 
-    onScroll () {
-      dispatch(scrollTopTimeline(props.type, false));
-    }
-  };
-};
+  onScrollToTop () {
+    dispatch(scrollTopTimeline(type, true));
+  },
+
+  onScroll () {
+    dispatch(scrollTopTimeline(type, false));
+  }
+
+});
 
 export default connect(mapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx
index 76e3dd940..003d061ad 100644
--- a/app/assets/javascripts/components/features/ui/index.jsx
+++ b/app/assets/javascripts/components/features/ui/index.jsx
@@ -8,12 +8,20 @@ import Compose from '../compose';
 import TabsBar from './components/tabs_bar';
 import ModalContainer from './containers/modal_container';
 import Notifications from '../notifications';
+import { connect } from 'react-redux';
+import { isMobile } from '../../is_mobile';
 import { debounce } from 'react-decoration';
 import { uploadCompose } from '../../actions/compose';
-import { connect } from 'react-redux';
+import { refreshTimeline } from '../../actions/timelines';
+import { refreshNotifications } from '../../actions/notifications';
 
 const UI = React.createClass({
 
+  propTypes: {
+    dispatch: React.PropTypes.func.isRequired,
+    children: React.PropTypes.node
+  },
+
   getInitialState () {
     return {
       width: window.innerWidth
@@ -41,7 +49,7 @@ const UI = React.createClass({
   handleDrop (e) {
     e.preventDefault();
 
-    if (e.dataTransfer) {
+    if (e.dataTransfer && e.dataTransfer.files.length === 1) {
       this.props.dispatch(uploadCompose(e.dataTransfer.files));
     }
   },
@@ -50,6 +58,9 @@ const UI = React.createClass({
     window.addEventListener('resize', this.handleResize, { passive: true });
     window.addEventListener('dragover', this.handleDragOver);
     window.addEventListener('drop', this.handleDrop);
+
+    this.props.dispatch(refreshTimeline('home'));
+    this.props.dispatch(refreshNotifications());
   },
 
   componentWillUnmount () {
@@ -59,11 +70,9 @@ const UI = React.createClass({
   },
 
   render () {
-    const layoutBreakpoint = 1024;
-
     let mountedColumns;
 
-    if (this.state.width <= layoutBreakpoint) {
+    if (isMobile(this.state.width)) {
       mountedColumns = (
         <ColumnsArea>
           {this.props.children}
@@ -72,7 +81,7 @@ const UI = React.createClass({
     } else {
       mountedColumns = (
         <ColumnsArea>
-          <Compose />
+          <Compose withHeader={true} />
           <HomeTimeline trackScroll={false} />
           <Notifications trackScroll={false} />
           {this.props.children}
diff --git a/app/assets/javascripts/components/is_mobile.jsx b/app/assets/javascripts/components/is_mobile.jsx
new file mode 100644
index 000000000..eaa6221e4
--- /dev/null
+++ b/app/assets/javascripts/components/is_mobile.jsx
@@ -0,0 +1,5 @@
+const LAYOUT_BREAKPOINT = 1024;
+
+export function isMobile(width) {
+  return width <= LAYOUT_BREAKPOINT;
+};
diff --git a/app/assets/javascripts/components/locales/de.jsx b/app/assets/javascripts/components/locales/de.jsx
index 17b74e15d..7d32824f1 100644
--- a/app/assets/javascripts/components/locales/de.jsx
+++ b/app/assets/javascripts/components/locales/de.jsx
@@ -8,6 +8,9 @@ const en = {
   "status.reblog": "Teilen",
   "status.favourite": "Favorisieren",
   "status.reblogged_by": "{name} teilte",
+  "status.sensitive_warning": "Sensible Inhalte",
+  "status.sensitive_toggle": "Klicken um zu zeigen",
+  "status.open": "Öffnen",
   "video_player.toggle_sound": "Ton umschalten",
   "account.mention": "Erwähnen",
   "account.edit_profile": "Profil bearbeiten",
@@ -19,14 +22,17 @@ const en = {
   "account.follows": "Folgt",
   "account.followers": "Folger",
   "account.follows_you": "Folgt dir",
+  "account.requested": "Warte auf Erlaubnis",
   "getting_started.heading": "Erste Schritte",
   "getting_started.about_addressing": "Du kannst Leuten folgen, falls du ihren Nutzernamen und ihre Domain kennst, in dem du eine e-mail-artige Addresse in das Suchfeld oben an der Seite eingibst.",
   "getting_started.about_shortcuts": "Falls der Zielnutzer an derselben Domain ist wie du, funktioniert der Nutzername auch alleine. Das gilt auch für Erwähnungen in Beiträgen.",
   "getting_started.about_developer": "Der Entwickler des Projekts kann unter Gargron@mastodon.social gefunden werden",
+  "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
   "column.home": "Home",
   "column.mentions": "Erwähnungen",
   "column.public": "Gesamtes Bekanntes Netz",
   "column.notifications": "Mitteilungen",
+  "column.follow_requests": "Folgeanfragen",
   "tabs_bar.compose": "Schreiben",
   "tabs_bar.home": "Home",
   "tabs_bar.mentions": "Erwähnungen",
@@ -36,9 +42,12 @@ const en = {
   "compose_form.publish": "Veröffentlichen",
   "compose_form.sensitive": "Medien als sensitiv markieren",
   "compose_form.unlisted": "Öffentlich nicht auflisten",
-  "navigation_bar.settings": "Einstellungen",
+  "compose_form.private": "Als privat markieren",
+  "navigation_bar.edit_profile": "Profil bearbeiten",
+  "navigation_bar.preferences": "Einstellungen",
   "navigation_bar.public_timeline": "Öffentlich",
   "navigation_bar.logout": "Abmelden",
+  "navigation_bar.follow_requests": "Folgeanfragen",
   "reply_indicator.cancel": "Abbrechen",
   "search.placeholder": "Suche",
   "search.account": "Konto",
@@ -48,7 +57,21 @@ const en = {
   "notification.follow": "{name} folgt dir",
   "notification.favourite": "{name} favorisierte deinen Status",
   "notification.reblog": "{name} teilte deinen Status",
-  "notification.mention": "{name} erwähnte dich"
+  "notification.mention": "{name} erwähnte dich",
+  "notifications.column_settings.alert": "Desktop-Benachrichtigunen",
+  "notifications.column_settings.show": "In der Spalte anzeigen",
+  "notifications.column_settings.follow": "Neue Folger:",
+  "notifications.column_settings.favourite": "Favorisierungen:",
+  "notifications.column_settings.mention": "Erwähnungen:",
+  "notifications.column_settings.reblog": "Geteilte Beiträge:",
+  "follow_request.authorize": "Erlauben",
+  "follow_request.reject": "Ablehnen",
+  "home.column_settings.basic": "Einfach",
+  "home.column_settings.advanced": "Fortgeschritten",
+  "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen",
+  "home.column_settings.show_replies": "Antworten anzeigen",
+  "home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke",
+  "missing_indicator.label": "Nicht gefunden"
 };
 
 export default en;
diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx
index 3d4a38919..92dcbaeb9 100644
--- a/app/assets/javascripts/components/locales/en.jsx
+++ b/app/assets/javascripts/components/locales/en.jsx
@@ -17,7 +17,6 @@ const en = {
   "account.unfollow": "Unfollow",
   "account.block": "Block",
   "account.follow": "Follow",
-  "account.block": "Block",
   "account.posts": "Posts",
   "account.follows": "Follows",
   "account.followers": "Followers",
@@ -27,6 +26,7 @@ const en = {
   "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}.",
   "column.home": "Home",
   "column.mentions": "Mentions",
   "column.public": "Public",
@@ -40,7 +40,9 @@ const en = {
   "compose_form.publish": "Toot",
   "compose_form.sensitive": "Mark media as sensitive",
   "compose_form.private": "Mark as private",
-  "navigation_bar.settings": "Settings",
+  "compose_form.unlisted": "Do not display in public timeline",
+  "navigation_bar.edit_profile": "Edit profile",
+  "navigation_bar.preferences": "Preferences",
   "navigation_bar.public_timeline": "Public timeline",
   "navigation_bar.logout": "Logout",
   "reply_indicator.cancel": "Cancel",
diff --git a/app/assets/javascripts/components/locales/es.jsx b/app/assets/javascripts/components/locales/es.jsx
index 6bd9b18ed..b75fb57d9 100644
--- a/app/assets/javascripts/components/locales/es.jsx
+++ b/app/assets/javascripts/components/locales/es.jsx
@@ -37,7 +37,8 @@ const es = {
   "compose_form.publish": "Publicar",
   "compose_form.sensitive": "Marcar el contenido como sensible",
   "compose_form.unlisted": "Privado",
-  "navigation_bar.settings": "Ajustes",
+  "navigation_bar.edit_profile": "Editar perfil",
+  "navigation_bar.preferences": "Preferencias",
   "navigation_bar.public_timeline": "Público",
   "navigation_bar.logout": "Cerrar sesión",
   "reply_indicator.cancel": "Cancelar",
diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx
index 968c3f8c3..183e5d5b5 100644
--- a/app/assets/javascripts/components/locales/fr.jsx
+++ b/app/assets/javascripts/components/locales/fr.jsx
@@ -38,7 +38,8 @@ const fr = {
   "compose_form.publish": "Pouet",
   "compose_form.sensitive": "Marquer le contenu comme délicat",
   "compose_form.unlisted": "Ne pas apparaître dans le fil public",
-  "navigation_bar.settings": "Paramètres",
+  "navigation_bar.edit_profile": "Modifier le profil",
+  "navigation_bar.preferences": "Préférences",
   "navigation_bar.public_timeline": "Public",
   "navigation_bar.logout": "Déconnexion",
   "reply_indicator.cancel": "Annuler",
diff --git a/app/assets/javascripts/components/locales/hu.jsx b/app/assets/javascripts/components/locales/hu.jsx
index 606fc830f..9a2d14d87 100644
--- a/app/assets/javascripts/components/locales/hu.jsx
+++ b/app/assets/javascripts/components/locales/hu.jsx
@@ -38,7 +38,8 @@ const hu = {
   "compose_form.publish": "Tülk!",
   "compose_form.sensitive": "Tartalom érzékenynek jelölése",
   "compose_form.unlisted": "Listázatlan mód",
-  "navigation_bar.settings": "Beállítások",
+  "navigation_bar.edit_profile": "Profil szerkesztése",
+  "navigation_bar.preferences": "Beállítások",
   "navigation_bar.public_timeline": "Nyilvános időfolyam",
   "navigation_bar.logout": "Kijelentkezés",
   "reply_indicator.cancel": "Mégsem",
diff --git a/app/assets/javascripts/components/locales/pt.jsx b/app/assets/javascripts/components/locales/pt.jsx
index 57cbcd31b..d68724b13 100644
--- a/app/assets/javascripts/components/locales/pt.jsx
+++ b/app/assets/javascripts/components/locales/pt.jsx
@@ -36,7 +36,8 @@ const pt = {
   "compose_form.publish": "Publicar",
   "compose_form.sensitive": "Marcar conteúdo como sensível",
   "compose_form.unlisted": "Modo não-listado",
-  "navigation_bar.settings": "Configurações",
+  "navigation_bar.edit_profile": "Editar perfil",
+  "navigation_bar.preferences": "Preferências",
   "navigation_bar.public_timeline": "Timeline Pública",
   "navigation_bar.logout": "Logout",
   "reply_indicator.cancel": "Cancelar",
diff --git a/app/assets/javascripts/components/locales/uk.jsx b/app/assets/javascripts/components/locales/uk.jsx
index 53535c25a..84a348c21 100644
--- a/app/assets/javascripts/components/locales/uk.jsx
+++ b/app/assets/javascripts/components/locales/uk.jsx
@@ -38,7 +38,8 @@ const uk = {
   "compose_form.publish": "Дмухнути",
   "compose_form.sensitive": "Непристойний зміст",
   "compose_form.unlisted": "Таємний режим",
-  "navigation_bar.settings": "Налаштування",
+  "navigation_bar.edit_profile": "Редагувати профіль",
+  "navigation_bar.preferences": "Налаштування",
   "navigation_bar.public_timeline": "Публічна стіна",
   "navigation_bar.logout": "Вийти",
   "reply_indicator.cancel": "Відмінити",
diff --git a/app/assets/javascripts/components/middleware/errors.jsx b/app/assets/javascripts/components/middleware/errors.jsx
index 3a1473bc1..74d77f0f9 100644
--- a/app/assets/javascripts/components/middleware/errors.jsx
+++ b/app/assets/javascripts/components/middleware/errors.jsx
@@ -23,7 +23,7 @@ export default function errorsMiddleware() {
           dispatch(showAlert(title, message));
         } else {
           console.error(action.error);
-          dispatch(showAlert('Oops!', 'An unexpected error occurred. Inspect the console for more details'));
+          dispatch(showAlert('Oops!', 'An unexpected error occurred.'));
         }
       }
     }
diff --git a/app/assets/javascripts/components/middleware/loading_bar.jsx b/app/assets/javascripts/components/middleware/loading_bar.jsx
new file mode 100644
index 000000000..a98f1bb2b
--- /dev/null
+++ b/app/assets/javascripts/components/middleware/loading_bar.jsx
@@ -0,0 +1,25 @@
+import { showLoading, hideLoading } from 'react-redux-loading-bar';
+
+const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED'];
+
+export default function loadingBarMiddleware(config = {}) {
+  const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes;
+
+  return ({ dispatch }) => next => (action) => {
+    if (action.type && !action.skipLoading) {
+      const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes;
+
+      const isPending = new RegExp(`${PENDING}$`, 'g');
+      const isFulfilled = new RegExp(`${FULFILLED}$`, 'g');
+      const isRejected = new RegExp(`${REJECTED}$`, 'g');
+
+      if (action.type.match(isPending)) {
+        dispatch(showLoading());
+      } else if (action.type.match(isFulfilled) || action.type.match(isRejected)) {
+        dispatch(hideLoading());
+      }
+    }
+
+    return next(action);
+  };
+};
diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx
index 7f2f89d0a..409dfd663 100644
--- a/app/assets/javascripts/components/reducers/accounts.jsx
+++ b/app/assets/javascripts/components/reducers/accounts.jsx
@@ -1,5 +1,4 @@
 import {
-  ACCOUNT_SET_SELF,
   ACCOUNT_FETCH_SUCCESS,
   FOLLOWERS_FETCH_SUCCESS,
   FOLLOWERS_EXPAND_SUCCESS,
@@ -7,7 +6,9 @@ import {
   FOLLOWING_EXPAND_SUCCESS,
   ACCOUNT_TIMELINE_FETCH_SUCCESS,
   ACCOUNT_TIMELINE_EXPAND_SUCCESS,
-  FOLLOW_REQUESTS_FETCH_SUCCESS
+  FOLLOW_REQUESTS_FETCH_SUCCESS,
+  ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_SUCCESS
 } from '../actions/accounts';
 import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
 import {
@@ -33,6 +34,11 @@ import {
   NOTIFICATIONS_REFRESH_SUCCESS,
   NOTIFICATIONS_EXPAND_SUCCESS
 } from '../actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
+import { STORE_HYDRATE } from '../actions/store';
 import Immutable from 'immutable';
 
 const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS(account));
@@ -67,38 +73,45 @@ const initialState = Immutable.Map();
 
 export default function accounts(state = initialState, action) {
   switch(action.type) {
-    case ACCOUNT_SET_SELF:
-    case ACCOUNT_FETCH_SUCCESS:
-    case NOTIFICATIONS_UPDATE:
-      return normalizeAccount(state, action.account);
-    case FOLLOWERS_FETCH_SUCCESS:
-    case FOLLOWERS_EXPAND_SUCCESS:
-    case FOLLOWING_FETCH_SUCCESS:
-    case FOLLOWING_EXPAND_SUCCESS:
-    case REBLOGS_FETCH_SUCCESS:
-    case FAVOURITES_FETCH_SUCCESS:
-    case COMPOSE_SUGGESTIONS_READY:
-    case SEARCH_SUGGESTIONS_READY:
-    case FOLLOW_REQUESTS_FETCH_SUCCESS:
-      return normalizeAccounts(state, action.accounts);
-    case NOTIFICATIONS_REFRESH_SUCCESS:
-    case NOTIFICATIONS_EXPAND_SUCCESS:
-      return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
-    case TIMELINE_REFRESH_SUCCESS:
-    case TIMELINE_EXPAND_SUCCESS:
-    case ACCOUNT_TIMELINE_FETCH_SUCCESS:
-    case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
-    case CONTEXT_FETCH_SUCCESS:
-      return normalizeAccountsFromStatuses(state, action.statuses);
-    case REBLOG_SUCCESS:
-    case FAVOURITE_SUCCESS:
-    case UNREBLOG_SUCCESS:
-    case UNFAVOURITE_SUCCESS:
-      return normalizeAccountFromStatus(state, action.response);
-    case TIMELINE_UPDATE:
-    case STATUS_FETCH_SUCCESS:
-      return normalizeAccountFromStatus(state, action.status);
-    default:
-      return state;
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('accounts'));
+  case ACCOUNT_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+    return normalizeAccount(state, action.account);
+  case FOLLOWERS_FETCH_SUCCESS:
+  case FOLLOWERS_EXPAND_SUCCESS:
+  case FOLLOWING_FETCH_SUCCESS:
+  case FOLLOWING_EXPAND_SUCCESS:
+  case REBLOGS_FETCH_SUCCESS:
+  case FAVOURITES_FETCH_SUCCESS:
+  case COMPOSE_SUGGESTIONS_READY:
+  case SEARCH_SUGGESTIONS_READY:
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+    return normalizeAccounts(state, action.accounts);
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+    return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
+  case TIMELINE_REFRESH_SUCCESS:
+  case TIMELINE_EXPAND_SUCCESS:
+  case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+  case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return normalizeAccountsFromStatuses(state, action.statuses);
+  case REBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+    return normalizeAccountFromStatus(state, action.response);
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+    return normalizeAccountFromStatus(state, action.status);
+  case ACCOUNT_FOLLOW_SUCCESS:
+    return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
+  case ACCOUNT_UNFOLLOW_SUCCESS:
+    return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/cards.jsx b/app/assets/javascripts/components/reducers/cards.jsx
new file mode 100644
index 000000000..3c9395011
--- /dev/null
+++ b/app/assets/javascripts/components/reducers/cards.jsx
@@ -0,0 +1,14 @@
+import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards';
+
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map();
+
+export default function cards(state = initialState, action) {
+  switch(action.type) {
+  case STATUS_CARD_FETCH_SUCCESS:
+    return state.set(action.id, Immutable.fromJS(action.card));
+  default:
+    return state;
+  }
+};
diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx
index 16215684e..d3a84842f 100644
--- a/app/assets/javascripts/components/reducers/compose.jsx
+++ b/app/assets/javascripts/components/reducers/compose.jsx
@@ -17,16 +17,20 @@ import {
   COMPOSE_SUGGESTIONS_READY,
   COMPOSE_SUGGESTION_SELECT,
   COMPOSE_SENSITIVITY_CHANGE,
+  COMPOSE_SPOILERNESS_CHANGE,
+  COMPOSE_SPOILER_TEXT_CHANGE,
   COMPOSE_VISIBILITY_CHANGE,
   COMPOSE_LISTABILITY_CHANGE
 } from '../actions/compose';
 import { TIMELINE_DELETE } from '../actions/timelines';
-import { ACCOUNT_SET_SELF } from '../actions/accounts';
+import { STORE_HYDRATE } from '../actions/store';
 import Immutable from 'immutable';
 
 const initialState = Immutable.Map({
   mounted: false,
   sensitive: false,
+  spoiler: false,
+  spoiler_text: '',
   unlisted: false,
   private: false,
   text: '',
@@ -38,7 +42,8 @@ const initialState = Immutable.Map({
   media_attachments: Immutable.List(),
   suggestion_token: null,
   suggestions: Immutable.List(),
-  me: null
+  me: null,
+  resetFileKey: Math.floor((Math.random() * 0x10000))
 });
 
 function statusToTextMentions(state, status) {
@@ -55,6 +60,8 @@ function statusToTextMentions(state, status) {
 function clearAll(state) {
   return state.withMutations(map => {
     map.set('text', '');
+    map.set('spoiler', false);
+    map.set('spoiler_text', '');
     map.set('is_submitting', false);
     map.set('in_reply_to', null);
     map.update('media_attachments', list => list.clear());
@@ -65,6 +72,7 @@ function appendMedia(state, media) {
   return state.withMutations(map => {
     map.update('media_attachments', list => list.push(media));
     map.set('is_uploading', false);
+    map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
     map.update('text', oldText => `${oldText} ${media.get('text_url')}`.trim());
   });
 };
@@ -80,7 +88,7 @@ function removeMedia(state, mediaId) {
 
 const insertSuggestion = (state, position, token, completion) => {
   return state.withMutations(map => {
-    map.update('text', oldText => `${oldText.slice(0, position)}${completion}${oldText.slice(position + token.length)}`);
+    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());
   });
@@ -88,64 +96,68 @@ const insertSuggestion = (state, position, token, completion) => {
 
 export default function compose(state = initialState, action) {
   switch(action.type) {
-    case COMPOSE_MOUNT:
-      return state.set('mounted', true);
-    case COMPOSE_UNMOUNT:
-      return state.set('mounted', false);
-    case COMPOSE_SENSITIVITY_CHANGE:
-      return state.set('sensitive', action.checked);
-    case COMPOSE_VISIBILITY_CHANGE:
-      return state.set('private', action.checked);
-    case COMPOSE_LISTABILITY_CHANGE:
-      return state.set('unlisted', action.checked);      
-    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));
-      });
-    case COMPOSE_REPLY_CANCEL:
-      return state.withMutations(map => {
-        map.set('in_reply_to', null);
-        map.set('text', '');
-      });
-    case COMPOSE_SUBMIT_REQUEST:
-      return state.set('is_submitting', true);
-    case COMPOSE_SUBMIT_SUCCESS:
-      return clearAll(state);
-    case COMPOSE_SUBMIT_FAIL:
-      return state.set('is_submitting', false);
-    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));
-    case COMPOSE_UPLOAD_FAIL:
-      return state.set('is_uploading', false);
-    case COMPOSE_UPLOAD_UNDO:
-      return removeMedia(state, action.media_id);
-    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')} `);
-    case COMPOSE_SUGGESTIONS_CLEAR:
-      return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
-    case COMPOSE_SUGGESTIONS_READY:
-      return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
-    case COMPOSE_SUGGESTION_SELECT:
-      return insertSuggestion(state, action.position, action.token, action.completion);
-    case TIMELINE_DELETE:
-      if (action.id === state.get('in_reply_to')) {
-        return state.set('in_reply_to', null);
-      } else {
-        return state;
-      }
-    case ACCOUNT_SET_SELF:
-      return state.set('me', action.account.id).set('private', action.account.locked);
-    default:
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('compose'));
+  case COMPOSE_MOUNT:
+    return state.set('mounted', true);
+  case COMPOSE_UNMOUNT:
+    return state.set('mounted', false);
+  case COMPOSE_SENSITIVITY_CHANGE:
+    return state.set('sensitive', action.checked);
+  case COMPOSE_SPOILERNESS_CHANGE:
+    return (action.checked ? state : state.set('spoiler_text', '')).set('spoiler', action.checked);
+  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);
+  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));
+    });
+  case COMPOSE_REPLY_CANCEL:
+    return state.withMutations(map => {
+      map.set('in_reply_to', null);
+      map.set('text', '');
+    });
+  case COMPOSE_SUBMIT_REQUEST:
+    return state.set('is_submitting', true);
+  case COMPOSE_SUBMIT_SUCCESS:
+    return clearAll(state);
+  case COMPOSE_SUBMIT_FAIL:
+    return state.set('is_submitting', false);
+  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));
+  case COMPOSE_UPLOAD_FAIL:
+    return state.set('is_uploading', false);
+  case COMPOSE_UPLOAD_UNDO:
+    return removeMedia(state, action.media_id);
+  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')} `);
+  case COMPOSE_SUGGESTIONS_CLEAR:
+    return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
+  case COMPOSE_SUGGESTIONS_READY:
+    return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
+  case COMPOSE_SUGGESTION_SELECT:
+    return insertSuggestion(state, action.position, action.token, action.completion);
+  case TIMELINE_DELETE:
+    if (action.id === state.get('in_reply_to')) {
+      return state.set('in_reply_to', null);
+    } else {
       return state;
+    }
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx
index aea9239f8..0798116c4 100644
--- a/app/assets/javascripts/components/reducers/index.jsx
+++ b/app/assets/javascripts/components/reducers/index.jsx
@@ -11,6 +11,9 @@ import statuses from './statuses';
 import relationships from './relationships';
 import search from './search';
 import notifications from './notifications';
+import settings from './settings';
+import status_lists from './status_lists';
+import cards from './cards';
 
 export default combineReducers({
   timelines,
@@ -20,9 +23,12 @@ export default combineReducers({
   loadingBar: loadingBarReducer,
   modal,
   user_lists,
+  status_lists,
   accounts,
   statuses,
   relationships,
   search,
-  notifications
+  notifications,
+  settings,
+  cards
 });
diff --git a/app/assets/javascripts/components/reducers/meta.jsx b/app/assets/javascripts/components/reducers/meta.jsx
index c7222c60b..cd4b313d5 100644
--- a/app/assets/javascripts/components/reducers/meta.jsx
+++ b/app/assets/javascripts/components/reducers/meta.jsx
@@ -1,16 +1,16 @@
-import { ACCESS_TOKEN_SET } from '../actions/meta';
-import { ACCOUNT_SET_SELF } from '../actions/accounts';
+import { STORE_HYDRATE } from '../actions/store';
 import Immutable from 'immutable';
 
-const initialState = Immutable.Map();
+const initialState = Immutable.Map({
+  access_token: null,
+  me: null
+});
 
 export default function meta(state = initialState, action) {
   switch(action.type) {
-    case ACCESS_TOKEN_SET:
-      return state.set('access_token', action.token);
-    case ACCOUNT_SET_SELF:
-      return state.set('me', action.account.id);
-    default:
-      return state;
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('meta'));
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx
index b529b6aa8..ac53ea210 100644
--- a/app/assets/javascripts/components/reducers/modal.jsx
+++ b/app/assets/javascripts/components/reducers/modal.jsx
@@ -8,14 +8,14 @@ const initialState = Immutable.Map({
 
 export default function modal(state = initialState, action) {
   switch(action.type) {
-    case MEDIA_OPEN:
-      return state.withMutations(map => {
-        map.set('url', action.url);
-        map.set('open', true);
-      });
-    case MODAL_CLOSE:
-      return state.set('open', false);
-    default:
-      return state;
+  case MEDIA_OPEN:
+    return state.withMutations(map => {
+      map.set('url', action.url);
+      map.set('open', true);
+    });
+  case MODAL_CLOSE:
+    return state.set('open', false);
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx
index e0d1ccf83..482093c33 100644
--- a/app/assets/javascripts/components/reducers/notifications.jsx
+++ b/app/assets/javascripts/components/reducers/notifications.jsx
@@ -2,7 +2,10 @@ import {
   NOTIFICATIONS_UPDATE,
   NOTIFICATIONS_REFRESH_SUCCESS,
   NOTIFICATIONS_EXPAND_SUCCESS,
-  NOTIFICATIONS_SETTING_CHANGE
+  NOTIFICATIONS_REFRESH_REQUEST,
+  NOTIFICATIONS_EXPAND_REQUEST,
+  NOTIFICATIONS_REFRESH_FAIL,
+  NOTIFICATIONS_EXPAND_FAIL
 } from '../actions/notifications';
 import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
 import Immutable from 'immutable';
@@ -11,22 +14,7 @@ const initialState = Immutable.Map({
   items: Immutable.List(),
   next: null,
   loaded: false,
-
-  settings: Immutable.Map({
-    alerts: Immutable.Map({
-      follow: true,
-      favourite: true,
-      reblog: true,
-      mention: true
-    }),
-
-    shows: Immutable.Map({
-      follow: true,
-      favourite: true,
-      reblog: true,
-      mention: true
-    })
-  })
+  isLoading: true
 });
 
 const notificationToMap = notification => Immutable.Map({
@@ -48,7 +36,11 @@ const normalizeNotifications = (state, notifications, next) => {
     items = items.set(i, notificationToMap(n));
   });
 
-  return state.update('items', list => loaded ? list.unshift(...items) : list.push(...items)).set('next', next).set('loaded', true);
+  return state
+    .update('items', list => loaded ? list.unshift(...items) : list.push(...items))
+    .set('next', next)
+    .set('loaded', true)
+    .set('isLoading', false);
 };
 
 const appendNormalizedNotifications = (state, notifications, next) => {
@@ -58,7 +50,10 @@ const appendNormalizedNotifications = (state, notifications, next) => {
     items = items.set(i, notificationToMap(n));
   });
 
-  return state.update('items', list => list.push(...items)).set('next', next);
+  return state
+    .update('items', list => list.push(...items))
+    .set('next', next)
+    .set('isLoading', false);
 };
 
 const filterNotifications = (state, relationship) => {
@@ -67,17 +62,20 @@ const filterNotifications = (state, relationship) => {
 
 export default function notifications(state = initialState, action) {
   switch(action.type) {
-    case NOTIFICATIONS_UPDATE:
-      return normalizeNotification(state, action.notification);
-    case NOTIFICATIONS_REFRESH_SUCCESS:
-      return normalizeNotifications(state, action.notifications, action.next);
-    case NOTIFICATIONS_EXPAND_SUCCESS:
-      return appendNormalizedNotifications(state, action.notifications, action.next);
-    case ACCOUNT_BLOCK_SUCCESS:
-      return filterNotifications(state, action.relationship);
-    case NOTIFICATIONS_SETTING_CHANGE:
-      return state.setIn(['settings', ...action.key], action.checked);
-    default:
-      return state;
+  case NOTIFICATIONS_REFRESH_REQUEST:
+  case NOTIFICATIONS_EXPAND_REQUEST:
+  case NOTIFICATIONS_REFRESH_FAIL:
+  case NOTIFICATIONS_EXPAND_FAIL:
+    return state.set('isLoading', true);
+  case NOTIFICATIONS_UPDATE:
+    return normalizeNotification(state, action.notification);
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+    return normalizeNotifications(state, action.notifications, action.next);
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+    return appendNormalizedNotifications(state, action.notifications, action.next);
+  case ACCOUNT_BLOCK_SUCCESS:
+    return filterNotifications(state, action.relationship);
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/search.jsx b/app/assets/javascripts/components/reducers/search.jsx
index 9c2041863..d835ef268 100644
--- a/app/assets/javascripts/components/reducers/search.jsx
+++ b/app/assets/javascripts/components/reducers/search.jsx
@@ -23,7 +23,7 @@ const normalizeSuggestions = (state, value, accounts) => {
     }
   ];
 
-  if (value.indexOf('@') === -1) {
+  if (value.indexOf('@') === -1 && value.indexOf(' ') === -1) {
     newSuggestions.push({
       title: 'hashtag',
       items: [
diff --git a/app/assets/javascripts/components/reducers/settings.jsx b/app/assets/javascripts/components/reducers/settings.jsx
new file mode 100644
index 000000000..8acc3faca
--- /dev/null
+++ b/app/assets/javascripts/components/reducers/settings.jsx
@@ -0,0 +1,46 @@
+import { SETTING_CHANGE } from '../actions/settings';
+import { STORE_HYDRATE } from '../actions/store';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  home: Immutable.Map({
+    shows: Immutable.Map({
+      reblog: true,
+      reply: true
+    })
+  }),
+
+  notifications: Immutable.Map({
+    alerts: Immutable.Map({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true
+    }),
+
+    shows: Immutable.Map({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true
+    }),
+
+    sounds: Immutable.Map({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true
+    })
+  })
+});
+
+export default function settings(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.mergeDeep(action.state.get('settings'));
+  case SETTING_CHANGE:
+    return state.setIn(action.key, action.value);
+  default:
+    return state;
+  }
+};
diff --git a/app/assets/javascripts/components/reducers/status_lists.jsx b/app/assets/javascripts/components/reducers/status_lists.jsx
new file mode 100644
index 000000000..b883b1c58
--- /dev/null
+++ b/app/assets/javascripts/components/reducers/status_lists.jsx
@@ -0,0 +1,39 @@
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  favourites: Immutable.Map({
+    next: null,
+    loaded: false,
+    items: Immutable.List()
+  })
+});
+
+const normalizeList = (state, listType, statuses, next) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('next', next);
+    map.set('loaded', true);
+    map.set('items', Immutable.List(statuses.map(item => item.id)));
+  }));
+};
+
+const appendToList = (state, listType, statuses, next) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('next', next);
+    map.set('items', map.get('items').push(...statuses.map(item => item.id)));
+  }));
+};
+
+export default function statusLists(state = initialState, action) {
+  switch(action.type) {
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+    return normalizeList(state, 'favourites', action.statuses, action.next);
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return appendToList(state, 'favourites', action.statuses, action.next);
+  default:
+    return state;
+  }
+};
diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx
index c740b6d64..084b6304c 100644
--- a/app/assets/javascripts/components/reducers/statuses.jsx
+++ b/app/assets/javascripts/components/reducers/statuses.jsx
@@ -28,6 +28,10 @@ import {
   NOTIFICATIONS_REFRESH_SUCCESS,
   NOTIFICATIONS_EXPAND_SUCCESS
 } from '../actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
 import Immutable from 'immutable';
 
 const normalizeStatus = (state, status) => {
@@ -77,36 +81,38 @@ const initialState = Immutable.Map();
 
 export default function statuses(state = initialState, action) {
   switch(action.type) {
-    case TIMELINE_UPDATE:
-    case STATUS_FETCH_SUCCESS:
-    case NOTIFICATIONS_UPDATE:
-      return normalizeStatus(state, action.status);
-    case REBLOG_SUCCESS:
-    case UNREBLOG_SUCCESS:
-    case FAVOURITE_SUCCESS:
-    case UNFAVOURITE_SUCCESS:
-      return normalizeStatus(state, action.response);
-    case FAVOURITE_REQUEST:
-      return state.setIn([action.status.get('id'), 'favourited'], true);
-    case FAVOURITE_FAIL:
-      return state.setIn([action.status.get('id'), 'favourited'], false);
-    case REBLOG_REQUEST:
-      return state.setIn([action.status.get('id'), 'reblogged'], true);
-    case REBLOG_FAIL:
-      return state.setIn([action.status.get('id'), 'reblogged'], false);
-    case TIMELINE_REFRESH_SUCCESS:
-    case TIMELINE_EXPAND_SUCCESS:
-    case ACCOUNT_TIMELINE_FETCH_SUCCESS:
-    case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
-    case CONTEXT_FETCH_SUCCESS:
-    case NOTIFICATIONS_REFRESH_SUCCESS:
-    case NOTIFICATIONS_EXPAND_SUCCESS:
-      return normalizeStatuses(state, action.statuses);
-    case TIMELINE_DELETE:
-      return deleteStatus(state, action.id, action.references);
-    case ACCOUNT_BLOCK_SUCCESS:
-      return filterStatuses(state, action.relationship);
-    default:
-      return state;
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+    return normalizeStatus(state, action.status);
+  case REBLOG_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+    return normalizeStatus(state, action.response);
+  case FAVOURITE_REQUEST:
+    return state.setIn([action.status.get('id'), 'favourited'], true);
+  case FAVOURITE_FAIL:
+    return state.setIn([action.status.get('id'), 'favourited'], false);
+  case REBLOG_REQUEST:
+    return state.setIn([action.status.get('id'), 'reblogged'], true);
+  case REBLOG_FAIL:
+    return state.setIn([action.status.get('id'), 'reblogged'], false);
+  case TIMELINE_REFRESH_SUCCESS:
+  case TIMELINE_EXPAND_SUCCESS:
+  case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+  case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return normalizeStatuses(state, action.statuses);
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.references);
+  case ACCOUNT_BLOCK_SUCCESS:
+    return filterStatuses(state, action.relationship);
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx
index b73c83e0f..6f2d26dcb 100644
--- a/app/assets/javascripts/components/reducers/timelines.jsx
+++ b/app/assets/javascripts/components/reducers/timelines.jsx
@@ -1,9 +1,12 @@
 import {
   TIMELINE_REFRESH_REQUEST,
   TIMELINE_REFRESH_SUCCESS,
+  TIMELINE_REFRESH_FAIL,
   TIMELINE_UPDATE,
   TIMELINE_DELETE,
   TIMELINE_EXPAND_SUCCESS,
+  TIMELINE_EXPAND_REQUEST,
+  TIMELINE_EXPAND_FAIL,
   TIMELINE_SCROLL_TOP
 } from '../actions/timelines';
 import {
@@ -13,37 +16,43 @@ import {
   UNFAVOURITE_SUCCESS
 } from '../actions/interactions';
 import {
-  ACCOUNT_FETCH_SUCCESS,
+  ACCOUNT_TIMELINE_FETCH_REQUEST,
   ACCOUNT_TIMELINE_FETCH_SUCCESS,
+  ACCOUNT_TIMELINE_FETCH_FAIL,
+  ACCOUNT_TIMELINE_EXPAND_REQUEST,
   ACCOUNT_TIMELINE_EXPAND_SUCCESS,
+  ACCOUNT_TIMELINE_EXPAND_FAIL,
   ACCOUNT_BLOCK_SUCCESS
 } from '../actions/accounts';
 import {
-  STATUS_FETCH_SUCCESS,
   CONTEXT_FETCH_SUCCESS
 } from '../actions/statuses';
 import Immutable from 'immutable';
 
 const initialState = Immutable.Map({
   home: Immutable.Map({
+    isLoading: false,
     loaded: false,
     top: true,
     items: Immutable.List()
   }),
 
   mentions: Immutable.Map({
+    isLoading: false,
     loaded: false,
     top: true,
     items: Immutable.List()
   }),
 
   public: Immutable.Map({
+    isLoading: false,
     loaded: false,
     top: true,
     items: Immutable.List()
   }),
 
   tag: Immutable.Map({
+    isLoading: false,
     id: null,
     loaded: false,
     top: true,
@@ -82,6 +91,7 @@ const normalizeTimeline = (state, timeline, statuses, replace = false) => {
   });
 
   state = state.setIn([timeline, 'loaded'], true);
+  state = state.setIn([timeline, 'isLoading'], false);
 
   return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? list.unshift(...ids) : ids));
 };
@@ -94,6 +104,8 @@ const appendNormalizedTimeline = (state, timeline, statuses) => {
     moreIds = moreIds.set(i, status.get('id'));
   });
 
+  state = state.setIn([timeline, 'isLoading'], false);
+
   return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds));
 };
 
@@ -105,7 +117,10 @@ const normalizeAccountTimeline = (state, accountId, statuses, replace = false) =
     ids   = ids.set(i, status.get('id'));
   });
 
-  return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => (replace ? ids : list.unshift(...ids)));
+  return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map
+    .set('isLoading', false)
+    .set('loaded', true)
+    .update('items', Immutable.List(), list => (replace ? ids : list.unshift(...ids))));
 };
 
 const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
@@ -116,7 +131,9 @@ const appendNormalizedAccountTimeline = (state, accountId, statuses) => {
     moreIds = moreIds.set(i, status.get('id'));
   });
 
-  return state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.push(...moreIds));
+  return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map
+    .set('isLoading', false)
+    .update('items', list => list.push(...moreIds)));
 };
 
 const updateTimeline = (state, timeline, status, references) => {
@@ -145,14 +162,19 @@ const updateTimeline = (state, timeline, status, references) => {
   return state;
 };
 
-const deleteStatus = (state, id, accountId, references) => {
+const deleteStatus = (state, id, accountId, references, reblogOf) => {
+  if (reblogOf) {
+    // If we are deleting a reblog, just replace reblog with its original
+    return state.updateIn(['home', 'items'], list => list.map(item => item === id ? reblogOf : item));
+  }
+
   // Remove references from timelines
   ['home', 'mentions', 'public', 'tag'].forEach(function (timeline) {
     state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
   });
 
   // Remove references from account timelines
-  state = state.updateIn(['accounts_timelines', accountId], Immutable.List([]), list => list.filterNot(item => item === id));
+  state = state.updateIn(['accounts_timelines', accountId, 'items'], Immutable.List([]), list => list.filterNot(item => item === id));
 
   // Remove references from context
   state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => {
@@ -202,8 +224,11 @@ const resetTimeline = (state, timeline, id) => {
   if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) {
     state = state.update(timeline, map => map
         .set('id', id)
+        .set('isLoading', true)
         .set('loaded', false)
         .update('items', list => list.clear()));
+  } else {
+    state = state.setIn([timeline, 'isLoading'], true);
   }
 
   return state;
@@ -211,27 +236,37 @@ const resetTimeline = (state, timeline, id) => {
 
 export default function timelines(state = initialState, action) {
   switch(action.type) {
-    case TIMELINE_REFRESH_REQUEST:
-      return resetTimeline(state, action.timeline, action.id);
-    case TIMELINE_REFRESH_SUCCESS:
-      return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
-    case TIMELINE_EXPAND_SUCCESS:
-      return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
-    case TIMELINE_UPDATE:
-      return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
-    case TIMELINE_DELETE:
-      return deleteStatus(state, action.id, action.accountId, action.references);
-    case CONTEXT_FETCH_SUCCESS:
-      return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
-    case ACCOUNT_TIMELINE_FETCH_SUCCESS:
-      return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
-    case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
-      return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
-    case ACCOUNT_BLOCK_SUCCESS:
-      return filterTimelines(state, action.relationship, action.statuses);
-    case TIMELINE_SCROLL_TOP:
-      return state.setIn([action.timeline, 'top'], action.top);
-    default:
-      return state;
+  case TIMELINE_REFRESH_REQUEST:
+  case TIMELINE_EXPAND_REQUEST:
+    return resetTimeline(state, action.timeline, action.id);
+  case TIMELINE_REFRESH_FAIL:
+  case TIMELINE_EXPAND_FAIL:
+    return state.setIn([action.timeline, 'isLoading'], false);
+  case TIMELINE_REFRESH_SUCCESS:
+    return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
+  case TIMELINE_EXPAND_SUCCESS:
+    return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
+  case TIMELINE_UPDATE:
+    return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
+  case CONTEXT_FETCH_SUCCESS:
+    return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
+  case ACCOUNT_TIMELINE_FETCH_REQUEST:
+  case ACCOUNT_TIMELINE_EXPAND_REQUEST:
+    return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', true));
+  case ACCOUNT_TIMELINE_FETCH_FAIL:
+  case ACCOUNT_TIMELINE_EXPAND_FAIL:
+    return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', false));
+  case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+    return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
+  case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+    return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
+  case ACCOUNT_BLOCK_SUCCESS:
+    return filterTimelines(state, action.relationship, action.statuses);
+  case TIMELINE_SCROLL_TOP:
+    return state.setIn([action.timeline, 'top'], action.top);
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/user_lists.jsx b/app/assets/javascripts/components/reducers/user_lists.jsx
index 36093663f..72922f509 100644
--- a/app/assets/javascripts/components/reducers/user_lists.jsx
+++ b/app/assets/javascripts/components/reducers/user_lists.jsx
@@ -36,24 +36,24 @@ const appendToList = (state, type, id, accounts, next) => {
 
 export default function userLists(state = initialState, action) {
   switch(action.type) {
-    case FOLLOWERS_FETCH_SUCCESS:
-      return normalizeList(state, 'followers', action.id, action.accounts, action.next);
-    case FOLLOWERS_EXPAND_SUCCESS:
-      return appendToList(state, 'followers', action.id, action.accounts, action.next);
-    case FOLLOWING_FETCH_SUCCESS:
-      return normalizeList(state, 'following', action.id, action.accounts, action.next);
-    case FOLLOWING_EXPAND_SUCCESS:
-      return appendToList(state, 'following', action.id, action.accounts, action.next);
-    case REBLOGS_FETCH_SUCCESS:
-      return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
-    case FAVOURITES_FETCH_SUCCESS:
-      return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
-    case FOLLOW_REQUESTS_FETCH_SUCCESS:
-      return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
-    case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
-    case FOLLOW_REQUEST_REJECT_SUCCESS:
-      return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
-    default:
-      return state;
+  case FOLLOWERS_FETCH_SUCCESS:
+    return normalizeList(state, 'followers', action.id, action.accounts, action.next);
+  case FOLLOWERS_EXPAND_SUCCESS:
+    return appendToList(state, 'followers', action.id, action.accounts, action.next);
+  case FOLLOWING_FETCH_SUCCESS:
+    return normalizeList(state, 'following', action.id, action.accounts, action.next);
+  case FOLLOWING_EXPAND_SUCCESS:
+    return appendToList(state, 'following', action.id, action.accounts, action.next);
+  case REBLOGS_FETCH_SUCCESS:
+    return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
+  case FAVOURITES_FETCH_SUCCESS:
+    return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+    return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+  case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
+  case FOLLOW_REQUEST_REJECT_SUCCESS:
+    return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/store/configureStore.jsx b/app/assets/javascripts/components/store/configureStore.jsx
index 3d03d4c19..ad0427b52 100644
--- a/app/assets/javascripts/components/store/configureStore.jsx
+++ b/app/assets/javascripts/components/store/configureStore.jsx
@@ -1,11 +1,23 @@
 import { createStore, applyMiddleware, compose } from 'redux';
-import thunk                                     from 'redux-thunk';
-import appReducer                                from '../reducers';
-import { loadingBarMiddleware }                  from 'react-redux-loading-bar';
-import errorsMiddleware                          from '../middleware/errors';
+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 Immutable from 'immutable';
 
-export default function configureStore(initialState) {
-  return createStore(appReducer, initialState, compose(applyMiddleware(thunk, loadingBarMiddleware({
-    promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'],
-  }), errorsMiddleware()), window.devToolsExtension ? window.devToolsExtension() : f => f));
+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)
+  ), window.devToolsExtension ? window.devToolsExtension() : f => f));
 };
diff --git a/app/assets/javascripts/extras.jsx b/app/assets/javascripts/extras.jsx
index b9f8e6842..5738863dd 100644
--- a/app/assets/javascripts/extras.jsx
+++ b/app/assets/javascripts/extras.jsx
@@ -1,7 +1,7 @@
 import emojify from './components/emoji'
 
 $(() => {
-  $.each($('.entry .content, .entry .status__content, .status__display-name, .display-name, .name, .account__header__content'), (_, content) => {
+  $.each($('.emojify'), (_, content) => {
     const $content = $(content);
     $content.html(emojify($content.html()));
   });
@@ -19,8 +19,6 @@ $(() => {
   });
 
   $('.webapp-btn').on('click', e => {
-    console.log(e);
-
     if (e.button === 0) {
       e.preventDefault();
       window.location.href = $(e.target).attr('href');
diff --git a/app/assets/stylesheets/about.scss b/app/assets/stylesheets/about.scss
index 3681672d8..b7d903ddf 100644
--- a/app/assets/stylesheets/about.scss
+++ b/app/assets/stylesheets/about.scss
@@ -1,20 +1,21 @@
-@import url(https://fonts.googleapis.com/css?family=Montserrat);
-@import url(https://fonts.googleapis.com/css?family=Judson);
-
 .about-body {
   .wrapper {
     max-width: 600px;
     margin: 0 auto;
-    color: #9baec8;
+    color: $color3;
     padding-top: 50px;
     padding-bottom: 50px;
+
+    &.thicc {
+      max-width: 700px;
+    }
   }
 
   h1 {
-    font: 46px/52px 'Roboto', sans-serif;
+    font: 46px/52px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
     font-weight: 600;
     margin-bottom: 20px;
-    color: #2b90d9;
+    color: $color4;
     padding: 20px 0;
 
     img {
@@ -26,17 +27,21 @@
   }
 
   h2 {
-    font: 24px/28px 'Judson', sans-serif;
-    font-weight: 300;
+    font-family: 'Montserrat', sans-serif;
+    font-size: 24px;
+    line-height: 28px;
+    font-weight: 400;
     margin-bottom: 20px;
-    color: #fff;
+    color: $color5;
   }
 
   h3 {
-    font: 20px/28px 'Judson', sans-serif;
-    font-weight: 300;
+    font-family: 'Montserrat', sans-serif;
+    font-size: 20px;
+    line-height: 28px;
+    font-weight: 400;
     margin-bottom: 20px;
-    color: #d9e1e8;
+    color: $color2;
   }
 
   ul, ol {
@@ -57,12 +62,12 @@
   }
 
   p, li {
-    font: 20px/28px 'Judson', sans-serif;
-    font-weight: 300;
+    font: 16px/28px 'Montserrat', sans-serif;
+    font-weight: 400;
     margin-bottom: 26px;
 
     a {
-      color: #2b90d9;
+      color: $color4;
       text-decoration: underline;
     }
   }
@@ -70,14 +75,15 @@
   em {
     display: inline-block;
     padding: 7px 7px 5px 7px;
-    background: #9baec8;
-    color: #282c37;
+    margin: 0 2px;
+    background: $color3;
+    color: $color1;
     font: 16px/16px 'Montserrat', sans-serif;
     font-weight: 300;
   }
 
   .screenshot {
-    box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
+    box-shadow: 0 0 15px rgba($color8, 0.4);
     margin-bottom: 26px;
 
     img {
@@ -96,7 +102,7 @@
       line-height: 36px;
 
       a {
-        color: #9baec8;
+        color: $color3;
         text-decoration: underline;
       }
     }
@@ -108,3 +114,162 @@
     }
   }
 }
+
+.information-board {
+  margin: 20px 0;
+  display: flex;
+  justify-content: space-between;
+  border-top: 1px solid lighten($color1, 10%);
+  border-bottom: 1px solid lighten($color1, 10%);
+  padding-right: 14px;
+
+  .section {
+    flex: 1 0 0;
+    padding: 14px;
+    text-align: right;
+    font: 16px/28px 'Montserrat', sans-serif;
+
+    span, strong {
+      display: block;
+    }
+
+    span {
+      font-size: 16px;
+
+      &:last-child {
+        color: $color2;
+        font-size: 14px;
+      }
+    }
+
+    strong {
+      font-weight: 500;
+      font-size: 32px;
+      line-height: 48px;
+      color: $color5;
+    }
+  }
+}
+
+.owner {
+  text-align: center;
+
+  .avatar {
+    width: 80px;
+    height: 80px;
+    margin: 0 auto;
+    margin-bottom: 15px;
+
+    img {
+      display: block;
+      width: 80px;
+      height: 80px;
+      border-radius: 48px;
+    }
+  }
+
+  .name {
+    font-size: 14px;
+
+    a {
+      display: block;
+      color: $color5;
+      text-decoration: none;
+
+      &:hover {
+        .display_name {
+          text-decoration: underline;
+        }
+      }
+    }
+
+    .username {
+      display: block;
+      color: $color3;
+    }
+  }
+}
+
+.contact-email {
+  text-align: center;
+  margin: 40px 0;
+
+  strong {
+    display: block;
+    color: $color5;
+  }
+}
+
+.sidebar-layout {
+  display: flex;
+
+  .main {
+    flex: 1 1 auto;
+    padding: 14px 0;
+
+    .panel {
+      padding-right: 14px;
+    }
+  }
+
+  .sidebar {
+    border-left: 1px solid lighten($color1, 10%);
+    width: 180px;
+    flex: 0 0 auto;
+  }
+
+  .panel {
+    .panel-header {
+      background: lighten($color1, 10%);
+      padding: 7px 14px;
+      text-transform: uppercase;
+      font-size: 12px;
+      font-weight: 500;
+    }
+
+    .panel-body {
+      padding: 14px;
+    }
+
+    .panel-list {
+      ul {
+        list-style: none;
+        margin: 0;
+
+        li {
+          margin: 0;
+          font-family: inherit;
+          font-size: 13px;
+          line-height: 18px;
+
+          a {
+            display: block;
+            padding: 7px 14px;
+            color: rgba($color5, 0.7);
+            text-decoration: none;
+            transition: all 200ms linear;
+
+            i.fa {
+              margin-right: 5px;
+            }
+
+            &:hover {
+              color: $color5;
+              background-color: darken($color1, 5%);
+              transition: all 100ms linear;
+            }
+
+            &.selected {
+              color: $color5;
+              background-color: $color4;
+
+              &:hover {
+                background-color: lighten($color4, 5%);
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/app/assets/stylesheets/accounts.scss b/app/assets/stylesheets/accounts.scss
index 748bb8224..7c48c91f3 100644
--- a/app/assets/stylesheets/accounts.scss
+++ b/app/assets/stylesheets/accounts.scss
@@ -1,10 +1,10 @@
 .card {
-  background: #282c37;
+  background: $color1;
   background-size: cover;
   padding: 60px 0;
   padding-bottom: 0;
   border-radius: 4px 4px 0 0;
-  box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
+  box-shadow: 0 0 15px rgba($color8, 0.2);
   overflow: hidden;
   position: relative;
 
@@ -14,7 +14,7 @@
   }
 
   &:after {
-    background: rgba(0, 0, 0, 0.5);
+    background: rgba($color8, 0.5);
     display: block;
     content: "";
     position: absolute;
@@ -29,7 +29,7 @@
     display: block;
     font-size: 20px;
     line-height: 18px * 1.5;
-    color: #fff;
+    color: $color5;
     font-weight: 500;
     text-align: center;
     position: relative;
@@ -38,7 +38,7 @@
     small {
       display: block;
       font-size: 14px;
-      color: #2b90d9;
+      color: $color4;
       font-weight: 400;
     }
   }
@@ -81,10 +81,10 @@
 
   .counter {
     width: 80px;
-    color: #9baec8;
+    color: $color3;
     padding: 0 10px;
     margin-bottom: 10px;
-    border-right: 1px solid #9baec8;
+    border-right: 1px solid $color3;
     cursor: default;
     position: relative;
 
@@ -99,14 +99,14 @@
       bottom: -10px;
       left: 0;
       width: 100%;
-      border-bottom: 4px solid #9baec8;
+      border-bottom: 4px solid $color3;
       opacity: 0.5;
       transition: all 0.8s ease;
     }
 
     &.active {
       &:after {
-        border-bottom: 4px solid #2b90d9;
+        border-bottom: 4px solid $color4;
         opacity: 1;
       }
     }
@@ -133,7 +133,7 @@
     .counter-number {
       font-weight: 500;
       font-size: 18px;
-      color: #fff;
+      color: $color5;
     }
   }
 
@@ -142,7 +142,7 @@
     font-size: 14px;
     line-height: 18px;
     padding: 5px 10px;
-    color: #d9e1e8;
+    color: $color2;
     order: 1;
   }
 
@@ -173,7 +173,7 @@
 
   a, .current, .next_page, .previous_page, .gap {
     font-size: 14px;
-    color: #fff;
+    color: $color5;
     font-weight: 500;
     display: inline-block;
     padding: 6px 10px;
@@ -181,9 +181,9 @@
   }
 
   .current {
-    background: #fff;
+    background: $color5;
     border-radius: 100px;
-    color: #282c37;
+    color: $color1;
     cursor: default;
   }
 
@@ -193,7 +193,7 @@
 
   .previous_page, .next_page {
     text-transform: uppercase;
-    color: #d9e1e8;
+    color: $color2;
   }
 
   .previous_page {
@@ -218,7 +218,7 @@
 
   .disabled {
     cursor: default;
-    color: lighten(#282c37, 10%);
+    color: lighten($color1, 10%);
   }
 
   @media screen and (max-width: 360px) {
@@ -236,8 +236,8 @@
 
 .accounts-grid {
   clear: both;
-  box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
-  background: #fff;
+  box-shadow: 0 0 15px rgba($color8, 0.2);
+  background: $color5;
   border-radius: 0 0 4px 4px;
   padding: 20px 10px;
   padding-bottom: 10px;
@@ -252,9 +252,9 @@
     box-sizing: border-box;
     width: 335px;
     float: left;
-    border: 1px solid #d9e1e8;
+    border: 1px solid $color2;
     border-radius: 4px;
-    color: #282c37;
+    color: $color1;
     height: 160px;
     margin-bottom: 10px;
 
@@ -265,7 +265,7 @@
     .account-grid-card__header {
       overflow: hidden;
       padding: 10px;
-      border-bottom: 1px solid #d9e1e8;
+      border-bottom: 1px solid $color2;
     }
 
     .avatar {
@@ -287,7 +287,7 @@
 
       a {
         display: block;
-        color: #282c37;
+        color: $color1;
         text-decoration: none;
 
         &:hover {
@@ -304,19 +304,19 @@
     }
 
     .username {
-      color: #2b90d9;
+      color: $color4;
     }
 
     .note {
       padding: 10px;
       padding-top: 15px;
-      color: #9baec8;
+      color: $color3;
     }
   }
 }
 
 .nothing-here {
-  color: #9baec8;
+  color: $color3;
   font-size: 14px;
   font-weight: 500;
   text-align: center;
@@ -327,10 +327,10 @@
 
 .account-card {
   padding: 14px 10px;
-  background: #fff;
+  background: $color5;
   border-radius: 4px;
   text-align: left;
-  box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
+  box-shadow: 0 0 15px rgba($color8, 0.2);
 
   .detailed-status__display-name {
     display: block;
@@ -363,12 +363,12 @@
 
       strong {
         font-weight: 500;
-        color: #282c37;
+        color: $color1;
       }
 
       span {
         font-size: 14px;
-        color: #9baec8;
+        color: $color3;
       }
     }
 
@@ -383,6 +383,6 @@
 
   .account__header__content {
     font-size: 14px;
-    color: #282c37;
+    color: $color1;
   }
 }
diff --git a/app/assets/stylesheets/admin.scss b/app/assets/stylesheets/admin.scss
index 6e4234d13..8d01ac4c4 100644
--- a/app/assets/stylesheets/admin.scss
+++ b/app/assets/stylesheets/admin.scss
@@ -2,7 +2,7 @@
   width: 100%;
   height: 100%;
   position: fixed;
-  background: #1a1c23;
+  background: darken($color1, 2%);
   overflow-y: scroll;
 
   .sidebar {
@@ -10,7 +10,7 @@
     position: fixed;
     left: 0;
     height: 100%;
-    background: #282c37;
+    background: $color1;
 
     .logo {
       display: block;
@@ -25,7 +25,7 @@
       a {
         display: block;
         padding: 15px 25px;
-        color: rgba(255, 255, 255, 0.7);
+        color: rgba($color5, 0.7);
         text-decoration: none;
         transition: all 200ms linear;
 
@@ -34,17 +34,17 @@
         }
 
         &:hover {
-          color: #fff;
-          background-color: darken(#282c37, 5%);
+          color: $color5;
+          background-color: darken($color1, 5%);
           transition: all 100ms linear;
         }
 
         &.selected {
-          color: #fff;
-          background-color: #2b90d9;
+          color: $color5;
+          background-color: $color4;
 
           &:hover {
-            background-color: lighten(#2b90d9, 5%);
+            background-color: lighten($color4, 5%);
           }
         }
       }
@@ -84,21 +84,21 @@
 
     a {
       display: inline-block;
-      color: rgba(255, 255, 255, 0.7);
+      color: rgba($color5, 0.7);
       text-decoration: none;
       text-transform: uppercase;
       font-size: 12px;
       font-weight: 500;
-      border-bottom: 2px solid #282c37;
+      border-bottom: 2px solid $color1;
 
       &:hover {
-        color: #fff;
-        border-bottom: 2px solid lighten(#282c37, 5%);
+        color: $color5;
+        border-bottom: 2px solid lighten($color1, 5%);
       }
 
       &.selected {
-        color: #2b90d9;
-        border-bottom: 2px solid #2b90d9;
+        color: $color4;
+        border-bottom: 2px solid $color4;
       }
     }
   }
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index e4c550b81..649a0148b 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -1,6 +1,8 @@
+@import 'variables';
 @import url(https://fonts.googleapis.com/css?family=Roboto:400,500,400italic);
 @import url(https://fonts.googleapis.com/css?family=Roboto+Mono:400,500);
-@import "font-awesome";
+@import url(https://fonts.googleapis.com/css?family=Montserrat);
+@import 'font-awesome';
 
 /* http://meyerweb.com/eric/tools/css/reset/
    v2.0 | 20110126
@@ -63,31 +65,31 @@ table {
 }
 
 ::-webkit-scrollbar-thumb {
-  background: #42495b;
-  border: 0px none #ffffff;
+  background: lighten($color1, 4%);
+  border: 0px none $color5;
   border-radius: 50px;
 }
 
 ::-webkit-scrollbar-thumb:hover {
-  background: #525a70;
+  background: lighten($color1, 6%);
 }
 
 ::-webkit-scrollbar-thumb:active {
-  background: #42495b;
+  background: lighten($color1, 4%);
 }
 
 ::-webkit-scrollbar-track {
-  border: 0px none #ffffff;
+  border: 0px none $color5;
   border-radius: 0;
-  background: rgba(0, 0, 0, 0.1);
+  background: rgba($color8, 0.1);
 }
 
 ::-webkit-scrollbar-track:hover {
-  background: #282c37;
+  background: $color1;
 }
 
 ::-webkit-scrollbar-track:active {
-  background: #282c37;
+  background: $color1;
 }
 
 ::-webkit-scrollbar-corner {
@@ -96,13 +98,13 @@ table {
 
 body {
   font-family: 'Roboto', sans-serif;
-  background: #282c37 image-url('background-photo.jpeg');
+  background: $color1 image-url('background-photo.jpeg');
   background-size: cover;
   background-attachment: fixed;
   font-size: 13px;
   line-height: 18px;
   font-weight: 400;
-  color: #fff;
+  color: $color5;
   padding-bottom: 140px;
   text-rendering: optimizelegibility;
   font-feature-settings: "kern";
@@ -164,7 +166,7 @@ body {
   h1 {
     display: block;
     text-align: center;
-    color: #fff;
+    color: $color5;
     font-size: 48px;
     font-weight: 500;
 
@@ -215,12 +217,10 @@ body {
   text-align: center;
   margin-top: 30px;
   font-size: 12px;
-  color: darken(#d9e1e8, 25%);
+  color: darken($color2, 25%);
 
   .domain {
-    //font-size: 12px;
     font-weight: 500;
-    //font-family: 'Roboto Mono', monospace;
 
     a {
       color: inherit;
diff --git a/app/assets/stylesheets/boost.scss b/app/assets/stylesheets/boost.scss
new file mode 100644
index 000000000..a2e6421f8
--- /dev/null
+++ b/app/assets/stylesheets/boost.scss
@@ -0,0 +1,7 @@
+@function url-friendly-colour($colour) {
+  @return '%23' + str-slice('#{$colour}', 2, -1)
+}
+
+button i.fa-retweet {
+  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{url-friendly-colour(lighten($color1, 26%))}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{url-friendly-colour($color4)}' stroke-width='0'/></svg>");
+}
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index acfa85c6b..6014da5b6 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -1,12 +1,12 @@
 .button {
-  background-color: #2b90d9;
-  font-family: 'Roboto';
+  background-color: darken($color4, 3%);
+  font-family: inherit;
   display: inline-block;
   position: relative;
   box-sizing: border-box;
   text-align: center;
   border: 10px none;
-  color: #fff;
+  color: $color5;
   font-size: 14px;
   font-weight: 500;
   letter-spacing: 0;
@@ -19,56 +19,69 @@
   text-decoration: none;
 
   &:hover {
-    background-color: #489fde;
+    background-color: lighten($color4, 7%);
   }
 
   &:disabled {
-    background-color: #9baec8;
+    background-color: $color3;
     cursor: default;
   }
 
   &.button-secondary {
-    background-color: #282c37;
+    background-color: $color1;
 
     &:hover {
-      background-color: #282c37;
+      background-color: $color1;
     }
 
     &:disabled {
-      background-color: #9baec8;
+      background-color: $color3;
     }
   }
 }
 
 .icon-button {
-  color: #616b86;
+  color: lighten($color1, 26%);
   border: none;
   background: transparent;
   cursor: pointer;
 
   &:hover {
-    color: #717b98;
+    color: lighten($color1, 33%);
   }
 
   &.disabled {
-    color: #454b5e;
+    color: lighten($color1, 13%);
     cursor: default;
   }
 
   &.active {
-    color: #2b90d9;
+    color: $color4;
+  }
+}
+
+.invisible {
+  font-size: 0;
+  line-height: 0;
+  display: inline-block;
+  width: 0;
+}
+
+.ellipsis {
+  &:after {
+    content: "…";
   }
 }
 
 .lightbox .icon-button {
-  color: #282c37;
+  color: $color1;
 }
 
 .compose-form__textarea, .follow-form__input {
-  background: #fff;
+  background: $color5;
 
   &:disabled {
-    background: #d9e1e8;
+    background: $color2;
   }
 }
 
@@ -107,7 +120,7 @@
   }
 
   a {
-    color: #d9e1e8;
+    color: $color2;
     text-decoration: none;
 
     &:hover {
@@ -139,11 +152,11 @@
 }
 
 .reply-indicator__content {
-  color: #282c37;
+  color: $color1;
   font-size: 14px;
 
   a {
-    color: #535b72;
+    color: lighten($color1, 20%);
   }
 }
 
@@ -183,13 +196,13 @@
   }
 }
 
-.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .account__display-name {
+.status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .detailed-status__application, .account__display-name {
   text-decoration: none;
 }
 
 .status__display-name, .account__display-name {
   strong {
-    color: #fff;
+    color: $color5;
   }
 
   &.muted {
@@ -214,7 +227,7 @@
 }
 
 .detailed-status__display-name {
-  color: #d9e1e8;
+  color: $color2;
   line-height: 24px;
 
   strong, span {
@@ -223,17 +236,17 @@
 
   strong {
     font-size: 16px;
-    color: #fff;
+    color: $color5;
   }
 }
 
 .muted {
   .status__content p, .status__content a {
-    color: #616b86;
+    color: lighten($color1, 26%);
   }
 
   .status__display-name strong {
-    color: #616b86;
+    color: lighten($color1, 26%);
   }
 
   .status__avatar {
@@ -246,7 +259,7 @@
   text-decoration: none;
 
   &:hover {
-    color: #fff;
+    color: $color5;
     text-decoration: underline;
   }
 }
@@ -282,17 +295,17 @@
     height: 0;
     border-style: solid;
     border-width: 0 4.5px 7.8px 4.5px;
-    border-color: transparent transparent #d9e1e8 transparent;
+    border-color: transparent transparent $color2 transparent;
     top: -7px;
     left: 8px;
   }
 
   ul {
     list-style: none;
-    background: #d9e1e8;
+    background: $color2;
     padding: 4px 0;
     border-radius: 4px;
-    box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
+    box-shadow: 0 0 15px rgba($color8, 0.4);
     min-width: 100px;
   }
 
@@ -302,12 +315,12 @@
     padding: 6px 16px;
     width: 100px;
     text-decoration: none;
-    background: #d9e1e8;
-    color: #282c37;
+    background: $color2;
+    color: $color1;
 
     &:hover {
-      background: #2b90d9;
-      color: #d9e1e8;
+      background: $color4;
+      color: $color2;
     }
   }
 }
@@ -315,7 +328,7 @@
 .static-content {
   padding: 10px;
   padding-top: 20px;
-  color: #616b86;
+  color: lighten($color1, 26%);
 
   h1 {
     font-size: 16px;
@@ -331,11 +344,15 @@
 }
 
 .columns-area {
-  margin: 10px;
-  margin-left: 0;
   flex-direction: row;
 }
 
+@media screen and (min-width: 360px) {
+  .columns-area {
+    margin: 10px;
+  }
+}
+
 .column {
   width: 330px;
   position: relative;
@@ -345,12 +362,43 @@
   width: 280px;
 }
 
+.drawer__inner {
+  background: linear-gradient(rgba(lighten($color1, 13%), 1), rgba(lighten($color1, 13%), 0.65));
+}
+
+.drawer__header {
+  flex: 0 0 auto;
+  font-size: 16px;
+  background: lighten($color1, 8%);
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: row;
+
+  a {
+    transition: all 100ms ease-in;
+
+    &:hover {
+      background: lighten($color1, 3%);
+      transition: all 200ms ease-out;
+    }
+  }
+}
+
 .column, .drawer {
-  margin-left: 10px;
+  margin-left: 5px;
+  margin-right: 5px;
   flex: 0 0 auto;
   overflow: hidden;
 }
 
+.column:first-child, .drawer:first-child {
+  margin-left: 0;
+}
+
+.column:last-child, .drawer:last-child {
+  margin-right: 0;
+}
+
 @media screen and (max-width: 1024px) {
   .column, .drawer {
     width: 100%;
@@ -359,7 +407,6 @@
   }
 
   .columns-area {
-    margin: 10px;
     flex-direction: column;
   }
 }
@@ -368,6 +415,13 @@
   display: flex;
 }
 
+@media screen and (min-width: 360px) {
+  .tabs-bar {
+    margin: 10px;
+    margin-bottom: 0;
+  }
+}
+
 @media screen and (min-width: 1025px) {
   .tabs-bar {
     display: none;
@@ -383,22 +437,22 @@
   top: 100%;
   width: 100%;
   z-index: 99;
-  box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
+  box-shadow: 0 0 15px rgba($color8, 0.4);
 }
 
 .react-autosuggest__section-title {
-  background: #9baec8;
+  background: $color3;
   padding: 4px 10px;
   font-weight: 500;
   cursor: default;
-  color: #282c37;
+  color: $color1;
   text-transform: uppercase;
   font-size: 11px;
 }
 
 .react-autosuggest__suggestions-list {
-  background: #d9e1e8;
-  color: #282c37;
+  background: $color2;
+  color: $color1;
   font-size: 14px;
 }
 
@@ -408,8 +462,8 @@
 }
 
 .react-autosuggest__suggestion--focused {
-  background: #2b90d9;
-  color: #fff;
+  background: $color4;
+  color: $color5;
 }
 
 .scrollable {
@@ -417,6 +471,10 @@
   overflow-x: hidden;
   flex: 1 1 auto;
   -webkit-overflow-scrolling: touch;
+
+  &.optionally-scrollable {
+    overflow-y: auto;
+  }
 }
 
 .column-back-button {
@@ -433,7 +491,7 @@
   border: 0;
   padding: 0;
   user-select: none;
-  -webkit-tap-highlight-color: rgba(0,0,0,0);
+  -webkit-tap-highlight-color: rgba($color8, 0);
   -webkit-tap-highlight-color: transparent;
 }
 
@@ -459,20 +517,20 @@
   height: 24px;
   padding: 0;
   border-radius: 30px;
-  background-color: #282c37;
+  background-color: $color1;
   transition: all 0.2s ease;
 }
 
 .react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
-  background-color: darken(#282c37, 10%);
+  background-color: darken($color1, 10%);
 }
 
 .react-toggle--checked .react-toggle-track {
-  background-color: #2b90d9;
+  background-color: $color4;
 }
 
 .react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
-  background-color: lighten(#2b90d9, 10%);
+  background-color: lighten($color4, 10%);
 }
 
 .react-toggle-track-check {
@@ -519,59 +577,62 @@
   left: 1px;
   width: 22px;
   height: 22px;
-  border: 1px solid #282c37;
+  border: 1px solid $color1;
   border-radius: 50%;
-  background-color: #FAFAFA;
+  background-color: darken($color5, 2%);
   box-sizing: border-box;
   transition: all 0.25s ease;
 }
 
 .react-toggle--checked .react-toggle-thumb {
   left: 27px;
-  border-color: #2b90d9;
+  border-color: $color4;
 }
 
 .column-link {
-  background: #373b4a;
+  background: lighten($color1, 6%);
 
   &:hover {
-    background: lighten(#373b4a, 5%);
+    background: lighten($color1, 11%);
   }
 }
 
-.autosuggest-textarea {
+.autosuggest-textarea, .spoiler-input {
   position: relative;
 }
 
-.autosuggest-textarea__textarea {
+.autosuggest-textarea__textarea, .spoiler-input__input {
   display: block;
   box-sizing: border-box;
   width: 100%;
-  height: 100px;
   resize: none;
-  color: #282c37;
+  margin: 0;
+  color: $color1;
   padding: 7px;
-  font-family: 'Roboto';
+  font-family: inherit;
   font-size: 14px;
-  margin: 0;
   resize: vertical;
 
   border: 3px dashed transparent;
   transition: border-color 0.3s ease;
 
   &.file-drop {
-    border-color: #aaa;
+    border-color: darken($color5, 33%);
   }
 }
 
+.autosuggest-textarea__textarea {
+  height: 100px;
+}
+
 .autosuggest-textarea__suggestions {
   position: absolute;
   top: 100%;
   width: 100%;
   z-index: 99;
-  box-shadow: 0 0 15px rgba(0, 0, 0, 0.4);
-  background: #d9e1e8;
-  color: #282c37;
+  box-shadow: 0 0 15px rgba($color8, 0.4);
+  background: $color2;
+  color: $color1;
   font-size: 14px;
 }
 
@@ -580,21 +641,69 @@
   cursor: pointer;
 
   &:hover {
-    background: darken(#d9e1e8, 10%);
+    background: darken($color2, 10%);
   }
 
   &.selected {
-    background: #2b90d9;
-    color: #fff;
+    background: $color4;
+    color: $color5;
   }
 }
 
-.getting-started__illustration {
-  width: 330px;
-  height: 235px;
-  background: image-url('mastodon-getting-started.png') no-repeat 0 0;
-  position: absolute;
-  pointer-events: none;
-  bottom: 0;
-  left: 0;
+.getting-started {
+  box-sizing: border-box;
+  overflow-y: auto;
+  padding-bottom: 235px;
+  background: image-url('mastodon-getting-started.png') no-repeat 0 100% local;
+  height: 100%;
+
+  p {
+    color: $color2;
+  }
+}
+
+.dropdown__content.dropdown__left {
+  transform: translateX(-108px);
+
+  &::before {
+    right: 8px !important;
+    left: initial !important;
+  }
+}
+
+.setting-text {
+  color: $color3;
+  background: transparent;
+  border: none;
+  border-bottom: 2px solid $color3;
+
+  &:focus, &:active {
+    color: $color5;
+    border-bottom-color: $color4;
+  }
+}
+
+@import 'boost';
+
+button i.fa-retweet {
+  height: 19px;
+  width: 22px;
+  background-position: 0 0;
+  transition: background-position 0.9s steps(10);
+  transition-duration: 0s;
+
+  &::before {
+    display: none !important;
+  }
+}
+
+button.active i.fa-retweet {
+  transition-duration: 0.9s;
+  background-position: 0 100%;
+}
+
+.status-card {
+  &:hover {
+    background: lighten($color1, 6%);
+  }
 }
diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss
index e6d2e85a2..365396511 100644
--- a/app/assets/stylesheets/forms.scss
+++ b/app/assets/stylesheets/forms.scss
@@ -16,7 +16,7 @@ code {
 
   .hint {
     display: block;
-    color: rgba(255, 255, 255, 0.8);
+    color: rgba($color5, 0.8);
     font-size: 12px;
   }
 
@@ -26,9 +26,9 @@ code {
     display: flex;
 
     label {
-      font-family: 'Roboto';
+      font-family: inherit;
       font-size: 16px;
-      color: #fff;
+      color: $color5;
       width: 100px;
       display: block;
       flex: 0 0 auto;
@@ -48,7 +48,7 @@ code {
     margin-bottom: 5px;
 
     label {
-      font-family: 'Roboto';
+      font-family: inherit;
       font-size: 14px;
       color: white;
       display: block;
@@ -75,42 +75,42 @@ code {
     background: transparent;
     box-sizing: border-box;
     border: 0;
-    border-bottom: 2px solid #9baec8;
+    border-bottom: 2px solid $color3;
     border-radius: 2px 2px 0 0;
     padding: 7px 4px;
     font-size: 16px;
-    color: #fff;
+    color: $color5;
     display: block;
     width: 100%;
     outline: 0;
-    font-family: 'Roboto';
+    font-family: inherit;
 
     &:invalid {
       box-shadow: none;
     }
 
     &:focus:invalid {
-      border-bottom-color: #df405a;
+      border-bottom-color: $color6;
     }
 
     &:required:valid {
-      border-bottom-color: #79bd9a;
+      border-bottom-color: $color7;
     }
 
     &:active, &:focus {
-      border-bottom-color: #2b90d9;
-      background: rgba(0, 0, 0, 0.1);
+      border-bottom-color: $color4;
+      background: rgba($color8, 0.1);
     }
   }
 
   .input.field_with_errors {
     input[type=text], input[type=email], input[type=password] {
-      border-bottom-color: #df405a;
+      border-bottom-color: $color6;
     }
 
     .error {
       font-weight: 500;
-      color: #df405a;
+      color: $color6;
     }
   }
 
@@ -123,8 +123,8 @@ code {
     width: 100%;
     border: 0;
     border-radius: 4px;
-    background: #2b90d9;
-    color: #fff;
+    background: $color4;
+    color: $color5;
     font-size: 18px;
     padding: 10px;
     text-transform: uppercase;
@@ -134,36 +134,36 @@ code {
     margin-bottom: 10px;
 
     &:hover {
-      background-color: lighten(#2b90d9, 5%);
+      background-color: lighten($color4, 5%);
     }
 
     &:active, &:focus {
       position: relative;
       top: 1px;
-      background-color: darken(#2b90d9, 5%);
+      background-color: darken($color4, 5%);
     }
 
     &.negative {
-      background: #df405a;
+      background: $color6;
 
       &:hover {
-        background-color: lighten(#df405a, 5%);
+        background-color: lighten($color6, 5%);
       }
 
       &:active, &:focus {
-        background-color: darken(#df405a, 5%);
+        background-color: darken($color6, 5%);
       }
     }
   }
 }
 
 .flash-message {
-  background: #282c37;
-  color: #9baec8;
+  background: $color1;
+  color: $color3;
   border-radius: 4px;
   padding: 15px 10px;
   margin-bottom: 30px;
-  box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
+  box-shadow: 0 0 5px rgba($color8, 0.2);
   text-align: center;
 
   strong {
@@ -188,7 +188,7 @@ code {
 .oauth-prompt, .follow-prompt {
   margin-bottom: 30px;
   text-align: center;
-  color: #9baec8;
+  color: $color3;
 
   h2 {
     font-size: 16px;
@@ -196,7 +196,7 @@ code {
   }
 
   strong {
-    color: #d9e1e8;
+    color: $color2;
     font-weight: 500;
   }
 }
diff --git a/app/assets/stylesheets/stream_entries.scss b/app/assets/stylesheets/stream_entries.scss
index 7624bbdc8..2d3cb1436 100644
--- a/app/assets/stylesheets/stream_entries.scss
+++ b/app/assets/stylesheets/stream_entries.scss
@@ -1,12 +1,12 @@
 .activity-stream {
   clear: both;
-  box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
+  box-shadow: 0 0 15px rgba($color8, 0.2);
 
   .entry {
-    background: lighten(#d9e1e8, 8%);
+    background: lighten($color2, 8%);
 
     &, .detailed-status.light {
-      border-bottom: 1px solid #d9e1e8;
+      border-bottom: 1px solid $color2;
     }
 
     &:last-child {
@@ -43,7 +43,7 @@
         font-size: 14px;
 
         .status__relative-time {
-          color: #9baec8;
+          color: $color3;
         }
       }
     }
@@ -52,7 +52,7 @@
       display: block;
       max-width: 100%;
       padding-right: 25px;
-      color: #282c37;
+      color: $color1;
     }
 
     .status__avatar {
@@ -82,20 +82,20 @@
 
       strong {
         font-weight: 500;
-        color: #282c37;
+        color: $color1;
       }
 
       span {
         font-size: 14px;
-        color: #9baec8;
+        color: $color3;
       }
     }
 
     .status__content {
-      color: #282c37;
+      color: $color1;
 
       a {
-        color: #2b90d9;
+        color: $color4;
       }
     }
 
@@ -111,7 +111,7 @@
 
   .detailed-status.light {
     padding: 14px;
-    background: #fff;
+    background: $color5;
     cursor: default;
 
     .detailed-status__display-name {
@@ -133,12 +133,12 @@
 
         strong {
           font-weight: 500;
-          color: #282c37;
+          color: $color1;
         }
 
         span {
           font-size: 14px;
-          color: #9baec8;
+          color: $color3;
         }
       }
     }
@@ -154,16 +154,16 @@
     }
 
     .status__content {
-      color: #282c37;
+      color: $color1;
 
       a {
-        color: #2b90d9;
+        color: $color4;
       }
     }
 
     .detailed-status__meta {
       margin-top: 15px;
-      color: #9baec8;
+      color: $color3;
       font-size: 14px;
       line-height: 18px;
 
@@ -248,12 +248,13 @@
       transform: translate(-50%, -50%);
       padding: 5px;
       border-radius: 100px;
-      color: rgba(255, 255, 255, 0.8);
+      color: rgba($color5, 0.8);
+      z-index: 1;
     }
   }
 
   .media-spoiler {
-    background: #9baec8;
+    background: $color3;
     width: 100%;
     height: 100%;
     cursor: pointer;
@@ -263,9 +264,10 @@
     flex-direction: column;
     text-align: center;
     transition: all 100ms linear;
+    z-index: 2;
 
     &:hover {
-      background: darken(#9baec8, 5%);
+      background: darken($color3, 5%);
     }
 
     span {
@@ -287,7 +289,7 @@
     padding-left: (48px + 14px*2);
     padding-bottom: 0;
     margin-bottom: -4px;
-    color: #9baec8;
+    color: $color3;
     font-size: 14px;
     position: relative;
 
@@ -297,7 +299,7 @@
     }
 
     .status__display-name.muted strong {
-      color: #9baec8;
+      color: $color3;
     }
   }
 }
diff --git a/app/assets/stylesheets/tables.scss b/app/assets/stylesheets/tables.scss
index a37870786..ad8050580 100644
--- a/app/assets/stylesheets/tables.scss
+++ b/app/assets/stylesheets/tables.scss
@@ -9,13 +9,13 @@
     padding: 8px;
     line-height: 18px;
     vertical-align: top;
-    border-top: 1px solid #282c37;
+    border-top: 1px solid $color1;
     text-align: left;
   }
 
   & > thead > tr > th {
     vertical-align: bottom;
-    border-bottom: 2px solid #282c37;
+    border-bottom: 2px solid $color1;
     border-top: 0;
     font-weight: 500;
   }
@@ -25,17 +25,21 @@
   }
 
   & > tbody > tr:nth-child(odd) > td, & > tbody > tr:nth-child(odd) > th {
-    background: lighten(#1a1c23, 2%);
+    background: $color1;
   }
 
   a {
-    color: #2b90d9;
+    color: $color4;
     text-decoration: underline;
 
     &:hover {
       text-decoration: none;
     }
   }
+
+  strong {
+    font-weight: 500;
+  }
 }
 
 samp {
@@ -47,11 +51,11 @@ a.table-action-link {
   display: inline-block;
   margin-right: 5px;
   padding: 0 10px;
-  color: rgba(255, 255, 255, 0.7);
+  color: rgba($color5, 0.7);
   font-weight: 500;
 
   &:hover {
-    color: #fff;
+    color: $color5;
   }
 
   i.fa {
diff --git a/app/assets/stylesheets/variables.scss b/app/assets/stylesheets/variables.scss
new file mode 100644
index 000000000..de4157af8
--- /dev/null
+++ b/app/assets/stylesheets/variables.scss
@@ -0,0 +1,8 @@
+$color1: #282c37; // darkest
+$color2: #d9e1e8; // lightest
+$color3: #9baec8; // lighter
+$color4: #2b90d9; // vibrant
+$color5: #fff; // white
+$color6: #df405a; // error red
+$color7: #79bd9a; // succ green
+$color8: #000; // black
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb
index 7df58444f..491036db2 100644
--- a/app/controllers/about_controller.rb
+++ b/app/controllers/about_controller.rb
@@ -4,11 +4,21 @@ class AboutController < ApplicationController
   before_action :set_body_classes
 
   def index
+    @description = Setting.site_description
   end
 
-  def terms
+  def more
+    @description          = Setting.site_description
+    @extended_description = Setting.site_extended_description
+    @contact_account      = Account.find_local(Setting.site_contact_username)
+    @contact_email        = Setting.site_contact_email
+    @user_count           = Rails.cache.fetch('user_count')            { User.count }
+    @status_count         = Rails.cache.fetch('local_status_count')    { Status.local.count }
+    @domain_count         = Rails.cache.fetch('distinct_domain_count') { Account.distinct.count(:domain) }
   end
 
+  def terms; end
+
   private
 
   def set_body_classes
diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb
new file mode 100644
index 000000000..af0be8823
--- /dev/null
+++ b/app/controllers/admin/settings_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Admin::SettingsController < ApplicationController
+  before_action :require_admin!
+
+  layout 'admin'
+
+  def index
+    @settings = Setting.all_as_records
+  end
+
+  def update
+    @setting = Setting.where(var: params[:id]).first_or_initialize(var: params[:id])
+
+    if @setting.value != params[:setting][:value]
+      @setting.value = params[:setting][:value]
+      @setting.save
+    end
+
+    respond_to do |format|
+      format.html { redirect_to admin_settings_path }
+      format.json { respond_with_bip(@setting) }
+    end
+  end
+end
diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb
index 2360061ff..379e910e6 100644
--- a/app/controllers/api/oembed_controller.rb
+++ b/app/controllers/api/oembed_controller.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-class Api::OembedController < ApiController
+class Api::OEmbedController < ApiController
   respond_to :json
 
   def show
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 05ff806c5..d97010c0e 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -16,13 +16,13 @@ class Api::V1::AccountsController < ApiController
   end
 
   def following
-    results   = Follow.where(account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
+    results   = Follow.where(account: @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 = following_api_v1_account_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
+    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?
 
     set_pagination_headers(next_path, prev_path)
@@ -31,13 +31,13 @@ class Api::V1::AccountsController < ApiController
   end
 
   def followers
-    results   = Follow.where(target_account: @account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
+    results   = Follow.where(target_account: @account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
     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)
 
-    next_path = followers_api_v1_account_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
+    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?
 
     set_pagination_headers(next_path, prev_path)
@@ -46,13 +46,13 @@ class Api::V1::AccountsController < ApiController
   end
 
   def statuses
-    @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id])
+    @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
     @statuses = cache_collection(@statuses, Status)
 
     set_maps(@statuses)
     set_counters_maps(@statuses)
 
-    next_path = statuses_api_v1_account_url(max_id: @statuses.last.id)    if @statuses.size == DEFAULT_STATUSES_LIMIT
+    next_path = statuses_api_v1_account_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
     prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -66,7 +66,12 @@ class Api::V1::AccountsController < ApiController
 
   def block
     BlockService.new.call(current_user.account, @account)
-    set_relationship
+
+    @following   = { @account.id => false }
+    @followed_by = { @account.id => false }
+    @blocking    = { @account.id => true }
+    @requested   = { @account.id => false }
+
     render action: :relationship
   end
 
@@ -93,10 +98,9 @@ class Api::V1::AccountsController < ApiController
   end
 
   def search
-    limit = params[:limit] ? [DEFAULT_ACCOUNTS_LIMIT, params[:limit].to_i].min : DEFAULT_ACCOUNTS_LIMIT
-    @accounts = SearchService.new.call(params[:q], limit, params[:resolve] == 'true')
+    @accounts = SearchService.new.call(params[:q], limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:resolve] == 'true')
 
-    set_account_counters_maps(@accounts)
+    set_account_counters_maps(@accounts) unless @accounts.nil?
 
     render action: :index
   end
diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb
index 1b33770f4..ca9dd0b7e 100644
--- a/app/controllers/api/v1/apps_controller.rb
+++ b/app/controllers/api/v1/apps_controller.rb
@@ -4,6 +4,6 @@ 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))
+    @app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes), website: params[:website])
   end
 end
diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb
index 8629242ab..b9816e052 100644
--- a/app/controllers/api/v1/blocks_controller.rb
+++ b/app/controllers/api/v1/blocks_controller.rb
@@ -7,13 +7,13 @@ class Api::V1::BlocksController < ApiController
   respond_to :json
 
   def index
-    results   = Block.where(account: current_account).paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
+    results   = Block.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_blocks_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
+    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?
 
     set_pagination_headers(next_path, prev_path)
diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb
index a71592acd..ef0a4854a 100644
--- a/app/controllers/api/v1/favourites_controller.rb
+++ b/app/controllers/api/v1/favourites_controller.rb
@@ -7,13 +7,13 @@ class Api::V1::FavouritesController < ApiController
   respond_to :json
 
   def index
-    results   = Favourite.where(account: current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id])
+    results   = Favourite.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
     @statuses = cache_collection(Status.where(id: results.map(&:status_id)), Status)
 
     set_maps(@statuses)
     set_counters_maps(@statuses)
 
-    next_path = api_v1_favourites_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
+    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?
 
     set_pagination_headers(next_path, prev_path)
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index c8f162cb0..877356a75 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -6,8 +6,10 @@ class Api::V1::NotificationsController < ApiController
 
   respond_to :json
 
+  DEFAULT_NOTIFICATIONS_LIMIT = 15
+
   def index
-    @notifications = Notification.where(account: current_account).browserable.paginate_by_max_id(20, params[:max_id], params[:since_id])
+    @notifications = Notification.where(account: current_account).browserable.paginate_by_max_id(limit_param(DEFAULT_NOTIFICATIONS_LIMIT), params[:max_id], params[:since_id])
     @notifications = cache_collection(@notifications, Notification)
     statuses       = @notifications.select { |n| !n.target_status.nil? }.map(&:target_status)
 
@@ -15,9 +17,18 @@ class Api::V1::NotificationsController < ApiController
     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 == 20
+    next_path = api_v1_notifications_url(max_id: @notifications.last.id)    if @notifications.size == limit_param(DEFAULT_NOTIFICATIONS_LIMIT)
     prev_path = api_v1_notifications_url(since_id: @notifications.first.id) unless @notifications.empty?
 
     set_pagination_headers(next_path, prev_path)
   end
+
+  def show
+    @notification = Notification.where(account: current_account).find(params[:id])
+  end
+
+  def clear
+    Notification.where(account: current_account).delete_all
+    render_empty
+  end
 end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index f7b4ed610..4b095a570 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -3,8 +3,8 @@
 class Api::V1::StatusesController < ApiController
   before_action -> { doorkeeper_authorize! :read }, except: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
   before_action -> { doorkeeper_authorize! :write }, only:  [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite]
-  before_action :require_user!, except: [:show, :context, :reblogged_by, :favourited_by]
-  before_action :set_status, only:      [:show, :context, :reblogged_by, :favourited_by]
+  before_action :require_user!, except: [:show, :context, :card, :reblogged_by, :favourited_by]
+  before_action :set_status, only:      [:show, :context, :card, :reblogged_by, :favourited_by]
 
   respond_to :json
 
@@ -14,21 +14,26 @@ class Api::V1::StatusesController < ApiController
   end
 
   def context
-    @context = OpenStruct.new(ancestors: @status.ancestors(current_account), descendants: @status.descendants(current_account))
+    @context = OpenStruct.new(ancestors: @status.in_reply_to_id.nil? ? [] : @status.ancestors(current_account), descendants: @status.descendants(current_account))
     statuses = [@status] + @context[:ancestors] + @context[:descendants]
 
     set_maps(statuses)
     set_counters_maps(statuses)
   end
 
+  def card
+    @card = PreviewCard.find_by(status: @status)
+    render_empty if @card.nil?
+  end
+
   def reblogged_by
-    results   = @status.reblogs.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
+    results   = @status.reblogs.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
     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)
 
-    next_path = reblogged_by_api_v1_status_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
+    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?
 
     set_pagination_headers(next_path, prev_path)
@@ -37,13 +42,13 @@ class Api::V1::StatusesController < ApiController
   end
 
   def favourited_by
-    results   = @status.favourites.paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])
+    results   = @status.favourites.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
     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)
 
-    next_path = favourited_by_api_v1_status_url(max_id: results.last.id)    if results.size == DEFAULT_ACCOUNTS_LIMIT
+    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?
 
     set_pagination_headers(next_path, prev_path)
@@ -52,7 +57,12 @@ 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], visibility: params[:visibility])
+    @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)
+
     render action: :show
   end
 
diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb
index 9727797e5..5042550db 100644
--- a/app/controllers/api/v1/timelines_controller.rb
+++ b/app/controllers/api/v1/timelines_controller.rb
@@ -7,14 +7,14 @@ class Api::V1::TimelinesController < ApiController
   respond_to :json
 
   def home
-    @statuses = Feed.new(:home, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id])
+    @statuses = Feed.new(:home, current_account).get(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
     @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)
 
-    next_path = api_v1_home_timeline_url(max_id: @statuses.last.id)    if @statuses.size == DEFAULT_STATUSES_LIMIT
+    next_path = api_v1_home_timeline_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
     prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -23,14 +23,14 @@ class Api::V1::TimelinesController < ApiController
   end
 
   def mentions
-    @statuses = Feed.new(:mentions, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id])
+    @statuses = Feed.new(:mentions, current_account).get(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
     @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)
 
-    next_path = api_v1_mentions_timeline_url(max_id: @statuses.last.id)    if @statuses.size == DEFAULT_STATUSES_LIMIT
+    next_path = api_v1_mentions_timeline_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
     prev_path = api_v1_mentions_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -39,14 +39,14 @@ class Api::V1::TimelinesController < ApiController
   end
 
   def public
-    @statuses = Status.as_public_timeline(current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id])
+    @statuses = Status.as_public_timeline(current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
     @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)
 
-    next_path = api_v1_public_timeline_url(max_id: @statuses.last.id)    if @statuses.size == DEFAULT_STATUSES_LIMIT
+    next_path = api_v1_public_timeline_url(max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
     prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
 
     set_pagination_headers(next_path, prev_path)
@@ -56,14 +56,14 @@ class Api::V1::TimelinesController < ApiController
 
   def tag
     @tag      = Tag.find_by(name: params[:id].downcase)
-    @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id])
+    @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
     @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)
 
-    next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id)    if @statuses.size == DEFAULT_STATUSES_LIMIT
+    next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id)    if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
     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/web/settings_controller.rb b/app/controllers/api/web/settings_controller.rb
new file mode 100644
index 000000000..c00e016a4
--- /dev/null
+++ b/app/controllers/api/web/settings_controller.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Api::Web::SettingsController < ApiController
+  respond_to :json
+
+  before_action :require_user!
+
+  def update
+    setting      = ::Web::Setting.where(user: current_user).first_or_initialize(user: current_user)
+    setting.data = params[:data]
+    setting.save!
+
+    render_empty
+  end
+end
diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb
index 8f1c8ac8a..5d2bd9a22 100644
--- a/app/controllers/api_controller.rb
+++ b/app/controllers/api_controller.rb
@@ -62,6 +62,11 @@ class ApiController < ApplicationController
     response.headers['Link'] = LinkHeader.new(links)
   end
 
+  def limit_param(default_limit)
+    return default_limit unless params[:limit]
+    [params[:limit].to_i.abs, default_limit * 2].min
+  end
+
   def current_resource_owner
     @current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
   end
@@ -89,19 +94,19 @@ class ApiController < ApplicationController
       return
     end
 
-    status_ids      = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact.uniq
+    status_ids      = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq
     @reblogs_map    = Status.reblogs_map(status_ids, current_account)
     @favourites_map = Status.favourites_map(status_ids, current_account)
   end
 
   def set_counters_maps(statuses) # rubocop:disable Style/AccessorMethodName
-    status_ids             = statuses.map { |s| s.reblog? ? s.reblog_of_id : s.id }.uniq
+    status_ids             = statuses.compact.map { |s| s.reblog? ? s.reblog_of_id : s.id }.uniq
     @favourites_counts_map = Favourite.select('status_id, COUNT(id) AS favourites_count').group('status_id').where(status_id: status_ids).map { |f| [f.status_id, f.favourites_count] }.to_h
     @reblogs_counts_map    = Status.select('statuses.id, COUNT(reblogs.id) AS reblogs_count').joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.id = reblogs.reblog_of_id').where(id: status_ids).group('statuses.id').map { |r| [r.id, r.reblogs_count] }.to_h
   end
 
   def set_account_counters_maps(accounts) # rubocop:disable Style/AccessorMethodName
-    account_ids = accounts.map(&:id)
+    account_ids = accounts.compact.map(&:id).uniq
     @followers_counts_map = Follow.unscoped.select('target_account_id, COUNT(account_id) AS followers_count').group('target_account_id').where(target_account_id: account_ids).map { |f| [f.target_account_id, f.followers_count] }.to_h
     @following_counts_map = Follow.unscoped.select('account_id, COUNT(target_account_id) AS following_count').group('account_id').where(account_id: account_ids).map { |f| [f.account_id, f.following_count] }.to_h
     @statuses_counts_map  = Status.unscoped.select('account_id, COUNT(id) AS statuses_count').group('account_id').where(account_id: account_ids).map { |s| [s.account_id, s.statuses_count] }.to_h
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 0a6b50a29..e4b6d0faf 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base
 
   rescue_from ActionController::RoutingError, with: :not_found
   rescue_from ActiveRecord::RecordNotFound, with: :not_found
+  rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity
 
   before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
   before_action :set_locale
@@ -50,12 +51,21 @@ class ApplicationController < ActionController::Base
   def not_found
     respond_to do |format|
       format.any  { head 404 }
+      format.html { render 'errors/404', layout: 'error' }
     end
   end
 
   def gone
     respond_to do |format|
       format.any  { head 410 }
+      format.html { render 'errors/410', layout: 'error' }
+    end
+  end
+
+  def unprocessable_entity
+    respond_to do |format|
+      format.any  { head 422 }
+      format.html { render 'errors/422', layout: 'error' }
     end
   end
 
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 60eb9905a..6ce4984bb 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -23,6 +23,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
     new_user_session_path
   end
 
+  def after_inactive_sign_up_path_for(_resource)
+    new_user_session_path
+  end
+
   def check_single_user_mode
     redirect_to root_path if Rails.configuration.x.single_user_mode
   end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index a25fe77da..814b1f758 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -6,6 +6,7 @@ class HomeController < ApplicationController
   def index
     @body_classes = 'app-body'
     @token        = find_or_create_access_token.token
+    @web_settings = Web::Setting.find_by(user: current_user)&.data || {}
   end
 
   private
diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb
index 6f1f7ec48..488c4f944 100644
--- a/app/controllers/media_controller.rb
+++ b/app/controllers/media_controller.rb
@@ -10,6 +10,7 @@ class MediaController < ApplicationController
   private
 
   def set_media_attachment
-    @media_attachment = MediaAttachment.where.not(status_id: nil).find(params[:id])
+    @media_attachment = MediaAttachment.where.not(status_id: nil).find_by!(shortcode: params[:id])
+    raise ActiveRecord::RecordNotFound unless @media_attachment.status.permitted?(current_account)
   end
 end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 3b6d109a6..f273b5f21 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -8,14 +8,18 @@ class Settings::PreferencesController < ApplicationController
   def show; end
 
   def update
-    current_user.settings(:notification_emails).follow         = user_params[:notification_emails][:follow]         == '1'
-    current_user.settings(:notification_emails).follow_request = user_params[:notification_emails][:follow_request] == '1'
-    current_user.settings(:notification_emails).reblog         = user_params[:notification_emails][:reblog]         == '1'
-    current_user.settings(:notification_emails).favourite      = user_params[:notification_emails][:favourite]      == '1'
-    current_user.settings(:notification_emails).mention        = user_params[:notification_emails][:mention]        == '1'
-
-    current_user.settings(:interactions).must_be_follower  = user_params[:interactions][:must_be_follower]  == '1'
-    current_user.settings(:interactions).must_be_following = user_params[:interactions][:must_be_following] == '1'
+    current_user.settings['notification_emails'] = {
+      follow:         user_params[:notification_emails][:follow]         == '1',
+      follow_request: user_params[:notification_emails][:follow_request] == '1',
+      reblog:         user_params[:notification_emails][:reblog]         == '1',
+      favourite:      user_params[:notification_emails][:favourite]      == '1',
+      mention:        user_params[:notification_emails][:mention]        == '1',
+    }
+
+    current_user.settings['interactions'] = {
+      must_be_follower:  user_params[:interactions][:must_be_follower]  == '1',
+      must_be_following: user_params[:interactions][:must_be_following] == '1',
+    }
 
     if current_user.update(user_params.except(:notification_emails, :interactions))
       redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg')
diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb
index 3f60bb0c4..5701b2efa 100644
--- a/app/controllers/stream_entries_controller.rb
+++ b/app/controllers/stream_entries_controller.rb
@@ -46,7 +46,7 @@ class StreamEntriesController < ApplicationController
     @stream_entry = @account.stream_entries.find(params[:id])
     @type         = @stream_entry.activity_type.downcase
 
-    raise ActiveRecord::RecordNotFound if @stream_entry.hidden? && (@stream_entry.activity_type != 'Status' || (@stream_entry.activity_type == 'Status' && !@stream_entry.activity.permitted?(current_account)))
+    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
diff --git a/app/helpers/atom_builder_helper.rb b/app/helpers/atom_builder_helper.rb
index 036a72166..c08d80ea0 100644
--- a/app/helpers/atom_builder_helper.rb
+++ b/app/helpers/atom_builder_helper.rb
@@ -41,7 +41,8 @@ module AtomBuilderHelper
     xml['activity'].send('verb', TagManager::VERBS[verb])
   end
 
-  def content(xml, content)
+  def content(xml, content, warning = nil)
+    xml.summary(warning) unless warning.blank?
     xml.content({ type: 'html' }, content) unless content.blank?
   end
 
@@ -153,12 +154,20 @@ module AtomBuilderHelper
     portable_contact xml, account
   end
 
+  def rich_content(xml, activity)
+    if activity.is_a?(Status)
+      content xml, conditionally_formatted(activity), activity.spoiler_text
+    else
+      content xml, conditionally_formatted(activity)
+    end
+  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
     updated_at     xml, stream_entry.updated_at
     title          xml, stream_entry.title
-    content        xml, conditionally_formatted(stream_entry.activity)
+    rich_content   xml, stream_entry.activity
     verb           xml, stream_entry.verb
     link_self      xml, account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom')
     link_alternate xml, account_stream_entry_url(stream_entry.account, stream_entry)
diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb
index 6f87c7b72..d3c6b13a6 100644
--- a/app/helpers/home_helper.rb
+++ b/app/helpers/home_helper.rb
@@ -3,8 +3,6 @@
 module HomeHelper
   def default_props
     {
-      token: @token,
-      account: render(file: 'api/v1/accounts/show', locals: { account: current_user.account }, formats: :json),
       locale: I18n.locale,
     }
   end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index fa569e73a..aed8770c8 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -14,4 +14,8 @@ module SettingsHelper
   def human_locale(locale)
     HUMAN_LOCALES[locale]
   end
+
+  def hash_to_object(hash)
+    HashObject.new(hash)
+  end
 end
diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb
index ae2f575b5..15601a079 100644
--- a/app/helpers/stream_entries_helper.rb
+++ b/app/helpers/stream_entries_helper.rb
@@ -15,10 +15,10 @@ module StreamEntriesHelper
 
   def entry_classes(status, is_predecessor, is_successor, include_threads)
     classes = ['entry']
-    classes << 'entry-reblog' if status.reblog?
-    classes << 'entry-predecessor' if is_predecessor
-    classes << 'entry-successor' if is_successor
-    classes << 'entry-center' if include_threads
+    classes << 'entry-reblog u-repost-of h-cite' if status.reblog?
+    classes << 'entry-predecessor u-in-reply-to h-cite' if is_predecessor
+    classes << 'entry-successor u-comment h-cite' if is_successor
+    classes << 'entry-center h-entry' if include_threads
     classes.join(' ')
   end
 
diff --git a/app/lib/application_extension.rb b/app/lib/application_extension.rb
new file mode 100644
index 000000000..93c0f42f0
--- /dev/null
+++ b/app/lib/application_extension.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module ApplicationExtension
+  extend ActiveSupport::Concern
+
+  included do
+    validates :website, url: true, unless: 'website.blank?'
+  end
+end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 0056321fa..cdd26e69c 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -43,12 +43,22 @@ class FeedManager
     timeline_key = key(:home, into_account.id)
 
     from_account.statuses.limit(MAX_ITEMS).each do |status|
+      next if filter?(:home, status, into_account)
       redis.zadd(timeline_key, status.id, status.id)
     end
 
     trim(:home, into_account.id)
   end
 
+  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)
+    end
+  end
+
   def inline_render(target_account, template, object)
     rabl_scope = Class.new do
       include RoutingHelper
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 04386d295..ff2a16f1b 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -14,7 +14,7 @@ class Formatter
 
     html = status.text
     html = encode(html)
-    html = simple_format(html, sanitize: false)
+    html = simple_format(html, {}, sanitize: false)
     html = html.gsub(/\n/, '')
     html = link_urls(html)
     html = link_mentions(html, status.mentions)
@@ -32,6 +32,7 @@ class Formatter
 
     html = encode(account.note)
     html = link_urls(html)
+    html = link_hashtags(html)
 
     html.html_safe # rubocop:disable Rails/OutputSafety
   end
@@ -43,8 +44,8 @@ class Formatter
   end
 
   def link_urls(html)
-    auto_link(html, link: :urls, html: { rel: 'nofollow noopener', target: '_blank' }) do |text|
-      truncate(text.gsub(/\Ahttps?:\/\/(www\.)?/, ''), length: 30)
+    html.gsub(URI.regexp(%w(http https))) do |match|
+      link_html(match)
     end
   end
 
@@ -63,6 +64,14 @@ class Formatter
     end
   end
 
+  def link_html(url)
+    prefix = url.match(/\Ahttps?:\/\/(www\.)?/).to_s
+    text   = url[prefix.length, 30]
+    suffix = url[prefix.length + 30..-1]
+
+    "<a rel=\"nofollow noopener\" target=\"_blank\" href=\"#{url}\"><span class=\"invisible\">#{prefix}</span><span class=\"ellipsis\">#{text}</span><span class=\"invisible\">#{suffix}</span></a>"
+  end
+
   def hashtag_html(match)
     prefix, affix = match.split('#')
     "#{prefix}<a href=\"#{tag_url(affix.downcase)}\" class=\"mention hashtag\">#<span>#{affix}</span></a>"
diff --git a/app/lib/hash_object.rb b/app/lib/hash_object.rb
new file mode 100644
index 000000000..274c020ad
--- /dev/null
+++ b/app/lib/hash_object.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class HashObject
+  def initialize(hash)
+    hash.each do |k, v|
+      instance_variable_set("@#{k}", v)
+      self.class.send(:define_method, k, proc { instance_variable_get("@#{k}") })
+    end
+  end
+end
diff --git a/app/lib/settings/extend.rb b/app/lib/settings/extend.rb
new file mode 100644
index 000000000..407c3480f
--- /dev/null
+++ b/app/lib/settings/extend.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Settings
+  module Extend
+    extend ActiveSupport::Concern
+
+    def settings
+      ScopedSettings.for_thing(self)
+    end
+  end
+end
diff --git a/app/lib/settings/scoped_settings.rb b/app/lib/settings/scoped_settings.rb
new file mode 100644
index 000000000..82b70d128
--- /dev/null
+++ b/app/lib/settings/scoped_settings.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Settings
+  class ScopedSettings < ::Setting
+    def self.for_thing(object)
+      @object = object
+      self
+    end
+
+    def self.thing_scoped
+      unscoped.where(thing_type: @object.class.base_class.to_s, thing_id: @object.id)
+    end
+  end
+end
diff --git a/app/lib/status_length_validator.rb b/app/lib/status_length_validator.rb
new file mode 100644
index 000000000..55135a598
--- /dev/null
+++ b/app/lib/status_length_validator.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class StatusLengthValidator < ActiveModel::Validator
+  MAX_CHARS = 500
+
+  def validate(status)
+    return unless status.local? && !status.reblog?
+    status.errors.add(:text, I18n.t('statuses.over_character_limit', max: MAX_CHARS)) if [status.text, status.spoiler_text].join.length > MAX_CHARS
+  end
+end
diff --git a/app/lib/url_validator.rb b/app/lib/url_validator.rb
new file mode 100644
index 000000000..4a5c4ef3f
--- /dev/null
+++ b/app/lib/url_validator.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class UrlValidator < ActiveModel::EachValidator
+  def validate_each(record, attribute, value)
+    record.errors.add(attribute, I18n.t('applications.invalid_url')) unless compliant?(value)
+  end
+
+  private
+
+  def compliant?(url)
+    parsed_url = Addressable::URI.parse(url)
+    !parsed_url.nil? && %w(http https).include?(parsed_url.scheme) && parsed_url.host
+  end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index 5c1f6e7c1..c2a41c4c6 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -62,8 +62,8 @@ class Account < ApplicationRecord
   scope :expiring, ->(time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers }
   scope :silenced, -> { where(silenced: true) }
   scope :suspended, -> { where(suspended: true) }
-  scope :recent, -> { reorder('id desc') }
-  scope :alphabetic, -> { order('domain ASC, username ASC') }
+  scope :recent, -> { reorder(id: :desc) }
+  scope :alphabetic, -> { order(domain: :asc, username: :asc) }
 
   def follow!(other_account)
     active_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
@@ -104,7 +104,7 @@ class Account < ApplicationRecord
   end
 
   def subscribed?
-    !subscription_expires_at.nil?
+    !subscription_expires_at.blank?
   end
 
   def favourited?(status)
@@ -125,13 +125,10 @@ class Account < ApplicationRecord
 
   def save_with_optional_avatar!
     save!
-  rescue ActiveRecord::RecordInvalid => invalid
-    if invalid.record.errors[:avatar_file_size] || invalid[:avatar_content_type]
-      self.avatar = nil
-      retry
-    end
-
-    raise invalid
+  rescue ActiveRecord::RecordInvalid
+    self.avatar              = nil
+    self[:avatar_remote_url] = ''
+    save!
   end
 
   def avatar_remote_url=(url)
@@ -159,6 +156,7 @@ class Account < ApplicationRecord
     end
 
     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!
     end
 
@@ -175,19 +173,25 @@ class Account < ApplicationRecord
     end
 
     def following_map(target_account_ids, account_id)
-      Follow.where(target_account_id: target_account_ids).where(account_id: account_id).map { |f| [f.target_account_id, true] }.to_h
+      follow_mapping(Follow.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
     end
 
     def followed_by_map(target_account_ids, account_id)
-      Follow.where(account_id: target_account_ids).where(target_account_id: account_id).map { |f| [f.account_id, true] }.to_h
+      follow_mapping(Follow.where(account_id: target_account_ids, target_account_id: account_id), :account_id)
     end
 
     def blocking_map(target_account_ids, account_id)
-      Block.where(target_account_id: target_account_ids).where(account_id: account_id).map { |b| [b.target_account_id, true] }.to_h
+      follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
     end
 
     def requested_map(target_account_ids, account_id)
-      FollowRequest.where(target_account_id: target_account_ids).where(account_id: account_id).map { |r| [r.target_account_id, true] }.to_h
+      follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
+    end
+
+    private
+
+    def follow_mapping(query, field)
+      query.pluck(field).inject({}) { |mapping, id| mapping[id] = true; mapping }
     end
   end
 
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index 9075b90a0..b4606da60 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class DomainBlock < ApplicationRecord
+  enum severity: [:silence, :suspend]
+
   validates :domain, presence: true, uniqueness: true
 
   def self.blocked?(domain)
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index 8eef3abf4..936ad0691 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -13,7 +13,7 @@ class FollowRequest < ApplicationRecord
 
   def authorize!
     account.follow!(target_account)
-    FeedManager.instance.merge_into_timeline(target_account, account)
+    MergeWorker.perform_async(target_account.id, account.id)
     destroy!
   end
 
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 2a5d23739..ecbed03e3 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -16,6 +16,7 @@ class MediaAttachment < ApplicationRecord
 
   validates :account, presence: true
 
+  scope :local, -> { where(remote_url: '') }
   default_scope { order('id asc') }
 
   def local?
@@ -38,6 +39,12 @@ class MediaAttachment < ApplicationRecord
     image? ? 'image' : 'video'
   end
 
+  def to_param
+    shortcode
+  end
+
+  before_create :set_shortcode
+
   class << self
     private
 
@@ -62,4 +69,15 @@ class MediaAttachment < ApplicationRecord
       end
     end
   end
+
+  private
+
+  def set_shortcode
+    return unless local?
+
+    loop do
+      self.shortcode = SecureRandom.urlsafe_base64(14)
+      break if MediaAttachment.find_by(shortcode: shortcode).nil?
+    end
+  end
 end
diff --git a/app/models/notification.rb b/app/models/notification.rb
index c0b5c45a8..b7e8c9e71 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -39,9 +39,9 @@ class Notification < ApplicationRecord
   def target_status
     case type
     when :reblog
-      activity.reblog
+      activity&.reblog
     when :favourite, :mention
-      activity.status
+      activity&.status
     end
   end
 
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
new file mode 100644
index 000000000..e59b05eb8
--- /dev/null
+++ b/app/models/preview_card.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class PreviewCard < ApplicationRecord
+  IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
+
+  belongs_to :status
+
+  has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' }
+
+  validates :url, presence: true
+  validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
+  validates_attachment_size :image, less_than: 1.megabytes
+
+  def save_with_optional_image!
+    save!
+  rescue ActiveRecord::RecordInvalid
+    self.image = nil
+    save!
+  end
+end
diff --git a/app/models/setting.rb b/app/models/setting.rb
new file mode 100644
index 000000000..3796253d4
--- /dev/null
+++ b/app/models/setting.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+class Setting < RailsSettings::Base
+  source Rails.root.join('config/settings.yml')
+  namespace Rails.env
+
+  def to_param
+    var
+  end
+
+  class << self
+    def [](key)
+      return super(key) unless rails_initialized?
+
+      val = Rails.cache.fetch(cache_key(key, @object)) do
+        db_val = object(key)
+
+        if db_val
+          default_value = default_settings[key]
+
+          return default_value.with_indifferent_access.merge!(db_val.value) if default_value.is_a?(Hash)
+          db_val.value
+        else
+          default_settings[key]
+        end
+      end
+
+      val
+    end
+
+    def all_as_records
+      vars    = thing_scoped
+      records = vars.map { |r| [r.var, r] }.to_h
+
+      default_settings.each do |key, default_value|
+        next if records.key?(key) || default_value.is_a?(Hash)
+        records[key] = Setting.new(var: key, value: default_value)
+      end
+
+      records
+    end
+
+    private
+
+    def default_settings
+      return {} unless RailsSettings::Default.enabled?
+      RailsSettings::Default.instance
+    end
+  end
+end
diff --git a/app/models/status.rb b/app/models/status.rb
index bc595c93b..651d0dbc9 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -1,12 +1,15 @@
 # frozen_string_literal: true
 
 class Status < ApplicationRecord
+  include ActiveModel::Validations
   include Paginable
   include Streamable
   include Cacheable
 
   enum visibility: [:public, :unlisted, :private], _suffix: :visibility
 
+  belongs_to :application, class_name: 'Doorkeeper::Application'
+
   belongs_to :account, inverse_of: :statuses
   belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account'
 
@@ -21,11 +24,12 @@ class Status < ApplicationRecord
   has_and_belongs_to_many :tags
 
   has_one :notification, as: :activity, dependent: :destroy
+  has_one :preview_card, dependent: :destroy
 
   validates :account, presence: true
   validates :uri, uniqueness: true, unless: 'local?'
-  validates :text, presence: true, length: { maximum: 500 }, if: proc { |s| s.local? && !s.reblog? }
-  validates :text, presence: true, if: proc { |s| !s.local? && !s.reblog? }
+  validates :text, presence: true, unless: 'reblog?'
+  validates_with StatusLengthValidator
   validates :reblog, uniqueness: { scope: :account, message: 'of status already exists' }, if: 'reblog?'
 
   default_scope { order('id desc') }
@@ -33,7 +37,7 @@ class Status < ApplicationRecord
   scope :remote, -> { where.not(uri: nil) }
   scope :local, -> { where(uri: nil) }
 
-  cache_associated :account, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
+  cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
 
   def local?
     uri.nil?
@@ -171,6 +175,7 @@ class Status < ApplicationRecord
 
   before_validation do
     text.strip!
+    spoiler_text&.strip!
 
     self.reblog                 = reblog.reblog if reblog? && reblog.reblog?
     self.in_reply_to_account_id = (thread.account_id == account_id && thread.reply? ? thread.in_reply_to_account_id : thread.account_id) if reply?
diff --git a/app/models/user.rb b/app/models/user.rb
index d5a52da06..71d3ee0b8 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class User < ApplicationRecord
+  include Settings::Extend
+
   devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable, :confirmable
 
   belongs_to :account, inverse_of: :user
@@ -14,11 +16,6 @@ class User < ApplicationRecord
   scope :recent,   -> { order('id desc') }
   scope :admins,   -> { where(admin: true) }
 
-  has_settings do |s|
-    s.key :notification_emails, defaults: { follow: false, reblog: false, favourite: false, mention: false, follow_request: true }
-    s.key :interactions, defaults: { must_be_follower: false, must_be_following: false }
-  end
-
   def send_devise_notification(notification, *args)
     devise_mailer.send(notification, self, *args).deliver_later
   end
diff --git a/app/models/web.rb b/app/models/web.rb
new file mode 100644
index 000000000..58654fd77
--- /dev/null
+++ b/app/models/web.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Web
+  def self.table_name_prefix
+    'web_'
+  end
+end
diff --git a/app/models/web/setting.rb b/app/models/web/setting.rb
new file mode 100644
index 000000000..3d601189b
--- /dev/null
+++ b/app/models/web/setting.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class Web::Setting < ApplicationRecord
+  belongs_to :user
+
+  validates :user, uniqueness: true
+end
diff --git a/app/services/after_block_service.rb b/app/services/after_block_service.rb
new file mode 100644
index 000000000..8c6197f2c
--- /dev/null
+++ b/app/services/after_block_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class AfterBlockService < BaseService
+  def call(account, target_account)
+    clear_timelines(account, target_account)
+    clear_notifications(account, target_account)
+  end
+
+  private
+
+  def clear_timelines(account, target_account)
+    mentions_key = FeedManager.instance.key(:mentions, account.id)
+    home_key     = FeedManager.instance.key(:home, account.id)
+
+    target_account.statuses.select('id').find_each do |status|
+      redis.zrem(mentions_key, status.id)
+      redis.zrem(home_key, status.id)
+    end
+  end
+
+  def clear_notifications(account, target_account)
+    Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).destroy_all
+    Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).destroy_all
+    Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).destroy_all
+    Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).destroy_all
+  end
+
+  def redis
+    Redis.current
+  end
+end
diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb
index a8fafe412..9518b1fcf 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -1,15 +1,16 @@
 # frozen_string_literal: true
 
 class BlockDomainService < BaseService
-  def call(domain)
-    DomainBlock.find_or_create_by!(domain: domain)
+  def call(domain, severity)
+    DomainBlock.where(domain: domain).first_or_create!(domain: domain, severity: severity)
 
-    Account.where(domain: domain).find_each do |account|
-      if account.subscribed?
-        account.subscription(api_subscription_url(account.id)).unsubscribe
+    if severity == :silence
+      Account.where(domain: domain).update_all(silenced: true)
+    else
+      Account.where(domain: domain).find_each do |account|
+        account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed?
+        SuspendAccountService.new.call(account)
       end
-
-      account.destroy!
     end
   end
 end
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index b08cf8ca8..e04b6cc39 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -9,32 +9,7 @@ class BlockService < BaseService
 
     block = account.block!(target_account)
 
-    clear_timelines(account, target_account)
-    clear_notifications(account, target_account)
-
+    BlockWorker.perform_async(account.id, target_account.id)
     NotificationWorker.perform_async(block.stream_entry.id, target_account.id) unless target_account.local?
   end
-
-  private
-
-  def clear_timelines(account, target_account)
-    mentions_key = FeedManager.instance.key(:mentions, account.id)
-    home_key     = FeedManager.instance.key(:home, account.id)
-
-    target_account.statuses.select('id').find_each do |status|
-      redis.zrem(mentions_key, status.id)
-      redis.zrem(home_key, status.id)
-    end
-  end
-
-  def clear_notifications(account, target_account)
-    Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).destroy_all
-    Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).destroy_all
-    Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).destroy_all
-    Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).destroy_all
-  end
-
-  def redis
-    Redis.current
-  end
 end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
new file mode 100644
index 000000000..005e5acea
--- /dev/null
+++ b/app/services/fetch_link_card_service.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class FetchLinkCardService < BaseService
+  def call(status)
+    # Get first URL
+    url = URI.extract(status.text).reject { |uri| (uri =~ /\Ahttps?:\/\//).nil? }.first
+
+    return if url.nil?
+
+    response = http_client.get(url)
+
+    return if response.code != 200 || response.mime_type != 'text/html'
+
+    page = Nokogiri::HTML(response.to_s)
+    card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url)
+
+    card.title       = meta_property(page, 'og:title') || page.at_xpath('//title')&.content
+    card.description = meta_property(page, 'og:description') || meta_property(page, 'description')
+    card.image       = URI.parse(meta_property(page, 'og:image')) if meta_property(page, 'og:image')
+
+    return if card.title.blank?
+
+    card.save_with_optional_image!
+  end
+
+  private
+
+  def http_client
+    HTTP.timeout(:per_operation, write: 10, connect: 10, read: 10).follow
+  end
+
+  def meta_property(html, property)
+    html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value
+  end
+end
diff --git a/app/services/follow_remote_account_service.rb b/app/services/follow_remote_account_service.rb
index f640222b0..b39eafc70 100644
--- a/app/services/follow_remote_account_service.rb
+++ b/app/services/follow_remote_account_service.rb
@@ -14,7 +14,6 @@ class FollowRemoteAccountService < BaseService
     username, domain = uri.split('@')
 
     return Account.find_local(username) if TagManager.instance.local_domain?(domain)
-    return nil if DomainBlock.blocked?(domain)
 
     account = Account.find_remote(username, domain)
     return account unless account.nil?
@@ -36,11 +35,15 @@ class FollowRemoteAccountService < BaseService
 
     Rails.logger.debug "Creating new remote account for #{uri}"
 
+    domain_block = DomainBlock.find_by(domain: domain)
+
     account.remote_url  = data.link('http://schemas.google.com/g/2010#updates-from').href
     account.salmon_url  = data.link('salmon').href
     account.url         = data.link('http://webfinger.net/rel/profile-page').href
     account.public_key  = magic_key_to_pem(data.link('magic-public-key').href)
     account.private_key = nil
+    account.suspended   = true if domain_block && domain_block.suspend?
+    account.silenced    = true if domain_block && domain_block.silence?
 
     xml  = get_feed(account.remote_url)
     hubs = get_hubs(xml)
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 555f01b6d..87c16a621 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -38,7 +38,7 @@ class FollowService < BaseService
       NotificationWorker.perform_async(follow.stream_entry.id, target_account.id)
     end
 
-    FeedManager.instance.merge_into_timeline(target_account, source_account)
+    MergeWorker.perform_async(target_account.id, source_account.id)
     Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id)
 
     follow
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 2fb1d3919..1ec36637c 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -6,7 +6,7 @@ class NotifyService < BaseService
     @activity     = activity
     @notification = Notification.new(account: @recipient, activity: @activity)
 
-    return if blocked?
+    return if blocked? || recipient.user.nil?
 
     create_notification
     send_email if email_enabled?
@@ -37,13 +37,13 @@ class NotifyService < BaseService
   end
 
   def blocked?
-    blocked   = @recipient.suspended?                                                                                             # Skip if the recipient account is suspended anyway
-    blocked ||= @recipient.id == @notification.from_account.id                                                                    # Skip for interactions with self
-    blocked ||= @recipient.blocking?(@notification.from_account)                                                                  # Skip for blocked accounts
-    blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account))                      # Hellban
-    blocked ||= (@recipient.user.settings(:interactions).must_be_follower  && !@notification.from_account.following?(@recipient)) # Options
-    blocked ||= (@recipient.user.settings(:interactions).must_be_following && !@recipient.following?(@notification.from_account)) # Options
-    blocked ||= send("blocked_#{@notification.type}?")                                                                            # Type-dependent filters
+    blocked   = @recipient.suspended?                                                                                              # Skip if the recipient account is suspended anyway
+    blocked ||= @recipient.id == @notification.from_account.id                                                                     # Skip for interactions with self
+    blocked ||= @recipient.blocking?(@notification.from_account)                                                                   # Skip for blocked accounts
+    blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account))                       # Hellban
+    blocked ||= (@recipient.user.settings.interactions['must_be_follower']  && !@notification.from_account.following?(@recipient)) # Options
+    blocked ||= (@recipient.user.settings.interactions['must_be_following'] && !@recipient.following?(@notification.from_account)) # Options
+    blocked ||= send("blocked_#{@notification.type}?")                                                                             # Type-dependent filters
     blocked
   end
 
@@ -58,6 +58,6 @@ class NotifyService < BaseService
   end
 
   def email_enabled?
-    @recipient.user.settings(:notification_emails).send(@notification.type)
+    @recipient.user.settings.notification_emails[@notification.type]
   end
 end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 55405c0db..979941c84 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -7,14 +7,24 @@ class PostStatusService < BaseService
   # @param [Status] in_reply_to Optional status to reply to
   # @param [Hash] options
   # @option [Boolean] :sensitive
+  # @option [String] :visibility
+  # @option [String] :spoiler_text
   # @option [Enumerable] :media_ids Optional array of media IDs to attach
+  # @option [Doorkeeper::Application] :application
   # @return [Status]
   def call(account, text, in_reply_to = nil, options = {})
-    status = account.statuses.create!(text: text, thread: in_reply_to, sensitive: options[:sensitive], visibility: options[:visibility])
+    status = account.statuses.create!(text: text,
+                                      thread: in_reply_to,
+                                      sensitive: options[:sensitive],
+                                      spoiler_text: options[:spoiler_text] || '',
+                                      visibility: options[:visibility],
+                                      application: options[:application])
+
     attach_media(status, options[:media_ids])
     process_mentions_service.call(status)
     process_hashtags_service.call(status)
 
+    LinkCrawlWorker.perform_async(status.id)
     DistributionWorker.perform_async(status.id)
     Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
 
diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb
index 3860a3504..626534176 100644
--- a/app/services/process_feed_service.rb
+++ b/app/services/process_feed_service.rb
@@ -44,6 +44,8 @@ class ProcessFeedService < BaseService
       Rails.logger.debug "Creating remote status #{id}"
       status = status_from_xml(@xml)
 
+      return if status.nil?
+
       if verb == :share
         original_status = status_from_xml(@xml.at_xpath('.//activity:object', activity: TagManager::AS_XMLNS))
         status.reblog   = original_status
@@ -59,6 +61,7 @@ class ProcessFeedService < BaseService
       status.save!
 
       NotifyService.new.call(status.reblog.account, status) if status.reblog? && status.reblog.account.local?
+      # LinkCrawlWorker.perform_async(status.reblog? ? status.reblog_of_id : status.id)
       Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution"
       DistributionWorker.perform_async(status.id)
       status
@@ -100,6 +103,7 @@ class ProcessFeedService < BaseService
         url: url(entry),
         account: account,
         text: content(entry),
+        spoiler_text: content_warning(entry),
         created_at: published(entry)
       )
 
@@ -177,6 +181,8 @@ class ProcessFeedService < BaseService
     end
 
     def media_from_xml(parent, xml)
+      return if DomainBlock.find_by(domain: parent.account.domain)&.reject_media?
+
       xml.xpath('./xmlns:link[@rel="enclosure"]', xmlns: TagManager::XMLNS).each do |link|
         next unless link['href']
 
@@ -218,6 +224,10 @@ class ProcessFeedService < BaseService
       xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS).content
     end
 
+    def content_warning(xml = @xml)
+      xml.at_xpath('./xmlns:summary', xmlns: TagManager::XMLNS)&.content || ''
+    end
+
     def published(xml = @xml)
       xml.at_xpath('./xmlns:published', xmlns: TagManager::XMLNS).content
     end
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index ddcc64aa5..617a38159 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -4,7 +4,7 @@ class ProcessHashtagsService < BaseService
   def call(status, tags = [])
     tags = status.text.scan(Tag::HASHTAG_RE).map(&:first) if status.local?
 
-    tags.map { |str| str.mb_chars.downcase }.uniq.each do |tag|
+    tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |tag|
       status.tags << Tag.where(name: tag).first_or_initialize(name: tag)
     end
 
diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb
index 11ec0d2dd..5f91e3127 100644
--- a/app/services/process_interaction_service.rb
+++ b/app/services/process_interaction_service.rb
@@ -17,8 +17,6 @@ class ProcessInteractionService < BaseService
     domain   = Addressable::URI.parse(url).host
     account  = Account.find_by(username: username, domain: domain)
 
-    return if DomainBlock.blocked?(domain)
-
     if account.nil?
       account = follow_remote_account_service.call("#{username}@#{domain}")
     end
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index ee42a5df2..72568e702 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -28,7 +28,7 @@ class ProcessMentionsService < BaseService
     status.mentions.each do |mention|
       mentioned_account = mention.account
 
-      next if status.private_visibility? && !mentioned_account.following?(status.account)
+      next if status.private_visibility? && (!mentioned_account.following?(status.account) || !mentioned_account.local?)
 
       if mentioned_account.local?
         NotifyService.new.call(mentioned_account, mention)
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 0cb51eecd..4ea0dbf6c 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -6,7 +6,9 @@ class ReblogService < BaseService
   # @param [Status] reblogged_status Status to be reblogged
   # @return [Status]
   def call(account, reblogged_status)
-    raise Mastodon::NotPermitted if reblogged_status.private_visibility?
+    reblogged_status = reblogged_status.reblog if reblogged_status.reblog?
+
+    raise Mastodon::NotPermitted if reblogged_status.private_visibility? || !reblogged_status.permitted?(account)
 
     reblog = account.statuses.create!(reblog: reblogged_status, text: '')
 
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 836b8fdc5..7aca24d12 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -53,7 +53,12 @@ class RemoveStatusService < BaseService
   end
 
   def unpush(type, receiver, status)
-    redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id)
+    if status.reblog?
+      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)
+    end
+
     FeedManager.instance.broadcast(receiver.id, type: 'delete', id: status.id)
   end
 
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index 04a086613..8528ef62a 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -18,7 +18,6 @@ class SuspendAccountService < BaseService
 
     @account.media_attachments.destroy_all
     @account.stream_entries.destroy_all
-    @account.mentions.destroy_all
     @account.notifications.destroy_all
     @account.favourites.destroy_all
     @account.active_relationships.destroy_all
diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb
index 7973a3611..f469793c1 100644
--- a/app/services/unfollow_service.rb
+++ b/app/services/unfollow_service.rb
@@ -7,21 +7,6 @@ class UnfollowService < BaseService
   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?
-    unmerge_from_timeline(target_account, source_account)
-  end
-
-  private
-
-  def unmerge_from_timeline(from_account, into_account)
-    timeline_key = FeedManager.instance.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)
-    end
-  end
-
-  def redis
-    Redis.current
+    UnmergeWorker.perform_async(target_account.id, source_account.id)
   end
 end
diff --git a/app/services/update_remote_profile_service.rb b/app/services/update_remote_profile_service.rb
index d961eda39..ad9c56540 100644
--- a/app/services/update_remote_profile_service.rb
+++ b/app/services/update_remote_profile_service.rb
@@ -8,9 +8,12 @@ class UpdateRemoteProfileService < BaseService
     hub_link   = xml.at_xpath('./xmlns:link[@rel="hub"]', xmlns: TagManager::XMLNS)
 
     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.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.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?
+
+      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?
+      end
     end
 
     old_hub_url     = account.hub_url
diff --git a/app/views/about/index.html.haml b/app/views/about/index.html.haml
index 6dd182205..88bfe3d61 100644
--- a/app/views/about/index.html.haml
+++ b/app/views/about/index.html.haml
@@ -1,3 +1,6 @@
+- content_for :header_tags do
+  = javascript_include_tag 'application_public'
+
 - content_for :page_title do
   = Rails.configuration.x.local_domain
 
@@ -5,10 +8,11 @@
   %meta{ property: 'og:site_name', content: 'Mastodon' }/
   %meta{ property: 'og:type', content: 'website' }/
   %meta{ property: 'og:title', content: Rails.configuration.x.local_domain }/
-  %meta{ property: 'og:description', content: "Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly" }/
+  %meta{ property: 'og:description', content: @description.blank? ? "Mastodon is a free, open-source social network server. A decentralized alternative to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly" : strip_tags(@description) }/
   %meta{ property: 'og:image', content: asset_url('mastodon_small.jpg') }/
   %meta{ property: 'og:image:width', content: '400' }/
   %meta{ property: 'og:image:height', content: '400' }/
+  %meta{ property: 'twitter:card', content: 'summary' }/
 
 .wrapper
   %h1
@@ -20,10 +24,14 @@
 
   .screenshot= image_tag 'screenshot.png'
 
+  - unless @description.blank?
+    %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.source_code'), 'https://github.com/Gargron/mastodon'
+      = 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'
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
new file mode 100644
index 000000000..2de3bf986
--- /dev/null
+++ b/app/views/about/more.html.haml
@@ -0,0 +1,56 @@
+- content_for :page_title do
+  #{Rails.configuration.x.local_domain}
+
+.wrapper.thicc
+  .sidebar-layout
+    .main
+      .panel
+        %h2= Rails.configuration.x.local_domain
+
+        - unless @description.blank?
+          %p= @description.html_safe
+
+      .information-board
+        .section
+          %span= t 'about.user_count_before'
+          %strong= number_with_delimiter @user_count
+          %span= t 'about.user_count_after'
+        .section
+          %span= t 'about.status_count_before'
+          %strong= number_with_delimiter @status_count
+          %span= t 'about.status_count_after'
+        .section
+          %span= t 'about.domain_count_before'
+          %strong= number_with_delimiter @domain_count
+          %span= t 'about.domain_count_after'
+
+      - unless @extended_description.blank?
+        .panel= @extended_description.html_safe
+
+    .sidebar
+      .panel
+        .panel-header= t 'about.contact'
+        .panel-body
+          - if @contact_account
+            .owner
+              .avatar= image_tag @contact_account.avatar.url
+              .name
+                = link_to TagManager.instance.url_for(@contact_account) do
+                  %span.display_name.emojify= display_name(@contact_account)
+                  %span.username= "@#{@contact_account.acct}"
+
+          - unless @contact_email.blank?
+            .contact-email
+              = t 'about.business_email'
+              %strong= @contact_email
+      .panel
+        .panel-header= t 'about.links'
+        .panel-list
+          %ul
+            - if user_signed_in?
+              %li= link_to t('about.get_started'), root_path
+            - else
+              %li= link_to t('about.get_started'), new_user_registration_path
+              %li= link_to t('auth.login'), new_user_session_path
+            %li= link_to t('about.terms'), terms_path
+            %li= link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon'
diff --git a/app/views/accounts/_grid_card.html.haml b/app/views/accounts/_grid_card.html.haml
index dfdb23161..d5418fca5 100644
--- a/app/views/accounts/_grid_card.html.haml
+++ b/app/views/accounts/_grid_card.html.haml
@@ -3,6 +3,6 @@
     .avatar= image_tag account.avatar.url(:original)
     .name
       = link_to TagManager.instance.url_for(account) do
-        %span.display_name= display_name(account)
+        %span.display_name.emojify= display_name(account)
         %span.username= "@#{account.acct}"
-  %p.note= truncate(strip_tags(account.note), length: 150)
+  %p.note.emojify= truncate(strip_tags(account.note), length: 150)
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index 1c6b5f0f6..f575e855e 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -1,27 +1,27 @@
-.card{ style: "background-image: url(#{@account.header.url( :original)})" }
+.card.h-card.p-author{ style: "background-image: url(#{@account.header.url( :original)})" }
   - if user_signed_in? && current_account.id != @account.id && !current_account.requested?(@account)
     .controls
       - if current_account.following?(@account)
         = link_to t('accounts.unfollow'), unfollow_account_path(@account), data: { method: :post }, class: 'button'
       - else
         = link_to t('accounts.follow'), follow_account_path(@account), data: { method: :post }, class: 'button'
-  - else
+  - elsif !user_signed_in?
     .controls
       .remote-follow
         = link_to t('accounts.remote_follow'), account_remote_follow_path(@account), class: 'button'
-  .avatar= image_tag @account.avatar.url(:original)
+  .avatar= image_tag @account.avatar.url(:original), class: 'u-photo'
   %h1.name
-    = display_name(@account)
+    %span.p-name.emojify= display_name(@account)
     %small
-      = "@#{@account.username}"
+      %span.p-nickname= "@#{@account.username}"
       = fa_icon('lock') if @account.locked?
   .details
     .bio
-      .account__header__content= Formatter.instance.simplified_format(@account)
+      .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) do
+        = link_to account_url(@account), class: 'u-url u-uid' do
           %span.counter-label= t('accounts.posts')
           %span.counter-number= number_with_delimiter @account.statuses.count
       .counter{ class: active_nav_class(following_account_url(@account)) }
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index 7afeb68a9..c194ce33d 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -12,16 +12,20 @@
   %meta{ property: 'og:image', content: full_asset_url(@account.avatar.url(:original)) }/
   %meta{ property: 'og:image:width', content: '120' }/
   %meta{ property: 'og:image:height', content: '120' }/
+  %meta{ property: 'twitter:card', content: 'summary' }/
 
-= render partial: 'header'
+.h-feed
+  %data.p-name{ value: "#{@account.username} on #{Rails.configuration.x.local_domain}" }/
 
-- if @statuses.empty?
-  .accounts-grid
-    = render partial: 'nothing_here'
-- else
-  .activity-stream
-    = render partial: 'stream_entries/status', collection: @statuses, as: :status
+  = render partial: 'header'
 
-.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'
+  - if @statuses.empty?
+    .accounts-grid
+      = render partial: 'nothing_here'
+  - else
+    .activity-stream
+      = render partial: 'stream_entries/status', collection: @statuses, as: :status
+
+  .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'
diff --git a/app/views/admin/domain_blocks/index.html.haml b/app/views/admin/domain_blocks/index.html.haml
index aedf163f7..dbaeb4716 100644
--- a/app/views/admin/domain_blocks/index.html.haml
+++ b/app/views/admin/domain_blocks/index.html.haml
@@ -5,10 +5,12 @@
   %thead
     %tr
       %th Domain
+      %th Severity
   %tbody
     - @blocks.each do |block|
       %tr
         %td
           %samp= block.domain
+        %td= block.severity
 
 = will_paginate @blocks, pagination_options
diff --git a/app/views/admin/settings/index.html.haml b/app/views/admin/settings/index.html.haml
new file mode 100644
index 000000000..5b482213b
--- /dev/null
+++ b/app/views/admin/settings/index.html.haml
@@ -0,0 +1,36 @@
+- content_for :page_title do
+  Site Settings
+
+%table.table
+  %colgroup
+    %col{ width: '35%' }/
+  %thead
+    %tr
+      %th Setting
+      %th Click to edit
+  %tbody
+    %tr
+      %td{ rowspan: 2 }
+        %strong Contact information
+      %td= best_in_place @settings['site_contact_username'], :value, url: admin_setting_path(@settings['site_contact_username']), place_holder: 'Enter a username'
+    %tr
+      %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 description
+        %br/
+        Displayed as a paragraph on the frontpage and used as a meta tag.
+        %br/
+        You can use HTML tags, in particular
+        %code= '<a>'
+        and
+        %code= '<em>'
+      %td= best_in_place @settings['site_description'], :value, as: :textarea, url: admin_setting_path(@settings['site_description'])
+    %tr
+      %td
+        %strong Extended site description
+        %br/
+        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
diff --git a/app/views/api/v1/apps/show.rabl b/app/views/api/v1/apps/show.rabl
new file mode 100644
index 000000000..6d9e607db
--- /dev/null
+++ b/app/views/api/v1/apps/show.rabl
@@ -0,0 +1,3 @@
+object @application
+
+attributes :name, :website
diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl
index a3391a67e..7309a78b8 100644
--- a/app/views/api/v1/statuses/_show.rabl
+++ b/app/views/api/v1/statuses/_show.rabl
@@ -1,4 +1,4 @@
-attributes :id, :created_at, :in_reply_to_id, :sensitive, :visibility
+attributes :id, :created_at, :in_reply_to_id, :sensitive, :spoiler_text, :visibility
 
 node(:uri)              { |status| TagManager.instance.uri_for(status) }
 node(:content)          { |status| Formatter.instance.format(status) }
@@ -6,6 +6,10 @@ 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 }
 
+child :application do
+  extends 'api/v1/apps/show'
+end
+
 child :account do
   extends 'api/v1/accounts/show'
 end
diff --git a/app/views/api/v1/statuses/card.rabl b/app/views/api/v1/statuses/card.rabl
new file mode 100644
index 000000000..8ba8dcbb1
--- /dev/null
+++ b/app/views/api/v1/statuses/card.rabl
@@ -0,0 +1,5 @@
+object @card
+
+attributes :url, :title, :description
+
+node(:image) { |card| card.image? ? full_asset_url(card.image.url(:original)) : nil }
diff --git a/app/views/authorize_follow/_card.html.haml b/app/views/authorize_follow/_card.html.haml
index a9b02c746..eef0bec07 100644
--- a/app/views/authorize_follow/_card.html.haml
+++ b/app/views/authorize_follow/_card.html.haml
@@ -4,8 +4,8 @@
       = image_tag account.avatar.url(:original), alt: '', width: 48, height: 48, class: 'avatar'
 
     %span.display-name
-      %strong= display_name(account)
+      %strong.emojify= display_name(account)
       %span= "@#{account.acct}"
 
   - unless account.note.blank?
-    .account__header__content= Formatter.instance.simplified_format(account)
+    .account__header__content.emojify= Formatter.instance.simplified_format(account)
diff --git a/app/views/errors/404.html.haml b/app/views/errors/404.html.haml
new file mode 100644
index 000000000..ba1d5f72d
--- /dev/null
+++ b/app/views/errors/404.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+  The page you were looking for doesn't exist
+
+- content_for :content do
+  The page you were looking for doesn't exist
diff --git a/app/views/errors/410.html.haml b/app/views/errors/410.html.haml
new file mode 100644
index 000000000..07cf3742f
--- /dev/null
+++ b/app/views/errors/410.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+  The page you were looking for doesn't exist anymore
+
+- content_for :content do
+  The page you were looking for doesn't exist anymore
diff --git a/app/views/errors/422.html.haml b/app/views/errors/422.html.haml
new file mode 100644
index 000000000..e369cded6
--- /dev/null
+++ b/app/views/errors/422.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+  Security verification failed
+
+- content_for :content do
+  Security verification failed. Are you blocking cookies?
diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml
index 498fae105..0147f4064 100644
--- a/app/views/home/index.html.haml
+++ b/app/views/home/index.html.haml
@@ -1,4 +1,7 @@
 - content_for :header_tags do
+  :javascript
+    window.INITIAL_STATE = #{json_escape(render(file: 'home/initial_state', formats: :json))}
+
   = javascript_include_tag 'application'
 
 = react_component 'Mastodon', default_props, class: 'app-holder', prerender: false
diff --git a/app/views/home/initial_state.json.rabl b/app/views/home/initial_state.json.rabl
new file mode 100644
index 000000000..0e9736f5f
--- /dev/null
+++ b/app/views/home/initial_state.json.rabl
@@ -0,0 +1,24 @@
+object false
+
+node(:meta) {
+  {
+    access_token: @token,
+    locale: I18n.locale,
+    me: current_account.id,
+  }
+}
+
+node(:compose) {
+  {
+    me: current_account.id,
+    private: current_account.locked?,
+  }
+}
+
+node(:accounts) {
+  {
+    current_account.id => partial('api/v1/accounts/show', object: current_account),
+  }
+}
+
+node(:settings) { @web_settings }
diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml
new file mode 100644
index 000000000..54563f7d8
--- /dev/null
+++ b/app/views/layouts/error.html.haml
@@ -0,0 +1,36 @@
+!!!
+%html{:lang => "en"}
+  %head
+    %meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/
+    %meta{:charset => "utf-8"}/
+    %title= yield :page_title
+    %meta{:content => "width=device-width,initial-scale=1", :name => "viewport"}/
+    %link{:href => "https://fonts.googleapis.com/css?family=Roboto:400", :rel => "stylesheet"}/
+    :css
+      body {
+        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+        background: #282c37;
+        color: #9baec8;
+        text-align: center;
+        margin: 0;
+        padding: 20px;
+      }
+
+      .dialog img {
+        display: block;
+        margin: 20px auto;
+        margin-top: 50px;
+        max-width: 600px;
+        width: 100%;
+        height: auto;
+      }
+
+      .dialog h1 {
+        font: 20px/28px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+        font-weight: 400;
+      }
+  %body
+    .dialog
+      %img{:alt => "Mastodon", :src => "/oops.png"}/
+      %div
+        %h1= yield :content
diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml
index e6de7d017..808fb0a0e 100644
--- a/app/views/layouts/public.html.haml
+++ b/app/views/layouts/public.html.haml
@@ -6,6 +6,6 @@
   .footer
     %span.domain= link_to Rails.configuration.x.local_domain, root_path
     %span.powered-by
-      = t('generic.powered_by', link: link_to('Mastodon', 'https://github.com/Gargron/mastodon')).html_safe
+      = t('generic.powered_by', link: link_to('Mastodon', 'https://github.com/tootsuite/mastodon')).html_safe
 
 = render template: "layouts/application"
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index a0860c94b..a9a1d21ac 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -6,14 +6,14 @@
 
   = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }
 
-  = f.simple_fields_for :notification_emails, current_user.settings(:notification_emails) do |ff|
+  = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff|
     = ff.input :follow, as: :boolean, wrapper: :with_label
     = ff.input :follow_request, as: :boolean, wrapper: :with_label
     = ff.input :reblog, as: :boolean, wrapper: :with_label
     = ff.input :favourite, as: :boolean, wrapper: :with_label
     = ff.input :mention, as: :boolean, wrapper: :with_label
 
-  = f.simple_fields_for :interactions, current_user.settings(:interactions) do |ff|
+  = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff|
     = ff.input :must_be_follower, as: :boolean, wrapper: :with_label
     = ff.input :must_be_following, as: :boolean, wrapper: :with_label
 
diff --git a/app/views/settings/shared/_links.html.haml b/app/views/settings/shared/_links.html.haml
index 44f097950..a6e90f457 100644
--- a/app/views/settings/shared/_links.html.haml
+++ b/app/views/settings/shared/_links.html.haml
@@ -5,3 +5,4 @@
     %li= link_to t('settings.preferences'), settings_preferences_path
   - if controller_name != 'registrations'
     %li= link_to t('auth.change_password'), edit_user_registration_path
+  %li= link_to t('settings.back'), root_path
\ No newline at end of file
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index 94451d3bd..6ee8c9e5b 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -1,36 +1,46 @@
 .detailed-status.light
-  = link_to TagManager.instance.url_for(status.account), class: 'detailed-status__display-name', target: @external_links ? '_blank' : nil, rel: 'noopener' do
+  = link_to TagManager.instance.url_for(status.account), class: 'detailed-status__display-name p-author h-card', target: @external_links ? '_blank' : nil, rel: 'noopener' do
     %div
       %div.avatar
-        = image_tag status.account.avatar.url(:original), width: 48, height: 48, alt: ''
+        = image_tag status.account.avatar.url(:original), width: 48, height: 48, alt: '', class: 'u-photo'
     %span.display-name
-      %strong= display_name(status.account)
-      %span= acct(status.account)
+      %strong.p-name.emojify= display_name(status.account)
+      %span.p-nickname= acct(status.account)
 
-  .status__content= Formatter.instance.format(status)
+  .status__content.e-content.p-name.emojify<
+    - unless status.spoiler_text.blank?
+      %p= status.spoiler_text
+    = Formatter.instance.format(status)
 
   - unless status.media_attachments.empty?
     - if status.media_attachments.first.video?
       .video-player
         - if status.sensitive?
           = render partial: 'stream_entries/content_spoiler'
-        %video{ src: status.media_attachments.first.file.url(:original), loop: true }
+        %video{ src: status.media_attachments.first.file.url(:original), loop: true, class: 'u-video' }
     - else
       .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'
+            = 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'}"
 
   %div.detailed-status__meta
-    = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: @external_links ? '_blank' : nil, rel: 'noopener' do
+    %data.dt-published{ value: status.created_at.to_time.iso8601 }
+    = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: @external_links ? '_blank' : nil, rel: 'noopener' do
       %span= l(status.created_at)
     ·
-    %span
+    - if status.application
+      - if status.application.website.blank?
+        %strong.detailed-status__application= status.application.name
+      - else
+        = link_to status.application.name, status.application.website, class: 'detailed-status__application', target: '_blank', rel: 'noopener'
+      ·
+    %span<
       = fa_icon('retweet')
       %span= status.reblogs.count
     ·
-    %span
+    %span<
       = fa_icon('star')
       %span= status.favourites.count
diff --git a/app/views/stream_entries/_favourite.html.haml b/app/views/stream_entries/_favourite.html.haml
index aac90dcdf..ea4879328 100644
--- a/app/views/stream_entries/_favourite.html.haml
+++ b/app/views/stream_entries/_favourite.html.haml
@@ -1,5 +1,5 @@
 .entry.entry-favourite
-  .content
+  .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
index 1a2e2c554..da6d062f0 100644
--- a/app/views/stream_entries/_follow.html.haml
+++ b/app/views/stream_entries/_follow.html.haml
@@ -1,5 +1,5 @@
 .entry.entry-follow
-  .content
+  .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/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index da3bc0ccb..95f90abd9 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -1,17 +1,21 @@
 .status.light
   .status__header
     .status__meta
-      = link_to time_ago_in_words(status.created_at), TagManager.instance.url_for(status), class: 'status__relative-time', title: l(status.created_at), target: @external_links ? '_blank' : nil, rel: 'noopener'
+      = link_to time_ago_in_words(status.created_at), TagManager.instance.url_for(status), class: 'status__relative-time u-url u-uid', title: l(status.created_at), target: @external_links ? '_blank' : nil, rel: 'noopener'
+      %data.dt-published{ value: status.created_at.to_time.iso8601 }
 
-    = link_to TagManager.instance.url_for(status.account), class: 'status__display-name', target: @external_links ? '_blank' : nil, rel: 'noopener' do
+    = link_to TagManager.instance.url_for(status.account), class: 'status__display-name p-author h-card', target: @external_links ? '_blank' : nil, rel: 'noopener' do
       .status__avatar
         %div
-          = image_tag status.account.avatar(:original), width: 48, height: 48, alt: ''
+          = image_tag status.account.avatar(:original), width: 48, height: 48, alt: '', class: 'u-photo'
       %span.display-name
-        %strong= display_name(status.account)
-        %span= acct(status.account)
+        %strong.p-name.emojify= display_name(status.account)
+        %span.p-nickname= acct(status.account)
 
-  .status__content= Formatter.instance.format(status)
+  .status__content.e-content.p-name.emojify<
+    - unless status.spoiler_text.blank?
+      %p= status.spoiler_text
+    = Formatter.instance.format(status)
 
   - unless status.media_attachments.empty?
     .status__attachments
@@ -19,10 +23,10 @@
         = 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' do
+          = 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'
+            = 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'}"
diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml
index 43935da60..6bad45705 100644
--- a/app/views/stream_entries/show.html.haml
+++ b/app/views/stream_entries/show.html.haml
@@ -5,7 +5,11 @@
   %meta{ property: 'og:site_name', content: 'Mastodon' }/
   %meta{ property: 'og:type', content: 'article' }/
   %meta{ property: 'og:title', content: "#{@account.username} on #{Rails.configuration.x.local_domain}" }/
-  %meta{ property: 'og:description', content: @stream_entry.activity.content }/
+
+  - if @stream_entry.activity.is_a?(Status) && !@stream_entry.activity.spoiler_text.blank?
+    %meta{ property: 'og:description', content: @stream_entry.activity.spoiler_text }/
+  - else
+    %meta{ property: 'og:description', content: @stream_entry.activity.content }/
 
   - if @stream_entry.activity.is_a?(Status) && @stream_entry.activity.media_attachments.size > 0
     %meta{ property: 'og:image', content: full_asset_url(@stream_entry.activity.media_attachments.first.file.url(:small)) }/
@@ -14,5 +18,7 @@
     %meta{ property: 'og:image:width', content: '120' }/
     %meta{ property: 'og:image:height', content: '120' }/
 
+  %meta{ property: 'twitter:card', content: 'summary' }/
+
 .activity-stream.activity-stream-headless
   = render partial: @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 dd42fe22c..412ec4fa5 100644
--- a/app/views/tags/show.html.haml
+++ b/app/views/tags/show.html.haml
@@ -2,7 +2,7 @@
   .accounts-grid
     = render partial: 'accounts/nothing_here'
 - else
-  .activity-stream
+  .activity-stream.h-feed
     = render partial: 'stream_entries/status', collection: @statuses, as: :status, cached: true
 
 .pagination
diff --git a/app/workers/block_worker.rb b/app/workers/block_worker.rb
new file mode 100644
index 000000000..0820490d3
--- /dev/null
+++ b/app/workers/block_worker.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class BlockWorker
+  include Sidekiq::Worker
+
+  def perform(account_id, target_account_id)
+    AfterBlockService.new.call(Account.find(account_id), Account.find(target_account_id))
+  end
+end
diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb
new file mode 100644
index 000000000..af3394b8b
--- /dev/null
+++ b/app/workers/link_crawl_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class LinkCrawlWorker
+  include Sidekiq::Worker
+
+  sidekiq_options retry: false
+
+  def perform(status_id)
+    FetchLinkCardService.new.call(Status.find(status_id))
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb
new file mode 100644
index 000000000..0f288f43f
--- /dev/null
+++ b/app/workers/merge_worker.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class MergeWorker
+  include Sidekiq::Worker
+
+  def perform(from_account_id, into_account_id)
+    FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id))
+  end
+end
diff --git a/app/workers/notification_worker.rb b/app/workers/notification_worker.rb
index 386e94111..e4c38d384 100644
--- a/app/workers/notification_worker.rb
+++ b/app/workers/notification_worker.rb
@@ -3,6 +3,8 @@
 class NotificationWorker
   include Sidekiq::Worker
 
+  sidekiq_options retry: 5
+
   def perform(stream_entry_id, target_account_id)
     SendInteractionService.new.call(StreamEntry.find(stream_entry_id), Account.find(target_account_id))
   end
diff --git a/app/workers/pubsubhubbub/confirmation_worker.rb b/app/workers/pubsubhubbub/confirmation_worker.rb
index 489bd8359..868fd9f97 100644
--- a/app/workers/pubsubhubbub/confirmation_worker.rb
+++ b/app/workers/pubsubhubbub/confirmation_worker.rb
@@ -4,7 +4,7 @@ class Pubsubhubbub::ConfirmationWorker
   include Sidekiq::Worker
   include RoutingHelper
 
-  sidekiq_options queue: 'push'
+  sidekiq_options queue: 'push', retry: false
 
   def perform(subscription_id, mode, secret = nil, lease_seconds = nil)
     subscription = Subscription.find(subscription_id)
diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb
index 35bf7b2f0..15005bc80 100644
--- a/app/workers/pubsubhubbub/delivery_worker.rb
+++ b/app/workers/pubsubhubbub/delivery_worker.rb
@@ -4,7 +4,11 @@ class Pubsubhubbub::DeliveryWorker
   include Sidekiq::Worker
   include RoutingHelper
 
-  sidekiq_options queue: 'push', retry: 5
+  sidekiq_options queue: 'push', retry: 3, dead: false
+
+  sidekiq_retry_in do |count|
+    5 * (count + 1)
+  end
 
   def perform(subscription_id, payload)
     subscription = Subscription.find(subscription_id)
diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb
index 84eae73be..593edd032 100644
--- a/app/workers/thread_resolve_worker.rb
+++ b/app/workers/thread_resolve_worker.rb
@@ -3,6 +3,8 @@
 class ThreadResolveWorker
   include Sidekiq::Worker
 
+  sidekiq_options retry: false
+
   def perform(child_status_id, parent_url)
     child_status  = Status.find(child_status_id)
     parent_status = FetchRemoteStatusService.new.call(parent_url)
diff --git a/app/workers/unfavourite_worker.rb b/app/workers/unfavourite_worker.rb
index a14c82d6f..cce07e486 100644
--- a/app/workers/unfavourite_worker.rb
+++ b/app/workers/unfavourite_worker.rb
@@ -5,5 +5,7 @@ class UnfavouriteWorker
 
   def perform(account_id, status_id)
     UnfavouriteService.new.call(Account.find(account_id), Status.find(status_id))
+  rescue ActiveRecord::RecordNotFound
+    true
   end
 end
diff --git a/app/workers/unmerge_worker.rb b/app/workers/unmerge_worker.rb
new file mode 100644
index 000000000..dbf7243de
--- /dev/null
+++ b/app/workers/unmerge_worker.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class UnmergeWorker
+  include Sidekiq::Worker
+
+  def perform(from_account_id, into_account_id)
+    FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id))
+  end
+end