about summary refs log tree commit diff
path: root/app/assets/javascripts/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/components')
-rw-r--r--app/assets/javascripts/components/actions/accounts.jsx102
-rw-r--r--app/assets/javascripts/components/actions/cards.jsx3
-rw-r--r--app/assets/javascripts/components/actions/compose.jsx44
-rw-r--r--app/assets/javascripts/components/actions/modal.jsx25
-rw-r--r--app/assets/javascripts/components/actions/notifications.jsx10
-rw-r--r--app/assets/javascripts/components/actions/search.jsx66
-rw-r--r--app/assets/javascripts/components/actions/statuses.jsx11
-rw-r--r--app/assets/javascripts/components/actions/timelines.jsx86
-rw-r--r--app/assets/javascripts/components/components/autosuggest_textarea.jsx54
-rw-r--r--app/assets/javascripts/components/components/column_back_button.jsx3
-rw-r--r--app/assets/javascripts/components/components/column_collapsable.jsx7
-rw-r--r--app/assets/javascripts/components/components/display_name.jsx2
-rw-r--r--app/assets/javascripts/components/components/dropdown_menu.jsx40
-rw-r--r--app/assets/javascripts/components/components/extended_video_player.jsx21
-rw-r--r--app/assets/javascripts/components/components/icon_button.jsx7
-rw-r--r--app/assets/javascripts/components/components/lightbox.jsx82
-rw-r--r--app/assets/javascripts/components/components/media_gallery.jsx233
-rw-r--r--app/assets/javascripts/components/components/status.jsx2
-rw-r--r--app/assets/javascripts/components/components/status_action_bar.jsx18
-rw-r--r--app/assets/javascripts/components/components/status_content.jsx12
-rw-r--r--app/assets/javascripts/components/components/status_list.jsx47
-rw-r--r--app/assets/javascripts/components/components/video_player.jsx68
-rw-r--r--app/assets/javascripts/components/containers/account_container.jsx12
-rw-r--r--app/assets/javascripts/components/containers/mastodon.jsx15
-rw-r--r--app/assets/javascripts/components/containers/status_container.jsx64
-rw-r--r--app/assets/javascripts/components/emoji.jsx34
-rw-r--r--app/assets/javascripts/components/features/account/components/action_bar.jsx45
-rw-r--r--app/assets/javascripts/components/features/account/components/header.jsx59
-rw-r--r--app/assets/javascripts/components/features/account_timeline/components/header.jsx15
-rw-r--r--app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx12
-rw-r--r--app/assets/javascripts/components/features/account_timeline/index.jsx5
-rw-r--r--app/assets/javascripts/components/features/community_timeline/index.jsx95
-rw-r--r--app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx7
-rw-r--r--app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx15
-rw-r--r--app/assets/javascripts/components/features/compose/components/character_counter.jsx2
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx158
-rw-r--r--app/assets/javascripts/components/features/compose/components/drawer.jsx42
-rw-r--r--app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx58
-rw-r--r--app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx101
-rw-r--r--app/assets/javascripts/components/features/compose/components/reply_indicator.jsx17
-rw-r--r--app/assets/javascripts/components/features/compose/components/search.jsx110
-rw-r--r--app/assets/javascripts/components/features/compose/components/search_results.jsx68
-rw-r--r--app/assets/javascripts/components/features/compose/components/text_icon_button.jsx31
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_button.jsx7
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_form.jsx28
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_progress.jsx44
-rw-r--r--app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx15
-rw-r--r--app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx121
-rw-r--r--app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx17
-rw-r--r--app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx24
-rw-r--r--app/assets/javascripts/components/features/compose/containers/search_container.jsx20
-rw-r--r--app/assets/javascripts/components/features/compose/containers/search_results_container.jsx8
-rw-r--r--app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx49
-rw-r--r--app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx24
-rw-r--r--app/assets/javascripts/components/features/compose/containers/upload_progress_container.jsx9
-rw-r--r--app/assets/javascripts/components/features/compose/index.jsx65
-rw-r--r--app/assets/javascripts/components/features/getting_started/index.jsx11
-rw-r--r--app/assets/javascripts/components/features/hashtag_timeline/index.jsx11
-rw-r--r--app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx4
-rw-r--r--app/assets/javascripts/components/features/home_timeline/index.jsx19
-rw-r--r--app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx3
-rw-r--r--app/assets/javascripts/components/features/notifications/components/notification.jsx2
-rw-r--r--app/assets/javascripts/components/features/notifications/index.jsx59
-rw-r--r--app/assets/javascripts/components/features/public_timeline/index.jsx46
-rw-r--r--app/assets/javascripts/components/features/status/components/action_bar.jsx11
-rw-r--r--app/assets/javascripts/components/features/status/components/detailed_status.jsx2
-rw-r--r--app/assets/javascripts/components/features/status/index.jsx46
-rw-r--r--app/assets/javascripts/components/features/ui/components/column.jsx7
-rw-r--r--app/assets/javascripts/components/features/ui/components/column_header.jsx7
-rw-r--r--app/assets/javascripts/components/features/ui/components/media_modal.jsx133
-rw-r--r--app/assets/javascripts/components/features/ui/components/modal_root.jsx80
-rw-r--r--app/assets/javascripts/components/features/ui/components/tabs_bar.jsx28
-rw-r--r--app/assets/javascripts/components/features/ui/components/upload_area.jsx32
-rw-r--r--app/assets/javascripts/components/features/ui/containers/modal_container.jsx163
-rw-r--r--app/assets/javascripts/components/features/ui/containers/status_list_container.jsx24
-rw-r--r--app/assets/javascripts/components/features/ui/index.jsx71
-rw-r--r--app/assets/javascripts/components/is_mobile.jsx6
-rw-r--r--app/assets/javascripts/components/locales/de.jsx2
-rw-r--r--app/assets/javascripts/components/locales/en.jsx24
-rw-r--r--app/assets/javascripts/components/locales/fr.jsx6
-rw-r--r--app/assets/javascripts/components/middleware/errors.jsx2
-rw-r--r--app/assets/javascripts/components/middleware/sounds.jsx22
-rw-r--r--app/assets/javascripts/components/reducers/accounts.jsx4
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx65
-rw-r--r--app/assets/javascripts/components/reducers/modal.jsx30
-rw-r--r--app/assets/javascripts/components/reducers/notifications.jsx24
-rw-r--r--app/assets/javascripts/components/reducers/relationships.jsx22
-rw-r--r--app/assets/javascripts/components/reducers/search.jsx94
-rw-r--r--app/assets/javascripts/components/reducers/statuses.jsx11
-rw-r--r--app/assets/javascripts/components/reducers/timelines.jsx65
-rw-r--r--app/assets/javascripts/components/rtl.jsx27
-rw-r--r--app/assets/javascripts/components/selectors/index.jsx57
-rw-r--r--app/assets/javascripts/components/store/configureStore.jsx11
93 files changed, 2404 insertions, 1166 deletions
diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx
index 0be05034e..37ebb9969 100644
--- a/app/assets/javascripts/components/actions/accounts.jsx
+++ b/app/assets/javascripts/components/actions/accounts.jsx
@@ -21,6 +21,14 @@ export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
 export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
 export const ACCOUNT_UNBLOCK_FAIL    = 'ACCOUNT_UNBLOCK_FAIL';
 
+export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
+export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
+export const ACCOUNT_MUTE_FAIL    = 'ACCOUNT_MUTE_FAIL';
+
+export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
+export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
+export const ACCOUNT_UNMUTE_FAIL    = 'ACCOUNT_UNMUTE_FAIL';
+
 export const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST';
 export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS';
 export const ACCOUNT_TIMELINE_FETCH_FAIL    = 'ACCOUNT_TIMELINE_FETCH_FAIL';
@@ -67,11 +75,16 @@ export const FOLLOW_REQUEST_REJECT_FAIL    = 'FOLLOW_REQUEST_REJECT_FAIL';
 
 export function fetchAccount(id) {
   return (dispatch, getState) => {
+    dispatch(fetchRelationships([id]));
+
+    if (getState().getIn(['accounts', id], null) !== null) {
+      return;
+    }
+
     dispatch(fetchAccountRequest(id));
 
     api(getState).get(`/api/v1/accounts/${id}`).then(response => {
       dispatch(fetchAccountSuccess(response.data));
-      dispatch(fetchRelationships([id]));
     }).catch(error => {
       dispatch(fetchAccountFail(id, error));
     });
@@ -138,7 +151,8 @@ export function fetchAccountFail(id, error) {
   return {
     type: ACCOUNT_FETCH_FAIL,
     id,
-    error
+    error,
+    skipAlert: true
   };
 };
 
@@ -231,7 +245,8 @@ export function fetchAccountTimelineFail(id, error, skipLoading) {
     type: ACCOUNT_TIMELINE_FETCH_FAIL,
     id,
     error,
-    skipLoading
+    skipLoading,
+    skipAlert: error.response.status === 404
   };
 };
 
@@ -326,6 +341,76 @@ export function unblockAccountFail(error) {
   };
 };
 
