about summary refs log tree commit diff
path: root/app/assets/javascripts
diff options
context:
space:
mode:
authorAnthony Bellew <anthonyreflected@gmail.com>2017-01-25 20:53:57 -0700
committerGitHub <noreply@github.com>2017-01-25 20:53:57 -0700
commit3d890c407356c8e0e7dd9b64e8e232ededcff8e8 (patch)
treea22df9a8737250f97a6024943af3445a163917b3 /app/assets/javascripts
parentfebe2449bb14f3d877fb934ceb6d52e320712bac (diff)
parent905c82917959a5afe24cb85c62c0b0ba13f0da8b (diff)
Merge pull request #3 from tootsuite/master
Updating to current
Diffstat (limited to 'app/assets/javascripts')
-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
92 files changed, 1886 insertions, 672 deletions
diff --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');