about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorhalna_Tanaguru <fhalna@oceaneconsulting.com>2017-04-04 12:06:53 +0200
committerGitHub <noreply@github.com>2017-04-04 12:06:53 +0200
commit3abb0f7bc708ae8ca387aee8560b9558f8b209ba (patch)
tree6014fdec3201d9e40fa244b821bdaffc83bfa131 /app
parent3f30ae1f97717177f29711d5b99d7970c6b75b3e (diff)
parenteb023beb4975a019d6a3b3091483c91c2c837bbd (diff)
Merge branch 'master' into development
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/fluffy-elephant-friend.pngbin1101408 -> 60667 bytes
-rw-r--r--app/assets/javascripts/components/actions/accounts.jsx11
-rw-r--r--app/assets/javascripts/components/actions/modal.jsx25
-rw-r--r--app/assets/javascripts/components/actions/search.jsx66
-rw-r--r--app/assets/javascripts/components/actions/timelines.jsx22
-rw-r--r--app/assets/javascripts/components/components/lightbox.jsx82
-rw-r--r--app/assets/javascripts/components/components/status_action_bar.jsx7
-rw-r--r--app/assets/javascripts/components/components/video_player.jsx4
-rw-r--r--app/assets/javascripts/components/containers/mastodon.jsx16
-rw-r--r--app/assets/javascripts/components/containers/status_container.jsx4
-rw-r--r--app/assets/javascripts/components/features/account/components/header.jsx51
-rw-r--r--app/assets/javascripts/components/features/community_timeline/index.jsx16
-rw-r--r--app/assets/javascripts/components/features/compose/components/drawer.jsx44
-rw-r--r--app/assets/javascripts/components/features/compose/components/search.jsx115
-rw-r--r--app/assets/javascripts/components/features/compose/components/search_results.jsx68
-rw-r--r--app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx31
-rw-r--r--app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx27
-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/index.jsx64
-rw-r--r--app/assets/javascripts/components/features/getting_started/index.jsx4
-rw-r--r--app/assets/javascripts/components/features/home_timeline/components/column_settings.jsx4
-rw-r--r--app/assets/javascripts/components/features/public_timeline/index.jsx16
-rw-r--r--app/assets/javascripts/components/features/status/index.jsx4
-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/containers/modal_container.jsx166
-rw-r--r--app/assets/javascripts/components/features/ui/index.jsx53
-rw-r--r--app/assets/javascripts/components/locales/en.jsx4
-rw-r--r--app/assets/javascripts/components/locales/fi.jsx68
-rw-r--r--app/assets/javascripts/components/locales/fr.jsx113
-rw-r--r--app/assets/javascripts/components/locales/index.jsx4
-rw-r--r--app/assets/javascripts/components/reducers/accounts.jsx4
-rw-r--r--app/assets/javascripts/components/reducers/modal.jsx30
-rw-r--r--app/assets/javascripts/components/reducers/relationships.jsx22
-rw-r--r--app/assets/javascripts/components/reducers/search.jsx31
-rw-r--r--app/assets/javascripts/components/reducers/statuses.jsx4
-rw-r--r--app/assets/javascripts/components/reducers/timelines.jsx11
-rw-r--r--app/assets/javascripts/components/selectors/index.jsx2
-rw-r--r--app/assets/javascripts/extras.jsx13
-rw-r--r--app/assets/stylesheets/accounts.scss1
-rw-r--r--app/assets/stylesheets/components.scss233
-rw-r--r--app/assets/stylesheets/stream_entries.scss18
-rw-r--r--app/controllers/about_controller.rb3
-rw-r--r--app/controllers/admin/domain_blocks_controller.rb18
-rw-r--r--app/controllers/admin/reports_controller.rb8
-rw-r--r--app/controllers/api/v1/apps_controller.rb8
-rw-r--r--app/controllers/api/v1/follows_controller.rb8
-rw-r--r--app/controllers/api/v1/media_controller.rb8
-rw-r--r--app/controllers/api/v1/reports_controller.rb12
-rw-r--r--app/controllers/api/v1/statuses_controller.rb14
-rw-r--r--app/controllers/api/v1/timelines_controller.rb12
-rw-r--r--app/controllers/application_controller.rb9
-rw-r--r--app/controllers/oauth/authorizations_controller.rb7
-rw-r--r--app/controllers/settings/imports_controller.rb34
-rw-r--r--app/helpers/settings_helper.rb1
-rw-r--r--app/lib/exceptions.rb1
-rw-r--r--app/lib/feed_manager.rb2
-rw-r--r--app/models/feed.rb12
-rw-r--r--app/models/import.rb14
-rw-r--r--app/models/report.rb1
-rw-r--r--app/models/status.rb2
-rw-r--r--app/services/block_domain_service.rb10
-rw-r--r--app/services/fan_out_on_write_service.rb8
-rw-r--r--app/services/precompute_feed_service.rb8
-rw-r--r--app/services/search_service.rb4
-rw-r--r--app/views/about/index.html.haml2
-rw-r--r--app/views/accounts/_header.html.haml6
-rw-r--r--app/views/admin/accounts/show.html.haml6
-rw-r--r--app/views/admin/domain_blocks/index.html.haml1
-rw-r--r--app/views/admin/domain_blocks/new.html.haml18
-rw-r--r--app/views/admin/reports/index.html.haml35
-rw-r--r--app/views/admin/reports/show.html.haml8
-rw-r--r--app/views/api/v1/accounts/show.rabl6
-rw-r--r--app/views/api/v1/statuses/_show.rabl4
-rw-r--r--app/views/layouts/admin.html.haml9
-rw-r--r--app/views/settings/imports/show.html.haml11
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml10
-rw-r--r--app/views/stream_entries/_simple_status.html.haml6
-rw-r--r--app/workers/after_remote_follow_request_worker.rb2
-rw-r--r--app/workers/after_remote_follow_worker.rb2
-rw-r--r--app/workers/domain_block_worker.rb11
-rw-r--r--app/workers/import_worker.rb54
-rw-r--r--app/workers/link_crawl_worker.rb2
-rw-r--r--app/workers/merge_worker.rb2
-rw-r--r--app/workers/notification_worker.rb2
-rw-r--r--app/workers/processing_worker.rb2
-rw-r--r--app/workers/regeneration_worker.rb6
-rw-r--r--app/workers/salmon_worker.rb2
-rw-r--r--app/workers/thread_resolve_worker.rb2
-rw-r--r--app/workers/unmerge_worker.rb2
92 files changed, 1390 insertions, 752 deletions
diff --git a/app/assets/images/fluffy-elephant-friend.png b/app/assets/images/fluffy-elephant-friend.png
index 11787e936..f0df29927 100644
--- a/app/assets/images/fluffy-elephant-friend.png
+++ b/app/assets/images/fluffy-elephant-friend.png
Binary files differdiff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx
index 05fa8e68d..37ebb9969 100644
--- a/app/assets/javascripts/components/actions/accounts.jsx
+++ b/app/assets/javascripts/components/actions/accounts.jsx
@@ -579,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/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/search.jsx b/app/assets/javascripts/components/actions/search.jsx
index e4af716ee..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,42 +15,59 @@ export function changeSearch(value) {
   };
 };
 