+
+export function muteAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(muteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => {
+      // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+      dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
+    }).catch(error => {
+      dispatch(muteAccountFail(id, error));
+    });
+  };
+};
+
+export function unmuteAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unmuteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
+      dispatch(unmuteAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(unmuteAccountFail(id, error));
+    });
+  };
+};
+
+export function muteAccountRequest(id) {
+  return {
+    type: ACCOUNT_MUTE_REQUEST,
+    id
+  };
+};
+
+export function muteAccountSuccess(relationship, statuses) {
+  return {
+    type: ACCOUNT_MUTE_SUCCESS,
+    relationship,
+    statuses
+  };
+};
+
+export function muteAccountFail(error) {
+  return {
+    type: ACCOUNT_MUTE_FAIL,
+    error
+  };
+};
+
+export function unmuteAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNMUTE_REQUEST,
+    id
+  };
+};
+
+export function unmuteAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_UNMUTE_SUCCESS,
+    relationship
+  };
+};
+
+export function unmuteAccountFail(error) {
+  return {
+    type: ACCOUNT_UNMUTE_FAIL,
+    error
+  };
+};
+
+
 export function fetchFollowers(id) {
   return (dispatch, getState) => {
     dispatch(fetchFollowersRequest(id));
@@ -494,15 +579,18 @@ export function expandFollowingFail(id, error) {
   };
 };
 
-export function fetchRelationships(account_ids) {
+export function fetchRelationships(accountIds) {
   return (dispatch, getState) => {
-    if (account_ids.length === 0) {
+    const loadedRelationships = getState().get('relationships');
+    const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null);
+
+    if (newAccountIds.length === 0) {
       return;
     }
 
-    dispatch(fetchRelationshipsRequest(account_ids));
+    dispatch(fetchRelationshipsRequest(newAccountIds));
 
-    api(getState).get(`/api/v1/accounts/relationships?${account_ids.map(id => `id[]=${id}`).join('&')}`).then(response => {
+    api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
       dispatch(fetchRelationshipsSuccess(response.data));
     }).catch(error => {
       dispatch(fetchRelationshipsFail(error));
diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx
index cc7baf376..d4c1eda60 100644
--- a/app/assets/javascripts/components/actions/cards.jsx
+++ b/app/assets/javascripts/components/actions/cards.jsx
@@ -46,6 +46,7 @@ export function fetchStatusCardFail(id, error) {
     type: STATUS_CARD_FETCH_FAIL,
     id,
     error,
-    skipLoading: true
+    skipLoading: true,
+    skipAlert: true
   };
 };
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx
index 03aae885e..1b3cc60dc 100644
--- a/app/assets/javascripts/components/actions/compose.jsx
+++ b/app/assets/javascripts/components/actions/compose.jsx
@@ -28,6 +28,8 @@ export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
 export const COMPOSE_VISIBILITY_CHANGE  = 'COMPOSE_VISIBILITY_CHANGE';
 export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
 
+export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
+
 export function changeCompose(text) {
   return {
     type: COMPOSE_CHANGE,
@@ -77,7 +79,7 @@ export function submitCompose() {
       media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
       sensitive: getState().getIn(['compose', 'sensitive']),
       spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
-      visibility: getState().getIn(['compose', 'private']) ? 'private' : (getState().getIn(['compose', 'unlisted']) ? 'unlisted' : 'public')
+      visibility: getState().getIn(['compose', 'privacy'])
     }).then(function (response) {
       dispatch(submitComposeSuccess({ ...response.data }));
 
@@ -85,7 +87,13 @@ export function submitCompose() {
       dispatch(updateTimeline('home', { ...response.data }));
 
       if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
-        dispatch(updateTimeline('public', { ...response.data }));
+        if (getState().getIn(['timelines', 'community', 'loaded'])) {
+          dispatch(updateTimeline('community', { ...response.data }));
+        }
+
+        if (getState().getIn(['timelines', 'public', 'loaded'])) {
+          dispatch(updateTimeline('public', { ...response.data }));
+        }
       }
     }).catch(function (error) {
       dispatch(submitComposeFail(error));
@@ -115,6 +123,10 @@ export function submitComposeFail(error) {
 
 export function uploadCompose(files) {
   return function (dispatch, getState) {
+    if (getState().getIn(['compose', 'media_attachments']).size > 3) {
+      return;
+    }
+
     dispatch(uploadComposeRequest());
 
     let data = new FormData();
@@ -134,7 +146,8 @@ export function uploadCompose(files) {
 
 export function uploadComposeRequest() {
   return {
-    type: COMPOSE_UPLOAD_REQUEST
+    type: COMPOSE_UPLOAD_REQUEST,
+    skipLoading: true
   };
 };
 
@@ -149,14 +162,16 @@ export function uploadComposeProgress(loaded, total) {
 export function uploadComposeSuccess(media) {
   return {
     type: COMPOSE_UPLOAD_SUCCESS,
-    media: media
+    media: media,
+    skipLoading: true
   };
 };
 
 export function uploadComposeFail(error) {
   return {
     type: COMPOSE_UPLOAD_FAIL,
-    error: error
+    error: error,
+    skipLoading: true
   };
 };
 
@@ -220,17 +235,15 @@ export function unmountCompose() {
   };
 };
 
-export function changeComposeSensitivity(checked) {
+export function changeComposeSensitivity() {
   return {
     type: COMPOSE_SENSITIVITY_CHANGE,
-    checked
   };
 };
 
-export function changeComposeSpoilerness(checked) {
+export function changeComposeSpoilerness() {
   return {
-    type: COMPOSE_SPOILERNESS_CHANGE,
-    checked
+    type: COMPOSE_SPOILERNESS_CHANGE
   };
 };
 
@@ -241,16 +254,17 @@ export function changeComposeSpoilerText(text) {
   };
 };
 
-export function changeComposeVisibility(checked) {
+export function changeComposeVisibility(value) {
   return {
     type: COMPOSE_VISIBILITY_CHANGE,
-    checked
+    value
   };
 };
 
-export function changeComposeListability(checked) {
+export function insertEmojiCompose(position, emoji) {
   return {
-    type: COMPOSE_LISTABILITY_CHANGE,
-    checked
+    type: COMPOSE_EMOJI_INSERT,
+    position,
+    emoji
   };
 };
diff --git a/app/assets/javascripts/components/actions/modal.jsx b/app/assets/javascripts/components/actions/modal.jsx
index d19218c48..615cd6bfe 100644
--- a/app/assets/javascripts/components/actions/modal.jsx
+++ b/app/assets/javascripts/components/actions/modal.jsx
@@ -1,14 +1,11 @@
-export const MEDIA_OPEN  = 'MEDIA_OPEN';
+export const MODAL_OPEN  = 'MODAL_OPEN';
 export const MODAL_CLOSE = 'MODAL_CLOSE';
 
-export const MODAL_INDEX_DECREASE = 'MODAL_INDEX_DECREASE';
-export const MODAL_INDEX_INCREASE = 'MODAL_INDEX_INCREASE';
-
-export function openMedia(media, index) {
+export function openModal(type, props) {
   return {
-    type: MEDIA_OPEN,
-    media,
-    index
+    type: MODAL_OPEN,
+    modalType: type,
+    modalProps: props
   };
 };
 
@@ -17,15 +14,3 @@ export function closeModal() {
     type: MODAL_CLOSE
   };
 };
-
-export function decreaseIndexInModal() {
-  return {
-    type: MODAL_INDEX_DECREASE
-  };
-};
-
-export function increaseIndexInModal() {
-  return {
-    type: MODAL_INDEX_INCREASE
-  };
-};
diff --git a/app/assets/javascripts/components/actions/notifications.jsx b/app/assets/javascripts/components/actions/notifications.jsx
index df82e73fc..980b7d63e 100644
--- a/app/assets/javascripts/components/actions/notifications.jsx
+++ b/app/assets/javascripts/components/actions/notifications.jsx
@@ -14,7 +14,8 @@ export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
 export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
 export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL';
 
-export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
+export const NOTIFICATIONS_CLEAR      = 'NOTIFICATIONS_CLEAR';
+export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
 
 const fetchRelatedRelationships = (dispatch, notifications) => {
   const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
@@ -151,3 +152,10 @@ export function clearNotifications() {
     api(getState).post('/api/v1/notifications/clear');
   };
 };
+
+export function scrollTopNotifications(top) {
+  return {
+    type: NOTIFICATIONS_SCROLL_TOP,
+    top
+  };
+};
diff --git a/app/assets/javascripts/components/actions/search.jsx b/app/assets/javascripts/components/actions/search.jsx
index ceb0e4a0c..df3ae0db1 100644
--- a/app/assets/javascripts/components/actions/search.jsx
+++ b/app/assets/javascripts/components/actions/search.jsx
@@ -1,9 +1,12 @@
 import api from '../api'
 
-export const SEARCH_CHANGE            = 'SEARCH_CHANGE';
-export const SEARCH_SUGGESTIONS_CLEAR = 'SEARCH_SUGGESTIONS_CLEAR';
-export const SEARCH_SUGGESTIONS_READY = 'SEARCH_SUGGESTIONS_READY';
-export const SEARCH_RESET             = 'SEARCH_RESET';
+export const SEARCH_CHANGE = 'SEARCH_CHANGE';
+export const SEARCH_CLEAR  = 'SEARCH_CLEAR';
+export const SEARCH_SHOW   = 'SEARCH_SHOW';
+
+export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
+export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
+export const SEARCH_FETCH_FAIL    = 'SEARCH_FETCH_FAIL';
 
 export function changeSearch(value) {
   return {
@@ -12,40 +15,59 @@ export function changeSearch(value) {
   };
 };
 
-export function clearSearchSuggestions() {
-  return {
-    type: SEARCH_SUGGESTIONS_CLEAR
-  };
-};
-
-export function readySearchSuggestions(value, accounts) {
+export function clearSearch() {
   return {
-    type: SEARCH_SUGGESTIONS_READY,
-    value,
-    accounts
+    type: SEARCH_CLEAR
   };
 };
 
-export function fetchSearchSuggestions(value) {
+export function submitSearch() {
   return (dispatch, getState) => {
-    if (getState().getIn(['search', 'loaded_value']) === value) {
+    const value = getState().getIn(['search', 'value']);
+
+    if (value.length === 0) {
       return;
     }
 
-    api(getState).get('/api/v1/accounts/search', {
+    dispatch(fetchSearchRequest());
+
+    api(getState).get('/api/v1/search', {
       params: {
         q: value,
-        resolve: true,
-        limit: 4
+        resolve: true
       }
     }).then(response => {
-      dispatch(readySearchSuggestions(value, response.data));
+      dispatch(fetchSearchSuccess(response.data));
+    }).catch(error => {
+      dispatch(fetchSearchFail(error));
     });
   };
 };
 
-export function resetSearch() {
+export function fetchSearchRequest() {
+  return {
+    type: SEARCH_FETCH_REQUEST
+  };
+};
+
+export function fetchSearchSuccess(results) {
+  return {
+    type: SEARCH_FETCH_SUCCESS,
+    results,
+    accounts: results.accounts,
+    statuses: results.statuses
+  };
+};
+
+export function fetchSearchFail(error) {
+  return {
+    type: SEARCH_FETCH_FAIL,
+    error
+  };
+};
+
+export function showSearch() {
   return {
-    type: SEARCH_RESET
+    type: SEARCH_SHOW
   };
 };
diff --git a/app/assets/javascripts/components/actions/statuses.jsx b/app/assets/javascripts/components/actions/statuses.jsx
index ee662fe79..19df2c36c 100644
--- a/app/assets/javascripts/components/actions/statuses.jsx
+++ b/app/assets/javascripts/components/actions/statuses.jsx
@@ -57,7 +57,8 @@ export function fetchStatusFail(id, error, skipLoading) {
     type: STATUS_FETCH_FAIL,
     id,
     error,
-    skipLoading
+    skipLoading,
+    skipAlert: true
   };
 };
 
@@ -102,7 +103,12 @@ export function fetchContext(id) {
 
     api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
       dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
+
     }).catch(error => {
+      if (error.response.status === 404) {
+        dispatch(deleteFromTimelines(id));
+      }
+
       dispatch(fetchContextFail(id, error));
     });
   };
@@ -129,6 +135,7 @@ export function fetchContextFail(id, error) {
   return {
     type: CONTEXT_FETCH_FAIL,
     id,
-    error
+    error,
+    skipAlert: true
   };
 };
diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx
index 1531b89a3..6cd1f04b3 100644
--- a/app/assets/javascripts/components/actions/timelines.jsx
+++ b/app/assets/javascripts/components/actions/timelines.jsx
@@ -1,4 +1,4 @@
-import api from '../api'
+import api, { getLinks } from '../api'
 import Immutable from 'immutable';
 
 export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE';
@@ -14,12 +14,16 @@ export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL';
 
 export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 
-export function refreshTimelineSuccess(timeline, statuses, skipLoading) {
+export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT';
+export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
+
+export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
   return {
     type: TIMELINE_REFRESH_SUCCESS,
     timeline,
     statuses,
-    skipLoading
+    skipLoading,
+    next
   };
 };
 
@@ -69,25 +73,27 @@ export function refreshTimeline(timeline, id = null) {
 
     const ids      = getState().getIn(['timelines', timeline, 'items'], Immutable.List());
     const newestId = ids.size > 0 ? ids.first() : null;
+    let params     = getState().getIn(['timelines', timeline, 'params'], {});
+    const path     = getState().getIn(['timelines', timeline, 'path'])(id);
 
-    let params      = '';
-    let path        = timeline;
     let skipLoading = false;
 
     if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) {
-      params      = `?since_id=${newestId}`;
-      skipLoading = true;
-    }
+      if (id === null && getState().getIn(['timelines', timeline, 'online'])) {
+        // Skip refreshing when timeline is live anyway
+        return;
+      }
 
-    if (id) {
-      path = `${path}/${id}`
+      params          = { ...params, since_id: newestId };
+      skipLoading     = true;
     }
 
     dispatch(refreshTimelineRequest(timeline, id, skipLoading));
 
-    api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) {
-      dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading));
-    }).catch(function (error) {
+    api(getState).get(path, { params }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading, next ? next.uri : null));
+    }).catch(error => {
       dispatch(refreshTimelineFail(timeline, error, skipLoading));
     });
   };
@@ -102,50 +108,50 @@ export function refreshTimelineFail(timeline, error, skipLoading) {
   };
 };
 
-export function expandTimeline(timeline, id = null) {
+export function expandTimeline(timeline) {
   return (dispatch, getState) => {
-    const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last();
-
-    if (!lastId || getState().getIn(['timelines', timeline, 'isLoading'])) {
-      // If timeline is empty, don't try to load older posts since there are none
-      // Also if already loading
+    if (getState().getIn(['timelines', timeline, 'isLoading'])) {
       return;
     }
 
-    dispatch(expandTimelineRequest(timeline, id));
+    if (getState().getIn(['timelines', timeline, 'items']).size === 0) {
+      return;
+    }
 
-    let path = timeline;
+    const path   = getState().getIn(['timelines', timeline, 'path'])(getState().getIn(['timelines', timeline, 'id']));
+    const params = getState().getIn(['timelines', timeline, 'params'], {});
+    const lastId = getState().getIn(['timelines', timeline, 'items']).last();
 
-    if (id) {
-      path = `${path}/${id}`
-    }
+    dispatch(expandTimelineRequest(timeline));
 
-    api(getState).get(`/api/v1/timelines/${path}`, {
+    api(getState).get(path, {
       params: {
-        limit: 10,
-        max_id: lastId
+        ...params,
+        max_id: lastId,
+        limit: 10
       }
     }).then(response => {
-      dispatch(expandTimelineSuccess(timeline, response.data));
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandTimelineSuccess(timeline, response.data, next ? next.uri : null));
     }).catch(error => {
       dispatch(expandTimelineFail(timeline, error));
     });
   };
 };
 
-export function expandTimelineRequest(timeline, id) {
+export function expandTimelineRequest(timeline) {
   return {
     type: TIMELINE_EXPAND_REQUEST,
-    timeline,
-    id
+    timeline
   };
 };
 
-export function expandTimelineSuccess(timeline, statuses) {
+export function expandTimelineSuccess(timeline, statuses, next) {
   return {
     type: TIMELINE_EXPAND_SUCCESS,
     timeline,
-    statuses
+    statuses,
+    next
   };
 };
 
@@ -164,3 +170,17 @@ export function scrollTopTimeline(timeline, top) {
     top
   };
 };
+
+export function connectTimeline(timeline) {
+  return {
+    type: TIMELINE_CONNECT,
+    timeline
+  };
+};
+
+export function disconnectTimeline(timeline) {
+  return {
+    type: TIMELINE_DISCONNECT,
+    timeline
+  };
+};
diff --git a/app/assets/javascripts/components/components/autosuggest_textarea.jsx b/app/assets/javascripts/components/components/autosuggest_textarea.jsx
index 4e4c2090c..744424661 100644
--- a/app/assets/javascripts/components/components/autosuggest_textarea.jsx
+++ b/app/assets/javascripts/components/components/autosuggest_textarea.jsx
@@ -1,5 +1,6 @@
 import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
 import ImmutablePropTypes from 'react-immutable-proptypes';
+import { isRtl } from '../rtl';
 
 const textAtCursorMatchesToken = (str, caretPosition) => {
   let word;
@@ -32,20 +33,18 @@ const AutosuggestTextarea = React.createClass({
     value: React.PropTypes.string,
     suggestions: ImmutablePropTypes.list,
     disabled: React.PropTypes.bool,
-    fileDropDate: React.PropTypes.instanceOf(Date),
     placeholder: React.PropTypes.string,
     onSuggestionSelected: React.PropTypes.func.isRequired,
     onSuggestionsClearRequested: React.PropTypes.func.isRequired,
     onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
     onChange: React.PropTypes.func.isRequired,
     onKeyUp: React.PropTypes.func,
-    onKeyDown: React.PropTypes.func
+    onKeyDown: React.PropTypes.func,
+    onPaste: React.PropTypes.func.isRequired,
   },
 
   getInitialState () {
     return {
-      isFileDragging: false,
-      fileDraggingDate: undefined,
       suggestionsHidden: false,
       selectedSuggestion: 0,
       lastToken: null,
@@ -137,45 +136,28 @@ const AutosuggestTextarea = React.createClass({
     if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
       this.setState({ suggestionsHidden: false });
     }
-
-    const fileDropDate = nextProps.fileDropDate;
-    const { isFileDragging, fileDraggingDate } = this.state;
-
-    /*
-     * We can't detect drop events, because they might not be on the textarea (the app allows dropping anywhere in the
-     * window). Instead, on-drop, we notify this textarea to stop its hover effect by passing in a prop with the
-     * drop-date.
-     */
-    if (isFileDragging && fileDraggingDate && fileDropDate // if dragging when props updated, and dates aren't undefined
-      && fileDropDate > fileDraggingDate) { // and if the drop date is now greater than when we started dragging
-      // then we should stop dragging
-      this.setState({
-        isFileDragging: false
-      });
-    }
   },
 
   setTextarea (c) {
     this.textarea = c;
   },
 
-  onDragEnter () {
-    this.setState({
-      isFileDragging: true,
-      fileDraggingDate: new Date()
-    })
-  },
-
-  onDragExit () {
-    this.setState({
-      isFileDragging: false
-    })
+  onPaste (e) {
+    if (e.clipboardData && e.clipboardData.files.length === 1) {
+      this.props.onPaste(e.clipboardData.files)
+      e.preventDefault();
+    }
   },
 
   render () {
-    const { value, suggestions, fileDropDate, disabled, placeholder, onKeyUp } = this.props;
-    const { isFileDragging, suggestionsHidden, selectedSuggestion } = this.state;
-    const className = isFileDragging ? 'autosuggest-textarea__textarea file-drop' : 'autosuggest-textarea__textarea';
+    const { value, suggestions, disabled, placeholder, onKeyUp } = this.props;
+    const { suggestionsHidden, selectedSuggestion } = this.state;
+    const className = 'autosuggest-textarea__textarea';
+    const style     = { direction: 'ltr' };
+
+    if (isRtl(value)) {
+      style.direction = 'rtl';
+    }
 
     return (
       <div className='autosuggest-textarea'>
@@ -190,8 +172,8 @@ const AutosuggestTextarea = React.createClass({
           onKeyDown={this.onKeyDown}
           onKeyUp={onKeyUp}
           onBlur={this.onBlur}
-          onDragEnter={this.onDragEnter}
-          onDragExit={this.onDragExit}
+          onPaste={this.onPaste}
+          style={style}
         />
 
         <div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
diff --git a/app/assets/javascripts/components/components/column_back_button.jsx b/app/assets/javascripts/components/components/column_back_button.jsx
index 6abf11239..6b5ffee53 100644
--- a/app/assets/javascripts/components/components/column_back_button.jsx
+++ b/app/assets/javascripts/components/components/column_back_button.jsx
@@ -15,7 +15,8 @@ const ColumnBackButton = React.createClass({
   mixins: [PureRenderMixin],
 
   handleClick () {
-    this.context.router.goBack();
+    if (window.history && window.history.length == 1) this.context.router.push("/");
+    else this.context.router.goBack();
   },
 
   render () {
diff --git a/app/assets/javascripts/components/components/column_collapsable.jsx b/app/assets/javascripts/components/components/column_collapsable.jsx
index 676759055..44added8a 100644
--- a/app/assets/javascripts/components/components/column_collapsable.jsx
+++ b/app/assets/javascripts/components/components/column_collapsable.jsx
@@ -7,7 +7,8 @@ const iconStyle = {
   position: 'absolute',
   right: '0',
   top: '-48px',
-  cursor: 'pointer'
+  cursor: 'pointer',
+  zIndex: '3'
 };
 
 const ColumnCollapsable = React.createClass({
@@ -41,10 +42,10 @@ const ColumnCollapsable = React.createClass({
     const { icon, fullHeight, children } = this.props;
     const { collapsed } = this.state;
     const collapsedClassName = collapsed ? 'collapsable-collapsed' : 'collapsable';
-    
+
     return (
       <div style={{ position: 'relative' }}>
-        <div style={{...iconStyle }} className={collapsedClassName} onClick={this.handleToggleCollapsed}><i className={`fa fa-${icon}`} /></div>
+        <div style={{...iconStyle }} className={`column-icon ${collapsedClassName}`} onClick={this.handleToggleCollapsed}><i className={`fa fa-${icon}`} /></div>
 
         <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}>
           {({ opacity, height }) =>
diff --git a/app/assets/javascripts/components/components/display_name.jsx b/app/assets/javascripts/components/components/display_name.jsx
index 053b5290c..aa48608d3 100644
--- a/app/assets/javascripts/components/components/display_name.jsx
+++ b/app/assets/javascripts/components/components/display_name.jsx
@@ -1,6 +1,6 @@
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
+import escapeTextContentForBrowser from 'escape-html';
 import emojify from '../emoji';
 
 const DisplayName = React.createClass({
diff --git a/app/assets/javascripts/components/components/dropdown_menu.jsx b/app/assets/javascripts/components/components/dropdown_menu.jsx
index 0a8492b56..2b42eaa60 100644
--- a/app/assets/javascripts/components/components/dropdown_menu.jsx
+++ b/app/assets/javascripts/components/components/dropdown_menu.jsx
@@ -10,12 +10,44 @@ const DropdownMenu = React.createClass({
     direction: React.PropTypes.string
   },
 
+  getDefaultProps () {
+    return {
+      direction: 'left'
+    };
+  },
+
   mixins: [PureRenderMixin],
 
   setRef (c) {
     this.dropdown = c;
   },
 
+  handleClick (i, e) {
+    const { action } = this.props.items[i];
+
+    if (typeof action === 'function') {
+      e.preventDefault();
+      action();
+      this.dropdown.hide();
+    }
+  },
+
+  renderItem (item, i) {
+    if (item === null) {
+      return <li key={i} className='dropdown__sep' />;
+    }
+
+    const { text, action, href = '#' } = item;
+
+    return (
+      <li key={i}>
+        <a href={href} target='_blank' rel='noopener' onClick={this.handleClick.bind(this, i)}>
+          {text}
+        </a>
+      </li>
+    );
+  },
+
   render () {
     const { icon, items, size, direction } = this.props;
     const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right";
@@ -28,13 +60,7 @@ const DropdownMenu = React.createClass({
 
         <DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}>
           <ul>
-            {items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => {
-              if (typeof action === 'function') {
-                e.preventDefault();
-                action();
-                this.dropdown.hide();
-              }
-            }}>{text}</a></li>)}
+            {items.map(this.renderItem)}
           </ul>
         </DropdownContent>
       </Dropdown>
diff --git a/app/assets/javascripts/components/components/extended_video_player.jsx b/app/assets/javascripts/components/components/extended_video_player.jsx
new file mode 100644
index 000000000..66e5dee16
--- /dev/null
+++ b/app/assets/javascripts/components/components/extended_video_player.jsx
@@ -0,0 +1,21 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const ExtendedVideoPlayer = React.createClass({
+
+  propTypes: {
+    src: React.PropTypes.string.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    return (
+      <div>
+        <video src={this.props.src} autoPlay muted loop />
+      </div>
+    );
+  },
+
+});
+
+export default ExtendedVideoPlayer;
diff --git a/app/assets/javascripts/components/components/icon_button.jsx b/app/assets/javascripts/components/components/icon_button.jsx
index f9b6192c0..a08b1159b 100644
--- a/app/assets/javascripts/components/components/icon_button.jsx
+++ b/app/assets/javascripts/components/components/icon_button.jsx
@@ -12,6 +12,7 @@ const IconButton = React.createClass({
     style: React.PropTypes.object,
     activeStyle: React.PropTypes.object,
     disabled: React.PropTypes.bool,
+    inverted: React.PropTypes.bool,
     animate: React.PropTypes.bool
   },
 
@@ -36,10 +37,6 @@ const IconButton = React.createClass({
 
   render () {
     let style = {
-      display: 'inline-block',
-      border: 'none',
-      padding: '0',
-      background: 'transparent',
       fontSize: `${this.props.size}px`,
       width: `${this.props.size * 1.28571429}px`,
       height: `${this.props.size}px`,
@@ -57,7 +54,7 @@ const IconButton = React.createClass({
           <button
             aria-label={this.props.title}
             title={this.props.title}
-            className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`}
+            className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''} ${this.props.inverted ? 'inverted' : ''}`}
             onClick={this.handleClick}
             style={style}>
             <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
diff --git a/app/assets/javascripts/components/components/lightbox.jsx b/app/assets/javascripts/components/components/lightbox.jsx
deleted file mode 100644
index f04ca47ba..000000000
--- a/app/assets/javascripts/components/components/lightbox.jsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import PureRenderMixin from 'react-addons-pure-render-mixin';
-import IconButton from './icon_button';
-import { Motion, spring } from 'react-motion';
-import { injectIntl } from 'react-intl';
-
-const overlayStyle = {
-  position: 'fixed',
-  top: '0',
-  left: '0',
-  width: '100%',
-  height: '100%',
-  background: 'rgba(0, 0, 0, 0.5)',
-  display: 'flex',
-  justifyContent: 'center',
-  alignContent: 'center',
-  flexDirection: 'row',
-  zIndex: '9999'
-};
-
-const dialogStyle = {
-  color: '#282c37',
-  boxShadow: '0 0 30px rgba(0, 0, 0, 0.8)',
-  margin: 'auto',
-  position: 'relative'
-};
-
-const closeStyle = {
-  position: 'absolute',
-  top: '4px',
-  right: '4px'
-};
-
-const Lightbox = React.createClass({
-
-  propTypes: {
-    isVisible: React.PropTypes.bool,
-    onOverlayClicked: React.PropTypes.func,
-    onCloseClicked: React.PropTypes.func,
-    intl: React.PropTypes.object.isRequired,
-    children: React.PropTypes.node
-  },
-
-  mixins: [PureRenderMixin],
-
-  componentDidMount () {
-    this._listener = e => {
-      if (this.props.isVisible && e.key === 'Escape') {
-        this.props.onCloseClicked();
-      }
-    };
-
-    window.addEventListener('keyup', this._listener);
-  },
-
-  componentWillUnmount () {
-    window.removeEventListener('keyup', this._listener);
-  },
-
-  stopPropagation (e) {
-    e.stopPropagation();
-  },
-
-  render () {
-    const { intl, isVisible, onOverlayClicked, onCloseClicked, children } = this.props;
-
-    return (
-      <Motion defaultStyle={{ backgroundOpacity: 0, opacity: 0, y: -400 }} style={{ backgroundOpacity: spring(isVisible ? 50 : 0), opacity: isVisible ? spring(200) : 0, y: spring(isVisible ? 0 : -400, { stiffness: 150, damping: 12 }) }}>
-        {({ backgroundOpacity, opacity, y }) =>
-          <div className='lightbox' style={{...overlayStyle, background: `rgba(0, 0, 0, ${backgroundOpacity / 100})`, display: Math.floor(backgroundOpacity) === 0 ? 'none' : 'flex', pointerEvents: !isVisible ? 'none' : 'auto'}} onClick={onOverlayClicked}>
-            <div style={{...dialogStyle, transform: `translateY(${y}px)`, opacity: opacity / 100 }} onClick={this.stopPropagation}>
-              <IconButton title={intl.formatMessage({ id: 'lightbox.close', defaultMessage: 'Close' })} icon='times' onClick={onCloseClicked} size={16} style={closeStyle} />
-              {children}
-            </div>
-          </div>
-        }
-      </Motion>
-    );
-  }
-
-});
-
-export default injectIntl(Lightbox);
diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx
index b0e397e80..72b5e977f 100644
--- a/app/assets/javascripts/components/components/media_gallery.jsx
+++ b/app/assets/javascripts/components/components/media_gallery.jsx
@@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import IconButton from './icon_button';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { isIOS } from '../is_mobile';
 
 const messages = defineMessages({
   toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
@@ -43,6 +44,141 @@ const spoilerButtonStyle = {
   zIndex: '100'
 };
 
+const itemStyle = {
+  boxSizing: 'border-box',
+  position: 'relative',
+  float: 'left',
+  border: 'none',
+  display: 'block'
+};
+
+const thumbStyle = {
+  display: 'block',
+  width: '100%',
+  height: '100%',
+  textDecoration: 'none',
+  backgroundSize: 'cover',
+  cursor: 'zoom-in'
+};
+
+const gifvThumbStyle = {
+  position: 'relative',
+  zIndex: '1',
+  width: '100%',
+  height: '100%',
+  objectFit: 'cover',
+  top: '50%',
+  transform: 'translateY(-50%)',
+  cursor: 'zoom-in'
+};
+
+const Item = React.createClass({
+
+  propTypes: {
+    attachment: ImmutablePropTypes.map.isRequired,
+    index: React.PropTypes.number.isRequired,
+    size: React.PropTypes.number.isRequired,
+    onClick: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleClick (e) {
+    const { index, onClick } = this.props;
+
+    if (e.button === 0) {
+      e.preventDefault();
+      onClick(index);
+    }
+
+    e.stopPropagation();
+  },
+
+  render () {
+    const { attachment, index, size } = this.props;
+
+    let width  = 50;
+    let height = 100;
+    let top    = 'auto';
+    let left   = 'auto';
+    let bottom = 'auto';
+    let right  = 'auto';
+
+    if (size === 1) {
+      width = 100;
+    }
+
+    if (size === 4 || (size === 3 && index > 0)) {
+      height = 50;
+    }
+
+    if (size === 2) {
+      if (index === 0) {
+        right = '2px';
+      } else {
+        left = '2px';
+      }
+    } else if (size === 3) {
+      if (index === 0) {
+        right = '2px';
+      } else if (index > 0) {
+        left = '2px';
+      }
+
+      if (index === 1) {
+        bottom = '2px';
+      } else if (index > 1) {
+        top = '2px';
+      }
+    } else if (size === 4) {
+      if (index === 0 || index === 2) {
+        right = '2px';
+      }
+
+      if (index === 1 || index === 3) {
+        left = '2px';
+      }
+
+      if (index < 2) {
+        bottom = '2px';
+      } else {
+        top = '2px';
+      }
+    }
+
+    let thumbnail = '';
+
+    if (attachment.get('type') === 'image') {
+      thumbnail = (
+        <a
+          href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')}
+          onClick={this.handleClick}
+          target='_blank'
+          style={{ background: `url(${attachment.get('preview_url')}) no-repeat center`, ...thumbStyle }}
+        />
+      );
+    } else if (attachment.get('type') === 'gifv') {
+      thumbnail = (
+        <video
+          src={attachment.get('url')}
+          onClick={this.handleClick}
+          autoPlay={!isIOS()}
+          loop={true}
+          muted={true}
+          style={gifvThumbStyle}
+        />
+      );
+    }
+
+    return (
+      <div key={attachment.get('id')} style={{ ...itemStyle, left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+        {thumbnail}
+      </div>
+    );
+  }
+
+});
+
 const MediaGallery = React.createClass({
 
   getInitialState () {
@@ -61,17 +197,12 @@ const MediaGallery = React.createClass({
 
   mixins: [PureRenderMixin],
 
-  handleClick (index, e) {
-    if (e.button === 0) {
-      e.preventDefault();
-      this.props.onOpenMedia(this.props.media, index);
-    }
-
-    e.stopPropagation();
+  handleOpen (e) {
+    this.setState({ visible: !this.state.visible });
   },
 
-  handleOpen () {
-    this.setState({ visible: !this.state.visible });
+  handleClick (index) {
+    this.props.onOpenMedia(this.props.media, index);
   },
 
   render () {
@@ -80,87 +211,31 @@ const MediaGallery = React.createClass({
     let children;
 
     if (!this.state.visible) {
+      let warning;
+
       if (sensitive) {
-        children = (
-          <div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
-            <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
-            <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-          </div>
-        );
+        warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
       } else {
-        children = (
-          <div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
-            <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
-            <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-          </div>
-        );
+        warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
       }
+
+      children = (
+        <div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
+          <span style={spoilerSpanStyle}>{warning}</span>
+          <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+        </div>
+      );
     } else {
       const size = media.take(4).size;
-
-      children = media.take(4).map((attachment, i) => {
-        let width  = 50;
-        let height = 100;
-        let top    = 'auto';
-        let left   = 'auto';
-        let bottom = 'auto';
-        let right  = 'auto';
-
-        if (size === 1) {
-          width = 100;
-        }
-
-        if (size === 4 || (size === 3 && i > 0)) {
-          height = 50;
-        }
-
-        if (size === 2) {
-          if (i === 0) {
-            right = '2px';
-          } else {
-            left = '2px';
-          }
-        } else if (size === 3) {
-          if (i === 0) {
-            right = '2px';
-          } else if (i > 0) {
-            left = '2px';
-          }
-
-          if (i === 1) {
-            bottom = '2px';
-          } else if (i > 1) {
-            top = '2px';
-          }
-        } else if (size === 4) {
-          if (i === 0 || i === 2) {
-            right = '2px';
-          }
-
-          if (i === 1 || i === 3) {
-            left = '2px';
-          }
-
-          if (i < 2) {
-            bottom = '2px';
-          } else {
-            top = '2px';
-          }
-        }
-
-        return (
-          <div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}>
-            <a href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')} onClick={this.handleClick.bind(this, i)} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
-          </div>
-        );
-      });
+      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
     }
 
     return (
       <div style={{ ...outerStyle, height: `${this.props.height}px` }}>
-        <div style={spoilerButtonStyle} >
+        <div style={spoilerButtonStyle}>
           <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} />
         </div>
+
         {children}
       </div>
     );
diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx
index 66c41b5f7..110d26c6d 100644
--- a/app/assets/javascripts/components/components/status.jsx
+++ b/app/assets/javascripts/components/components/status.jsx
@@ -9,7 +9,7 @@ import StatusContent from './status_content';
 import StatusActionBar from './status_action_bar';
 import { FormattedMessage } from 'react-intl';
 import emojify from '../emoji';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
+import escapeTextContentForBrowser from 'escape-html';
 
 const Status = React.createClass({
 
diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx
index 35c458b5e..234cd396a 100644
--- a/app/assets/javascripts/components/components/status_action_bar.jsx
+++ b/app/assets/javascripts/components/components/status_action_bar.jsx
@@ -6,13 +6,13 @@ import { defineMessages, injectIntl } from 'react-intl';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
-  mention: { id: 'status.mention', defaultMessage: 'Mention' },
-  block: { id: 'account.block', defaultMessage: 'Block' },
+  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
   reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
-  open: { id: 'status.open', defaultMessage: 'Expand' },
-  report: { id: 'status.report', defaultMessage: 'Report' }
+  open: { id: 'status.open', defaultMessage: 'Expand this status' },
+  report: { id: 'status.report', defaultMessage: 'Report @{name}' }
 });
 
 const StatusActionBar = React.createClass({
@@ -74,19 +74,21 @@ const StatusActionBar = React.createClass({
     let menu = [];
 
     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+    menu.push(null);
 
     if (status.getIn(['account', 'id']) === me) {
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
-      menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
-      menu.push({ text: intl.formatMessage(messages.block), action: this.handleBlockClick });
-      menu.push({ text: intl.formatMessage(messages.report), action: this.handleReport });
+      menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
+      menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
     }
 
     return (
       <div style={{ marginTop: '10px', overflow: 'hidden' }}>
         <div style={{ float: 'left', marginRight: '18px'}}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
-        <div style={{ float: 'left', marginRight: '18px'}}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div>
+        <div style={{ float: 'left', marginRight: '18px'}}><IconButton disabled={status.get('visibility') === 'private' || status.get('visibility') === 'direct'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'direct' ? 'envelope' : (status.get('visibility') === 'private' ? 'lock' : 'retweet')} onClick={this.handleReblogClick} /></div>
         <div style={{ float: 'left', marginRight: '18px'}}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
 
         <div style={{ width: '18px', height: '18px', float: 'left' }}>
diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx
index c0397e81c..6c25afdea 100644
--- a/app/assets/javascripts/components/components/status_content.jsx
+++ b/app/assets/javascripts/components/components/status_content.jsx
@@ -1,7 +1,8 @@
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
+import escapeTextContentForBrowser from 'escape-html';
 import emojify from '../emoji';
+import { isRtl } from '../rtl';
 import { FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
 
@@ -92,6 +93,11 @@ const StatusContent = React.createClass({
 
     const content = { __html: emojify(status.get('content')) };
     const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
+    const directionStyle = { direction: 'ltr' };
+
+    if (isRtl(status.get('content'))) {
+      directionStyle.direction = 'rtl';
+    }
 
     if (status.get('spoiler_text').length > 0) {
       let mentionsPlaceholder = '';
@@ -116,14 +122,14 @@ const StatusContent = React.createClass({
 
           {mentionsPlaceholder}
 
-          <div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} />
+          <div style={{ display: hidden ? 'none' : 'block', ...directionStyle }} dangerouslySetInnerHTML={content} />
         </div>
       );
     } else {
       return (
         <div
           className='status__content'
-          style={{ cursor: 'pointer' }}
+          style={{ cursor: 'pointer', ...directionStyle }}
           onMouseDown={this.handleMouseDown}
           onMouseUp={this.handleMouseUp}
           dangerouslySetInnerHTML={content}
diff --git a/app/assets/javascripts/components/components/status_list.jsx b/app/assets/javascripts/components/components/status_list.jsx
index 0e64f0ee6..345944e4d 100644
--- a/app/assets/javascripts/components/components/status_list.jsx
+++ b/app/assets/javascripts/components/components/status_list.jsx
@@ -14,7 +14,10 @@ const StatusList = React.createClass({
     onScroll: React.PropTypes.func,
     trackScroll: React.PropTypes.bool,
     isLoading: React.PropTypes.bool,
-    prepend: React.PropTypes.node
+    isUnread: React.PropTypes.bool,
+    hasMore: React.PropTypes.bool,
+    prepend: React.PropTypes.node,
+    emptyMessage: React.PropTypes.node
   },
 
   getDefaultProps () {
@@ -71,27 +74,43 @@ const StatusList = React.createClass({
   },
 
   render () {
-    const { statusIds, onScrollToBottom, trackScroll, isLoading, prepend } = this.props;
+    const { statusIds, onScrollToBottom, trackScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
 
-    let loadMore = '';
+    let loadMore       = '';
+    let scrollableArea = '';
+    let unread         = '';
 
-    if (!isLoading && statusIds.size > 0) {
+    if (!isLoading && statusIds.size > 0 && hasMore) {
       loadMore = <LoadMore onClick={this.handleLoadMore} />;
     }
 
-    const scrollableArea = (
-      <div className='scrollable' ref={this.setRef}>
-        <div>
-          {prepend}
+    if (isUnread) {
+      unread = <div className='status-list__unread-indicator' />;
+    }
+
+    if (isLoading || statusIds.size > 0 || !emptyMessage) {
+      scrollableArea = (
+        <div className='scrollable' ref={this.setRef}>
+          {unread}
 
-          {statusIds.map((statusId) => {
-            return <StatusContainer key={statusId} id={statusId} />;
-          })}
+          <div>
+            {prepend}
 
-          {loadMore}
+            {statusIds.map((statusId) => {
+              return <StatusContainer key={statusId} id={statusId} />;
+            })}
+
+            {loadMore}
+          </div>
+        </div>
+      );
+    } else {
+      scrollableArea = (
+        <div className='empty-column-indicator' ref={this.setRef}>
+          {emptyMessage}
         </div>
-      </div>
-    );
+      );
+    }
 
     if (trackScroll) {
       return (
diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx
index ccd67ddf0..ab21ca9cd 100644
--- a/app/assets/javascripts/components/components/video_player.jsx
+++ b/app/assets/javascripts/components/components/video_player.jsx
@@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import IconButton from './icon_button';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { isIOS } from '../is_mobile';
 
 const messages = defineMessages({
   toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
@@ -22,6 +23,8 @@ const muteStyle = {
   position: 'absolute',
   top: '10px',
   right: '10px',
+  color: 'white',
+  textShadow: "0px 1px 1px black, 1px 0px 1px black",
   opacity: '0.8',
   zIndex: '5'
 };
@@ -53,6 +56,8 @@ const spoilerButtonStyle = {
   position: 'absolute',
   top: '6px',
   left: '8px',
+  color: 'white',
+  textShadow: "0px 1px 1px black, 1px 0px 1px black",
   zIndex: '100'
 };
 
@@ -61,12 +66,14 @@ const VideoPlayer = React.createClass({
     media: ImmutablePropTypes.map.isRequired,
     width: React.PropTypes.number,
     height: React.PropTypes.number,
-    sensitive: React.PropTypes.bool
+    sensitive: React.PropTypes.bool,
+    intl: React.PropTypes.object.isRequired,
+    autoplay: React.PropTypes.bool
   },
 
   getDefaultProps () {
     return {
-      width: 196,
+      width: 239,
       height: 110
     };
   },
@@ -75,7 +82,8 @@ const VideoPlayer = React.createClass({
     return {
       visible: !this.props.sensitive,
       preview: true,
-      muted: true
+      muted: true,
+      hasAudio: true
     };
   },
 
@@ -108,8 +116,42 @@ const VideoPlayer = React.createClass({
     });
   },
 
+  setRef (c) {
+    this.video = c;
+  },
+
+  handleLoadedData () {
+    if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
+      this.setState({ hasAudio: false });
+    }
+  },
+
+  componentDidMount () {
+    if (!this.video) {
+      return;
+    }
+
+    this.video.addEventListener('loadeddata', this.handleLoadedData);
+  },
+
+  componentDidUpdate () {
+    if (!this.video) {
+      return;
+    }
+
+    this.video.addEventListener('loadeddata', this.handleLoadedData);
+  },
+
+  componentWillUnmount () {
+    if (!this.video) {
+      return;
+    }
+
+    this.video.removeEventListener('loadeddata', this.handleLoadedData);
+  },
+
   render () {
-    const { media, intl, width, height, sensitive } = this.props;
+    const { media, intl, width, height, sensitive, autoplay } = this.props;
 
     let spoilerButton = (
       <div style={spoilerButtonStyle} >
@@ -117,6 +159,16 @@ const VideoPlayer = React.createClass({
       </div>
     );
 
+    let muteButton = '';
+
+    if (this.state.hasAudio) {
+      muteButton = (
+        <div style={muteStyle}>
+          <IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
+        </div>
+      );
+    }
+
     if (!this.state.visible) {
       if (sensitive) {
         return (
@@ -128,7 +180,7 @@ const VideoPlayer = React.createClass({
         );
       } else {
         return (
-          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleOpen}>
+          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
             {spoilerButton}
             <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
             <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
@@ -137,7 +189,7 @@ const VideoPlayer = React.createClass({
       }
     }
 
-    if (this.state.preview) {
+    if (this.state.preview && !autoplay) {
       return (
         <div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}>
           {spoilerButton}
@@ -149,8 +201,8 @@ const VideoPlayer = React.createClass({
     return (
       <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
         {spoilerButton}
-        <div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /></div>
-        <video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
+        {muteButton}
+        <video ref={this.setRef} src={media.get('url')} autoPlay={!isIOS()} loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/containers/account_container.jsx b/app/assets/javascripts/components/containers/account_container.jsx
index 889c0ac4c..3c30be715 100644
--- a/app/assets/javascripts/components/containers/account_container.jsx
+++ b/app/assets/javascripts/components/containers/account_container.jsx
@@ -5,7 +5,9 @@ import {
   followAccount,
   unfollowAccount,
   blockAccount,
-  unblockAccount
+  unblockAccount,
+  muteAccount,
+  unmuteAccount,
 } from '../actions/accounts';
 
 const makeMapStateToProps = () => {
@@ -34,6 +36,14 @@ const mapDispatchToProps = (dispatch) => ({
     } else {
       dispatch(blockAccount(account.get('id')));
     }
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(muteAccount(account.get('id')));
+    }
   }
 });
 
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index ebef5c81b..6dc08bb4c 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -4,7 +4,9 @@ import {
   refreshTimelineSuccess,
   updateTimeline,
   deleteFromTimelines,
-  refreshTimeline
+  refreshTimeline,
+  connectTimeline,
+  disconnectTimeline
 } from '../actions/timelines';
 import { updateNotifications, refreshNotifications } from '../actions/notifications';
 import createBrowserHistory from 'history/lib/createBrowserHistory';
@@ -21,6 +23,7 @@ import UI from '../features/ui';
 import Status from '../features/status';
 import GettingStarted from '../features/getting_started';
 import PublicTimeline from '../features/public_timeline';
+import CommunityTimeline from '../features/community_timeline';
 import AccountTimeline from '../features/account_timeline';
 import HomeTimeline from '../features/home_timeline';
 import Compose from '../features/compose';
@@ -69,6 +72,14 @@ const Mastodon = React.createClass({
 
     this.subscription = createStream(accessToken, 'user', {
 
+      connected () {
+        store.dispatch(connectTimeline('home'));
+      },
+
+      disconnected () {
+        store.dispatch(disconnectTimeline('home'));
+      },
+
       received (data) {
         switch(data.event) {
         case 'update':
@@ -84,6 +95,7 @@ const Mastodon = React.createClass({
       },
 
       reconnected () {
+        store.dispatch(connectTimeline('home'));
         store.dispatch(refreshTimeline('home'));
         store.dispatch(refreshNotifications());
       }
@@ -116,6 +128,7 @@ const Mastodon = React.createClass({
               <Route path='getting-started' component={GettingStarted} />
               <Route path='timelines/home' component={HomeTimeline} />
               <Route path='timelines/public' component={PublicTimeline} />
+              <Route path='timelines/public/local' component={CommunityTimeline} />
               <Route path='timelines/tag/:id' component={HashtagTimeline} />
 
               <Route path='notifications' component={Notifications} />
diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx
index fc096a375..fd3fbe4c3 100644
--- a/app/assets/javascripts/components/containers/status_container.jsx
+++ b/app/assets/javascripts/components/containers/status_container.jsx
@@ -11,52 +11,22 @@ import {
   unreblog,
   unfavourite
 } from '../actions/interactions';
-import { blockAccount } from '../actions/accounts';
+import {
+  blockAccount,
+  muteAccount
+} from '../actions/accounts';
 import { deleteStatus } from '../actions/statuses';
 import { initReport } from '../actions/reports';
-import { openMedia } from '../actions/modal';
+import { openModal } from '../actions/modal';
 import { createSelector } from 'reselect'
 import { isMobile } from '../is_mobile'
 
-const mapStateToProps = (state, props) => ({
-  statusBase: state.getIn(['statuses', props.id]),
-  me: state.getIn(['meta', 'me'])
-});
-
-const makeMapStateToPropsInner = () => {
-  const getStatus = (() => {
-    return createSelector(
-      [
-        (_, base)     => base,
-        (state, base) => (base ? state.getIn(['accounts', base.get('account')]) : null),
-        (state, base) => (base ? state.getIn(['statuses', base.get('reblog')], null) : null)
-      ],
-
-      (base, account, reblog) => (base ? base.set('account', account).set('reblog', reblog) : null)
-    );
-  })();
-
-  const mapStateToProps = (state, { statusBase }) => ({
-    status: getStatus(state, statusBase)
-  });
-
-  return mapStateToProps;
-};
-
-const makeMapStateToPropsLast = () => {
-  const getStatus = (() => {
-    return createSelector(
-      [
-        (_, status)     => status,
-        (state, status) => (status ? state.getIn(['accounts', status.getIn(['reblog', 'account'])], null) : null)
-      ],
-
-      (status, reblogAccount) => (status && status.get('reblog') ? status.setIn(['reblog', 'account'], reblogAccount) : status)
-    );
-  })();
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
 
-  const mapStateToProps = (state, { status }) => ({
-    status: getStatus(state, status)
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, props.id),
+    me: state.getIn(['meta', 'me'])
   });
 
   return mapStateToProps;
@@ -93,7 +63,7 @@ const mapDispatchToProps = (dispatch) => ({
   },
 
   onOpenMedia (media, index) {
-    dispatch(openMedia(media, index));
+    dispatch(openModal('MEDIA', { media, index }));
   },
 
   onBlock (account) {
@@ -102,12 +72,12 @@ const mapDispatchToProps = (dispatch) => ({
 
   onReport (status) {
     dispatch(initReport(status.get('account'), status));
-  }
+  },
+
+  onMute (account) {
+    dispatch(muteAccount(account.get('id')));
+  },
 
 });
 
-export default connect(mapStateToProps, mapDispatchToProps)(
-  connect(makeMapStateToPropsInner)(
-    connect(makeMapStateToPropsLast)(Status)
-  )
-);
+export default connect(makeMapStateToProps, mapDispatchToProps)(Status);
diff --git a/app/assets/javascripts/components/emoji.jsx b/app/assets/javascripts/components/emoji.jsx
index c93c07c74..eee657b86 100644
--- a/app/assets/javascripts/components/emoji.jsx
+++ b/app/assets/javascripts/components/emoji.jsx
@@ -1,9 +1,35 @@
 import emojione from 'emojione';
 
-emojione.imageType    = 'png';
-emojione.sprites      = false;
-emojione.imagePathPNG = '/emoji/';
+const toImage = str => shortnameToImage(unicodeToImage(str));
+
+const unicodeToImage = str => {
+  const mappedUnicode = emojione.mapUnicodeToShort();
+
+  return str.replace(emojione.regUnicode, unicodeChar => {
+    if (typeof unicodeChar === 'undefined' || unicodeChar === '' || !(unicodeChar in emojione.jsEscapeMap)) {
+      return unicodeChar;
+    }
+
+    const unicode  = emojione.jsEscapeMap[unicodeChar];
+    const short    = mappedUnicode[unicode];
+    const filename = emojione.emojioneList[short].fname;
+    const alt      = emojione.convert(unicode.toUpperCase());
+
+    return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${filename}.svg" />`;
+  });
+};
+
+const shortnameToImage = str => str.replace(emojione.regShortNames, shortname => {
+  if (typeof shortname === 'undefined' || shortname === '' || !(shortname in emojione.emojioneList)) {
+    return shortname;
+  }
+
+  const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1];
+  const alt     = emojione.convert(unicode.toUpperCase());
+
+  return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${unicode}.svg" />`;
+});
 
 export default function emojify(text) {
-  return emojione.toImage(text);
+  return toImage(text);
 };
diff --git a/app/assets/javascripts/components/features/account/components/action_bar.jsx b/app/assets/javascripts/components/features/account/components/action_bar.jsx
index a2ab8172b..80a32d3e2 100644
--- a/app/assets/javascripts/components/features/account/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx
@@ -5,14 +5,16 @@ import { Link } from 'react-router';
 import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
 
 const messages = defineMessages({
-  mention: { id: 'account.mention', defaultMessage: 'Mention' },
+  mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
   edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
-  unblock: { id: 'account.unblock', defaultMessage: 'Unblock' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
-  block: { id: 'account.block', defaultMessage: 'Block' },
+  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  block: { id: 'account.block', defaultMessage: 'Block' },
-  report: { id: 'account.report', defaultMessage: 'Report' }
+  report: { id: 'account.report', defaultMessage: 'Report @{name}' },
+  disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' }
 });
 
 const outerDropdownStyle = {
@@ -35,6 +37,7 @@ const ActionBar = React.createClass({
     onBlock: React.PropTypes.func.isRequired,
     onMention: React.PropTypes.func.isRequired,
     onReport: React.PropTypes.func.isRequired,
+    onMute: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired
   },
 
@@ -44,21 +47,31 @@ const ActionBar = React.createClass({
     const { account, me, intl } = this.props;
 
     let menu = [];
+    let extraInfo = '';
 
-    menu.push({ text: intl.formatMessage(messages.mention), action: this.props.onMention });
+    menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+    menu.push(null);
 
     if (account.get('id') === me) {
       menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
-    } else if (account.getIn(['relationship', 'blocking'])) {
-      menu.push({ text: intl.formatMessage(messages.unblock), action: this.props.onBlock });
-    } else if (account.getIn(['relationship', 'following'])) {
-      menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
     } else {
-      menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
+      if (account.getIn(['relationship', 'muting'])) {
+        menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute });
+      }
+
+      if (account.getIn(['relationship', 'blocking'])) {
+        menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock });
+      }
+
+      menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
     }
 
-    if (account.get('id') !== me) {
-      menu.push({ text: intl.formatMessage(messages.report), action: this.props.onReport });
+    if (account.get('acct') !== account.get('username')) {
+      extraInfo = <abbr title={intl.formatMessage(messages.disclaimer)}>*</abbr>;
     }
 
     return (
@@ -70,17 +83,17 @@ const ActionBar = React.createClass({
         <div style={outerLinksStyle}>
           <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
             <span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span>
-            <strong><FormattedNumber value={account.get('statuses_count')} /></strong>
+            <strong><FormattedNumber value={account.get('statuses_count')} /> {extraInfo}</strong>
           </Link>
 
           <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
             <span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span>
-            <strong><FormattedNumber value={account.get('following_count')} /></strong>
+            <strong><FormattedNumber value={account.get('following_count')} /> {extraInfo}</strong>
           </Link>
 
           <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
             <span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span>
-            <strong><FormattedNumber value={account.get('followers_count')} /></strong>
+            <strong><FormattedNumber value={account.get('followers_count')} /> {extraInfo}</strong>
           </Link>
         </div>
       </div>
diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx
index a4f0ca768..a359963c4 100644
--- a/app/assets/javascripts/components/features/account/components/header.jsx
+++ b/app/assets/javascripts/components/features/account/components/header.jsx
@@ -1,9 +1,10 @@
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import emojify from '../../../emoji';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
+import escapeTextContentForBrowser from 'escape-html';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import IconButton from '../../../components/icon_button';
+import { Motion, spring } from 'react-motion';
 
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -11,10 +12,51 @@ const messages = defineMessages({
   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
 });
 
+const Avatar = React.createClass({
+
+  propTypes: {
+    account: ImmutablePropTypes.map.isRequired
+  },
+
+  getInitialState () {
+    return {
+      isHovered: false
+    };
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleMouseOver () {
+    if (this.state.isHovered) return;
+    this.setState({ isHovered: true });
+  },
+
+  handleMouseOut () {
+    if (!this.state.isHovered) return;
+    this.setState({ isHovered: false });
+  },
+
+  render () {
+    const { account }   = this.props;
+    const { isHovered } = this.state;
+
+    return (
+      <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
+        {({ radius }) =>
+          <a href={account.get('url')} className='account__header__avatar' target='_blank' rel='noopener' style={{ display: 'block', width: '90px', height: '90px', margin: '0 auto', marginBottom: '10px', borderRadius: `${radius}px`, overflow: 'hidden' }} onMouseOver={this.handleMouseOver} onMouseOut={this.handleMouseOut}>
+            <img src={account.get('avatar')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} />
+          </a>
+        }
+      </Motion>
+    );
+  }
+
+});
+
 const Header = React.createClass({
 
   propTypes: {
-    account: ImmutablePropTypes.map.isRequired,
+    account: ImmutablePropTypes.map,
     me: React.PropTypes.number.isRequired,
     onFollow: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired
@@ -25,6 +67,10 @@ const Header = React.createClass({
   render () {
     const { account, me, intl } = this.props;
 
+    if (!account) {
+      return null;
+    }
+
     let displayName = account.get('display_name');
     let info        = '';
     let actionBtn   = '';
@@ -64,14 +110,9 @@ const Header = React.createClass({
     return (
       <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
         <div style={{ padding: '20px 10px' }}>
-          <a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
-            <div className='account__header__avatar' style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}>
-              <img src={account.get('avatar')} alt='' style={{ display: 'block', width: '90px', height: '90px', borderRadius: '90px' }} />
-            </div>
-
-            <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
-          </a>
+          <Avatar account={account} />
 
+          <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
           <span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
           <div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
 
diff --git a/app/assets/javascripts/components/features/account_timeline/components/header.jsx b/app/assets/javascripts/components/features/account_timeline/components/header.jsx
index 0cdfc8b02..99a10562e 100644
--- a/app/assets/javascripts/components/features/account_timeline/components/header.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/components/header.jsx
@@ -2,6 +2,7 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import InnerHeader from '../../account/components/header';
 import ActionBar from '../../account/components/action_bar';
+import MissingIndicator from '../../../components/missing_indicator';
 
 const Header = React.createClass({
   contextTypes: {
@@ -9,12 +10,13 @@ const Header = React.createClass({
   },
 
   propTypes: {
-    account: ImmutablePropTypes.map.isRequired,
+    account: ImmutablePropTypes.map,
     me: React.PropTypes.number.isRequired,
     onFollow: React.PropTypes.func.isRequired,
     onBlock: React.PropTypes.func.isRequired,
     onMention: React.PropTypes.func.isRequired,
-    onReport: React.PropTypes.func.isRequired
+    onReport: React.PropTypes.func.isRequired,
+    onMute: React.PropTypes.func.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -36,11 +38,15 @@ const Header = React.createClass({
     this.context.router.push('/report');
   },
 
+  handleMute() {
+    this.props.onMute(this.props.account);
+  },
+
   render () {
     const { account, me } = this.props;
 
-    if (!account) {
-      return null;
+    if (account === null) {
+      return <MissingIndicator />;
     }
 
     return (
@@ -57,6 +63,7 @@ const Header = React.createClass({
           onBlock={this.handleBlock}
           onMention={this.handleMention}
           onReport={this.handleReport}
+          onMute={this.handleMute}
         />
       </div>
     );
diff --git a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx
index e4ce905fe..8472d25a5 100644
--- a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx
@@ -5,7 +5,9 @@ import {
   followAccount,
   unfollowAccount,
   blockAccount,
-  unblockAccount
+  unblockAccount,
+  muteAccount,
+  unmuteAccount
 } from '../../../actions/accounts';
 import { mentionCompose } from '../../../actions/compose';
 import { initReport } from '../../../actions/reports';
@@ -44,6 +46,14 @@ const mapDispatchToProps = dispatch => ({
 
   onReport (account) {
     dispatch(initReport(account));
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(muteAccount(account.get('id')));
+    }
   }
 });
 
diff --git a/app/assets/javascripts/components/features/account_timeline/index.jsx b/app/assets/javascripts/components/features/account_timeline/index.jsx
index 349510295..f92e1b49c 100644
--- a/app/assets/javascripts/components/features/account_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/index.jsx
@@ -16,6 +16,7 @@ import Immutable from 'immutable';
 const mapStateToProps = (state, props) => ({
   statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items'], Immutable.List()),
   isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']),
+  hasMore: !!state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'next']),
   me: state.getIn(['meta', 'me'])
 });
 
@@ -26,6 +27,7 @@ const AccountTimeline = React.createClass({
     dispatch: React.PropTypes.func.isRequired,
     statusIds: ImmutablePropTypes.list,
     isLoading: React.PropTypes.bool,
+    hasMore: React.PropTypes.bool,
     me: React.PropTypes.number.isRequired
   },
 
@@ -48,7 +50,7 @@ const AccountTimeline = React.createClass({
   },
 
   render () {
-    const { statusIds, isLoading, me } = this.props;
+    const { statusIds, isLoading, hasMore, me } = this.props;
 
     if (!statusIds && isLoading) {
       return (
@@ -66,6 +68,7 @@ const AccountTimeline = React.createClass({
           prepend={<HeaderContainer accountId={this.props.params.accountId} />}
           statusIds={statusIds}
           isLoading={isLoading}
+          hasMore={hasMore}
           me={me}
           onScrollToBottom={this.handleScrollToBottom}
         />
diff --git a/app/assets/javascripts/components/features/community_timeline/index.jsx b/app/assets/javascripts/components/features/community_timeline/index.jsx
new file mode 100644
index 000000000..0957338cf
--- /dev/null
+++ b/app/assets/javascripts/components/features/community_timeline/index.jsx
@@ -0,0 +1,95 @@
+import { connect } from 'react-redux';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../ui/components/column';
+import {
+  refreshTimeline,
+  updateTimeline,
+  deleteFromTimelines,
+  connectTimeline,
+  disconnectTimeline
+} from '../../actions/timelines';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import createStream from '../../stream';
+
+const messages = defineMessages({
+  title: { id: 'column.community', defaultMessage: 'Local' }
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
+  accessToken: state.getIn(['meta', 'access_token'])
+});
+
+let subscription;
+
+const CommunityTimeline = React.createClass({
+
+  propTypes: {
+    dispatch: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired,
+    accessToken: React.PropTypes.string.isRequired,
+    hasUnread: React.PropTypes.bool
+  },
+
+  mixins: [PureRenderMixin],
+
+  componentDidMount () {
+    const { dispatch, accessToken } = this.props;
+
+    dispatch(refreshTimeline('community'));
+
+    if (typeof subscription !== 'undefined') {
+      return;
+    }
+
+    subscription = createStream(accessToken, 'public:local', {
+
+      connected () {
+        dispatch(connectTimeline('community'));
+      },
+
+      reconnected () {
+        dispatch(connectTimeline('community'));
+      },
+
+      disconnected () {
+        dispatch(disconnectTimeline('community'));
+      },
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline('community', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
+        }
+      }
+
+    });
+  },
+
+  componentWillUnmount () {
+    // if (typeof subscription !== 'undefined') {
+    //   subscription.close();
+    //   subscription = null;
+    // }
+  },
+
+  render () {
+    const { intl, hasUnread } = this.props;
+
+    return (
+      <Column icon='users' active={hasUnread} heading={intl.formatMessage(messages.title)}>
+        <ColumnBackButtonSlim />
+        <StatusListContainer type='community' emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} />
+      </Column>
+    );
+  },
+
+});
+
+export default connect(mapStateToProps)(injectIntl(CommunityTimeline));
diff --git a/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx b/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx
index 9ea7f190f..5591b45cf 100644
--- a/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx
+++ b/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx
@@ -1,11 +1,16 @@
 import Avatar from '../../../components/avatar';
 import DisplayName from '../../../components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
 
 const AutosuggestAccount = ({ account }) => (
-  <div style={{ overflow: 'hidden' }}>
+  <div style={{ overflow: 'hidden' }} className='autosuggest-account'>
     <div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} size={18} /></div>
     <DisplayName account={account} />
   </div>
 );
 
+AutosuggestAccount.propTypes = {
+  account: ImmutablePropTypes.map.isRequired
+};
+
 export default AutosuggestAccount;
diff --git a/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx b/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx
new file mode 100644
index 000000000..086488649
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/autosuggest_status.jsx
@@ -0,0 +1,15 @@
+import { FormattedMessage } from 'react-intl';
+import DisplayName from '../../../components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const AutosuggestStatus = ({ status }) => (
+  <div style={{ overflow: 'hidden' }} className='autosuggest-status'>
+    <FormattedMessage id='search.status_by' defaultMessage='Status by {name}' values={{ name: <strong>@{status.getIn(['account', 'acct'])}</strong> }} />
+  </div>
+);
+
+AutosuggestStatus.propTypes = {
+  status: ImmutablePropTypes.map.isRequired
+};
+
+export default AutosuggestStatus;
diff --git a/app/assets/javascripts/components/features/compose/components/character_counter.jsx b/app/assets/javascripts/components/features/compose/components/character_counter.jsx
index f0c1b7c8d..e6b675354 100644
--- a/app/assets/javascripts/components/features/compose/components/character_counter.jsx
+++ b/app/assets/javascripts/components/features/compose/components/character_counter.jsx
@@ -10,7 +10,7 @@ const CharacterCounter = React.createClass({
   mixins: [PureRenderMixin],
 
   render () {
-    const diff = this.props.max - this.props.text.length;
+    const diff = this.props.max - this.props.text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "_").length;
 
     return (
       <span style={{ fontSize: '16px', cursor: 'default' }}>
diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
index 9edc01ed7..b016d3f28 100644
--- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -2,15 +2,19 @@ import CharacterCounter from './character_counter';
 import Button from '../../../components/button';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import ReplyIndicator from './reply_indicator';
-import UploadButton from './upload_button';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
 import AutosuggestTextarea from '../../../components/autosuggest_textarea';
-import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container';
 import { debounce } from 'react-decoration';
 import UploadButtonContainer from '../containers/upload_button_container';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import Toggle from 'react-toggle';
 import Collapsable from '../../../components/collapsable';
+import SpoilerButtonContainer from '../containers/spoiler_button_container';
+import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
+import SensitiveButtonContainer from '../containers/sensitive_button_container';
+import EmojiPickerDropdown from './emoji_picker_dropdown';
+import UploadFormContainer from '../containers/upload_form_container';
+import TextIconButton from './text_icon_button';
 
 const messages = defineMessages({
   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@@ -25,30 +29,24 @@ const ComposeForm = React.createClass({
     text: React.PropTypes.string.isRequired,
     suggestion_token: React.PropTypes.string,
     suggestions: ImmutablePropTypes.list,
-    sensitive: React.PropTypes.bool,
     spoiler: React.PropTypes.bool,
+    privacy: React.PropTypes.string,
     spoiler_text: React.PropTypes.string,
-    unlisted: React.PropTypes.bool,
-    private: React.PropTypes.bool,
-    fileDropDate: React.PropTypes.instanceOf(Date),
+    focusDate: React.PropTypes.instanceOf(Date),
+    preselectDate: React.PropTypes.instanceOf(Date),
     is_submitting: React.PropTypes.bool,
     is_uploading: React.PropTypes.bool,
-    in_reply_to: ImmutablePropTypes.map,
-    media_count: React.PropTypes.number,
     me: React.PropTypes.number,
     needsPrivacyWarning: React.PropTypes.bool,
     mentionedDomains: React.PropTypes.array.isRequired,
     onChange: React.PropTypes.func.isRequired,
     onSubmit: React.PropTypes.func.isRequired,
-    onCancelReply: React.PropTypes.func.isRequired,
     onClearSuggestions: React.PropTypes.func.isRequired,
     onFetchSuggestions: React.PropTypes.func.isRequired,
     onSuggestionSelected: React.PropTypes.func.isRequired,
-    onChangeSensitivity: React.PropTypes.func.isRequired,
-    onChangeSpoilerness: React.PropTypes.func.isRequired,
     onChangeSpoilerText: React.PropTypes.func.isRequired,
-    onChangeVisibility: React.PropTypes.func.isRequired,
-    onChangeListability: React.PropTypes.func.isRequired,
+    onPaste: React.PropTypes.func.isRequired,
+    onPickEmoji: React.PropTypes.func.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -77,37 +75,31 @@ const ComposeForm = React.createClass({
   },
 
   onSuggestionSelected (tokenStart, token, value) {
+    this._restoreCaret = null;
     this.props.onSuggestionSelected(tokenStart, token, value);
   },
 
-  handleChangeSensitivity (e) {
-    this.props.onChangeSensitivity(e.target.checked);
-  },
-
-  handleChangeSpoilerness (e) {
-    this.props.onChangeSpoilerness(e.target.checked);
-    this.props.onChangeSpoilerText('');
-  },
-
   handleChangeSpoilerText (e) {
     this.props.onChangeSpoilerText(e.target.value);
   },
 
-  handleChangeVisibility (e) {
-    this.props.onChangeVisibility(e.target.checked);
-  },
-
-  handleChangeListability (e) {
-    this.props.onChangeListability(e.target.checked);
-  },
-
   componentDidUpdate (prevProps) {
-    if ((prevProps.in_reply_to === null && this.props.in_reply_to !== null) || (prevProps.in_reply_to !== null && this.props.in_reply_to !== null && prevProps.in_reply_to.get('id') !== this.props.in_reply_to.get('id'))) {
+    if (this.props.focusDate !== prevProps.focusDate) {
       // If replying to zero or one users, places the cursor at the end of the textbox.
       // If replying to more than one user, selects any usernames past the first;
       // this provides a convenient shortcut to drop everyone else from the conversation.
-      const selectionStart = this.props.text.search(/\s/) + 1;
-      const selectionEnd   = this.props.text.length;
+      let selectionEnd, selectionStart;
+
+      if (this.props.preselectDate !== prevProps.preselectDate) {
+        selectionEnd   = this.props.text.length;
+        selectionStart = this.props.text.search(/\s/) + 1;
+      } else if (typeof this._restoreCaret === 'number') {
+        selectionStart = this._restoreCaret;
+        selectionEnd   = this._restoreCaret;
+      } else {
+        selectionEnd   = this.props.text.length;
+        selectionStart = selectionEnd;
+      }
 
       this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
       this.autosuggestTextarea.textarea.focus();
@@ -118,18 +110,19 @@ const ComposeForm = React.createClass({
     this.autosuggestTextarea = c;
   },
 
+  handleEmojiPick (data) {
+    const position     = this.autosuggestTextarea.textarea.selectionStart;
+    this._restoreCaret = position + data.shortname.length + 1;
+    this.props.onPickEmoji(position, data);
+  },
+
   render () {
-    const { intl, needsPrivacyWarning, mentionedDomains } = this.props;
+    const { intl, needsPrivacyWarning, mentionedDomains, onPaste } = this.props;
     const disabled = this.props.is_submitting || this.props.is_uploading;
 
-    let replyArea      = '';
     let publishText    = '';
     let privacyWarning = '';
-    let reply_to_other = !!this.props.in_reply_to && (this.props.in_reply_to.getIn(['account', 'id']) !== this.props.me);
-
-    if (this.props.in_reply_to) {
-      replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
-    }
+    let reply_to_other = false;
 
     if (needsPrivacyWarning) {
       privacyWarning = (
@@ -143,66 +136,59 @@ const ComposeForm = React.createClass({
       );
     }
 
-    if (this.props.private) {
+    if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
       publishText = <span><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
     } else {
-      publishText = intl.formatMessage(messages.publish) + (!this.props.unlisted ? '!' : '');
+      publishText = intl.formatMessage(messages.publish) + (this.props.privacy !== 'unlisted' ? '!' : '');
     }
 
     return (
       <div style={{ padding: '10px' }}>
         <Collapsable isVisible={this.props.spoiler} fullHeight={50}>
           <div className="spoiler-input">
-            <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} type="text" className="spoiler-input__input" />
+            <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type="text" className="spoiler-input__input" />
           </div>
         </Collapsable>
 
         {privacyWarning}
-        {replyArea}
-
-        <AutosuggestTextarea
-          ref={this.setAutosuggestTextarea}
-          placeholder={intl.formatMessage(messages.placeholder)}
-          disabled={disabled}
-          fileDropDate={this.props.fileDropDate}
-          value={this.props.text}
-          onChange={this.handleChange}
-          suggestions={this.props.suggestions}
-          onKeyDown={this.handleKeyDown}
-          onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
-          onSuggestionsClearRequested={this.onSuggestionsClearRequested}
-          onSuggestionSelected={this.onSuggestionSelected}
-        />
-
-        <div style={{ marginTop: '10px', overflow: 'hidden' }}>
-          <div style={{ float: 'right' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div>
-          <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div>
-          <UploadButtonContainer style={{ paddingTop: '4px' }} />
+
+        <ReplyIndicatorContainer />
+
+        <div style={{ position: 'relative' }}>
+          <AutosuggestTextarea
+            ref={this.setAutosuggestTextarea}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            disabled={disabled}
+            value={this.props.text}
+            onChange={this.handleChange}
+            suggestions={this.props.suggestions}
+            onKeyDown={this.handleKeyDown}
+            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+            onSuggestionSelected={this.onSuggestionSelected}
+            onPaste={onPaste}
+          />
+
+          <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
         </div>
 
-        <label className='compose-form__label with-border' style={{ marginTop: '10px' }}>
-          <Toggle checked={this.props.spoiler} onChange={this.handleChangeSpoilerness} />
-          <span className='compose-form__label__text'><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span>
-        </label>
-
-        <label className='compose-form__label with-border'>
-          <Toggle checked={this.props.private} onChange={this.handleChangeVisibility} />
-          <span className='compose-form__label__text'><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
-        </label>
-
-        <Collapsable isVisible={!(this.props.private || reply_to_other)} fullHeight={39.5}>
-          <label className='compose-form__label'>
-            <Toggle checked={this.props.unlisted} onChange={this.handleChangeListability} />
-            <span className='compose-form__label__text'><FormattedMessage id='compose_form.unlisted' defaultMessage='Do not display in public timeline' /></span>
-          </label>
-        </Collapsable>
+        <div className='compose-form__modifiers'>
+          <UploadFormContainer />
+        </div>
 
-        <Collapsable isVisible={this.props.media_count > 0} fullHeight={39.5}>
-          <label className='compose-form__label'>
-            <Toggle checked={this.props.sensitive} onChange={this.handleChangeSensitivity} />
-            <span className='compose-form__label__text'><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark media as sensitive' /></span>
-          </label>
-        </Collapsable>
+        <div style={{ display: 'flex', justifyContent: 'space-between' }}>
+          <div className='compose-form__buttons'>
+            <UploadButtonContainer />
+            <PrivacyDropdownContainer />
+            <SensitiveButtonContainer />
+            <SpoilerButtonContainer />
+          </div>
+
+          <div style={{ display: 'flex' }}>
+            <div style={{ paddingTop: '10px', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div>
+            <div style={{ paddingTop: '10px' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div>
+          </div>
+        </div>
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/compose/components/drawer.jsx b/app/assets/javascripts/components/features/compose/components/drawer.jsx
deleted file mode 100644
index 83f3fa27d..000000000
--- a/app/assets/javascripts/components/features/compose/components/drawer.jsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { Link } from 'react-router';
-import { injectIntl, defineMessages } from 'react-intl';
-
-const messages = defineMessages({
-  start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
-  public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
-  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
-  logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
-});
-
-const Drawer = ({ children, withHeader, intl }) => {
-  let header = '';
-
-  if (withHeader) {
-    header = (
-      <div className='drawer__header'>
-        <Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
-        <Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
-        <a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
-        <a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
-      </div>
-    );
-  }
-
-  return (
-    <div className='drawer'>
-      {header}
-
-      <div className='drawer__inner'>
-        {children}
-      </div>
-    </div>
-  );
-};
-
-Drawer.propTypes = {
-  withHeader: React.PropTypes.bool,
-  children: React.PropTypes.node,
-  intl: React.PropTypes.object
-};
-
-export default injectIntl(Drawer);
diff --git a/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx
new file mode 100644
index 000000000..1920b29bf
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx
@@ -0,0 +1,58 @@
+import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
+import EmojiPicker from 'emojione-picker';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }
+});
+
+const settings = {
+  imageType: 'png',
+  sprites: false,
+  imagePathPNG: '/emoji/'
+};
+
+const style = {
+  position: 'absolute',
+  right: '5px',
+  top: '5px'
+};
+
+const EmojiPickerDropdown = React.createClass({
+
+  propTypes: {
+    intl: React.PropTypes.object.isRequired,
+    onPickEmoji: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  setRef (c) {
+    this.dropdown = c;
+  },
+
+  handleChange (data) {
+    this.dropdown.hide();
+    this.props.onPickEmoji(data);
+  },
+
+  render () {
+    const { intl } = this.props;
+
+    return (
+      <Dropdown ref={this.setRef} style={style}>
+        <DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)} style={{ fontSize: `24px`, width: `24px`, lineHeight: `24px`, display: 'block', marginLeft: '2px' }}>
+          <img draggable="false" className="emojione" alt="🙂" src="/emoji/1f602.svg" />
+        </DropdownTrigger>
+
+        <DropdownContent className='dropdown__left'>
+          <EmojiPicker emojione={settings} onChange={this.handleChange} />
+        </DropdownContent>
+      </Dropdown>
+    );
+  }
+
+});
+
+export default injectIntl(EmojiPickerDropdown);
diff --git a/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx
new file mode 100644
index 000000000..e54fa4d28
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx
@@ -0,0 +1,101 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { injectIntl, defineMessages } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+
+const messages = defineMessages({
+  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+  public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
+  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
+  private_short: { id: 'privacy.private.short', defaultMessage: 'Private' },
+  private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
+  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
+  change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }
+});
+
+const iconStyle = {
+  lineHeight: '27px',
+  height: null
+};
+
+const PrivacyDropdown = React.createClass({
+
+  propTypes: {
+    value: React.PropTypes.string.isRequired,
+    onChange: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
+  },
+
+  getInitialState () {
+    return {
+      open: false
+    };
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleToggle () {
+    this.setState({ open: !this.state.open });
+  },
+
+  handleClick (value, e) {
+    e.preventDefault();
+    this.setState({ open: false });
+    this.props.onChange(value);
+  },
+
+  onGlobalClick (e) {
+    if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
+      this.setState({ open: false });
+    }
+  },
+
+  componentDidMount () {
+    window.addEventListener('click', this.onGlobalClick);
+    window.addEventListener('touchstart', this.onGlobalClick);
+  },
+
+  componentWillUnmount () {
+    window.removeEventListener('click', this.onGlobalClick);
+    window.removeEventListener('touchstart', this.onGlobalClick);
+  },
+
+  setRef (c) {
+    this.node = c;
+  },
+
+  render () {
+    const { value, onChange, intl } = this.props;
+    const { open } = this.state;
+
+    const options = [
+      { icon: 'globe', value: 'public', shortText: intl.formatMessage(messages.public_short), longText: intl.formatMessage(messages.public_long) },
+      { icon: 'unlock-alt', value: 'unlisted', shortText: intl.formatMessage(messages.unlisted_short), longText: intl.formatMessage(messages.unlisted_long) },
+      { icon: 'lock', value: 'private', shortText: intl.formatMessage(messages.private_short), longText: intl.formatMessage(messages.private_long) },
+      { icon: 'envelope', value: 'direct', shortText: intl.formatMessage(messages.direct_short), longText: intl.formatMessage(messages.direct_long) }
+    ];
+
+    const valueOption = options.find(item => item.value === value);
+
+    return (
+      <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}>
+        <div className='privacy-dropdown__value'><IconButton icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div>
+        <div className='privacy-dropdown__dropdown'>
+          {options.map(item =>
+            <div key={item.value} onClick={this.handleClick.bind(this, item.value)} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}>
+              <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div>
+              <div className='privacy-dropdown__option__content'>
+                <strong>{item.shortText}</strong>
+                {item.longText}
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+    );
+  }
+
+});
+
+export default injectIntl(PrivacyDropdown);
diff --git a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx
index 73e5ee99e..a72bd32c2 100644
--- a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx
+++ b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx
@@ -17,7 +17,7 @@ const ReplyIndicator = React.createClass({
   },
 
   propTypes: {
-    status: ImmutablePropTypes.map.isRequired,
+    status: ImmutablePropTypes.map,
     onCancel: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired
   },
@@ -36,17 +36,22 @@ const ReplyIndicator = React.createClass({
   },
 
   render () {
-    const { intl } = this.props;
-    const content  = { __html: emojify(this.props.status.get('content')) };
+    const { status, intl } = this.props;
+
+    if (!status) {
+      return null;
+    }
+
+    const content  = { __html: emojify(status.get('content')) };
 
     return (
       <div className='reply-indicator'>
         <div style={{ overflow: 'hidden', marginBottom: '5px' }}>
           <div style={{ float: 'right', lineHeight: '24px' }}><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
 
-          <a href={this.props.status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}>
-            <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={this.props.status.getIn(['account', 'avatar'])} /></div>
-            <DisplayName account={this.props.status.get('account')} />
+          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}>
+            <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={status.getIn(['account', 'avatar'])} /></div>
+            <DisplayName account={status.get('account')} />
           </a>
         </div>
 
diff --git a/app/assets/javascripts/components/features/compose/components/search.jsx b/app/assets/javascripts/components/features/compose/components/search.jsx
index c1f23939d..936e003f2 100644
--- a/app/assets/javascripts/components/features/compose/components/search.jsx
+++ b/app/assets/javascripts/components/features/compose/components/search.jsx
@@ -1,118 +1,68 @@
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import Autosuggest from 'react-autosuggest';
-import AutosuggestAccountContainer from '../containers/autosuggest_account_container';
-import { debounce } from 'react-decoration';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 
 const messages = defineMessages({
   placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }
 });
 
-const getSuggestionValue = suggestion => suggestion.value;
-
-const renderSuggestion = suggestion => {
-  if (suggestion.type === 'account') {
-    return <AutosuggestAccountContainer id={suggestion.id} />;
-  } else {
-    return <span>#{suggestion.id}</span>
-  }
-};
-
-const renderSectionTitle = section => (
-  <strong><FormattedMessage id={`search.${section.title}`} defaultMessage={section.title} /></strong>
-);
-
-const getSectionSuggestions = section => section.items;
-
-const outerStyle = {
-  padding: '10px',
-  lineHeight: '20px',
-  position: 'relative'
-};
-
-const iconStyle = {
-  position: 'absolute',
-  top: '18px',
-  right: '20px',
-  fontSize: '18px',
-  pointerEvents: 'none'
-};
-
 const Search = React.createClass({
 
-  contextTypes: {
-    router: React.PropTypes.object
-  },
-
   propTypes: {
-    suggestions: React.PropTypes.array.isRequired,
     value: React.PropTypes.string.isRequired,
+    submitted: React.PropTypes.bool,
     onChange: React.PropTypes.func.isRequired,
+    onSubmit: React.PropTypes.func.isRequired,
     onClear: React.PropTypes.func.isRequired,
-    onFetch: React.PropTypes.func.isRequired,
-    onReset: React.PropTypes.func.isRequired,
+    onShow: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
 
-  onChange (_, { newValue }) {
-    if (typeof newValue !== 'string') {
-      return;
-    }
-
-    this.props.onChange(newValue);
+  handleChange (e) {
+    this.props.onChange(e.target.value);
   },
 
-  onSuggestionsClearRequested () {
+  handleClear (e) {
+    e.preventDefault();
     this.props.onClear();
   },
 
-  @debounce(500)
-  onSuggestionsFetchRequested ({ value }) {
-    value = value.replace('#', '');
-    this.props.onFetch(value.trim());
+  handleKeyDown (e) {
+    if (e.key === 'Enter') {
+      e.preventDefault();
+      this.props.onSubmit();
+    }
   },
 
-  onSuggestionSelected (_, { suggestion }) {
-    if (suggestion.type === 'account') {
-      this.context.router.push(`/accounts/${suggestion.id}`);
-    } else {
-      this.context.router.push(`/timelines/tag/${suggestion.id}`);
-    }
+  handleFocus () {
+    this.props.onShow();
   },
 
   render () {
-    const inputProps = {
-      placeholder: this.props.intl.formatMessage(messages.placeholder),
-      value: this.props.value,
-      onChange: this.onChange,
-      className: 'search__input'
-    };
+    const { intl, value, submitted } = this.props;
+    const hasValue = value.length > 0 || submitted;
 
     return (
-      <div className='search' style={outerStyle}>
-        <Autosuggest
-          multiSection={true}
-          suggestions={this.props.suggestions}
-          focusFirstSuggestion={true}
-          focusInputOnSuggestionClick={false}
-          alwaysRenderSuggestions={false}
-          onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
-          onSuggestionsClearRequested={this.onSuggestionsClearRequested}
-          onSuggestionSelected={this.onSuggestionSelected}
-          getSuggestionValue={getSuggestionValue}
-          renderSuggestion={renderSuggestion}
-          renderSectionTitle={renderSectionTitle}
-          getSectionSuggestions={getSectionSuggestions}
-          inputProps={inputProps}
+      <div className='search'>
+        <input
+          className='search__input'
+          type='text'
+          placeholder={intl.formatMessage(messages.placeholder)}
+          value={value}
+          onChange={this.handleChange}
+          onKeyUp={this.handleKeyDown}
+          onFocus={this.handleFocus}
         />
 
-        <div style={iconStyle}><i className='fa fa-search' /></div>
+        <div className='search__icon'>
+          <i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
+          <i className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} onClick={this.handleClear} />
+        </div>
       </div>
     );
-  },
+  }
 
 });
 
diff --git a/app/assets/javascripts/components/features/compose/components/search_results.jsx b/app/assets/javascripts/components/features/compose/components/search_results.jsx
new file mode 100644
index 000000000..fd05e7f7e
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/search_results.jsx
@@ -0,0 +1,68 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import AccountContainer from '../../../containers/account_container';
+import StatusContainer from '../../../containers/status_container';
+import { Link } from 'react-router';
+
+const SearchResults = React.createClass({
+
+  propTypes: {
+    results: ImmutablePropTypes.map.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { results } = this.props;
+
+    let accounts, statuses, hashtags;
+    let count = 0;
+
+    if (results.get('accounts') && results.get('accounts').size > 0) {
+      count   += results.get('accounts').size;
+      accounts = (
+        <div className='search-results__section'>
+          {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
+        </div>
+      );
+    }
+
+    if (results.get('statuses') && results.get('statuses').size > 0) {
+      count   += results.get('statuses').size;
+      statuses = (
+        <div className='search-results__section'>
+          {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
+        </div>
+      );
+    }
+
+    if (results.get('hashtags') && results.get('hashtags').size > 0) {
+      count += results.get('hashtags').size;
+      hashtags = (
+        <div className='search-results__section'>
+          {results.get('hashtags').map(hashtag =>
+            <Link className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
+              #{hashtag}
+            </Link>
+          )}
+        </div>
+      );
+    }
+
+    return (
+      <div className='search-results'>
+        <div className='search-results__header'>
+          <FormattedMessage id='search_results.total' defaultMessage='{count} {count, plural, one {result} other {results}}' values={{ count }} />
+        </div>
+
+        {accounts}
+        {statuses}
+        {hashtags}
+      </div>
+    );
+  }
+
+});
+
+export default SearchResults;
diff --git a/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx b/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx
new file mode 100644
index 000000000..e3ac63d87
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx
@@ -0,0 +1,31 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const TextIconButton = React.createClass({
+
+  propTypes: {
+    label: React.PropTypes.string.isRequired,
+    title: React.PropTypes.string,
+    active: React.PropTypes.bool,
+    onClick: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleClick (e) {
+    e.preventDefault();
+    this.props.onClick();
+  },
+
+  render () {
+    const { label, title, active } = this.props;
+
+    return (
+      <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} onClick={this.handleClick}>
+        {label}
+      </button>
+    );
+  }
+
+});
+
+export default TextIconButton;
diff --git a/app/assets/javascripts/components/features/compose/components/upload_button.jsx b/app/assets/javascripts/components/features/compose/components/upload_button.jsx
index 4c8181aa1..2ba0e8fd2 100644
--- a/app/assets/javascripts/components/features/compose/components/upload_button.jsx
+++ b/app/assets/javascripts/components/features/compose/components/upload_button.jsx
@@ -6,6 +6,11 @@ const messages = defineMessages({
   upload: { id: 'upload_button.label', defaultMessage: 'Add media' }
 });
 
+const iconStyle = {
+  lineHeight: '27px',
+  height: null
+};
+
 const UploadButton = React.createClass({
 
   propTypes: {
@@ -37,7 +42,7 @@ const UploadButton = React.createClass({
 
     return (
       <div style={this.props.style}>
-        <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} />
+        <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} style={iconStyle} size={18} inverted />
         <input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} />
       </div>
     );
diff --git a/app/assets/javascripts/components/features/compose/components/upload_form.jsx b/app/assets/javascripts/components/features/compose/components/upload_form.jsx
index 94c94b4b7..77590d90d 100644
--- a/app/assets/javascripts/components/features/compose/components/upload_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/upload_form.jsx
@@ -2,6 +2,8 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import IconButton from '../../../components/icon_button';
 import { defineMessages, injectIntl } from 'react-intl';
+import UploadProgressContainer from '../containers/upload_progress_container';
+import { Motion, spring } from 'react-motion';
 
 const messages = defineMessages({
   undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }
@@ -11,7 +13,6 @@ const UploadForm = React.createClass({
 
   propTypes: {
     media: ImmutablePropTypes.list.isRequired,
-    is_uploading: React.PropTypes.bool,
     onRemoveFile: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired
   },
@@ -21,21 +22,22 @@ const UploadForm = React.createClass({
   render () {
     const { intl, media } = this.props;
 
-    if (!media.size) {
-      return null;
-    }
-
-    const uploads = media.map(attachment => (
-      <div key={attachment.get('id')} style={{ borderRadius: '4px', marginBottom: '10px' }} className='transparent-background'>
-        <div style={{ width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}>
-          <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} />
-        </div>
+    const uploads = media.map(attachment =>
+      <div key={attachment.get('id')} style={{ margin: '5px', flex: '1 1 0' }}>
+        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
+          {({ scale }) =>
+            <div style={{ transform: `translateZ(0) scale(${scale})`, width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}>
+              <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} />
+            </div>
+          }
+        </Motion>
       </div>
-    ));
+    );
 
     return (
-      <div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden', flexShrink: '0' }}>
-        {uploads}
+      <div style={{ overflow: 'hidden' }}>
+        <UploadProgressContainer />
+        <div style={{ display: 'flex', padding: '5px' }}>{uploads}</div>
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/compose/components/upload_progress.jsx b/app/assets/javascripts/components/features/compose/components/upload_progress.jsx
new file mode 100644
index 000000000..86ffbf936
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/upload_progress.jsx
@@ -0,0 +1,44 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { Motion, spring } from 'react-motion';
+import { FormattedMessage } from 'react-intl';
+
+const UploadProgress = React.createClass({
+
+  propTypes: {
+    active: React.PropTypes.bool,
+    progress: React.PropTypes.number
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { active, progress } = this.props;
+
+    if (!active) {
+      return null;
+    }
+
+    return (
+      <div className='upload-progress'>
+        <div>
+          <i className='fa fa-upload' />
+        </div>
+
+        <div style={{ flex: '1 1 auto' }}>
+          <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
+
+          <div className='upload-progress__backdrop'>
+            <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
+              {({ width }) =>
+                <div className='upload-progress__tracker' style={{ width: `${width}%` }} />
+              }
+            </Motion>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+});
+
+export default UploadProgress;
diff --git a/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx b/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx
new file mode 100644
index 000000000..ef46eb09c
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/autosuggest_status_container.jsx
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import AutosuggestStatus from '../components/autosuggest_status';
+import { makeGetStatus } from '../../../selectors';
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, { id }) => ({
+    status: getStatus(state, id)
+  });
+
+  return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(AutosuggestStatus);
diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
index 2671ea618..604e1182f 100644
--- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
@@ -1,95 +1,78 @@
 import { connect } from 'react-redux';
 import ComposeForm from '../components/compose_form';
+import { uploadCompose } from '../../../actions/compose';
+import { createSelector } from 'reselect';
 import {
   changeCompose,
   submitCompose,
-  cancelReplyCompose,
   clearComposeSuggestions,
   fetchComposeSuggestions,
   selectComposeSuggestion,
-  changeComposeSensitivity,
-  changeComposeSpoilerness,
   changeComposeSpoilerText,
-  changeComposeVisibility,
-  changeComposeListability
+  insertEmojiCompose
 } from '../../../actions/compose';
-import { makeGetStatus } from '../../../selectors';
 
-const makeMapStateToProps = () => {
-  const getStatus = makeGetStatus();
+const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig));
 
-  const mapStateToProps = function (state, props) {
-    const mentionedUsernamesWithDomains = state.getIn(['compose', 'text']).match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig);
+const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => {
+  return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : [];
+});
 
-    return {
-      text: state.getIn(['compose', 'text']),
-      suggestion_token: state.getIn(['compose', 'suggestion_token']),
-      suggestions: state.getIn(['compose', 'suggestions']),
-      sensitive: state.getIn(['compose', 'sensitive']),
-      spoiler: state.getIn(['compose', 'spoiler']),
-      spoiler_text: state.getIn(['compose', 'spoiler_text']),
-      unlisted: state.getIn(['compose', 'unlisted'], ),
-      private: state.getIn(['compose', 'private']),
-      fileDropDate: state.getIn(['compose', 'fileDropDate']),
-      is_submitting: state.getIn(['compose', 'is_submitting']),
-      is_uploading: state.getIn(['compose', 'is_uploading']),
-      in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
-      media_count: state.getIn(['compose', 'media_attachments']).size,
-      me: state.getIn(['compose', 'me']),
-      needsPrivacyWarning: state.getIn(['compose', 'private']) && mentionedUsernamesWithDomains !== null,
-      mentionedDomains: mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : []
-    };
-  };
-
-  return mapStateToProps;
-};
+const mapStateToProps = (state, props) => {
+  const mentionedUsernames = getMentionedUsernames(state);
+  const mentionedUsernamesWithDomains = getMentionedDomains(state);
 
-const mapDispatchToProps = function (dispatch) {
   return {
-    onChange (text) {
-      dispatch(changeCompose(text));
-    },
+    text: state.getIn(['compose', 'text']),
+    suggestion_token: state.getIn(['compose', 'suggestion_token']),
+    suggestions: state.getIn(['compose', 'suggestions']),
+    spoiler: state.getIn(['compose', 'spoiler']),
+    spoiler_text: state.getIn(['compose', 'spoiler_text']),
+    privacy: state.getIn(['compose', 'privacy']),
+    focusDate: state.getIn(['compose', 'focusDate']),
+    preselectDate: state.getIn(['compose', 'preselectDate']),
+    is_submitting: state.getIn(['compose', 'is_submitting']),
+    is_uploading: state.getIn(['compose', 'is_uploading']),
+    me: state.getIn(['compose', 'me']),
+    needsPrivacyWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null,
+    mentionedDomains: mentionedUsernamesWithDomains
+  };
+};
 
-    onSubmit () {
-      dispatch(submitCompose());
-    },
+const mapDispatchToProps = (dispatch) => ({
 
-    onCancelReply () {
-      dispatch(cancelReplyCompose());
-    },
+  onChange (text) {
+    dispatch(changeCompose(text));
+  },
 
-    onClearSuggestions () {
-      dispatch(clearComposeSuggestions());
-    },
+  onSubmit () {
+    dispatch(submitCompose());
+  },
 
-    onFetchSuggestions (token) {
-      dispatch(fetchComposeSuggestions(token));
-    },
+  onClearSuggestions () {
+    dispatch(clearComposeSuggestions());
+  },
 
-    onSuggestionSelected (position, token, accountId) {
-      dispatch(selectComposeSuggestion(position, token, accountId));
-    },
+  onFetchSuggestions (token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
 
-    onChangeSensitivity (checked) {
-      dispatch(changeComposeSensitivity(checked));
-    },
+  onSuggestionSelected (position, token, accountId) {
+    dispatch(selectComposeSuggestion(position, token, accountId));
+  },
 
-    onChangeSpoilerness (checked) {
-      dispatch(changeComposeSpoilerness(checked));
-    },
+  onChangeSpoilerText (checked) {
+    dispatch(changeComposeSpoilerText(checked));
+  },
 
-    onChangeSpoilerText (checked) {
-      dispatch(changeComposeSpoilerText(checked));
-    },
+  onPaste (files) {
+    dispatch(uploadCompose(files));
+  },
 
-    onChangeVisibility (checked) {
-      dispatch(changeComposeVisibility(checked));
-    },
+  onPickEmoji (position, data) {
+    dispatch(insertEmojiCompose(position, data));
+  },
 
-    onChangeListability (checked) {
-      dispatch(changeComposeListability(checked));
-    }
-  }
-};
+});
 
-export default connect(makeMapStateToProps, mapDispatchToProps)(ComposeForm);
+export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx b/app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx
new file mode 100644
index 000000000..1eee8f84c
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import PrivacyDropdown from '../components/privacy_dropdown';
+import { changeComposeVisibility } from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['compose', 'privacy'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeComposeVisibility(value));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
diff --git a/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx b/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx
new file mode 100644
index 000000000..39b48f3b6
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/reply_indicator_container.jsx
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { cancelReplyCompose } from '../../../actions/compose';
+import { makeGetStatus } from '../../../selectors';
+import ReplyIndicator from '../components/reply_indicator';
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onCancel () {
+    dispatch(cancelReplyCompose());
+  }
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
diff --git a/app/assets/javascripts/components/features/compose/containers/search_container.jsx b/app/assets/javascripts/components/features/compose/containers/search_container.jsx
index 17a68f2fc..906c0c28c 100644
--- a/app/assets/javascripts/components/features/compose/containers/search_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/search_container.jsx
@@ -1,15 +1,15 @@
 import { connect } from 'react-redux';
 import {
   changeSearch,
-  clearSearchSuggestions,
-  fetchSearchSuggestions,
-  resetSearch
+  clearSearch,
+  submitSearch,
+  showSearch
 } from '../../../actions/search';
 import Search from '../components/search';
 
 const mapStateToProps = state => ({
-  suggestions: state.getIn(['search', 'suggestions']),
-  value: state.getIn(['search', 'value'])
+  value: state.getIn(['search', 'value']),
+  submitted: state.getIn(['search', 'submitted'])
 });
 
 const mapDispatchToProps = dispatch => ({
@@ -19,15 +19,15 @@ const mapDispatchToProps = dispatch => ({
   },
 
   onClear () {
-    dispatch(clearSearchSuggestions());
+    dispatch(clearSearch());
   },
 
-  onFetch (value) {
-    dispatch(fetchSearchSuggestions(value));
+  onSubmit () {
+    dispatch(submitSearch());
   },
 
-  onReset () {
-    dispatch(resetSearch());
+  onShow () {
+    dispatch(showSearch());
   }
 
 });
diff --git a/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx b/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx
new file mode 100644
index 000000000..e5911fd38
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/search_results_container.jsx
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import SearchResults from '../components/search_results';
+
+const mapStateToProps = state => ({
+  results: state.getIn(['search', 'results'])
+});
+
+export default connect(mapStateToProps)(SearchResults);
diff --git a/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx
new file mode 100644
index 000000000..074b568f4
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx
@@ -0,0 +1,49 @@
+import { connect } from 'react-redux';
+import TextIconButton from '../components/text_icon_button';
+import { changeComposeSensitivity } from '../../../actions/compose';
+import { Motion, spring } from 'react-motion';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' }
+});
+
+const mapStateToProps = state => ({
+  visible: state.getIn(['compose', 'media_attachments']).size > 0,
+  active: state.getIn(['compose', 'sensitive'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSensitivity());
+  }
+
+});
+
+const SensitiveButton = React.createClass({
+
+  propTypes: {
+    visible: React.PropTypes.bool,
+    active: React.PropTypes.bool,
+    onClick: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
+  },
+
+  render () {
+    const { visible, active, onClick, intl } = this.props;
+
+    return (
+      <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
+        {({ scale }) =>
+          <div style={{ display: visible ? 'block' : 'none', transform: `translateZ(0) scale(${scale})` }}>
+            <TextIconButton onClick={onClick} label='NSFW' title={intl.formatMessage(messages.title)} active={active} />
+          </div>
+        }
+      </Motion>
+    );
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx
new file mode 100644
index 000000000..61ac32b85
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import TextIconButton from '../components/text_icon_button';
+import { changeComposeSpoilerness } from '../../../actions/compose';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind content warning' }
+});
+
+const mapStateToProps = (state, { intl }) => ({
+  label: 'CW',
+  title: intl.formatMessage(messages.title),
+  active: state.getIn(['compose', 'spoiler'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSpoilerness());
+  }
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));
diff --git a/app/assets/javascripts/components/features/compose/containers/upload_progress_container.jsx b/app/assets/javascripts/components/features/compose/containers/upload_progress_container.jsx
new file mode 100644
index 000000000..b0f1d4d19
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/upload_progress_container.jsx
@@ -0,0 +1,9 @@
+import { connect } from 'react-redux';
+import UploadProgress from '../components/upload_progress';
+
+const mapStateToProps = (state, props) => ({
+  active: state.getIn(['compose', 'is_uploading']),
+  progress: state.getIn(['compose', 'progress'])
+});
+
+export default connect(mapStateToProps)(UploadProgress);
diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx
index f6095c0c6..9421de3ff 100644
--- a/app/assets/javascripts/components/features/compose/index.jsx
+++ b/app/assets/javascripts/components/features/compose/index.jsx
@@ -1,17 +1,34 @@
-import Drawer from './components/drawer';
 import ComposeFormContainer from './containers/compose_form_container';
 import UploadFormContainer from './containers/upload_form_container';
 import NavigationContainer from './containers/navigation_container';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
-import SearchContainer from './containers/search_container';
 import { connect } from 'react-redux';
 import { mountCompose, unmountCompose } from '../../actions/compose';
+import { Link } from 'react-router';
+import { injectIntl, defineMessages } from 'react-intl';
+import SearchContainer from './containers/search_container';
+import { Motion, spring } from 'react-motion';
+import SearchResultsContainer from './containers/search_results_container';
+
+const messages = defineMessages({
+  start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+  public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' },
+  community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
+});
+
+const mapStateToProps = state => ({
+  showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden'])
+});
 
 const Compose = React.createClass({
 
   propTypes: {
     dispatch: React.PropTypes.func.isRequired,
-    withHeader: React.PropTypes.bool
+    withHeader: React.PropTypes.bool,
+    showSearch: React.PropTypes.bool,
+    intl: React.PropTypes.object.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -25,16 +42,46 @@ const Compose = React.createClass({
   },
 
   render () {
+    const { withHeader, showSearch, intl } = this.props;
+
+    let header = '';
+
+    if (withHeader) {
+      header = (
+        <div className='drawer__header'>
+          <Link title={intl.formatMessage(messages.start)} className='drawer__tab' to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
+          <Link title={intl.formatMessage(messages.community)} className='drawer__tab' to='/timelines/public/local'><i className='fa fa-fw fa-users' /></Link>
+          <Link title={intl.formatMessage(messages.public)} className='drawer__tab' to='/timelines/public'><i className='fa fa-fw fa-globe' /></Link>
+          <a title={intl.formatMessage(messages.preferences)} className='drawer__tab' href='/settings/preferences'><i className='fa fa-fw fa-cog' /></a>
+          <a title={intl.formatMessage(messages.logout)} className='drawer__tab' href='/auth/sign_out' data-method='delete'><i className='fa fa-fw fa-sign-out' /></a>
+        </div>
+      );
+    }
+
     return (
-      <Drawer withHeader={this.props.withHeader}>
+      <div className='drawer'>
+        {header}
+
         <SearchContainer />
-        <NavigationContainer />
-        <ComposeFormContainer />
-        <UploadFormContainer />
-      </Drawer>
+
+        <div className='drawer__pager'>
+          <div className='drawer__inner'>
+            <NavigationContainer />
+            <ComposeFormContainer />
+          </div>
+
+          <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
+            {({ x }) =>
+              <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
+                <SearchResultsContainer />
+              </div>
+            }
+          </Motion>
+        </div>
+      </div>
     );
   }
 
 });
 
-export default connect()(Compose);
+export default connect(mapStateToProps)(injectIntl(Compose));
diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx
index 0e1937b43..8253ad017 100644
--- a/app/assets/javascripts/components/features/getting_started/index.jsx
+++ b/app/assets/javascripts/components/features/getting_started/index.jsx
@@ -7,7 +7,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 
 const messages = defineMessages({
   heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
-  public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Public timeline' },
+  public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Whole Known Network' },
+  community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
   sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Sign out' },
@@ -30,6 +31,7 @@ const GettingStarted = ({ intl, me }) => {
   return (
     <Column icon='asterisk' heading={intl.formatMessage(messages.heading)}>
       <div style={{ position: 'relative' }}>
+        <ColumnLink icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />
         <ColumnLink icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />
         <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
         <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
@@ -39,12 +41,9 @@ const GettingStarted = ({ intl, me }) => {
         <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
       </div>
 
-      <div className='scrollable optionally-scrollable'>
+      <div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}>
         <div className='static-content getting-started'>
-          <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
-          <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
-          <p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p>
-          <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}' values={{ github: <a href="https://github.com/tootsuite/mastodon">tootsuite/mastodon</a> }} /></p>
+          <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p>
         </div>
       </div>
     </Column>
diff --git a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
index 4a0e7684d..7fb413336 100644
--- a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx
@@ -8,9 +8,11 @@ import {
   deleteFromTimelines
 } from '../../actions/timelines';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import { FormattedMessage } from 'react-intl';
 import createStream from '../../stream';
 
 const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0,
   accessToken: state.getIn(['meta', 'access_token'])
 });
 
@@ -19,7 +21,8 @@ const HashtagTimeline = React.createClass({
   propTypes: {
     params: React.PropTypes.object.isRequired,
     dispatch: React.PropTypes.func.isRequired,
-    accessToken: React.PropTypes.string.isRequired
+    accessToken: React.PropTypes.string.isRequired,
+    hasUnread: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
@@ -71,12 +74,12 @@ const HashtagTimeline = React.createClass({
   },
 
   render () {
-    const { id } = this.props.params;
+    const { id, hasUnread } = this.props.params;
 
     return (
-      <Column icon='hashtag' heading={id}>
+      <Column icon='hashtag' active={hasUnread} heading={id}>
         <ColumnBackButtonSlim />
-        <StatusListContainer type='tag' id={id} />
+        <StatusListContainer type='tag' id={id} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} />
       </Column>
     );
   },
diff --git a/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx
index 3317210bf..92e700874 100644
--- a/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx
+++ b/app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx
@@ -6,7 +6,7 @@ import SettingToggle from '../../notifications/components/setting_toggle';
 import SettingText from './setting_text';
 
 const messages = defineMessages({
-  filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter by regular expressions' }
+  filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' }
 });
 
 const outerStyle = {
@@ -44,7 +44,7 @@ const ColumnSettings = React.createClass({
           <span className='column-settings--section' style={sectionStyle}><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
 
           <div style={rowStyle}>
-            <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reblogs' />} />
+            <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} />
           </div>
 
           <div style={rowStyle}>
diff --git a/app/assets/javascripts/components/features/home_timeline/index.jsx b/app/assets/javascripts/components/features/home_timeline/index.jsx
index 5d2263f15..a2b775764 100644
--- a/app/assets/javascripts/components/features/home_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/home_timeline/index.jsx
@@ -1,32 +1,39 @@
+import { connect } from 'react-redux';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import StatusListContainer from '../ui/containers/status_list_container';
 import Column from '../ui/components/column';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
+import { Link } from 'react-router';
 
 const messages = defineMessages({
   title: { id: 'column.home', defaultMessage: 'Home' }
 });
 
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0
+});
+
 const HomeTimeline = React.createClass({
 
   propTypes: {
-    intl: React.PropTypes.object.isRequired
+    intl: React.PropTypes.object.isRequired,
+    hasUnread: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
 
   render () {
-    const { intl } = this.props;
+    const { intl, hasUnread } = this.props;
 
     return (
-      <Column icon='home' heading={intl.formatMessage(messages.title)}>
+      <Column icon='home' active={hasUnread} heading={intl.formatMessage(messages.title)}>
         <ColumnSettingsContainer />
-        <StatusListContainer {...this.props} type='home' />
+        <StatusListContainer {...this.props} type='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage="You aren't following anyone yet. Visit {public} or use search to get started and meet other users." values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} />
       </Column>
     );
   },
 
 });
 
-export default injectIntl(HomeTimeline);
+export default connect(mapStateToProps)(injectIntl(HomeTimeline));
diff --git a/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx
index 0b7c737c6..d75149a0e 100644
--- a/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx
+++ b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx
@@ -4,7 +4,8 @@ const iconStyle = {
   position: 'absolute',
   right: '48px',
   top: '0',
-  cursor: 'pointer'
+  cursor: 'pointer',
+  zIndex: '2'
 };
 
 const ClearColumnButton = ({ onClick }) => (
diff --git a/app/assets/javascripts/components/features/notifications/components/notification.jsx b/app/assets/javascripts/components/features/notifications/components/notification.jsx
index fa8466140..0de4df52e 100644
--- a/app/assets/javascripts/components/features/notifications/components/notification.jsx
+++ b/app/assets/javascripts/components/features/notifications/components/notification.jsx
@@ -5,7 +5,7 @@ import AccountContainer from '../../../containers/account_container';
 import { FormattedMessage } from 'react-intl';
 import Permalink from '../../../components/permalink';
 import emojify from '../../../emoji';
-import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
+import escapeTextContentForBrowser from 'escape-html';
 
 const linkStyle = {
   fontWeight: '500'
diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx
index 6d10768de..74b914ffd 100644
--- a/app/assets/javascripts/components/features/notifications/index.jsx
+++ b/app/assets/javascripts/components/features/notifications/index.jsx
@@ -2,10 +2,10 @@ import { connect } from 'react-redux';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import Column from '../ui/components/column';
-import { expandNotifications, clearNotifications } from '../../actions/notifications';
+import { expandNotifications, clearNotifications, scrollTopNotifications } from '../../actions/notifications';
 import NotificationContainer from './containers/notification_container';
 import { ScrollContainer } from 'react-router-scroll';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
 import { createSelector } from 'reselect';
 import Immutable from 'immutable';
@@ -13,7 +13,8 @@ import LoadMore from '../../components/load_more';
 import ClearColumnButton from './components/clear_column_button';
 
 const messages = defineMessages({
-  title: { id: 'column.notifications', defaultMessage: 'Notifications' }
+  title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+  confirm: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to clear all your notifications?' }
 });
 
 const getNotifications = createSelector([
@@ -23,7 +24,8 @@ const getNotifications = createSelector([
 
 const mapStateToProps = state => ({
   notifications: getNotifications(state),
-  isLoading: state.getIn(['notifications', 'isLoading'], true)
+  isLoading: state.getIn(['notifications', 'isLoading'], true),
+  isUnread: state.getIn(['notifications', 'unread']) > 0
 });
 
 const Notifications = React.createClass({
@@ -33,7 +35,8 @@ const Notifications = React.createClass({
     dispatch: React.PropTypes.func.isRequired,
     trackScroll: React.PropTypes.bool,
     intl: React.PropTypes.object.isRequired,
-    isLoading: React.PropTypes.bool
+    isLoading: React.PropTypes.bool,
+    isUnread: React.PropTypes.bool
   },
 
   getDefaultProps () {
@@ -51,6 +54,10 @@ const Notifications = React.createClass({
 
     if (250 > offset && !this.props.isLoading) {
       this.props.dispatch(expandNotifications());
+    } else if (scrollTop < 100) {
+      this.props.dispatch(scrollTopNotifications(true));
+    } else {
+      this.props.dispatch(scrollTopNotifications(false));
     }
   },
 
@@ -66,7 +73,9 @@ const Notifications = React.createClass({
   },
 
   handleClear () {
-    this.props.dispatch(clearNotifications());
+    if (window.confirm(this.props.intl.formatMessage(messages.confirm))) {
+      this.props.dispatch(clearNotifications());
+    }
   },
 
   setRef (c) {
@@ -74,26 +83,42 @@ const Notifications = React.createClass({
   },
 
   render () {
-    const { intl, notifications, trackScroll, isLoading } = this.props;
+    const { intl, notifications, trackScroll, isLoading, isUnread } = this.props;
 
-    let loadMore = '';
+    let loadMore       = '';
+    let scrollableArea = '';
+    let unread         = '';
 
     if (!isLoading && notifications.size > 0) {
       loadMore = <LoadMore onClick={this.handleLoadMore} />;
     }
 
-    const scrollableArea = (
-      <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}>
-        <div>
-          {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)}
-          {loadMore}
+    if (isUnread) {
+      unread = <div className='notifications__unread-indicator' />;
+    }
+
+    if (isLoading || notifications.size > 0) {
+      scrollableArea = (
+        <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}>
+          {unread}
+
+          <div>
+            {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)}
+            {loadMore}
+          </div>
         </div>
-      </div>
-    );
+      );
+    } else {
+      scrollableArea = (
+        <div className='empty-column-indicator' ref={this.setRef}>
+          <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
+        </div>
+      );
+    }
 
     if (trackScroll) {
       return (
-        <Column icon='bell' heading={intl.formatMessage(messages.title)}>
+        <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}>
           <ColumnSettingsContainer />
           <ClearColumnButton onClick={this.handleClear} />
           <ScrollContainer scrollKey='notifications'>
@@ -103,7 +128,7 @@ const Notifications = React.createClass({
       );
     } else {
       return (
-        <Column icon='bell' heading={intl.formatMessage(messages.title)}>
+        <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}>
           <ColumnSettingsContainer />
           <ClearColumnButton onClick={this.handleClear} />
           {scrollableArea}
diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx
index 36d68dbbb..6d766a83b 100644
--- a/app/assets/javascripts/components/features/public_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/public_timeline/index.jsx
@@ -5,26 +5,32 @@ import Column from '../ui/components/column';
 import {
   refreshTimeline,
   updateTimeline,
-  deleteFromTimelines
+  deleteFromTimelines,
+  connectTimeline,
+  disconnectTimeline
 } from '../../actions/timelines';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
 import createStream from '../../stream';
 
 const messages = defineMessages({
-  title: { id: 'column.public', defaultMessage: 'Public' }
+  title: { id: 'column.public', defaultMessage: 'Whole Known Network' }
 });
 
 const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
   accessToken: state.getIn(['meta', 'access_token'])
 });
 
+let subscription;
+
 const PublicTimeline = React.createClass({
 
   propTypes: {
     dispatch: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired,
-    accessToken: React.PropTypes.string.isRequired
+    accessToken: React.PropTypes.string.isRequired,
+    hasUnread: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
@@ -34,7 +40,23 @@ const PublicTimeline = React.createClass({
 
     dispatch(refreshTimeline('public'));
 
-    this.subscription = createStream(accessToken, 'public', {
+    if (typeof subscription !== 'undefined') {
+      return;
+    }
+
+    subscription = createStream(accessToken, 'public', {
+
+      connected () {
+        dispatch(connectTimeline('public'));
+      },
+
+      reconnected () {
+        dispatch(connectTimeline('public'));
+      },
+
+      disconnected () {
+        dispatch(disconnectTimeline('public'));
+      },
 
       received (data) {
         switch(data.event) {
@@ -51,19 +73,19 @@ const PublicTimeline = React.createClass({
   },
 
   componentWillUnmount () {
-    if (typeof this.subscription !== 'undefined') {
-      this.subscription.close();
-      this.subscription = null;
-    }
+    // if (typeof subscription !== 'undefined') {
+    //   subscription.close();
+    //   subscription = null;
+    // }
   },
 
   render () {
-    const { intl } = this.props;
+    const { intl, hasUnread } = this.props;
 
     return (
-      <Column icon='globe' heading={intl.formatMessage(messages.title)}>
+      <Column icon='globe' active={hasUnread} heading={intl.formatMessage(messages.title)}>
         <ColumnBackButtonSlim />
-        <StatusListContainer type='public' />
+        <StatusListContainer type='public' emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} />
       </Column>
     );
   },
diff --git a/app/assets/javascripts/components/features/status/components/action_bar.jsx b/app/assets/javascripts/components/features/status/components/action_bar.jsx
index cc4d5cca4..2aebcd709 100644
--- a/app/assets/javascripts/components/features/status/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/status/components/action_bar.jsx
@@ -6,11 +6,11 @@ import { defineMessages, injectIntl } from 'react-intl';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
-  mention: { id: 'status.mention', defaultMessage: 'Mention' },
+  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
   reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
-  report: { id: 'status.report', defaultMessage: 'Report' }
+  report: { id: 'status.report', defaultMessage: 'Report @{name}' }
 });
 
 const ActionBar = React.createClass({
@@ -66,14 +66,15 @@ const ActionBar = React.createClass({
     if (me === status.getIn(['account', 'id'])) {
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
-      menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
-      menu.push({ text: intl.formatMessage(messages.report), action: this.handleReport });
+      menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
     }
 
     return (
       <div className='detailed-status__action-bar'>
         <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
-        <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'private' ? 'lock' : 'retweet'} onClick={this.handleReblogClick} /></div>
+        <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton disabled={status.get('visibility') === 'direct' || status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon={status.get('visibility') === 'direct' ? 'envelope' : (status.get('visibility') === 'private' ? 'lock' : 'retweet')} onClick={this.handleReblogClick} /></div>
         <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
         <div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" /></div>
       </div>
diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
index 8a7c0c5d5..caa46ff3c 100644
--- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx
+++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
@@ -39,7 +39,7 @@ const DetailedStatus = React.createClass({
 
     if (status.get('media_attachments').size > 0) {
       if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
-        media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={317} height={178} />;
+        media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} autoplay />;
       } else {
         media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
       }
diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx
index 40c0460a5..f98fe1b01 100644
--- a/app/assets/javascripts/components/features/status/index.jsx
+++ b/app/assets/javascripts/components/features/status/index.jsx
@@ -1,29 +1,34 @@
-import { connect }           from 'react-redux';
-import PureRenderMixin       from 'react-addons-pure-render-mixin';
-import ImmutablePropTypes    from 'react-immutable-proptypes';
-import { fetchStatus }       from '../../actions/statuses';
-import Immutable             from 'immutable';
-import EmbeddedStatus        from '../../components/status';
-import LoadingIndicator      from '../../components/loading_indicator';
-import DetailedStatus        from './components/detailed_status';
-import ActionBar             from './components/action_bar';
-import Column                from '../ui/components/column';
-import { favourite, reblog } from '../../actions/interactions';
+import { connect } from 'react-redux';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchStatus } from '../../actions/statuses';
+import Immutable from 'immutable';
+import EmbeddedStatus from '../../components/status';
+import MissingIndicator from '../../components/missing_indicator';
+import DetailedStatus from './components/detailed_status';
+import ActionBar from './components/action_bar';
+import Column from '../ui/components/column';
+import {
+  favourite,
+  unfavourite,
+  reblog,
+  unreblog
+} from '../../actions/interactions';
 import {
   replyCompose,
   mentionCompose
-}                            from '../../actions/compose';
-import { deleteStatus }      from '../../actions/statuses';
+} from '../../actions/compose';
+import { deleteStatus } from '../../actions/statuses';
 import { initReport } from '../../actions/reports';
 import {
   makeGetStatus,
   getStatusAncestors,
   getStatusDescendants
-}                            from '../../selectors';
-import { ScrollContainer }   from 'react-router-scroll';
-import ColumnBackButton      from '../../components/column_back_button';
-import StatusContainer       from '../../containers/status_container';
-import { openMedia }         from '../../actions/modal';
+} from '../../selectors';
+import { ScrollContainer } from 'react-router-scroll';
+import ColumnBackButton from '../../components/column_back_button';
+import StatusContainer from '../../containers/status_container';
+import { openModal } from '../../actions/modal';
 import { isMobile } from '../../is_mobile'
 
 const makeMapStateToProps = () => {
@@ -94,7 +99,7 @@ const Status = React.createClass({
   },
 
   handleOpenMedia (media, index) {
-    this.props.dispatch(openMedia(media, index));
+    this.props.dispatch(openModal('MEDIA', { media, index }));
   },
 
   handleReport (status) {
@@ -112,7 +117,8 @@ const Status = React.createClass({
     if (status === null) {
       return (
         <Column>
-          <LoadingIndicator />
+          <ColumnBackButton />
+          <MissingIndicator />
         </Column>
       );
     }
diff --git a/app/assets/javascripts/components/features/ui/components/column.jsx b/app/assets/javascripts/components/features/ui/components/column.jsx
index 5b0603ee9..2b7e11bf1 100644
--- a/app/assets/javascripts/components/features/ui/components/column.jsx
+++ b/app/assets/javascripts/components/features/ui/components/column.jsx
@@ -34,7 +34,8 @@ const Column = React.createClass({
   propTypes: {
     heading: React.PropTypes.string,
     icon: React.PropTypes.string,
-    children: React.PropTypes.node
+    children: React.PropTypes.node,
+    active: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
@@ -51,12 +52,12 @@ const Column = React.createClass({
   },
 
   render () {
-    const { heading, icon, children } = this.props;
+    const { heading, icon, children, active } = this.props;
 
     let header = '';
 
     if (heading) {
-      header = <ColumnHeader icon={icon} type={heading} onClick={this.handleHeaderClick} />;
+      header = <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} />;
     }
 
     return (
diff --git a/app/assets/javascripts/components/features/ui/components/column_header.jsx b/app/assets/javascripts/components/features/ui/components/column_header.jsx
index 8b072d723..de55fa748 100644
--- a/app/assets/javascripts/components/features/ui/components/column_header.jsx
+++ b/app/assets/javascripts/components/features/ui/components/column_header.jsx
@@ -5,6 +5,7 @@ const ColumnHeader = React.createClass({
   propTypes: {
     icon: React.PropTypes.string,
     type: React.PropTypes.string,
+    active: React.PropTypes.bool,
     onClick: React.PropTypes.func
   },
 
@@ -15,6 +16,8 @@ const ColumnHeader = React.createClass({
   },
 
   render () {
+    const { type, active } = this.props;
+
     let icon = '';
 
     if (this.props.icon) {
@@ -22,9 +25,9 @@ const ColumnHeader = React.createClass({
     }
 
     return (
-      <div className='column-header' onClick={this.handleClick}>
+      <div className={`column-header ${active ? 'active' : ''}`} onClick={this.handleClick}>
         {icon}
-        {this.props.type}
+        {type}
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/ui/components/media_modal.jsx b/app/assets/javascripts/components/features/ui/components/media_modal.jsx
new file mode 100644
index 000000000..35eb2cb0c
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/media_modal.jsx
@@ -0,0 +1,133 @@
+import LoadingIndicator from '../../../components/loading_indicator';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ExtendedVideoPlayer from '../../../components/extended_video_player';
+import ImageLoader from 'react-imageloader';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' }
+});
+
+const leftNavStyle = {
+  position: 'absolute',
+  background: 'rgba(0, 0, 0, 0.5)',
+  padding: '30px 15px',
+  cursor: 'pointer',
+  fontSize: '24px',
+  top: '0',
+  left: '-61px',
+  boxSizing: 'border-box',
+  height: '100%',
+  display: 'flex',
+  alignItems: 'center'
+};
+
+const rightNavStyle = {
+  position: 'absolute',
+  background: 'rgba(0, 0, 0, 0.5)',
+  padding: '30px 15px',
+  cursor: 'pointer',
+  fontSize: '24px',
+  top: '0',
+  right: '-61px',
+  boxSizing: 'border-box',
+  height: '100%',
+  display: 'flex',
+  alignItems: 'center'
+};
+
+const closeStyle = {
+  position: 'absolute',
+  top: '4px',
+  right: '4px'
+};
+
+const MediaModal = React.createClass({
+
+  propTypes: {
+    media: ImmutablePropTypes.list.isRequired,
+    index: React.PropTypes.number.isRequired,
+    onClose: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
+  },
+
+  getInitialState () {
+    return {
+      index: null
+    };
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleNextClick () {
+    this.setState({ index: (this.getIndex() + 1) % this.props.media.size});
+  },
+
+  handlePrevClick () {
+    this.setState({ index: (this.getIndex() - 1) % this.props.media.size});
+  },
+
+  handleKeyUp (e) {
+    switch(e.key) {
+    case 'ArrowLeft':
+      this.handlePrevClick();
+      break;
+    case 'ArrowRight':
+      this.handleNextClick();
+      break;
+    }
+  },
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  },
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  },
+
+  getIndex () {
+    return this.state.index !== null ? this.state.index : this.props.index;
+  },
+
+  render () {
+    const { media, intl, onClose } = this.props;
+
+    const index = this.getIndex();
+    const attachment = media.get(index);
+    const url = attachment.get('url');
+
+    let leftNav, rightNav, content;
+
+    leftNav = rightNav = content = '';
+
+    if (media.size > 1) {
+      leftNav  = <div style={leftNavStyle} className='modal-container__nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
+      rightNav = <div style={rightNavStyle} className='modal-container__nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
+    }
+
+    if (attachment.get('type') === 'image') {
+      content = <ImageLoader src={url} imgProps={{ style: { display: 'block' } }} />;
+    } else if (attachment.get('type') === 'gifv') {
+      content = <ExtendedVideoPlayer src={url} />;
+    }
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        {leftNav}
+
+        <div>
+          <IconButton title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} style={closeStyle} />
+          {content}
+        </div>
+
+        {rightNav}
+      </div>
+    );
+  }
+
+});
+
+export default injectIntl(MediaModal);
diff --git a/app/assets/javascripts/components/features/ui/components/modal_root.jsx b/app/assets/javascripts/components/features/ui/components/modal_root.jsx
new file mode 100644
index 000000000..d2ae5e145
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/modal_root.jsx
@@ -0,0 +1,80 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import MediaModal from './media_modal';
+import { TransitionMotion, spring } from 'react-motion';
+
+const MODAL_COMPONENTS = {
+  'MEDIA': MediaModal
+};
+
+const ModalRoot = React.createClass({
+
+  propTypes: {
+    type: React.PropTypes.string,
+    props: React.PropTypes.object,
+    onClose: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleKeyUp (e) {
+    if (e.key === 'Escape' && !!this.props.type) {
+      this.props.onClose();
+    }
+  },
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  },
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  },
+
+  willEnter () {
+    return { opacity: 0, scale: 0.98 };
+  },
+
+  willLeave () {
+    return { opacity: spring(0), scale: spring(0.98) };
+  },
+
+  render () {
+    const { type, props, onClose } = this.props;
+    const items = [];
+
+    if (!!type) {
+      items.push({
+        key: type,
+        data: { type, props },
+        style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) }
+      });
+    }
+
+    return (
+      <TransitionMotion
+        styles={items}
+        willEnter={this.willEnter}
+        willLeave={this.willLeave}>
+        {interpolatedStyles =>
+          <div className='modal-root'>
+            {interpolatedStyles.map(({ key, data: { type, props }, style }) => {
+              const SpecificComponent = MODAL_COMPONENTS[type];
+
+              return (
+                <div key={key}>
+                  <div className='modal-root__overlay' style={{ opacity: style.opacity, transform: `translateZ(0px)` }} onClick={onClose} />
+                  <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
+                    <SpecificComponent {...props} onClose={onClose} />
+                  </div>
+                </div>
+              );
+            })}
+          </div>
+        }
+      </TransitionMotion>
+    );
+  }
+
+});
+
+export default ModalRoot;
diff --git a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
index 225a6a5fc..6cdb29dbf 100644
--- a/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
+++ b/app/assets/javascripts/components/features/ui/components/tabs_bar.jsx
@@ -1,15 +1,23 @@
 import { Link } from 'react-router';
 import { FormattedMessage } from 'react-intl';
 
-const TabsBar = () => {
-  return (
-    <div className='tabs-bar'>
-      <Link className='tabs-bar__link' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /> <FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
-      <Link className='tabs-bar__link' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /> <FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
-      <Link className='tabs-bar__link' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /> <FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
-      <Link className='tabs-bar__link' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link>
-    </div>
-  );
-};
+const TabsBar = React.createClass({
+
+  render () {
+    return (
+      <div className='tabs-bar'>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
+
+        <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public/local'><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></Link>
+        <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public'><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></Link>
+
+        <Link className='tabs-bar__link primary' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-bars' /></Link>
+      </div>
+    );
+  }
+
+});
 
 export default TabsBar;
diff --git a/app/assets/javascripts/components/features/ui/components/upload_area.jsx b/app/assets/javascripts/components/features/ui/components/upload_area.jsx
new file mode 100644
index 000000000..70b687019
--- /dev/null
+++ b/app/assets/javascripts/components/features/ui/components/upload_area.jsx
@@ -0,0 +1,32 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { Motion, spring } from 'react-motion';
+import { FormattedMessage } from 'react-intl';
+
+const UploadArea = React.createClass({
+
+  propTypes: {
+    active: React.PropTypes.bool
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    const { active } = this.props;
+
+    return (
+      <Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}>
+        {({ backgroundOpacity, backgroundScale }) =>
+          <div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}>
+            <div className='upload-area__drop'>
+              <div className='upload-area__background' style={{ transform: `translateZ(0) scale(${backgroundScale})` }} />
+              <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div>
+            </div>
+          </div>
+        }
+      </Motion>
+    );
+  }
+
+});
+
+export default UploadArea;
diff --git a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
index 4c47fb8c5..26d77818c 100644
--- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
@@ -1,167 +1,16 @@
 import { connect } from 'react-redux';
-import {
-  closeModal,
-  decreaseIndexInModal,
-  increaseIndexInModal
-} from '../../../actions/modal';
-import Lightbox from '../../../components/lightbox';
-import ImageLoader from 'react-imageloader';
-import LoadingIndicator from '../../../components/loading_indicator';
-import PureRenderMixin from 'react-addons-pure-render-mixin';
-import ImmutablePropTypes from 'react-immutable-proptypes';
+import { closeModal } from '../../../actions/modal';
+import ModalRoot from '../components/modal_root';
 
 const mapStateToProps = state => ({
-  media: state.getIn(['modal', 'media']),
-  index: state.getIn(['modal', 'index']),
-  isVisible: state.getIn(['modal', 'open'])
+  type: state.get('modal').modalType,
+  props: state.get('modal').modalProps
 });
 
 const mapDispatchToProps = dispatch => ({
-  onCloseClicked () {
+  onClose () {
     dispatch(closeModal());
   },
-
-  onOverlayClicked () {
-    dispatch(closeModal());
-  },
-
-  onNextClicked () {
-    dispatch(increaseIndexInModal());
-  },
-
-  onPrevClicked () {
-    dispatch(decreaseIndexInModal());
-  }
-});
-
-const imageStyle = {
-  display: 'block',
-  maxWidth: '80vw',
-  maxHeight: '80vh'
-};
-
-const loadingStyle = {
-  width: '400px',
-  paddingBottom: '120px'
-};
-
-const preloader = () => (
-  <div className='modal-container--preloader' style={loadingStyle}>
-    <LoadingIndicator />
-  </div>
-);
-
-const leftNavStyle = {
-  position: 'absolute',
-  background: 'rgba(0, 0, 0, 0.5)',
-  padding: '30px 15px',
-  cursor: 'pointer',
-  fontSize: '24px',
-  top: '0',
-  left: '-61px',
-  boxSizing: 'border-box',
-  height: '100%',
-  display: 'flex',
-  alignItems: 'center'
-};
-
-const rightNavStyle = {
-  position: 'absolute',
-  background: 'rgba(0, 0, 0, 0.5)',
-  padding: '30px 15px',
-  cursor: 'pointer',
-  fontSize: '24px',
-  top: '0',
-  right: '-61px',
-  boxSizing: 'border-box',
-  height: '100%',
-  display: 'flex',
-  alignItems: 'center'
-};
-
-const Modal = React.createClass({
-
-  propTypes: {
-    media: ImmutablePropTypes.list,
-    index: React.PropTypes.number.isRequired,
-    isVisible: React.PropTypes.bool,
-    onCloseClicked: React.PropTypes.func,
-    onOverlayClicked: React.PropTypes.func,
-    onNextClicked: React.PropTypes.func,
-    onPrevClicked: React.PropTypes.func
-  },
-
-  mixins: [PureRenderMixin],
-
-  handleNextClick () {
-    this.props.onNextClicked();
-  },
-
-  handlePrevClick () {
-    this.props.onPrevClicked();
-  },
-
-  componentDidMount () {
-    this._listener = e => {
-      if (!this.props.isVisible) {
-        return;
-      }
-
-      switch(e.key) {
-      case 'ArrowLeft':
-        this.props.onPrevClicked();
-        break;
-      case 'ArrowRight':
-        this.props.onNextClicked();
-        break;
-      }
-    };
-
-    window.addEventListener('keyup', this._listener);
-  },
-
-  componentWillUnmount () {
-    window.removeEventListener('keyup', this._listener);
-  },
-
-  render () {
-    const { media, index, ...other } = this.props;
-
-    if (!media) {
-      return null;
-    }
-
-    const url      = media.get(index).get('url');
-    const hasLeft  = index > 0;
-    const hasRight = index + 1 < media.size;
-
-    let leftNav, rightNav;
-
-    leftNav = rightNav = '';
-
-    if (hasLeft) {
-      leftNav = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
-    }
-
-    if (hasRight) {
-      rightNav = <div style={rightNavStyle} className='modal-container--nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
-    }
-
-    return (
-      <Lightbox {...other}>
-        {leftNav}
-
-        <ImageLoader
-          src={url}
-          preloader={preloader}
-          imgProps={{ style: imageStyle }}
-        />
-
-        {rightNav}
-      </Lightbox>
-    );
-  }
-
 });
 
-export default connect(mapStateToProps, mapDispatchToProps)(Modal);
+export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);
diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
index 100989d22..f249240d8 100644
--- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx
@@ -3,8 +3,9 @@ import StatusList from '../../../components/status_list';
 import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines';
 import Immutable from 'immutable';
 import { createSelector } from 'reselect';
+import { debounce } from 'react-decoration';
 
-const getStatusIds = createSelector([
+const makeGetStatusIds = () => createSelector([
   (state, { type }) => state.getIn(['settings', type], Immutable.Map()),
   (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()),
   (state)           => state.get('statuses'),
@@ -33,26 +34,37 @@ const getStatusIds = createSelector([
   return showStatus;
 }));
 
-const mapStateToProps = (state, props) => ({
-  statusIds: getStatusIds(state, props),
-  isLoading: state.getIn(['timelines', props.type, 'isLoading'], true)
-});
+const makeMapStateToProps = () => {
+  const getStatusIds = makeGetStatusIds();
+
+  const mapStateToProps = (state, props) => ({
+    statusIds: getStatusIds(state, props),
+    isLoading: state.getIn(['timelines', props.type, 'isLoading'], true),
+    isUnread: state.getIn(['timelines', props.type, 'unread']) > 0,
+    hasMore: !!state.getIn(['timelines', props.type, 'next'])
+  });
+
+  return mapStateToProps;
+};
 
 const mapDispatchToProps = (dispatch, { type, id }) => ({
 
+  @debounce(300, true)
   onScrollToBottom () {
     dispatch(scrollTopTimeline(type, false));
     dispatch(expandTimeline(type, id));
   },
 
+  @debounce(100)
   onScrollToTop () {
     dispatch(scrollTopTimeline(type, true));
   },
 
+  @debounce(100)
   onScroll () {
     dispatch(scrollTopTimeline(type, false));
   }
 
 });
 
-export default connect(mapStateToProps, mapDispatchToProps)(StatusList);
+export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx
index 900d83dba..89fb82568 100644
--- a/app/assets/javascripts/components/features/ui/index.jsx
+++ b/app/assets/javascripts/components/features/ui/index.jsx
@@ -13,6 +13,7 @@ import { debounce } from 'react-decoration';
 import { uploadCompose } from '../../actions/compose';
 import { refreshTimeline } from '../../actions/timelines';
 import { refreshNotifications } from '../../actions/notifications';
+import UploadArea from './components/upload_area';
 
 const UI = React.createClass({
 
@@ -23,7 +24,8 @@ const UI = React.createClass({
 
   getInitialState () {
     return {
-      width: window.innerWidth
+      width: window.innerWidth,
+      draggingOver: false
     };
   },
 
@@ -34,29 +36,64 @@ const UI = React.createClass({
     this.setState({ width: window.innerWidth });
   },
 
+  handleDragEnter (e) {
+    e.preventDefault();
+
+    if (!this.dragTargets) {
+      this.dragTargets = [];
+    }
+
+    if (this.dragTargets.indexOf(e.target) === -1) {
+      this.dragTargets.push(e.target);
+    }
+
+    if (e.dataTransfer && e.dataTransfer.files.length > 0) {
+      this.setState({ draggingOver: true });
+    }
+  },
+
   handleDragOver (e) {
     e.preventDefault();
     e.stopPropagation();
 
-    e.dataTransfer.dropEffect = 'copy';
+    try {
+      e.dataTransfer.dropEffect = 'copy';
+    } catch (err) {
 
-    if (e.dataTransfer.effectAllowed === 'all' || e.dataTransfer.effectAllowed === 'uninitialized') {
-      //
     }
+
+    return false;
   },
 
   handleDrop (e) {
     e.preventDefault();
 
+    this.setState({ draggingOver: false });
+
     if (e.dataTransfer && e.dataTransfer.files.length === 1) {
       this.props.dispatch(uploadCompose(e.dataTransfer.files));
     }
   },
 
+  handleDragLeave (e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
+
+    if (this.dragTargets.length > 0) {
+      return;
+    }
+
+    this.setState({ draggingOver: false });
+  },
+
   componentWillMount () {
     window.addEventListener('resize', this.handleResize, { passive: true });
-    window.addEventListener('dragover', this.handleDragOver);
-    window.addEventListener('drop', this.handleDrop);
+    document.addEventListener('dragenter', this.handleDragEnter, false);
+    document.addEventListener('dragover', this.handleDragOver, false);
+    document.addEventListener('drop', this.handleDrop, false);
+    document.addEventListener('dragleave', this.handleDragLeave, false);
 
     this.props.dispatch(refreshTimeline('home'));
     this.props.dispatch(refreshNotifications());
@@ -64,17 +101,26 @@ const UI = React.createClass({
 
   componentWillUnmount () {
     window.removeEventListener('resize', this.handleResize);
-    window.removeEventListener('dragover', this.handleDragOver);
-    window.removeEventListener('drop', this.handleDrop);
+    document.removeEventListener('dragenter', this.handleDragEnter);
+    document.removeEventListener('dragover', this.handleDragOver);
+    document.removeEventListener('drop', this.handleDrop);
+    document.removeEventListener('dragleave', this.handleDragLeave);
+  },
+
+  setRef (c) {
+    this.node = c;
   },
 
   render () {
+    const { width, draggingOver } = this.state;
+    const { children } = this.props;
+
     let mountedColumns;
 
-    if (isMobile(this.state.width)) {
+    if (isMobile(width)) {
       mountedColumns = (
         <ColumnsArea>
-          {this.props.children}
+          {children}
         </ColumnsArea>
       );
     } else {
@@ -83,13 +129,13 @@ const UI = React.createClass({
           <Compose withHeader={true} />
           <HomeTimeline trackScroll={false} />
           <Notifications trackScroll={false} />
-          {this.props.children}
+          {children}
         </ColumnsArea>
       );
     }
 
     return (
-      <div className='ui'>
+      <div className='ui' ref={this.setRef}>
         <TabsBar />
 
         {mountedColumns}
@@ -97,6 +143,7 @@ const UI = React.createClass({
         <NotificationsContainer />
         <LoadingBarContainer style={{ backgroundColor: '#2b90d9', left: '0', top: '0' }} />
         <ModalContainer />
+        <UploadArea active={draggingOver} />
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/is_mobile.jsx b/app/assets/javascripts/components/is_mobile.jsx
index eaa6221e4..992e63727 100644
--- a/app/assets/javascripts/components/is_mobile.jsx
+++ b/app/assets/javascripts/components/is_mobile.jsx
@@ -3,3 +3,9 @@ const LAYOUT_BREAKPOINT = 1024;
 export function isMobile(width) {
   return width <= LAYOUT_BREAKPOINT;
 };
+
+const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+
+export function isIOS() {
+  return iOS;
+};
diff --git a/app/assets/javascripts/components/locales/de.jsx b/app/assets/javascripts/components/locales/de.jsx
index 7d32824f1..882c31fa7 100644
--- a/app/assets/javascripts/components/locales/de.jsx
+++ b/app/assets/javascripts/components/locales/de.jsx
@@ -39,7 +39,7 @@ const en = {
   "tabs_bar.public": "Gesamtes Netz",
   "tabs_bar.notifications": "Mitteilungen",
   "compose_form.placeholder": "Worüber möchstest du schreiben?",
-  "compose_form.publish": "Veröffentlichen",
+  "compose_form.publish": "Tröt",
   "compose_form.sensitive": "Medien als sensitiv markieren",
   "compose_form.unlisted": "Öffentlich nicht auflisten",
   "compose_form.private": "Als privat markieren",
diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx
index 95962fd73..2d3360b6b 100644
--- a/app/assets/javascripts/components/locales/en.jsx
+++ b/app/assets/javascripts/components/locales/en.jsx
@@ -2,7 +2,7 @@ const en = {
   "column_back_button.label": "Back",
   "lightbox.close": "Close",
   "loading_indicator.label": "Loading...",
-  "status.mention": "Mention",
+  "status.mention": "Mention @{name}",
   "status.delete": "Delete",
   "status.reply": "Reply",
   "status.reblog": "Boost",
@@ -11,11 +11,11 @@ const en = {
   "status.sensitive_warning": "Sensitive content",
   "status.sensitive_toggle": "Click to view",
   "video_player.toggle_sound": "Toggle sound",
-  "account.mention": "Mention",
+  "account.mention": "Mention @{name}",
   "account.edit_profile": "Edit profile",
-  "account.unblock": "Unblock",
+  "account.unblock": "Unblock @{name}",
   "account.unfollow": "Unfollow",
-  "account.block": "Block",
+  "account.block": "Block @{name}",
   "account.follow": "Follow",
   "account.posts": "Posts",
   "account.follows": "Follows",
@@ -25,27 +25,27 @@ const en = {
   "getting_started.heading": "Getting started",
   "getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.",
   "getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.",
-  "getting_started.about_developer": "The developer of this project can be followed as Gargron@mastodon.social",
-  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}.",
+  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}. {apps}.",
   "column.home": "Home",
-  "column.mentions": "Mentions",
-  "column.public": "Public",
+  "column.community": "Local timeline",
+  "column.public": "Federated timeline",
   "column.notifications": "Notifications",
   "tabs_bar.compose": "Compose",
   "tabs_bar.home": "Home",
   "tabs_bar.mentions": "Mentions",
-  "tabs_bar.public": "Public",
+  "tabs_bar.public": "Federated timeline",
   "tabs_bar.notifications": "Notifications",
   "compose_form.placeholder": "What is on your mind?",
   "compose_form.publish": "Toot",
   "compose_form.sensitive": "Mark media as sensitive",
   "compose_form.spoiler": "Hide text behind warning",
   "compose_form.private": "Mark as private",
-  "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}} to not leak your status?",
-  "compose_form.unlisted": "Do not display in public timeline",
+  "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
+  "compose_form.unlisted": "Do not display on public timelines",
   "navigation_bar.edit_profile": "Edit profile",
   "navigation_bar.preferences": "Preferences",
-  "navigation_bar.public_timeline": "Public timeline",
+  "navigation_bar.community_timeline": "Local timeline",
+  "navigation_bar.public_timeline": "Federated timeline",
   "navigation_bar.logout": "Logout",
   "reply_indicator.cancel": "Cancel",
   "search.placeholder": "Search",
diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx
index 2f5dd182f..b52aef0fb 100644
--- a/app/assets/javascripts/components/locales/fr.jsx
+++ b/app/assets/javascripts/components/locales/fr.jsx
@@ -15,6 +15,8 @@ const fr = {
   "column.mentions": "Mentions",
   "column.notifications": "Notifications",
   "column.public": "Fil public",
+  "column.blocks": "Utilisateurs bloqués",
+  "column.favourites": "Favoris",
   "compose_form.placeholder": "Qu’avez-vous en tête ?",
   "compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ?",
   "compose_form.private": "Rendre privé",
@@ -33,6 +35,10 @@ const fr = {
   "navigation_bar.logout": "Déconnexion",
   "navigation_bar.preferences": "Préférences",
   "navigation_bar.public_timeline": "Fil public",
+  "navigation_bar.community_timeline": "Fil local",
+  "navigation_bar.blocks": "Utilisateurs bloqués",
+  "navigation_bar.favourites": "Favoris",
+  "navigation_bar.info": "Plus d'informations",
   "notification.favourite": "{name} a ajouté à ses favoris :",
   "notification.follow": "{name} vous suit.",
   "notification.mention": "{name} vous a mentionné⋅e :",
diff --git a/app/assets/javascripts/components/middleware/errors.jsx b/app/assets/javascripts/components/middleware/errors.jsx
index 74d77f0f9..4aca75f1e 100644
--- a/app/assets/javascripts/components/middleware/errors.jsx
+++ b/app/assets/javascripts/components/middleware/errors.jsx
@@ -5,7 +5,7 @@ const defaultFailSuffix = 'FAIL';
 
 export default function errorsMiddleware() {
   return ({ dispatch }) => next => action => {
-    if (action.type) {
+    if (action.type && !action.skipAlert) {
       const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');
       const isSuccess = new RegExp(`${defaultSuccessSuffix}$`, 'g');
 
diff --git a/app/assets/javascripts/components/middleware/sounds.jsx b/app/assets/javascripts/components/middleware/sounds.jsx
new file mode 100644
index 000000000..200efa3d7
--- /dev/null
+++ b/app/assets/javascripts/components/middleware/sounds.jsx
@@ -0,0 +1,22 @@
+const play = audio => {
+  if (!audio.paused) {
+    audio.pause();
+    audio.fastSeek(0);
+  }
+
+  audio.play();
+};
+
+export default function soundsMiddleware() {
+  const soundCache = {
+    boop: new Audio(['/sounds/boop.mp3'])
+  };
+
+  return ({ dispatch }) => next => (action) => {
+    if (action.meta && action.meta.sound && soundCache[action.meta.sound]) {
+      play(soundCache[action.meta.sound]);
+    }
+
+    return next(action);
+  };
+};
diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx
index f3938cee1..df9440093 100644
--- a/app/assets/javascripts/components/reducers/accounts.jsx
+++ b/app/assets/javascripts/components/reducers/accounts.jsx
@@ -33,7 +33,7 @@ import {
   STATUS_FETCH_SUCCESS,
   CONTEXT_FETCH_SUCCESS
 } from '../actions/statuses';
-import { SEARCH_SUGGESTIONS_READY } from '../actions/search';
+import { SEARCH_FETCH_SUCCESS } from '../actions/search';
 import {
   NOTIFICATIONS_UPDATE,
   NOTIFICATIONS_REFRESH_SUCCESS,
@@ -90,7 +90,6 @@ export default function accounts(state = initialState, action) {
   case REBLOGS_FETCH_SUCCESS:
   case FAVOURITES_FETCH_SUCCESS:
   case COMPOSE_SUGGESTIONS_READY:
-  case SEARCH_SUGGESTIONS_READY:
   case FOLLOW_REQUESTS_FETCH_SUCCESS:
   case FOLLOW_REQUESTS_EXPAND_SUCCESS:
   case BLOCKS_FETCH_SUCCESS:
@@ -98,6 +97,7 @@ export default function accounts(state = initialState, action) {
     return normalizeAccounts(state, action.accounts);
   case NOTIFICATIONS_REFRESH_SUCCESS:
   case NOTIFICATIONS_EXPAND_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
     return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
   case TIMELINE_REFRESH_SUCCESS:
   case TIMELINE_EXPAND_SUCCESS:
diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx
index 77ec2705f..4470ad643 100644
--- a/app/assets/javascripts/components/reducers/compose.jsx
+++ b/app/assets/javascripts/components/reducers/compose.jsx
@@ -20,7 +20,8 @@ import {
   COMPOSE_SPOILERNESS_CHANGE,
   COMPOSE_SPOILER_TEXT_CHANGE,
   COMPOSE_VISIBILITY_CHANGE,
-  COMPOSE_LISTABILITY_CHANGE
+  COMPOSE_LISTABILITY_CHANGE,
+  COMPOSE_EMOJI_INSERT
 } from '../actions/compose';
 import { TIMELINE_DELETE } from '../actions/timelines';
 import { STORE_HYDRATE } from '../actions/store';
@@ -31,10 +32,10 @@ const initialState = Immutable.Map({
   sensitive: false,
   spoiler: false,
   spoiler_text: '',
-  unlisted: false,
-  private: false,
+  privacy: null,
   text: '',
-  fileDropDate: null,
+  focusDate: null,
+  preselectDate: null,
   in_reply_to: null,
   is_submitting: false,
   is_uploading: false,
@@ -65,8 +66,7 @@ function clearAll(state) {
     map.set('spoiler_text', '');
     map.set('is_submitting', false);
     map.set('in_reply_to', null);
-    map.set('unlisted', state.get('default_privacy') === 'unlisted');
-    map.set('private', state.get('default_privacy') === 'private');
+    map.set('privacy', state.get('default_privacy'));
     map.update('media_attachments', list => list.clear());
   });
 };
@@ -99,9 +99,31 @@ const insertSuggestion = (state, position, token, completion) => {
     map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
     map.set('suggestion_token', null);
     map.update('suggestions', Immutable.List(), list => list.clear());
+    map.set('focusDate', new Date());
   });
 };
 
+const insertEmoji = (state, position, emojiData) => {
+  const emoji = emojiData.shortname;
+
+  return state.withMutations(map => {
+    map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);
+    map.set('focusDate', new Date());
+  });
+};
+
+const privacyPreference = (a, b) => {
+  if (a === 'direct' || b === 'direct') {
+    return 'direct';
+  } else if (a === 'private' || b === 'private') {
+    return 'private';
+  } else if (a === 'unlisted' || b === 'unlisted') {
+    return 'unlisted';
+  } else {
+    return 'public';
+  }
+};
+
 export default function compose(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
@@ -111,30 +133,38 @@ export default function compose(state = initialState, action) {
   case COMPOSE_UNMOUNT:
     return state.set('mounted', false);
   case COMPOSE_SENSITIVITY_CHANGE:
-    return state.set('sensitive', action.checked);
+    return state.set('sensitive', !state.get('sensitive'));
   case COMPOSE_SPOILERNESS_CHANGE:
-    return (action.checked ? state : state.set('spoiler_text', '')).set('spoiler', action.checked);
+    return state.withMutations(map => {
+      map.set('spoiler_text', '');
+      map.set('spoiler', !state.get('spoiler'));
+    });
   case COMPOSE_SPOILER_TEXT_CHANGE:
     return state.set('spoiler_text', action.text);
   case COMPOSE_VISIBILITY_CHANGE:
-    return state.set('private', action.checked);
-  case COMPOSE_LISTABILITY_CHANGE:
-    return state.set('unlisted', action.checked);
+    return state.set('privacy', action.value);
   case COMPOSE_CHANGE:
     return state.set('text', action.text);
   case COMPOSE_REPLY:
     return state.withMutations(map => {
       map.set('in_reply_to', action.status.get('id'));
       map.set('text', statusToTextMentions(state, action.status));
-      map.set('unlisted', action.status.get('visibility') === 'unlisted');
-      map.set('private', action.status.get('visibility') === 'private');
+      map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
+      map.set('focusDate', new Date());
+      map.set('preselectDate', new Date());
+
+      if (action.status.get('spoiler_text').length > 0) {
+        map.set('spoiler', true);
+        map.set('spoiler_text', action.status.get('spoiler_text'));
+      }
     });
   case COMPOSE_REPLY_CANCEL:
     return state.withMutations(map => {
       map.set('in_reply_to', null);
       map.set('text', '');
-      map.set('unlisted', state.get('default_privacy') === 'unlisted');
-      map.set('private', state.get('default_privacy') === 'private');
+      map.set('spoiler', false);
+      map.set('spoiler_text', '');
+      map.set('privacy', state.get('default_privacy'));
     });
   case COMPOSE_SUBMIT_REQUEST:
     return state.set('is_submitting', true);
@@ -145,7 +175,6 @@ export default function compose(state = initialState, action) {
   case COMPOSE_UPLOAD_REQUEST:
     return state.withMutations(map => {
       map.set('is_uploading', true);
-      map.set('fileDropDate', new Date());
     });
   case COMPOSE_UPLOAD_SUCCESS:
     return appendMedia(state, Immutable.fromJS(action.media));
@@ -156,7 +185,7 @@ export default function compose(state = initialState, action) {
   case COMPOSE_UPLOAD_PROGRESS:
     return state.set('progress', Math.round((action.loaded / action.total) * 100));
   case COMPOSE_MENTION:
-    return state.update('text', text => `${text}@${action.account.get('acct')} `);
+    return state.update('text', text => `${text}@${action.account.get('acct')} `).set('focusDate', new Date());
   case COMPOSE_SUGGESTIONS_CLEAR:
     return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
   case COMPOSE_SUGGESTIONS_READY:
@@ -169,6 +198,8 @@ export default function compose(state = initialState, action) {
     } else {
       return state;
     }
+  case COMPOSE_EMOJI_INSERT:
+    return insertEmoji(state, action.position, action.emoji);
   default:
     return state;
   }
diff --git a/app/assets/javascripts/components/reducers/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx
index 07da65771..3566820ef 100644
--- a/app/assets/javascripts/components/reducers/modal.jsx
+++ b/app/assets/javascripts/components/reducers/modal.jsx
@@ -1,31 +1,17 @@
-import {
-  MEDIA_OPEN,
-  MODAL_CLOSE,
-  MODAL_INDEX_DECREASE,
-  MODAL_INDEX_INCREASE
-} from '../actions/modal';
+import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal';
 import Immutable from 'immutable';
 
-const initialState = Immutable.Map({
-  media: null,
-  index: 0,
-  open: false
-});
+const initialState = {
+  modalType: null,
+  modalProps: {}
+};
 
 export default function modal(state = initialState, action) {
   switch(action.type) {
-  case MEDIA_OPEN:
-    return state.withMutations(map => {
-      map.set('media', action.media);
-      map.set('index', action.index);
-      map.set('open', true);
-    });
+  case MODAL_OPEN:
+    return { modalType: action.modalType, modalProps: action.modalProps };
   case MODAL_CLOSE:
-    return state.set('open', false);
-  case MODAL_INDEX_DECREASE:
-    return state.update('index', index => Math.max(index - 1, 0));
-  case MODAL_INDEX_INCREASE:
-    return state.update('index', index => Math.min(index + 1, state.get('media').size - 1));
+    return initialState;
   default:
     return state;
   }
diff --git a/app/assets/javascripts/components/reducers/notifications.jsx b/app/assets/javascripts/components/reducers/notifications.jsx
index 4a7af8856..1406a388a 100644
--- a/app/assets/javascripts/components/reducers/notifications.jsx
+++ b/app/assets/javascripts/components/reducers/notifications.jsx
@@ -6,7 +6,8 @@ import {
   NOTIFICATIONS_EXPAND_REQUEST,
   NOTIFICATIONS_REFRESH_FAIL,
   NOTIFICATIONS_EXPAND_FAIL,
-  NOTIFICATIONS_CLEAR
+  NOTIFICATIONS_CLEAR,
+  NOTIFICATIONS_SCROLL_TOP
 } from '../actions/notifications';
 import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
 import Immutable from 'immutable';
@@ -14,6 +15,8 @@ import Immutable from 'immutable';
 const initialState = Immutable.Map({
   items: Immutable.List(),
   next: null,
+  top: true,
+  unread: 0,
   loaded: false,
   isLoading: true
 });
@@ -26,6 +29,10 @@ const notificationToMap = notification => Immutable.Map({
 });
 
 const normalizeNotification = (state, notification) => {
+  if (!state.get('top')) {
+    state = state.update('unread', unread => unread + 1);
+  }
+
   return state.update('items', list => list.unshift(notificationToMap(notification)));
 };
 
@@ -37,9 +44,12 @@ const normalizeNotifications = (state, notifications, next) => {
     items = items.set(i, notificationToMap(n));
   });
 
+  if (state.get('next') === null) {
+    state = state.set('next', next);
+  }
+
   return state
     .update('items', list => loaded ? list.unshift(...items) : list.push(...items))
-    .set('next', next)
     .set('loaded', true)
     .set('isLoading', false);
 };
@@ -61,6 +71,14 @@ const filterNotifications = (state, relationship) => {
   return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id));
 };
 
+const updateTop = (state, top) => {
+  if (top) {
+    state = state.set('unread', 0);
+  }
+
+  return state.set('top', top);
+};
+
 export default function notifications(state = initialState, action) {
   switch(action.type) {
   case NOTIFICATIONS_REFRESH_REQUEST:
@@ -68,6 +86,8 @@ export default function notifications(state = initialState, action) {
   case NOTIFICATIONS_REFRESH_FAIL:
   case NOTIFICATIONS_EXPAND_FAIL:
     return state.set('isLoading', true);
+  case NOTIFICATIONS_SCROLL_TOP:
+    return updateTop(state, action.top);
   case NOTIFICATIONS_UPDATE:
     return normalizeNotification(state, action.notification);
   case NOTIFICATIONS_REFRESH_SUCCESS:
diff --git a/app/assets/javascripts/components/reducers/relationships.jsx b/app/assets/javascripts/components/reducers/relationships.jsx
index e4af1f028..c65c48b43 100644
--- a/app/assets/javascripts/components/reducers/relationships.jsx
+++ b/app/assets/javascripts/components/reducers/relationships.jsx
@@ -3,6 +3,8 @@ import {
   ACCOUNT_UNFOLLOW_SUCCESS,
   ACCOUNT_BLOCK_SUCCESS,
   ACCOUNT_UNBLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+  ACCOUNT_UNMUTE_SUCCESS,
   RELATIONSHIPS_FETCH_SUCCESS
 } from '../actions/accounts';
 import Immutable from 'immutable';
@@ -21,14 +23,16 @@ const initialState = Immutable.Map();
 
 export default function relationships(state = initialState, action) {
   switch(action.type) {
-    case ACCOUNT_FOLLOW_SUCCESS:
-    case ACCOUNT_UNFOLLOW_SUCCESS:
-    case ACCOUNT_BLOCK_SUCCESS:
-    case ACCOUNT_UNBLOCK_SUCCESS:
-      return normalizeRelationship(state, action.relationship);
-    case RELATIONSHIPS_FETCH_SUCCESS:
-      return normalizeRelationships(state, action.relationships);
-    default:
-      return state;
+  case ACCOUNT_FOLLOW_SUCCESS:
+  case ACCOUNT_UNFOLLOW_SUCCESS:
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_UNBLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+  case ACCOUNT_UNMUTE_SUCCESS:
+    return normalizeRelationship(state, action.relationship);
+  case RELATIONSHIPS_FETCH_SUCCESS:
+    return normalizeRelationships(state, action.relationships);
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/search.jsx b/app/assets/javascripts/components/reducers/search.jsx
index d835ef268..b3fe6c7be 100644
--- a/app/assets/javascripts/components/reducers/search.jsx
+++ b/app/assets/javascripts/components/reducers/search.jsx
@@ -1,38 +1,64 @@
 import {
   SEARCH_CHANGE,
-  SEARCH_SUGGESTIONS_READY,
-  SEARCH_RESET
+  SEARCH_CLEAR,
+  SEARCH_FETCH_SUCCESS,
+  SEARCH_SHOW
 } from '../actions/search';
+import { COMPOSE_MENTION, COMPOSE_REPLY } from '../actions/compose';
 import Immutable from 'immutable';
 
 const initialState = Immutable.Map({
   value: '',
-  loaded_value: '',
-  suggestions: []
+  submitted: false,
+  hidden: false,
+  results: Immutable.Map()
 });
 
-const normalizeSuggestions = (state, value, accounts) => {
-  let newSuggestions = [
-    {
+const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => {
+  let newSuggestions = [];
+
+  if (accounts.length > 0) {
+    newSuggestions.push({
       title: 'account',
       items: accounts.map(item => ({
         type: 'account',
         id: item.id,
         value: item.acct
       }))
+    });
+  }
+
+  if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 || hashtags.length > 0) {
+    let hashtagItems = hashtags.map(item => ({
+      type: 'hashtag',
+      id: item,
+      value: `#${item}`
+    }));
+
+    if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 && !value.startsWith('http://') && !value.startsWith('https://') && hashtags.indexOf(value) === -1) {
+      hashtagItems.unshift({
+        type: 'hashtag',
+        id: value,
+        value: `#${value}`
+      });
     }
-  ];
 
-  if (value.indexOf('@') === -1 && value.indexOf(' ') === -1) {
+    if (hashtagItems.length > 0) {
+      newSuggestions.push({
+        title: 'hashtag',
+        items: hashtagItems
+      });
+    }
+  }
+
+  if (statuses.length > 0) {
     newSuggestions.push({
-      title: 'hashtag',
-      items: [
-        {
-          type: 'hashtag',
-          id: value,
-          value: `#${value}`
-        }
-      ]
+      title: 'status',
+      items: statuses.map(item => ({
+        type: 'status',
+        id: item.id,
+        value: item.id
+      }))
     });
   }
 
@@ -44,17 +70,27 @@ const normalizeSuggestions = (state, value, accounts) => {
 
 export default function search(state = initialState, action) {
   switch(action.type) {
-    case SEARCH_CHANGE:
-      return state.set('value', action.value);
-    case SEARCH_SUGGESTIONS_READY:
-      return normalizeSuggestions(state, action.value, action.accounts);
-    case SEARCH_RESET:
-      return state.withMutations(map => {
-        map.set('suggestions', []);
-        map.set('value', '');
-        map.set('loaded_value', '');
-      });
-    default:
-      return state;
+  case SEARCH_CHANGE:
+    return state.set('value', action.value);
+  case SEARCH_CLEAR:
+    return state.withMutations(map => {
+      map.set('value', '');
+      map.set('results', Immutable.Map());
+      map.set('submitted', false);
+      map.set('hidden', false);
+    });
+  case SEARCH_SHOW:
+    return state.set('hidden', false);
+  case COMPOSE_REPLY:
+  case COMPOSE_MENTION:
+    return state.set('hidden', true);
+  case SEARCH_FETCH_SUCCESS:
+    return state.set('results', Immutable.Map({
+      accounts: Immutable.List(action.results.accounts.map(item => item.id)),
+      statuses: Immutable.List(action.results.statuses.map(item => item.id)),
+      hashtags: Immutable.List(action.results.hashtags)
+    })).set('submitted', true);
+  default:
+    return state;
   }
 };
diff --git a/app/assets/javascripts/components/reducers/statuses.jsx b/app/assets/javascripts/components/reducers/statuses.jsx
index 6323e0fbe..ca8fa7a01 100644
--- a/app/assets/javascripts/components/reducers/statuses.jsx
+++ b/app/assets/javascripts/components/reducers/statuses.jsx
@@ -32,6 +32,7 @@ import {
   FAVOURITED_STATUSES_FETCH_SUCCESS,
   FAVOURITED_STATUSES_EXPAND_SUCCESS
 } from '../actions/favourites';
+import { SEARCH_FETCH_SUCCESS } from '../actions/search';
 import Immutable from 'immutable';
 
 const normalizeStatus = (state, status) => {
@@ -39,14 +40,15 @@ const normalizeStatus = (state, status) => {
     return state;
   }
 
-  status.account = status.account.id;
+  const normalStatus   = { ...status };
+  normalStatus.account = status.account.id;
 
   if (status.reblog && status.reblog.id) {
-    state         = normalizeStatus(state, status.reblog);
-    status.reblog = status.reblog.id;
+    state               = normalizeStatus(state, status.reblog);
+    normalStatus.reblog = status.reblog.id;
   }
 
-  return state.update(status.id, Immutable.Map(), map => map.mergeDeep(Immutable.fromJS(status)));
+  return state.update(status.id, Immutable.Map(), map => map.mergeDeep(Immutable.fromJS(normalStatus)));
 };
 
 const normalizeStatuses = (state, statuses) => {
@@ -107,6 +109,7 @@ export default function statuses(state = initialState, action) {
   case NOTIFICATIONS_EXPAND_SUCCESS:
   case FAVOURITED_STATUSES_FETCH_SUCCESS:
   case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
     return normalizeStatuses(state, action.statuses);
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.references);
diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx
index 6f2d26dcb..675a52759 100644
--- a/app/assets/javascripts/components/reducers/timelines.jsx
+++ b/app/assets/javascripts/components/reducers/timelines.jsx
@@ -7,7 +7,9 @@ import {
   TIMELINE_EXPAND_SUCCESS,
   TIMELINE_EXPAND_REQUEST,
   TIMELINE_EXPAND_FAIL,
-  TIMELINE_SCROLL_TOP
+  TIMELINE_SCROLL_TOP,
+  TIMELINE_CONNECT,
+  TIMELINE_DISCONNECT
 } from '../actions/timelines';
 import {
   REBLOG_SUCCESS,
@@ -22,7 +24,8 @@ import {
   ACCOUNT_TIMELINE_EXPAND_REQUEST,
   ACCOUNT_TIMELINE_EXPAND_SUCCESS,
   ACCOUNT_TIMELINE_EXPAND_FAIL,
-  ACCOUNT_BLOCK_SUCCESS
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS
 } from '../actions/accounts';
 import {
   CONTEXT_FETCH_SUCCESS
@@ -31,31 +34,47 @@ import Immutable from 'immutable';
 
 const initialState = Immutable.Map({
   home: Immutable.Map({
+    path: () => '/api/v1/timelines/home',
+    next: null,
     isLoading: false,
+    online: false,
     loaded: false,
     top: true,
+    unread: 0,
     items: Immutable.List()
   }),
 
-  mentions: Immutable.Map({
+  public: Immutable.Map({
+    path: () => '/api/v1/timelines/public',
+    next: null,
     isLoading: false,
+    online: false,
     loaded: false,
     top: true,
+    unread: 0,
     items: Immutable.List()
   }),
 
-  public: Immutable.Map({
+  community: Immutable.Map({
+    path: () => '/api/v1/timelines/public',
+    next: null,
+    params: { local: true },
     isLoading: false,
+    online: false,
     loaded: false,
     top: true,
+    unread: 0,
     items: Immutable.List()
   }),
 
   tag: Immutable.Map({
+    path: (id) => `/api/v1/timelines/tag/${id}`,
+    next: null,
     isLoading: false,
     id: null,
     loaded: false,
     top: true,
+    unread: 0,
     items: Immutable.List()
   }),
 
@@ -81,7 +100,7 @@ const normalizeStatus = (state, status) => {
   return state;
 };
 
-const normalizeTimeline = (state, timeline, statuses, replace = false) => {
+const normalizeTimeline = (state, timeline, statuses, next) => {
   let ids      = Immutable.List();
   const loaded = state.getIn([timeline, 'loaded']);
 
@@ -93,10 +112,14 @@ const normalizeTimeline = (state, timeline, statuses, replace = false) => {
   state = state.setIn([timeline, 'loaded'], true);
   state = state.setIn([timeline, 'isLoading'], false);
 
+  if (state.getIn([timeline, 'next']) === null) {
+    state = state.setIn([timeline, 'next'], next);
+  }
+
   return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? list.unshift(...ids) : ids));
 };
 
-const appendNormalizedTimeline = (state, timeline, statuses) => {
+const appendNormalizedTimeline = (state, timeline, statuses, next) => {
   let moreIds = Immutable.List();
 
   statuses.forEach((status, i) => {
@@ -105,6 +128,7 @@ const appendNormalizedTimeline = (state, timeline, statuses) => {
   });
 
   state = state.setIn([timeline, 'isLoading'], false);
+  state = state.setIn([timeline, 'next'], next);
 
   return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds));
 };
@@ -141,6 +165,10 @@ const updateTimeline = (state, timeline, status, references) => {
 
   state = normalizeStatus(state, status);
 
+  if (!top) {
+    state = state.updateIn([timeline, 'unread'], unread => unread + 1);
+  }
+
   state = state.updateIn([timeline, 'items'], Immutable.List(), list => {
     if (top && list.size > 40) {
       list = list.take(20);
@@ -169,7 +197,7 @@ const deleteStatus = (state, id, accountId, references, reblogOf) => {
   }
 
   // Remove references from timelines
-  ['home', 'mentions', 'public', 'tag'].forEach(function (timeline) {
+  ['home', 'public', 'community', 'tag'].forEach(function (timeline) {
     state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
   });
 
@@ -221,11 +249,13 @@ const normalizeContext = (state, id, ancestors, descendants) => {
 };
 
 const resetTimeline = (state, timeline, id) => {
-  if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) {
+  if (timeline === 'tag' && typeof id !== 'undefined' && state.getIn([timeline, 'id']) !== id) {
     state = state.update(timeline, map => map
         .set('id', id)
         .set('isLoading', true)
         .set('loaded', false)
+        .set('next', null)
+        .set('top', true)
         .update('items', list => list.clear()));
   } else {
     state = state.setIn([timeline, 'isLoading'], true);
@@ -234,6 +264,14 @@ const resetTimeline = (state, timeline, id) => {
   return state;
 };
 
+const updateTop = (state, timeline, top) => {
+  if (top) {
+    state = state.setIn([timeline, 'unread'], 0);
+  }
+
+  return state.setIn([timeline, 'top'], top);
+};
+
 export default function timelines(state = initialState, action) {
   switch(action.type) {
   case TIMELINE_REFRESH_REQUEST:
@@ -243,9 +281,9 @@ export default function timelines(state = initialState, action) {
   case TIMELINE_EXPAND_FAIL:
     return state.setIn([action.timeline, 'isLoading'], false);
   case TIMELINE_REFRESH_SUCCESS:
-    return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
+    return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next);
   case TIMELINE_EXPAND_SUCCESS:
-    return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses));
+    return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next);
   case TIMELINE_UPDATE:
     return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
   case TIMELINE_DELETE:
@@ -263,9 +301,14 @@ export default function timelines(state = initialState, action) {
   case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
     return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
   case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
     return filterTimelines(state, action.relationship, action.statuses);
   case TIMELINE_SCROLL_TOP:
-    return state.setIn([action.timeline, 'top'], action.top);
+    return updateTop(state, action.timeline, action.top);
+  case TIMELINE_CONNECT:
+    return state.setIn([action.timeline, 'online'], true);
+  case TIMELINE_DISCONNECT:
+    return state.setIn([action.timeline, 'online'], false);
   default:
     return state;
   }
diff --git a/app/assets/javascripts/components/rtl.jsx b/app/assets/javascripts/components/rtl.jsx
new file mode 100644
index 000000000..8f14bb338
--- /dev/null
+++ b/app/assets/javascripts/components/rtl.jsx
@@ -0,0 +1,27 @@
+// U+0590  to U+05FF  - Hebrew
+// U+0600  to U+06FF  - Arabic
+// U+0700  to U+074F  - Syriac
+// U+0750  to U+077F  - Arabic Supplement
+// U+0780  to U+07BF  - Thaana
+// U+07C0  to U+07FF  - N'Ko
+// U+0800  to U+083F  - Samaritan
+// U+08A0  to U+08FF  - Arabic Extended-A
+// U+FB1D  to U+FB4F  - Hebrew presentation forms
+// U+FB50  to U+FDFF  - Arabic presentation forms A
+// U+FE70  to U+FEFF  - Arabic presentation forms B
+
+const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
+
+export function isRtl(text) {
+  if (text.length === 0) {
+    return false;
+  }
+
+  const matches = text.match(rtlChars);
+
+  if (!matches) {
+    return false;
+  }
+
+  return matches.length / text.trim().length > 0.3;
+};
diff --git a/app/assets/javascripts/components/selectors/index.jsx b/app/assets/javascripts/components/selectors/index.jsx
index 20debe604..01a6cb264 100644
--- a/app/assets/javascripts/components/selectors/index.jsx
+++ b/app/assets/javascripts/components/selectors/index.jsx
@@ -1,11 +1,11 @@
-import { createSelector } from 'reselect'
+import { createSelector } from 'reselect';
 import Immutable from 'immutable';
 
 const getStatuses = state => state.get('statuses');
 const getAccounts = state => state.get('accounts');
 
 const getAccountBase         = (state, id) => state.getIn(['accounts', id], null);
-const getAccountRelationship = (state, id) => state.getIn(['relationships', id]);
+const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null);
 
 export const makeGetAccount = () => {
   return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => {
@@ -17,37 +17,32 @@ export const makeGetAccount = () => {
   });
 };
 
-const getStatusBase = (state, id) => state.getIn(['statuses', id], null);
-
 export const makeGetStatus = () => {
-  return createSelector([getStatusBase, getStatuses, getAccounts], (base, statuses, accounts) => {
-    if (base === null) {
-      return null;
+  return createSelector(
+    [
+      (state, id) => state.getIn(['statuses', id]),
+      (state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
+      (state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
+      (state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
+    ],
+
+    (statusBase, statusReblog, accountBase, accountReblog) => {
+      if (!statusBase) {
+        return null;
+      }
+
+      if (statusReblog) {
+        statusReblog = statusReblog.set('account', accountReblog);
+      } else {
+        statusReblog = null;
+      }
+
+      return statusBase.withMutations(map => {
+        map.set('reblog', statusReblog);
+        map.set('account', accountBase);
+      });
     }
-
-    return assembleStatus(base.get('id'), statuses, accounts);
-  });
-};
-
-const assembleStatus = (id, statuses, accounts) => {
-  let status = statuses.get(id, null);
-  let reblog = null;
-
-  if (status === null) {
-    return null;
-  }
-
-  if (status.get('reblog', null) !== null) {
-    reblog = statuses.get(status.get('reblog'), null);
-
-    if (reblog !== null) {
-      reblog = reblog.set('account', accounts.get(reblog.get('account')));
-    } else {
-      return null;
-    }
-  }
-
-  return status.set('reblog', reblog).set('account', accounts.get(status.get('account')));
+  );
 };
 
 const getAlertsBase = state => state.get('alerts');
diff --git a/app/assets/javascripts/components/store/configureStore.jsx b/app/assets/javascripts/components/store/configureStore.jsx
index ad0427b52..a92d756f5 100644
--- a/app/assets/javascripts/components/store/configureStore.jsx
+++ b/app/assets/javascripts/components/store/configureStore.jsx
@@ -3,21 +3,14 @@ import thunk from 'redux-thunk';
 import appReducer from '../reducers';
 import loadingBarMiddleware from '../middleware/loading_bar';
 import errorsMiddleware from '../middleware/errors';
-import soundsMiddleware from 'redux-sounds';
-import Howler from 'howler';
+import soundsMiddleware from '../middleware/sounds';
 import Immutable from 'immutable';
 
-Howler.mobileAutoEnable = false;
-
-const soundsData = {
-  boop: '/sounds/boop.mp3'
-};
-
 export default function configureStore() {
   return createStore(appReducer, compose(applyMiddleware(
     thunk,
     loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
     errorsMiddleware(),
-    soundsMiddleware(soundsData)
+    soundsMiddleware()
   ), window.devToolsExtension ? window.devToolsExtension() : f => f));
 };