-export function clearSearchSuggestions() {
-  return {
-    type: SEARCH_SUGGESTIONS_CLEAR
-  };
-};
-
-export function readySearchSuggestions(value, { accounts, hashtags, statuses }) {
+export function clearSearch() {
   return {
-    type: SEARCH_SUGGESTIONS_READY,
-    value,
-    accounts,
-    hashtags,
-    statuses
+    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;
     }
 
+    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/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx
index 3e2d4ff43..6cd1f04b3 100644
--- a/app/assets/javascripts/components/actions/timelines.jsx
+++ b/app/assets/javascripts/components/actions/timelines.jsx
@@ -14,6 +14,9 @@ export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL';
 
 export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 
+export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT';
+export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
+
 export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
   return {
     type: TIMELINE_REFRESH_SUCCESS,
@@ -76,6 +79,11 @@ export function refreshTimeline(timeline, id = null) {
     let skipLoading = false;
 
     if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) {
+      if (id === null && getState().getIn(['timelines', timeline, 'online'])) {
+        // Skip refreshing when timeline is live anyway
+        return;
+      }
+
       params          = { ...params, since_id: newestId };
       skipLoading     = true;
     }
@@ -162,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/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/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx
index 234cd396a..4ebb76ea7 100644
--- a/app/assets/javascripts/components/components/status_action_bar.jsx
+++ b/app/assets/javascripts/components/components/status_action_bar.jsx
@@ -7,6 +7,7 @@ import { defineMessages, injectIntl } from 'react-intl';
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
   mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
   block: { id: 'account.block', defaultMessage: 'Block @{name}' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
   reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
@@ -28,6 +29,7 @@ const StatusActionBar = React.createClass({
     onReblog: React.PropTypes.func,
     onDelete: React.PropTypes.func,
     onMention: React.PropTypes.func,
+    onMute: React.PropTypes.func,
     onBlock: React.PropTypes.func,
     onReport: React.PropTypes.func,
     me: React.PropTypes.number.isRequired,
@@ -56,6 +58,10 @@ const StatusActionBar = React.createClass({
     this.props.onMention(this.props.status.get('account'), this.context.router);
   },
 
+  handleMuteClick () {
+    this.props.onMute(this.props.status.get('account'));
+  },
+
   handleBlockClick () {
     this.props.onBlock(this.props.status.get('account'));
   },
@@ -81,6 +87,7 @@ const StatusActionBar = React.createClass({
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
       menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
       menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
       menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
     }
diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx
index 92597a2ec..ab21ca9cd 100644
--- a/app/assets/javascripts/components/components/video_player.jsx
+++ b/app/assets/javascripts/components/components/video_player.jsx
@@ -23,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'
 };
@@ -54,6 +56,8 @@ const spoilerButtonStyle = {
   position: 'absolute',
   top: '6px',
   left: '8px',
+  color: 'white',
+  textShadow: "0px 1px 1px black, 1px 0px 1px black",
   zIndex: '100'
 };
 
diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx
index 40fbac525..cbb7b85bc 100644
--- a/app/assets/javascripts/components/containers/mastodon.jsx
+++ b/app/assets/javascripts/components/containers/mastodon.jsx
@@ -4,7 +4,9 @@ import {
   refreshTimelineSuccess,
   updateTimeline,
   deleteFromTimelines,
-  refreshTimeline
+  refreshTimeline,
+  connectTimeline,
+  disconnectTimeline
 } from '../actions/timelines';
 import { updateNotifications, refreshNotifications } from '../actions/notifications';
 import createBrowserHistory from 'history/lib/createBrowserHistory';
@@ -44,6 +46,7 @@ import fr from 'react-intl/locale-data/fr';
 import pt from 'react-intl/locale-data/pt';
 import hu from 'react-intl/locale-data/hu';
 import uk from 'react-intl/locale-data/uk';
+import fi from 'react-intl/locale-data/fi';
 import getMessagesForLocale from '../locales';
 import { hydrateStore } from '../actions/store';
 import createStream from '../stream';
@@ -56,7 +59,7 @@ const browserHistory = useRouterHistory(createBrowserHistory)({
   basename: '/web'
 });
 
-addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk]);
+addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk, ...fi]);
 
 const Mastodon = React.createClass({
 
@@ -70,6 +73,14 @@ const Mastodon = React.createClass({
 
     this.subscription = createStream(accessToken, 'user', {
 
+      connected () {
+        store.dispatch(connectTimeline('home'));
+      },
+
+      disconnected () {
+        store.dispatch(disconnectTimeline('home'));
+      },
+
       received (data) {
         switch(data.event) {
         case 'update':
@@ -85,6 +96,7 @@ const Mastodon = React.createClass({
       },
 
       reconnected () {
+        store.dispatch(connectTimeline('home'));
         store.dispatch(refreshTimeline('home'));
         store.dispatch(refreshNotifications());
       }
diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx
index e7543bc39..fd3fbe4c3 100644
--- a/app/assets/javascripts/components/containers/status_container.jsx
+++ b/app/assets/javascripts/components/containers/status_container.jsx
@@ -17,7 +17,7 @@ import {
 } 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'
 
@@ -63,7 +63,7 @@ const mapDispatchToProps = (dispatch) => ({
   },
 
   onOpenMedia (media, index) {
-    dispatch(openMedia(media, index));
+    dispatch(openModal('MEDIA', { media, index }));
   },
 
   onBlock (account) {
diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx
index e1aae3c77..a359963c4 100644
--- a/app/assets/javascripts/components/features/account/components/header.jsx
+++ b/app/assets/javascripts/components/features/account/components/header.jsx
@@ -4,6 +4,7 @@ import emojify from '../../../emoji';
 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,6 +12,47 @@ 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: {
@@ -68,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/community_timeline/index.jsx b/app/assets/javascripts/components/features/community_timeline/index.jsx
index 2cfd7b2fe..0957338cf 100644
--- a/app/assets/javascripts/components/features/community_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/community_timeline/index.jsx
@@ -5,7 +5,9 @@ import Column from '../ui/components/column';
 import {
   refreshTimeline,
   updateTimeline,
-  deleteFromTimelines
+  deleteFromTimelines,
+  connectTimeline,
+  disconnectTimeline
 } from '../../actions/timelines';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
@@ -44,6 +46,18 @@ const CommunityTimeline = React.createClass({
 
     subscription = createStream(accessToken, 'public:local', {
 
+      connected () {
+        dispatch(connectTimeline('community'));
+      },
+
+      reconnected () {
+        dispatch(connectTimeline('community'));
+      },
+
+      disconnected () {
+        dispatch(disconnectTimeline('community'));
+      },
+
       received (data) {
         switch(data.event) {
         case 'update':
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 ab67c86ea..000000000
--- a/app/assets/javascripts/components/features/compose/components/drawer.jsx
+++ /dev/null
@@ -1,44 +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: '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 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.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 (
-    <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/search.jsx b/app/assets/javascripts/components/features/compose/components/search.jsx
index a0e8f82fb..936e003f2 100644
--- a/app/assets/javascripts/components/features/compose/components/search.jsx
+++ b/app/assets/javascripts/components/features/compose/components/search.jsx
@@ -1,123 +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 AutosuggestStatusContainer from '../containers/autosuggest_status_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 if (suggestion.type === 'hashtag') {
-    return <span>#{suggestion.id}</span>;
-  } else {
-    return <AutosuggestStatusContainer id={suggestion.id} />;
-  }
-};
-
-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 if(suggestion.type === 'hashtag') {
-      this.context.router.push(`/timelines/tag/${suggestion.id}`);
-    } else {
-      this.context.router.push(`/statuses/${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/sensitive_toggle.jsx b/app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx
deleted file mode 100644
index 97cc9487e..000000000
--- a/app/assets/javascripts/components/features/compose/components/sensitive_toggle.jsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import PureRenderMixin from 'react-addons-pure-render-mixin';
-import { FormattedMessage } from 'react-intl';
-import Toggle from 'react-toggle';
-import Collapsable from '../../../components/collapsable';
-
-const SensitiveToggle = React.createClass({
-
-  propTypes: {
-    hasMedia: React.PropTypes.bool,
-    isSensitive: React.PropTypes.bool,
-    onChange: React.PropTypes.func.isRequired
-  },
-
-  mixins: [PureRenderMixin],
-
-  render () {
-    const { hasMedia, isSensitive, onChange } = this.props;
-
-    return (
-      <Collapsable isVisible={hasMedia} fullHeight={39.5}>
-        <label className='compose-form__label'>
-          <Toggle checked={isSensitive} onChange={onChange} />
-          <span className='compose-form__label__text'><FormattedMessage id='compose_form.sensitive' defaultMessage='Mark media as sensitive' /></span>
-        </label>
-      </Collapsable>
-    );
-  }
-
-});
-
-export default SensitiveToggle;
diff --git a/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx b/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx
deleted file mode 100644
index 1c59e4393..000000000
--- a/app/assets/javascripts/components/features/compose/components/spoiler_toggle.jsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import PureRenderMixin from 'react-addons-pure-render-mixin';
-import { FormattedMessage } from 'react-intl';
-import Toggle from 'react-toggle';
-
-const SpoilerToggle = React.createClass({
-
-  propTypes: {
-    isSpoiler: React.PropTypes.bool,
-    onChange: React.PropTypes.func.isRequired
-  },
-
-  mixins: [PureRenderMixin],
-
-  render () {
-    const { isSpoiler, onChange } = this.props;
-
-    return (
-      <label className='compose-form__label with-border' style={{ marginTop: '10px' }}>
-        <Toggle checked={isSpoiler} onChange={onChange} />
-        <span className='compose-form__label__text'><FormattedMessage id='compose_form.spoiler' defaultMessage='Hide text behind warning' /></span>
-      </label>
-    );
-  }
-
-});
-
-export default SpoilerToggle;
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/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx
index 15e2c5809..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,15 +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 />
-      </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 6f9e988ba..d7a78d9cc 100644
--- a/app/assets/javascripts/components/features/getting_started/index.jsx
+++ b/app/assets/javascripts/components/features/getting_started/index.jsx
@@ -43,9 +43,7 @@ const GettingStarted = ({ intl, me }) => {
 
       <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.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>
+          <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/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/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx
index b2342abbd..6d766a83b 100644
--- a/app/assets/javascripts/components/features/public_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/public_timeline/index.jsx
@@ -5,7 +5,9 @@ import Column from '../ui/components/column';
 import {
   refreshTimeline,
   updateTimeline,
-  deleteFromTimelines
+  deleteFromTimelines,
+  connectTimeline,
+  disconnectTimeline
 } from '../../actions/timelines';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnBackButtonSlim from '../../components/column_back_button_slim';
@@ -44,6 +46,18 @@ const PublicTimeline = React.createClass({
 
     subscription = createStream(accessToken, 'public', {
 
+      connected () {
+        dispatch(connectTimeline('public'));
+      },
+
+      reconnected () {
+        dispatch(connectTimeline('public'));
+      },
+
+      disconnected () {
+        dispatch(disconnectTimeline('public'));
+      },
+
       received (data) {
         switch(data.event) {
         case 'update':
diff --git a/app/assets/javascripts/components/features/status/index.jsx b/app/assets/javascripts/components/features/status/index.jsx
index 6a7635cc6..f98fe1b01 100644
--- a/app/assets/javascripts/components/features/status/index.jsx
+++ b/app/assets/javascripts/components/features/status/index.jsx
@@ -28,7 +28,7 @@ import {
 import { ScrollContainer } from 'react-router-scroll';
 import ColumnBackButton from '../../components/column_back_button';
 import StatusContainer from '../../containers/status_container';
-import { openMedia } from '../../actions/modal';
+import { openModal } from '../../actions/modal';
 import { isMobile } from '../../is_mobile'
 
 const makeMapStateToProps = () => {
@@ -99,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) {
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/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
index e3c4281b9..26d77818c 100644
--- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
@@ -1,170 +1,16 @@
 import { connect } from 'react-redux';
-import {
-  closeModal,
-  decreaseIndexInModal,
-  increaseIndexInModal
-} from '../../../actions/modal';
-import Lightbox from '../../../components/lightbox';
-import ImageLoader from 'react-imageloader';
-import LoadingIndicator from '../../../components/loading_indicator';
-import PureRenderMixin from 'react-addons-pure-render-mixin';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ExtendedVideoPlayer from '../../../components/extended_video_player';
+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 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}
-          preloader={preloader}
-          imgProps={{ style: imageStyle }}
-        />
-      );
-    } else if (attachment.get('type') === 'gifv') {
-      content = <ExtendedVideoPlayer src={url} />;
-    }
-
-    return (
-      <Lightbox {...other}>
-        {leftNav}
-        {content}
-        {rightNav}
-      </Lightbox>
-    );
-  }
-
 });
 
-export default connect(mapStateToProps, mapDispatchToProps)(Modal);
+export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);
diff --git a/app/assets/javascripts/components/features/ui/index.jsx b/app/assets/javascripts/components/features/ui/index.jsx
index b7e8f32a4..89fb82568 100644
--- a/app/assets/javascripts/components/features/ui/index.jsx
+++ b/app/assets/javascripts/components/features/ui/index.jsx
@@ -36,15 +36,33 @@ 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') {
-      this.setState({ draggingOver: true });
     }
+
+    return false;
   },
 
   handleDrop (e) {
@@ -57,14 +75,25 @@ const UI = React.createClass({
     }
   },
 
-  handleDragLeave () {
+  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());
@@ -72,8 +101,14 @@ 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 () {
@@ -100,7 +135,7 @@ const UI = React.createClass({
     }
 
     return (
-      <div className='ui' onDragLeave={this.handleDragLeave}>
+      <div className='ui' ref={this.setRef}>
         <TabsBar />
 
         {mountedColumns}
diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx
index 5af44ea4b..53e2898eb 100644
--- a/app/assets/javascripts/components/locales/en.jsx
+++ b/app/assets/javascripts/components/locales/en.jsx
@@ -25,7 +25,7 @@ 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.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}. {apps}.",
+  "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.community": "Local timeline",
   "column.public": "Federated timeline",
@@ -40,7 +40,7 @@ const en = {
   "compose_form.sensitive": "Mark media as sensitive",
   "compose_form.spoiler": "Hide text behind warning",
   "compose_form.private": "Mark as private",
-  "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}} to not leak your status?",
+  "compose_form.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",
diff --git a/app/assets/javascripts/components/locales/fi.jsx b/app/assets/javascripts/components/locales/fi.jsx
new file mode 100644
index 000000000..5bef99923
--- /dev/null
+++ b/app/assets/javascripts/components/locales/fi.jsx
@@ -0,0 +1,68 @@
+const fi = {
+  "column_back_button.label": "Takaisin",
+  "lightbox.close": "Sulje",
+  "loading_indicator.label": "Ladataan...",
+  "status.mention": "Mainitse @{name}",
+  "status.delete": "Poista",
+  "status.reply": "Vastaa",
+  "status.reblog": "Boostaa",
+  "status.favourite": "Tykkää",
+  "status.reblogged_by": "{name} boostattu",
+  "status.sensitive_warning": "Arkaluontoista sisältöä",
+  "status.sensitive_toggle": "Klikkaa nähdäksesi",
+  "video_player.toggle_sound": "Äänet päälle/pois",
+  "account.mention": "Mainitse @{name}",
+  "account.edit_profile": "Muokkaa",
+  "account.unblock": "Salli @{name}",
+  "account.unfollow": "Lopeta seuraaminen",
+  "account.block": "Estä @{name}",
+  "account.follow": "Seuraa",
+  "account.posts": "Postit",
+  "account.follows": "Seuraa",
+  "account.followers": "Seuraajia",
+  "account.follows_you": "Seuraa sinua",
+  "account.requested": "Odottaa hyväksyntää",
+  "getting_started.heading": "Päästä alkuun",
+  "getting_started.about_addressing": "Voit seurata ihmisiä jos tiedät heidän käyttäjänimensä ja domainin missä he ovat syöttämällä e-mail-esque osoitteen Etsi kenttään.",
+  "getting_started.about_shortcuts": "Jos etsimäsi henkilö on samassa domainissa kuin sinä, pelkkä käyttäjänimi kelpaa. Sama pätee kun mainitset ihmisiä statuksessasi",
+  "getting_started.open_source_notice": "Mastodon Mastodon on avoimen lähdekoodin ohjelma. Voit avustaa tai raportoida ongelmia githubissa {github}. {apps}.",
+  "column.home": "Koti",
+  "column.community": "Paikallinen aikajana",
+  "column.public": "Yhdistetty aikajana",
+  "column.notifications": "Ilmoitukset",
+  "tabs_bar.compose": "Luo",
+  "tabs_bar.home": "Koti",
+  "tabs_bar.mentions": "Maininnat",
+  "tabs_bar.public": "Yleinen aikajana",
+  "tabs_bar.notifications": "Ilmoitukset",
+  "compose_form.placeholder": "Mitä sinulla on mielessä?",
+  "compose_form.publish": "Toot",
+  "compose_form.sensitive": "Merkitse media herkäksi",
+  "compose_form.spoiler": "Piiloita teksti varoituksen taakse",
+  "compose_form.private": "Merkitse yksityiseksi",
+  "compose_form.privacy_disclaimer": "Sinun yksityinen status toimitetaan mainitsemallesi käyttäjille domaineissa {domains}. Luotatko {domainsCount, plural, one {tähän palvelimeen} other {näihin palvelimiin}}? Postauksen yksityisyys toimii van Mastodon palvelimilla. Jos {domains} {domainsCount, plural, one {ei ole Mastodon palvelin} other {eivät ole Mastodon palvelin}}, viestiin ei tule Yksityinen-merkintää, ja sitä voidaan boostata tai muuten tehdä näkyväksi muille vastaanottajille.",
+  "compose_form.unlisted": "Älä näytä julkisilla aikajanoilla",
+  "navigation_bar.edit_profile": "Muokkaa profiilia",
+  "navigation_bar.preferences": "Ominaisuudet",
+  "navigation_bar.community_timeline": "Paikallinen aikajana",
+  "navigation_bar.public_timeline": "Yleinen aikajana",
+  "navigation_bar.logout": "Kirjaudu ulos",
+  "reply_indicator.cancel": "Peruuta",
+  "search.placeholder": "Hae",
+  "search.account": "Tili",
+  "search.hashtag": "Hashtag",
+  "upload_button.label": "Lisää mediaa",
+  "upload_form.undo": "Peru",
+  "notification.follow": "{name} seurasi sinua",
+  "notification.favourite": "{name} tykkäsi statuksestasi",
+  "notification.reblog": "{name} boostasi statustasi",
+  "notification.mention": "{name} mainitsi sinut",
+  "notifications.column_settings.alert": "Työpöytä ilmoitukset",
+  "notifications.column_settings.show": "Näytä sarakkeessa",
+  "notifications.column_settings.follow": "Uusia seuraajia:",
+  "notifications.column_settings.favourite": "Tykkäyksiä:",
+  "notifications.column_settings.mention": "Mainintoja:",
+  "notifications.column_settings.reblog": "Boosteja:",
+};
+
+export default fi;
diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx
index 2f5dd182f..23fa9349c 100644
--- a/app/assets/javascripts/components/locales/fr.jsx
+++ b/app/assets/javascripts/components/locales/fr.jsx
@@ -1,68 +1,91 @@
 const fr = {
-  "account.block": "Bloquer",
+  "column_back_button.label": "Retour",
+  "lightbox.close": "Fermer",
+  "loading_indicator.label": "Chargement…",
+  "status.mention": "Mentionner",
+  "status.delete": "Effacer",
+  "status.reply": "Répondre",
+  "status.reblog": "Partager",
+  "status.favourite": "Ajouter aux favoris",
+  "status.reblogged_by": "{name} a partagé :",
+  "status.sensitive_warning": "Contenu délicat",
+  "status.sensitive_toggle": "Cliquer pour dévoiler",
+  "video_player.toggle_sound": "Mettre/Couper le son",
+  "account.mention": "Mentionner",
   "account.edit_profile": "Modifier le profil",
-  "account.followers": "Abonnés",
-  "account.follows": "Abonnements",
+  "account.unblock": "Débloquer",
+  "account.unfollow": "Ne plus suivre",
+  "account.block": "Bloquer",
+  "account.mute": "Masquer",
+  "account.unmute": "Ne plus masquer",
   "account.follow": "Suivre",
-  "account.follows_you": "Vous suit",
-  "account.mention": "Mentionner",
   "account.posts": "Statuts",
+  "account.follows": "Abonnements",
+  "account.followers": "Abonnés",
+  "account.follows_you": "Vous suit",
   "account.requested": "Invitation envoyée",
-  "account.unblock": "Débloquer",
-  "account.unfollow": "Ne plus suivre",
-  "column_back_button.label": "Retour",
+  "account.report": "Signaler",
+  "account.disclaimer": "Ce compte est situé sur une autre instance. Les nombres peuvent être plus grands.",
+  "getting_started.heading": "Pour commencer",
+  "getting_started.about_addressing": "Vous pouvez suivre les statuts de quelqu’un en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.",
+  "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, l’identifiant suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.",
+  "getting_started.about_developer": "Pour suivre le développeur de ce projet, c’est Gargron@mastodon.social",
+  "getting_started.open_source_notice": "Mastodon est un logiciel libre. Vous pouvez contribuer et envoyer vos commentaires et rapports de bogues via {github} sur GitHub.",
   "column.home": "Accueil",
-  "column.mentions": "Mentions",
+  "column.community": "Fil public local",
+  "column.public": "Fil public global",
   "column.notifications": "Notifications",
   "column.public": "Fil public",
+  "column.blocks": "Utilisateurs bloqués",
+  "column.favourites": "Favoris",
+  "tabs_bar.compose": "Composer",
+  "tabs_bar.home": "Accueil",
+  "tabs_bar.mentions": "Mentions",
+  "tabs_bar.public": "Fil public global",
+  "tabs_bar.notifications": "Notifications",
   "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é",
   "compose_form.publish": "Pouet ",
   "compose_form.sensitive": "Marquer le média comme délicat",
-  "compose_form.spoiler": "Masque le texte par un avertissement",
-  "compose_form.unlisted": "Ne pas afficher dans le fil public",
-  "getting_started.about_addressing": "Vous pouvez vous suivre les statuts de quelqu’un en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.",
-  "getting_started.about_developer": "Pour suivre le développeur de ce projet, c’est Gargron@mastodon.social",
-  "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, l’identifiant suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.",
-  "getting_started.heading": "Pour commencer",
-  "getting_started.open_source_notice": "Mastodon est un logiciel libre. Vous pouvez contribuer et envoyer vos commentaires et rapports de bogues via {github} sur GitHub.",
-  "lightbox.close": "Fermer",
-  "loading_indicator.label": "Chargement…",
+  "compose_form.spoiler": "Masquer le texte par un avertissement",
+  "compose_form.private": "Rendre privé",
+  "compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ? Les statuts privés ne fonctionnent que sur les instances de Mastodons. Si {domains} {domainsCount, plural, one {n'est pas une instance de Mastodon} other {ne sont pas des instances de Mastodon}}, il n'y aura aucune indication que votre statut est privé, et il pourrait être partagé ou rendu visible d'une autre manière à d'autres personnes imprévues",
+  "compose_form.unlisted": "Ne pas afficher dans les fils publics",
+  "emoji_button.label": "Insérer un emoji",
   "navigation_bar.edit_profile": "Modifier le profil",
-  "navigation_bar.logout": "Déconnexion",
   "navigation_bar.preferences": "Préférences",
-  "navigation_bar.public_timeline": "Fil public",
+  "navigation_bar.community_timeline": "Fil public local",
+  "navigation_bar.public_timeline": "Fil public global",
+  "navigation_bar.blocks": "Utilisateurs bloqués",
+  "navigation_bar.favourites": "Favoris",
+  "navigation_bar.info": "Plus d'informations",
   "notification.favourite": "{name} a ajouté à ses favoris :",
+  "navigation_bar.logout": "Déconnexion",
+  "reply_indicator.cancel": "Annuler",
+  "search.placeholder": "Chercher",
+  "search.account": "Compte",
+  "search.hashtag": "Mot-clé",
+  "search_results.total": "{count} {count, plural, one {résultat} other {résultats}}",
+  "upload_button.label": "Joindre un média",
+  "upload_form.undo": "Annuler",
   "notification.follow": "{name} vous suit.",
-  "notification.mention": "{name} vous a mentionné⋅e :",
+  "notification.favourite": "{name} a ajouté à ses favoris :",
   "notification.reblog": "{name} a partagé votre statut :",
+  "notification.mention": "{name} vous a mentionné⋅e :",
   "notifications.column_settings.alert": "Notifications locales",
-  "notifications.column_settings.favourite": "Favoris :",
+  "notifications.column_settings.show": "Afficher dans la colonne",
   "notifications.column_settings.follow": "Nouveaux abonnés :",
+  "notifications.column_settings.favourite": "Favoris :",
   "notifications.column_settings.mention": "Mentions :",
   "notifications.column_settings.reblog": "Partages :",
-  "notifications.column_settings.show": "Afficher dans la colonne",
-  "reply_indicator.cancel": "Annuler",
-  "search.account": "Compte",
-  "search.hashtag": "Mot-clé",
-  "search.placeholder": "Chercher",
-  "status.delete": "Effacer",
-  "status.favourite": "Ajouter aux favoris",
-  "status.mention": "Mentionner",
-  "status.reblogged_by": "{name} a partagé :",
-  "status.reblog": "Partager",
-  "status.reply": "Répondre",
-  "status.sensitive_toggle": "Cliquer pour dévoiler",
-  "status.sensitive_warning": "Contenu délicat",
-  "tabs_bar.compose": "Composer",
-  "tabs_bar.home": "Accueil",
-  "tabs_bar.mentions": "Mentions",
-  "tabs_bar.notifications": "Notifications",
-  "tabs_bar.public": "Public",
-  "upload_button.label": "Joindre un média",
-  "upload_form.undo": "Annuler",
-  "video_player.toggle_sound": "Mettre/Couper le son",
+  "privacy.public.short": "Public",
+  "privacy.public.long": "Afficher dans les fils publics",
+  "privacy.unlisted.short": "Non-listé",
+  "privacy.unlisted.long": "Ne pas afficher dans les fils publics",
+  "privacy.private.short": "Privé",
+  "privacy.private.long": "N’afficher que pour vos abonné⋅e⋅s",
+  "privacy.direct.short": "Direct",
+  "privacy.direct.long": "N’afficher que pour les personnes mentionné⋅e⋅s",
+  "privacy.change": "Ajuster la confidentialité du message",
 };
 
 export default fr;
diff --git a/app/assets/javascripts/components/locales/index.jsx b/app/assets/javascripts/components/locales/index.jsx
index 203929d66..72b8a5df5 100644
--- a/app/assets/javascripts/components/locales/index.jsx
+++ b/app/assets/javascripts/components/locales/index.jsx
@@ -5,6 +5,7 @@ import hu from './hu';
 import fr from './fr';
 import pt from './pt';
 import uk from './uk';
+import fi from './fi';
 
 const locales = {
   en,
@@ -13,7 +14,8 @@ const locales = {
   hu,
   fr,
   pt,
-  uk
+  uk,
+  fi
 };
 
 export default function getMessagesForLocale (locale) {
diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx
index 6ce41670d..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,
@@ -97,7 +97,7 @@ export default function accounts(state = initialState, action) {
     return normalizeAccounts(state, action.accounts);
   case NOTIFICATIONS_REFRESH_SUCCESS:
   case NOTIFICATIONS_EXPAND_SUCCESS:
-  case SEARCH_SUGGESTIONS_READY:
+  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/modal.jsx b/app/assets/javascripts/components/reducers/modal.jsx
index 37ffbc62b..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 => (index - 1) % state.get('media').size);
-  case MODAL_INDEX_INCREASE:
-    return state.update('index', index => (index + 1) % state.get('media').size);
+    return initialState;
   default:
     return state;
   }
diff --git a/app/assets/javascripts/components/reducers/relationships.jsx b/app/assets/javascripts/components/reducers/relationships.jsx
index 591f8034b..c65c48b43 100644
--- a/app/assets/javascripts/components/reducers/relationships.jsx
+++ b/app/assets/javascripts/components/reducers/relationships.jsx
@@ -23,16 +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:
-    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;
+  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 e95f9ed79..b3fe6c7be 100644
--- a/app/assets/javascripts/components/reducers/search.jsx
+++ b/app/assets/javascripts/components/reducers/search.jsx
@@ -1,14 +1,17 @@
 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, hashtags, statuses) => {
@@ -69,14 +72,24 @@ 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, action.hashtags, action.statuses);
-  case SEARCH_RESET:
+  case SEARCH_CLEAR:
     return state.withMutations(map => {
-      map.set('suggestions', []);
       map.set('value', '');
-      map.set('loaded_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 1669b8c65..ca8fa7a01 100644
--- a/app/assets/javascripts/components/reducers/statuses.jsx
+++ b/app/assets/javascripts/components/reducers/statuses.jsx
@@ -32,7 +32,7 @@ import {
   FAVOURITED_STATUSES_FETCH_SUCCESS,
   FAVOURITED_STATUSES_EXPAND_SUCCESS
 } from '../actions/favourites';
-import { SEARCH_SUGGESTIONS_READY } from '../actions/search';
+import { SEARCH_FETCH_SUCCESS } from '../actions/search';
 import Immutable from 'immutable';
 
 const normalizeStatus = (state, status) => {
@@ -109,7 +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_SUGGESTIONS_READY:
+  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 c67d05423..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,
@@ -35,6 +37,7 @@ const initialState = Immutable.Map({
     path: () => '/api/v1/timelines/home',
     next: null,
     isLoading: false,
+    online: false,
     loaded: false,
     top: true,
     unread: 0,
@@ -45,6 +48,7 @@ const initialState = Immutable.Map({
     path: () => '/api/v1/timelines/public',
     next: null,
     isLoading: false,
+    online: false,
     loaded: false,
     top: true,
     unread: 0,
@@ -56,6 +60,7 @@ const initialState = Immutable.Map({
     next: null,
     params: { local: true },
     isLoading: false,
+    online: false,
     loaded: false,
     top: true,
     unread: 0,
@@ -300,6 +305,10 @@ export default function timelines(state = initialState, action) {
     return filterTimelines(state, action.relationship, action.statuses);
   case TIMELINE_SCROLL_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/selectors/index.jsx b/app/assets/javascripts/components/selectors/index.jsx
index 0e88654a1..01a6cb264 100644
--- a/app/assets/javascripts/components/selectors/index.jsx
+++ b/app/assets/javascripts/components/selectors/index.jsx
@@ -5,7 +5,7 @@ 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) => {
diff --git a/app/assets/javascripts/extras.jsx b/app/assets/javascripts/extras.jsx
index 5738863dd..c13feceff 100644
--- a/app/assets/javascripts/extras.jsx
+++ b/app/assets/javascripts/extras.jsx
@@ -24,4 +24,17 @@ $(() => {
       window.location.href = $(e.target).attr('href');
     }
   });
+
+  $('.status__content__spoiler-link').on('click', e => {
+    e.preventDefault();
+    const contentEl = $(e.target).parent().parent().find('div');
+
+    if (contentEl.is(':visible')) {
+      contentEl.hide();
+      $(e.target).parent().attr('style', 'margin-bottom: 0');
+    } else {
+      contentEl.show();
+      $(e.target).parent().attr('style', null);
+    }
+  });
 });
diff --git a/app/assets/stylesheets/accounts.scss b/app/assets/stylesheets/accounts.scss
index 7c48c91f3..25e24a95a 100644
--- a/app/assets/stylesheets/accounts.scss
+++ b/app/assets/stylesheets/accounts.scss
@@ -311,6 +311,7 @@
       padding: 10px;
       padding-top: 15px;
       color: $color3;
+      word-wrap: break-word;
     }
   }
 }
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index 04d37546c..d233b3471 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -21,7 +21,7 @@
   text-decoration: none;
   transition: all 100ms ease-in;
 
-  &:hover {
+  &:hover, &:active, &:focus {
     background-color: lighten($color4, 7%);
     transition: all 200ms ease-out;
   }
@@ -54,7 +54,7 @@
   cursor: pointer;
   transition: all 100ms ease-in;
 
-  &:hover {
+  &:hover, &:active, &:focus {
     color: lighten($color1, 33%);
     transition: all 200ms ease-out;
   }
@@ -79,7 +79,7 @@
   &.inverted {
     color: lighten($color1, 33%);
 
-    &:hover {
+    &:hover, &:active, &:focus {
       color: lighten($color1, 26%);
     }
 
@@ -105,7 +105,7 @@
   outline: 0;
   transition: all 100ms ease-in;
 
-  &:hover {
+  &:hover, &:active, &:focus {
     color: lighten($color1, 26%);
     transition: all 200ms ease-out;
   }
@@ -424,6 +424,7 @@ a.status__content__spoiler-link {
 
 .account__header__content {
   word-wrap: break-word;
+  word-break: normal;
   font-weight: 400;
   overflow: hidden;
   color: $color3;
@@ -764,8 +765,19 @@ a.status__content__spoiler-link {
   }
 }
 
+.drawer__pager {
+  box-sizing: border-box;
+  padding: 0;
+  flex-grow: 1;
+  position: relative;
+  overflow: hidden;
+  display: flex;
+}
+
 .drawer__inner {
-  //background: linear-gradient(rgba(lighten($color1, 13%), 1), rgba(lighten($color1, 13%), 0.65));
+  position: absolute;
+  top: 0;
+  left: 0;
   background: lighten($color1, 13%);
   box-sizing: border-box;
   padding: 0;
@@ -773,7 +785,12 @@ a.status__content__spoiler-link {
   flex-direction: column;
   overflow: hidden;
   overflow-y: auto;
-  flex-grow: 1;
+  width: 100%;
+  height: 100%;
+
+  &.darker {
+    background: $color1;
+  }
 }
 
 .drawer__header {
@@ -842,11 +859,25 @@ a.status__content__spoiler-link {
   font-size:12px;
   font-weight: 500;
   border-bottom: 2px solid lighten($color1, 8%);
+  transition: all 200ms linear;
+
+  .fa {
+    font-weight: 400;
+  }
 
   &.active {
     border-bottom: 2px solid $color4;
     color: $color4;
   }
+
+  &:hover, &:focus, &:active {
+    background: lighten($color1, 14%);
+    transition: all 100ms linear;
+  }
+
+  span {
+    display: none;
+  }
 }
 
 @media screen and (min-width: 360px) {
@@ -854,6 +885,22 @@ a.status__content__spoiler-link {
     margin: 10px;
     margin-bottom: 0;
   }
+
+  .search {
+    margin-bottom: 10px;
+  }
+}
+
+@media screen and (min-width: 600px) {
+  .tabs-bar__link {
+    .fa {
+      margin-right: 5px;
+    }
+
+    span {
+      display: inline;
+    }
+  }
 }
 
 @media screen and (min-width: 1025px) {
@@ -1102,11 +1149,9 @@ a.status__content__spoiler-link {
 
 .getting-started {
   box-sizing: border-box;
-  overflow-y: auto;
   padding-bottom: 235px;
-  background: image-url('mastodon-getting-started.png') no-repeat bottom left;
-  height: auto;
-  min-height: 100%;
+  background: image-url('mastodon-getting-started.png') no-repeat 0 100% local;
+  flex: 1 0 auto;
 
   p {
     color: $color2;
@@ -1224,26 +1269,6 @@ button.active i.fa-retweet {
   }
 }
 
-.search {
-  .fa {
-    color: $color3;
-  }
-}
-
-.search__input {
-  box-sizing: border-box;
-  display: block;
-  width: 100%;
-  border: none;
-  padding: 10px;
-  padding-right: 30px;
-  font-family: inherit;
-  background: $color1;
-  color: $color3;
-  font-size: 14px;
-  margin: 0;
-}
-
 .loading-indicator {
   color: $color2;
 }
@@ -1286,7 +1311,7 @@ button.active i.fa-retweet {
   color: $color3;
 }
 
-.modal-container--nav {
+.modal-container__nav {
   color: $color5;
 }
 
@@ -1640,7 +1665,7 @@ button.active i.fa-retweet {
     margin-top: 2px;
   }
 
-  &:hover {
+  &:hover, &:active, &:focus {
     img {
       opacity: 1;
       filter: none;
@@ -1723,3 +1748,147 @@ button.active i.fa-retweet {
     box-shadow: 2px 4px 6px rgba($color8, 0.1);
   }
 }
+
+.search {
+  position: relative;
+}
+
+.search__input {
+  padding-right: 30px;
+  color: $color2;
+  outline: 0;
+  box-sizing: border-box;
+  display: block;
+  width: 100%;
+  border: none;
+  padding: 10px;
+  padding-right: 30px;
+  font-family: inherit;
+  background: $color1;
+  color: $color3;
+  font-size: 14px;
+  margin: 0;
+
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner, &:focus, &:active {
+    outline: 0 !important;
+  }
+
+  &:focus {
+    background: lighten($color1, 4%);
+  }
+}
+
+.search__icon {
+  .fa {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    z-index: 2;
+    display: inline-block;
+    opacity: 0;
+    transition: all 100ms linear;
+    font-size: 18px;
+    width: 18px;
+    height: 18px;
+    color: $color2;
+    cursor: default;
+    pointer-events: none;
+
+    &.active {
+      pointer-events: auto;
+      opacity: 0.3;
+    }
+  }
+
+  .fa-search {
+    transform: translateZ(0) rotate(90deg);
+
+    &.active {
+      pointer-events: none;
+      transform: translateZ(0) rotate(0deg);
+    }
+  }
+
+  .fa-times-circle {
+    top: 11px;
+    transform: translateZ(0) rotate(0deg);
+    cursor: pointer;
+
+    &.active {
+      transform: translateZ(0) rotate(90deg);
+    }
+
+    &:hover {
+      color: $color5;
+    }
+  }
+}
+
+.search-results__header {
+  color: lighten($color1, 26%);
+  background: lighten($color1, 2%);
+  border-bottom: 1px solid darken($color1, 4%);
+  padding: 15px 10px;
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.search-results__hashtag {
+  display: block;
+  padding: 10px;
+  color: $color2;
+  text-decoration: none;
+
+  &:hover, &:active, &:focus {
+    color: lighten($color2, 4%);
+    text-decoration: underline;
+  }
+}
+
+.modal-root__overlay {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 9999;
+  opacity: 0;
+  background: rgba($color8, 0.7);
+}
+
+.modal-root__container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  align-content: space-around;
+  z-index: 9999;
+  opacity: 0;
+  pointer-events: none;
+  user-select: none;
+}
+
+.modal-root__modal {
+  pointer-events: auto;
+  display: flex;
+}
+
+.media-modal {
+  max-width: 80vw;
+  max-height: 80vh;
+  position: relative;
+
+  img, video {
+    max-width: 80vw;
+    max-height: 80vh;
+  }
+}
diff --git a/app/assets/stylesheets/stream_entries.scss b/app/assets/stylesheets/stream_entries.scss
index b9a9a1da3..4a6dc6aa4 100644
--- a/app/assets/stylesheets/stream_entries.scss
+++ b/app/assets/stylesheets/stream_entries.scss
@@ -97,6 +97,15 @@
       a {
         color: $color4;
       }
+
+      a.status__content__spoiler-link {
+        color: $color5;
+        background: $color3;
+
+        &:hover {
+          background: lighten($color3, 8%);
+        }
+      }
     }
 
     .status__attachments {
@@ -163,6 +172,15 @@
       a {
         color: $color4;
       }
+
+      a.status__content__spoiler-link {
+        color: $color5;
+        background: $color3;
+
+        &:hover {
+          background: lighten($color3, 8%);
+        }
+      }
     }
 
     .detailed-status__meta {
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb
index 491036db2..abf4b7df4 100644
--- a/app/controllers/about_controller.rb
+++ b/app/controllers/about_controller.rb
@@ -5,6 +5,9 @@ class AboutController < ApplicationController
 
   def index
     @description = Setting.site_description
+
+    @user = User.new
+    @user.build_account
   end
 
   def more
diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb
index e362957e7..1f4432847 100644
--- a/app/controllers/admin/domain_blocks_controller.rb
+++ b/app/controllers/admin/domain_blocks_controller.rb
@@ -9,6 +9,24 @@ class Admin::DomainBlocksController < ApplicationController
     @blocks = DomainBlock.paginate(page: params[:page], per_page: 40)
   end
 
+  def new
+    @domain_block = DomainBlock.new
+  end
+
   def create
+    @domain_block = DomainBlock.new(resource_params)
+
+    if @domain_block.save
+      DomainBlockWorker.perform_async(@domain_block.id)
+      redirect_to admin_domain_blocks_path, notice: 'Domain block is now being processed'
+    else
+      render action: :new
+    end
+  end
+
+  private
+
+  def resource_params
+    params.require(:domain_block).permit(:domain, :severity)
   end
 end
diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb
index 67d57e4eb..2b3b1809f 100644
--- a/app/controllers/admin/reports_controller.rb
+++ b/app/controllers/admin/reports_controller.rb
@@ -7,7 +7,7 @@ class Admin::ReportsController < ApplicationController
   layout 'admin'
 
   def index
-    @reports = Report.includes(:account, :target_account).paginate(page: params[:page], per_page: 40)
+    @reports = Report.includes(:account, :target_account).order('id desc').paginate(page: params[:page], per_page: 40)
     @reports = params[:action_taken].present? ? @reports.resolved : @reports.unresolved
   end
 
@@ -16,19 +16,19 @@ class Admin::ReportsController < ApplicationController
   end
 
   def resolve
-    @report.update(action_taken: true)
+    @report.update(action_taken: true, action_taken_by_account_id: current_account.id)
     redirect_to admin_report_path(@report)
   end
 
   def suspend
     Admin::SuspensionWorker.perform_async(@report.target_account.id)
-    @report.update(action_taken: true)
+    Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id)
     redirect_to admin_report_path(@report)
   end
 
   def silence
     @report.target_account.update(silenced: true)
-    @report.update(action_taken: true)
+    Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id)
     redirect_to admin_report_path(@report)
   end
 
diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb
index ca9dd0b7e..2ec7280af 100644
--- a/app/controllers/api/v1/apps_controller.rb
+++ b/app/controllers/api/v1/apps_controller.rb
@@ -4,6 +4,12 @@ class Api::V1::AppsController < ApiController
   respond_to :json
 
   def create
-    @app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes), website: params[:website])
+    @app = Doorkeeper::Application.create!(name: app_params[:client_name], redirect_uri: app_params[:redirect_uris], scopes: (app_params[:scopes] || Doorkeeper.configuration.default_scopes), website: app_params[:website])
+  end
+
+  private
+
+  def app_params
+    params.permit(:client_name, :redirect_uris, :scopes, :website)
   end
 end
diff --git a/app/controllers/api/v1/follows_controller.rb b/app/controllers/api/v1/follows_controller.rb
index c22dacbaa..7c0f44f03 100644
--- a/app/controllers/api/v1/follows_controller.rb
+++ b/app/controllers/api/v1/follows_controller.rb
@@ -7,7 +7,7 @@ class Api::V1::FollowsController < ApiController
   respond_to :json
 
   def create
-    raise ActiveRecord::RecordNotFound if params[:uri].blank?
+    raise ActiveRecord::RecordNotFound if follow_params[:uri].blank?
 
     @account = FollowService.new.call(current_user.account, target_uri).try(:target_account)
     render action: :show
@@ -16,6 +16,10 @@ class Api::V1::FollowsController < ApiController
   private
 
   def target_uri
-    params[:uri].strip.gsub(/\A@/, '')
+    follow_params[:uri].strip.gsub(/\A@/, '')
+  end
+
+  def follow_params
+    params.permit(:uri)
   end
 end
diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb
index f8139ade7..aed3578d7 100644
--- a/app/controllers/api/v1/media_controller.rb
+++ b/app/controllers/api/v1/media_controller.rb
@@ -10,10 +10,16 @@ class Api::V1::MediaController < ApiController
   respond_to :json
 
   def create
-    @media = MediaAttachment.create!(account: current_user.account, file: params[:file])
+    @media = MediaAttachment.create!(account: current_user.account, file: media_params[:file])
   rescue Paperclip::Errors::NotIdentifiedByImageMagickError
     render json: { error: 'File type of uploaded media could not be verified' }, status: 422
   rescue Paperclip::Error
     render json: { error: 'Error processing thumbnail for uploaded media' }, status: 500
   end
+
+  private
+
+  def media_params
+    params.permit(:file)
+  end
 end
diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb
index 46bdddbc1..f83c573cb 100644
--- a/app/controllers/api/v1/reports_controller.rb
+++ b/app/controllers/api/v1/reports_controller.rb
@@ -12,13 +12,19 @@ class Api::V1::ReportsController < ApiController
   end
 
   def create
-    status_ids = params[:status_ids].is_a?(Enumerable) ? params[:status_ids] : [params[:status_ids]]
+    status_ids = report_params[:status_ids].is_a?(Enumerable) ? report_params[:status_ids] : [report_params[:status_ids]]
 
     @report = Report.create!(account: current_account,
-                             target_account: Account.find(params[:account_id]),
+                             target_account: Account.find(report_params[:account_id]),
                              status_ids: Status.find(status_ids).pluck(:id),
-                             comment: params[:comment])
+                             comment: report_params[:comment])
 
     render :show
   end
+
+  private
+
+  def report_params
+    params.permit(:account_id, :comment, status_ids: [])
+  end
 end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 024258c0e..4ece7e702 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -62,11 +62,11 @@ class Api::V1::StatusesController < ApiController
   end
 
   def create
-    @status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids],
-                                                                                                                                                             sensitive: params[:sensitive],
-                                                                                                                                                             spoiler_text: params[:spoiler_text],
-                                                                                                                                                             visibility: params[:visibility],
-                                                                                                                                                             application: doorkeeper_token.application)
+    @status = PostStatusService.new.call(current_user.account, status_params[:status], status_params[:in_reply_to_id].blank? ? nil : Status.find(status_params[:in_reply_to_id]), media_ids: status_params[:media_ids],
+                                                                                                                                                                                  sensitive: status_params[:sensitive],
+                                                                                                                                                                                  spoiler_text: status_params[:spoiler_text],
+                                                                                                                                                                                  visibility: status_params[:visibility],
+                                                                                                                                                                                  application: doorkeeper_token.application)
     render action: :show
   end
 
@@ -111,4 +111,8 @@ class Api::V1::StatusesController < ApiController
     @status = Status.find(params[:id])
     raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account)
   end
+
+  def status_params
+    params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, media_ids: [])
+  end
 end
diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb
index af6e5b7df..0446b9e4d 100644
--- a/app/controllers/api/v1/timelines_controller.rb
+++ b/app/controllers/api/v1/timelines_controller.rb
@@ -11,8 +11,8 @@ class Api::V1::TimelinesController < ApiController
     @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
-    set_counters_maps(@statuses)
-    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
+    # set_counters_maps(@statuses)
+    # set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
     next_path = api_v1_home_timeline_url(max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = api_v1_home_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
@@ -27,8 +27,8 @@ class Api::V1::TimelinesController < ApiController
     @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
-    set_counters_maps(@statuses)
-    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
+    # set_counters_maps(@statuses)
+    # set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
     next_path = api_v1_public_timeline_url(max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = api_v1_public_timeline_url(since_id: @statuses.first.id) unless @statuses.empty?
@@ -44,8 +44,8 @@ class Api::V1::TimelinesController < ApiController
     @statuses = cache_collection(@statuses)
 
     set_maps(@statuses)
-    set_counters_maps(@statuses)
-    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
+    # set_counters_maps(@statuses)
+    # set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
     next_path = api_v1_hashtag_timeline_url(params[:id], max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = api_v1_hashtag_timeline_url(params[:id], since_id: @statuses.first.id) unless @statuses.empty?
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index ef9364897..c06142fd4 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -39,7 +39,14 @@ class ApplicationController < ActionController::Base
   end
 
   def set_user_activity
-    current_user.touch(:current_sign_in_at) if !current_user.nil? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < 24.hours.ago)
+    return unless !current_user.nil? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < 24.hours.ago)
+
+    # Mark user as signed-in today
+    current_user.update_tracked_fields(request)
+
+    # If the sign in is after a two week break, we need to regenerate their feed
+    RegenerationWorker.perform_async(current_user.account_id) if current_user.last_sign_in_at < 14.days.ago
+    return
   end
 
   def check_suspension
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
index feaad04f6..7c25266d8 100644
--- a/app/controllers/oauth/authorizations_controller.rb
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -3,6 +3,7 @@
 class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
   skip_before_action :authenticate_resource_owner!
 
+  before_action :set_locale
   before_action :store_current_location
   before_action :authenticate_resource_owner!
 
@@ -11,4 +12,10 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
   def store_current_location
     store_location_for(:user, request.url)
   end
+
+  def set_locale
+    I18n.locale = current_user.try(:locale) || I18n.default_locale
+  rescue I18n::InvalidLocale
+    I18n.locale = I18n.default_locale
+  end
 end
diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb
new file mode 100644
index 000000000..cbb5e65da
--- /dev/null
+++ b/app/controllers/settings/imports_controller.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class Settings::ImportsController < ApplicationController
+  layout 'admin'
+
+  before_action :authenticate_user!
+  before_action :set_account
+
+  def show
+    @import = Import.new
+  end
+
+  def create
+    @import = Import.new(import_params)
+    @import.account = @account
+
+    if @import.save
+      ImportWorker.perform_async(@import.id)
+      redirect_to settings_import_path, notice: I18n.t('imports.success')
+    else
+      render action: :show
+    end
+  end
+
+  private
+
+  def set_account
+    @account = current_user.account
+  end
+
+  def import_params
+    params.require(:import).permit(:data, :type)
+  end
+end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 74215e8df..e01f7d0cc 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -10,6 +10,7 @@ module SettingsHelper
     hu: 'Magyar',
     uk: 'Українська',
     'zh-CN': '简体中文',
+    fi: 'Suomi',
   }.freeze
 
   def human_locale(locale)
diff --git a/app/lib/exceptions.rb b/app/lib/exceptions.rb
index 200da9fe1..9bc802c12 100644
--- a/app/lib/exceptions.rb
+++ b/app/lib/exceptions.rb
@@ -4,4 +4,5 @@ module Mastodon
   class Error < StandardError; end
   class NotPermittedError < Error; end
   class ValidationError < Error; end
+  class RaceConditionError < Error; end
 end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index b0dda1256..cd6ca1291 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -52,7 +52,7 @@ class FeedManager
     timeline_key = key(:home, into_account.id)
 
     from_account.statuses.limit(MAX_ITEMS).each do |status|
-      next if filter?(:home, status, into_account)
+      next if status.direct_visibility? || filter?(:home, status, into_account)
       redis.zadd(timeline_key, status.id, status.id)
     end
 
diff --git a/app/models/feed.rb b/app/models/feed.rb
index 5e1905e15..3cbc160a0 100644
--- a/app/models/feed.rb
+++ b/app/models/feed.rb
@@ -10,17 +10,9 @@ class Feed
     max_id     = '+inf' if max_id.blank?
     since_id   = '-inf' if since_id.blank?
     unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i)
+    status_map = Status.where(id: unhydrated).cache_ids.map { |s| [s.id, s] }.to_h
 
-    # If we're after most recent items and none are there, we need to precompute the feed
-    if unhydrated.empty? && max_id == '+inf' && since_id == '-inf'
-      RegenerationWorker.perform_async(@account.id, @type)
-      @statuses = Status.send("as_#{@type}_timeline", @account).cache_ids.paginate_by_max_id(limit, nil, nil)
-    else
-      status_map = Status.where(id: unhydrated).cache_ids.map { |s| [s.id, s] }.to_h
-      @statuses  = unhydrated.map { |id| status_map[id] }.compact
-    end
-
-    @statuses
+    unhydrated.map { |id| status_map[id] }.compact
   end
 
   private
diff --git a/app/models/import.rb b/app/models/import.rb
new file mode 100644
index 000000000..5384986d8
--- /dev/null
+++ b/app/models/import.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Import < ApplicationRecord
+  self.inheritance_column = false
+
+  enum type: [:following, :blocking]
+
+  belongs_to :account
+
+  FILE_TYPES = ['text/plain', 'text/csv'].freeze
+
+  has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET']
+  validates_attachment_content_type :data, content_type: FILE_TYPES
+end
diff --git a/app/models/report.rb b/app/models/report.rb
index 05dc8cff1..fd8e46aac 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -3,6 +3,7 @@
 class Report < ApplicationRecord
   belongs_to :account
   belongs_to :target_account, class_name: 'Account'
+  belongs_to :action_taken_by_account, class_name: 'Account'
 
   scope :unresolved, -> { where(action_taken: false) }
   scope :resolved,   -> { where(action_taken: true) }
diff --git a/app/models/status.rb b/app/models/status.rb
index 81b26fd14..daf128572 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -188,7 +188,7 @@ class Status < ApplicationRecord
   end
 
   before_validation do
-    text.strip!
+    text&.strip!
     spoiler_text&.strip!
 
     self.reply                  = !(in_reply_to_id.nil? && thread.nil?) unless reply
diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb
index 9518b1fcf..6c131bd34 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -1,13 +1,11 @@
 # frozen_string_literal: true
 
 class BlockDomainService < BaseService
-  def call(domain, severity)
-    DomainBlock.where(domain: domain).first_or_create!(domain: domain, severity: severity)
-
-    if severity == :silence
-      Account.where(domain: domain).update_all(silenced: true)
+  def call(domain_block)
+    if domain_block.silence?
+      Account.where(domain: domain_block.domain).update_all(silenced: true)
     else
-      Account.where(domain: domain).find_each do |account|
+      Account.where(domain: domain_block.domain).find_each do |account|
         account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed?
         SuspendAccountService.new.call(account)
       end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 0cacfd7cd..df404cbef 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -4,9 +4,15 @@ class FanOutOnWriteService < BaseService
   # Push a status into home and mentions feeds
   # @param [Status] status
   def call(status)
+    raise Mastodon::RaceConditionError if status.visibility.nil?
+
     deliver_to_self(status) if status.account.local?
 
-    status.direct_visibility? ? deliver_to_mentioned_followers(status) : deliver_to_followers(status)
+    if status.direct_visibility?
+      deliver_to_mentioned_followers(status)
+    else
+      deliver_to_followers(status)
+    end
 
     return if status.account.silenced? || !status.public_visibility? || status.reblog?
 
diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb
index 54d11b631..e1ec56e8d 100644
--- a/app/services/precompute_feed_service.rb
+++ b/app/services/precompute_feed_service.rb
@@ -4,10 +4,10 @@ class PrecomputeFeedService < BaseService
   # Fill up a user's home/mentions feed from DB and return a subset
   # @param [Symbol] type :home or :mentions
   # @param [Account] account
-  def call(type, account)
-    Status.send("as_#{type}_timeline", account).limit(FeedManager::MAX_ITEMS).each do |status|
-      next if FeedManager.instance.filter?(type, status, account)
-      redis.zadd(FeedManager.instance.key(type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
+  def call(_, account)
+    Status.as_home_timeline(account).limit(FeedManager::MAX_ITEMS).each do |status|
+      next if status.direct_visibility? || FeedManager.instance.filter?(:home, status, account)
+      redis.zadd(FeedManager.instance.key(:home, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
     end
   end
 
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 159c03713..e9745010b 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -2,10 +2,10 @@
 
 class SearchService < BaseService
   def call(query, limit, resolve = false, account = nil)
-    return if query.blank?
-
     results = { accounts: [], hashtags: [], statuses: [] }
 
+    return results if query.blank?
+
     if query =~ /\Ahttps?:\/\//
       resource = FetchRemoteResourceService.new.call(query)
 
diff --git a/app/views/about/index.html.haml b/app/views/about/index.html.haml
index be5e406c5..fdfb2b916 100644
--- a/app/views/about/index.html.haml
+++ b/app/views/about/index.html.haml
@@ -24,7 +24,7 @@
   .screenshot-with-signup
     .mascot= image_tag 'fluffy-elephant-friend.png'
 
-    = simple_form_for(:user, url: user_registration_path) do |f|
+    = simple_form_for(@user, url: user_registration_path) do |f|
       = f.simple_fields_for :account do |ff|
         = ff.input :username, autofocus: true, placeholder: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username') }
 
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index e35b08317..0d43fba30 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -23,12 +23,12 @@
       .counter{ class: active_nav_class(short_account_url(@account)) }
         = link_to short_account_url(@account), class: 'u-url u-uid' do
           %span.counter-label= t('accounts.posts')
-          %span.counter-number= number_with_delimiter @account.statuses.count
+          %span.counter-number= number_with_delimiter @account.statuses_count
       .counter{ class: active_nav_class(following_account_url(@account)) }
         = link_to following_account_url(@account) do
           %span.counter-label= t('accounts.following')
-          %span.counter-number= number_with_delimiter @account.following.count
+          %span.counter-number= number_with_delimiter @account.following_count
       .counter{ class: active_nav_class(followers_account_url(@account)) }
         = link_to followers_account_url(@account) do
           %span.counter-label= t('accounts.followers')
-          %span.counter-number= number_with_delimiter @account.followers.count
+          %span.counter-number= number_with_delimiter @account.followers_count
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index b528e161e..ba1c3bae7 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -47,13 +47,13 @@
 
     %tr
       %th Follows
-      %td= @account.following.count
+      %td= @account.following_count
     %tr
       %th Followers
-      %td= @account.followers.count
+      %td= @account.followers_count
     %tr
       %th Statuses
-      %td= @account.statuses.count
+      %td= @account.statuses_count
     %tr
       %th Media attachments
       %td
diff --git a/app/views/admin/domain_blocks/index.html.haml b/app/views/admin/domain_blocks/index.html.haml
index dbaeb4716..eb7894b86 100644
--- a/app/views/admin/domain_blocks/index.html.haml
+++ b/app/views/admin/domain_blocks/index.html.haml
@@ -14,3 +14,4 @@
         %td= block.severity
 
 = will_paginate @blocks, pagination_options
+= link_to 'Add new', new_admin_domain_block_path, class: 'button'
diff --git a/app/views/admin/domain_blocks/new.html.haml b/app/views/admin/domain_blocks/new.html.haml
new file mode 100644
index 000000000..fbd39d6cf
--- /dev/null
+++ b/app/views/admin/domain_blocks/new.html.haml
@@ -0,0 +1,18 @@
+- content_for :page_title do
+  New domain block
+
+= simple_form_for @domain_block, url: admin_domain_blocks_path do |f|
+  = render 'shared/error_messages', object: @domain_block
+
+  %p.hint The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts.
+
+  = f.input :domain, placeholder: 'Domain'
+  = f.input :severity, collection: DomainBlock.severities.keys, wrapper: :with_label, include_blank: false
+
+  %p.hint
+    %strong Silence
+    will make the account's posts invisible to anyone who isn't following them.
+    %strong Suspend
+    will remove all of the account's content, media, and profile data.
+  .actions
+    = f.button :button, 'Create block', type: :submit
diff --git a/app/views/admin/reports/index.html.haml b/app/views/admin/reports/index.html.haml
index 8a5414cef..839259dc2 100644
--- a/app/views/admin/reports/index.html.haml
+++ b/app/views/admin/reports/index.html.haml
@@ -8,20 +8,25 @@
       %li= filter_link_to 'Unresolved', action_taken: nil
       %li= filter_link_to 'Resolved', action_taken: '1'
 
-%table.table
-  %thead
-    %tr
-      %th ID
-      %th Target
-      %th Reported by
-      %th Comment
-      %th
-  %tbody
-    - @reports.each do |report|
+= form_tag do
+
+  %table.table
+    %thead
       %tr
-        %td= "##{report.id}"
-        %td= link_to report.target_account.acct, admin_account_path(report.target_account.id)
-        %td= link_to report.account.acct, admin_account_path(report.account.id)
-        %td= truncate(report.comment, length: 30, separator: ' ')
-        %td= table_link_to 'circle', 'View', admin_report_path(report)
+        %th
+        %th ID
+        %th Target
+        %th Reported by
+        %th Comment
+        %th
+    %tbody
+      - @reports.each do |report|
+        %tr
+          %td= check_box_tag 'select', report.id
+          %td= "##{report.id}"
+          %td= link_to report.target_account.acct, admin_account_path(report.target_account.id)
+          %td= link_to report.account.acct, admin_account_path(report.account.id)
+          %td= truncate(report.comment, length: 30, separator: ' ')
+          %td= table_link_to 'circle', 'View', admin_report_path(report)
+
 = will_paginate @reports, pagination_options
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index 74cac016d..caa8415df 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -27,7 +27,7 @@
         = link_to remove_admin_report_path(@report, status_id: status.id), method: :post, class: 'icon-button', style: 'font-size: 24px; width: 24px; height: 24px', title: 'Delete' do
           = fa_icon 'trash'
 
-- unless @report.action_taken?
+- if !@report.action_taken?
   %hr/
 
   %div{ style: 'overflow: hidden' }
@@ -36,3 +36,9 @@
       = link_to 'Suspend account', suspend_admin_report_path(@report), method: :post, class: 'button'
     %div{ style: 'float: left' }
       = link_to 'Mark as resolved', resolve_admin_report_path(@report), method: :post, class: 'button'
+- elsif !@report.action_taken_by_account.nil?
+  %hr/
+
+  %p
+    %strong Action taken by:
+    = @report.action_taken_by_account.acct
diff --git a/app/views/api/v1/accounts/show.rabl b/app/views/api/v1/accounts/show.rabl
index e21fe7941..32df0457a 100644
--- a/app/views/api/v1/accounts/show.rabl
+++ b/app/views/api/v1/accounts/show.rabl
@@ -6,6 +6,6 @@ node(:note)            { |account| Formatter.instance.simplified_format(account)
 node(:url)             { |account| TagManager.instance.url_for(account) }
 node(:avatar)          { |account| full_asset_url(account.avatar.url(:original)) }
 node(:header)          { |account| full_asset_url(account.header.url(:original)) }
-node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : (account.try(:followers_count) || account.followers.count) }
-node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : (account.try(:following_count) || account.following.count) }
-node(:statuses_count)  { |account| defined?(@statuses_counts_map)  ? (@statuses_counts_map[account.id]  || 0) : (account.try(:statuses_count)  || account.statuses.count) }
+node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : account.followers_count }
+node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : account.following_count }
+node(:statuses_count)  { |account| defined?(@statuses_counts_map)  ? (@statuses_counts_map[account.id]  || 0) : account.statuses_count }
diff --git a/app/views/api/v1/statuses/_show.rabl b/app/views/api/v1/statuses/_show.rabl
index f384b6d14..54e8a86d8 100644
--- a/app/views/api/v1/statuses/_show.rabl
+++ b/app/views/api/v1/statuses/_show.rabl
@@ -3,8 +3,8 @@ attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitiv
 node(:uri)              { |status| TagManager.instance.uri_for(status) }
 node(:content)          { |status| Formatter.instance.format(status) }
 node(:url)              { |status| TagManager.instance.url_for(status) }
-node(:reblogs_count)    { |status| defined?(@reblogs_counts_map)    ? (@reblogs_counts_map[status.id]    || 0) : (status.try(:reblogs_count) || status.reblogs.count) }
-node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : (status.try(:favourites_count) || status.favourites.count) }
+node(:reblogs_count)    { |status| defined?(@reblogs_counts_map)    ? (@reblogs_counts_map[status.id]    || 0) : status.reblogs_count }
+node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites_count }
 
 child :application do
   extends 'api/v1/apps/show'
diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml
index 750d6036f..59fe078df 100644
--- a/app/views/layouts/admin.html.haml
+++ b/app/views/layouts/admin.html.haml
@@ -12,6 +12,15 @@
     .content-wrapper
       .content
         %h2= yield :page_title
+
+        - if flash[:notice]
+          .flash-message.notice
+            %strong= flash[:notice]
+
+        - if flash[:alert]
+          .flash-message.alert
+            %strong= flash[:alert]
+
         = yield
 
 = render template: "layouts/application", locals: { body_classes: 'admin' }
diff --git a/app/views/settings/imports/show.html.haml b/app/views/settings/imports/show.html.haml
new file mode 100644
index 000000000..8502913dc
--- /dev/null
+++ b/app/views/settings/imports/show.html.haml
@@ -0,0 +1,11 @@
+- content_for :page_title do
+  = t('settings.import')
+
+%p.hint= t('imports.preface')
+
+= simple_form_for @import, url: settings_import_path do |f|
+  = f.input :type, collection: Import.types.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| I18n.t("imports.types.#{type}") }
+  = f.input :data, wrapper: :with_label, hint: t('simple_form.hints.imports.data')
+
+  .actions
+    = f.button :button, t('imports.upload'), type: :submit
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index 8c0456b1f..8495f28b9 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -9,8 +9,10 @@
 
   .status__content.e-content.p-name.emojify<
     - unless status.spoiler_text.blank?
-      %p= status.spoiler_text
-    %div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
+      %p{ style: 'margin-bottom: 0' }<
+        %span>= "#{status.spoiler_text} "
+        %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
+    %div{ style: "display: #{status.spoiler_text.blank? ? 'block' : 'none'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
 
   - unless status.media_attachments.empty?
     - if status.media_attachments.first.video?
@@ -39,11 +41,11 @@
       ·
     %span<
       = fa_icon('retweet')
-      %span= status.reblogs.count
+      %span= status.reblogs_count
     ·
     %span<
       = fa_icon('star')
-      %span= status.favourites.count
+      %span= status.favourites_count
 
     - if user_signed_in?
       ·
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index cb2c976ce..2eb9bf166 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -14,8 +14,10 @@
 
   .status__content.e-content.p-name.emojify<
     - unless status.spoiler_text.blank?
-      %p= status.spoiler_text
-    %div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
+      %p{ style: 'margin-bottom: 0' }<
+        %span>= "#{status.spoiler_text} "
+        %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
+    %div{ style: "display: #{status.spoiler_text.blank? ? 'block' : 'none'}; direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
 
   - unless status.media_attachments.empty?
     .status__attachments
diff --git a/app/workers/after_remote_follow_request_worker.rb b/app/workers/after_remote_follow_request_worker.rb
index f1d6869cc..1f2db3061 100644
--- a/app/workers/after_remote_follow_request_worker.rb
+++ b/app/workers/after_remote_follow_request_worker.rb
@@ -3,7 +3,7 @@
 class AfterRemoteFollowRequestWorker
   include Sidekiq::Worker
 
-  sidekiq_options retry: 5
+  sidekiq_options queue: 'pull', retry: 5
 
   def perform(follow_request_id)
     follow_request  = FollowRequest.find(follow_request_id)
diff --git a/app/workers/after_remote_follow_worker.rb b/app/workers/after_remote_follow_worker.rb
index 0d04456a9..bdd2c2a91 100644
--- a/app/workers/after_remote_follow_worker.rb
+++ b/app/workers/after_remote_follow_worker.rb
@@ -3,7 +3,7 @@
 class AfterRemoteFollowWorker
   include Sidekiq::Worker
 
-  sidekiq_options retry: 5
+  sidekiq_options queue: 'pull', retry: 5
 
   def perform(follow_id)
     follow          = Follow.find(follow_id)
diff --git a/app/workers/domain_block_worker.rb b/app/workers/domain_block_worker.rb
new file mode 100644
index 000000000..884477829
--- /dev/null
+++ b/app/workers/domain_block_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class DomainBlockWorker
+  include Sidekiq::Worker
+
+  def perform(domain_block_id)
+    BlockDomainService.new.call(DomainBlock.find(domain_block_id))
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/import_worker.rb b/app/workers/import_worker.rb
new file mode 100644
index 000000000..7cf29fb53
--- /dev/null
+++ b/app/workers/import_worker.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'csv'
+
+class ImportWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull', retry: false
+
+  def perform(import_id)
+    import = Import.find(import_id)
+
+    case import.type
+    when 'blocking'
+      process_blocks(import)
+    when 'following'
+      process_follows(import)
+    end
+
+    import.destroy
+  end
+
+  private
+
+  def process_blocks(import)
+    from_account = import.account
+
+    CSV.foreach(import.data.path) do |row|
+      next if row.size != 1
+
+      begin
+        target_account = FollowRemoteAccountService.new.call(row[0])
+        next if target_account.nil?
+        BlockService.new.call(from_account, target_account)
+      rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError
+        next
+      end
+    end
+  end
+
+  def process_follows(import)
+    from_account = import.account
+
+    CSV.foreach(import.data.path) do |row|
+      next if row.size != 1
+
+      begin
+        FollowService.new.call(from_account, row[0])
+      rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError
+        next
+      end
+    end
+  end
+end
diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb
index af3394b8b..834b0088b 100644
--- a/app/workers/link_crawl_worker.rb
+++ b/app/workers/link_crawl_worker.rb
@@ -3,7 +3,7 @@
 class LinkCrawlWorker
   include Sidekiq::Worker
 
-  sidekiq_options retry: false
+  sidekiq_options queue: 'pull', retry: false
 
   def perform(status_id)
     FetchLinkCardService.new.call(Status.find(status_id))
diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb
index 0f288f43f..d745cb99c 100644
--- a/app/workers/merge_worker.rb
+++ b/app/workers/merge_worker.rb
@@ -3,6 +3,8 @@
 class MergeWorker
   include Sidekiq::Worker
 
+  sidekiq_options queue: 'pull'
+
   def perform(from_account_id, into_account_id)
     FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id))
   end
diff --git a/app/workers/notification_worker.rb b/app/workers/notification_worker.rb
index 1a2faefd8..da1d6ab45 100644
--- a/app/workers/notification_worker.rb
+++ b/app/workers/notification_worker.rb
@@ -3,7 +3,7 @@
 class NotificationWorker
   include Sidekiq::Worker
 
-  sidekiq_options retry: 5
+  sidekiq_options queue: 'push', retry: 5
 
   def perform(xml, source_account_id, target_account_id)
     SendInteractionService.new.call(xml, Account.find(source_account_id), Account.find(target_account_id))
diff --git a/app/workers/processing_worker.rb b/app/workers/processing_worker.rb
index 5df404bcc..4a467d924 100644
--- a/app/workers/processing_worker.rb
+++ b/app/workers/processing_worker.rb
@@ -3,7 +3,7 @@
 class ProcessingWorker
   include Sidekiq::Worker
 
-  sidekiq_options backtrace: true
+  sidekiq_options queue: 'pull', backtrace: true
 
   def perform(account_id, body)
     ProcessFeedService.new.call(body, Account.find(account_id))
diff --git a/app/workers/regeneration_worker.rb b/app/workers/regeneration_worker.rb
index 3aece0ba2..82665b581 100644
--- a/app/workers/regeneration_worker.rb
+++ b/app/workers/regeneration_worker.rb
@@ -3,7 +3,9 @@
 class RegenerationWorker
   include Sidekiq::Worker
 
-  def perform(account_id, timeline_type)
-    PrecomputeFeedService.new.call(timeline_type, Account.find(account_id))
+  sidekiq_options queue: 'pull', backtrace: true
+
+  def perform(account_id, _ = :home)
+    PrecomputeFeedService.new.call(:home, Account.find(account_id))
   end
 end
diff --git a/app/workers/salmon_worker.rb b/app/workers/salmon_worker.rb
index fc95ce47f..2888b574b 100644
--- a/app/workers/salmon_worker.rb
+++ b/app/workers/salmon_worker.rb
@@ -3,7 +3,7 @@
 class SalmonWorker
   include Sidekiq::Worker
 
-  sidekiq_options backtrace: true
+  sidekiq_options queue: 'pull', backtrace: true
 
   def perform(account_id, body)
     ProcessInteractionService.new.call(body, Account.find(account_id))
diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb
index 593edd032..38287e8e6 100644
--- a/app/workers/thread_resolve_worker.rb
+++ b/app/workers/thread_resolve_worker.rb
@@ -3,7 +3,7 @@
 class ThreadResolveWorker
   include Sidekiq::Worker
 
-  sidekiq_options retry: false
+  sidekiq_options queue: 'pull', retry: false
 
   def perform(child_status_id, parent_url)
     child_status  = Status.find(child_status_id)
diff --git a/app/workers/unmerge_worker.rb b/app/workers/unmerge_worker.rb
index dbf7243de..ea6aacebf 100644
--- a/app/workers/unmerge_worker.rb
+++ b/app/workers/unmerge_worker.rb
@@ -3,6 +3,8 @@
 class UnmergeWorker
   include Sidekiq::Worker
 
+  sidekiq_options queue: 'pull'
+
   def perform(from_account_id, into_account_id)
     FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id))
   end