about summary refs log tree commit diff
path: root/app/javascript/mastodon
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2017-05-03 02:04:16 +0200
committerGitHub <noreply@github.com>2017-05-03 02:04:16 +0200
commitf5bf5ebb82e3af420dcd23d602b1be6cc86838e1 (patch)
tree92eef08642a038cf44ccbc6d16a884293e7a0814 /app/javascript/mastodon
parent26bc5915727e0a0173c03cb49f5193dd612fb888 (diff)
Replace sprockets/browserify with Webpack (#2617)
* Replace browserify with webpack

* Add react-intl-translations-manager

* Do not minify in development, add offline-plugin for ServiceWorker background cache updates

* Adjust tests and dependencies

* Fix production deployments

* Fix tests

* More optimizations

* Improve travis cache for npm stuff

* Re-run travis

* Add back support for custom.scss as before

* Remove offline-plugin and babili

* Fix issue with Immutable.List().unshift(...values) not working as expected

* Make travis load schema instead of running all migrations in sequence

* Fix missing React import in WarningContainer. Optimize rendering performance by using ImmutablePureComponent instead of
React.PureComponent. ImmutablePureComponent uses Immutable.is() to compare props. Replace dynamic callback bindings in
<UI />

* Add react definitions to places that use JSX

* Add Procfile.dev for running rails, webpack and streaming API at the same time
Diffstat (limited to 'app/javascript/mastodon')
-rw-r--r--app/javascript/mastodon/.gitkeep0
-rw-r--r--app/javascript/mastodon/actions/accounts.js762
-rw-r--r--app/javascript/mastodon/actions/alerts.js24
-rw-r--r--app/javascript/mastodon/actions/blocks.js82
-rw-r--r--app/javascript/mastodon/actions/cards.js52
-rw-r--r--app/javascript/mastodon/actions/compose.js279
-rw-r--r--app/javascript/mastodon/actions/favourites.js83
-rw-r--r--app/javascript/mastodon/actions/interactions.js235
-rw-r--r--app/javascript/mastodon/actions/modal.js16
-rw-r--r--app/javascript/mastodon/actions/mutes.js82
-rw-r--r--app/javascript/mastodon/actions/notifications.js165
-rw-r--r--app/javascript/mastodon/actions/onboarding.js14
-rw-r--r--app/javascript/mastodon/actions/reports.js72
-rw-r--r--app/javascript/mastodon/actions/search.js73
-rw-r--r--app/javascript/mastodon/actions/settings.js19
-rw-r--r--app/javascript/mastodon/actions/statuses.js141
-rw-r--r--app/javascript/mastodon/actions/store.js17
-rw-r--r--app/javascript/mastodon/actions/timelines.js186
-rw-r--r--app/javascript/mastodon/api.js26
-rw-r--r--app/javascript/mastodon/components/account.js93
-rw-r--r--app/javascript/mastodon/components/attachment_list.js33
-rw-r--r--app/javascript/mastodon/components/autosuggest_textarea.js213
-rw-r--r--app/javascript/mastodon/components/avatar.js68
-rw-r--r--app/javascript/mastodon/components/button.js50
-rw-r--r--app/javascript/mastodon/components/collapsable.js21
-rw-r--r--app/javascript/mastodon/components/column_back_button.js32
-rw-r--r--app/javascript/mastodon/components/column_back_button_slim.js32
-rw-r--r--app/javascript/mastodon/components/column_collapsable.js57
-rw-r--r--app/javascript/mastodon/components/display_name.js25
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js79
-rw-r--r--app/javascript/mastodon/components/extended_video_player.js54
-rw-r--r--app/javascript/mastodon/components/icon_button.js96
-rw-r--r--app/javascript/mastodon/components/load_more.js15
-rw-r--r--app/javascript/mastodon/components/loading_indicator.js10
-rw-r--r--app/javascript/mastodon/components/media_gallery.js196
-rw-r--r--app/javascript/mastodon/components/missing_indicator.js12
-rw-r--r--app/javascript/mastodon/components/permalink.js41
-rw-r--r--app/javascript/mastodon/components/relative_timestamp.js20
-rw-r--r--app/javascript/mastodon/components/status.js123
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js138
-rw-r--r--app/javascript/mastodon/components/status_content.js165
-rw-r--r--app/javascript/mastodon/components/status_list.js130
-rw-r--r--app/javascript/mastodon/components/video_player.js210
-rw-r--r--app/javascript/mastodon/containers/account_container.js50
-rw-r--r--app/javascript/mastodon/containers/mastodon.js314
-rw-r--r--app/javascript/mastodon/containers/status_container.js118
-rw-r--r--app/javascript/mastodon/emoji.js35
-rw-r--r--app/javascript/mastodon/features/account/components/action_bar.js93
-rw-r--r--app/javascript/mastodon/features/account/components/header.js150
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js83
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js76
-rw-r--r--app/javascript/mastodon/features/account_timeline/index.js89
-rw-r--r--app/javascript/mastodon/features/blocks/index.js74
-rw-r--r--app/javascript/mastodon/features/community_timeline/index.js96
-rw-r--r--app/javascript/mastodon/features/compose/components/autosuggest_account.js26
-rw-r--r--app/javascript/mastodon/features/compose/components/character_counter.js27
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js211
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js115
-rw-r--r--app/javascript/mastodon/features/compose/components/navigation_bar.js37
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js105
-rw-r--r--app/javascript/mastodon/features/compose/components/reply_indicator.js71
-rw-r--r--app/javascript/mastodon/features/compose/components/search.js82
-rw-r--r--app/javascript/mastodon/features/compose/components/search_results.js67
-rw-r--r--app/javascript/mastodon/features/compose/components/text_icon_button.js36
-rw-r--r--app/javascript/mastodon/features/compose/components/upload_button.js61
-rw-r--r--app/javascript/mastodon/features/compose/components/upload_form.js46
-rw-r--r--app/javascript/mastodon/features/compose/components/upload_progress.js43
-rw-r--r--app/javascript/mastodon/features/compose/components/warning.js26
-rw-r--r--app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js15
-rw-r--r--app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js15
-rw-r--r--app/javascript/mastodon/features/compose/containers/compose_form_container.js64
-rw-r--r--app/javascript/mastodon/features/compose/containers/navigation_container.js10
-rw-r--r--app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js17
-rw-r--r--app/javascript/mastodon/features/compose/containers/reply_indicator_container.js24
-rw-r--r--app/javascript/mastodon/features/compose/containers/search_container.js35
-rw-r--r--app/javascript/mastodon/features/compose/containers/search_results_container.js8
-rw-r--r--app/javascript/mastodon/features/compose/containers/sensitive_button_container.js51
-rw-r--r--app/javascript/mastodon/features/compose/containers/spoiler_button_container.js25
-rw-r--r--app/javascript/mastodon/features/compose/containers/upload_button_container.js18
-rw-r--r--app/javascript/mastodon/features/compose/containers/upload_form_container.js17
-rw-r--r--app/javascript/mastodon/features/compose/containers/upload_progress_container.js9
-rw-r--r--app/javascript/mastodon/features/compose/containers/warning_container.js49
-rw-r--r--app/javascript/mastodon/features/compose/index.js86
-rw-r--r--app/javascript/mastodon/features/favourited_statuses/index.js67
-rw-r--r--app/javascript/mastodon/features/favourites/index.js61
-rw-r--r--app/javascript/mastodon/features/follow_requests/components/account_authorize.js51
-rw-r--r--app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js26
-rw-r--r--app/javascript/mastodon/features/follow_requests/index.js74
-rw-r--r--app/javascript/mastodon/features/followers/index.js92
-rw-r--r--app/javascript/mastodon/features/following/index.js92
-rw-r--r--app/javascript/mastodon/features/generic_not_found/index.js11
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js73
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/index.js90
-rw-r--r--app/javascript/mastodon/features/home_timeline/components/column_settings.js51
-rw-r--r--app/javascript/mastodon/features/home_timeline/components/setting_text.js38
-rw-r--r--app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js21
-rw-r--r--app/javascript/mastodon/features/home_timeline/index.js38
-rw-r--r--app/javascript/mastodon/features/mutes/index.js75
-rw-r--r--app/javascript/mastodon/features/notifications/components/clear_column_button.js27
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js71
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js90
-rw-r--r--app/javascript/mastodon/features/notifications/components/setting_toggle.js21
-rw-r--r--app/javascript/mastodon/features/notifications/containers/column_settings_container.js21
-rw-r--r--app/javascript/mastodon/features/notifications/containers/notification_container.js15
-rw-r--r--app/javascript/mastodon/features/notifications/index.js143
-rw-r--r--app/javascript/mastodon/features/public_timeline/index.js96
-rw-r--r--app/javascript/mastodon/features/reblogs/index.js61
-rw-r--r--app/javascript/mastodon/features/report/components/status_check_box.js40
-rw-r--r--app/javascript/mastodon/features/report/containers/status_check_box_container.js19
-rw-r--r--app/javascript/mastodon/features/report/index.js131
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js102
-rw-r--r--app/javascript/mastodon/features/status/components/card.js96
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js96
-rw-r--r--app/javascript/mastodon/features/status/containers/card_container.js8
-rw-r--r--app/javascript/mastodon/features/status/index.js199
-rw-r--r--app/javascript/mastodon/features/ui/components/boost_modal.js84
-rw-r--r--app/javascript/mastodon/features/ui/components/column.js93
-rw-r--r--app/javascript/mastodon/features/ui/components/column_header.js43
-rw-r--r--app/javascript/mastodon/features/ui/components/column_link.js32
-rw-r--r--app/javascript/mastodon/features/ui/components/column_subheading.js16
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js20
-rw-r--r--app/javascript/mastodon/features/ui/components/confirmation_modal.js51
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js103
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js93
-rw-r--r--app/javascript/mastodon/features/ui/components/onboarding_modal.js264
-rw-r--r--app/javascript/mastodon/features/ui/components/tabs_bar.js24
-rw-r--r--app/javascript/mastodon/features/ui/components/upload_area.js60
-rw-r--r--app/javascript/mastodon/features/ui/components/video_modal.js40
-rw-r--r--app/javascript/mastodon/features/ui/containers/loading_bar_container.js8
-rw-r--r--app/javascript/mastodon/features/ui/containers/modal_container.js16
-rw-r--r--app/javascript/mastodon/features/ui/containers/notifications_container.js21
-rw-r--r--app/javascript/mastodon/features/ui/containers/status_list_container.js74
-rw-r--r--app/javascript/mastodon/features/ui/index.js169
-rw-r--r--app/javascript/mastodon/is_mobile.js11
-rw-r--r--app/javascript/mastodon/link_header.js33
-rw-r--r--app/javascript/mastodon/locales/ar.json172
-rw-r--r--app/javascript/mastodon/locales/bg.json163
-rw-r--r--app/javascript/mastodon/locales/de.json163
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json1068
-rw-r--r--app/javascript/mastodon/locales/en.json163
-rw-r--r--app/javascript/mastodon/locales/eo.json163
-rw-r--r--app/javascript/mastodon/locales/es.json163
-rw-r--r--app/javascript/mastodon/locales/fa.json163
-rw-r--r--app/javascript/mastodon/locales/fi.json163
-rw-r--r--app/javascript/mastodon/locales/fr.json163
-rw-r--r--app/javascript/mastodon/locales/he.json165
-rw-r--r--app/javascript/mastodon/locales/hr.json163
-rw-r--r--app/javascript/mastodon/locales/hu.json163
-rw-r--r--app/javascript/mastodon/locales/id.json167
-rw-r--r--app/javascript/mastodon/locales/index.js57
-rw-r--r--app/javascript/mastodon/locales/io.json163
-rw-r--r--app/javascript/mastodon/locales/it.json163
-rw-r--r--app/javascript/mastodon/locales/ja.json163
-rw-r--r--app/javascript/mastodon/locales/nl.json163
-rw-r--r--app/javascript/mastodon/locales/no.json163
-rw-r--r--app/javascript/mastodon/locales/oc.json163
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json163
-rw-r--r--app/javascript/mastodon/locales/pt.json163
-rw-r--r--app/javascript/mastodon/locales/ru.json163
-rw-r--r--app/javascript/mastodon/locales/uk.json163
-rw-r--r--app/javascript/mastodon/locales/whitelist_ar.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_bg.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_de.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_en.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_eo.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_es.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_fa.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_fi.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_fr.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_hr.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_hu.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_id.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_io.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_it.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_ja.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_nl.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_no.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_oc.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_pt-BR.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_pt.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_ru.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_uk.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_zh-CN.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_zh-HK.json2
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json163
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json163
-rw-r--r--app/javascript/mastodon/middleware/errors.js33
-rw-r--r--app/javascript/mastodon/middleware/loading_bar.js25
-rw-r--r--app/javascript/mastodon/middleware/sounds.js22
-rw-r--r--app/javascript/mastodon/reducers/accounts.js133
-rw-r--r--app/javascript/mastodon/reducers/accounts_counters.js135
-rw-r--r--app/javascript/mastodon/reducers/alerts.js25
-rw-r--r--app/javascript/mastodon/reducers/cards.js14
-rw-r--r--app/javascript/mastodon/reducers/compose.js232
-rw-r--r--app/javascript/mastodon/reducers/index.js38
-rw-r--r--app/javascript/mastodon/reducers/meta.js17
-rw-r--r--app/javascript/mastodon/reducers/modal.js18
-rw-r--r--app/javascript/mastodon/reducers/notifications.js104
-rw-r--r--app/javascript/mastodon/reducers/relationships.js38
-rw-r--r--app/javascript/mastodon/reducers/reports.js60
-rw-r--r--app/javascript/mastodon/reducers/search.js96
-rw-r--r--app/javascript/mastodon/reducers/settings.js52
-rw-r--r--app/javascript/mastodon/reducers/status_lists.js39
-rw-r--r--app/javascript/mastodon/reducers/statuses.js124
-rw-r--r--app/javascript/mastodon/reducers/timelines.js317
-rw-r--r--app/javascript/mastodon/reducers/user_lists.js80
-rw-r--r--app/javascript/mastodon/rtl.js27
-rw-r--r--app/javascript/mastodon/selectors/index.js73
-rw-r--r--app/javascript/mastodon/store/configureStore.js16
-rw-r--r--app/javascript/mastodon/stream.js22
-rw-r--r--app/javascript/mastodon/uuid.js3
211 files changed, 17172 insertions, 0 deletions
diff --git a/app/javascript/mastodon/.gitkeep b/app/javascript/mastodon/.gitkeep
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/app/javascript/mastodon/.gitkeep
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
new file mode 100644
index 000000000..eac5c78bb
--- /dev/null
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -0,0 +1,762 @@
+import api, { getLinks } from '../api'
+import Immutable from 'immutable';
+
+export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
+export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
+export const ACCOUNT_FETCH_FAIL    = 'ACCOUNT_FETCH_FAIL';
+
+export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST';
+export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS';
+export const ACCOUNT_FOLLOW_FAIL    = 'ACCOUNT_FOLLOW_FAIL';
+
+export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST';
+export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS';
+export const ACCOUNT_UNFOLLOW_FAIL    = 'ACCOUNT_UNFOLLOW_FAIL';
+
+export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST';
+export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS';
+export const ACCOUNT_BLOCK_FAIL    = 'ACCOUNT_BLOCK_FAIL';
+
+export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
+export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
+export const ACCOUNT_UNBLOCK_FAIL    = 'ACCOUNT_UNBLOCK_FAIL';
+
+export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
+export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
+export const ACCOUNT_MUTE_FAIL    = 'ACCOUNT_MUTE_FAIL';
+
+export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
+export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
+export const ACCOUNT_UNMUTE_FAIL    = 'ACCOUNT_UNMUTE_FAIL';
+
+export const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST';
+export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS';
+export const ACCOUNT_TIMELINE_FETCH_FAIL    = 'ACCOUNT_TIMELINE_FETCH_FAIL';
+
+export const ACCOUNT_TIMELINE_EXPAND_REQUEST = 'ACCOUNT_TIMELINE_EXPAND_REQUEST';
+export const ACCOUNT_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_TIMELINE_EXPAND_SUCCESS';
+export const ACCOUNT_TIMELINE_EXPAND_FAIL    = 'ACCOUNT_TIMELINE_EXPAND_FAIL';
+
+export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
+export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
+export const FOLLOWERS_FETCH_FAIL    = 'FOLLOWERS_FETCH_FAIL';
+
+export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST';
+export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS';
+export const FOLLOWERS_EXPAND_FAIL    = 'FOLLOWERS_EXPAND_FAIL';
+
+export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST';
+export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS';
+export const FOLLOWING_FETCH_FAIL    = 'FOLLOWING_FETCH_FAIL';
+
+export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST';
+export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS';
+export const FOLLOWING_EXPAND_FAIL    = 'FOLLOWING_EXPAND_FAIL';
+
+export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
+export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
+export const RELATIONSHIPS_FETCH_FAIL    = 'RELATIONSHIPS_FETCH_FAIL';
+
+export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST';
+export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS';
+export const FOLLOW_REQUESTS_FETCH_FAIL    = 'FOLLOW_REQUESTS_FETCH_FAIL';
+
+export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST';
+export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS';
+export const FOLLOW_REQUESTS_EXPAND_FAIL    = 'FOLLOW_REQUESTS_EXPAND_FAIL';
+
+export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST';
+export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS';
+export const FOLLOW_REQUEST_AUTHORIZE_FAIL    = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
+
+export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
+export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
+export const FOLLOW_REQUEST_REJECT_FAIL    = 'FOLLOW_REQUEST_REJECT_FAIL';
+
+export function fetchAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchRelationships([id]));
+
+    if (getState().getIn(['accounts', id], null) !== null) {
+      return;
+    }
+
+    dispatch(fetchAccountRequest(id));
+
+    api(getState).get(`/api/v1/accounts/${id}`).then(response => {
+      dispatch(fetchAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(fetchAccountFail(id, error));
+    });
+  };
+};
+
+export function fetchAccountTimeline(id, replace = false) {
+  return (dispatch, getState) => {
+    const ids      = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List());
+    const newestId = ids.size > 0 ? ids.first() : null;
+
+    let params = '';
+    let skipLoading = false;
+
+    if (newestId !== null && !replace) {
+      params      = `?since_id=${newestId}`;
+      skipLoading = true;
+    }
+
+    dispatch(fetchAccountTimelineRequest(id, skipLoading));
+
+    api(getState).get(`/api/v1/accounts/${id}/statuses${params}`).then(response => {
+      dispatch(fetchAccountTimelineSuccess(id, response.data, replace, skipLoading));
+    }).catch(error => {
+      dispatch(fetchAccountTimelineFail(id, error, skipLoading));
+    });
+  };
+};
+
+export function expandAccountTimeline(id) {
+  return (dispatch, getState) => {
+    const lastId = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()).last();
+
+    dispatch(expandAccountTimelineRequest(id));
+
+    api(getState).get(`/api/v1/accounts/${id}/statuses`, {
+      params: {
+        limit: 10,
+        max_id: lastId
+      }
+    }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandAccountTimelineSuccess(id, response.data, next));
+    }).catch(error => {
+      dispatch(expandAccountTimelineFail(id, error));
+    });
+  };
+};
+
+export function fetchAccountRequest(id) {
+  return {
+    type: ACCOUNT_FETCH_REQUEST,
+    id
+  };
+};
+
+export function fetchAccountSuccess(account) {
+  return {
+    type: ACCOUNT_FETCH_SUCCESS,
+    account
+  };
+};
+
+export function fetchAccountFail(id, error) {
+  return {
+    type: ACCOUNT_FETCH_FAIL,
+    id,
+    error,
+    skipAlert: true
+  };
+};
+
+export function followAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(followAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => {
+      dispatch(followAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(followAccountFail(error));
+    });
+  };
+};
+
+export function unfollowAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unfollowAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => {
+      dispatch(unfollowAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(unfollowAccountFail(error));
+    });
+  }
+};
+
+export function followAccountRequest(id) {
+  return {
+    type: ACCOUNT_FOLLOW_REQUEST,
+    id
+  };
+};
+
+export function followAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_FOLLOW_SUCCESS,
+    relationship
+  };
+};
+
+export function followAccountFail(error) {
+  return {
+    type: ACCOUNT_FOLLOW_FAIL,
+    error
+  };
+};
+
+export function unfollowAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNFOLLOW_REQUEST,
+    id
+  };
+};
+
+export function unfollowAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_UNFOLLOW_SUCCESS,
+    relationship
+  };
+};
+
+export function unfollowAccountFail(error) {
+  return {
+    type: ACCOUNT_UNFOLLOW_FAIL,
+    error
+  };
+};
+
+export function fetchAccountTimelineRequest(id, skipLoading) {
+  return {
+    type: ACCOUNT_TIMELINE_FETCH_REQUEST,
+    id,
+    skipLoading
+  };
+};
+
+export function fetchAccountTimelineSuccess(id, statuses, replace, skipLoading) {
+  return {
+    type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
+    id,
+    statuses,
+    replace,
+    skipLoading
+  };
+};
+
+export function fetchAccountTimelineFail(id, error, skipLoading) {
+  return {
+    type: ACCOUNT_TIMELINE_FETCH_FAIL,
+    id,
+    error,
+    skipLoading,
+    skipAlert: error.response.status === 404
+  };
+};
+
+export function expandAccountTimelineRequest(id) {
+  return {
+    type: ACCOUNT_TIMELINE_EXPAND_REQUEST,
+    id
+  };
+};
+
+export function expandAccountTimelineSuccess(id, statuses, next) {
+  return {
+    type: ACCOUNT_TIMELINE_EXPAND_SUCCESS,
+    id,
+    statuses,
+    next
+  };
+};
+
+export function expandAccountTimelineFail(id, error) {
+  return {
+    type: ACCOUNT_TIMELINE_EXPAND_FAIL,
+    id,
+    error
+  };
+};
+
+export function blockAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(blockAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/block`).then(response => {
+      // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+      dispatch(blockAccountSuccess(response.data, getState().get('statuses')));
+    }).catch(error => {
+      dispatch(blockAccountFail(id, error));
+    });
+  };
+};
+
+export function unblockAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unblockAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => {
+      dispatch(unblockAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(unblockAccountFail(id, error));
+    });
+  };
+};
+
+export function blockAccountRequest(id) {
+  return {
+    type: ACCOUNT_BLOCK_REQUEST,
+    id
+  };
+};
+
+export function blockAccountSuccess(relationship, statuses) {
+  return {
+    type: ACCOUNT_BLOCK_SUCCESS,
+    relationship,
+    statuses
+  };
+};
+
+export function blockAccountFail(error) {
+  return {
+    type: ACCOUNT_BLOCK_FAIL,
+    error
+  };
+};
+
+export function unblockAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNBLOCK_REQUEST,
+    id
+  };
+};
+
+export function unblockAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_UNBLOCK_SUCCESS,
+    relationship
+  };
+};
+
+export function unblockAccountFail(error) {
+  return {
+    type: ACCOUNT_UNBLOCK_FAIL,
+    error
+  };
+};
+
+
+export function muteAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(muteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => {
+      // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+      dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
+    }).catch(error => {
+      dispatch(muteAccountFail(id, error));
+    });
+  };
+};
+
+export function unmuteAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unmuteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
+      dispatch(unmuteAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(unmuteAccountFail(id, error));
+    });
+  };
+};
+
+export function muteAccountRequest(id) {
+  return {
+    type: ACCOUNT_MUTE_REQUEST,
+    id
+  };
+};
+
+export function muteAccountSuccess(relationship, statuses) {
+  return {
+    type: ACCOUNT_MUTE_SUCCESS,
+    relationship,
+    statuses
+  };
+};
+
+export function muteAccountFail(error) {
+  return {
+    type: ACCOUNT_MUTE_FAIL,
+    error
+  };
+};
+
+export function unmuteAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNMUTE_REQUEST,
+    id
+  };
+};
+
+export function unmuteAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_UNMUTE_SUCCESS,
+    relationship
+  };
+};
+
+export function unmuteAccountFail(error) {
+  return {
+    type: ACCOUNT_UNMUTE_FAIL,
+    error
+  };
+};
+
+
+export function fetchFollowers(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchFollowersRequest(id));
+
+    api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => {
+      dispatch(fetchFollowersFail(id, error));
+    });
+  };
+};
+
+export function fetchFollowersRequest(id) {
+  return {
+    type: FOLLOWERS_FETCH_REQUEST,
+    id
+  };
+};
+
+export function fetchFollowersSuccess(id, accounts, next) {
+  return {
+    type: FOLLOWERS_FETCH_SUCCESS,
+    id,
+    accounts,
+    next
+  };
+};
+
+export function fetchFollowersFail(id, error) {
+  return {
+    type: FOLLOWERS_FETCH_FAIL,
+    id,
+    error
+  };
+};
+
+export function expandFollowers(id) {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'followers', id, 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFollowersRequest(id));
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => {
+      dispatch(expandFollowersFail(id, error));
+    });
+  };
+};
+
+export function expandFollowersRequest(id) {
+  return {
+    type: FOLLOWERS_EXPAND_REQUEST,
+    id
+  };
+};
+
+export function expandFollowersSuccess(id, accounts, next) {
+  return {
+    type: FOLLOWERS_EXPAND_SUCCESS,
+    id,
+    accounts,
+    next
+  };
+};
+
+export function expandFollowersFail(id, error) {
+  return {
+    type: FOLLOWERS_EXPAND_FAIL,
+    id,
+    error
+  };
+};
+
+export function fetchFollowing(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchFollowingRequest(id));
+
+    api(getState).get(`/api/v1/accounts/${id}/following`).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => {
+      dispatch(fetchFollowingFail(id, error));
+    });
+  };
+};
+
+export function fetchFollowingRequest(id) {
+  return {
+    type: FOLLOWING_FETCH_REQUEST,
+    id
+  };
+};
+
+export function fetchFollowingSuccess(id, accounts, next) {
+  return {
+    type: FOLLOWING_FETCH_SUCCESS,
+    id,
+    accounts,
+    next
+  };
+};
+
+export function fetchFollowingFail(id, error) {
+  return {
+    type: FOLLOWING_FETCH_FAIL,
+    id,
+    error
+  };
+};
+
+export function expandFollowing(id) {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'following', id, 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFollowingRequest(id));
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => {
+      dispatch(expandFollowingFail(id, error));
+    });
+  };
+};
+
+export function expandFollowingRequest(id) {
+  return {
+    type: FOLLOWING_EXPAND_REQUEST,
+    id
+  };
+};
+
+export function expandFollowingSuccess(id, accounts, next) {
+  return {
+    type: FOLLOWING_EXPAND_SUCCESS,
+    id,
+    accounts,
+    next
+  };
+};
+
+export function expandFollowingFail(id, error) {
+  return {
+    type: FOLLOWING_EXPAND_FAIL,
+    id,
+    error
+  };
+};
+
+export function fetchRelationships(accountIds) {
+  return (dispatch, getState) => {
+    const loadedRelationships = getState().get('relationships');
+    const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null);
+
+    if (newAccountIds.length === 0) {
+      return;
+    }
+
+    dispatch(fetchRelationshipsRequest(newAccountIds));
+
+    api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
+      dispatch(fetchRelationshipsSuccess(response.data));
+    }).catch(error => {
+      dispatch(fetchRelationshipsFail(error));
+    });
+  };
+};
+
+export function fetchRelationshipsRequest(ids) {
+  return {
+    type: RELATIONSHIPS_FETCH_REQUEST,
+    ids,
+    skipLoading: true
+  };
+};
+
+export function fetchRelationshipsSuccess(relationships) {
+  return {
+    type: RELATIONSHIPS_FETCH_SUCCESS,
+    relationships,
+    skipLoading: true
+  };
+};
+
+export function fetchRelationshipsFail(error) {
+  return {
+    type: RELATIONSHIPS_FETCH_FAIL,
+    error,
+    skipLoading: true
+  };
+};
+
+export function fetchFollowRequests() {
+  return (dispatch, getState) => {
+    dispatch(fetchFollowRequestsRequest());
+
+    api(getState).get('/api/v1/follow_requests').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null))
+    }).catch(error => dispatch(fetchFollowRequestsFail(error)));
+  };
+};
+
+export function fetchFollowRequestsRequest() {
+  return {
+    type: FOLLOW_REQUESTS_FETCH_REQUEST
+  };
+};
+
+export function fetchFollowRequestsSuccess(accounts, next) {
+  return {
+    type: FOLLOW_REQUESTS_FETCH_SUCCESS,
+    accounts,
+    next
+  };
+};
+
+export function fetchFollowRequestsFail(error) {
+  return {
+    type: FOLLOW_REQUESTS_FETCH_FAIL,
+    error
+  };
+};
+
+export function expandFollowRequests() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'follow_requests', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFollowRequestsRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null))
+    }).catch(error => dispatch(expandFollowRequestsFail(error)));
+  };
+};
+
+export function expandFollowRequestsRequest() {
+  return {
+    type: FOLLOW_REQUESTS_EXPAND_REQUEST
+  };
+};
+
+export function expandFollowRequestsSuccess(accounts, next) {
+  return {
+    type: FOLLOW_REQUESTS_EXPAND_SUCCESS,
+    accounts,
+    next
+  };
+};
+
+export function expandFollowRequestsFail(error) {
+  return {
+    type: FOLLOW_REQUESTS_EXPAND_FAIL,
+    error
+  };
+};
+
+export function authorizeFollowRequest(id) {
+  return (dispatch, getState) => {
+    dispatch(authorizeFollowRequestRequest(id));
+
+    api(getState)
+      .post(`/api/v1/follow_requests/${id}/authorize`)
+      .then(response => dispatch(authorizeFollowRequestSuccess(id)))
+      .catch(error => dispatch(authorizeFollowRequestFail(id, error)));
+  };
+};
+
+export function authorizeFollowRequestRequest(id) {
+  return {
+    type: FOLLOW_REQUEST_AUTHORIZE_REQUEST,
+    id
+  };
+};
+
+export function authorizeFollowRequestSuccess(id) {
+  return {
+    type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+    id
+  };
+};
+
+export function authorizeFollowRequestFail(id, error) {
+  return {
+    type: FOLLOW_REQUEST_AUTHORIZE_FAIL,
+    id,
+    error
+  };
+};
+
+
+export function rejectFollowRequest(id) {
+  return (dispatch, getState) => {
+    dispatch(rejectFollowRequestRequest(id));
+
+    api(getState)
+      .post(`/api/v1/follow_requests/${id}/reject`)
+      .then(response => dispatch(rejectFollowRequestSuccess(id)))
+      .catch(error => dispatch(rejectFollowRequestFail(id, error)));
+  };
+};
+
+export function rejectFollowRequestRequest(id) {
+  return {
+    type: FOLLOW_REQUEST_REJECT_REQUEST,
+    id
+  };
+};
+
+export function rejectFollowRequestSuccess(id) {
+  return {
+    type: FOLLOW_REQUEST_REJECT_SUCCESS,
+    id
+  };
+};
+
+export function rejectFollowRequestFail(id, error) {
+  return {
+    type: FOLLOW_REQUEST_REJECT_FAIL,
+    id,
+    error
+  };
+};
diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js
new file mode 100644
index 000000000..086e0727e
--- /dev/null
+++ b/app/javascript/mastodon/actions/alerts.js
@@ -0,0 +1,24 @@
+export const ALERT_SHOW    = 'ALERT_SHOW';
+export const ALERT_DISMISS = 'ALERT_DISMISS';
+export const ALERT_CLEAR   = 'ALERT_CLEAR';
+
+export function dismissAlert(alert) {
+  return {
+    type: ALERT_DISMISS,
+    alert
+  };
+};
+
+export function clearAlert() {
+  return {
+    type: ALERT_CLEAR
+  };
+};
+
+export function showAlert(title, message) {
+  return {
+    type: ALERT_SHOW,
+    title,
+    message
+  };
+};
diff --git a/app/javascript/mastodon/actions/blocks.js b/app/javascript/mastodon/actions/blocks.js
new file mode 100644
index 000000000..79e316497
--- /dev/null
+++ b/app/javascript/mastodon/actions/blocks.js
@@ -0,0 +1,82 @@
+import api, { getLinks } from '../api'
+import { fetchRelationships } from './accounts';
+
+export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
+export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
+export const BLOCKS_FETCH_FAIL    = 'BLOCKS_FETCH_FAIL';
+
+export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST';
+export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS';
+export const BLOCKS_EXPAND_FAIL    = 'BLOCKS_EXPAND_FAIL';
+
+export function fetchBlocks() {
+  return (dispatch, getState) => {
+    dispatch(fetchBlocksRequest());
+
+    api(getState).get('/api/v1/blocks').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => dispatch(fetchBlocksFail(error)));
+  };
+};
+
+export function fetchBlocksRequest() {
+  return {
+    type: BLOCKS_FETCH_REQUEST
+  };
+};
+
+export function fetchBlocksSuccess(accounts, next) {
+  return {
+    type: BLOCKS_FETCH_SUCCESS,
+    accounts,
+    next
+  };
+};
+
+export function fetchBlocksFail(error) {
+  return {
+    type: BLOCKS_FETCH_FAIL,
+    error
+  };
+};
+
+export function expandBlocks() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'blocks', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandBlocksRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandBlocksSuccess(response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => dispatch(expandBlocksFail(error)));
+  };
+};
+
+export function expandBlocksRequest() {
+  return {
+    type: BLOCKS_EXPAND_REQUEST
+  };
+};
+
+export function expandBlocksSuccess(accounts, next) {
+  return {
+    type: BLOCKS_EXPAND_SUCCESS,
+    accounts,
+    next
+  };
+};
+
+export function expandBlocksFail(error) {
+  return {
+    type: BLOCKS_EXPAND_FAIL,
+    error
+  };
+};
diff --git a/app/javascript/mastodon/actions/cards.js b/app/javascript/mastodon/actions/cards.js
new file mode 100644
index 000000000..805be9709
--- /dev/null
+++ b/app/javascript/mastodon/actions/cards.js
@@ -0,0 +1,52 @@
+import api from '../api';
+
+export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST';
+export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS';
+export const STATUS_CARD_FETCH_FAIL    = 'STATUS_CARD_FETCH_FAIL';
+
+export function fetchStatusCard(id) {
+  return (dispatch, getState) => {
+    if (getState().getIn(['cards', id], null) !== null) {
+      return;
+    }
+
+    dispatch(fetchStatusCardRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/card`).then(response => {
+      if (!response.data.url) {
+        return;
+      }
+
+      dispatch(fetchStatusCardSuccess(id, response.data));
+    }).catch(error => {
+      dispatch(fetchStatusCardFail(id, error));
+    });
+  };
+};
+
+export function fetchStatusCardRequest(id) {
+  return {
+    type: STATUS_CARD_FETCH_REQUEST,
+    id,
+    skipLoading: true
+  };
+};
+
+export function fetchStatusCardSuccess(id, card) {
+  return {
+    type: STATUS_CARD_FETCH_SUCCESS,
+    id,
+    card,
+    skipLoading: true
+  };
+};
+
+export function fetchStatusCardFail(id, error) {
+  return {
+    type: STATUS_CARD_FETCH_FAIL,
+    id,
+    error,
+    skipLoading: true,
+    skipAlert: true
+  };
+};
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
new file mode 100644
index 000000000..d7ff6ea63
--- /dev/null
+++ b/app/javascript/mastodon/actions/compose.js
@@ -0,0 +1,279 @@
+import api from '../api';
+
+import { updateTimeline } from './timelines';
+
+import * as emojione from 'emojione';
+
+export const COMPOSE_CHANGE          = 'COMPOSE_CHANGE';
+export const COMPOSE_SUBMIT_REQUEST  = 'COMPOSE_SUBMIT_REQUEST';
+export const COMPOSE_SUBMIT_SUCCESS  = 'COMPOSE_SUBMIT_SUCCESS';
+export const COMPOSE_SUBMIT_FAIL     = 'COMPOSE_SUBMIT_FAIL';
+export const COMPOSE_REPLY           = 'COMPOSE_REPLY';
+export const COMPOSE_REPLY_CANCEL    = 'COMPOSE_REPLY_CANCEL';
+export const COMPOSE_MENTION         = 'COMPOSE_MENTION';
+export const COMPOSE_UPLOAD_REQUEST  = 'COMPOSE_UPLOAD_REQUEST';
+export const COMPOSE_UPLOAD_SUCCESS  = 'COMPOSE_UPLOAD_SUCCESS';
+export const COMPOSE_UPLOAD_FAIL     = 'COMPOSE_UPLOAD_FAIL';
+export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
+export const COMPOSE_UPLOAD_UNDO     = 'COMPOSE_UPLOAD_UNDO';
+
+export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
+export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
+export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
+
+export const COMPOSE_MOUNT   = 'COMPOSE_MOUNT';
+export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
+
+export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
+export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
+export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
+export const COMPOSE_VISIBILITY_CHANGE  = 'COMPOSE_VISIBILITY_CHANGE';
+export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
+
+export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
+
+export function changeCompose(text) {
+  return {
+    type: COMPOSE_CHANGE,
+    text: text
+  };
+};
+
+export function replyCompose(status, router) {
+  return (dispatch, getState) => {
+    dispatch({
+      type: COMPOSE_REPLY,
+      status: status
+    });
+
+    if (!getState().getIn(['compose', 'mounted'])) {
+      router.push('/statuses/new');
+    }
+  };
+};
+
+export function cancelReplyCompose() {
+  return {
+    type: COMPOSE_REPLY_CANCEL
+  };
+};
+
+export function mentionCompose(account, router) {
+  return (dispatch, getState) => {
+    dispatch({
+      type: COMPOSE_MENTION,
+      account: account
+    });
+
+    if (!getState().getIn(['compose', 'mounted'])) {
+      router.push('/statuses/new');
+    }
+  };
+};
+
+export function submitCompose() {
+  return function (dispatch, getState) {
+    const status = emojione.shortnameToUnicode(getState().getIn(['compose', 'text'], ''));
+    if (!status || !status.length) {
+      return;
+    }
+    dispatch(submitComposeRequest());
+    api(getState).post('/api/v1/statuses', {
+      status,
+      in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
+      media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
+      sensitive: getState().getIn(['compose', 'sensitive']),
+      spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
+      visibility: getState().getIn(['compose', 'privacy'])
+    }, {
+      headers: {
+        'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey'])
+      }
+    }).then(function (response) {
+      dispatch(submitComposeSuccess({ ...response.data }));
+
+      // To make the app more responsive, immediately get the status into the columns
+      dispatch(updateTimeline('home', { ...response.data }));
+
+      if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
+        if (getState().getIn(['timelines', 'community', 'loaded'])) {
+          dispatch(updateTimeline('community', { ...response.data }));
+        }
+
+        if (getState().getIn(['timelines', 'public', 'loaded'])) {
+          dispatch(updateTimeline('public', { ...response.data }));
+        }
+      }
+    }).catch(function (error) {
+      dispatch(submitComposeFail(error));
+    });
+  };
+};
+
+export function submitComposeRequest() {
+  return {
+    type: COMPOSE_SUBMIT_REQUEST
+  };
+};
+
+export function submitComposeSuccess(status) {
+  return {
+    type: COMPOSE_SUBMIT_SUCCESS,
+    status: status
+  };
+};
+
+export function submitComposeFail(error) {
+  return {
+    type: COMPOSE_SUBMIT_FAIL,
+    error: error
+  };
+};
+
+export function uploadCompose(files) {
+  return function (dispatch, getState) {
+    if (getState().getIn(['compose', 'media_attachments']).size > 3) {
+      return;
+    }
+
+    dispatch(uploadComposeRequest());
+
+    let data = new FormData();
+    data.append('file', files[0]);
+
+    api(getState).post('/api/v1/media', data, {
+      onUploadProgress: function (e) {
+        dispatch(uploadComposeProgress(e.loaded, e.total));
+      }
+    }).then(function (response) {
+      dispatch(uploadComposeSuccess(response.data));
+    }).catch(function (error) {
+      dispatch(uploadComposeFail(error));
+    });
+  };
+};
+
+export function uploadComposeRequest() {
+  return {
+    type: COMPOSE_UPLOAD_REQUEST,
+    skipLoading: true
+  };
+};
+
+export function uploadComposeProgress(loaded, total) {
+  return {
+    type: COMPOSE_UPLOAD_PROGRESS,
+    loaded: loaded,
+    total: total
+  };
+};
+
+export function uploadComposeSuccess(media) {
+  return {
+    type: COMPOSE_UPLOAD_SUCCESS,
+    media: media,
+    skipLoading: true
+  };
+};
+
+export function uploadComposeFail(error) {
+  return {
+    type: COMPOSE_UPLOAD_FAIL,
+    error: error,
+    skipLoading: true
+  };
+};
+
+export function undoUploadCompose(media_id) {
+  return {
+    type: COMPOSE_UPLOAD_UNDO,
+    media_id: media_id
+  };
+};
+
+export function clearComposeSuggestions() {
+  return {
+    type: COMPOSE_SUGGESTIONS_CLEAR
+  };
+};
+
+export function fetchComposeSuggestions(token) {
+  return (dispatch, getState) => {
+    api(getState).get('/api/v1/accounts/search', {
+      params: {
+        q: token,
+        resolve: false,
+        limit: 4
+      }
+    }).then(response => {
+      dispatch(readyComposeSuggestions(token, response.data));
+    });
+  };
+};
+
+export function readyComposeSuggestions(token, accounts) {
+  return {
+    type: COMPOSE_SUGGESTIONS_READY,
+    token,
+    accounts
+  };
+};
+
+export function selectComposeSuggestion(position, token, accountId) {
+  return (dispatch, getState) => {
+    const completion = getState().getIn(['accounts', accountId, 'acct']);
+
+    dispatch({
+      type: COMPOSE_SUGGESTION_SELECT,
+      position,
+      token,
+      completion
+    });
+  };
+};
+
+export function mountCompose() {
+  return {
+    type: COMPOSE_MOUNT
+  };
+};
+
+export function unmountCompose() {
+  return {
+    type: COMPOSE_UNMOUNT
+  };
+};
+
+export function changeComposeSensitivity() {
+  return {
+    type: COMPOSE_SENSITIVITY_CHANGE,
+  };
+};
+
+export function changeComposeSpoilerness() {
+  return {
+    type: COMPOSE_SPOILERNESS_CHANGE
+  };
+};
+
+export function changeComposeSpoilerText(text) {
+  return {
+    type: COMPOSE_SPOILER_TEXT_CHANGE,
+    text
+  };
+};
+
+export function changeComposeVisibility(value) {
+  return {
+    type: COMPOSE_VISIBILITY_CHANGE,
+    value
+  };
+};
+
+export function insertEmojiCompose(position, emoji) {
+  return {
+    type: COMPOSE_EMOJI_INSERT,
+    position,
+    emoji
+  };
+};
diff --git a/app/javascript/mastodon/actions/favourites.js b/app/javascript/mastodon/actions/favourites.js
new file mode 100644
index 000000000..a25c1ae1c
--- /dev/null
+++ b/app/javascript/mastodon/actions/favourites.js
@@ -0,0 +1,83 @@
+import api, { getLinks } from '../api'
+
+export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
+export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
+export const FAVOURITED_STATUSES_FETCH_FAIL    = 'FAVOURITED_STATUSES_FETCH_FAIL';
+
+export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST';
+export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS';
+export const FAVOURITED_STATUSES_EXPAND_FAIL    = 'FAVOURITED_STATUSES_EXPAND_FAIL';
+
+export function fetchFavouritedStatuses() {
+  return (dispatch, getState) => {
+    dispatch(fetchFavouritedStatusesRequest());
+
+    api(getState).get('/api/v1/favourites').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(fetchFavouritedStatusesFail(error));
+    });
+  };
+};
+
+export function fetchFavouritedStatusesRequest() {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_REQUEST
+  };
+};
+
+export function fetchFavouritedStatusesSuccess(statuses, next) {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_SUCCESS,
+    statuses,
+    next
+  };
+};
+
+export function fetchFavouritedStatusesFail(error) {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_FAIL,
+    error
+  };
+};
+
+export function expandFavouritedStatuses() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['status_lists', 'favourites', 'next'], null);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFavouritedStatusesRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandFavouritedStatusesFail(error));
+    });
+  };
+};
+
+export function expandFavouritedStatusesRequest() {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_REQUEST
+  };
+};
+
+export function expandFavouritedStatusesSuccess(statuses, next) {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_SUCCESS,
+    statuses,
+    next
+  };
+};
+
+export function expandFavouritedStatusesFail(error) {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_FAIL,
+    error
+  };
+};
diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js
new file mode 100644
index 000000000..45f4508f6
--- /dev/null
+++ b/app/javascript/mastodon/actions/interactions.js
@@ -0,0 +1,235 @@
+import api from '../api'
+
+export const REBLOG_REQUEST = 'REBLOG_REQUEST';
+export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
+export const REBLOG_FAIL    = 'REBLOG_FAIL';
+
+export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
+export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
+export const FAVOURITE_FAIL    = 'FAVOURITE_FAIL';
+
+export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST';
+export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS';
+export const UNREBLOG_FAIL    = 'UNREBLOG_FAIL';
+
+export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
+export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
+export const UNFAVOURITE_FAIL    = 'UNFAVOURITE_FAIL';
+
+export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
+export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
+export const REBLOGS_FETCH_FAIL    = 'REBLOGS_FETCH_FAIL';
+
+export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
+export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
+export const FAVOURITES_FETCH_FAIL    = 'FAVOURITES_FETCH_FAIL';
+
+export function reblog(status) {
+  return function (dispatch, getState) {
+    dispatch(reblogRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) {
+      // The reblog API method returns a new status wrapped around the original. In this case we are only
+      // interested in how the original is modified, hence passing it skipping the wrapper
+      dispatch(reblogSuccess(status, response.data.reblog));
+    }).catch(function (error) {
+      dispatch(reblogFail(status, error));
+    });
+  };
+};
+
+export function unreblog(status) {
+  return (dispatch, getState) => {
+    dispatch(unreblogRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => {
+      dispatch(unreblogSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(unreblogFail(status, error));
+    });
+  };
+};
+
+export function reblogRequest(status) {
+  return {
+    type: REBLOG_REQUEST,
+    status: status
+  };
+};
+
+export function reblogSuccess(status, response) {
+  return {
+    type: REBLOG_SUCCESS,
+    status: status,
+    response: response
+  };
+};
+
+export function reblogFail(status, error) {
+  return {
+    type: REBLOG_FAIL,
+    status: status,
+    error: error
+  };
+};
+
+export function unreblogRequest(status) {
+  return {
+    type: UNREBLOG_REQUEST,
+    status: status
+  };
+};
+
+export function unreblogSuccess(status, response) {
+  return {
+    type: UNREBLOG_SUCCESS,
+    status: status,
+    response: response
+  };
+};
+
+export function unreblogFail(status, error) {
+  return {
+    type: UNREBLOG_FAIL,
+    status: status,
+    error: error
+  };
+};
+
+export function favourite(status) {
+  return function (dispatch, getState) {
+    dispatch(favouriteRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) {
+      dispatch(favouriteSuccess(status, response.data));
+    }).catch(function (error) {
+      dispatch(favouriteFail(status, error));
+    });
+  };
+};
+
+export function unfavourite(status) {
+  return (dispatch, getState) => {
+    dispatch(unfavouriteRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => {
+      dispatch(unfavouriteSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(unfavouriteFail(status, error));
+    });
+  };
+};
+
+export function favouriteRequest(status) {
+  return {
+    type: FAVOURITE_REQUEST,
+    status: status
+  };
+};
+
+export function favouriteSuccess(status, response) {
+  return {
+    type: FAVOURITE_SUCCESS,
+    status: status,
+    response: response
+  };
+};
+
+export function favouriteFail(status, error) {
+  return {
+    type: FAVOURITE_FAIL,
+    status: status,
+    error: error
+  };
+};
+
+export function unfavouriteRequest(status) {
+  return {
+    type: UNFAVOURITE_REQUEST,
+    status: status
+  };
+};
+
+export function unfavouriteSuccess(status, response) {
+  return {
+    type: UNFAVOURITE_SUCCESS,
+    status: status,
+    response: response
+  };
+};
+
+export function unfavouriteFail(status, error) {
+  return {
+    type: UNFAVOURITE_FAIL,
+    status: status,
+    error: error
+  };
+};
+
+export function fetchReblogs(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchReblogsRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
+      dispatch(fetchReblogsSuccess(id, response.data));
+    }).catch(error => {
+      dispatch(fetchReblogsFail(id, error));
+    });
+  };
+};
+
+export function fetchReblogsRequest(id) {
+  return {
+    type: REBLOGS_FETCH_REQUEST,
+    id
+  };
+};
+
+export function fetchReblogsSuccess(id, accounts) {
+  return {
+    type: REBLOGS_FETCH_SUCCESS,
+    id,
+    accounts
+  };
+};
+
+export function fetchReblogsFail(id, error) {
+  return {
+    type: REBLOGS_FETCH_FAIL,
+    error
+  };
+};
+
+export function fetchFavourites(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchFavouritesRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
+      dispatch(fetchFavouritesSuccess(id, response.data));
+    }).catch(error => {
+      dispatch(fetchFavouritesFail(id, error));
+    });
+  };
+};
+
+export function fetchFavouritesRequest(id) {
+  return {
+    type: FAVOURITES_FETCH_REQUEST,
+    id
+  };
+};
+
+export function fetchFavouritesSuccess(id, accounts) {
+  return {
+    type: FAVOURITES_FETCH_SUCCESS,
+    id,
+    accounts
+  };
+};
+
+export function fetchFavouritesFail(id, error) {
+  return {
+    type: FAVOURITES_FETCH_FAIL,
+    error
+  };
+};
diff --git a/app/javascript/mastodon/actions/modal.js b/app/javascript/mastodon/actions/modal.js
new file mode 100644
index 000000000..615cd6bfe
--- /dev/null
+++ b/app/javascript/mastodon/actions/modal.js
@@ -0,0 +1,16 @@
+export const MODAL_OPEN  = 'MODAL_OPEN';
+export const MODAL_CLOSE = 'MODAL_CLOSE';
+
+export function openModal(type, props) {
+  return {
+    type: MODAL_OPEN,
+    modalType: type,
+    modalProps: props
+  };
+};
+
+export function closeModal() {
+  return {
+    type: MODAL_CLOSE
+  };
+};
diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js
new file mode 100644
index 000000000..824821594
--- /dev/null
+++ b/app/javascript/mastodon/actions/mutes.js
@@ -0,0 +1,82 @@
+import api, { getLinks } from '../api'
+import { fetchRelationships } from './accounts';
+
+export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
+export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
+export const MUTES_FETCH_FAIL    = 'MUTES_FETCH_FAIL';
+
+export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST';
+export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS';
+export const MUTES_EXPAND_FAIL    = 'MUTES_EXPAND_FAIL';
+
+export function fetchMutes() {
+  return (dispatch, getState) => {
+    dispatch(fetchMutesRequest());
+
+    api(getState).get('/api/v1/mutes').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchMutesSuccess(response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => dispatch(fetchMutesFail(error)));
+  };
+};
+
+export function fetchMutesRequest() {
+  return {
+    type: MUTES_FETCH_REQUEST
+  };
+};
+
+export function fetchMutesSuccess(accounts, next) {
+  return {
+    type: MUTES_FETCH_SUCCESS,
+    accounts,
+    next
+  };
+};
+
+export function fetchMutesFail(error) {
+  return {
+    type: MUTES_FETCH_FAIL,
+    error
+  };
+};
+
+export function expandMutes() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'mutes', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandMutesRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandMutesSuccess(response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => dispatch(expandMutesFail(error)));
+  };
+};
+
+export function expandMutesRequest() {
+  return {
+    type: MUTES_EXPAND_REQUEST
+  };
+};
+
+export function expandMutesSuccess(accounts, next) {
+  return {
+    type: MUTES_EXPAND_SUCCESS,
+    accounts,
+    next
+  };
+};
+
+export function expandMutesFail(error) {
+  return {
+    type: MUTES_EXPAND_FAIL,
+    error
+  };
+};
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
new file mode 100644
index 000000000..b09ca0854
--- /dev/null
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -0,0 +1,165 @@
+import api, { getLinks } from '../api'
+import Immutable from 'immutable';
+import IntlMessageFormat from 'intl-messageformat';
+
+import { fetchRelationships } from './accounts';
+
+export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
+
+export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
+export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
+export const NOTIFICATIONS_REFRESH_FAIL    = 'NOTIFICATIONS_REFRESH_FAIL';
+
+export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
+export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
+export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL';
+
+export const NOTIFICATIONS_CLEAR      = 'NOTIFICATIONS_CLEAR';
+export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
+
+const fetchRelatedRelationships = (dispatch, notifications) => {
+  const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
+
+  if (accountIds > 0) {
+    dispatch(fetchRelationships(accountIds));
+  }
+};
+
+export function updateNotifications(notification, intlMessages, intlLocale) {
+  return (dispatch, getState) => {
+    const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
+    const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
+
+    dispatch({
+      type: NOTIFICATIONS_UPDATE,
+      notification,
+      account: notification.account,
+      status: notification.status,
+      meta: playSound ? { sound: 'boop' } : undefined
+    });
+
+    fetchRelatedRelationships(dispatch, [notification]);
+
+    // Desktop notifications
+    if (typeof window.Notification !== 'undefined' && showAlert) {
+      const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
+      const body  = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : $('<p>').html(notification.status ? notification.status.content : '').text();
+
+      new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
+    }
+  };
+};
+
+const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
+
+export function refreshNotifications() {
+  return (dispatch, getState) => {
+    dispatch(refreshNotificationsRequest());
+
+    const params = {};
+    const ids    = getState().getIn(['notifications', 'items']);
+
+    if (ids.size > 0) {
+      params.since_id = ids.first().get('id');
+    }
+
+    params.exclude_types = excludeTypesFromSettings(getState());
+
+    api(getState).get('/api/v1/notifications', { params }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(refreshNotificationsSuccess(response.data, next ? next.uri : null));
+      fetchRelatedRelationships(dispatch, response.data);
+    }).catch(error => {
+      dispatch(refreshNotificationsFail(error));
+    });
+  };
+};
+
+export function refreshNotificationsRequest() {
+  return {
+    type: NOTIFICATIONS_REFRESH_REQUEST
+  };
+};
+
+export function refreshNotificationsSuccess(notifications, next) {
+  return {
+    type: NOTIFICATIONS_REFRESH_SUCCESS,
+    notifications,
+    accounts: notifications.map(item => item.account),
+    statuses: notifications.map(item => item.status).filter(status => !!status),
+    next
+  };
+};
+
+export function refreshNotificationsFail(error) {
+  return {
+    type: NOTIFICATIONS_REFRESH_FAIL,
+    error
+  };
+};
+
+export function expandNotifications() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['notifications', 'next'], null);
+
+    if (url === null || getState().getIn(['notifications', 'isLoading'])) {
+      return;
+    }
+
+    dispatch(expandNotificationsRequest());
+
+    const params = {};
+
+    params.exclude_types = excludeTypesFromSettings(getState());
+
+    api(getState).get(url, params).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
+      fetchRelatedRelationships(dispatch, response.data);
+    }).catch(error => {
+      dispatch(expandNotificationsFail(error));
+    });
+  };
+};
+
+export function expandNotificationsRequest() {
+  return {
+    type: NOTIFICATIONS_EXPAND_REQUEST
+  };
+};
+
+export function expandNotificationsSuccess(notifications, next) {
+  return {
+    type: NOTIFICATIONS_EXPAND_SUCCESS,
+    notifications,
+    accounts: notifications.map(item => item.account),
+    statuses: notifications.map(item => item.status).filter(status => !!status),
+    next
+  };
+};
+
+export function expandNotificationsFail(error) {
+  return {
+    type: NOTIFICATIONS_EXPAND_FAIL,
+    error
+  };
+};
+
+export function clearNotifications() {
+  return (dispatch, getState) => {
+    dispatch({
+      type: NOTIFICATIONS_CLEAR
+    });
+
+    api(getState).post('/api/v1/notifications/clear');
+  };
+};
+
+export function scrollTopNotifications(top) {
+  return {
+    type: NOTIFICATIONS_SCROLL_TOP,
+    top
+  };
+};
diff --git a/app/javascript/mastodon/actions/onboarding.js b/app/javascript/mastodon/actions/onboarding.js
new file mode 100644
index 000000000..a161c50ef
--- /dev/null
+++ b/app/javascript/mastodon/actions/onboarding.js
@@ -0,0 +1,14 @@
+import { openModal } from './modal';
+import { changeSetting, saveSettings } from './settings';
+
+export function showOnboardingOnce() {
+  return (dispatch, getState) => {
+    const alreadySeen = getState().getIn(['settings', 'onboarded']);
+
+    if (!alreadySeen) {
+      dispatch(openModal('ONBOARDING'));
+      dispatch(changeSetting(['onboarded'], true));
+      dispatch(saveSettings());
+    }
+  };
+};
diff --git a/app/javascript/mastodon/actions/reports.js b/app/javascript/mastodon/actions/reports.js
new file mode 100644
index 000000000..094670d62
--- /dev/null
+++ b/app/javascript/mastodon/actions/reports.js
@@ -0,0 +1,72 @@
+import api from '../api';
+
+export const REPORT_INIT   = 'REPORT_INIT';
+export const REPORT_CANCEL = 'REPORT_CANCEL';
+
+export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST';
+export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS';
+export const REPORT_SUBMIT_FAIL    = 'REPORT_SUBMIT_FAIL';
+
+export const REPORT_STATUS_TOGGLE  = 'REPORT_STATUS_TOGGLE';
+export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
+
+export function initReport(account, status) {
+  return {
+    type: REPORT_INIT,
+    account,
+    status
+  };
+};
+
+export function cancelReport() {
+  return {
+    type: REPORT_CANCEL
+  };
+};
+
+export function toggleStatusReport(statusId, checked) {
+  return {
+    type: REPORT_STATUS_TOGGLE,
+    statusId,
+    checked,
+  };
+};
+
+export function submitReport() {
+  return (dispatch, getState) => {
+    dispatch(submitReportRequest());
+
+    api(getState).post('/api/v1/reports', {
+      account_id: getState().getIn(['reports', 'new', 'account_id']),
+      status_ids: getState().getIn(['reports', 'new', 'status_ids']),
+      comment: getState().getIn(['reports', 'new', 'comment'])
+    }).then(response => dispatch(submitReportSuccess(response.data))).catch(error => dispatch(submitReportFail(error)));
+  };
+};
+
+export function submitReportRequest() {
+  return {
+    type: REPORT_SUBMIT_REQUEST
+  };
+};
+
+export function submitReportSuccess(report) {
+  return {
+    type: REPORT_SUBMIT_SUCCESS,
+    report
+  };
+};
+
+export function submitReportFail(error) {
+  return {
+    type: REPORT_SUBMIT_FAIL,
+    error
+  };
+};
+
+export function changeReportComment(comment) {
+  return {
+    type: REPORT_COMMENT_CHANGE,
+    comment
+  };
+};
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
new file mode 100644
index 000000000..df3ae0db1
--- /dev/null
+++ b/app/javascript/mastodon/actions/search.js
@@ -0,0 +1,73 @@
+import api from '../api'
+
+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 {
+    type: SEARCH_CHANGE,
+    value
+  };
+};
+
+export function clearSearch() {
+  return {
+    type: SEARCH_CLEAR
+  };
+};
+
+export function submitSearch() {
+  return (dispatch, getState) => {
+    const value = getState().getIn(['search', 'value']);
+
+    if (value.length === 0) {
+      return;
+    }
+
+    dispatch(fetchSearchRequest());
+
+    api(getState).get('/api/v1/search', {
+      params: {
+        q: value,
+        resolve: true
+      }
+    }).then(response => {
+      dispatch(fetchSearchSuccess(response.data));
+    }).catch(error => {
+      dispatch(fetchSearchFail(error));
+    });
+  };
+};
+
+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_SHOW
+  };
+};
diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js
new file mode 100644
index 000000000..c754b30ca
--- /dev/null
+++ b/app/javascript/mastodon/actions/settings.js
@@ -0,0 +1,19 @@
+import axios from 'axios';
+
+export const SETTING_CHANGE = 'SETTING_CHANGE';
+
+export function changeSetting(key, value) {
+  return {
+    type: SETTING_CHANGE,
+    key,
+    value
+  };
+};
+
+export function saveSettings() {
+  return (_, getState) => {
+    axios.put('/api/web/settings', {
+      data: getState().get('settings').toJS()
+    });
+  };
+};
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
new file mode 100644
index 000000000..19df2c36c
--- /dev/null
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -0,0 +1,141 @@
+import api from '../api';
+
+import { deleteFromTimelines } from './timelines';
+import { fetchStatusCard } from './cards';
+
+export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
+export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
+export const STATUS_FETCH_FAIL    = 'STATUS_FETCH_FAIL';
+
+export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
+export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
+export const STATUS_DELETE_FAIL    = 'STATUS_DELETE_FAIL';
+
+export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
+export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
+export const CONTEXT_FETCH_FAIL    = 'CONTEXT_FETCH_FAIL';
+
+export function fetchStatusRequest(id, skipLoading) {
+  return {
+    type: STATUS_FETCH_REQUEST,
+    id,
+    skipLoading
+  };
+};
+
+export function fetchStatus(id) {
+  return (dispatch, getState) => {
+    const skipLoading = getState().getIn(['statuses', id], null) !== null;
+
+    dispatch(fetchContext(id));
+    dispatch(fetchStatusCard(id));
+
+    if (skipLoading) {
+      return;
+    }
+
+    dispatch(fetchStatusRequest(id, skipLoading));
+
+    api(getState).get(`/api/v1/statuses/${id}`).then(response => {
+      dispatch(fetchStatusSuccess(response.data, skipLoading));
+    }).catch(error => {
+      dispatch(fetchStatusFail(id, error, skipLoading));
+    });
+  };
+};
+
+export function fetchStatusSuccess(status, skipLoading) {
+  return {
+    type: STATUS_FETCH_SUCCESS,
+    status,
+    skipLoading
+  };
+};
+
+export function fetchStatusFail(id, error, skipLoading) {
+  return {
+    type: STATUS_FETCH_FAIL,
+    id,
+    error,
+    skipLoading,
+    skipAlert: true
+  };
+};
+
+export function deleteStatus(id) {
+  return (dispatch, getState) => {
+    dispatch(deleteStatusRequest(id));
+
+    api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
+      dispatch(deleteStatusSuccess(id));
+      dispatch(deleteFromTimelines(id));
+    }).catch(error => {
+      dispatch(deleteStatusFail(id, error));
+    });
+  };
+};
+
+export function deleteStatusRequest(id) {
+  return {
+    type: STATUS_DELETE_REQUEST,
+    id: id
+  };
+};
+
+export function deleteStatusSuccess(id) {
+  return {
+    type: STATUS_DELETE_SUCCESS,
+    id: id
+  };
+};
+
+export function deleteStatusFail(id, error) {
+  return {
+    type: STATUS_DELETE_FAIL,
+    id: id,
+    error: error
+  };
+};
+
+export function fetchContext(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchContextRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
+      dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
+
+    }).catch(error => {
+      if (error.response.status === 404) {
+        dispatch(deleteFromTimelines(id));
+      }
+
+      dispatch(fetchContextFail(id, error));
+    });
+  };
+};
+
+export function fetchContextRequest(id) {
+  return {
+    type: CONTEXT_FETCH_REQUEST,
+    id
+  };
+};
+
+export function fetchContextSuccess(id, ancestors, descendants) {
+  return {
+    type: CONTEXT_FETCH_SUCCESS,
+    id,
+    ancestors,
+    descendants,
+    statuses: ancestors.concat(descendants)
+  };
+};
+
+export function fetchContextFail(id, error) {
+  return {
+    type: CONTEXT_FETCH_FAIL,
+    id,
+    error,
+    skipAlert: true
+  };
+};
diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js
new file mode 100644
index 000000000..3bba99549
--- /dev/null
+++ b/app/javascript/mastodon/actions/store.js
@@ -0,0 +1,17 @@
+import Immutable from 'immutable';
+
+export const STORE_HYDRATE = 'STORE_HYDRATE';
+
+const convertState = rawState =>
+  Immutable.fromJS(rawState, (k, v) =>
+    Immutable.Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x =>
+      Number.isNaN(x * 1) ? x : x * 1));
+
+export function hydrateStore(rawState) {
+  const state = convertState(rawState);
+
+  return {
+    type: STORE_HYDRATE,
+    state
+  };
+};
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
new file mode 100644
index 000000000..6cd1f04b3
--- /dev/null
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -0,0 +1,186 @@
+import api, { getLinks } from '../api'
+import Immutable from 'immutable';
+
+export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE';
+export const TIMELINE_DELETE  = 'TIMELINE_DELETE';
+
+export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST';
+export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS';
+export const TIMELINE_REFRESH_FAIL    = 'TIMELINE_REFRESH_FAIL';
+
+export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
+export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
+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,
+    timeline,
+    statuses,
+    skipLoading,
+    next
+  };
+};
+
+export function updateTimeline(timeline, status) {
+  return (dispatch, getState) => {
+    const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
+
+    dispatch({
+      type: TIMELINE_UPDATE,
+      timeline,
+      status,
+      references
+    });
+  };
+};
+
+export function deleteFromTimelines(id) {
+  return (dispatch, getState) => {
+    const accountId  = getState().getIn(['statuses', id, 'account']);
+    const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
+    const reblogOf   = getState().getIn(['statuses', id, 'reblog'], null);
+
+    dispatch({
+      type: TIMELINE_DELETE,
+      id,
+      accountId,
+      references,
+      reblogOf
+    });
+  };
+};
+
+export function refreshTimelineRequest(timeline, id, skipLoading) {
+  return {
+    type: TIMELINE_REFRESH_REQUEST,
+    timeline,
+    id,
+    skipLoading
+  };
+};
+
+export function refreshTimeline(timeline, id = null) {
+  return function (dispatch, getState) {
+    if (getState().getIn(['timelines', timeline, 'isLoading'])) {
+      return;
+    }
+
+    const ids      = getState().getIn(['timelines', timeline, 'items'], Immutable.List());
+    const newestId = ids.size > 0 ? ids.first() : null;
+    let params     = getState().getIn(['timelines', timeline, 'params'], {});
+    const path     = getState().getIn(['timelines', timeline, 'path'])(id);
+
+    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;
+    }
+
+    dispatch(refreshTimelineRequest(timeline, id, skipLoading));
+
+    api(getState).get(path, { params }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(refreshTimelineFail(timeline, error, skipLoading));
+    });
+  };
+};
+
+export function refreshTimelineFail(timeline, error, skipLoading) {
+  return {
+    type: TIMELINE_REFRESH_FAIL,
+    timeline,
+    error,
+    skipLoading
+  };
+};
+
+export function expandTimeline(timeline) {
+  return (dispatch, getState) => {
+    if (getState().getIn(['timelines', timeline, 'isLoading'])) {
+      return;
+    }
+
+    if (getState().getIn(['timelines', timeline, 'items']).size === 0) {
+      return;
+    }
+
+    const path   = getState().getIn(['timelines', timeline, 'path'])(getState().getIn(['timelines', timeline, 'id']));
+    const params = getState().getIn(['timelines', timeline, 'params'], {});
+    const lastId = getState().getIn(['timelines', timeline, 'items']).last();
+
+    dispatch(expandTimelineRequest(timeline));
+
+    api(getState).get(path, {
+      params: {
+        ...params,
+        max_id: lastId,
+        limit: 10
+      }
+    }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandTimelineSuccess(timeline, response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandTimelineFail(timeline, error));
+    });
+  };
+};
+
+export function expandTimelineRequest(timeline) {
+  return {
+    type: TIMELINE_EXPAND_REQUEST,
+    timeline
+  };
+};
+
+export function expandTimelineSuccess(timeline, statuses, next) {
+  return {
+    type: TIMELINE_EXPAND_SUCCESS,
+    timeline,
+    statuses,
+    next
+  };
+};
+
+export function expandTimelineFail(timeline, error) {
+  return {
+    type: TIMELINE_EXPAND_FAIL,
+    timeline,
+    error
+  };
+};
+
+export function scrollTopTimeline(timeline, top) {
+  return {
+    type: TIMELINE_SCROLL_TOP,
+    timeline,
+    top
+  };
+};
+
+export function connectTimeline(timeline) {
+  return {
+    type: TIMELINE_CONNECT,
+    timeline
+  };
+};
+
+export function disconnectTimeline(timeline) {
+  return {
+    type: TIMELINE_DISCONNECT,
+    timeline
+  };
+};
diff --git a/app/javascript/mastodon/api.js b/app/javascript/mastodon/api.js
new file mode 100644
index 000000000..185729ce0
--- /dev/null
+++ b/app/javascript/mastodon/api.js
@@ -0,0 +1,26 @@
+import axios from 'axios';
+import LinkHeader from './link_header';
+
+export const getLinks = response => {
+  const value = response.headers.link;
+
+  if (!value) {
+    return { refs: [] };
+  }
+
+  return LinkHeader.parse(value);
+};
+
+export default getState => axios.create({
+  headers: {
+    'Authorization': `Bearer ${getState().getIn(['meta', 'access_token'], '')}`
+  },
+
+  transformResponse: [function (data) {
+    try {
+      return JSON.parse(data);
+    } catch(Exception) {
+      return data;
+    }
+  }]
+});
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
new file mode 100644
index 000000000..9016bedb6
--- /dev/null
+++ b/app/javascript/mastodon/components/account.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from './avatar';
+import DisplayName from './display_name';
+import Permalink from './permalink';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }
+});
+
+class Account extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleFollow = this.handleFollow.bind(this);
+    this.handleBlock = this.handleBlock.bind(this);
+    this.handleMute = this.handleMute.bind(this);
+  }
+
+  handleFollow () {
+    this.props.onFollow(this.props.account);
+  }
+
+  handleBlock () {
+    this.props.onBlock(this.props.account);
+  }
+
+  handleMute () {
+    this.props.onMute(this.props.account);
+  }
+
+  render () {
+    const { account, me, intl } = this.props;
+
+    if (!account) {
+      return <div />;
+    }
+
+    let buttons;
+
+    if (account.get('id') !== me && account.get('relationship', null) !== null) {
+      const following = account.getIn(['relationship', 'following']);
+      const requested = account.getIn(['relationship', 'requested']);
+      const blocking  = account.getIn(['relationship', 'blocking']);
+      const muting  = account.getIn(['relationship', 'muting']);
+
+      if (requested) {
+        buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
+      } else if (blocking) {
+        buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
+      } else if (muting) {
+        buttons = <IconButton active={true} icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
+      } else {
+        buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+      }
+    }
+
+    return (
+      <div className='account'>
+        <div className='account__wrapper'>
+          <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+            <div className='account__avatar-wrapper'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={36} /></div>
+            <DisplayName account={account} />
+          </Permalink>
+
+          <div className='account__relationship'>
+            {buttons}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+Account.propTypes = {
+  account: ImmutablePropTypes.map.isRequired,
+  me: PropTypes.number.isRequired,
+  onFollow: PropTypes.func.isRequired,
+  onBlock: PropTypes.func.isRequired,
+  onMute: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+}
+
+export default injectIntl(Account);
diff --git a/app/javascript/mastodon/components/attachment_list.js b/app/javascript/mastodon/components/attachment_list.js
new file mode 100644
index 000000000..6df578b77
--- /dev/null
+++ b/app/javascript/mastodon/components/attachment_list.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
+
+class AttachmentList extends React.PureComponent {
+
+  render () {
+    const { media } = this.props;
+
+    return (
+      <div className='attachment-list'>
+        <div className='attachment-list__icon'>
+          <i className='fa fa-link' />
+        </div>
+
+        <ul className='attachment-list__list'>
+          {media.map(attachment =>
+            <li key={attachment.get('id')}>
+              <a href={attachment.get('remote_url')} target='_blank' rel='noopener'>{filename(attachment.get('remote_url'))}</a>
+            </li>
+          )}
+        </ul>
+      </div>
+    );
+  }
+}
+
+AttachmentList.propTypes = {
+  media: ImmutablePropTypes.list.isRequired
+};
+
+export default AttachmentList;
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js
new file mode 100644
index 000000000..6d8d3b2a3
--- /dev/null
+++ b/app/javascript/mastodon/components/autosuggest_textarea.js
@@ -0,0 +1,213 @@
+import React from 'react';
+import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { isRtl } from '../rtl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const textAtCursorMatchesToken = (str, caretPosition) => {
+  let word;
+
+  let left  = str.slice(0, caretPosition).search(/\S+$/);
+  let right = str.slice(caretPosition).search(/\s/);
+
+  if (right < 0) {
+    word = str.slice(left);
+  } else {
+    word = str.slice(left, right + caretPosition);
+  }
+
+  if (!word || word.trim().length < 2 || word[0] !== '@') {
+    return [null, null];
+  }
+
+  word = word.trim().toLowerCase().slice(1);
+
+  if (word.length > 0) {
+    return [left + 1, word];
+  } else {
+    return [null, null];
+  }
+};
+
+class AutosuggestTextarea extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      suggestionsHidden: false,
+      selectedSuggestion: 0,
+      lastToken: null,
+      tokenStart: 0
+    };
+    this.onChange = this.onChange.bind(this);
+    this.onKeyDown = this.onKeyDown.bind(this);
+    this.onBlur = this.onBlur.bind(this);
+    this.onSuggestionClick = this.onSuggestionClick.bind(this);
+    this.setTextarea = this.setTextarea.bind(this);
+    this.onPaste = this.onPaste.bind(this);
+  }
+
+  onChange (e) {
+    const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
+
+    if (token !== null && this.state.lastToken !== token) {
+      this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
+      this.props.onSuggestionsFetchRequested(token);
+    } else if (token === null) {
+      this.setState({ lastToken: null });
+      this.props.onSuggestionsClearRequested();
+    }
+
+    // auto-resize textarea
+    e.target.style.height = `${e.target.scrollHeight}px`;
+
+    this.props.onChange(e);
+  }
+
+  onKeyDown (e) {
+    const { suggestions, disabled } = this.props;
+    const { selectedSuggestion, suggestionsHidden } = this.state;
+
+    if (disabled) {
+      e.preventDefault();
+      return;
+    }
+
+    switch(e.key) {
+    case 'Escape':
+      if (!suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ suggestionsHidden: true });
+      }
+
+      break;
+    case 'ArrowDown':
+      if (suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+      }
+
+      break;
+    case 'ArrowUp':
+      if (suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+      }
+
+      break;
+    case 'Enter':
+    case 'Tab':
+      // Select suggestion
+      if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
+      }
+
+      break;
+    }
+
+    if (e.defaultPrevented || !this.props.onKeyDown) {
+      return;
+    }
+
+    this.props.onKeyDown(e);
+  }
+
+  onBlur () {
+    // If we hide the suggestions immediately, then this will prevent the
+    // onClick for the suggestions themselves from firing.
+    // Setting a short window for that to take place before hiding the
+    // suggestions ensures that can't happen.
+    setTimeout(() => {
+      this.setState({ suggestionsHidden: true });
+    }, 100);
+  }
+
+  onSuggestionClick (suggestion, e) {
+    e.preventDefault();
+    this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
+    this.textarea.focus();
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
+      this.setState({ suggestionsHidden: false });
+    }
+  }
+
+  setTextarea (c) {
+    this.textarea = c;
+  }
+
+  onPaste (e) {
+    if (e.clipboardData && e.clipboardData.files.length === 1) {
+      this.props.onPaste(e.clipboardData.files)
+      e.preventDefault();
+    }
+  }
+
+  reset () {
+    this.textarea.style.height = 'auto';
+  }
+
+  render () {
+    const { value, suggestions, disabled, placeholder, onKeyUp } = this.props;
+    const { suggestionsHidden, selectedSuggestion } = this.state;
+    const style = { direction: 'ltr' };
+
+    if (isRtl(value)) {
+      style.direction = 'rtl';
+    }
+
+    return (
+      <div className='autosuggest-textarea'>
+        <textarea
+          ref={this.setTextarea}
+          className='autosuggest-textarea__textarea'
+          disabled={disabled}
+          placeholder={placeholder}
+          autoFocus={true}
+          value={value}
+          onChange={this.onChange}
+          onKeyDown={this.onKeyDown}
+          onKeyUp={onKeyUp}
+          onBlur={this.onBlur}
+          onPaste={this.onPaste}
+          style={style}
+        />
+
+        <div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
+          {suggestions.map((suggestion, i) => (
+            <div
+              role='button'
+              tabIndex='0'
+              key={suggestion}
+              className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`}
+              onClick={this.onSuggestionClick.bind(this, suggestion)}>
+              <AutosuggestAccountContainer id={suggestion} />
+            </div>
+          ))}
+        </div>
+      </div>
+    );
+  }
+
+};
+
+AutosuggestTextarea.propTypes = {
+  value: PropTypes.string,
+  suggestions: ImmutablePropTypes.list,
+  disabled: PropTypes.bool,
+  placeholder: PropTypes.string,
+  onSuggestionSelected: PropTypes.func.isRequired,
+  onSuggestionsClearRequested: PropTypes.func.isRequired,
+  onSuggestionsFetchRequested: PropTypes.func.isRequired,
+  onChange: PropTypes.func.isRequired,
+  onKeyUp: PropTypes.func,
+  onKeyDown: PropTypes.func,
+  onPaste: PropTypes.func.isRequired,
+};
+
+export default AutosuggestTextarea;
diff --git a/app/javascript/mastodon/components/avatar.js b/app/javascript/mastodon/components/avatar.js
new file mode 100644
index 000000000..47f2715c7
--- /dev/null
+++ b/app/javascript/mastodon/components/avatar.js
@@ -0,0 +1,68 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class Avatar extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+
+    this.state = {
+      hovering: false
+    };
+
+    this.handleMouseEnter = this.handleMouseEnter.bind(this);
+    this.handleMouseLeave = this.handleMouseLeave.bind(this);
+  }
+
+  handleMouseEnter () {
+    if (this.props.animate) return;
+    this.setState({ hovering: true });
+  }
+
+  handleMouseLeave () {
+    if (this.props.animate) return;
+    this.setState({ hovering: false });
+  }
+
+  render () {
+    const { src, size, staticSrc, animate } = this.props;
+    const { hovering } = this.state;
+
+    const style = {
+      ...this.props.style,
+      width: `${size}px`,
+      height: `${size}px`,
+      backgroundSize: `${size}px ${size}px`
+    };
+
+    if (hovering || animate) {
+      style.backgroundImage = `url(${src})`;
+    } else {
+      style.backgroundImage = `url(${staticSrc})`;
+    }
+
+    return (
+      <div
+        className='account__avatar'
+        onMouseEnter={this.handleMouseEnter}
+        onMouseLeave={this.handleMouseLeave}
+        style={style}
+      />
+    );
+  }
+
+}
+
+Avatar.propTypes = {
+  src: PropTypes.string.isRequired,
+  staticSrc: PropTypes.string,
+  size: PropTypes.number.isRequired,
+  style: PropTypes.object,
+  animate: PropTypes.bool
+};
+
+Avatar.defaultProps = {
+  animate: false
+};
+
+export default Avatar;
diff --git a/app/javascript/mastodon/components/button.js b/app/javascript/mastodon/components/button.js
new file mode 100644
index 000000000..1063e0289
--- /dev/null
+++ b/app/javascript/mastodon/components/button.js
@@ -0,0 +1,50 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class Button extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick (e) {
+    if (!this.props.disabled) {
+      this.props.onClick();
+    }
+  }
+
+  render () {
+    const style = {
+      display: this.props.block ? 'block' : 'inline-block',
+      width: this.props.block ? '100%' : 'auto',
+      padding: `0 ${this.props.size / 2.25}px`,
+      height: `${this.props.size}px`,
+      lineHeight: `${this.props.size}px`
+    };
+
+    return (
+      <button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={{ ...style, ...this.props.style }}>
+        {this.props.text || this.props.children}
+      </button>
+    );
+  }
+
+}
+
+Button.propTypes = {
+  text: PropTypes.node,
+  onClick: PropTypes.func,
+  disabled: PropTypes.bool,
+  block: PropTypes.bool,
+  secondary: PropTypes.bool,
+  size: PropTypes.number,
+  style: PropTypes.object,
+  children: PropTypes.node
+};
+
+Button.defaultProps = {
+  size: 36
+};
+
+export default Button;
diff --git a/app/javascript/mastodon/components/collapsable.js b/app/javascript/mastodon/components/collapsable.js
new file mode 100644
index 000000000..a61f67d8e
--- /dev/null
+++ b/app/javascript/mastodon/components/collapsable.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import { Motion, spring } from 'react-motion';
+import PropTypes from 'prop-types';
+
+const Collapsable = ({ fullHeight, isVisible, children }) => (
+  <Motion defaultStyle={{ opacity: !isVisible ? 0 : 100, height: isVisible ? fullHeight : 0 }} style={{ opacity: spring(!isVisible ? 0 : 100), height: spring(!isVisible ? 0 : fullHeight) }}>
+    {({ opacity, height }) =>
+      <div style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100, display: Math.floor(opacity) === 0 ? 'none' : 'block' }}>
+        {children}
+      </div>
+    }
+  </Motion>
+);
+
+Collapsable.propTypes = {
+  fullHeight: PropTypes.number.isRequired,
+  isVisible: PropTypes.bool.isRequired,
+  children: PropTypes.node.isRequired
+};
+
+export default Collapsable;
diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js
new file mode 100644
index 000000000..bedc417fd
--- /dev/null
+++ b/app/javascript/mastodon/components/column_back_button.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+class ColumnBackButton extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick () {
+    if (window.history && window.history.length === 1) this.context.router.push("/");
+    else this.context.router.goBack();
+  }
+
+  render () {
+    return (
+      <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button'>
+        <i className='fa fa-fw fa-chevron-left column-back-button__icon'/>
+        <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
+      </div>
+    );
+  }
+
+};
+
+ColumnBackButton.contextTypes = {
+  router: PropTypes.object
+};
+
+export default ColumnBackButton;
diff --git a/app/javascript/mastodon/components/column_back_button_slim.js b/app/javascript/mastodon/components/column_back_button_slim.js
new file mode 100644
index 000000000..9aa7e92c2
--- /dev/null
+++ b/app/javascript/mastodon/components/column_back_button_slim.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+class ColumnBackButtonSlim extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick () {
+    this.context.router.push('/');
+  }
+
+  render () {
+    return (
+      <div className='column-back-button--slim'>
+        <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'>
+          <i className='fa fa-fw fa-chevron-left column-back-button__icon' />
+          <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
+        </div>
+      </div>
+    );
+  }
+}
+
+ColumnBackButtonSlim.contextTypes = {
+  router: PropTypes.object
+};
+
+export default ColumnBackButtonSlim;
diff --git a/app/javascript/mastodon/components/column_collapsable.js b/app/javascript/mastodon/components/column_collapsable.js
new file mode 100644
index 000000000..797946859
--- /dev/null
+++ b/app/javascript/mastodon/components/column_collapsable.js
@@ -0,0 +1,57 @@
+import React from 'react';
+import { Motion, spring } from 'react-motion';
+import PropTypes from 'prop-types';
+
+class ColumnCollapsable extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      collapsed: true
+    };
+
+    this.handleToggleCollapsed = this.handleToggleCollapsed.bind(this);
+  }
+
+  handleToggleCollapsed () {
+    const currentState = this.state.collapsed;
+
+    this.setState({ collapsed: !currentState });
+
+    if (!currentState && this.props.onCollapse) {
+      this.props.onCollapse();
+    }
+  }
+
+  render () {
+    const { icon, title, fullHeight, children } = this.props;
+    const { collapsed } = this.state;
+    const collapsedClassName = collapsed ? 'collapsable-collapsed' : 'collapsable';
+
+    return (
+      <div className='column-collapsable'>
+        <div role='button' tabIndex='0' title={`${title}`} className={`column-icon ${collapsedClassName}`} onClick={this.handleToggleCollapsed}>
+          <i className={`fa fa-${icon}`} />
+        </div>
+
+        <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}>
+          {({ opacity, height }) =>
+            <div style={{ overflow: height === fullHeight ? 'auto' : 'hidden', height: `${height}px`, opacity: opacity / 100, maxHeight: '70vh' }}>
+              {children}
+            </div>
+          }
+        </Motion>
+      </div>
+    );
+  }
+}
+
+ColumnCollapsable.propTypes = {
+  icon: PropTypes.string.isRequired,
+  title: PropTypes.string,
+  fullHeight: PropTypes.number.isRequired,
+  children: PropTypes.node,
+  onCollapse: PropTypes.func
+};
+
+export default ColumnCollapsable;
diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js
new file mode 100644
index 000000000..6bdd06db7
--- /dev/null
+++ b/app/javascript/mastodon/components/display_name.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import escapeTextContentForBrowser from 'escape-html';
+import emojify from '../emoji';
+
+class DisplayName extends React.PureComponent {
+
+  render () {
+    const displayName     = this.props.account.get('display_name').length === 0 ? this.props.account.get('username') : this.props.account.get('display_name');
+    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+
+    return (
+      <span className='display-name'>
+        <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHTML} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
+      </span>
+    );
+  }
+
+};
+
+DisplayName.propTypes = {
+  account: ImmutablePropTypes.map.isRequired
+}
+
+export default DisplayName;
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
new file mode 100644
index 000000000..aed0757b1
--- /dev/null
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -0,0 +1,79 @@
+import React from 'react';
+import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
+import PropTypes from 'prop-types';
+
+class DropdownMenu extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      direction: 'left'
+    };
+    this.setRef = this.setRef.bind(this);
+    this.renderItem = this.renderItem.bind(this);
+  }
+
+  setRef (c) {
+    this.dropdown = c;
+  }
+
+  handleClick (i, e) {
+    const { action } = this.props.items[i];
+
+    if (typeof action === 'function') {
+      e.preventDefault();
+      action();
+      this.dropdown.hide();
+    }
+  }
+
+  renderItem (item, i) {
+    if (item === null) {
+      return <li key={ 'sep' + i } className='dropdown__sep' />;
+    }
+
+    const { text, action, href = '#' } = item;
+
+    return (
+      <li className='dropdown__content-list-item' key={ text + i }>
+        <a href={href} target='_blank' rel='noopener' onClick={this.handleClick.bind(this, i)} className='dropdown__content-list-link'>
+          {text}
+        </a>
+      </li>
+    );
+  }
+
+  render () {
+    const { icon, items, size, direction, ariaLabel } = this.props;
+    const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right";
+
+    return (
+      <Dropdown ref={this.setRef}>
+        <DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }} aria-label={ariaLabel}>
+          <i className={ `fa fa-fw fa-${icon} dropdown__icon` }  aria-hidden={true} />
+        </DropdownTrigger>
+
+        <DropdownContent className={directionClass}>
+          <ul className='dropdown__content-list'>
+            {items.map(this.renderItem)}
+          </ul>
+        </DropdownContent>
+      </Dropdown>
+    );
+  }
+
+}
+
+DropdownMenu.propTypes = {
+  icon: PropTypes.string.isRequired,
+  items: PropTypes.array.isRequired,
+  size: PropTypes.number.isRequired,
+  direction: PropTypes.string,
+  ariaLabel: PropTypes.string
+};
+
+DropdownMenu.defaultProps = {
+  ariaLabel: "Menu"
+};
+
+export default DropdownMenu;
diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js
new file mode 100644
index 000000000..34ede66fd
--- /dev/null
+++ b/app/javascript/mastodon/components/extended_video_player.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class ExtendedVideoPlayer extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleLoadedData = this.handleLoadedData.bind(this);
+    this.setRef = this.setRef.bind(this);
+  }
+
+  handleLoadedData () {
+    if (this.props.time) {
+      this.video.currentTime = this.props.time;
+    }
+  }
+
+  componentDidMount () {
+    this.video.addEventListener('loadeddata', this.handleLoadedData);
+  }
+
+  componentWillUnmount () {
+    this.video.removeEventListener('loadeddata', this.handleLoadedData);
+  }
+
+  setRef (c) {
+    this.video = c;
+  }
+
+  render () {
+    return (
+      <div className='extended-video-player'>
+        <video
+          ref={this.setRef}
+          src={this.props.src}
+          autoPlay
+          muted={this.props.muted}
+          controls={this.props.controls}
+          loop={!this.props.controls}
+        />
+      </div>
+    );
+  }
+
+}
+
+ExtendedVideoPlayer.propTypes = {
+  src: PropTypes.string.isRequired,
+  time: PropTypes.number,
+  controls: PropTypes.bool.isRequired,
+  muted: PropTypes.bool.isRequired
+};
+
+export default ExtendedVideoPlayer;
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
new file mode 100644
index 000000000..87324b6c8
--- /dev/null
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import { Motion, spring } from 'react-motion';
+import PropTypes from 'prop-types';
+
+class IconButton extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick (e) {
+    e.preventDefault();
+
+    if (!this.props.disabled) {
+      this.props.onClick(e);
+    }
+  }
+
+  render () {
+    let style = {
+      fontSize: `${this.props.size}px`,
+      width: `${this.props.size * 1.28571429}px`,
+      height: `${this.props.size * 1.28571429}px`,
+      lineHeight: `${this.props.size}px`,
+      ...this.props.style
+    };
+
+    if (this.props.active) {
+      style = { ...style, ...this.props.activeStyle };
+    }
+
+    const classes = ['icon-button'];
+
+    if (this.props.active) {
+      classes.push('active');
+    }
+
+    if (this.props.disabled) {
+      classes.push('disabled');
+    }
+
+    if (this.props.inverted) {
+      classes.push('inverted');
+    }
+
+    if (this.props.overlay) {
+      classes.push('overlayed');
+    }
+
+    if (this.props.className) {
+      classes.push(this.props.className)
+    }
+
+    return (
+      <Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
+        {({ rotate }) =>
+          <button
+            aria-label={this.props.title}
+            title={this.props.title}
+            className={classes.join(' ')}
+            onClick={this.handleClick}
+            style={style}>
+            <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
+          </button>
+        }
+      </Motion>
+    );
+  }
+
+}
+
+IconButton.propTypes = {
+  className: PropTypes.string,
+  title: PropTypes.string.isRequired,
+  icon: PropTypes.string.isRequired,
+  onClick: PropTypes.func,
+  size: PropTypes.number,
+  active: PropTypes.bool,
+  style: PropTypes.object,
+  activeStyle: PropTypes.object,
+  disabled: PropTypes.bool,
+  inverted: PropTypes.bool,
+  animate: PropTypes.bool,
+  overlay: PropTypes.bool
+};
+
+IconButton.defaultProps = {
+  size: 18,
+  active: false,
+  disabled: false,
+  animate: false,
+  overlay: false
+};
+
+export default IconButton;
diff --git a/app/javascript/mastodon/components/load_more.js b/app/javascript/mastodon/components/load_more.js
new file mode 100644
index 000000000..36dae79af
--- /dev/null
+++ b/app/javascript/mastodon/components/load_more.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+const LoadMore = ({ onClick }) => (
+  <a href="#" className='load-more' role='button' onClick={onClick}>
+    <FormattedMessage id='status.load_more' defaultMessage='Load more' />
+  </a>
+);
+
+LoadMore.propTypes = {
+  onClick: PropTypes.func
+};
+
+export default LoadMore;
diff --git a/app/javascript/mastodon/components/loading_indicator.js b/app/javascript/mastodon/components/loading_indicator.js
new file mode 100644
index 000000000..c09244834
--- /dev/null
+++ b/app/javascript/mastodon/components/loading_indicator.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const LoadingIndicator = () => (
+  <div className='loading-indicator'>
+    <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
+  </div>
+);
+
+export default LoadingIndicator;
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
new file mode 100644
index 000000000..dc08c457d
--- /dev/null
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -0,0 +1,196 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { isIOS } from '../is_mobile';
+
+const messages = defineMessages({
+  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
+});
+
+class Item extends React.PureComponent {
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick (e) {
+    const { index, onClick } = this.props;
+
+    if (e.button === 0) {
+      e.preventDefault();
+      onClick(index);
+    }
+
+    e.stopPropagation();
+  }
+
+  render () {
+    const { attachment, index, size } = this.props;
+
+    let width  = 50;
+    let height = 100;
+    let top    = 'auto';
+    let left   = 'auto';
+    let bottom = 'auto';
+    let right  = 'auto';
+
+    if (size === 1) {
+      width = 100;
+    }
+
+    if (size === 4 || (size === 3 && index > 0)) {
+      height = 50;
+    }
+
+    if (size === 2) {
+      if (index === 0) {
+        right = '2px';
+      } else {
+        left = '2px';
+      }
+    } else if (size === 3) {
+      if (index === 0) {
+        right = '2px';
+      } else if (index > 0) {
+        left = '2px';
+      }
+
+      if (index === 1) {
+        bottom = '2px';
+      } else if (index > 1) {
+        top = '2px';
+      }
+    } else if (size === 4) {
+      if (index === 0 || index === 2) {
+        right = '2px';
+      }
+
+      if (index === 1 || index === 3) {
+        left = '2px';
+      }
+
+      if (index < 2) {
+        bottom = '2px';
+      } else {
+        top = '2px';
+      }
+    }
+
+    let thumbnail = '';
+
+    if (attachment.get('type') === 'image') {
+      thumbnail = (
+        <a
+          className='media-gallery__item-thumbnail'
+          href={attachment.get('remote_url') || attachment.get('url')}
+          onClick={this.handleClick}
+          target='_blank'
+          style={{ backgroundImage: `url(${attachment.get('preview_url')})` }}
+        />
+      );
+    } else if (attachment.get('type') === 'gifv') {
+      const autoPlay = !isIOS() && this.props.autoPlayGif;
+
+      thumbnail = (
+        <div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}>
+          <video
+            className='media-gallery__item-gifv-thumbnail'
+            role='application'
+            src={attachment.get('url')}
+            onClick={this.handleClick}
+            autoPlay={autoPlay}
+            loop={true}
+            muted={true}
+          />
+
+          <span className='media-gallery__gifv__label'>GIF</span>
+        </div>
+      );
+    }
+
+    return (
+      <div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+        {thumbnail}
+      </div>
+    );
+  }
+
+}
+
+Item.propTypes = {
+  attachment: ImmutablePropTypes.map.isRequired,
+  index: PropTypes.number.isRequired,
+  size: PropTypes.number.isRequired,
+  onClick: PropTypes.func.isRequired,
+  autoPlayGif: PropTypes.bool.isRequired
+};
+
+class MediaGallery extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      visible: !props.sensitive
+    };
+    this.handleOpen = this.handleOpen.bind(this);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleOpen (e) {
+    this.setState({ visible: !this.state.visible });
+  }
+
+  handleClick (index) {
+    this.props.onOpenMedia(this.props.media, index);
+  }
+
+  render () {
+    const { media, intl, sensitive } = this.props;
+
+    let children;
+
+    if (!this.state.visible) {
+      let warning;
+
+      if (sensitive) {
+        warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
+      } else {
+        warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
+      }
+
+      children = (
+        <div role='button' tabIndex='0' className='media-spoiler' onClick={this.handleOpen}>
+          <span className='media-spoiler__warning'>{warning}</span>
+          <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+        </div>
+      );
+    } else {
+      const size = media.take(4).size;
+      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
+    }
+
+    return (
+      <div className='media-gallery' style={{ height: `${this.props.height}px` }}>
+        <div className='spoiler-button' style={{ display: !this.state.visible ? 'none' : 'block' }}>
+          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
+        </div>
+
+        {children}
+      </div>
+    );
+  }
+
+}
+
+MediaGallery.propTypes = {
+  sensitive: PropTypes.bool,
+  media: ImmutablePropTypes.list.isRequired,
+  height: PropTypes.number.isRequired,
+  onOpenMedia: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired,
+  autoPlayGif: PropTypes.bool.isRequired
+};
+
+export default injectIntl(MediaGallery);
diff --git a/app/javascript/mastodon/components/missing_indicator.js b/app/javascript/mastodon/components/missing_indicator.js
new file mode 100644
index 000000000..87df7f61c
--- /dev/null
+++ b/app/javascript/mastodon/components/missing_indicator.js
@@ -0,0 +1,12 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const MissingIndicator = () => (
+  <div className='missing-indicator'>
+    <div>
+      <FormattedMessage id='missing_indicator.label' defaultMessage='Not found' />
+    </div>
+  </div>
+);
+
+export default MissingIndicator;
diff --git a/app/javascript/mastodon/components/permalink.js b/app/javascript/mastodon/components/permalink.js
new file mode 100644
index 000000000..26444f27c
--- /dev/null
+++ b/app/javascript/mastodon/components/permalink.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class Permalink extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick (e) {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.push(this.props.to);
+    }
+  }
+
+  render () {
+    const { href, children, className, ...other } = this.props;
+
+    return (
+      <a href={href} onClick={this.handleClick} {...other} className={'permalink ' + className}>
+        {children}
+      </a>
+    );
+  }
+
+}
+
+Permalink.contextTypes = {
+  router: PropTypes.object
+};
+
+Permalink.propTypes = {
+  className: PropTypes.string,
+  href: PropTypes.string.isRequired,
+  to: PropTypes.string.isRequired,
+  children: PropTypes.node
+};
+
+export default Permalink;
diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js
new file mode 100644
index 000000000..9c7a8121e
--- /dev/null
+++ b/app/javascript/mastodon/components/relative_timestamp.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import { injectIntl, FormattedRelative } from 'react-intl';
+import PropTypes from 'prop-types';
+
+const RelativeTimestamp = ({ intl, timestamp }) => {
+  const date = new Date(timestamp);
+
+  return (
+    <time dateTime={timestamp} title={intl.formatDate(date, { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}>
+      <FormattedRelative value={date} />
+    </time>
+  );
+};
+
+RelativeTimestamp.propTypes = {
+  intl: PropTypes.object.isRequired,
+  timestamp: PropTypes.string.isRequired
+};
+
+export default injectIntl(RelativeTimestamp);
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
new file mode 100644
index 000000000..39ed6dd4f
--- /dev/null
+++ b/app/javascript/mastodon/components/status.js
@@ -0,0 +1,123 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from './avatar';
+import RelativeTimestamp from './relative_timestamp';
+import DisplayName from './display_name';
+import MediaGallery from './media_gallery';
+import VideoPlayer from './video_player';
+import AttachmentList from './attachment_list';
+import StatusContent from './status_content';
+import StatusActionBar from './status_action_bar';
+import { FormattedMessage } from 'react-intl';
+import emojify from '../emoji';
+import escapeTextContentForBrowser from 'escape-html';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class Status extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+    this.handleAccountClick = this.handleAccountClick.bind(this);
+  }
+
+  handleClick () {
+    const { status } = this.props;
+    this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
+  }
+
+  handleAccountClick (id, e) {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.push(`/accounts/${id}`);
+    }
+  }
+
+  render () {
+    let media = '';
+    const { status, ...other } = this.props;
+
+    if (status === null) {
+      return <div />;
+    }
+
+    if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
+      let displayName = status.getIn(['account', 'display_name']);
+
+      if (displayName.length === 0) {
+        displayName = status.getIn(['account', 'username']);
+      }
+
+      const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+
+      return (
+        <div className='status__wrapper'>
+          <div className='status__prepend'>
+            <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
+            <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} />
+          </div>
+
+          <Status {...other} wrapped={true} status={status.get('reblog')} />
+        </div>
+      );
+    }
+
+    if (status.get('media_attachments').size > 0 && !this.props.muted) {
+      if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
+
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+        media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />;
+      } else {
+        media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />;
+      }
+    }
+
+    return (
+      <div className={this.props.muted ? 'status muted' : 'status'}>
+        <div className='status__info'>
+          <div className='status__info-time'>
+            <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+          </div>
+
+          <a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'>
+            <div className='status__avatar'>
+              <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />
+            </div>
+
+            <DisplayName account={status.get('account')} />
+          </a>
+        </div>
+
+        <StatusContent status={status} onClick={this.handleClick} />
+
+        {media}
+
+        <StatusActionBar {...this.props} />
+      </div>
+    );
+  }
+
+}
+
+Status.contextTypes = {
+  router: PropTypes.object
+};
+
+Status.propTypes = {
+  status: ImmutablePropTypes.map,
+  wrapped: PropTypes.bool,
+  onReply: PropTypes.func,
+  onFavourite: PropTypes.func,
+  onReblog: PropTypes.func,
+  onDelete: PropTypes.func,
+  onOpenMedia: PropTypes.func,
+  onOpenVideo: PropTypes.func,
+  onBlock: PropTypes.func,
+  me: PropTypes.number,
+  boostModal: PropTypes.bool,
+  autoPlayGif: PropTypes.bool,
+  muted: PropTypes.bool
+};
+
+export default Status;
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
new file mode 100644
index 000000000..dc4466d6c
--- /dev/null
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -0,0 +1,138 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from './icon_button';
+import DropdownMenu from './dropdown_menu';
+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' },
+  replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+  cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+  open: { id: 'status.open', defaultMessage: 'Expand this status' },
+  report: { id: 'status.report', defaultMessage: 'Report @{name}' }
+});
+
+class StatusActionBar extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleReplyClick = this.handleReplyClick.bind(this);
+    this.handleFavouriteClick = this.handleFavouriteClick.bind(this);
+    this.handleReblogClick = this.handleReblogClick.bind(this);
+    this.handleDeleteClick = this.handleDeleteClick.bind(this);
+    this.handleMentionClick = this.handleMentionClick.bind(this);
+    this.handleMuteClick = this.handleMuteClick.bind(this);
+    this.handleBlockClick = this.handleBlockClick.bind(this);
+    this.handleOpen = this.handleOpen.bind(this);
+    this.handleReport = this.handleReport.bind(this);
+  }
+
+  handleReplyClick () {
+    this.props.onReply(this.props.status, this.context.router);
+  }
+
+  handleFavouriteClick () {
+    this.props.onFavourite(this.props.status);
+  }
+
+  handleReblogClick (e) {
+    this.props.onReblog(this.props.status, e);
+  }
+
+  handleDeleteClick () {
+    this.props.onDelete(this.props.status);
+  }
+
+  handleMentionClick () {
+    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'));
+  }
+
+  handleOpen () {
+    this.context.router.push(`/statuses/${this.props.status.get('id')}`);
+  }
+
+  handleReport () {
+    this.props.onReport(this.props.status);
+    this.context.router.push('/report');
+  }
+
+  render () {
+    const { status, me, intl } = this.props;
+    const reblog_disabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
+    let menu = [];
+
+    menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+    menu.push(null);
+
+    if (status.getIn(['account', 'id']) === me) {
+      menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+    } else {
+      menu.push({ text: intl.formatMessage(messages.mention, { 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 });
+    }
+
+    let reblogIcon = 'retweet';
+    if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
+    else if (status.get('visibility') === 'private') reblogIcon = 'lock';
+    let reply_icon;
+    let reply_title;
+    if (status.get('in_reply_to_id', null) === null) {
+      reply_icon = "reply";
+      reply_title = intl.formatMessage(messages.reply);
+    } else {
+      reply_icon = "reply-all";
+      reply_title = intl.formatMessage(messages.replyAll);
+    }
+
+    return (
+      <div className='status__action-bar'>
+        <div className='status__action-bar-button-wrapper'><IconButton title={reply_title} icon={reply_icon} onClick={this.handleReplyClick} /></div>
+        <div className='status__action-bar-button-wrapper'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
+        <div className='status__action-bar-button-wrapper'><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} className='star-icon' /></div>
+
+        <div className='status__action-bar-dropdown'>
+          <DropdownMenu items={menu} icon='ellipsis-h' size={18} direction="right" ariaLabel="More"/>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+StatusActionBar.contextTypes = {
+  router: PropTypes.object
+};
+
+StatusActionBar.propTypes = {
+  status: ImmutablePropTypes.map.isRequired,
+  onReply: PropTypes.func,
+  onFavourite: PropTypes.func,
+  onReblog: PropTypes.func,
+  onDelete: PropTypes.func,
+  onMention: PropTypes.func,
+  onMute: PropTypes.func,
+  onBlock: PropTypes.func,
+  onReport: PropTypes.func,
+  me: PropTypes.number.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(StatusActionBar);
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
new file mode 100644
index 000000000..1d462103b
--- /dev/null
+++ b/app/javascript/mastodon/components/status_content.js
@@ -0,0 +1,165 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import escapeTextContentForBrowser from 'escape-html';
+import PropTypes from 'prop-types';
+import emojify from '../emoji';
+import { isRtl } from '../rtl';
+import { FormattedMessage } from 'react-intl';
+import Permalink from './permalink';
+
+class StatusContent extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      hidden: true
+    };
+    this.onMentionClick = this.onMentionClick.bind(this);
+    this.onHashtagClick = this.onHashtagClick.bind(this);
+    this.handleMouseDown = this.handleMouseDown.bind(this)
+    this.handleMouseUp = this.handleMouseUp.bind(this);
+    this.handleSpoilerClick = this.handleSpoilerClick.bind(this);
+    this.setRef = this.setRef.bind(this);
+  };
+
+  componentDidMount () {
+    const node  = this.node;
+    const links = node.querySelectorAll('a');
+
+    for (var i = 0; i < links.length; ++i) {
+      let link    = links[i];
+      let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
+      let media   = this.props.status.get('media_attachments').find(item => link.href === item.get('text_url') || (item.get('remote_url').length > 0 && link.href === item.get('remote_url')));
+
+      if (mention) {
+        link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+        link.setAttribute('title', mention.get('acct'));
+      } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
+        link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+      } else if (media) {
+        link.innerHTML = '<i class="fa fa-fw fa-photo"></i>';
+      } else {
+        link.setAttribute('target', '_blank');
+        link.setAttribute('rel', 'noopener');
+        link.setAttribute('title', link.href);
+      }
+    }
+  }
+
+  onMentionClick (mention, e) {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.push(`/accounts/${mention.get('id')}`);
+    }
+  }
+
+  onHashtagClick (hashtag, e) {
+    hashtag = hashtag.replace(/^#/, '').toLowerCase();
+
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.push(`/timelines/tag/${hashtag}`);
+    }
+  }
+
+  handleMouseDown (e) {
+    this.startXY = [e.clientX, e.clientY];
+  }
+
+  handleMouseUp (e) {
+    const [ startX, startY ] = this.startXY;
+    const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
+
+    if (e.target.localName === 'a' || (e.target.parentNode && e.target.parentNode.localName === 'a')) {
+      return;
+    }
+
+    if (deltaX + deltaY < 5 && e.button === 0) {
+      this.props.onClick();
+    }
+
+    this.startXY = null;
+  }
+
+  handleSpoilerClick (e) {
+    e.preventDefault();
+    this.setState({ hidden: !this.state.hidden });
+  }
+
+  setRef (c) {
+    this.node = c;
+  }
+
+  render () {
+    const { status } = this.props;
+    const { hidden } = this.state;
+
+    const content = { __html: emojify(status.get('content')) };
+    const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
+    const directionStyle = { direction: 'ltr' };
+
+    if (isRtl(status.get('content'))) {
+      directionStyle.direction = 'rtl';
+    }
+
+    if (status.get('spoiler_text').length > 0) {
+      let mentionsPlaceholder = '';
+
+      const mentionLinks = status.get('mentions').map(item => (
+        <Permalink to={`/accounts/${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'>
+          @<span>{item.get('username')}</span>
+        </Permalink>
+      )).reduce((aggregate, item) => [...aggregate, item, ' '], [])
+
+      const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
+
+      if (hidden) {
+        mentionsPlaceholder = <div>{mentionLinks}</div>;
+      }
+
+      return (
+        <div className='status__content' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
+          <p style={{ marginBottom: hidden && status.get('mentions').size === 0 ? '0px' : '' }} >
+            <span dangerouslySetInnerHTML={spoilerContent} />  <a tabIndex='0' className='status__content__spoiler-link' role='button' onClick={this.handleSpoilerClick}>{toggleText}</a>
+          </p>
+
+          {mentionsPlaceholder}
+
+          <div ref={this.setRef} style={{ display: hidden ? 'none' : 'block', ...directionStyle }} dangerouslySetInnerHTML={content} />
+        </div>
+      );
+    } else if (this.props.onClick) {
+      return (
+        <div
+          ref={this.setRef}
+          className='status__content'
+          style={{ ...directionStyle }}
+          onMouseDown={this.handleMouseDown}
+          onMouseUp={this.handleMouseUp}
+          dangerouslySetInnerHTML={content}
+        />
+      );
+    } else {
+      return (
+        <div
+          ref={this.setRef}
+          className='status__content status__content--no-action'
+          style={{ ...directionStyle }}
+          dangerouslySetInnerHTML={content}
+        />
+      );
+    }
+  }
+
+}
+
+StatusContent.contextTypes = {
+  router: PropTypes.object
+};
+
+StatusContent.propTypes = {
+  status: ImmutablePropTypes.map.isRequired,
+  onClick: PropTypes.func
+};
+
+export default StatusContent;
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
new file mode 100644
index 000000000..9abf1fbfe
--- /dev/null
+++ b/app/javascript/mastodon/components/status_list.js
@@ -0,0 +1,130 @@
+import React from 'react';
+import Status from './status';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { ScrollContainer } from 'react-router-scroll';
+import PropTypes from 'prop-types';
+import StatusContainer from '../containers/status_container';
+import LoadMore from './load_more';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class StatusList extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScroll = this.handleScroll.bind(this);
+    this.setRef = this.setRef.bind(this);
+    this.handleLoadMore = this.handleLoadMore.bind(this);
+  }
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+    const offset = scrollHeight - scrollTop - clientHeight;
+    this._oldScrollPosition = scrollHeight - scrollTop;
+
+    if (250 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
+      this.props.onScrollToBottom();
+    } else if (scrollTop < 100 && this.props.onScrollToTop) {
+      this.props.onScrollToTop();
+    } else if (this.props.onScroll) {
+      this.props.onScroll();
+    }
+  }
+
+  componentDidMount () {
+    this.attachScrollListener();
+  }
+
+  componentDidUpdate (prevProps) {
+    if (this.node.scrollTop > 0 && (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && !!this._oldScrollPosition)) {
+      this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition;
+    }
+  }
+
+  componentWillUnmount () {
+    this.detachScrollListener();
+  }
+
+  attachScrollListener () {
+    this.node.addEventListener('scroll', this.handleScroll);
+  }
+
+  detachScrollListener () {
+    this.node.removeEventListener('scroll', this.handleScroll);
+  }
+
+  setRef (c) {
+    this.node = c;
+  }
+
+  handleLoadMore (e) {
+    e.preventDefault();
+    this.props.onScrollToBottom();
+  }
+
+  render () {
+    const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
+
+    let loadMore       = '';
+    let scrollableArea = '';
+    let unread         = '';
+
+    if (!isLoading && statusIds.size > 0 && hasMore) {
+      loadMore = <LoadMore onClick={this.handleLoadMore} />;
+    }
+
+    if (isUnread) {
+      unread = <div className='status-list__unread-indicator' />;
+    }
+
+    if (isLoading || statusIds.size > 0 || !emptyMessage) {
+      scrollableArea = (
+        <div className='scrollable' ref={this.setRef}>
+          {unread}
+
+          <div className='status-list'>
+            {prepend}
+
+            {statusIds.map((statusId) => {
+              return <StatusContainer key={statusId} id={statusId} />;
+            })}
+
+            {loadMore}
+          </div>
+        </div>
+      );
+    } else {
+      scrollableArea = (
+        <div className='empty-column-indicator' ref={this.setRef}>
+          {emptyMessage}
+        </div>
+      );
+    }
+
+    return (
+      <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
+        {scrollableArea}
+      </ScrollContainer>
+    );
+  }
+
+}
+
+StatusList.propTypes = {
+  scrollKey: PropTypes.string.isRequired,
+  statusIds: ImmutablePropTypes.list.isRequired,
+  onScrollToBottom: PropTypes.func,
+  onScrollToTop: PropTypes.func,
+  onScroll: PropTypes.func,
+  shouldUpdateScroll: PropTypes.func,
+  isLoading: PropTypes.bool,
+  isUnread: PropTypes.bool,
+  hasMore: PropTypes.bool,
+  prepend: PropTypes.node,
+  emptyMessage: PropTypes.node
+};
+
+StatusList.defaultProps = {
+  trackScroll: true
+};
+
+export default StatusList;
diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js
new file mode 100644
index 000000000..0c8aea3a9
--- /dev/null
+++ b/app/javascript/mastodon/components/video_player.js
@@ -0,0 +1,210 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { isIOS } from '../is_mobile';
+
+const messages = defineMessages({
+  toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
+  toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' },
+  expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' },
+  expand_video: { id: 'video_player.video_error', defaultMessage: 'Video could not be played' }
+});
+
+class VideoPlayer extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      visible: !this.props.sensitive,
+      preview: true,
+      muted: true,
+      hasAudio: true,
+      videoError: false
+    };
+
+    this.handleClick = this.handleClick.bind(this);
+    this.handleVideoClick = this.handleVideoClick.bind(this);
+    this.handleOpen = this.handleOpen.bind(this);
+    this.handleVisibility = this.handleVisibility.bind(this);
+    this.handleExpand = this.handleExpand.bind(this);
+    this.setRef = this.setRef.bind(this);
+    this.handleLoadedData = this.handleLoadedData.bind(this);
+    this.handleVideoError = this.handleVideoError.bind(this);
+  }
+
+  handleClick () {
+    this.setState({ muted: !this.state.muted });
+  }
+
+  handleVideoClick (e) {
+    e.stopPropagation();
+
+    const node = this.video;
+
+    if (node.paused) {
+      node.play();
+    } else {
+      node.pause();
+    }
+  }
+
+  handleOpen () {
+    this.setState({ preview: !this.state.preview });
+  }
+
+  handleVisibility () {
+    this.setState({
+      visible: !this.state.visible,
+      preview: true
+    });
+  }
+
+  handleExpand () {
+    this.video.pause();
+    this.props.onOpenVideo(this.props.media, this.video.currentTime);
+  }
+
+  setRef (c) {
+    this.video = c;
+  }
+
+  handleLoadedData () {
+    if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
+      this.setState({ hasAudio: false });
+    }
+  }
+
+  handleVideoError () {
+    this.setState({ videoError: true });
+  }
+
+  componentDidMount () {
+    if (!this.video) {
+      return;
+    }
+
+    this.video.addEventListener('loadeddata', this.handleLoadedData);
+    this.video.addEventListener('error', this.handleVideoError);
+  }
+
+  componentDidUpdate () {
+    if (!this.video) {
+      return;
+    }
+
+    this.video.addEventListener('loadeddata', this.handleLoadedData);
+    this.video.addEventListener('error', this.handleVideoError);
+  }
+
+  componentWillUnmount () {
+    if (!this.video) {
+      return;
+    }
+
+    this.video.removeEventListener('loadeddata', this.handleLoadedData);
+    this.video.removeEventListener('error', this.handleVideoError);
+  }
+
+  render () {
+    const { media, intl, width, height, sensitive, autoplay } = this.props;
+
+    let spoilerButton = (
+      <div className='status__video-player-spoiler' style={{ display: !this.state.visible ? 'none' : 'block' }} >
+        <IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
+      </div>
+    );
+
+    let expandButton = (
+      <div className='status__video-player-expand'>
+        <IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} />
+      </div>
+    );
+
+    let muteButton = '';
+
+    if (this.state.hasAudio) {
+      muteButton = (
+        <div className='status__video-player-mute'>
+          <IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
+        </div>
+      );
+    }
+
+    if (!this.state.visible) {
+      if (sensitive) {
+        return (
+          <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
+            {spoilerButton}
+            <span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+            <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+          </div>
+        );
+      } else {
+        return (
+          <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
+            {spoilerButton}
+            <span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
+            <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+          </div>
+        );
+      }
+    }
+
+    if (this.state.preview && !autoplay) {
+      return (
+        <div role='button' tabIndex='0' className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center` }} onClick={this.handleOpen}>
+          {spoilerButton}
+          <div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div>
+        </div>
+      );
+    }
+
+    if (this.state.videoError) {
+      return (
+        <div style={{ width: `${width}px`, height: `${height}px` }} className='video-error-cover' >
+          <span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span>
+        </div>
+      );
+    }
+
+    return (
+      <div className='status__video-player' style={{ width: `${width}px`, height: `${height}px` }}>
+        {spoilerButton}
+        {muteButton}
+        {expandButton}
+
+        <video
+          className='status__video-player-video'
+          role='button'
+          tabIndex='0'
+          ref={this.setRef}
+          src={media.get('url')}
+          autoPlay={!isIOS()}
+          loop={true}
+          muted={this.state.muted}
+          onClick={this.handleVideoClick}
+        />
+      </div>
+    );
+  }
+
+}
+
+VideoPlayer.propTypes = {
+  media: ImmutablePropTypes.map.isRequired,
+  width: PropTypes.number,
+  height: PropTypes.number,
+  sensitive: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  autoplay: PropTypes.bool,
+  onOpenVideo: PropTypes.func.isRequired
+};
+
+VideoPlayer.defaultProps = {
+  width: 239,
+  height: 110
+};
+
+export default injectIntl(VideoPlayer);
diff --git a/app/javascript/mastodon/containers/account_container.js b/app/javascript/mastodon/containers/account_container.js
new file mode 100644
index 000000000..3c30be715
--- /dev/null
+++ b/app/javascript/mastodon/containers/account_container.js
@@ -0,0 +1,50 @@
+import { connect } from 'react-redux';
+import { makeGetAccount } from '../selectors';
+import Account from '../components/account';
+import {
+  followAccount,
+  unfollowAccount,
+  blockAccount,
+  unblockAccount,
+  muteAccount,
+  unmuteAccount,
+} from '../actions/accounts';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, props) => ({
+    account: getAccount(state, props.id),
+    me: state.getIn(['meta', 'me'])
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch) => ({
+  onFollow (account) {
+    if (account.getIn(['relationship', 'following'])) {
+      dispatch(unfollowAccount(account.get('id')));
+    } else {
+      dispatch(followAccount(account.get('id')));
+    }
+  },
+
+  onBlock (account) {
+    if (account.getIn(['relationship', 'blocking'])) {
+      dispatch(unblockAccount(account.get('id')));
+    } else {
+      dispatch(blockAccount(account.get('id')));
+    }
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(muteAccount(account.get('id')));
+    }
+  }
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(Account);
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
new file mode 100644
index 000000000..637199686
--- /dev/null
+++ b/app/javascript/mastodon/containers/mastodon.js
@@ -0,0 +1,314 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+import PropTypes from 'prop-types';
+import configureStore from '../store/configureStore';
+import {
+  refreshTimelineSuccess,
+  updateTimeline,
+  deleteFromTimelines,
+  refreshTimeline,
+  connectTimeline,
+  disconnectTimeline
+} from '../actions/timelines';
+import { showOnboardingOnce } from '../actions/onboarding';
+import { updateNotifications, refreshNotifications } from '../actions/notifications';
+import createBrowserHistory from 'history/lib/createBrowserHistory';
+import {
+  applyRouterMiddleware,
+  useRouterHistory,
+  Router,
+  Route,
+  IndexRedirect,
+  IndexRoute
+} from 'react-router';
+import { useScroll } from 'react-router-scroll';
+import UI from '../features/ui';
+import Status from '../features/status';
+import GettingStarted from '../features/getting_started';
+import PublicTimeline from '../features/public_timeline';
+import CommunityTimeline from '../features/community_timeline';
+import AccountTimeline from '../features/account_timeline';
+import HomeTimeline from '../features/home_timeline';
+import Compose from '../features/compose';
+import Followers from '../features/followers';
+import Following from '../features/following';
+import Reblogs from '../features/reblogs';
+import Favourites from '../features/favourites';
+import HashtagTimeline from '../features/hashtag_timeline';
+import Notifications from '../features/notifications';
+import FollowRequests from '../features/follow_requests';
+import GenericNotFound from '../features/generic_not_found';
+import FavouritedStatuses from '../features/favourited_statuses';
+import Blocks from '../features/blocks';
+import Mutes from '../features/mutes';
+import Report from '../features/report';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import ar from 'react-intl/locale-data/ar';
+import en from 'react-intl/locale-data/en';
+import de from 'react-intl/locale-data/de';
+import eo from 'react-intl/locale-data/eo';
+import es from 'react-intl/locale-data/es';
+import fa from 'react-intl/locale-data/fa';
+import fi from 'react-intl/locale-data/fi';
+import fr from 'react-intl/locale-data/fr';
+import he from 'react-intl/locale-data/he';
+import hu from 'react-intl/locale-data/hu';
+import it from 'react-intl/locale-data/it';
+import ja from 'react-intl/locale-data/ja';
+import pt from 'react-intl/locale-data/pt';
+import nl from 'react-intl/locale-data/nl';
+import no from 'react-intl/locale-data/no';
+import ru from 'react-intl/locale-data/ru';
+import uk from 'react-intl/locale-data/uk';
+import zh from 'react-intl/locale-data/zh';
+import bg from 'react-intl/locale-data/bg';
+import id from 'react-intl/locale-data/id';
+import getMessagesForLocale from '../locales';
+import { hydrateStore } from '../actions/store';
+import createStream from '../stream';
+
+const store = configureStore();
+const initialState = JSON.parse(document.getElementById("initial-state").textContent);
+store.dispatch(hydrateStore(initialState));
+
+const browserHistory = useRouterHistory(createBrowserHistory)({
+  basename: '/web'
+});
+
+addLocaleData([
+  ...en,
+  ...ar,
+  ...de,
+  ...eo,
+  ...es,
+  ...fa,
+  ...fi,
+  ...fr,
+  ...he,
+  ...hu,
+  ...it,
+  ...ja,
+  ...pt,
+  ...nl,
+  ...no,
+  ...ru,
+  ...uk,
+  ...zh,
+  ...bg,
+  ...id,
+]);
+
+const getTopWhenReplacing = (previous, { location }) => location && location.action === 'REPLACE' && [0, 0];
+
+const hiddenColumnContainerStyle = {
+  position: 'absolute',
+  left: '0',
+  top:  '0',
+  visibility: 'hidden'
+};
+
+class Container extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      renderedPersistents: [],
+      unrenderedPersistents: [],
+    };
+  }
+
+  componentWillMount () {
+    this.unlistenHistory = null;
+
+    this.setState(() => {
+      return {
+        mountImpersistent: false,
+        renderedPersistents: [],
+        unrenderedPersistents: [
+          {pathname: '/timelines/home', component: HomeTimeline},
+          {pathname: '/timelines/public', component: PublicTimeline},
+          {pathname: '/timelines/public/local', component: CommunityTimeline},
+
+          {pathname: '/notifications', component: Notifications},
+          {pathname: '/favourites', component: FavouritedStatuses}
+        ],
+      };
+    }, () => {
+      if (this.unlistenHistory) {
+        return;
+      }
+
+      this.unlistenHistory = browserHistory.listen(location => {
+        const pathname = location.pathname.replace(/\/$/, '').toLowerCase();
+
+        this.setState(oldState => {
+          let persistentMatched = false;
+
+          const newState = {
+            renderedPersistents: oldState.renderedPersistents.map(persistent => {
+              const givenMatched = persistent.pathname === pathname;
+
+              if (givenMatched) {
+                persistentMatched = true;
+              }
+
+              return {
+                hidden: !givenMatched,
+                pathname: persistent.pathname,
+                component: persistent.component
+              };
+            }),
+          };
+
+          if (!persistentMatched) {
+            newState.unrenderedPersistents = [];
+
+            oldState.unrenderedPersistents.forEach(persistent => {
+              if (persistent.pathname === pathname) {
+                persistentMatched = true;
+
+                newState.renderedPersistents.push({
+                  hidden: false,
+                  pathname: persistent.pathname,
+                  component: persistent.component
+                });
+              } else {
+                newState.unrenderedPersistents.push(persistent);
+              }
+            });
+          }
+
+          newState.mountImpersistent = !persistentMatched;
+
+          return newState;
+        });
+      });
+    });
+  }
+
+  componentWillUnmount () {
+    if (this.unlistenHistory) {
+      this.unlistenHistory();
+    }
+
+    this.unlistenHistory = "done";
+  }
+
+  render () {
+    // Hide some components rather than unmounting them to allow to show again
+    // quickly and keep the view state such as the scrolled offset.
+    const persistentsView = this.state.renderedPersistents.map((persistent) =>
+      <div aria-hidden={persistent.hidden} key={persistent.pathname} className='mastodon-column-container' style={persistent.hidden ? hiddenColumnContainerStyle : null}>
+        <persistent.component shouldUpdateScroll={persistent.hidden ? Function.prototype : getTopWhenReplacing} />
+      </div>
+    );
+
+    return (
+      <UI>
+        {this.state.mountImpersistent && this.props.children}
+        {persistentsView}
+      </UI>
+    );
+  }
+}
+
+Container.propTypes = {
+  children: PropTypes.node,
+};
+
+class Mastodon extends React.Component {
+
+  componentDidMount() {
+    const { locale }  = this.props;
+    const streamingAPIBaseURL = store.getState().getIn(['meta', 'streaming_api_base_url']);
+    const accessToken = store.getState().getIn(['meta', 'access_token']);
+
+    this.subscription = createStream(streamingAPIBaseURL, accessToken, 'user', {
+
+      connected () {
+        store.dispatch(connectTimeline('home'));
+      },
+
+      disconnected () {
+        store.dispatch(disconnectTimeline('home'));
+      },
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          store.dispatch(updateTimeline('home', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          store.dispatch(deleteFromTimelines(data.payload));
+          break;
+        case 'notification':
+          store.dispatch(updateNotifications(JSON.parse(data.payload), getMessagesForLocale(locale), locale));
+          break;
+        }
+      },
+
+      reconnected () {
+        store.dispatch(connectTimeline('home'));
+        store.dispatch(refreshTimeline('home'));
+        store.dispatch(refreshNotifications());
+      }
+
+    });
+
+    // Desktop notifications
+    if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
+      Notification.requestPermission();
+    }
+
+    store.dispatch(showOnboardingOnce());
+  }
+
+  componentWillUnmount () {
+    if (typeof this.subscription !== 'undefined') {
+      this.subscription.close();
+      this.subscription = null;
+    }
+  }
+
+  render () {
+    const { locale } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={getMessagesForLocale(locale)}>
+        <Provider store={store}>
+          <Router history={browserHistory} render={applyRouterMiddleware(useScroll())}>
+            <Route path='/' component={Container}>
+              <IndexRedirect to='/getting-started' />
+              <Route path='getting-started' component={GettingStarted} />
+              <Route path='timelines/tag/:id' component={HashtagTimeline} />
+
+              <Route path='statuses/new' component={Compose} />
+              <Route path='statuses/:statusId' component={Status} />
+              <Route path='statuses/:statusId/reblogs' component={Reblogs} />
+              <Route path='statuses/:statusId/favourites' component={Favourites} />
+
+              <Route path='accounts/:accountId' component={AccountTimeline} />
+              <Route path='accounts/:accountId/followers' component={Followers} />
+              <Route path='accounts/:accountId/following' component={Following} />
+
+              <Route path='follow_requests' component={FollowRequests} />
+              <Route path='blocks' component={Blocks} />
+              <Route path='mutes' component={Mutes} />
+              <Route path='report' component={Report} />
+
+              <Route path='*' component={GenericNotFound} />
+            </Route>
+          </Router>
+        </Provider>
+      </IntlProvider>
+    );
+  }
+
+}
+
+Mastodon.propTypes = {
+  locale: PropTypes.string.isRequired
+};
+
+export default Mastodon;
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
new file mode 100644
index 000000000..eb1f1ab79
--- /dev/null
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -0,0 +1,118 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import Status from '../components/status';
+import { makeGetStatus } from '../selectors';
+import {
+  replyCompose,
+  mentionCompose
+} from '../actions/compose';
+import {
+  reblog,
+  favourite,
+  unreblog,
+  unfavourite
+} from '../actions/interactions';
+import {
+  blockAccount,
+  muteAccount
+} from '../actions/accounts';
+import { deleteStatus } from '../actions/statuses';
+import { initReport } from '../actions/reports';
+import { openModal } from '../actions/modal';
+import { createSelector } from 'reselect'
+import { isMobile } from '../is_mobile'
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+  deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+  deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+  blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+  muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, props.id),
+    me: state.getIn(['meta', 'me']),
+    boostModal: state.getIn(['meta', 'boost_modal']),
+    autoPlayGif: state.getIn(['meta', 'auto_play_gif'])
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onReply (status, router) {
+    dispatch(replyCompose(status, router));
+  },
+
+  onModalReblog (status) {
+    dispatch(reblog(status));
+  },
+
+  onReblog (status, e) {
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else {
+      if (e.shiftKey || !this.boostModal) {
+        this.onModalReblog(status);
+      } else {
+        dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
+      }
+    }
+  },
+
+  onFavourite (status) {
+    if (status.get('favourited')) {
+      dispatch(unfavourite(status));
+    } else {
+      dispatch(favourite(status));
+    }
+  },
+
+  onDelete (status) {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.deleteMessage),
+      confirm: intl.formatMessage(messages.deleteConfirm),
+      onConfirm: () => dispatch(deleteStatus(status.get('id')))
+    }));
+  },
+
+  onMention (account, router) {
+    dispatch(mentionCompose(account, router));
+  },
+
+  onOpenMedia (media, index) {
+    dispatch(openModal('MEDIA', { media, index }));
+  },
+
+  onOpenVideo (media, time) {
+    dispatch(openModal('VIDEO', { media, time }));
+  },
+
+  onBlock (account) {
+    dispatch(openModal('CONFIRM', {
+      message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+      confirm: intl.formatMessage(messages.blockConfirm),
+      onConfirm: () => dispatch(blockAccount(account.get('id')))
+    }));
+  },
+
+  onReport (status) {
+    dispatch(initReport(status.get('account'), status));
+  },
+
+  onMute (account) {
+    dispatch(openModal('CONFIRM', {
+      message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+      confirm: intl.formatMessage(messages.muteConfirm),
+      onConfirm: () => dispatch(muteAccount(account.get('id')))
+    }));
+  },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js
new file mode 100644
index 000000000..eee657b86
--- /dev/null
+++ b/app/javascript/mastodon/emoji.js
@@ -0,0 +1,35 @@
+import emojione from 'emojione';
+
+const toImage = str => shortnameToImage(unicodeToImage(str));
+
+const unicodeToImage = str => {
+  const mappedUnicode = emojione.mapUnicodeToShort();
+
+  return str.replace(emojione.regUnicode, unicodeChar => {
+    if (typeof unicodeChar === 'undefined' || unicodeChar === '' || !(unicodeChar in emojione.jsEscapeMap)) {
+      return unicodeChar;
+    }
+
+    const unicode  = emojione.jsEscapeMap[unicodeChar];
+    const short    = mappedUnicode[unicode];
+    const filename = emojione.emojioneList[short].fname;
+    const alt      = emojione.convert(unicode.toUpperCase());
+
+    return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${filename}.svg" />`;
+  });
+};
+
+const shortnameToImage = str => str.replace(emojione.regShortNames, shortname => {
+  if (typeof shortname === 'undefined' || shortname === '' || !(shortname in emojione.emojioneList)) {
+    return shortname;
+  }
+
+  const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1];
+  const alt     = emojione.convert(unicode.toUpperCase());
+
+  return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${unicode}.svg" />`;
+});
+
+export default function emojify(text) {
+  return toImage(text);
+};
diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js
new file mode 100644
index 000000000..069348050
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/action_bar.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import DropdownMenu from '../../../components/dropdown_menu';
+import { Link } from 'react-router';
+import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
+
+const messages = defineMessages({
+  mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
+  edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  report: { id: 'account.report', defaultMessage: 'Report @{name}' },
+  disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' }
+});
+
+class ActionBar extends React.PureComponent {
+
+  render () {
+    const { account, me, intl } = this.props;
+
+    let menu = [];
+    let extraInfo = '';
+
+    menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+    menu.push(null);
+
+    if (account.get('id') === me) {
+      menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
+    } else {
+      if (account.getIn(['relationship', 'muting'])) {
+        menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute });
+      }
+
+      if (account.getIn(['relationship', 'blocking'])) {
+        menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock });
+      }
+
+      menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
+    }
+
+    if (account.get('acct') !== account.get('username')) {
+      extraInfo = <abbr title={intl.formatMessage(messages.disclaimer)}>*</abbr>;
+    }
+
+    return (
+      <div className='account__action-bar'>
+        <div className='account__action-bar-dropdown'>
+          <DropdownMenu items={menu} icon='bars' size={24} direction="right" />
+        </div>
+
+        <div className='account__action-bar-links'>
+          <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
+            <span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span>
+            <strong><FormattedNumber value={account.get('statuses_count')} /> {extraInfo}</strong>
+          </Link>
+
+          <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
+            <span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span>
+            <strong><FormattedNumber value={account.get('following_count')} /> {extraInfo}</strong>
+          </Link>
+
+          <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
+            <span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span>
+            <strong><FormattedNumber value={account.get('followers_count')} /> {extraInfo}</strong>
+          </Link>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+ActionBar.propTypes = {
+  account: ImmutablePropTypes.map.isRequired,
+  me: PropTypes.number.isRequired,
+  onFollow: PropTypes.func,
+  onBlock: PropTypes.func.isRequired,
+  onMention: PropTypes.func.isRequired,
+  onReport: PropTypes.func.isRequired,
+  onMute: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(ActionBar);
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
new file mode 100644
index 000000000..fbaa5e9e6
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -0,0 +1,150 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+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';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
+});
+
+const makeMapStateToProps = () => {
+  const mapStateToProps = (state, props) => ({
+    autoPlayGif: state.getIn(['meta', 'auto_play_gif'])
+  });
+
+  return mapStateToProps;
+};
+
+class Avatar extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+
+    this.state = {
+      isHovered: false
+    };
+
+    this.handleMouseOver = this.handleMouseOver.bind(this);
+    this.handleMouseOut = this.handleMouseOut.bind(this);
+  }
+
+  handleMouseOver () {
+    if (this.state.isHovered) return;
+    this.setState({ isHovered: true });
+  }
+
+  handleMouseOut () {
+    if (!this.state.isHovered) return;
+    this.setState({ isHovered: false });
+  }
+
+  render () {
+    const { account, autoPlayGif }   = 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={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }}
+            onMouseOver={this.handleMouseOver}
+            onMouseOut={this.handleMouseOut}
+            onFocus={this.handleMouseOver}
+            onBlur={this.handleMouseOut}
+          />
+        }
+      </Motion>
+    );
+  }
+
+}
+
+Avatar.propTypes = {
+  account: ImmutablePropTypes.map.isRequired,
+  autoPlayGif: PropTypes.bool.isRequired
+};
+
+class Header extends ImmutablePureComponent {
+
+  render () {
+    const { account, me, intl } = this.props;
+
+    if (!account) {
+      return null;
+    }
+
+    let displayName = account.get('display_name');
+    let info        = '';
+    let actionBtn   = '';
+    let lockedIcon  = '';
+
+    if (displayName.length === 0) {
+      displayName = account.get('username');
+    }
+
+    if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
+      info = <span className='account--follows-info' style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>
+    }
+
+    if (me !== account.get('id')) {
+      if (account.getIn(['relationship', 'requested'])) {
+        actionBtn = (
+          <div style={{ position: 'absolute', top: '10px', left: '20px' }}>
+            <IconButton size={26} disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
+          </div>
+        );
+      } else if (!account.getIn(['relationship', 'blocking'])) {
+        actionBtn = (
+          <div style={{ position: 'absolute', top: '10px', left: '20px' }}>
+            <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />
+          </div>
+        );
+      }
+    }
+
+    if (account.get('locked')) {
+      lockedIcon = <i className='fa fa-lock' />;
+    }
+
+    const content         = { __html: emojify(account.get('note')) };
+    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+
+    return (
+      <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
+        <div style={{ padding: '20px 10px' }}>
+          <Avatar account={account} autoPlayGif={this.props.autoPlayGif} />
+
+          <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} />
+
+          {info}
+          {actionBtn}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+Header.propTypes = {
+  account: ImmutablePropTypes.map,
+  me: PropTypes.number.isRequired,
+  onFollow: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired,
+  autoPlayGif: PropTypes.bool.isRequired
+};
+
+export default connect(makeMapStateToProps)(injectIntl(Header));
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
new file mode 100644
index 000000000..b4dca3a57
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -0,0 +1,83 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import InnerHeader from '../../account/components/header';
+import ActionBar from '../../account/components/action_bar';
+import MissingIndicator from '../../../components/missing_indicator';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class Header extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleFollow = this.handleFollow.bind(this);
+    this.handleBlock = this.handleBlock.bind(this);
+    this.handleMention = this.handleMention.bind(this);
+    this.handleReport = this.handleReport.bind(this);
+    this.handleMute = this.handleMute.bind(this);
+  }
+
+  handleFollow () {
+    this.props.onFollow(this.props.account);
+  }
+
+  handleBlock () {
+    this.props.onBlock(this.props.account);
+  }
+
+  handleMention () {
+    this.props.onMention(this.props.account, this.context.router);
+  }
+
+  handleReport () {
+    this.props.onReport(this.props.account);
+    this.context.router.push('/report');
+  }
+
+  handleMute() {
+    this.props.onMute(this.props.account);
+  }
+
+  render () {
+    const { account, me } = this.props;
+
+    if (account === null) {
+      return <MissingIndicator />;
+    }
+
+    return (
+      <div className='account-timeline__header'>
+        <InnerHeader
+          account={account}
+          me={me}
+          onFollow={this.handleFollow}
+        />
+
+        <ActionBar
+          account={account}
+          me={me}
+          onBlock={this.handleBlock}
+          onMention={this.handleMention}
+          onReport={this.handleReport}
+          onMute={this.handleMute}
+        />
+      </div>
+    );
+  }
+}
+
+Header.propTypes = {
+  account: ImmutablePropTypes.map,
+  me: PropTypes.number.isRequired,
+  onFollow: PropTypes.func.isRequired,
+  onBlock: PropTypes.func.isRequired,
+  onMention: PropTypes.func.isRequired,
+  onReport: PropTypes.func.isRequired,
+  onMute: PropTypes.func.isRequired
+};
+
+Header.contextTypes = {
+  router: PropTypes.object
+};
+
+export default Header;
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
new file mode 100644
index 000000000..50999d2e0
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -0,0 +1,76 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount } from '../../../selectors';
+import Header from '../components/header';
+import {
+  followAccount,
+  unfollowAccount,
+  blockAccount,
+  unblockAccount,
+  muteAccount,
+  unmuteAccount
+} from '../../../actions/accounts';
+import { mentionCompose } from '../../../actions/compose';
+import { initReport } from '../../../actions/reports';
+import { openModal } from '../../../actions/modal';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+  blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+  muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' }
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId }) => ({
+    account: getAccount(state, Number(accountId)),
+    me: state.getIn(['meta', 'me'])
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onFollow (account) {
+    if (account.getIn(['relationship', 'following'])) {
+      dispatch(unfollowAccount(account.get('id')));
+    } else {
+      dispatch(followAccount(account.get('id')));
+    }
+  },
+
+  onBlock (account) {
+    if (account.getIn(['relationship', 'blocking'])) {
+      dispatch(unblockAccount(account.get('id')));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+        confirm: intl.formatMessage(messages.blockConfirm),
+        onConfirm: () => dispatch(blockAccount(account.get('id')))
+      }));
+    }
+  },
+
+  onMention (account, router) {
+    dispatch(mentionCompose(account, router));
+  },
+
+  onReport (account) {
+    dispatch(initReport(account));
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+        confirm: intl.formatMessage(messages.muteConfirm),
+        onConfirm: () => dispatch(muteAccount(account.get('id')))
+      }));
+    }
+  }
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
new file mode 100644
index 000000000..fb76f4d2e
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/index.js
@@ -0,0 +1,89 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import {
+  fetchAccount,
+  fetchAccountTimeline,
+  expandAccountTimeline
+} from '../../actions/accounts';
+import StatusList from '../../components/status_list';
+import LoadingIndicator from '../../components/loading_indicator';
+import Column from '../ui/components/column';
+import HeaderContainer from './containers/header_container';
+import ColumnBackButton from '../../components/column_back_button';
+import Immutable from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items'], Immutable.List()),
+  isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']),
+  hasMore: !!state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'next']),
+  me: state.getIn(['meta', 'me'])
+});
+
+class AccountTimeline extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScrollToBottom = this.handleScrollToBottom.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
+    this.props.dispatch(fetchAccountTimeline(Number(this.props.params.accountId)));
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
+      this.props.dispatch(fetchAccountTimeline(Number(nextProps.params.accountId)));
+    }
+  }
+
+  handleScrollToBottom () {
+    if (!this.props.isLoading && this.props.hasMore) {
+      this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
+    }
+  }
+
+  render () {
+    const { statusIds, isLoading, hasMore, me } = this.props;
+
+    if (!statusIds && isLoading) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <StatusList
+          prepend={<HeaderContainer accountId={this.props.params.accountId} />}
+          scrollKey='account_timeline'
+          statusIds={statusIds}
+          isLoading={isLoading}
+          hasMore={hasMore}
+          me={me}
+          onScrollToBottom={this.handleScrollToBottom}
+        />
+      </Column>
+    );
+  }
+
+}
+
+AccountTimeline.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  statusIds: ImmutablePropTypes.list,
+  isLoading: PropTypes.bool,
+  hasMore: PropTypes.bool,
+  me: PropTypes.number.isRequired
+};
+
+export default connect(mapStateToProps)(AccountTimeline);
diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js
new file mode 100644
index 000000000..e25d9b2b4
--- /dev/null
+++ b/app/javascript/mastodon/features/blocks/index.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import LoadingIndicator from '../../components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import AccountContainer from '../../containers/account_container';
+import { fetchBlocks, expandBlocks } from '../../actions/blocks';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.blocks', defaultMessage: 'Blocked users' }
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'blocks', 'items'])
+});
+
+class Blocks extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScroll = this.handleScroll.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchBlocks());
+  }
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight) {
+      this.props.dispatch(expandBlocks());
+    }
+  }
+
+  render () {
+    const { intl, accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column icon='ban' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <ScrollContainer scrollKey='blocks'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            {accountIds.map(id =>
+              <AccountContainer key={id} id={id} />
+            )}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+}
+
+Blocks.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  accountIds: ImmutablePropTypes.list,
+  intl: PropTypes.object.isRequired
+};
+
+export default connect(mapStateToProps)(injectIntl(Blocks));
diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js
new file mode 100644
index 000000000..883263631
--- /dev/null
+++ b/app/javascript/mastodon/features/community_timeline/index.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../ui/components/column';
+import {
+  refreshTimeline,
+  updateTimeline,
+  deleteFromTimelines,
+  connectTimeline,
+  disconnectTimeline
+} from '../../actions/timelines';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import createStream from '../../stream';
+
+const messages = defineMessages({
+  title: { id: 'column.community', defaultMessage: 'Local timeline' }
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
+  streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
+  accessToken: state.getIn(['meta', 'access_token'])
+});
+
+let subscription;
+
+class CommunityTimeline extends React.PureComponent {
+
+  componentDidMount () {
+    const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
+
+    dispatch(refreshTimeline('community'));
+
+    if (typeof subscription !== 'undefined') {
+      return;
+    }
+
+    subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', {
+
+      connected () {
+        dispatch(connectTimeline('community'));
+      },
+
+      reconnected () {
+        dispatch(connectTimeline('community'));
+      },
+
+      disconnected () {
+        dispatch(disconnectTimeline('community'));
+      },
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline('community', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
+        }
+      }
+
+    });
+  }
+
+  componentWillUnmount () {
+    // if (typeof subscription !== 'undefined') {
+    //   subscription.close();
+    //   subscription = null;
+    // }
+  }
+
+  render () {
+    const { intl, hasUnread } = this.props;
+
+    return (
+      <Column icon='users' active={hasUnread} heading={intl.formatMessage(messages.title)}>
+        <ColumnBackButtonSlim />
+        <StatusListContainer {...this.props} scrollKey='community_timeline' type='community' emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} />
+      </Column>
+    );
+  }
+
+}
+
+CommunityTimeline.propTypes = {
+  dispatch: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired,
+  streamingAPIBaseURL: PropTypes.string.isRequired,
+  accessToken: PropTypes.string.isRequired,
+  hasUnread: PropTypes.bool
+};
+
+export default connect(mapStateToProps)(injectIntl(CommunityTimeline));
diff --git a/app/javascript/mastodon/features/compose/components/autosuggest_account.js b/app/javascript/mastodon/features/compose/components/autosuggest_account.js
new file mode 100644
index 000000000..3d87c4649
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/autosuggest_account.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class AutosuggestAccount extends ImmutablePureComponent {
+
+  render () {
+    const { account } = this.props;
+
+    return (
+      <div className='autosuggest-account'>
+        <div className='autosuggest-account-icon'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={18} /></div>
+        <DisplayName account={account} />
+      </div>
+    );
+  }
+
+}
+
+AutosuggestAccount.propTypes = {
+  account: ImmutablePropTypes.map.isRequired
+};
+
+export default AutosuggestAccount;
diff --git a/app/javascript/mastodon/features/compose/components/character_counter.js b/app/javascript/mastodon/features/compose/components/character_counter.js
new file mode 100644
index 000000000..617f85cfe
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/character_counter.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { length } from 'stringz';
+
+class CharacterCounter extends React.PureComponent {
+
+  checkRemainingText (diff) {
+    if (diff < 0) {
+      return <span className='character-counter character-counter--over'>{diff}</span>;
+    }
+    return <span className='character-counter'>{diff}</span>;
+  }
+
+  render () {
+    const diff = this.props.max - length(this.props.text);
+
+    return this.checkRemainingText(diff);
+  }
+
+}
+
+CharacterCounter.propTypes = {
+  text: PropTypes.string.isRequired,
+  max: PropTypes.number.isRequired
+}
+
+export default CharacterCounter;
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
new file mode 100644
index 000000000..0b9c097e3
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -0,0 +1,211 @@
+import React from 'react';
+import CharacterCounter from './character_counter';
+import Button from '../../../components/button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import AutosuggestTextarea from '../../../components/autosuggest_textarea';
+import { debounce } from 'react-decoration';
+import UploadButtonContainer from '../containers/upload_button_container';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
+import Collapsable from '../../../components/collapsable';
+import SpoilerButtonContainer from '../containers/spoiler_button_container';
+import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
+import SensitiveButtonContainer from '../containers/sensitive_button_container';
+import EmojiPickerDropdown from './emoji_picker_dropdown';
+import UploadFormContainer from '../containers/upload_form_container';
+import TextIconButton from './text_icon_button';
+import WarningContainer from '../containers/warning_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
+  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' },
+  publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }
+});
+
+class ComposeForm extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleChange = this.handleChange.bind(this);
+    this.handleKeyDown = this.handleKeyDown.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.onSuggestionsClearRequested = this.onSuggestionsClearRequested.bind(this);
+    this.onSuggestionsFetchRequested = this.onSuggestionsFetchRequested.bind(this);
+    this.onSuggestionSelected = this.onSuggestionSelected.bind(this);
+    this.handleChangeSpoilerText = this.handleChangeSpoilerText.bind(this);
+    this.setAutosuggestTextarea = this.setAutosuggestTextarea.bind(this);
+    this.handleEmojiPick = this.handleEmojiPick.bind(this);
+  }
+
+  handleChange (e) {
+    this.props.onChange(e.target.value);
+  }
+
+  handleKeyDown (e) {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      this.handleSubmit();
+    }
+  }
+
+  handleSubmit () {
+    this.autosuggestTextarea.reset();
+    this.props.onSubmit();
+  }
+
+  onSuggestionsClearRequested () {
+    this.props.onClearSuggestions();
+  }
+
+  @debounce(500)
+  onSuggestionsFetchRequested (token) {
+    this.props.onFetchSuggestions(token);
+  }
+
+  onSuggestionSelected (tokenStart, token, value) {
+    this._restoreCaret = null;
+    this.props.onSuggestionSelected(tokenStart, token, value);
+  }
+
+  handleChangeSpoilerText (e) {
+    this.props.onChangeSpoilerText(e.target.value);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    // If this is the update where we've finished uploading,
+    // save the last caret position so we can restore it below!
+    if (!nextProps.is_uploading && this.props.is_uploading) {
+      this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    // This statement does several things:
+    // - If we're beginning a reply, and,
+    //     - Replying to zero or one users, places the cursor at the end of the textbox.
+    //     - Replying to more than one user, selects any usernames past the first;
+    //       this provides a convenient shortcut to drop everyone else from the conversation.
+    // - If we've just finished uploading an image, and have a saved caret position,
+    //   restores the cursor to that position after the text changes!
+    if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
+      let selectionEnd, selectionStart;
+
+      if (this.props.preselectDate !== prevProps.preselectDate) {
+        selectionEnd   = this.props.text.length;
+        selectionStart = this.props.text.search(/\s/) + 1;
+      } else if (typeof this._restoreCaret === 'number') {
+        selectionStart = this._restoreCaret;
+        selectionEnd   = this._restoreCaret;
+      } else {
+        selectionEnd   = this.props.text.length;
+        selectionStart = selectionEnd;
+      }
+
+      this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
+      this.autosuggestTextarea.textarea.focus();
+    }
+  }
+
+  setAutosuggestTextarea (c) {
+    this.autosuggestTextarea = c;
+  }
+
+  handleEmojiPick (data) {
+    const position     = this.autosuggestTextarea.textarea.selectionStart;
+    this._restoreCaret = position + data.shortname.length + 1;
+    this.props.onPickEmoji(position, data);
+  }
+
+  render () {
+    const { intl, onPaste } = this.props;
+    const disabled = this.props.is_submitting;
+    const text = [this.props.spoiler_text, this.props.text].join('');
+
+    let publishText    = '';
+    let reply_to_other = false;
+
+    if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
+      publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
+    } else {
+      publishText = intl.formatMessage(messages.publish) + (this.props.privacy !== 'unlisted' ? '!' : '');
+    }
+
+    return (
+      <div className='compose-form'>
+        <Collapsable isVisible={this.props.spoiler} fullHeight={50}>
+          <div className="spoiler-input">
+            <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type="text" className="spoiler-input__input"  id='cw-spoiler-input'/>
+          </div>
+        </Collapsable>
+
+        <WarningContainer />
+
+        <ReplyIndicatorContainer />
+
+        <div className='compose-form__autosuggest-wrapper'>
+          <AutosuggestTextarea
+            ref={this.setAutosuggestTextarea}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            disabled={disabled}
+            value={this.props.text}
+            onChange={this.handleChange}
+            suggestions={this.props.suggestions}
+            onKeyDown={this.handleKeyDown}
+            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+            onSuggestionSelected={this.onSuggestionSelected}
+            onPaste={onPaste}
+          />
+
+          <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
+        </div>
+
+        <div className='compose-form__modifiers'>
+          <UploadFormContainer />
+        </div>
+
+        <div className='compose-form__buttons-wrapper'>
+          <div className='compose-form__buttons'>
+            <UploadButtonContainer />
+            <PrivacyDropdownContainer />
+            <SensitiveButtonContainer />
+            <SpoilerButtonContainer />
+          </div>
+
+          <div className='compose-form__publish'>
+            <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div>
+            <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "_").length > 500 || (text.length !==0 && text.trim().length === 0)} block /></div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+ComposeForm.propTypes = {
+  intl: PropTypes.object.isRequired,
+  text: PropTypes.string.isRequired,
+  suggestion_token: PropTypes.string,
+  suggestions: ImmutablePropTypes.list,
+  spoiler: PropTypes.bool,
+  privacy: PropTypes.string,
+  spoiler_text: PropTypes.string,
+  focusDate: PropTypes.instanceOf(Date),
+  preselectDate: PropTypes.instanceOf(Date),
+  is_submitting: PropTypes.bool,
+  is_uploading: PropTypes.bool,
+  me: PropTypes.number,
+  onChange: PropTypes.func.isRequired,
+  onSubmit: PropTypes.func.isRequired,
+  onClearSuggestions: PropTypes.func.isRequired,
+  onFetchSuggestions: PropTypes.func.isRequired,
+  onSuggestionSelected: PropTypes.func.isRequired,
+  onChangeSpoilerText: PropTypes.func.isRequired,
+  onPaste: PropTypes.func.isRequired,
+  onPickEmoji: PropTypes.func.isRequired
+};
+
+export default injectIntl(ComposeForm);
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
new file mode 100644
index 000000000..3e0b290d6
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -0,0 +1,115 @@
+import React from 'react';
+import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
+import EmojiPicker from 'emojione-picker';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
+  emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
+  people: { id: 'emoji_button.people', defaultMessage: 'People' },
+  nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
+  food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
+  activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
+  travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
+  objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
+  symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
+  flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }
+});
+
+const settings = {
+  imageType: 'png',
+  sprites: false,
+  imagePathPNG: '/emoji/'
+};
+
+const dropdownStyle = {
+  position: 'absolute',
+  right: '5px',
+  top: '5px'
+};
+
+const dropdownTriggerStyle = {
+  display: 'block',
+  fontSize: '24px',
+  lineHeight: '24px',
+  marginLeft: '2px',
+  width: '24px'
+}
+
+class EmojiPickerDropdown extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.setRef = this.setRef.bind(this);
+    this.handleChange = this.handleChange.bind(this);
+  }
+
+  setRef (c) {
+    this.dropdown = c;
+  }
+
+  handleChange (data) {
+    this.dropdown.hide();
+    this.props.onPickEmoji(data);
+  }
+
+  render () {
+    const { intl } = this.props;
+
+    const categories = {
+      people: {
+        title: intl.formatMessage(messages.people),
+        emoji: 'smile',
+      },
+      nature: {
+        title: intl.formatMessage(messages.nature),
+        emoji: 'hamster',
+      },
+      food: {
+        title: intl.formatMessage(messages.food),
+        emoji: 'pizza',
+      },
+      activity: {
+        title: intl.formatMessage(messages.activity),
+        emoji: 'soccer',
+      },
+      travel: {
+        title: intl.formatMessage(messages.travel),
+        emoji: 'earth_americas',
+      },
+      objects: {
+        title: intl.formatMessage(messages.objects),
+        emoji: 'bulb',
+      },
+      symbols: {
+        title: intl.formatMessage(messages.symbols),
+        emoji: 'clock9',
+      },
+      flags: {
+        title: intl.formatMessage(messages.flags),
+        emoji: 'flag_gb',
+      }
+    }
+
+    return (
+      <Dropdown ref={this.setRef} style={dropdownStyle}>
+        <DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)} style={dropdownTriggerStyle}>
+          <img draggable="false" className="emojione" alt="🙂" src="/emoji/1f602.svg" />
+        </DropdownTrigger>
+
+        <DropdownContent className='dropdown__left'>
+          <EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} categories={categories} search={true} />
+        </DropdownContent>
+      </Dropdown>
+    );
+  }
+
+}
+
+EmojiPickerDropdown.propTypes = {
+  intl: PropTypes.object.isRequired,
+  onPickEmoji: PropTypes.func.isRequired
+};
+
+export default injectIntl(EmojiPickerDropdown);
diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js
new file mode 100644
index 000000000..aec8f6153
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from '../../../components/avatar';
+import IconButton from '../../../components/icon_button';
+import DisplayName from '../../../components/display_name';
+import Permalink from '../../../components/permalink';
+import { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class NavigationBar extends ImmutablePureComponent {
+
+  render () {
+    return (
+      <div className='navigation-bar'>
+        <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+          <Avatar src={this.props.account.get('avatar')} animate size={40} />
+        </Permalink>
+
+        <div className='navigation-bar__profile'>
+          <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+            <strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong>
+          </Permalink>
+
+          <a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+NavigationBar.propTypes = {
+  account: ImmutablePropTypes.map.isRequired
+};
+
+export default NavigationBar;
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
new file mode 100644
index 000000000..b77d55f4d
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -0,0 +1,105 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+
+const messages = defineMessages({
+  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+  public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
+  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
+  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+  private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
+  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
+  change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }
+});
+
+const iconStyle = {
+  height: null,
+  lineHeight: '27px'
+}
+
+class PrivacyDropdown extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      open: false
+    };
+    this.handleToggle = this.handleToggle.bind(this);
+    this.handleClick = this.handleClick.bind(this);
+    this.onGlobalClick = this.onGlobalClick.bind(this);
+    this.setRef = this.setRef.bind(this);
+  }
+
+  handleToggle () {
+    this.setState({ open: !this.state.open });
+  }
+
+  handleClick (value, e) {
+    e.preventDefault();
+    this.setState({ open: false });
+    this.props.onChange(value);
+  }
+
+  onGlobalClick (e) {
+    if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
+      this.setState({ open: false });
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('click', this.onGlobalClick);
+    window.addEventListener('touchstart', this.onGlobalClick);
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('click', this.onGlobalClick);
+    window.removeEventListener('touchstart', this.onGlobalClick);
+  }
+
+  setRef (c) {
+    this.node = c;
+  }
+
+  render () {
+    const { value, onChange, intl } = this.props;
+    const { open } = this.state;
+
+    const options = [
+      { icon: 'globe', value: 'public', shortText: intl.formatMessage(messages.public_short), longText: intl.formatMessage(messages.public_long) },
+      { icon: 'unlock-alt', value: 'unlisted', shortText: intl.formatMessage(messages.unlisted_short), longText: intl.formatMessage(messages.unlisted_long) },
+      { icon: 'lock', value: 'private', shortText: intl.formatMessage(messages.private_short), longText: intl.formatMessage(messages.private_long) },
+      { icon: 'envelope', value: 'direct', shortText: intl.formatMessage(messages.direct_short), longText: intl.formatMessage(messages.direct_long) }
+    ];
+
+    const valueOption = options.find(item => item.value === value);
+
+    return (
+      <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}>
+        <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle}/></div>
+        <div className='privacy-dropdown__dropdown'>
+          {options.map(item =>
+            <div role='button' tabIndex='0' key={item.value} onClick={this.handleClick.bind(this, item.value)} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}>
+              <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div>
+              <div className='privacy-dropdown__option__content'>
+                <strong>{item.shortText}</strong>
+                {item.longText}
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+PrivacyDropdown.propTypes = {
+  value: PropTypes.string.isRequired,
+  onChange: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(PrivacyDropdown);
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js
new file mode 100644
index 000000000..e53831b60
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from '../../../components/avatar';
+import IconButton from '../../../components/icon_button';
+import DisplayName from '../../../components/display_name';
+import emojify from '../../../emoji';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }
+});
+
+class ReplyIndicator extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+    this.handleAccountClick = this.handleAccountClick.bind(this);
+  }
+
+  handleClick () {
+    this.props.onCancel();
+  }
+
+  handleAccountClick (e) {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+  }
+
+  render () {
+    const { status, intl } = this.props;
+
+    if (!status) {
+      return null;
+    }
+
+    const content  = { __html: emojify(status.get('content')) };
+
+    return (
+      <div className='reply-indicator'>
+        <div className='reply-indicator__header'>
+          <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
+
+          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
+            <div className='reply-indicator__display-avatar'><Avatar size={24} src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} /></div>
+            <DisplayName account={status.get('account')} />
+          </a>
+        </div>
+
+        <div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
+      </div>
+    );
+  }
+
+}
+
+ReplyIndicator.contextTypes = {
+  router: PropTypes.object
+};
+
+ReplyIndicator.propTypes = {
+  status: ImmutablePropTypes.map,
+  onCancel: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(ReplyIndicator);
diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js
new file mode 100644
index 000000000..61ae9ce23
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/search.js
@@ -0,0 +1,82 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+  placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }
+});
+
+class Search extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleChange = this.handleChange.bind(this);
+    this.handleKeyDown = this.handleKeyDown.bind(this);
+    this.handleFocus = this.handleFocus.bind(this);
+    this.handleClear = this.handleClear.bind(this);
+  }
+
+  handleChange (e) {
+    this.props.onChange(e.target.value);
+  }
+
+  handleClear (e) {
+    e.preventDefault();
+
+    if (this.props.value.length > 0 || this.props.submitted) {
+      this.props.onClear();
+    }
+  }
+
+  handleKeyDown (e) {
+    if (e.key === 'Enter') {
+      e.preventDefault();
+      this.props.onSubmit();
+    }
+  }
+
+  noop () {
+
+  }
+
+  handleFocus () {
+    this.props.onShow();
+  }
+
+  render () {
+    const { intl, value, submitted } = this.props;
+    const hasValue = value.length > 0 || submitted;
+
+    return (
+      <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 role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
+          <i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
+          <i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+Search.propTypes = {
+  value: PropTypes.string.isRequired,
+  submitted: PropTypes.bool,
+  onChange: PropTypes.func.isRequired,
+  onSubmit: PropTypes.func.isRequired,
+  onClear: PropTypes.func.isRequired,
+  onShow: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(Search);
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
new file mode 100644
index 000000000..79e880f0a
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/search_results.js
@@ -0,0 +1,67 @@
+import React from 'react';
+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';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class SearchResults extends ImmutablePureComponent {
+
+  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, number} {count, plural, one {result} other {results}}' values={{ count }} />
+        </div>
+
+        {accounts}
+        {statuses}
+        {hashtags}
+      </div>
+    );
+  }
+
+}
+
+SearchResults.propTypes = {
+  results: ImmutablePropTypes.map.isRequired
+};
+
+export default SearchResults;
diff --git a/app/javascript/mastodon/features/compose/components/text_icon_button.js b/app/javascript/mastodon/features/compose/components/text_icon_button.js
new file mode 100644
index 000000000..bcfa21090
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/text_icon_button.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class TextIconButton extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick (e) {
+    e.preventDefault();
+    this.props.onClick();
+  }
+
+  render () {
+    const { label, title, active, ariaControls } = this.props;
+
+    return (
+      <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}>
+        {label}
+      </button>
+    );
+  }
+
+}
+
+TextIconButton.propTypes = {
+  label: PropTypes.string.isRequired,
+  title: PropTypes.string,
+  active: PropTypes.bool,
+  onClick: PropTypes.func.isRequired,
+  ariaControls: PropTypes.string
+};
+
+export default TextIconButton;
diff --git a/app/javascript/mastodon/features/compose/components/upload_button.js b/app/javascript/mastodon/features/compose/components/upload_button.js
new file mode 100644
index 000000000..15ec2edd6
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload_button.js
@@ -0,0 +1,61 @@
+import React from 'react';
+import IconButton from '../../../components/icon_button';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  upload: { id: 'upload_button.label', defaultMessage: 'Add media' }
+});
+
+
+const iconStyle = {
+  height: null,
+  lineHeight: '27px'
+}
+
+class UploadButton extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleChange = this.handleChange.bind(this);
+    this.handleClick = this.handleClick.bind(this);
+    this.setRef = this.setRef.bind(this);
+  }
+
+  handleChange (e) {
+    if (e.target.files.length > 0) {
+      this.props.onSelectFile(e.target.files);
+    }
+  }
+
+  handleClick () {
+    this.fileElement.click();
+  }
+
+  setRef (c) {
+    this.fileElement = c;
+  }
+
+  render () {
+
+    const { intl, resetFileKey, disabled } = this.props;
+
+    return (
+      <div className='compose-form__upload-button'>
+        <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle}/>
+        <input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} />
+      </div>
+    );
+  }
+
+}
+
+UploadButton.propTypes = {
+  disabled: PropTypes.bool,
+  onSelectFile: PropTypes.func.isRequired,
+  style: PropTypes.object,
+  resetFileKey: PropTypes.number,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(UploadButton);
diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js
new file mode 100644
index 000000000..8e48538da
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload_form.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from '../../../components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import UploadProgressContainer from '../containers/upload_progress_container';
+import { Motion, spring } from 'react-motion';
+
+const messages = defineMessages({
+  undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }
+});
+
+class UploadForm extends React.PureComponent {
+
+  render () {
+    const { intl, media } = this.props;
+
+    const uploads = media.map(attachment =>
+      <div className='compose-form__upload' key={attachment.get('id')}>
+        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
+          {({ scale }) =>
+            <div className='compose-form__upload-thumbnail' style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${attachment.get('preview_url')})` }}>
+              <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} />
+            </div>
+          }
+        </Motion>
+      </div>
+    );
+
+    return (
+      <div className='compose-form__upload-wrapper'>
+        <UploadProgressContainer />
+        <div className='compose-form__uploads-wrapper'>{uploads}</div>
+      </div>
+    );
+  }
+
+}
+
+UploadForm.propTypes = {
+  media: ImmutablePropTypes.list.isRequired,
+  onRemoveFile: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(UploadForm);
diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.js b/app/javascript/mastodon/features/compose/components/upload_progress.js
new file mode 100644
index 000000000..bb2932a55
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload_progress.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Motion, spring } from 'react-motion';
+import { FormattedMessage } from 'react-intl';
+
+class UploadProgress extends React.PureComponent {
+
+  render () {
+    const { active, progress } = this.props;
+
+    if (!active) {
+      return null;
+    }
+
+    return (
+      <div className='upload-progress'>
+        <div className='upload-progress__icon'>
+          <i className='fa fa-upload' />
+        </div>
+
+        <div className='upload-progress__message'>
+          <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
+
+          <div className='upload-progress__backdrop'>
+            <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
+              {({ width }) =>
+                <div className='upload-progress__tracker' style={{ width: `${width}%` }} />
+              }
+            </Motion>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+UploadProgress.propTypes = {
+  active: PropTypes.bool,
+  progress: PropTypes.number
+};
+
+export default UploadProgress;
diff --git a/app/javascript/mastodon/features/compose/components/warning.js b/app/javascript/mastodon/features/compose/components/warning.js
new file mode 100644
index 000000000..6ad00b691
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/warning.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class Warning extends React.PureComponent {
+
+  constructor (props) {
+    super(props);
+  }
+
+  render () {
+    const { message } = this.props;
+
+    return (
+      <div className='compose-form__warning'>
+        {message}
+      </div>
+    );
+  }
+
+}
+
+Warning.propTypes = {
+  message: PropTypes.node.isRequired
+};
+
+export default Warning;
diff --git a/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js b/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js
new file mode 100644
index 000000000..de76a364d
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import AutosuggestAccount from '../components/autosuggest_account';
+import { makeGetAccount } from '../../../selectors';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { id }) => ({
+    account: getAccount(state, id)
+  });
+
+  return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(AutosuggestAccount);
diff --git a/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js b/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js
new file mode 100644
index 000000000..ef46eb09c
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import AutosuggestStatus from '../components/autosuggest_status';
+import { makeGetStatus } from '../../../selectors';
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, { id }) => ({
+    status: getStatus(state, id)
+  });
+
+  return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(AutosuggestStatus);
diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
new file mode 100644
index 000000000..892183b83
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -0,0 +1,64 @@
+import { connect } from 'react-redux';
+import ComposeForm from '../components/compose_form';
+import { uploadCompose } from '../../../actions/compose';
+import {
+  changeCompose,
+  submitCompose,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  selectComposeSuggestion,
+  changeComposeSpoilerText,
+  insertEmojiCompose
+} from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+  text: state.getIn(['compose', 'text']),
+  suggestion_token: state.getIn(['compose', 'suggestion_token']),
+  suggestions: state.getIn(['compose', 'suggestions']),
+  spoiler: state.getIn(['compose', 'spoiler']),
+  spoiler_text: state.getIn(['compose', 'spoiler_text']),
+  privacy: state.getIn(['compose', 'privacy']),
+  focusDate: state.getIn(['compose', 'focusDate']),
+  preselectDate: state.getIn(['compose', 'preselectDate']),
+  is_submitting: state.getIn(['compose', 'is_submitting']),
+  is_uploading: state.getIn(['compose', 'is_uploading']),
+  me: state.getIn(['compose', 'me'])
+});
+
+const mapDispatchToProps = (dispatch) => ({
+
+  onChange (text) {
+    dispatch(changeCompose(text));
+  },
+
+  onSubmit () {
+    dispatch(submitCompose());
+  },
+
+  onClearSuggestions () {
+    dispatch(clearComposeSuggestions());
+  },
+
+  onFetchSuggestions (token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
+
+  onSuggestionSelected (position, token, accountId) {
+    dispatch(selectComposeSuggestion(position, token, accountId));
+  },
+
+  onChangeSpoilerText (checked) {
+    dispatch(changeComposeSpoilerText(checked));
+  },
+
+  onPaste (files) {
+    dispatch(uploadCompose(files));
+  },
+
+  onPickEmoji (position, data) {
+    dispatch(insertEmojiCompose(position, data));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/javascript/mastodon/features/compose/containers/navigation_container.js b/app/javascript/mastodon/features/compose/containers/navigation_container.js
new file mode 100644
index 000000000..0006608da
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/navigation_container.js
@@ -0,0 +1,10 @@
+import { connect }   from 'react-redux';
+import NavigationBar from '../components/navigation_bar';
+
+const mapStateToProps = (state, props) => {
+  return {
+    account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
+  };
+};
+
+export default connect(mapStateToProps)(NavigationBar);
diff --git a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
new file mode 100644
index 000000000..1eee8f84c
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import PrivacyDropdown from '../components/privacy_dropdown';
+import { changeComposeVisibility } from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['compose', 'privacy'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeComposeVisibility(value));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
new file mode 100644
index 000000000..39b48f3b6
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { cancelReplyCompose } from '../../../actions/compose';
+import { makeGetStatus } from '../../../selectors';
+import ReplyIndicator from '../components/reply_indicator';
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onCancel () {
+    dispatch(cancelReplyCompose());
+  }
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
diff --git a/app/javascript/mastodon/features/compose/containers/search_container.js b/app/javascript/mastodon/features/compose/containers/search_container.js
new file mode 100644
index 000000000..906c0c28c
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/search_container.js
@@ -0,0 +1,35 @@
+import { connect } from 'react-redux';
+import {
+  changeSearch,
+  clearSearch,
+  submitSearch,
+  showSearch
+} from '../../../actions/search';
+import Search from '../components/search';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['search', 'value']),
+  submitted: state.getIn(['search', 'submitted'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeSearch(value));
+  },
+
+  onClear () {
+    dispatch(clearSearch());
+  },
+
+  onSubmit () {
+    dispatch(submitSearch());
+  },
+
+  onShow () {
+    dispatch(showSearch());
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Search);
diff --git a/app/javascript/mastodon/features/compose/containers/search_results_container.js b/app/javascript/mastodon/features/compose/containers/search_results_container.js
new file mode 100644
index 000000000..e5911fd38
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/search_results_container.js
@@ -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/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
new file mode 100644
index 000000000..78e40e048
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import TextIconButton from '../components/text_icon_button';
+import { changeComposeSensitivity } from '../../../actions/compose';
+import { Motion, spring } from 'react-motion';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' }
+});
+
+const mapStateToProps = state => ({
+  visible: state.getIn(['compose', 'media_attachments']).size > 0,
+  active: state.getIn(['compose', 'sensitive'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSensitivity());
+  }
+
+});
+
+class SensitiveButton extends React.PureComponent {
+
+  render () {
+    const { visible, active, onClick, intl } = this.props;
+
+    return (
+      <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
+        {({ scale }) =>
+          <div style={{ display: visible ? 'block' : 'none', transform: `translateZ(0) scale(${scale})` }}>
+            <TextIconButton onClick={onClick} label='NSFW' title={intl.formatMessage(messages.title)} active={active} />
+          </div>
+        }
+      </Motion>
+    );
+  }
+
+}
+
+SensitiveButton.propTypes = {
+  visible: PropTypes.bool,
+  active: PropTypes.bool,
+  onClick: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js b/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js
new file mode 100644
index 000000000..b1c80fe19
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import TextIconButton from '../components/text_icon_button';
+import { changeComposeSpoilerness } from '../../../actions/compose';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' }
+});
+
+const mapStateToProps = (state, { intl }) => ({
+  label: 'CW',
+  title: intl.formatMessage(messages.title),
+  active: state.getIn(['compose', 'spoiler']),
+  ariaControls: 'cw-spoiler-input'
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSpoilerness());
+  }
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));
diff --git a/app/javascript/mastodon/features/compose/containers/upload_button_container.js b/app/javascript/mastodon/features/compose/containers/upload_button_container.js
new file mode 100644
index 000000000..78e5312f5
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/upload_button_container.js
@@ -0,0 +1,18 @@
+import { connect } from 'react-redux';
+import UploadButton from '../components/upload_button';
+import { uploadCompose } from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+  disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
+  resetFileKey: state.getIn(['compose', 'resetFileKey'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onSelectFile (files) {
+    dispatch(uploadCompose(files));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);
diff --git a/app/javascript/mastodon/features/compose/containers/upload_form_container.js b/app/javascript/mastodon/features/compose/containers/upload_form_container.js
new file mode 100644
index 000000000..a6a202e17
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/upload_form_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import UploadForm from '../components/upload_form';
+import { undoUploadCompose } from '../../../actions/compose';
+
+const mapStateToProps = (state, props) => ({
+  media: state.getIn(['compose', 'media_attachments']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onRemoveFile (media_id) {
+    dispatch(undoUploadCompose(media_id));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(UploadForm);
diff --git a/app/javascript/mastodon/features/compose/containers/upload_progress_container.js b/app/javascript/mastodon/features/compose/containers/upload_progress_container.js
new file mode 100644
index 000000000..b0f1d4d19
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/upload_progress_container.js
@@ -0,0 +1,9 @@
+import { connect } from 'react-redux';
+import UploadProgress from '../components/upload_progress';
+
+const mapStateToProps = (state, props) => ({
+  active: state.getIn(['compose', 'is_uploading']),
+  progress: state.getIn(['compose', 'progress'])
+});
+
+export default connect(mapStateToProps)(UploadProgress);
diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.js b/app/javascript/mastodon/features/compose/containers/warning_container.js
new file mode 100644
index 000000000..bf5e6a5f8
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/warning_container.js
@@ -0,0 +1,49 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import Warning from '../components/warning';
+import { createSelector } from 'reselect';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig));
+
+const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => {
+  return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : [];
+});
+
+const mapStateToProps = state => {
+  const mentionedUsernames = getMentionedUsernames(state);
+  const mentionedUsernamesWithDomains = getMentionedDomains(state);
+
+  return {
+    needsLeakWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null,
+    mentionedDomains: mentionedUsernamesWithDomains,
+    needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked'])
+  };
+};
+
+const WarningWrapper = ({ needsLeakWarning, needsLockWarning, mentionedDomains }) => {
+  if (needsLockWarning) {
+    return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
+  } else if (needsLeakWarning) {
+    return (
+      <Warning
+        message={<FormattedMessage
+          id='compose_form.privacy_disclaimer'
+          defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? 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.'
+          values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.length }}
+        />}
+      />
+    );
+  }
+
+  return null;
+};
+
+WarningWrapper.propTypes = {
+  needsLeakWarning: PropTypes.bool,
+  needsLockWarning: PropTypes.bool,
+  mentionedDomains: PropTypes.array.isRequired,
+};
+
+export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
new file mode 100644
index 000000000..68d779c6c
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -0,0 +1,86 @@
+import React from 'react';
+import ComposeFormContainer from './containers/compose_form_container';
+import UploadFormContainer from './containers/upload_form_container';
+import NavigationContainer from './containers/navigation_container';
+import PropTypes from 'prop-types';
+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: 'Federated timeline' },
+  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'])
+});
+
+class Compose extends React.PureComponent {
+
+  componentDidMount () {
+    this.props.dispatch(mountCompose());
+  }
+
+  componentWillUnmount () {
+    this.props.dispatch(unmountCompose());
+  }
+
+  render () {
+    const { withHeader, showSearch, intl } = this.props;
+
+    let header = '';
+
+    if (withHeader) {
+      header = (
+        <div className='drawer__header'>
+          <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)}><i role="img" aria-label={intl.formatMessage(messages.start)} className='fa fa-fw fa-asterisk' /></Link>
+          <Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)}><i role="img" aria-label={intl.formatMessage(messages.community)} className='fa fa-fw fa-users' /></Link>
+          <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)}><i role="img" aria-label={intl.formatMessage(messages.public)} className='fa fa-fw fa-globe' /></Link>
+          <a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)}><i role="img" aria-label={intl.formatMessage(messages.preferences)} className='fa fa-fw fa-cog' /></a>
+          <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)}><i role="img" aria-label={intl.formatMessage(messages.logout)} className='fa fa-fw fa-sign-out' /></a>
+        </div>
+      );
+    }
+
+    return (
+      <div className='drawer'>
+        {header}
+
+        <SearchContainer />
+
+        <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>
+    );
+  }
+
+}
+
+Compose.propTypes = {
+  dispatch: PropTypes.func.isRequired,
+  withHeader: PropTypes.bool,
+  showSearch: PropTypes.bool,
+  intl: PropTypes.object.isRequired
+};
+
+export default connect(mapStateToProps)(injectIntl(Compose));
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js
new file mode 100644
index 000000000..995f61f17
--- /dev/null
+++ b/app/javascript/mastodon/features/favourited_statuses/index.js
@@ -0,0 +1,67 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
+import Column from '../ui/components/column';
+import StatusList from '../../components/status_list';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.favourites', defaultMessage: 'Favourites' }
+});
+
+const mapStateToProps = state => ({
+  statusIds: state.getIn(['status_lists', 'favourites', 'items']),
+  loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
+  me: state.getIn(['meta', 'me'])
+});
+
+class Favourites extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScrollToBottom = this.handleScrollToBottom.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchFavouritedStatuses());
+  }
+
+  handleScrollToBottom () {
+    this.props.dispatch(expandFavouritedStatuses());
+  }
+
+  render () {
+    const { statusIds, loaded, intl, me } = this.props;
+
+    if (!loaded) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column icon='star' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <StatusList {...this.props} scrollKey='favourited_statuses' onScrollToBottom={this.handleScrollToBottom} />
+      </Column>
+    );
+  }
+
+}
+
+Favourites.propTypes = {
+  dispatch: PropTypes.func.isRequired,
+  statusIds: ImmutablePropTypes.list.isRequired,
+  loaded: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  me: PropTypes.number.isRequired
+};
+
+export default connect(mapStateToProps)(injectIntl(Favourites));
diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js
new file mode 100644
index 000000000..c916aa176
--- /dev/null
+++ b/app/javascript/mastodon/features/favourites/index.js
@@ -0,0 +1,61 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { fetchFavourites } from '../../actions/interactions';
+import { ScrollContainer } from 'react-router-scroll';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import ColumnBackButton from '../../components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'favourited_by', Number(props.params.statusId)])
+});
+
+class Favourites extends ImmutablePureComponent {
+
+  componentWillMount () {
+    this.props.dispatch(fetchFavourites(Number(this.props.params.statusId)));
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+      this.props.dispatch(fetchFavourites(Number(nextProps.params.statusId)));
+    }
+  }
+
+  render () {
+    const { accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='favourites'>
+          <div className='scrollable'>
+            {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+Favourites.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  accountIds: ImmutablePropTypes.list
+};
+
+export default connect(mapStateToProps)(Favourites);
diff --git a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
new file mode 100644
index 000000000..9fe464628
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Permalink from '../../../components/permalink';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import emojify from '../../../emoji';
+import IconButton from '../../../components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
+  reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }
+});
+
+class AccountAuthorize extends ImmutablePureComponent {
+
+  render () {
+    const { intl, account, onAuthorize, onReject } = this.props;
+    const content = { __html: emojify(account.get('note')) };
+
+    return (
+      <div className='account-authorize__wrapper'>
+        <div className='account-authorize'>
+          <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'>
+            <div className='account-authorize__avatar'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={48} /></div>
+            <DisplayName account={account} />
+          </Permalink>
+
+          <div className='account__header__content' dangerouslySetInnerHTML={content} />
+        </div>
+
+        <div className='account--panel'>
+          <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div>
+          <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+AccountAuthorize.propTypes = {
+  account: ImmutablePropTypes.map.isRequired,
+  onAuthorize: PropTypes.func.isRequired,
+  onReject: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(AccountAuthorize);
diff --git a/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js b/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js
new file mode 100644
index 000000000..da1e5eaa1
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux';
+import { makeGetAccount } from '../../../selectors';
+import AccountAuthorize from '../components/account_authorize';
+import { authorizeFollowRequest, rejectFollowRequest } from '../../../actions/accounts';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, props) => ({
+    account: getAccount(state, props.id)
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+  onAuthorize (account) {
+    dispatch(authorizeFollowRequest(id));
+  },
+
+  onReject (account) {
+    dispatch(rejectFollowRequest(id));
+  }
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize);
diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js
new file mode 100644
index 000000000..c88de48c0
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_requests/index.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import AccountAuthorizeContainer from './containers/account_authorize_container';
+import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'follow_requests', 'items'])
+});
+
+class FollowRequests extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScroll = this.handleScroll.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchFollowRequests());
+  }
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight) {
+      this.props.dispatch(expandFollowRequests());
+    }
+  }
+
+  render () {
+    const { intl, accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column icon='users' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <ScrollContainer scrollKey='follow_requests'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            {accountIds.map(id =>
+              <AccountAuthorizeContainer key={id} id={id} />
+            )}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+}
+
+FollowRequests.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  accountIds: ImmutablePropTypes.list,
+  intl: PropTypes.object.isRequired
+};
+
+export default connect(mapStateToProps)(injectIntl(FollowRequests));
diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js
new file mode 100644
index 000000000..8a1105b55
--- /dev/null
+++ b/app/javascript/mastodon/features/followers/index.js
@@ -0,0 +1,92 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import {
+  fetchAccount,
+  fetchFollowers,
+  expandFollowers
+} from '../../actions/accounts';
+import { ScrollContainer } from 'react-router-scroll';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import HeaderContainer from '../account_timeline/containers/header_container';
+import LoadMore from '../../components/load_more';
+import ColumnBackButton from '../../components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'items'])
+});
+
+class Followers extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScroll = this.handleScroll.bind(this);
+    this.handleLoadMore = this.handleLoadMore.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
+    this.props.dispatch(fetchFollowers(Number(this.props.params.accountId)));
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
+      this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId)));
+    }
+  }
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight) {
+      this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
+    }
+  }
+
+  handleLoadMore (e) {
+    e.preventDefault();
+    this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
+  }
+
+  render () {
+    const { accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='followers'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            <div className='followers'>
+              <HeaderContainer accountId={this.props.params.accountId} />
+              {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
+              <LoadMore onClick={this.handleLoadMore} />
+            </div>
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+Followers.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  accountIds: ImmutablePropTypes.list
+};
+
+export default connect(mapStateToProps)(Followers);
diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js
new file mode 100644
index 000000000..f181fe727
--- /dev/null
+++ b/app/javascript/mastodon/features/following/index.js
@@ -0,0 +1,92 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import {
+  fetchAccount,
+  fetchFollowing,
+  expandFollowing
+} from '../../actions/accounts';
+import { ScrollContainer } from 'react-router-scroll';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import HeaderContainer from '../account_timeline/containers/header_container';
+import LoadMore from '../../components/load_more';
+import ColumnBackButton from '../../components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId), 'items'])
+});
+
+class Following extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScroll = this.handleScroll.bind(this);
+    this.handleLoadMore = this.handleLoadMore.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
+    this.props.dispatch(fetchFollowing(Number(this.props.params.accountId)));
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
+      this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId)));
+    }
+  }
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight) {
+      this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
+    }
+  }
+
+  handleLoadMore (e) {
+    e.preventDefault();
+    this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
+  }
+
+  render () {
+    const { accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='following'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            <div className='following'>
+              <HeaderContainer accountId={this.props.params.accountId} />
+              {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
+              <LoadMore onClick={this.handleLoadMore} />
+            </div>
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+Following.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  accountIds: ImmutablePropTypes.list
+};
+
+export default connect(mapStateToProps)(Following);
diff --git a/app/javascript/mastodon/features/generic_not_found/index.js b/app/javascript/mastodon/features/generic_not_found/index.js
new file mode 100644
index 000000000..0290be47f
--- /dev/null
+++ b/app/javascript/mastodon/features/generic_not_found/index.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import Column from '../ui/components/column';
+import MissingIndicator from '../../components/missing_indicator';
+
+const GenericNotFound = () => (
+  <Column>
+    <MissingIndicator />
+  </Column>
+);
+
+export default GenericNotFound;
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
new file mode 100644
index 000000000..6bdff2fba
--- /dev/null
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -0,0 +1,73 @@
+import React from 'react';
+import Column from '../ui/components/column';
+import ColumnLink from '../ui/components/column_link';
+import ColumnSubheading from '../ui/components/column_subheading';
+import { Link } from 'react-router';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+  public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
+  navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation'},
+  settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings'},
+  community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+  sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+  blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+  mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
+  info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' }
+});
+
+const mapStateToProps = state => ({
+  me: state.getIn(['accounts', state.getIn(['meta', 'me'])])
+});
+
+class GettingStarted extends ImmutablePureComponent {
+
+  render () {
+    const { intl, me } = this.props;
+
+    let followRequests = '';
+
+    if (me.get('locked')) {
+      followRequests = <ColumnLink icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />;
+    }
+
+    return (
+      <Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile={true}>
+        <div className='getting-started__wrapper'>
+          <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)}/>
+          <ColumnLink icon='users' hideOnMobile={true} text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />
+          <ColumnLink icon='globe' hideOnMobile={true} text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />
+          <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
+          {followRequests}
+          <ColumnLink icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
+          <ColumnLink icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
+          <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)}/>
+          <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
+          <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
+          <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
+        </div>
+
+        <div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}>
+          <div className='static-content getting-started'>
+            <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/documentation/blob/master/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p>
+          </div>
+        </div>
+      </Column>
+    );
+  }
+}
+
+GettingStarted.propTypes = {
+  intl: PropTypes.object.isRequired,
+  me: ImmutablePropTypes.map.isRequired
+};
+
+export default connect(mapStateToProps)(injectIntl(GettingStarted));
diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js
new file mode 100644
index 000000000..f5134decf
--- /dev/null
+++ b/app/javascript/mastodon/features/hashtag_timeline/index.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../ui/components/column';
+import {
+  refreshTimeline,
+  updateTimeline,
+  deleteFromTimelines
+} from '../../actions/timelines';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import { FormattedMessage } from 'react-intl';
+import createStream from '../../stream';
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0,
+  streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
+  accessToken: state.getIn(['meta', 'access_token'])
+});
+
+class HashtagTimeline extends React.PureComponent {
+
+  _subscribe (dispatch, id) {
+    const { streamingAPIBaseURL, accessToken } = this.props;
+
+    this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, {
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline('tag', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
+        }
+      }
+
+    });
+  }
+
+  _unsubscribe () {
+    if (typeof this.subscription !== 'undefined') {
+      this.subscription.close();
+      this.subscription = null;
+    }
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    const { id } = this.props.params;
+
+    dispatch(refreshTimeline('tag', id));
+    this._subscribe(dispatch, id);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.id !== this.props.params.id) {
+      this.props.dispatch(refreshTimeline('tag', nextProps.params.id));
+      this._unsubscribe();
+      this._subscribe(this.props.dispatch, nextProps.params.id);
+    }
+  }
+
+  componentWillUnmount () {
+    this._unsubscribe();
+  }
+
+  render () {
+    const { id, hasUnread } = this.props.params;
+
+    return (
+      <Column icon='hashtag' active={hasUnread} heading={id}>
+        <ColumnBackButtonSlim />
+        <StatusListContainer scrollKey='hashtag_timeline' type='tag' id={id} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} />
+      </Column>
+    );
+  }
+
+}
+
+HashtagTimeline.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  streamingAPIBaseURL: PropTypes.string.isRequired,
+  accessToken: PropTypes.string.isRequired,
+  hasUnread: PropTypes.bool
+};
+
+export default connect(mapStateToProps)(HashtagTimeline);
diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.js b/app/javascript/mastodon/features/home_timeline/components/column_settings.js
new file mode 100644
index 000000000..460221fc3
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/components/column_settings.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnCollapsable from '../../../components/column_collapsable';
+import SettingToggle from '../../notifications/components/setting_toggle';
+import SettingText from './setting_text';
+
+const messages = defineMessages({
+  filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
+  settings: { id: 'home.settings', defaultMessage: 'Column settings' }
+});
+
+class ColumnSettings extends React.PureComponent {
+
+  render () {
+    const { settings, onChange, onSave, intl } = this.props;
+
+    return (
+      <ColumnCollapsable icon='sliders' title={intl.formatMessage(messages.settings)} fullHeight={209} onCollapse={onSave}>
+        <div className='column-settings__outer'>
+          <span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} />
+          </div>
+
+          <div className='column-settings__row'>
+            <SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
+          </div>
+
+          <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
+
+          <div className='column-settings__row'>
+            <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
+          </div>
+        </div>
+      </ColumnCollapsable>
+    );
+  }
+
+}
+
+ColumnSettings.propTypes = {
+  settings: ImmutablePropTypes.map.isRequired,
+  onChange: PropTypes.func.isRequired,
+  onSave: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+}
+
+export default injectIntl(ColumnSettings);
diff --git a/app/javascript/mastodon/features/home_timeline/components/setting_text.js b/app/javascript/mastodon/features/home_timeline/components/setting_text.js
new file mode 100644
index 000000000..dfa2939b7
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/components/setting_text.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+class SettingText extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleChange = this.handleChange.bind(this);
+  }
+
+  handleChange (e) {
+    this.props.onChange(this.props.settingKey, e.target.value)
+  }
+
+  render () {
+    const { settings, settingKey, label } = this.props;
+
+    return (
+      <input
+        className='setting-text'
+        value={settings.getIn(settingKey)}
+        onChange={this.handleChange}
+        placeholder={label}
+      />
+    );
+  }
+
+}
+
+SettingText.propTypes = {
+  settings: ImmutablePropTypes.map.isRequired,
+  settingKey: PropTypes.array.isRequired,
+  label: PropTypes.string.isRequired,
+  onChange: PropTypes.func.isRequired
+};
+
+export default SettingText;
diff --git a/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..3b3ce19bc
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting, saveSettings } from '../../../actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'home'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (key, checked) {
+    dispatch(changeSetting(['home', ...key], checked));
+  },
+
+  onSave () {
+    dispatch(saveSettings());
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js
new file mode 100644
index 000000000..d7c438122
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/index.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../ui/components/column';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { Link } from 'react-router';
+
+const messages = defineMessages({
+  title: { id: 'column.home', defaultMessage: 'Home' }
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0
+});
+
+class HomeTimeline extends React.PureComponent {
+
+  render () {
+    const { intl, hasUnread } = this.props;
+
+    return (
+      <Column icon='home' active={hasUnread} heading={intl.formatMessage(messages.title)}>
+        <ColumnSettingsContainer />
+        <StatusListContainer {...this.props} scrollKey='home_timeline' type='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage="You aren't following anyone yet. Visit {public} or use search to get started and meet other users." values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} />
+      </Column>
+    );
+  }
+
+}
+
+HomeTimeline.propTypes = {
+  intl: PropTypes.object.isRequired,
+  hasUnread: PropTypes.bool
+};
+
+export default connect(mapStateToProps)(injectIntl(HomeTimeline));
diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js
new file mode 100644
index 000000000..884b3b3e7
--- /dev/null
+++ b/app/javascript/mastodon/features/mutes/index.js
@@ -0,0 +1,75 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import AccountContainer from '../../containers/account_container';
+import { fetchMutes, expandMutes } from '../../actions/mutes';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.mutes', defaultMessage: 'Muted users' }
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'mutes', 'items'])
+});
+
+class Mutes extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScroll = this.handleScroll.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchMutes());
+  }
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight) {
+      this.props.dispatch(expandMutes());
+    }
+  }
+
+  render () {
+    const { intl, accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column icon='volume-off' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <ScrollContainer scrollKey='mutes'>
+          <div className='scrollable mutes' onScroll={this.handleScroll}>
+            {accountIds.map(id =>
+              <AccountContainer key={id} id={id} />
+            )}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+Mutes.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  accountIds: ImmutablePropTypes.list,
+  intl: PropTypes.object.isRequired
+};
+
+export default connect(mapStateToProps)(injectIntl(Mutes));
diff --git a/app/javascript/mastodon/features/notifications/components/clear_column_button.js b/app/javascript/mastodon/features/notifications/components/clear_column_button.js
new file mode 100644
index 000000000..a948bff46
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/clear_column_button.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  clear: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }
+});
+
+class ClearColumnButton extends React.Component {
+
+  render () {
+    const { intl } = this.props;
+
+    return (
+      <div role='button' title={intl.formatMessage(messages.clear)} className='column-icon column-icon-clear' tabIndex='0' onClick={this.props.onClick}>
+        <i className='fa fa-eraser' />
+      </div>
+    );
+  }
+}
+
+ClearColumnButton.propTypes = {
+  onClick: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(ClearColumnButton);
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
new file mode 100644
index 000000000..7d52b7dcd
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnCollapsable from '../../../components/column_collapsable';
+import SettingToggle from './setting_toggle';
+
+const messages = defineMessages({
+  settings: { id: 'notifications.settings', defaultMessage: 'Column settings' }
+});
+
+class ColumnSettings extends React.PureComponent {
+
+  render () {
+    const { settings, intl, onChange, onSave } = this.props;
+
+    const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
+    const showStr  = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
+    const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
+
+    return (
+      <ColumnCollapsable icon='sliders' title={intl.formatMessage(messages.settings)} fullHeight={616} onCollapse={onSave}>
+        <div className='column-settings__outer'>
+          <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+            <SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
+          </div>
+
+          <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+            <SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
+          </div>
+
+          <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+            <SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
+          </div>
+
+          <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+            <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
+            <SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+      </ColumnCollapsable>
+    );
+  }
+
+}
+
+ColumnSettings.propTypes = {
+  settings: ImmutablePropTypes.map.isRequired,
+  onChange: PropTypes.func.isRequired,
+  onSave: PropTypes.func.isRequired,
+  intl: PropTypes.shape({
+    formatMessage: PropTypes.func.isRequired
+  }).isRequired
+};
+
+export default injectIntl(ColumnSettings);
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
new file mode 100644
index 000000000..f54a65747
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import StatusContainer from '../../../containers/status_container';
+import AccountContainer from '../../../containers/account_container';
+import { FormattedMessage } from 'react-intl';
+import Permalink from '../../../components/permalink';
+import emojify from '../../../emoji';
+import escapeTextContentForBrowser from 'escape-html';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class Notification extends ImmutablePureComponent {
+
+  renderFollow (account, link) {
+    return (
+      <div className='notification notification-follow'>
+        <div className='notification__message'>
+          <div className='notification__favourite-icon-wrapper'>
+            <i className='fa fa-fw fa-user-plus' />
+          </div>
+
+          <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
+        </div>
+
+        <AccountContainer id={account.get('id')} withNote={false} />
+      </div>
+    );
+  }
+
+  renderMention (notification) {
+    return <StatusContainer id={notification.get('status')} />;
+  }
+
+  renderFavourite (notification, link) {
+    return (
+      <div className='notification notification-favourite'>
+        <div className='notification__message'>
+          <div className='notification__favourite-icon-wrapper'>
+            <i className='fa fa-fw fa-star star-icon'/>
+          </div>
+
+          <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
+        </div>
+
+        <StatusContainer id={notification.get('status')} muted={true} />
+      </div>
+    );
+  }
+
+  renderReblog (notification, link) {
+    return (
+      <div className='notification notification-reblog'>
+        <div className='notification__message'>
+          <div className='notification__favourite-icon-wrapper'>
+            <i className='fa fa-fw fa-retweet' />
+          </div>
+
+          <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
+        </div>
+
+        <StatusContainer id={notification.get('status')} muted={true} />
+      </div>
+    );
+  }
+
+  render () { // eslint-disable-line consistent-return
+    const { notification } = this.props;
+    const account          = notification.get('account');
+    const displayName      = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
+    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+    const link             = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
+
+    switch(notification.get('type')) {
+    case 'follow':
+      return this.renderFollow(account, link);
+    case 'mention':
+      return this.renderMention(notification);
+    case 'favourite':
+      return this.renderFavourite(notification, link);
+    case 'reblog':
+      return this.renderReblog(notification, link);
+    }
+  }
+
+}
+
+Notification.propTypes = {
+  notification: ImmutablePropTypes.map.isRequired
+};
+
+export default Notification;
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
new file mode 100644
index 000000000..080804a40
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Toggle from 'react-toggle';
+
+const SettingToggle = ({ settings, settingKey, label, onChange, htmlFor = '' }) => (
+  <label htmlFor={htmlFor} className='setting-toggle__label'>
+    <Toggle checked={settings.getIn(settingKey)} onChange={(e) => onChange(settingKey, e.target.checked)} />
+    <span className='setting-toggle'>{label}</span>
+  </label>
+);
+
+SettingToggle.propTypes = {
+  settings: ImmutablePropTypes.map.isRequired,
+  settingKey: PropTypes.array.isRequired,
+  label: PropTypes.node.isRequired,
+  onChange: PropTypes.func.isRequired,
+  htmlFor: PropTypes.string
+};
+
+export default SettingToggle;
diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
new file mode 100644
index 000000000..bc24c75e0
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting, saveSettings } from '../../../actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'notifications'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (key, checked) {
+    dispatch(changeSetting(['notifications', ...key], checked));
+  },
+
+  onSave () {
+    dispatch(saveSettings());
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js
new file mode 100644
index 000000000..4ca1b1b7b
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import { makeGetNotification } from '../../../selectors';
+import Notification from '../components/notification';
+
+const makeMapStateToProps = () => {
+  const getNotification = makeGetNotification();
+
+  const mapStateToProps = (state, props) => ({
+    notification: getNotification(state, props.notification, props.accountId)
+  });
+
+  return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(Notification);
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
new file mode 100644
index 000000000..989013cc7
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -0,0 +1,143 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from '../ui/components/column';
+import { expandNotifications, clearNotifications, scrollTopNotifications } from '../../actions/notifications';
+import NotificationContainer from './containers/notification_container';
+import { ScrollContainer } from 'react-router-scroll';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { createSelector } from 'reselect';
+import Immutable from 'immutable';
+import LoadMore from '../../components/load_more';
+import ClearColumnButton from './components/clear_column_button';
+import { openModal } from '../../actions/modal';
+
+const messages = defineMessages({
+  title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+  clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
+  clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }
+});
+
+const getNotifications = createSelector([
+  state => Immutable.List(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
+  state => state.getIn(['notifications', 'items'])
+], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
+
+const mapStateToProps = state => ({
+  notifications: getNotifications(state),
+  isLoading: state.getIn(['notifications', 'isLoading'], true),
+  isUnread: state.getIn(['notifications', 'unread']) > 0
+});
+
+class Notifications extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScroll = this.handleScroll.bind(this);
+    this.handleLoadMore = this.handleLoadMore.bind(this);
+    this.handleClear = this.handleClear.bind(this);
+    this.setRef = this.setRef.bind(this);
+  }
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+    const offset = scrollHeight - scrollTop - clientHeight;
+    this._oldScrollPosition = scrollHeight - scrollTop;
+
+    if (250 > offset && !this.props.isLoading) {
+      this.props.dispatch(expandNotifications());
+    } else if (scrollTop < 100) {
+      this.props.dispatch(scrollTopNotifications(true));
+    } else {
+      this.props.dispatch(scrollTopNotifications(false));
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (this.node.scrollTop > 0 && (prevProps.notifications.size < this.props.notifications.size && prevProps.notifications.first() !== this.props.notifications.first() && !!this._oldScrollPosition)) {
+      this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition;
+    }
+  }
+
+  handleLoadMore (e) {
+    e.preventDefault();
+    this.props.dispatch(expandNotifications());
+  }
+
+  handleClear () {
+    const { dispatch, intl } = this.props;
+
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.clearMessage),
+      confirm: intl.formatMessage(messages.clearConfirm),
+      onConfirm: () => dispatch(clearNotifications())
+    }));
+  }
+
+  setRef (c) {
+    this.node = c;
+  }
+
+  render () {
+    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread } = this.props;
+
+    let loadMore       = '';
+    let scrollableArea = '';
+    let unread         = '';
+
+    if (!isLoading && notifications.size > 0) {
+      loadMore = <LoadMore onClick={this.handleLoadMore} />;
+    }
+
+    if (isUnread) {
+      unread = <div className='notifications__unread-indicator' />;
+    }
+
+    if (isLoading || notifications.size > 0) {
+      scrollableArea = (
+        <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}>
+          {unread}
+
+          <div>
+            {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)}
+            {loadMore}
+          </div>
+        </div>
+      );
+    } else {
+      scrollableArea = (
+        <div className='empty-column-indicator' ref={this.setRef}>
+          <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
+        </div>
+      );
+    }
+
+    return (
+      <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}>
+        <ColumnSettingsContainer />
+        <ClearColumnButton onClick={this.handleClear} />
+        <ScrollContainer scrollKey='notifications' shouldUpdateScroll={shouldUpdateScroll}>
+          {scrollableArea}
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+Notifications.propTypes = {
+  notifications: ImmutablePropTypes.list.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  shouldUpdateScroll: PropTypes.func,
+  intl: PropTypes.object.isRequired,
+  isLoading: PropTypes.bool,
+  isUnread: PropTypes.bool
+};
+
+Notifications.defaultProps = {
+  trackScroll: true
+};
+
+export default connect(mapStateToProps)(injectIntl(Notifications));
diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js
new file mode 100644
index 000000000..3b270c62f
--- /dev/null
+++ b/app/javascript/mastodon/features/public_timeline/index.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../ui/components/column';
+import {
+  refreshTimeline,
+  updateTimeline,
+  deleteFromTimelines,
+  connectTimeline,
+  disconnectTimeline
+} from '../../actions/timelines';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import createStream from '../../stream';
+
+const messages = defineMessages({
+  title: { id: 'column.public', defaultMessage: 'Federated timeline' }
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
+  streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
+  accessToken: state.getIn(['meta', 'access_token'])
+});
+
+let subscription;
+
+class PublicTimeline extends React.PureComponent {
+
+  componentDidMount () {
+    const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
+
+    dispatch(refreshTimeline('public'));
+
+    if (typeof subscription !== 'undefined') {
+      return;
+    }
+
+    subscription = createStream(streamingAPIBaseURL, accessToken, 'public', {
+
+      connected () {
+        dispatch(connectTimeline('public'));
+      },
+
+      reconnected () {
+        dispatch(connectTimeline('public'));
+      },
+
+      disconnected () {
+        dispatch(disconnectTimeline('public'));
+      },
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline('public', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
+        }
+      }
+
+    });
+  }
+
+  componentWillUnmount () {
+    // if (typeof subscription !== 'undefined') {
+    //   subscription.close();
+    //   subscription = null;
+    // }
+  }
+
+  render () {
+    const { intl, hasUnread } = this.props;
+
+    return (
+      <Column icon='globe' active={hasUnread} heading={intl.formatMessage(messages.title)}>
+        <ColumnBackButtonSlim />
+        <StatusListContainer {...this.props} type='public' scrollKey='public_timeline' emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} />
+      </Column>
+    );
+  }
+
+}
+
+PublicTimeline.propTypes = {
+  dispatch: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired,
+  streamingAPIBaseURL: PropTypes.string.isRequired,
+  accessToken: PropTypes.string.isRequired,
+  hasUnread: PropTypes.bool
+};
+
+export default connect(mapStateToProps)(injectIntl(PublicTimeline));
diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js
new file mode 100644
index 000000000..48df8451d
--- /dev/null
+++ b/app/javascript/mastodon/features/reblogs/index.js
@@ -0,0 +1,61 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { fetchReblogs } from '../../actions/interactions';
+import { ScrollContainer } from 'react-router-scroll';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import ColumnBackButton from '../../components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'reblogged_by', Number(props.params.statusId)])
+});
+
+class Reblogs extends ImmutablePureComponent {
+
+  componentWillMount () {
+    this.props.dispatch(fetchReblogs(Number(this.props.params.statusId)));
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+      this.props.dispatch(fetchReblogs(Number(nextProps.params.statusId)));
+    }
+  }
+
+  render () {
+    const { accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='reblogs'>
+          <div className='scrollable reblogs'>
+            {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+Reblogs.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  accountIds: ImmutablePropTypes.list
+};
+
+export default connect(mapStateToProps)(Reblogs);
diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js
new file mode 100644
index 000000000..85f792479
--- /dev/null
+++ b/app/javascript/mastodon/features/report/components/status_check_box.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import emojify from '../../../emoji';
+import Toggle from 'react-toggle';
+
+class StatusCheckBox extends React.PureComponent {
+
+  render () {
+    const { status, checked, onToggle, disabled } = this.props;
+    const content = { __html: emojify(status.get('content')) };
+
+    if (status.get('reblog')) {
+      return null;
+    }
+
+    return (
+      <div className='status-check-box'>
+        <div
+          className='status__content'
+          dangerouslySetInnerHTML={content}
+        />
+
+        <div className='status-check-box-toggle'>
+          <Toggle checked={checked} onChange={onToggle} disabled={disabled} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+StatusCheckBox.propTypes = {
+  status: ImmutablePropTypes.map.isRequired,
+  checked: PropTypes.bool,
+  onToggle: PropTypes.func.isRequired,
+  disabled: PropTypes.bool
+};
+
+export default StatusCheckBox;
diff --git a/app/javascript/mastodon/features/report/containers/status_check_box_container.js b/app/javascript/mastodon/features/report/containers/status_check_box_container.js
new file mode 100644
index 000000000..67ce9d9f3
--- /dev/null
+++ b/app/javascript/mastodon/features/report/containers/status_check_box_container.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+import StatusCheckBox from '../components/status_check_box';
+import { toggleStatusReport } from '../../../actions/reports';
+import Immutable from 'immutable';
+
+const mapStateToProps = (state, { id }) => ({
+  status: state.getIn(['statuses', id]),
+  checked: state.getIn(['reports', 'new', 'status_ids'], Immutable.Set()).includes(id)
+});
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+
+  onToggle (e) {
+    dispatch(toggleStatusReport(id, e.target.checked));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox);
diff --git a/app/javascript/mastodon/features/report/index.js b/app/javascript/mastodon/features/report/index.js
new file mode 100644
index 000000000..661fffe56
--- /dev/null
+++ b/app/javascript/mastodon/features/report/index.js
@@ -0,0 +1,131 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { cancelReport, changeReportComment, submitReport } from '../../actions/reports';
+import { fetchAccountTimeline } from '../../actions/accounts';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from '../ui/components/column';
+import Button from '../../components/button';
+import { makeGetAccount } from '../../selectors';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import StatusCheckBox from './containers/status_check_box_container';
+import Immutable from 'immutable';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+
+const messages = defineMessages({
+  heading: { id: 'report.heading', defaultMessage: 'New report' },
+  placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
+  submit: { id: 'report.submit', defaultMessage: 'Submit' }
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = state => {
+    const accountId = state.getIn(['reports', 'new', 'account_id']);
+
+    return {
+      isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
+      account: getAccount(state, accountId),
+      comment: state.getIn(['reports', 'new', 'comment']),
+      statusIds: Immutable.OrderedSet(state.getIn(['timelines', 'accounts_timelines', accountId, 'items'])).union(state.getIn(['reports', 'new', 'status_ids']))
+    };
+  };
+
+  return mapStateToProps;
+};
+
+class Report extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleCommentChange = this.handleCommentChange.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+  }
+
+  componentWillMount () {
+    if (!this.props.account) {
+      this.context.router.replace('/');
+    }
+  }
+
+  componentDidMount () {
+    if (!this.props.account) {
+      return;
+    }
+
+    this.props.dispatch(fetchAccountTimeline(this.props.account.get('id')));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.account !== nextProps.account && nextProps.account) {
+      this.props.dispatch(fetchAccountTimeline(nextProps.account.get('id')));
+    }
+  }
+
+  handleCommentChange (e) {
+    this.props.dispatch(changeReportComment(e.target.value));
+  }
+
+  handleSubmit () {
+    this.props.dispatch(submitReport());
+    this.context.router.replace('/');
+  }
+
+  render () {
+    const { account, comment, intl, statusIds, isSubmitting } = this.props;
+
+    if (!account) {
+      return null;
+    }
+
+    return (
+      <Column heading={intl.formatMessage(messages.heading)} icon='flag'>
+        <ColumnBackButtonSlim />
+
+        <div className='report scrollable'>
+          <div className='report__target'>
+            <FormattedMessage id='report.target' defaultMessage='Reporting' />
+            <strong>{account.get('acct')}</strong>
+          </div>
+
+          <div className='scrollable report__statuses'>
+            <div>
+              {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)}
+            </div>
+          </div>
+
+          <div className='report__textarea-wrapper'>
+            <textarea
+              className='report__textarea'
+              placeholder={intl.formatMessage(messages.placeholder)}
+              value={comment}
+              onChange={this.handleCommentChange}
+              disabled={isSubmitting}
+            />
+
+            <div className='report__submit'>
+              <div className='report__submit-button'><Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /></div>
+            </div>
+          </div>
+        </div>
+      </Column>
+    );
+  }
+
+}
+
+Report.contextTypes = {
+  router: PropTypes.object
+};
+
+Report.propTypes = {
+  isSubmitting: PropTypes.bool,
+  account: ImmutablePropTypes.map,
+  statusIds: ImmutablePropTypes.orderedSet.isRequired,
+  comment: PropTypes.string.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default connect(makeMapStateToProps)(injectIntl(Report));
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
new file mode 100644
index 000000000..384b47c8f
--- /dev/null
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import IconButton from '../../../components/icon_button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import DropdownMenu from '../../../components/dropdown_menu';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  delete: { id: 'status.delete', defaultMessage: 'Delete' },
+  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+  reply: { id: 'status.reply', defaultMessage: 'Reply' },
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+  cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+  report: { id: 'status.report', defaultMessage: 'Report @{name}' }
+});
+
+class ActionBar extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleReplyClick = this.handleReplyClick.bind(this);
+    this.handleReblogClick = this.handleReblogClick.bind(this);
+    this.handleFavouriteClick = this.handleFavouriteClick.bind(this);
+    this.handleDeleteClick = this.handleDeleteClick.bind(this);
+    this.handleMentionClick = this.handleMentionClick.bind(this);
+    this.handleReport = this.handleReport.bind(this);
+  }
+
+  handleReplyClick () {
+    this.props.onReply(this.props.status);
+  }
+
+  handleReblogClick (e) {
+    this.props.onReblog(this.props.status, e);
+  }
+
+  handleFavouriteClick () {
+    this.props.onFavourite(this.props.status);
+  }
+
+  handleDeleteClick () {
+    this.props.onDelete(this.props.status);
+  }
+
+  handleMentionClick () {
+    this.props.onMention(this.props.status.get('account'), this.context.router);
+  }
+
+  handleReport () {
+    this.props.onReport(this.props.status);
+    this.context.router.push('/report');
+  }
+
+  render () {
+    const { status, me, intl } = this.props;
+
+    let menu = [];
+
+    if (me === status.getIn(['account', 'id'])) {
+      menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+    } 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.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
+    }
+
+    let reblogIcon = 'retweet';
+    if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
+    else if (status.get('visibility') === 'private') reblogIcon = 'lock';
+
+    let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private');
+
+    return (
+      <div className='detailed-status__action-bar'>
+        <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
+        <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
+        <div className='detailed-status__button'><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
+        <div className='detailed-status__button'><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" ariaLabel="More" /></div>
+      </div>
+    );
+  }
+
+}
+
+ActionBar.contextTypes = {
+  router: PropTypes.object
+};
+
+ActionBar.propTypes = {
+  status: ImmutablePropTypes.map.isRequired,
+  onReply: PropTypes.func.isRequired,
+  onReblog: PropTypes.func.isRequired,
+  onFavourite: PropTypes.func.isRequired,
+  onDelete: PropTypes.func.isRequired,
+  onMention: PropTypes.func.isRequired,
+  onReport: PropTypes.func,
+  me: PropTypes.number.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(ActionBar);
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
new file mode 100644
index 000000000..9e7d4f884
--- /dev/null
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const hostStyle = {
+  display: 'block',
+  marginTop: '5px',
+  fontSize: '13px'
+};
+
+const getHostname = url => {
+  const parser = document.createElement('a');
+  parser.href = url;
+  return parser.hostname;
+};
+
+class Card extends React.PureComponent {
+
+  renderLink () {
+    const { card } = this.props;
+
+    let image    = '';
+    let provider = card.get('provider_name');
+
+    if (card.get('image')) {
+      image = (
+        <div className='status-card__image'>
+          <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' />
+        </div>
+      );
+    }
+
+    if (provider.length < 1) {
+      provider = getHostname(card.get('url'))
+    }
+
+    return (
+      <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'>
+        {image}
+
+        <div className='status-card__content'>
+          <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>
+          <p className='status-card__description'>{(card.get('description') || '').substring(0, 50)}</p>
+          <span className='status-card__host' style={hostStyle}>{provider}</span>
+        </div>
+      </a>
+    );
+  }
+
+  renderPhoto () {
+    const { card } = this.props;
+
+    return (
+      <a href={card.get('url')} className='status-card-photo' target='_blank' rel='noopener'>
+        <img src={card.get('url')} alt={card.get('title')} width={card.get('width')} height={card.get('height')} />
+      </a>
+    );
+  }
+
+  renderVideo () {
+    const { card } = this.props;
+    const content  = { __html: card.get('html') };
+
+    return (
+      <div
+        className='status-card-video'
+        dangerouslySetInnerHTML={content}
+      />
+    );
+  }
+
+  render () {
+    const { card } = this.props;
+
+    if (card === null) {
+      return null;
+    }
+
+    switch(card.get('type')) {
+    case 'link':
+      return this.renderLink();
+    case 'photo':
+      return this.renderPhoto();
+    case 'video':
+      return this.renderVideo();
+    case 'rich':
+    default:
+      return null;
+    }
+  }
+}
+
+Card.propTypes = {
+  card: ImmutablePropTypes.map
+};
+
+export default Card;
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
new file mode 100644
index 000000000..913a186b9
--- /dev/null
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import StatusContent from '../../../components/status_content';
+import MediaGallery from '../../../components/media_gallery';
+import VideoPlayer from '../../../components/video_player';
+import AttachmentList from '../../../components/attachment_list';
+import { Link } from 'react-router';
+import { FormattedDate, FormattedNumber } from 'react-intl';
+import CardContainer from '../containers/card_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class DetailedStatus extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleAccountClick = this.handleAccountClick.bind(this);
+  }
+
+  handleAccountClick (e) {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+
+    e.stopPropagation();
+  }
+
+  render () {
+    const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
+
+    let media           = '';
+    let applicationLink = '';
+
+    if (status.get('media_attachments').size > 0) {
+      if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
+        media = <AttachmentList media={status.get('media_attachments')} />;
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+        media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} onOpenVideo={this.props.onOpenVideo} autoplay />;
+      } else {
+        media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />;
+      }
+    } else if (status.get('spoiler_text').length === 0) {
+      media = <CardContainer statusId={status.get('id')} />;
+    }
+
+    if (status.get('application')) {
+      applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>;
+    }
+
+    return (
+      <div className='detailed-status'>
+        <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
+          <div className='detailed-status__display-avatar'><Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /></div>
+          <DisplayName account={status.get('account')} />
+        </a>
+
+        <StatusContent status={status} />
+
+        {media}
+
+        <div className='detailed-status__meta'>
+          <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
+            <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
+          </a>{applicationLink} · <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
+            <i className='fa fa-retweet' />
+            <span className='detailed-status__reblogs'>
+              <FormattedNumber value={status.get('reblogs_count')} />
+            </span>
+          </Link> · <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
+            <i className='fa fa-star' />
+            <span className='detailed-status__favorites'>
+              <FormattedNumber value={status.get('favourites_count')} />
+            </span>
+          </Link>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+DetailedStatus.contextTypes = {
+  router: PropTypes.object
+};
+
+DetailedStatus.propTypes = {
+  status: ImmutablePropTypes.map.isRequired,
+  onOpenMedia: PropTypes.func.isRequired,
+  onOpenVideo: PropTypes.func.isRequired,
+  autoPlayGif: PropTypes.bool,
+};
+
+export default DetailedStatus;
diff --git a/app/javascript/mastodon/features/status/containers/card_container.js b/app/javascript/mastodon/features/status/containers/card_container.js
new file mode 100644
index 000000000..5c8bfeec2
--- /dev/null
+++ b/app/javascript/mastodon/features/status/containers/card_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import Card from '../components/card';
+
+const mapStateToProps = (state, { statusId }) => ({
+  card: state.getIn(['cards', statusId], null)
+});
+
+export default connect(mapStateToProps)(Card);
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
new file mode 100644
index 000000000..2e8c9e56a
--- /dev/null
+++ b/app/javascript/mastodon/features/status/index.js
@@ -0,0 +1,199 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchStatus } from '../../actions/statuses';
+import Immutable from 'immutable';
+import EmbeddedStatus from '../../components/status';
+import MissingIndicator from '../../components/missing_indicator';
+import DetailedStatus from './components/detailed_status';
+import ActionBar from './components/action_bar';
+import Column from '../ui/components/column';
+import {
+  favourite,
+  unfavourite,
+  reblog,
+  unreblog
+} from '../../actions/interactions';
+import {
+  replyCompose,
+  mentionCompose
+} from '../../actions/compose';
+import { deleteStatus } from '../../actions/statuses';
+import { initReport } from '../../actions/reports';
+import {
+  makeGetStatus,
+  getStatusAncestors,
+  getStatusDescendants
+} from '../../selectors';
+import { ScrollContainer } from 'react-router-scroll';
+import ColumnBackButton from '../../components/column_back_button';
+import StatusContainer from '../../containers/status_container';
+import { openModal } from '../../actions/modal';
+import { isMobile } from '../../is_mobile'
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+  deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, Number(props.params.statusId)),
+    ancestorsIds: state.getIn(['timelines', 'ancestors', Number(props.params.statusId)]),
+    descendantsIds: state.getIn(['timelines', 'descendants', Number(props.params.statusId)]),
+    me: state.getIn(['meta', 'me']),
+    boostModal: state.getIn(['meta', 'boost_modal']),
+    autoPlayGif: state.getIn(['meta', 'auto_play_gif'])
+  });
+
+  return mapStateToProps;
+};
+
+class Status extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleFavouriteClick = this.handleFavouriteClick.bind(this);
+    this.handleReplyClick = this.handleReplyClick.bind(this);
+    this.handleModalReblog = this.handleModalReblog.bind(this);
+    this.handleReblogClick = this.handleReblogClick.bind(this);
+    this.handleDeleteClick = this.handleDeleteClick.bind(this);
+    this.handleMentionClick = this.handleMentionClick.bind(this);
+    this.handleOpenMedia = this.handleOpenMedia.bind(this);
+    this.handleOpenVideo = this.handleOpenVideo.bind(this);
+    this.handleReport = this.handleReport.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchStatus(Number(this.props.params.statusId)));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+      this.props.dispatch(fetchStatus(Number(nextProps.params.statusId)));
+    }
+  }
+
+  handleFavouriteClick (status) {
+    if (status.get('favourited')) {
+      this.props.dispatch(unfavourite(status));
+    } else {
+      this.props.dispatch(favourite(status));
+    }
+  }
+
+  handleReplyClick (status) {
+    this.props.dispatch(replyCompose(status, this.context.router));
+  }
+
+  handleModalReblog (status) {
+    this.props.dispatch(reblog(status));
+  }
+
+  handleReblogClick (status, e) {
+    if (status.get('reblogged')) {
+      this.props.dispatch(unreblog(status));
+    } else {
+      if (e.shiftKey || !this.props.boostModal) {
+        this.handleModalReblog(status);
+      } else {
+        this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
+      }
+    }
+  }
+
+  handleDeleteClick (status) {
+    const { dispatch, intl } = this.props;
+
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.deleteMessage),
+      confirm: intl.formatMessage(messages.deleteConfirm),
+      onConfirm: () => dispatch(deleteStatus(status.get('id')))
+    }));
+  }
+
+  handleMentionClick (account, router) {
+    this.props.dispatch(mentionCompose(account, router));
+  }
+
+  handleOpenMedia (media, index) {
+    this.props.dispatch(openModal('MEDIA', { media, index }));
+  }
+
+  handleOpenVideo (media, time) {
+    this.props.dispatch(openModal('VIDEO', { media, time }));
+  }
+
+  handleReport (status) {
+    this.props.dispatch(initReport(status.get('account'), status));
+  }
+
+  renderChildren (list) {
+    return list.map(id => <StatusContainer key={id} id={id} />);
+  }
+
+  render () {
+    let ancestors, descendants;
+    const { status, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props;
+
+    if (status === null) {
+      return (
+        <Column>
+          <ColumnBackButton />
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    const account = status.get('account');
+
+    if (ancestorsIds && ancestorsIds.size > 0) {
+      ancestors = <div>{this.renderChildren(ancestorsIds)}</div>;
+    }
+
+    if (descendantsIds && descendantsIds.size > 0) {
+      descendants = <div>{this.renderChildren(descendantsIds)}</div>;
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='thread'>
+          <div className='scrollable detailed-status__wrapper'>
+            {ancestors}
+
+            <DetailedStatus status={status} autoPlayGif={autoPlayGif} me={me} onOpenVideo={this.handleOpenVideo} onOpenMedia={this.handleOpenMedia} />
+            <ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} />
+
+            {descendants}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+Status.contextTypes = {
+  router: PropTypes.object
+};
+
+Status.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  status: ImmutablePropTypes.map,
+  ancestorsIds: ImmutablePropTypes.list,
+  descendantsIds: ImmutablePropTypes.list,
+  me: PropTypes.number,
+  boostModal: PropTypes.bool,
+  autoPlayGif: PropTypes.bool,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(connect(makeMapStateToProps)(Status));
diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js
new file mode 100644
index 000000000..d6000fe4e
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/boost_modal.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import Button from '../../../components/button';
+import StatusContent from '../../../components/status_content';
+import Avatar from '../../../components/avatar';
+import RelativeTimestamp from '../../../components/relative_timestamp';
+import DisplayName from '../../../components/display_name';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' }
+});
+
+class BoostModal extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleReblog = this.handleReblog.bind(this);
+    this.handleAccountClick = this.handleAccountClick.bind(this);
+  }
+
+  handleReblog() {
+    this.props.onReblog(this.props.status);
+    this.props.onClose();
+  }
+
+  handleAccountClick (e) {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.props.onClose();
+      this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+  }
+
+  render () {
+    const { status, intl, onClose } = this.props;
+
+    return (
+      <div className='modal-root__modal boost-modal'>
+        <div className='boost-modal__container'>
+          <div className='status light'>
+            <div className='boost-modal__status-header'>
+              <div className='boost-modal__status-time'>
+                <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+              </div>
+
+              <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'>
+                <div className='status__avatar'>
+                  <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />
+                </div>
+
+                <DisplayName account={status.get('account')} />
+              </a>
+            </div>
+
+            <StatusContent status={status} />
+          </div>
+        </div>
+
+        <div className='boost-modal__action-bar'>
+          <div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <i className='fa fa-retweet' /></span> }} /></div>
+          <Button text={intl.formatMessage(messages.reblog)} onClick={this.handleReblog} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+BoostModal.contextTypes = {
+  router: PropTypes.object
+};
+
+BoostModal.propTypes = {
+  status: ImmutablePropTypes.map.isRequired,
+  onReblog: PropTypes.func.isRequired,
+  onClose: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(BoostModal);
diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js
new file mode 100644
index 000000000..fcb197573
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import ColumnHeader from './column_header';
+import PropTypes from 'prop-types';
+
+const easingOutQuint = (x, t, b, c, d) => c*((t=t/d-1)*t*t*t*t + 1) + b;
+
+const scrollTop = (node) => {
+  const startTime = Date.now();
+  const offset    = node.scrollTop;
+  const targetY   = -offset;
+  const duration  = 1000;
+  let interrupt   = false;
+
+  const step = () => {
+    const elapsed    = Date.now() - startTime;
+    const percentage = elapsed / duration;
+
+    if (percentage > 1 || interrupt) {
+      return;
+    }
+
+    node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration);
+    requestAnimationFrame(step);
+  };
+
+  step();
+
+  return () => {
+    interrupt = true;
+  };
+};
+
+class Column extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleHeaderClick = this.handleHeaderClick.bind(this);
+    this.handleWheel = this.handleWheel.bind(this);
+    this.setRef = this.setRef.bind(this);
+  }
+
+  handleHeaderClick () {
+    const scrollable = this.node.querySelector('.scrollable');
+    if (!scrollable) {
+      return;
+    }
+    this._interruptScrollAnimation = scrollTop(scrollable);
+  }
+
+  handleWheel () {
+    if (typeof this._interruptScrollAnimation !== 'undefined') {
+      this._interruptScrollAnimation();
+    }
+  }
+
+  setRef (c) {
+    this.node = c;
+  }
+
+  render () {
+    const { heading, icon, children, active, hideHeadingOnMobile } = this.props;
+
+    let columnHeaderId = null
+    let header = '';
+
+    if (heading) {
+      columnHeaderId = heading.replace(/ /g, '-')
+      header = <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} hideOnMobile={hideHeadingOnMobile} columnHeaderId={columnHeaderId}/>;
+    }
+    return (
+      <div
+        ref={this.setRef}
+        role='region'
+        aria-labelledby={columnHeaderId}
+        className='column'
+        onWheel={this.handleWheel}>
+        {header}
+        {children}
+      </div>
+    );
+  }
+
+}
+
+Column.propTypes = {
+  heading: PropTypes.string,
+  icon: PropTypes.string,
+  children: PropTypes.node,
+  active: PropTypes.bool,
+  hideHeadingOnMobile: PropTypes.bool
+};
+
+export default Column;
diff --git a/app/javascript/mastodon/features/ui/components/column_header.js b/app/javascript/mastodon/features/ui/components/column_header.js
new file mode 100644
index 000000000..2701cd57d
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_header.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types'
+
+class ColumnHeader extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick () {
+    this.props.onClick();
+  }
+
+  render () {
+    const { type, active, hideOnMobile, columnHeaderId } = this.props;
+
+    let icon = '';
+
+    if (this.props.icon) {
+      icon = <i className={`fa fa-fw fa-${this.props.icon} column-header__icon`} />;
+    }
+
+    return (
+      <div role='button heading' tabIndex='0' className={`column-header ${active ? 'active' : ''} ${hideOnMobile ? 'hidden-on-mobile' : ''}`} onClick={this.handleClick} id={columnHeaderId || null}>
+        {icon}
+        {type}
+      </div>
+    );
+  }
+
+}
+
+ColumnHeader.propTypes = {
+  icon: PropTypes.string,
+  type: PropTypes.string,
+  active: PropTypes.bool,
+  onClick: PropTypes.func,
+  hideOnMobile: PropTypes.bool,
+  columnHeaderId: PropTypes.string
+};
+
+export default ColumnHeader;
diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js
new file mode 100644
index 000000000..cffe796ba
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_link.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Link } from 'react-router';
+
+const ColumnLink = ({ icon, text, to, href, method, hideOnMobile }) => {
+  if (href) {
+    return (
+      <a href={href} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`} data-method={method}>
+        <i className={`fa fa-fw fa-${icon} column-link__icon`} />
+        {text}
+      </a>
+    );
+  } else {
+    return (
+      <Link to={to} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`}>
+        <i className={`fa fa-fw fa-${icon} column-link__icon`} />
+        {text}
+      </Link>
+    );
+  }
+};
+
+ColumnLink.propTypes = {
+  icon: PropTypes.string.isRequired,
+  text: PropTypes.string.isRequired,
+  to: PropTypes.string,
+  href: PropTypes.string,
+  method: PropTypes.string,
+  hideOnMobile: PropTypes.bool
+};
+
+export default ColumnLink;
diff --git a/app/javascript/mastodon/features/ui/components/column_subheading.js b/app/javascript/mastodon/features/ui/components/column_subheading.js
new file mode 100644
index 000000000..8160c4aa3
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_subheading.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const ColumnSubheading = ({ text }) => {
+  return (
+    <div className='column-subheading'>
+      {text}
+    </div>
+  );
+};
+
+ColumnSubheading.propTypes = {
+  text: PropTypes.string.isRequired,
+};
+
+export default ColumnSubheading;
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
new file mode 100644
index 000000000..05f9f3fb5
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class ColumnsArea extends React.PureComponent {
+
+  render () {
+    return (
+      <div className='columns-area'>
+        {this.props.children}
+      </div>
+    );
+  }
+
+}
+
+ColumnsArea.propTypes = {
+  children: PropTypes.node
+};
+
+export default ColumnsArea;
diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modal.js b/app/javascript/mastodon/features/ui/components/confirmation_modal.js
new file mode 100644
index 000000000..499993207
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/confirmation_modal.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Button from '../../../components/button';
+
+class ConfirmationModal extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+    this.handleCancel = this.handleCancel.bind(this);
+  }
+
+  handleClick () {
+    this.props.onClose();
+    this.props.onConfirm();
+  }
+
+  handleCancel (e) {
+    e.preventDefault();
+    this.props.onClose();
+  }
+
+  render () {
+    const { intl, message, confirm, onConfirm, onClose } = this.props;
+
+    return (
+      <div className='modal-root__modal confirmation-modal'>
+        <div className='confirmation-modal__container'>
+          {message}
+        </div>
+
+        <div className='confirmation-modal__action-bar'>
+          <div><a href='#' onClick={this.handleCancel}><FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /></a></div>
+          <Button text={confirm} onClick={this.handleClick} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+ConfirmationModal.propTypes = {
+  message: PropTypes.node.isRequired,
+  confirm: PropTypes.string.isRequired,
+  onClose: PropTypes.func.isRequired,
+  onConfirm: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(ConfirmationModal);
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
new file mode 100644
index 000000000..a8fb3858a
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -0,0 +1,103 @@
+import React from 'react';
+import LoadingIndicator from '../../../components/loading_indicator';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ExtendedVideoPlayer from '../../../components/extended_video_player';
+import ImageLoader from 'react-imageloader';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' }
+});
+
+class MediaModal extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      index: null
+    };
+    this.handleNextClick = this.handleNextClick.bind(this);
+    this.handlePrevClick = this.handlePrevClick.bind(this);
+    this.handleKeyUp = this.handleKeyUp.bind(this);
+  }
+
+  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 role='button' tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
+      rightNav = <div role='button' tabIndex='0' className='modal-container__nav  modal-container__nav--right' 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} muted={true} controls={false} />;
+    }
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        {leftNav}
+
+        <div className='media-modal__content'>
+          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
+          {content}
+        </div>
+
+        {rightNav}
+      </div>
+    );
+  }
+
+}
+
+MediaModal.propTypes = {
+  media: ImmutablePropTypes.list.isRequired,
+  index: PropTypes.number.isRequired,
+  onClose: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(MediaModal);
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
new file mode 100644
index 000000000..5cde65907
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import MediaModal from './media_modal';
+import OnboardingModal from './onboarding_modal';
+import VideoModal from './video_modal';
+import BoostModal from './boost_modal';
+import ConfirmationModal from './confirmation_modal';
+import { TransitionMotion, spring } from 'react-motion';
+
+const MODAL_COMPONENTS = {
+  'MEDIA': MediaModal,
+  'ONBOARDING': OnboardingModal,
+  'VIDEO': VideoModal,
+  'BOOST': BoostModal,
+  'CONFIRM': ConfirmationModal
+};
+
+class ModalRoot extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleKeyUp = this.handleKeyUp.bind(this);
+  }
+
+  handleKeyUp (e) {
+    if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
+         && !!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 role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} 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>
+    );
+  }
+
+}
+
+ModalRoot.propTypes = {
+  type: PropTypes.string,
+  props: PropTypes.object,
+  onClose: PropTypes.func.isRequired
+};
+
+export default ModalRoot;
diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
new file mode 100644
index 000000000..7cdd3527a
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
@@ -0,0 +1,264 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Permalink from '../../../components/permalink';
+import { TransitionMotion, spring } from 'react-motion';
+import ComposeForm from '../../compose/components/compose_form';
+import Search from '../../compose/components/search';
+import NavigationBar from '../../compose/components/navigation_bar';
+import ColumnHeader from './column_header';
+import Immutable from 'immutable';
+
+const messages = defineMessages({
+  home_title: { id: 'column.home', defaultMessage: 'Home' },
+  notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+  local_title: { id: 'column.community', defaultMessage: 'Local timeline' },
+  federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' }
+});
+
+const PageOne = ({ acct, domain }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-one'>
+    <div style={{ flex: '0 0 auto' }}>
+      <div className='onboarding-modal__page-one__elephant-friend' />
+    </div>
+
+    <div>
+      <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1>
+      <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' /></p>
+      <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }}/></p>
+    </div>
+  </div>
+);
+
+PageOne.propTypes = {
+  acct: PropTypes.string.isRequired,
+  domain: PropTypes.string.isRequired
+};
+
+const PageTwo = ({ me }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-two'>
+    <div className='figure non-interactive'>
+      <div className='pseudo-drawer'>
+        <NavigationBar account={me} />
+      </div>
+      <ComposeForm
+        text='Awoo! #introductions'
+        suggestions={Immutable.List()}
+        mentionedDomains={[]}
+        spoiler={false}
+        onChange={() => {}}
+        onSubmit={() => {}}
+        onPaste={() => {}}
+        onPickEmoji={() => {}}
+        onChangeSpoilerText={() => {}}
+        onClearSuggestions={() => {}}
+        onFetchSuggestions={() => {}}
+        onSuggestionSelected={() => {}}
+      />
+    </div>
+
+    <p><FormattedMessage id='onboarding.page_two.compose' defaultMessage='Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.' /></p>
+  </div>
+);
+
+PageTwo.propTypes = {
+  me: ImmutablePropTypes.map.isRequired,
+};
+
+const PageThree = ({ me, domain }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-three'>
+    <div className='figure non-interactive'>
+      <Search
+        value=''
+        onChange={() => {}}
+        onSubmit={() => {}}
+        onClear={() => {}}
+        onShow={() => {}}
+      />
+
+      <div className='pseudo-drawer'>
+        <NavigationBar account={me} />
+      </div>
+    </div>
+
+    <p><FormattedMessage id='onboarding.page_three.search' defaultMessage='Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.' values={{ illustration: <Permalink to='/timelines/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/timelines/tag/introductions' href='/tags/introductions'>#introductions</Permalink> }}/></p>
+    <p><FormattedMessage id='onboarding.page_three.profile' defaultMessage='Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.' /></p>
+  </div>
+);
+
+PageThree.propTypes = {
+  me: ImmutablePropTypes.map.isRequired,
+  domain: PropTypes.string.isRequired
+};
+
+const PageFour = ({ domain, intl }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-four'>
+    <div className='onboarding-modal__page-four__columns'>
+      <div className='row'>
+        <div>
+          <div className='figure non-interactive'><ColumnHeader icon='home' type={intl.formatMessage(messages.home_title)} /></div>
+          <p><FormattedMessage id='onboarding.page_four.home' defaultMessage='The home timeline shows posts from people you follow.'/></p>
+        </div>
+
+        <div>
+          <div className='figure non-interactive'><ColumnHeader icon='bell' type={intl.formatMessage(messages.notifications_title)} /></div>
+          <p><FormattedMessage id='onboarding.page_four.notifications' defaultMessage='The notifications column shows when someone interacts with you.' /></p>
+        </div>
+      </div>
+
+      <div className='row'>
+        <div>
+          <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='users' type={intl.formatMessage(messages.local_title)} /></div>
+        </div>
+
+        <div>
+          <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='globe' type={intl.formatMessage(messages.federated_title)} /></div>
+        </div>
+      </div>
+
+      <p><FormattedMessage id='onboarding.page_five.public_timelines' defaultMessage='The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.' values={{ domain }} /></p>
+    </div>
+  </div>
+);
+
+PageFour.propTypes = {
+  domain: PropTypes.string.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+const PageSix = ({ admin, domain }) => {
+  let adminSection = '';
+
+  if (admin) {
+    adminSection = (
+      <p>
+        <FormattedMessage id='onboarding.page_six.admin' defaultMessage="Your instance's admin is {admin}." values={{ admin: <Permalink href={admin.get('url')} to={`/accounts/${admin.get('id')}`}>@{admin.get('acct')}</Permalink> }} />
+        <br />
+        <FormattedMessage id='onboarding.page_six.read_guidelines' defaultMessage="Please read {domain}'s {guidelines}!" values={{domain, guidelines: <a href='/about/more' target='_blank'><FormattedMessage id='onboarding.page_six.guidelines' defaultMessage='community guidelines' /></a> }}/>
+      </p>
+    );
+  }
+
+  return (
+    <div className='onboarding-modal__page onboarding-modal__page-six'>
+      <h1><FormattedMessage id='onboarding.page_six.almost_done' defaultMessage='Almost done...' /></h1>
+      {adminSection}
+      <p><FormattedMessage id='onboarding.page_six.github' defaultMessage='Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ github: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p>
+      <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p>
+      <p><em><FormattedMessage id='onboarding.page_six.appetoot' defaultMessage='Bon Appetoot!' /></em></p>
+    </div>
+  );
+};
+
+PageSix.propTypes = {
+  admin: ImmutablePropTypes.map,
+  domain: PropTypes.string.isRequired
+};
+
+const mapStateToProps = state => ({
+  me: state.getIn(['accounts', state.getIn(['meta', 'me'])]),
+  admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]),
+  domain: state.getIn(['meta', 'domain'])
+});
+
+class OnboardingModal extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      currentIndex: 0
+    };
+    this.handleSkip = this.handleSkip.bind(this);
+    this.handleDot = this.handleDot.bind(this);
+    this.handleNext = this.handleNext.bind(this);
+  }
+
+  handleSkip (e) {
+    e.preventDefault();
+    this.props.onClose();
+  }
+
+  handleDot (i, e) {
+    e.preventDefault();
+    this.setState({ currentIndex: i });
+  }
+
+  handleNext (maxNum, e) {
+    e.preventDefault();
+
+    if (this.state.currentIndex < maxNum - 1) {
+      this.setState({ currentIndex: this.state.currentIndex + 1 });
+    } else {
+      this.props.onClose();
+    }
+  }
+
+  render () {
+    const { me, admin, domain, intl } = this.props;
+
+    const pages = [
+      <PageOne acct={me.get('acct')} domain={domain} />,
+      <PageTwo me={me} />,
+      <PageThree me={me} domain={domain} />,
+      <PageFour domain={domain} intl={intl} />,
+      <PageSix admin={admin} domain={domain} />
+    ];
+
+    const { currentIndex } = this.state;
+    const hasMore = currentIndex < pages.length - 1;
+
+    let nextOrDoneBtn;
+
+    if(hasMore) {
+      nextOrDoneBtn = <a href='#' onClick={this.handleNext.bind(null, pages.length)} className='onboarding-modal__nav onboarding-modal__next'><FormattedMessage id='onboarding.next' defaultMessage='Next' /></a>;
+    } else {
+      nextOrDoneBtn = <a href='#' onClick={this.handleNext.bind(null, pages.length)} className='onboarding-modal__nav onboarding-modal__done'><FormattedMessage id='onboarding.done' defaultMessage='Done' /></a>;
+    }
+
+    const styles = pages.map((page, i) => ({
+      key: `page-${i}`,
+      style: { opacity: spring(i === currentIndex ? 1 : 0) }
+    }));
+
+    return (
+      <div className='modal-root__modal onboarding-modal'>
+        <TransitionMotion styles={styles}>
+          {interpolatedStyles =>
+            <div className='onboarding-modal__pager'>
+              {pages.map((page, i) =>
+                <div key={`page-${i}`} style={{ opacity: interpolatedStyles[i].style.opacity, pointerEvents: i === currentIndex ? 'auto' : 'none' }}>{page}</div>
+              )}
+            </div>
+          }
+        </TransitionMotion>
+
+        <div className='onboarding-modal__paginator'>
+          <div>
+            <a href='#' className='onboarding-modal__skip' onClick={this.handleSkip}><FormattedMessage id='onboarding.skip' defaultMessage='Skip' /></a>
+          </div>
+
+          <div className='onboarding-modal__dots'>
+            {pages.map((_, i) => <div key={i} onClick={this.handleDot.bind(null, i)} className={`onboarding-modal__dot ${i === currentIndex ? 'active' : ''}`} />)}
+          </div>
+
+          <div>
+            {nextOrDoneBtn}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+OnboardingModal.propTypes = {
+  onClose: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired,
+  me: ImmutablePropTypes.map.isRequired,
+  domain: PropTypes.string.isRequired,
+  admin: ImmutablePropTypes.map
+}
+
+export default connect(mapStateToProps)(injectIntl(OnboardingModal));
diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js
new file mode 100644
index 000000000..b6a30bc11
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { Link } from 'react-router';
+import { FormattedMessage } from 'react-intl';
+
+class TabsBar extends React.Component {
+
+  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-asterisk' /></Link>
+      </div>
+    );
+  }
+
+}
+
+export default TabsBar;
diff --git a/app/javascript/mastodon/features/ui/components/upload_area.js b/app/javascript/mastodon/features/ui/components/upload_area.js
new file mode 100644
index 000000000..c5710ee69
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/upload_area.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Motion, spring } from 'react-motion';
+import { FormattedMessage } from 'react-intl';
+
+class UploadArea extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+
+    this.handleKeyUp = this.handleKeyUp.bind(this);
+  }
+
+  handleKeyUp (e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    const keyCode = e.keyCode
+    if (this.props.active) {
+      switch(keyCode) {
+      case 27:
+        this.props.onClose();
+        break;
+      }
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  }
+
+  render () {
+    const { active } = this.props;
+
+    return (
+      <Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}>
+        {({ backgroundOpacity, backgroundScale }) =>
+          <div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}>
+            <div className='upload-area__drop'>
+              <div className='upload-area__background' style={{ transform: `translateZ(0) scale(${backgroundScale})` }} />
+              <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div>
+            </div>
+          </div>
+        }
+      </Motion>
+    );
+  }
+
+}
+
+UploadArea.propTypes = {
+  active: PropTypes.bool,
+  onClose: PropTypes.func
+};
+
+export default UploadArea;
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js
new file mode 100644
index 000000000..8e2e4a533
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/video_modal.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import LoadingIndicator from '../../../components/loading_indicator';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ExtendedVideoPlayer from '../../../components/extended_video_player';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' }
+});
+
+class VideoModal extends ImmutablePureComponent {
+
+  render () {
+    const { media, intl, time, onClose } = this.props;
+
+    const url = media.get('url');
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        <div>
+          <div className='media-modal__close'><IconButton title={intl.formatMessage(messages.close)} icon='times' overlay onClick={onClose} /></div>
+          <ExtendedVideoPlayer src={url} muted={false} controls={true} time={time} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+VideoModal.propTypes = {
+  media: ImmutablePropTypes.map.isRequired,
+  time: PropTypes.number,
+  onClose: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(VideoModal);
diff --git a/app/javascript/mastodon/features/ui/containers/loading_bar_container.js b/app/javascript/mastodon/features/ui/containers/loading_bar_container.js
new file mode 100644
index 000000000..6c4e73e38
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/loading_bar_container.js
@@ -0,0 +1,8 @@
+import { connect }    from 'react-redux';
+import LoadingBar from 'react-redux-loading-bar';
+
+const mapStateToProps = (state) => ({
+  loading: state.get('loadingBar')
+});
+
+export default connect(mapStateToProps)(LoadingBar.WrappedComponent);
diff --git a/app/javascript/mastodon/features/ui/containers/modal_container.js b/app/javascript/mastodon/features/ui/containers/modal_container.js
new file mode 100644
index 000000000..26d77818c
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/modal_container.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import { closeModal } from '../../../actions/modal';
+import ModalRoot from '../components/modal_root';
+
+const mapStateToProps = state => ({
+  type: state.get('modal').modalType,
+  props: state.get('modal').modalProps
+});
+
+const mapDispatchToProps = dispatch => ({
+  onClose () {
+    dispatch(closeModal());
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);
diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js
new file mode 100644
index 000000000..529ebf6c8
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/notifications_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { NotificationStack } from 'react-notification';
+import {
+  dismissAlert,
+  clearAlerts
+} from '../../../actions/alerts';
+import { getAlerts } from '../../../selectors';
+
+const mapStateToProps = (state, props) => ({
+  notifications: getAlerts(state)
+});
+
+const mapDispatchToProps = (dispatch) => {
+  return {
+    onDismiss: alert => {
+      dispatch(dismissAlert(alert));
+    }
+  };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(NotificationStack);
diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js
new file mode 100644
index 000000000..1599000b5
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js
@@ -0,0 +1,74 @@
+import { connect } from 'react-redux';
+import StatusList from '../../../components/status_list';
+import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines';
+import Immutable from 'immutable';
+import { createSelector } from 'reselect';
+import { debounce } from 'react-decoration';
+
+const makeGetStatusIds = () => createSelector([
+  (state, { type }) => state.getIn(['settings', type], Immutable.Map()),
+  (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()),
+  (state)           => state.get('statuses'),
+  (state)           => state.getIn(['meta', 'me'])
+], (columnSettings, statusIds, statuses, me) => statusIds.filter(id => {
+  const statusForId = statuses.get(id);
+  let showStatus    = true;
+
+  if (columnSettings.getIn(['shows', 'reblog']) === false) {
+    showStatus = showStatus && statusForId.get('reblog') === null;
+  }
+
+  if (columnSettings.getIn(['shows', 'reply']) === false) {
+    showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me);
+  }
+
+  if (columnSettings.getIn(['regex', 'body'], '').trim().length > 0) {
+    try {
+      if (showStatus) {
+        const regex = new RegExp(columnSettings.getIn(['regex', 'body']).trim(), 'i');
+        showStatus = !regex.test(statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'unescaped_content']) : statusForId.get('unescaped_content'));
+      }
+    } catch(e) {
+      // Bad regex, don't affect filters
+    }
+  }
+
+  return showStatus;
+}));
+
+const makeMapStateToProps = () => {
+  const getStatusIds = makeGetStatusIds();
+
+  const mapStateToProps = (state, props) => ({
+    scrollKey: props.scrollKey,
+    shouldUpdateScroll: props.shouldUpdateScroll,
+    statusIds: getStatusIds(state, props),
+    isLoading: state.getIn(['timelines', props.type, 'isLoading'], true),
+    isUnread: state.getIn(['timelines', props.type, 'unread']) > 0,
+    hasMore: !!state.getIn(['timelines', props.type, 'next'])
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { type, id }) => ({
+
+  @debounce(300, true)
+  onScrollToBottom () {
+    dispatch(scrollTopTimeline(type, false));
+    dispatch(expandTimeline(type, id));
+  },
+
+  @debounce(100)
+  onScrollToTop () {
+    dispatch(scrollTopTimeline(type, true));
+  },
+
+  @debounce(100)
+  onScroll () {
+    dispatch(scrollTopTimeline(type, false));
+  }
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
new file mode 100644
index 000000000..d096cb882
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -0,0 +1,169 @@
+import React from 'react';
+import ColumnsArea from './components/columns_area';
+import NotificationsContainer from './containers/notifications_container';
+import PropTypes from 'prop-types';
+import LoadingBarContainer from './containers/loading_bar_container';
+import HomeTimeline from '../home_timeline';
+import Compose from '../compose';
+import TabsBar from './components/tabs_bar';
+import ModalContainer from './containers/modal_container';
+import Notifications from '../notifications';
+import { connect } from 'react-redux';
+import { isMobile } from '../../is_mobile';
+import { debounce } from 'react-decoration';
+import { uploadCompose } from '../../actions/compose';
+import { refreshTimeline } from '../../actions/timelines';
+import { refreshNotifications } from '../../actions/notifications';
+import UploadArea from './components/upload_area';
+
+const noOp = () => false;
+
+class UI extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      width: window.innerWidth,
+      draggingOver: false
+    };
+    this.handleResize = this.handleResize.bind(this);
+    this.handleDragEnter = this.handleDragEnter.bind(this);
+    this.handleDragOver = this.handleDragOver.bind(this);
+    this.handleDrop = this.handleDrop.bind(this);
+    this.handleDragLeave = this.handleDragLeave.bind(this);
+    this.handleDragEnd = this.handleDragLeave.bind(this)
+    this.closeUploadModal = this.closeUploadModal.bind(this)
+    this.setRef = this.setRef.bind(this);
+  }
+
+  @debounce(500)
+  handleResize () {
+    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.types.includes('Files')) {
+      this.setState({ draggingOver: true });
+    }
+  }
+
+  handleDragOver (e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    try {
+      e.dataTransfer.dropEffect = 'copy';
+    } catch (err) {
+
+    }
+
+    return false;
+  }
+
+  handleDrop (e) {
+    e.preventDefault();
+
+    this.setState({ draggingOver: false });
+
+    if (e.dataTransfer && e.dataTransfer.files.length === 1) {
+      this.props.dispatch(uploadCompose(e.dataTransfer.files));
+    }
+  }
+
+  handleDragLeave (e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
+
+    if (this.dragTargets.length > 0) {
+      return;
+    }
+
+    this.setState({ draggingOver: false });
+  }
+
+  closeUploadModal() {
+    this.setState({ draggingOver: false });
+  }
+
+  componentWillMount () {
+    window.addEventListener('resize', this.handleResize, { passive: true });
+    document.addEventListener('dragenter', this.handleDragEnter, false);
+    document.addEventListener('dragover', this.handleDragOver, false);
+    document.addEventListener('drop', this.handleDrop, false);
+    document.addEventListener('dragleave', this.handleDragLeave, false);
+    document.addEventListener('dragend', this.handleDragEnd, false);
+
+    this.props.dispatch(refreshTimeline('home'));
+    this.props.dispatch(refreshNotifications());
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize);
+    document.removeEventListener('dragenter', this.handleDragEnter);
+    document.removeEventListener('dragover', this.handleDragOver);
+    document.removeEventListener('drop', this.handleDrop);
+    document.removeEventListener('dragleave', this.handleDragLeave);
+    document.removeEventListener('dragend', this.handleDragEnd);
+  }
+
+  setRef (c) {
+    this.node = c;
+  }
+
+  render () {
+    const { width, draggingOver } = this.state;
+    const { children } = this.props;
+
+    let mountedColumns;
+
+    if (isMobile(width)) {
+      mountedColumns = (
+        <ColumnsArea>
+          {children}
+        </ColumnsArea>
+      );
+    } else {
+      mountedColumns = (
+        <ColumnsArea>
+          <Compose withHeader={true} />
+          <HomeTimeline shouldUpdateScroll={noOp} />
+          <Notifications shouldUpdateScroll={noOp} />
+          <div style={{display: 'flex', flex: '1 1 auto', position: 'relative'}}>{children}</div>
+        </ColumnsArea>
+      );
+    }
+
+    return (
+      <div className='ui' ref={this.setRef}>
+        <TabsBar />
+
+        {mountedColumns}
+
+        <NotificationsContainer />
+        <LoadingBarContainer className="loading-bar" />
+        <ModalContainer />
+        <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
+      </div>
+    );
+  }
+
+}
+
+UI.propTypes = {
+  dispatch: PropTypes.func.isRequired,
+  children: PropTypes.node
+};
+
+export default connect()(UI);
diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js
new file mode 100644
index 000000000..992e63727
--- /dev/null
+++ b/app/javascript/mastodon/is_mobile.js
@@ -0,0 +1,11 @@
+const LAYOUT_BREAKPOINT = 1024;
+
+export function isMobile(width) {
+  return width <= LAYOUT_BREAKPOINT;
+};
+
+const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+
+export function isIOS() {
+  return iOS;
+};
diff --git a/app/javascript/mastodon/link_header.js b/app/javascript/mastodon/link_header.js
new file mode 100644
index 000000000..b872dc24a
--- /dev/null
+++ b/app/javascript/mastodon/link_header.js
@@ -0,0 +1,33 @@
+import Link from 'http-link-header';
+import querystring from 'querystring';
+
+Link.parseAttrs = (link, parts) => {
+  let match = null
+  let attr  = ''
+  let value = ''
+  let attrs = ''
+
+  let uriAttrs = /<(.*)>;\s*(.*)/gi.exec(parts)
+
+  if(uriAttrs) {
+    attrs = uriAttrs[2]
+    link  = Link.parseParams(link, uriAttrs[1])
+  }
+
+  while(match = Link.attrPattern.exec(attrs)) { // eslint-disable-line no-cond-assign
+    attr  = match[1].toLowerCase()
+    value = match[4] || match[3] || match[2]
+
+    if( /\*$/.test(attr)) {
+      Link.setAttr(link, attr, Link.parseExtendedValue(value))
+    } else if(/%/.test(value)) {
+      Link.setAttr(link, attr, querystring.decode(value))
+    } else {
+      Link.setAttr(link, attr, value)
+    }
+  }
+
+  return link
+};
+
+export default Link;
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
new file mode 100644
index 000000000..b4f05a73e
--- /dev/null
+++ b/app/javascript/mastodon/locales/ar.json
@@ -0,0 +1,172 @@
+{
+  "account.block": "حظر @{name}",
+  "account.disclaimer": "هذا المستخدم من مثيل خادم آخر. قد يكون هذا الرقم أكبر.",
+  "account.edit_profile": "تعديل الملف الشخصي",
+  "account.follow": "إتبع",
+  "account.followers": "المتابعون",
+  "account.follows": "يتبع",
+  "account.follows_you": "يتابعك",
+  "account.mention": "أُذكُر @{name}",
+  "account.mute": "أكتم @{name}",
+  "account.posts": "المشاركات",
+  "account.report": "أبلغ عن @{name}",
+  "account.requested": "في انتظار الموافقة",
+  "account.unblock": "إلغاء الحظر عن @{name}",
+  "account.unfollow": "إلغاء المتابعة",
+  "account.unmute": "إلغاء الكتم عن @{name}",
+  "boost_modal.combo": "يمكنك ضغط {combo} لتخطّي هذه في المرّة القادمة",
+  "column.blocks": "الحسابات المحجوبة",
+  "column.community": "الخيط العام المحلي",
+  "column.favourites": "المفضلة",
+  "column.follow_requests": "طلبات المتابعة",
+  "column.home": "الرئيسية",
+  "column.mutes": "الحسابات المكتومة",
+  "column.notifications": "الإشعارات",
+  "column.public": "الخيط العام الموحد",
+  "column_back_button.label": "العودة",
+  "column_subheading.navigation": "التصفح",
+  "column_subheading.settings": "الإعدادات",
+  "compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.",
+  "compose_form.lock_disclaimer.lock": "مقفل",
+  "compose_form.placeholder": "فيمَ تفكّر؟",
+  "compose_form.publish": "بوّق !",
+  "compose_form.sensitive": "ضع علامة على الوسيط باعتباره حسّاس",
+  "compose_form.spoiler": "أخفِ النص واعرض تحذيرا",
+  "compose_form.spoiler_placeholder": "تنبيه عن المحتوى",
+  "confirmation_modal.cancel": "إلغاء",
+  "confirmations.block.confirm": "حجب",
+  "confirmations.block.message": "هل أنت متأكد أنك تريد حجب {name} ؟",
+  "confirmations.delete.confirm": "حذف",
+  "confirmations.delete.message": "هل أنت متأكد أنك تريد حذف هذا المنشور ؟",
+  "confirmations.mute.confirm": "أكتم",
+  "confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟",
+  "emoji_button.activity": "الأنشطة",
+  "emoji_button.flags": "الأعلام",
+  "emoji_button.food": "الطعام والشراب",
+  "emoji_button.label": "أدرج إيموجي",
+  "emoji_button.nature": "الطبيعة",
+  "emoji_button.objects": "أشياء",
+  "emoji_button.people": "الناس",
+  "emoji_button.search": "ابحث...",
+  "emoji_button.symbols": "رموز",
+  "emoji_button.travel": "أماكن و أسفار",
+  "empty_column.community": "الخط الزمني المحلي فارغ. اكتب شيئا ما للعامة كبداية.",
+  "empty_column.hashtag": "ليس هناك بعدُ أي محتوى ذو علاقة بهذا الوسم.",
+  "empty_column.home": "إنك لا تتبع بعد أي شخص إلى حد الآن. زر {public} أو استخدام حقل البحث لكي تبدأ على التعرف على مستخدمين آخرين.",
+  "empty_column.home.public_timeline": "الخيط العام",
+  "empty_column.notifications": "لم تتلق أي إشعار بعدُ. تفاعل مع المستخدمين الآخرين لإنشاء محادثة.",
+  "empty_column.public": "لا يوجد شيء هنا ! قم بتحرير شيء ما بشكل عام، أو اتبع مستخدمين آخرين في الخوادم المثيلة الأخرى لملء خيط المحادثات العام.",
+  "follow_request.authorize": "ترخيص",
+  "follow_request.reject": "رفض",
+  "getting_started.apps": "عدة تطبيقات مختلفة متوفرة",
+  "getting_started.heading": "إستعدّ للبدء",
+  "getting_started.open_source_notice": "ماستدون برنامج مفتوح المصدر. يمكنك المساهمة، أو الإبلاغ عن تقارير الأخطاء، على GitHub {github}. {apps}.",
+  "home.column_settings.advanced": "متقدمة",
+  "home.column_settings.basic": "أساسية",
+  "home.column_settings.filter_regex": "تصفية حسب التعبيرات العادية",
+  "home.column_settings.show_reblogs": "عرض الترقيات",
+  "home.column_settings.show_replies": "عرض الردود",
+  "home.settings": "إعدادات العمود",
+  "lightbox.close": "إغلاق",
+  "loading_indicator.label": "تحميل ...",
+  "media_gallery.toggle_visible": "Toggle visibility",
+  "missing_indicator.label": "تعذر العثور عليه",
+  "navigation_bar.blocks": "الحسابات المحجوبة",
+  "navigation_bar.community_timeline": "الخيط العام المحلي",
+  "navigation_bar.edit_profile": "تعديل الملف الشخصي",
+  "navigation_bar.favourites": "Favourites",
+  "navigation_bar.follow_requests": "Follow requests",
+  "navigation_bar.info": "Extended information",
+  "navigation_bar.logout": "خروج",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.preferences": "التفضيلات",
+  "navigation_bar.public_timeline": "الخيط العام الموحد",
+  "notification.favourite": "{name} أعجب بمنشورك",
+  "notification.follow": "{name} يتبعك",
+  "notification.reblog": "{name} قام بترقية تبويقك",
+  "notifications.clear": "Clear notifications",
+  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
+  "notifications.column_settings.alert": "إشعارات سطح المكتب",
+  "notifications.column_settings.favourite": "المُفَضَّلة :",
+  "notifications.column_settings.follow": "متابعُون جُدُد :",
+  "notifications.column_settings.mention": "الإشارات :",
+  "notifications.column_settings.reblog": "الترقيّات:",
+  "notifications.column_settings.show": "إعرِضها في عمود",
+  "notifications.column_settings.sound": "Play sound",
+  "notifications.settings": "Column settings",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Adjust status privacy",
+  "privacy.direct.long": "Post to mentioned users only",
+  "privacy.direct.short": "Direct",
+  "privacy.private.long": "Post to followers only",
+  "privacy.private.short": "Followers-only",
+  "privacy.public.long": "Post to public timelines",
+  "privacy.public.short": "Public",
+  "privacy.unlisted.long": "Do not show in public timelines",
+  "privacy.unlisted.short": "Unlisted",
+  "reply_indicator.cancel": "إلغاء",
+  "report.heading": "New report",
+  "report.placeholder": "Additional comments",
+  "report.submit": "Submit",
+  "report.target": "Reporting",
+  "search.placeholder": "ابحث",
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "إحذف",
+  "status.favourite": "أضف إلى المفضلة",
+  "status.load_more": "Load more",
+  "status.media_hidden": "Media hidden",
+  "status.mention": "أذكُر @{name}",
+  "status.open": "وسع هذه المشاركة",
+  "status.reblog": "رَقِّي",
+  "status.reblogged_by": "{name} رقى",
+  "status.reply": "ردّ",
+  "status.replyAll": "Reply to thread",
+  "status.report": "إبلِغ عن @{name}",
+  "status.sensitive_toggle": "اضغط للعرض",
+  "status.sensitive_warning": "محتوى حساس",
+  "status.show_less": "إعرض أقلّ",
+  "status.show_more": "أظهر المزيد",
+  "tabs_bar.compose": "تحرير",
+  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.home": "الرئيسية",
+  "tabs_bar.mentions": "الإشارات",
+  "tabs_bar.public": "الخيط العام الموحد",
+  "tabs_bar.notifications": "الإخطارات",
+  "upload_button.label": "إضافة وسائط",
+  "upload_form.undo": "إلغاء",
+  "upload_progress.label": "يرفع...",
+  "notification.follow": "{name} يتبعك",
+  "notification.favourite": "{name} أعجب بمنشورك",
+  "notification.reblog": "{name} قام بترقية تبويقك",
+  "notification.mention": "{name} ذكرك",
+  "notifications.column_settings.alert": "إشعارات سطح المكتب",
+  "notifications.column_settings.show": "إعرِضها في عمود",
+  "notifications.column_settings.follow": "متابعُون جُدُد :",
+  "notifications.column_settings.favourite": "المُفَضَّلة :",
+  "notifications.column_settings.mention": "الإشارات :",
+  "notifications.column_settings.reblog": "الترقيّات:",
+  "video_player.toggle_sound": "تبديل الصوت",
+  "video_player.toggle_visible": "إظهار / إخفاء الفيديو",
+  "video_player.expand": "وسّع الفيديو",
+  "video_player.video_error": "تعذر تشغيل الفيديو"
+}
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
new file mode 100644
index 000000000..38dbb8b61
--- /dev/null
+++ b/app/javascript/mastodon/locales/bg.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Блокирай",
+  "account.disclaimer": "This user is from another instance. This number may be larger.",
+  "account.edit_profile": "Редактирай профила си",
+  "account.follow": "Последвай",
+  "account.followers": "Последователи",
+  "account.follows": "Следвам",
+  "account.follows_you": "Твой последовател",
+  "account.mention": "Споменаване",
+  "account.mute": "Mute @{name}",
+  "account.posts": "Публикации",
+  "account.report": "Report @{name}",
+  "account.requested": "В очакване на одобрение",
+  "account.unblock": "Не блокирай",
+  "account.unfollow": "Не следвай",
+  "account.unmute": "Unmute @{name}",
+  "boost_modal.combo": "You can press {combo} to skip this next time",
+  "column.blocks": "Blocked users",
+  "column.community": "Local timeline",
+  "column.favourites": "Favourites",
+  "column.follow_requests": "Follow requests",
+  "column.home": "Начало",
+  "column.mutes": "Muted users",
+  "column.notifications": "Известия",
+  "column.public": "Публичен канал",
+  "column_back_button.label": "Назад",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "Какво си мислиш?",
+  "compose_form.privacy_disclaimer": "Поверителни публикации ще бъдат изпратени до споменатите потребители на {domains}. Доверяваш ли се на {domainsCount, plural, one {that server} other {those servers}}, че няма да издаде твоята публикация?",
+  "compose_form.publish": "Раздумай",
+  "compose_form.sensitive": "Отбележи съдържанието като деликатно",
+  "compose_form.spoiler": "Скрий текста зад предупреждение",
+  "compose_form.spoiler_placeholder": "Content warning",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Insert emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
+  "empty_column.hashtag": "There is nothing in this hashtag yet.",
+  "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.",
+  "empty_column.home.public_timeline": "the public timeline",
+  "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
+  "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
+  "follow_request.authorize": "Authorize",
+  "follow_request.reject": "Reject",
+  "getting_started.apps": "Various apps are available",
+  "getting_started.heading": "Първи стъпки",
+  "getting_started.open_source_notice": "Mastodon е софтуер с отворен код. Можеш да помогнеш или да докладваш за проблеми в Github: {github}.",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.basic": "Basic",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "home.column_settings.show_reblogs": "Show boosts",
+  "home.column_settings.show_replies": "Show replies",
+  "home.settings": "Column settings",
+  "lightbox.close": "Затвори",
+  "loading_indicator.label": "Зареждане...",
+  "media_gallery.toggle_visible": "Toggle visibility",
+  "missing_indicator.label": "Not found",
+  "navigation_bar.blocks": "Blocked users",
+  "navigation_bar.community_timeline": "Local timeline",
+  "navigation_bar.edit_profile": "Редактирай профил",
+  "navigation_bar.favourites": "Favourites",
+  "navigation_bar.follow_requests": "Follow requests",
+  "navigation_bar.info": "Extended information",
+  "navigation_bar.logout": "Излизане",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.preferences": "Предпочитания",
+  "navigation_bar.public_timeline": "Публичен канал",
+  "notification.favourite": "{name} хареса твоята публикация",
+  "notification.follow": "{name} те последва",
+  "notification.reblog": "{name} сподели твоята публикация",
+  "notifications.clear": "Clear notifications",
+  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
+  "notifications.column_settings.alert": "Десктоп известия",
+  "notifications.column_settings.favourite": "Предпочитани:",
+  "notifications.column_settings.follow": "Нови последователи:",
+  "notifications.column_settings.mention": "Споменавания:",
+  "notifications.column_settings.reblog": "Споделяния:",
+  "notifications.column_settings.show": "Покажи в колона",
+  "notifications.column_settings.sound": "Play sound",
+  "notifications.settings": "Column settings",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Adjust status privacy",
+  "privacy.direct.long": "Post to mentioned users only",
+  "privacy.direct.short": "Direct",
+  "privacy.private.long": "Post to followers only",
+  "privacy.private.short": "Followers-only",
+  "privacy.public.long": "Post to public timelines",
+  "privacy.public.short": "Public",
+  "privacy.unlisted.long": "Do not show in public timelines",
+  "privacy.unlisted.short": "Unlisted",
+  "reply_indicator.cancel": "Отказ",
+  "report.heading": "New report",
+  "report.placeholder": "Additional comments",
+  "report.submit": "Submit",
+  "report.target": "Reporting",
+  "search.placeholder": "Търсене",
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Изтриване",
+  "status.favourite": "Предпочитани",
+  "status.load_more": "Load more",
+  "status.media_hidden": "Media hidden",
+  "status.mention": "Споменаване",
+  "status.open": "Expand this status",
+  "status.reblog": "Споделяне",
+  "status.reblogged_by": "{name} сподели",
+  "status.reply": "Отговор",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Report @{name}",
+  "status.sensitive_toggle": "Покажи",
+  "status.sensitive_warning": "Деликатно съдържание",
+  "status.show_less": "Show less",
+  "status.show_more": "Show more",
+  "tabs_bar.compose": "Съставяне",
+  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.home": "Начало",
+  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.notifications": "Известия",
+  "upload_area.title": "Drag & drop to upload",
+  "upload_button.label": "Добави медия",
+  "upload_form.undo": "Отмяна",
+  "upload_progress.label": "Uploading...",
+  "video_player.expand": "Expand video",
+  "video_player.toggle_sound": "Звук",
+  "video_player.toggle_visible": "Toggle visibility",
+  "video_player.video_error": "Video could not be played"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
new file mode 100644
index 000000000..1d79f0cb8
--- /dev/null
+++ b/app/javascript/mastodon/locales/de.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "@{name} blocken",
+  "account.disclaimer": "Dieser Benutzer ist von einer anderen Instanz. Diese Zahl könnte größer sein.",
+  "account.edit_profile": "Profil bearbeiten",
+  "account.follow": "Folgen",
+  "account.followers": "Folgende",
+  "account.follows": "Folgt",
+  "account.follows_you": "Folgt dir",
+  "account.mention": "@{name} erwähnen",
+  "account.mute": "@{name} stummschalten",
+  "account.posts": "Beiträge",
+  "account.report": "@{name} melden",
+  "account.requested": "Warte auf Erlaubnis",
+  "account.unblock": "@{name} entblocken",
+  "account.unfollow": "Entfolgen",
+  "account.unmute": "@{name} nicht mehr stummschalten",
+  "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen",
+  "column.blocks": "Blockierte Benutzer",
+  "column.community": "Lokale Zeitleiste",
+  "column.favourites": "Favoriten",
+  "column.follow_requests": "Folgeanfragen",
+  "column.home": "Startseite",
+  "column.mutes": "Stummgeschaltete Benutzer",
+  "column.notifications": "Mitteilungen",
+  "column.public": "Gesamtes bekanntes Netz",
+  "column_back_button.label": "Zurück",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "Worüber möchtest du schreiben?",
+  "compose_form.privacy_disclaimer": "Dein privater Status wird an die genannten Benutzer auf den Domains {domains} zugestellt. Vertraust du {domainsCount, plural, one {diesem Server} other {diesen Servern}}? Private Beiträge funktionieren nur auf Mastodon-Instanzen. Wenn {domains} {domainsCount, plural, one {keine Mastodon-Instanz ist} other {keine Mastodon-Instanzen sind}}, wird es dort kein Anzeichen geben, dass dein Beitrag privat ist und er könnte geteilt oder anderweitig für unerwünschte Empfänger sichtbar gemacht werden.",
+  "compose_form.publish": "Tröt",
+  "compose_form.sensitive": "Medien als heikel markieren",
+  "compose_form.spoiler": "Text hinter Warnung verbergen",
+  "compose_form.spoiler_placeholder": "Inhaltswarnung",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Emoji einfügen",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe etwas öffentlich, um den Ball ins Rollen zu bringen!",
+  "empty_column.hashtag": "Es gibt noch nichts unter diesem Hashtag.",
+  "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Benutzer anzutreffen.",
+  "empty_column.home.public_timeline": "die öffentliche Zeitleiste",
+  "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um die Konversation zu starten.",
+  "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Benutzern von anderen Instanzen, um es aufzufüllen.",
+  "follow_request.authorize": "Erlauben",
+  "follow_request.reject": "Ablehnen",
+  "getting_started.apps": "Es sind verschiedene Apps verfügbar",
+  "getting_started.heading": "Erste Schritte",
+  "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
+  "home.column_settings.advanced": "Fortgeschritten",
+  "home.column_settings.basic": "Einfach",
+  "home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke",
+  "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen",
+  "home.column_settings.show_replies": "Antworten anzeigen",
+  "home.settings": "Spalteneinstellungen",
+  "lightbox.close": "Schließen",
+  "loading_indicator.label": "Lade…",
+  "media_gallery.toggle_visible": "Sichtbarkeit einstellen",
+  "missing_indicator.label": "Nicht gefunden",
+  "navigation_bar.blocks": "Blockierte Benutzer",
+  "navigation_bar.community_timeline": "Lokale Zeitleiste",
+  "navigation_bar.edit_profile": "Profil bearbeiten",
+  "navigation_bar.favourites": "Favoriten",
+  "navigation_bar.follow_requests": "Folgeanfragen",
+  "navigation_bar.info": "Erweiterte Informationen",
+  "navigation_bar.logout": "Abmelden",
+  "navigation_bar.mutes": "Stummgeschaltete Benutzer",
+  "navigation_bar.preferences": "Einstellungen",
+  "navigation_bar.public_timeline": "Föderierte Zeitleiste",
+  "notification.favourite": "{name} favorisierte deinen Status",
+  "notification.follow": "{name} folgt dir",
+  "notification.reblog": "{name} teilte deinen Status",
+  "notifications.clear": "Mitteilungen beseitigen",
+  "notifications.clear_confirmation": "Bist du sicher, dass du alle Mitteilungen beseitigen willst?",
+  "notifications.column_settings.alert": "Desktop-Benachrichtigungen",
+  "notifications.column_settings.favourite": "Favorisierungen:",
+  "notifications.column_settings.follow": "Neue Folgende:",
+  "notifications.column_settings.mention": "Erwähnungen:",
+  "notifications.column_settings.reblog": "Geteilte Beiträge:",
+  "notifications.column_settings.show": "In der Spalte anzeigen",
+  "notifications.column_settings.sound": "Ton abspielen",
+  "notifications.settings": "Spalteneinstellungen",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Privatsphäre des Status anpassen",
+  "privacy.direct.long": "Beitrag nur an erwähnte Benutzer",
+  "privacy.direct.short": "Direkt",
+  "privacy.private.long": "Beitrag nur an Folgende",
+  "privacy.private.short": "Privat",
+  "privacy.public.long": "Beitrag an öffentliche Zeitleisten",
+  "privacy.public.short": "Öffentlich",
+  "privacy.unlisted.long": "Nicht in öffentlichen Zeitleisten anzeigen",
+  "privacy.unlisted.short": "Nicht gelistet",
+  "reply_indicator.cancel": "Abbrechen",
+  "report.heading": "Neue Meldung",
+  "report.placeholder": "Zusätzliche Kommentare",
+  "report.submit": "Absenden",
+  "report.target": "Melden",
+  "search.placeholder": "Suche",
+  "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Löschen",
+  "status.favourite": "Favorisieren",
+  "status.load_more": "Weitere laden",
+  "status.media_hidden": "Medien versteckt",
+  "status.mention": "Erwähnen",
+  "status.open": "Öffnen",
+  "status.reblog": "Teilen",
+  "status.reblogged_by": "{name} teilte",
+  "status.reply": "Antworten",
+  "status.replyAll": "Auf Thread antworten",
+  "status.report": "@{name} melden",
+  "status.sensitive_toggle": "Klicke, um sie zu sehen",
+  "status.sensitive_warning": "Heikle Inhalte",
+  "status.show_less": "Weniger anzeigen",
+  "status.show_more": "Mehr anzeigen",
+  "tabs_bar.compose": "Schreiben",
+  "tabs_bar.federated_timeline": "Föderation",
+  "tabs_bar.home": "Home",
+  "tabs_bar.local_timeline": "Lokal",
+  "tabs_bar.notifications": "Mitteilungen",
+  "upload_area.title": "Hereinziehen zum Hochladen",
+  "upload_button.label": "Mediendatei hinzufügen",
+  "upload_form.undo": "Entfernen",
+  "upload_progress.label": "Lade hoch…",
+  "video_player.expand": "Videoanzeige vergrößern",
+  "video_player.toggle_sound": "Ton umschalten",
+  "video_player.toggle_visible": "Sichtbarkeit umschalten",
+  "video_player.video_error": "Video konnte nicht abgespielt werden"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
new file mode 100644
index 000000000..ea481e154
--- /dev/null
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -0,0 +1,1068 @@
+[
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Follow",
+        "id": "account.follow"
+      },
+      {
+        "defaultMessage": "Unfollow",
+        "id": "account.unfollow"
+      },
+      {
+        "defaultMessage": "Awaiting approval",
+        "id": "account.requested"
+      },
+      {
+        "defaultMessage": "Unblock @{name}",
+        "id": "account.unblock"
+      },
+      {
+        "defaultMessage": "Unmute @{name}",
+        "id": "account.unmute"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/account.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Back",
+        "id": "column_back_button.label"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/column_back_button_slim.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Back",
+        "id": "column_back_button.label"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/column_back_button.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Load more",
+        "id": "status.load_more"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/load_more.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Loading...",
+        "id": "loading_indicator.label"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/loading_indicator.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Toggle visibility",
+        "id": "media_gallery.toggle_visible"
+      },
+      {
+        "defaultMessage": "Sensitive content",
+        "id": "status.sensitive_warning"
+      },
+      {
+        "defaultMessage": "Media hidden",
+        "id": "status.media_hidden"
+      },
+      {
+        "defaultMessage": "Click to view",
+        "id": "status.sensitive_toggle"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/media_gallery.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Not found",
+        "id": "missing_indicator.label"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/missing_indicator.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Delete",
+        "id": "status.delete"
+      },
+      {
+        "defaultMessage": "Mention @{name}",
+        "id": "status.mention"
+      },
+      {
+        "defaultMessage": "Mute @{name}",
+        "id": "account.mute"
+      },
+      {
+        "defaultMessage": "Block @{name}",
+        "id": "account.block"
+      },
+      {
+        "defaultMessage": "Reply",
+        "id": "status.reply"
+      },
+      {
+        "defaultMessage": "Reply to thread",
+        "id": "status.replyAll"
+      },
+      {
+        "defaultMessage": "Boost",
+        "id": "status.reblog"
+      },
+      {
+        "defaultMessage": "This post cannot be boosted",
+        "id": "status.cannot_reblog"
+      },
+      {
+        "defaultMessage": "Favourite",
+        "id": "status.favourite"
+      },
+      {
+        "defaultMessage": "Expand this status",
+        "id": "status.open"
+      },
+      {
+        "defaultMessage": "Report @{name}",
+        "id": "status.report"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/status_action_bar.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Show more",
+        "id": "status.show_more"
+      },
+      {
+        "defaultMessage": "Show less",
+        "id": "status.show_less"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/status_content.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "{name} boosted",
+        "id": "status.reblogged_by"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/status.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Toggle sound",
+        "id": "video_player.toggle_sound"
+      },
+      {
+        "defaultMessage": "Toggle visibility",
+        "id": "video_player.toggle_visible"
+      },
+      {
+        "defaultMessage": "Expand video",
+        "id": "video_player.expand"
+      },
+      {
+        "defaultMessage": "Video could not be played",
+        "id": "video_player.video_error"
+      },
+      {
+        "defaultMessage": "Sensitive content",
+        "id": "status.sensitive_warning"
+      },
+      {
+        "defaultMessage": "Click to view",
+        "id": "status.sensitive_toggle"
+      },
+      {
+        "defaultMessage": "Media hidden",
+        "id": "status.media_hidden"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/video_player.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Delete",
+        "id": "confirmations.delete.confirm"
+      },
+      {
+        "defaultMessage": "Are you sure you want to delete this status?",
+        "id": "confirmations.delete.message"
+      },
+      {
+        "defaultMessage": "Block",
+        "id": "confirmations.block.confirm"
+      },
+      {
+        "defaultMessage": "Mute",
+        "id": "confirmations.mute.confirm"
+      },
+      {
+        "defaultMessage": "Are you sure you want to block {name}?",
+        "id": "confirmations.block.message"
+      },
+      {
+        "defaultMessage": "Are you sure you want to mute {name}?",
+        "id": "confirmations.mute.message"
+      }
+    ],
+    "path": "app/javascript/mastodon/containers/status_container.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Block",
+        "id": "confirmations.block.confirm"
+      },
+      {
+        "defaultMessage": "Mute",
+        "id": "confirmations.mute.confirm"
+      },
+      {
+        "defaultMessage": "Are you sure you want to block {name}?",
+        "id": "confirmations.block.message"
+      },
+      {
+        "defaultMessage": "Are you sure you want to mute {name}?",
+        "id": "confirmations.mute.message"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/account_timeline/containers/header_container.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Mention @{name}",
+        "id": "account.mention"
+      },
+      {
+        "defaultMessage": "Edit profile",
+        "id": "account.edit_profile"
+      },
+      {
+        "defaultMessage": "Unblock @{name}",
+        "id": "account.unblock"
+      },
+      {
+        "defaultMessage": "Unfollow",
+        "id": "account.unfollow"
+      },
+      {
+        "defaultMessage": "Unmute @{name}",
+        "id": "account.unmute"
+      },
+      {
+        "defaultMessage": "Block @{name}",
+        "id": "account.block"
+      },
+      {
+        "defaultMessage": "Mute @{name}",
+        "id": "account.mute"
+      },
+      {
+        "defaultMessage": "Follow",
+        "id": "account.follow"
+      },
+      {
+        "defaultMessage": "Report @{name}",
+        "id": "account.report"
+      },
+      {
+        "defaultMessage": "This user is from another instance. This number may be larger.",
+        "id": "account.disclaimer"
+      },
+      {
+        "defaultMessage": "Posts",
+        "id": "account.posts"
+      },
+      {
+        "defaultMessage": "Follows",
+        "id": "account.follows"
+      },
+      {
+        "defaultMessage": "Followers",
+        "id": "account.followers"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/account/components/action_bar.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Unfollow",
+        "id": "account.unfollow"
+      },
+      {
+        "defaultMessage": "Follow",
+        "id": "account.follow"
+      },
+      {
+        "defaultMessage": "Awaiting approval",
+        "id": "account.requested"
+      },
+      {
+        "defaultMessage": "Follows you",
+        "id": "account.follows_you"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/account/components/header.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Blocked users",
+        "id": "column.blocks"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/blocks/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Local timeline",
+        "id": "column.community"
+      },
+      {
+        "defaultMessage": "The local timeline is empty. Write something publicly to get the ball rolling!",
+        "id": "empty_column.community"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/community_timeline/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "What is on your mind?",
+        "id": "compose_form.placeholder"
+      },
+      {
+        "defaultMessage": "Content warning",
+        "id": "compose_form.spoiler_placeholder"
+      },
+      {
+        "defaultMessage": "Toot",
+        "id": "compose_form.publish"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/components/compose_form.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Insert emoji",
+        "id": "emoji_button.label"
+      },
+      {
+        "defaultMessage": "Search...",
+        "id": "emoji_button.search"
+      },
+      {
+        "defaultMessage": "People",
+        "id": "emoji_button.people"
+      },
+      {
+        "defaultMessage": "Nature",
+        "id": "emoji_button.nature"
+      },
+      {
+        "defaultMessage": "Food & Drink",
+        "id": "emoji_button.food"
+      },
+      {
+        "defaultMessage": "Activity",
+        "id": "emoji_button.activity"
+      },
+      {
+        "defaultMessage": "Travel & Places",
+        "id": "emoji_button.travel"
+      },
+      {
+        "defaultMessage": "Objects",
+        "id": "emoji_button.objects"
+      },
+      {
+        "defaultMessage": "Symbols",
+        "id": "emoji_button.symbols"
+      },
+      {
+        "defaultMessage": "Flags",
+        "id": "emoji_button.flags"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Edit profile",
+        "id": "navigation_bar.edit_profile"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/components/navigation_bar.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Public",
+        "id": "privacy.public.short"
+      },
+      {
+        "defaultMessage": "Post to public timelines",
+        "id": "privacy.public.long"
+      },
+      {
+        "defaultMessage": "Unlisted",
+        "id": "privacy.unlisted.short"
+      },
+      {
+        "defaultMessage": "Do not show in public timelines",
+        "id": "privacy.unlisted.long"
+      },
+      {
+        "defaultMessage": "Followers-only",
+        "id": "privacy.private.short"
+      },
+      {
+        "defaultMessage": "Post to followers only",
+        "id": "privacy.private.long"
+      },
+      {
+        "defaultMessage": "Direct",
+        "id": "privacy.direct.short"
+      },
+      {
+        "defaultMessage": "Post to mentioned users only",
+        "id": "privacy.direct.long"
+      },
+      {
+        "defaultMessage": "Adjust status privacy",
+        "id": "privacy.change"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/components/privacy_dropdown.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Cancel",
+        "id": "reply_indicator.cancel"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/components/reply_indicator.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "{count, number} {count, plural, one {result} other {results}}",
+        "id": "search_results.total"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/components/search_results.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Search",
+        "id": "search.placeholder"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/components/search.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Add media",
+        "id": "upload_button.label"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/components/upload_button.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Undo",
+        "id": "upload_form.undo"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/components/upload_form.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Uploading...",
+        "id": "upload_progress.label"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/components/upload_progress.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Mark media as sensitive",
+        "id": "compose_form.sensitive"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/containers/sensitive_button_container.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Hide text behind warning",
+        "id": "compose_form.spoiler"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/containers/spoiler_button_container.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+        "id": "compose_form.lock_disclaimer"
+      },
+      {
+        "defaultMessage": "locked",
+        "id": "compose_form.lock_disclaimer.lock"
+      },
+      {
+        "defaultMessage": "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.",
+        "id": "compose_form.privacy_disclaimer"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/containers/warning_container.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Getting started",
+        "id": "getting_started.heading"
+      },
+      {
+        "defaultMessage": "Federated timeline",
+        "id": "navigation_bar.public_timeline"
+      },
+      {
+        "defaultMessage": "Local timeline",
+        "id": "navigation_bar.community_timeline"
+      },
+      {
+        "defaultMessage": "Preferences",
+        "id": "navigation_bar.preferences"
+      },
+      {
+        "defaultMessage": "Logout",
+        "id": "navigation_bar.logout"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Favourites",
+        "id": "column.favourites"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/favourited_statuses/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Authorize",
+        "id": "follow_request.authorize"
+      },
+      {
+        "defaultMessage": "Reject",
+        "id": "follow_request.reject"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/follow_requests/components/account_authorize.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Follow requests",
+        "id": "column.follow_requests"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/follow_requests/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Getting started",
+        "id": "getting_started.heading"
+      },
+      {
+        "defaultMessage": "Federated timeline",
+        "id": "navigation_bar.public_timeline"
+      },
+      {
+        "defaultMessage": "Navigation",
+        "id": "column_subheading.navigation"
+      },
+      {
+        "defaultMessage": "Settings",
+        "id": "column_subheading.settings"
+      },
+      {
+        "defaultMessage": "Local timeline",
+        "id": "navigation_bar.community_timeline"
+      },
+      {
+        "defaultMessage": "Preferences",
+        "id": "navigation_bar.preferences"
+      },
+      {
+        "defaultMessage": "Follow requests",
+        "id": "navigation_bar.follow_requests"
+      },
+      {
+        "defaultMessage": "Logout",
+        "id": "navigation_bar.logout"
+      },
+      {
+        "defaultMessage": "Favourites",
+        "id": "navigation_bar.favourites"
+      },
+      {
+        "defaultMessage": "Blocked users",
+        "id": "navigation_bar.blocks"
+      },
+      {
+        "defaultMessage": "Muted users",
+        "id": "navigation_bar.mutes"
+      },
+      {
+        "defaultMessage": "Extended information",
+        "id": "navigation_bar.info"
+      },
+      {
+        "defaultMessage": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.",
+        "id": "getting_started.open_source_notice"
+      },
+      {
+        "defaultMessage": "Various apps are available",
+        "id": "getting_started.apps"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/getting_started/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "There is nothing in this hashtag yet.",
+        "id": "empty_column.hashtag"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/hashtag_timeline/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Filter out by regular expressions",
+        "id": "home.column_settings.filter_regex"
+      },
+      {
+        "defaultMessage": "Column settings",
+        "id": "home.settings"
+      },
+      {
+        "defaultMessage": "Basic",
+        "id": "home.column_settings.basic"
+      },
+      {
+        "defaultMessage": "Show boosts",
+        "id": "home.column_settings.show_reblogs"
+      },
+      {
+        "defaultMessage": "Show replies",
+        "id": "home.column_settings.show_replies"
+      },
+      {
+        "defaultMessage": "Advanced",
+        "id": "home.column_settings.advanced"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/home_timeline/components/column_settings.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Home",
+        "id": "column.home"
+      },
+      {
+        "defaultMessage": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.",
+        "id": "empty_column.home"
+      },
+      {
+        "defaultMessage": "the public timeline",
+        "id": "empty_column.home.public_timeline"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/home_timeline/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Muted users",
+        "id": "column.mutes"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/mutes/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Clear notifications",
+        "id": "notifications.clear"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/notifications/components/clear_column_button.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Column settings",
+        "id": "notifications.settings"
+      },
+      {
+        "defaultMessage": "Desktop notifications",
+        "id": "notifications.column_settings.alert"
+      },
+      {
+        "defaultMessage": "Show in column",
+        "id": "notifications.column_settings.show"
+      },
+      {
+        "defaultMessage": "Play sound",
+        "id": "notifications.column_settings.sound"
+      },
+      {
+        "defaultMessage": "New followers:",
+        "id": "notifications.column_settings.follow"
+      },
+      {
+        "defaultMessage": "Favourites:",
+        "id": "notifications.column_settings.favourite"
+      },
+      {
+        "defaultMessage": "Mentions:",
+        "id": "notifications.column_settings.mention"
+      },
+      {
+        "defaultMessage": "Boosts:",
+        "id": "notifications.column_settings.reblog"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/notifications/components/column_settings.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "{name} followed you",
+        "id": "notification.follow"
+      },
+      {
+        "defaultMessage": "{name} favourited your status",
+        "id": "notification.favourite"
+      },
+      {
+        "defaultMessage": "{name} boosted your status",
+        "id": "notification.reblog"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/notifications/components/notification.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Notifications",
+        "id": "column.notifications"
+      },
+      {
+        "defaultMessage": "Are you sure you want to permanently clear all your notifications?",
+        "id": "notifications.clear_confirmation"
+      },
+      {
+        "defaultMessage": "Clear notifications",
+        "id": "notifications.clear"
+      },
+      {
+        "defaultMessage": "You don't have any notifications yet. Interact with others to start the conversation.",
+        "id": "empty_column.notifications"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/notifications/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Federated timeline",
+        "id": "column.public"
+      },
+      {
+        "defaultMessage": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
+        "id": "empty_column.public"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/public_timeline/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "New report",
+        "id": "report.heading"
+      },
+      {
+        "defaultMessage": "Additional comments",
+        "id": "report.placeholder"
+      },
+      {
+        "defaultMessage": "Submit",
+        "id": "report.submit"
+      },
+      {
+        "defaultMessage": "Reporting",
+        "id": "report.target"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/report/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Delete",
+        "id": "status.delete"
+      },
+      {
+        "defaultMessage": "Mention @{name}",
+        "id": "status.mention"
+      },
+      {
+        "defaultMessage": "Reply",
+        "id": "status.reply"
+      },
+      {
+        "defaultMessage": "Boost",
+        "id": "status.reblog"
+      },
+      {
+        "defaultMessage": "This post cannot be boosted",
+        "id": "status.cannot_reblog"
+      },
+      {
+        "defaultMessage": "Favourite",
+        "id": "status.favourite"
+      },
+      {
+        "defaultMessage": "Report @{name}",
+        "id": "status.report"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/status/components/action_bar.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Delete",
+        "id": "confirmations.delete.confirm"
+      },
+      {
+        "defaultMessage": "Are you sure you want to delete this status?",
+        "id": "confirmations.delete.message"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/status/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Boost",
+        "id": "status.reblog"
+      },
+      {
+        "defaultMessage": "You can press {combo} to skip this next time",
+        "id": "boost_modal.combo"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/ui/components/boost_modal.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Cancel",
+        "id": "confirmation_modal.cancel"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/ui/components/confirmation_modal.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Close",
+        "id": "lightbox.close"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/ui/components/media_modal.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Home",
+        "id": "column.home"
+      },
+      {
+        "defaultMessage": "Notifications",
+        "id": "column.notifications"
+      },
+      {
+        "defaultMessage": "Local timeline",
+        "id": "column.community"
+      },
+      {
+        "defaultMessage": "Federated timeline",
+        "id": "column.public"
+      },
+      {
+        "defaultMessage": "Welcome to Mastodon!",
+        "id": "onboarding.page_one.welcome"
+      },
+      {
+        "defaultMessage": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+        "id": "onboarding.page_one.federation"
+      },
+      {
+        "defaultMessage": "You are on {domain}, so your full handle is {handle}",
+        "id": "onboarding.page_one.handle"
+      },
+      {
+        "defaultMessage": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+        "id": "onboarding.page_two.compose"
+      },
+      {
+        "defaultMessage": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+        "id": "onboarding.page_three.search"
+      },
+      {
+        "defaultMessage": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+        "id": "onboarding.page_three.profile"
+      },
+      {
+        "defaultMessage": "The home timeline shows posts from people you follow.",
+        "id": "onboarding.page_four.home"
+      },
+      {
+        "defaultMessage": "The notifications column shows when someone interacts with you.",
+        "id": "onboarding.page_four.notifications"
+      },
+      {
+        "defaultMessage": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+        "id": "onboarding.page_five.public_timelines"
+      },
+      {
+        "defaultMessage": "Your instance's admin is {admin}.",
+        "id": "onboarding.page_six.admin"
+      },
+      {
+        "defaultMessage": "Please read {domain}'s {guidelines}!",
+        "id": "onboarding.page_six.read_guidelines"
+      },
+      {
+        "defaultMessage": "community guidelines",
+        "id": "onboarding.page_six.guidelines"
+      },
+      {
+        "defaultMessage": "Almost done...",
+        "id": "onboarding.page_six.almost_done"
+      },
+      {
+        "defaultMessage": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+        "id": "onboarding.page_six.github"
+      },
+      {
+        "defaultMessage": "There are {apps} available for iOS, Android and other platforms.",
+        "id": "onboarding.page_six.apps_available"
+      },
+      {
+        "defaultMessage": "mobile apps",
+        "id": "onboarding.page_six.various_app"
+      },
+      {
+        "defaultMessage": "Bon Appetoot!",
+        "id": "onboarding.page_six.appetoot"
+      },
+      {
+        "defaultMessage": "Next",
+        "id": "onboarding.next"
+      },
+      {
+        "defaultMessage": "Done",
+        "id": "onboarding.done"
+      },
+      {
+        "defaultMessage": "Skip",
+        "id": "onboarding.skip"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/ui/components/onboarding_modal.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Compose",
+        "id": "tabs_bar.compose"
+      },
+      {
+        "defaultMessage": "Home",
+        "id": "tabs_bar.home"
+      },
+      {
+        "defaultMessage": "Notifications",
+        "id": "tabs_bar.notifications"
+      },
+      {
+        "defaultMessage": "Local",
+        "id": "tabs_bar.local_timeline"
+      },
+      {
+        "defaultMessage": "Federated",
+        "id": "tabs_bar.federated_timeline"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/ui/components/tabs_bar.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Drag & drop to upload",
+        "id": "upload_area.title"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/ui/components/upload_area.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Close",
+        "id": "lightbox.close"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/ui/components/video_modal.json"
+  }
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
new file mode 100644
index 000000000..797a1caf2
--- /dev/null
+++ b/app/javascript/mastodon/locales/en.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Block @{name}",
+  "account.disclaimer": "This user is from another instance. This number may be larger.",
+  "account.edit_profile": "Edit profile",
+  "account.follow": "Follow",
+  "account.followers": "Followers",
+  "account.follows": "Follows",
+  "account.follows_you": "Follows you",
+  "account.mention": "Mention @{name}",
+  "account.mute": "Mute @{name}",
+  "account.posts": "Posts",
+  "account.report": "Report @{name}",
+  "account.requested": "Awaiting approval",
+  "account.unblock": "Unblock @{name}",
+  "account.unfollow": "Unfollow",
+  "account.unmute": "Unmute @{name}",
+  "boost_modal.combo": "You can press {combo} to skip this next time",
+  "column.blocks": "Blocked users",
+  "column.community": "Local timeline",
+  "column.favourites": "Favourites",
+  "column.follow_requests": "Follow requests",
+  "column.home": "Home",
+  "column.mutes": "Muted users",
+  "column.notifications": "Notifications",
+  "column.public": "Federated timeline",
+  "column_back_button.label": "Back",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "What is on your mind?",
+  "compose_form.privacy_disclaimer": "Your post 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 not a public post, and it may be boosted or otherwise made visible to unintended recipients.",
+  "compose_form.publish": "Toot",
+  "compose_form.sensitive": "Mark media as sensitive",
+  "compose_form.spoiler": "Hide text behind warning",
+  "compose_form.spoiler_placeholder": "Content warning",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Insert emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
+  "empty_column.hashtag": "There is nothing in this hashtag yet.",
+  "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.",
+  "empty_column.home.public_timeline": "the public timeline",
+  "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
+  "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
+  "follow_request.authorize": "Authorize",
+  "follow_request.reject": "Reject",
+  "getting_started.apps": "Various apps are available",
+  "getting_started.heading": "Getting started",
+  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.basic": "Basic",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "home.column_settings.show_reblogs": "Show boosts",
+  "home.column_settings.show_replies": "Show replies",
+  "home.settings": "Column settings",
+  "lightbox.close": "Close",
+  "loading_indicator.label": "Loading...",
+  "media_gallery.toggle_visible": "Toggle visibility",
+  "missing_indicator.label": "Not found",
+  "navigation_bar.blocks": "Blocked users",
+  "navigation_bar.community_timeline": "Local timeline",
+  "navigation_bar.edit_profile": "Edit profile",
+  "navigation_bar.favourites": "Favourites",
+  "navigation_bar.follow_requests": "Follow requests",
+  "navigation_bar.info": "Extended information",
+  "navigation_bar.logout": "Logout",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.preferences": "Preferences",
+  "navigation_bar.public_timeline": "Federated timeline",
+  "notification.favourite": "{name} favourited your status",
+  "notification.follow": "{name} followed you",
+  "notification.reblog": "{name} boosted your status",
+  "notifications.clear": "Clear notifications",
+  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
+  "notifications.column_settings.alert": "Desktop notifications",
+  "notifications.column_settings.favourite": "Favourites:",
+  "notifications.column_settings.follow": "New followers:",
+  "notifications.column_settings.mention": "Mentions:",
+  "notifications.column_settings.reblog": "Boosts:",
+  "notifications.column_settings.show": "Show in column",
+  "notifications.column_settings.sound": "Play sound",
+  "notifications.settings": "Column settings",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Adjust status privacy",
+  "privacy.direct.long": "Post to mentioned users only",
+  "privacy.direct.short": "Direct",
+  "privacy.private.long": "Post to followers only",
+  "privacy.private.short": "Followers-only",
+  "privacy.public.long": "Post to public timelines",
+  "privacy.public.short": "Public",
+  "privacy.unlisted.long": "Do not post to public timelines",
+  "privacy.unlisted.short": "Unlisted",
+  "reply_indicator.cancel": "Cancel",
+  "report.heading": "New report",
+  "report.placeholder": "Additional comments",
+  "report.submit": "Submit",
+  "report.target": "Reporting",
+  "search.placeholder": "Search",
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Delete",
+  "status.favourite": "Favourite",
+  "status.load_more": "Load more",
+  "status.media_hidden": "Media hidden",
+  "status.mention": "Mention @{name}",
+  "status.open": "Expand this status",
+  "status.reblog": "Boost",
+  "status.reblogged_by": "{name} boosted",
+  "status.reply": "Reply",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Report @{name}",
+  "status.sensitive_toggle": "Click to view",
+  "status.sensitive_warning": "Sensitive content",
+  "status.show_less": "Show less",
+  "status.show_more": "Show more",
+  "tabs_bar.compose": "Compose",
+  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.home": "Home",
+  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.notifications": "Notifications",
+  "upload_area.title": "Drag & drop to upload",
+  "upload_button.label": "Add media",
+  "upload_form.undo": "Undo",
+  "upload_progress.label": "Uploading...",
+  "video_player.expand": "Expand video",
+  "video_player.toggle_sound": "Toggle sound",
+  "video_player.toggle_visible": "Toggle visibility",
+  "video_player.video_error": "Video could not be played"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
new file mode 100644
index 000000000..b71088490
--- /dev/null
+++ b/app/javascript/mastodon/locales/eo.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Bloki @{name}",
+  "account.disclaimer": "This user is from another instance. This number may be larger.",
+  "account.edit_profile": "Redakti la profilon",
+  "account.follow": "Sekvi",
+  "account.followers": "Sekvantoj",
+  "account.follows": "Sekvatoj",
+  "account.follows_you": "Sekvas vin",
+  "account.mention": "Mencii @{name}",
+  "account.mute": "Mute @{name}",
+  "account.posts": "Mesaĝoj",
+  "account.report": "Report @{name}",
+  "account.requested": "Atendas aprobon",
+  "account.unblock": "Malbloki @{name}",
+  "account.unfollow": "Malsekvi",
+  "account.unmute": "Unmute @{name}",
+  "boost_modal.combo": "You can press {combo} to skip this next time",
+  "column.blocks": "Blocked users",
+  "column.community": "Loka tempolinio",
+  "column.favourites": "Favourites",
+  "column.follow_requests": "Follow requests",
+  "column.home": "Hejmo",
+  "column.mutes": "Muted users",
+  "column.notifications": "Sciigoj",
+  "column.public": "Fratara tempolinio",
+  "column_back_button.label": "Reveni",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "Pri kio vi pensas?",
+  "compose_form.privacy_disclaimer": "Via privata mesaĝo estos sendita nur al menciitaj uzantoj en {domains}. Ĉu vi fidas {domainsCount, plural, one {tiun servilon} other {tiujn servilojn}}? Mesaĝa privateco funkcias nur en aperaĵoj de Mastodon. Se {domains} {domainsCount, plural, one {ne estas aperaĵo de Mastodon} other {ne estas aperaĵoj de Mastodon}}, estos neniu indiko ke via mesaĝo estas privata, kaj ĝi povus esti diskonigita aŭ videbligita al necelitaj ricevantoj.",
+  "compose_form.publish": "Hup",
+  "compose_form.sensitive": "Marki ke la enhavo estas tikla",
+  "compose_form.spoiler": "Kaŝi la tekston malantaŭ averto",
+  "compose_form.spoiler_placeholder": "Content warning",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Insert emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
+  "empty_column.hashtag": "There is nothing in this hashtag yet.",
+  "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.",
+  "empty_column.home.public_timeline": "the public timeline",
+  "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
+  "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
+  "follow_request.authorize": "Authorize",
+  "follow_request.reject": "Reject",
+  "getting_started.apps": "Various apps are available",
+  "getting_started.heading": "Por komenci",
+  "getting_started.open_source_notice": "Mastodon estas malfermitkoda programo. Vi povas kontribui aŭ raporti problemojn en github je {github}. {apps}.",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.basic": "Basic",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "home.column_settings.show_reblogs": "Show boosts",
+  "home.column_settings.show_replies": "Show replies",
+  "home.settings": "Column settings",
+  "lightbox.close": "Fermi",
+  "loading_indicator.label": "Ŝarĝanta...",
+  "media_gallery.toggle_visible": "Toggle visibility",
+  "missing_indicator.label": "Not found",
+  "navigation_bar.blocks": "Blocked users",
+  "navigation_bar.community_timeline": "Loka tempolinio",
+  "navigation_bar.edit_profile": "Redakti la profilon",
+  "navigation_bar.favourites": "Favourites",
+  "navigation_bar.follow_requests": "Follow requests",
+  "navigation_bar.info": "Extended information",
+  "navigation_bar.logout": "Elsaluti",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.preferences": "Preferoj",
+  "navigation_bar.public_timeline": "Fratara tempolinio",
+  "notification.favourite": "{name} favoris vian mesaĝon",
+  "notification.follow": "{name} sekvis vin",
+  "notification.reblog": "{name} diskonigis vian mesaĝon",
+  "notifications.clear": "Clear notifications",
+  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
+  "notifications.column_settings.alert": "Retumilaj atentigoj",
+  "notifications.column_settings.favourite": "Favoroj:",
+  "notifications.column_settings.follow": "Novaj sekvantoj:",
+  "notifications.column_settings.mention": "Mencioj:",
+  "notifications.column_settings.reblog": "Diskonigoj:",
+  "notifications.column_settings.show": "Montri en kolono",
+  "notifications.column_settings.sound": "Play sound",
+  "notifications.settings": "Column settings",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Adjust status privacy",
+  "privacy.direct.long": "Post to mentioned users only",
+  "privacy.direct.short": "Direct",
+  "privacy.private.long": "Post to followers only",
+  "privacy.private.short": "Followers-only",
+  "privacy.public.long": "Post to public timelines",
+  "privacy.public.short": "Public",
+  "privacy.unlisted.long": "Do not show in public timelines",
+  "privacy.unlisted.short": "Unlisted",
+  "reply_indicator.cancel": "Rezigni",
+  "report.heading": "New report",
+  "report.placeholder": "Additional comments",
+  "report.submit": "Submit",
+  "report.target": "Reporting",
+  "search.placeholder": "Serĉi",
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Forigi",
+  "status.favourite": "Favori",
+  "status.load_more": "Load more",
+  "status.media_hidden": "Media hidden",
+  "status.mention": "Mencii @{name}",
+  "status.open": "Expand this status",
+  "status.reblog": "Diskonigi",
+  "status.reblogged_by": "{name} diskonigita",
+  "status.reply": "Respondi",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Report @{name}",
+  "status.sensitive_toggle": "Alklaki por vidi",
+  "status.sensitive_warning": "Tikla enhavo",
+  "status.show_less": "Show less",
+  "status.show_more": "Show more",
+  "tabs_bar.compose": "Ekskribi",
+  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.home": "Hejmo",
+  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.notifications": "Sciigoj",
+  "upload_area.title": "Drag & drop to upload",
+  "upload_button.label": "Aldoni enhavaĵon",
+  "upload_form.undo": "Malfari",
+  "upload_progress.label": "Uploading...",
+  "video_player.expand": "Expand video",
+  "video_player.toggle_sound": "Aktivigi sonojn",
+  "video_player.toggle_visible": "Toggle visibility",
+  "video_player.video_error": "Video could not be played"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
new file mode 100644
index 000000000..c023a4b7e
--- /dev/null
+++ b/app/javascript/mastodon/locales/es.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Bloquear",
+  "account.disclaimer": "This user is from another instance. This number may be larger.",
+  "account.edit_profile": "Editar perfil",
+  "account.follow": "Seguir",
+  "account.followers": "Seguidores",
+  "account.follows": "Seguir",
+  "account.follows_you": "Te sigue",
+  "account.mention": "Mencionar",
+  "account.mute": "Silenciar",
+  "account.posts": "Publicaciones",
+  "account.report": "Report @{name}",
+  "account.requested": "Esperando aprobación",
+  "account.unblock": "Desbloquear",
+  "account.unfollow": "Dejar de seguir",
+  "account.unmute": "Unmute @{name}",
+  "boost_modal.combo": "You can press {combo} to skip this next time",
+  "column.blocks": "Usuarios bloqueados",
+  "column.community": "Historia local",
+  "column.favourites": "Favoritos",
+  "column.follow_requests": "Solicitudes para seguirte",
+  "column.home": "Inicio",
+  "column.mutes": "Usuarios silenciados",
+  "column.notifications": "Notificaciones",
+  "column.public": "Historia federada",
+  "column_back_button.label": "Atrás",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "¿En qué estás pensando?",
+  "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.publish": "Tootear",
+  "compose_form.sensitive": "Marcar contenido como sensible",
+  "compose_form.spoiler": "Ocultar texto tras advertencia",
+  "compose_form.spoiler_placeholder": "Advertencia de contenido",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Insertar emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
+  "empty_column.hashtag": "There is nothing in this hashtag yet.",
+  "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.",
+  "empty_column.home.public_timeline": "the public timeline",
+  "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
+  "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
+  "follow_request.authorize": "Authorize",
+  "follow_request.reject": "Reject",
+  "getting_started.apps": "Various apps are available",
+  "getting_started.heading": "Primeros pasos",
+  "getting_started.open_source_notice": "Mastodon es software libre. Puedes contribuir o reportar errores en {github}. {apps}.",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.basic": "Basic",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "home.column_settings.show_reblogs": "Show boosts",
+  "home.column_settings.show_replies": "Show replies",
+  "home.settings": "Column settings",
+  "lightbox.close": "Cerrar",
+  "loading_indicator.label": "Cargando...",
+  "media_gallery.toggle_visible": "Toggle visibility",
+  "missing_indicator.label": "Not found",
+  "navigation_bar.blocks": "Usuarios bloqueados",
+  "navigation_bar.community_timeline": "Historia local",
+  "navigation_bar.edit_profile": "Editar perfil",
+  "navigation_bar.favourites": "Favoritos",
+  "navigation_bar.follow_requests": "Solicitudes para seguirte",
+  "navigation_bar.info": "Información adicional",
+  "navigation_bar.logout": "Cerrar sesión",
+  "navigation_bar.mutes": "Usuarios silenciados",
+  "navigation_bar.preferences": "Preferencias",
+  "navigation_bar.public_timeline": "Historia federada",
+  "notification.favourite": "{name} marcó tu estado como favorito",
+  "notification.follow": "{name} te empezó a seguir",
+  "notification.reblog": "{name} ha retooteado tu estado",
+  "notifications.clear": "Clear notifications",
+  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
+  "notifications.column_settings.alert": "Notificaciones de escritorio",
+  "notifications.column_settings.favourite": "Favoritos:",
+  "notifications.column_settings.follow": "Nuevos seguidores:",
+  "notifications.column_settings.mention": "Menciones:",
+  "notifications.column_settings.reblog": "Retoots:",
+  "notifications.column_settings.show": "Mostrar en columna",
+  "notifications.column_settings.sound": "Play sound",
+  "notifications.settings": "Column settings",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Ajustar privacidad",
+  "privacy.direct.long": "Sólo mostrar a los usuarios mencionados",
+  "privacy.direct.short": "Directo",
+  "privacy.private.long": "Sólo mostrar a seguidores",
+  "privacy.private.short": "Privado",
+  "privacy.public.long": "Mostrar en la historia federada",
+  "privacy.public.short": "Público",
+  "privacy.unlisted.long": "No mostrar en la historia federada",
+  "privacy.unlisted.short": "Sin federar",
+  "reply_indicator.cancel": "Cancelar",
+  "report.heading": "New report",
+  "report.placeholder": "Additional comments",
+  "report.submit": "Submit",
+  "report.target": "Reporting",
+  "search.placeholder": "Buscar",
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Borrar",
+  "status.favourite": "Favorito",
+  "status.load_more": "Load more",
+  "status.media_hidden": "Media hidden",
+  "status.mention": "Mencionar",
+  "status.open": "Expandir estado",
+  "status.reblog": "Retoot",
+  "status.reblogged_by": "Retooteado por {name}",
+  "status.reply": "Responder",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Reportar",
+  "status.sensitive_toggle": "Click para ver",
+  "status.sensitive_warning": "Contenido sensible",
+  "status.show_less": "Mostrar menos",
+  "status.show_more": "Mostrar más",
+  "tabs_bar.compose": "Redactar",
+  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.home": "Inicio",
+  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.notifications": "Notificaciones",
+  "upload_area.title": "Drag & drop to upload",
+  "upload_button.label": "Subir multimedia",
+  "upload_form.undo": "Deshacer",
+  "upload_progress.label": "Uploading...",
+  "video_player.expand": "Expand video",
+  "video_player.toggle_sound": "Act/Desac. sonido",
+  "video_player.toggle_visible": "Toggle visibility",
+  "video_player.video_error": "Video could not be played"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
new file mode 100644
index 000000000..7f6585884
--- /dev/null
+++ b/app/javascript/mastodon/locales/fa.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "@{name} را مسدود کن",
+  "account.disclaimer": "این کاربر عضو سرور متفاوتی است. شاید عدد واقعی بیشتر از این باشد.",
+  "account.edit_profile": "ویرایش نمایه",
+  "account.follow": "پی بگیرید",
+  "account.followers": "پیگیران",
+  "account.follows": "پی می‌گیرد",
+  "account.follows_you": "پیگیر شماست",
+  "account.mention": "نام‌بردن از @{name}",
+  "account.mute": "بی‌صدا کردن @{name}",
+  "account.posts": "نوشته‌ها",
+  "account.report": "گزارش @{name}",
+  "account.requested": "در انتظار پذیرش",
+  "account.unblock": "رفع انسداد @{name}",
+  "account.unfollow": "پایان پیگیری",
+  "account.unmute": "باصدا کردن @{name}",
+  "boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید",
+  "column.blocks": "کاربران مسدودشده",
+  "column.community": "نوشته‌های محلی",
+  "column.favourites": "پسندیده‌ها",
+  "column.follow_requests": "درخواست‌های پیگیری",
+  "column.home": "خانه",
+  "column.mutes": "کاربران بی‌صداشده",
+  "column.notifications": "اعلان‌ها",
+  "column.public": "نوشته‌های همه‌جا",
+  "column_back_button.label": "بازگشت",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "تازه چه خبر؟",
+  "compose_form.privacy_disclaimer": "نوشتهٔ خصوصی شما به کاربران نام‌برده‌شده در {domains} فرستاده می‌شود. آیا به {domainsCount, plural, one {آن سرور} other {آن سرورها}} اعتماد دارید؟ تنظیمات حریم خصوصی نوشته‌ها تنها در سرورهای ماستدون کار می‌کند. اگر {domains} {domainsCount, plural, one {یک سرور ماستدون نباشد} other {سرورهای ماستدون نباشند}}، اشاره‌ای به خصوصی‌بودن نوشتهٔ شما نخواهد شد و شاید نوشتهٔ شما هم‌رسان شود یا برای کاربرانی که نمی‌خواهید نمایش یابد.",
+  "compose_form.publish": "بوق",
+  "compose_form.sensitive": "تصاویر حساس هستند",
+  "compose_form.spoiler": "نوشته را پشت هشدار پنهان کنید",
+  "compose_form.spoiler_placeholder": "هشدار محتوا",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "فعالیت",
+  "emoji_button.flags": "پرچم‌ها",
+  "emoji_button.food": "غذا و نوشیدنی",
+  "emoji_button.label": "افزودن شکلک",
+  "emoji_button.nature": "طبیعت",
+  "emoji_button.objects": "اشیا",
+  "emoji_button.people": "مردم",
+  "emoji_button.search": "جستجو...",
+  "emoji_button.symbols": "نمادها",
+  "emoji_button.travel": "سفر و مکان",
+  "empty_column.community": "فهرست نوشته‌های محلی خالی است. چیزی بنویسید تا چرخش بچرخد!",
+  "empty_column.hashtag": "هنوز هیچ چیزی با این هشتگ نیست.",
+  "empty_column.home": "شما هنوز پیگیر کسی نیستید. {public} را ببینید یا چیزی را جستجو کنید تا کاربران دیگر را ببینید.",
+  "empty_column.home.public_timeline": "فهرست نوشته‌های همه‌جا",
+  "empty_column.notifications": "هنوز هیچ اعلانی ندارید. به نوشته‌های دیگران واکنش نشان دهید تا گفتگو آغاز شود.",
+  "empty_column.public": "این‌جا هنوز چیزی نیست! خودتان چیزی بنویسید یا کاربران دیگر را پی بگیرید تا این‌جا پر شود",
+  "follow_request.authorize": "اجازه دهید",
+  "follow_request.reject": "اجازه ندهید",
+  "getting_started.apps": "اپ‌های گوناگونی در دسترس‌اند",
+  "getting_started.heading": "آغاز کنید",
+  "getting_started.open_source_notice": "ماستدون یک نرم‌افزار آزاد است. می‌توانید در ساخت آن مشارکت کنید یا مشکلاتش را در {github} گزارش دهید. {apps}.",
+  "home.column_settings.advanced": "پیشرفته",
+  "home.column_settings.basic": "اصلی",
+  "home.column_settings.filter_regex": "با عبارت‌های باقاعده فیلتر کنید",
+  "home.column_settings.show_reblogs": "نمایش بازبوق‌ها",
+  "home.column_settings.show_replies": "نمایش پاسخ‌ها",
+  "home.settings": "تنظیمات ستون",
+  "lightbox.close": "بستن",
+  "loading_indicator.label": "بارگیری...",
+  "media_gallery.toggle_visible": "تغییر پیدایی",
+  "missing_indicator.label": "پیدا نشد",
+  "navigation_bar.blocks": "کاربران مسدودشده",
+  "navigation_bar.community_timeline": "نوشته‌های محلی",
+  "navigation_bar.edit_profile": "ویرایش نمایه",
+  "navigation_bar.favourites": "پسندیده‌ها",
+  "navigation_bar.follow_requests": "درخواست‌های پیگیری",
+  "navigation_bar.info": "اطلاعات تکمیلی",
+  "navigation_bar.logout": "خروج",
+  "navigation_bar.mutes": "کاربران بی‌صداشده",
+  "navigation_bar.preferences": "ترجیحات",
+  "navigation_bar.public_timeline": "نوشته‌های همه‌جا",
+  "notification.favourite": "{name} نوشتهٔ شما را پسندید",
+  "notification.follow": "{name} پیگیر شما شد",
+  "notification.reblog": "{name} نوشتهٔ شما را بازبوقید",
+  "notifications.clear": "پاک‌کردن اعلان‌ها",
+  "notifications.clear_confirmation": "واقعاً می‌خواهید همهٔ اعلان‌هایتان را برای همیشه پاک کنید؟",
+  "notifications.column_settings.alert": "اعلان در کامپیوتر",
+  "notifications.column_settings.favourite": "پسندیده‌ها:",
+  "notifications.column_settings.follow": "پیگیران تازه:",
+  "notifications.column_settings.mention": "نام‌بردن‌ها:",
+  "notifications.column_settings.reblog": "بازبوق‌ها:",
+  "notifications.column_settings.show": "در ستون نشان بده",
+  "notifications.column_settings.sound": "صدا را پخش کن",
+  "notifications.settings": "تنظیمات ستون",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "تنظیم حریم خصوصی نوشته‌ها",
+  "privacy.direct.long": "تنها به کاربران نام‌برده‌شده نشان بده",
+  "privacy.direct.short": "مستقیم",
+  "privacy.private.long": "تنها به پیگیران نشان بده",
+  "privacy.private.short": "خصوصی",
+  "privacy.public.long": "در فهرست نوشته‌های عمومی نشان بده",
+  "privacy.public.short": "عمومی",
+  "privacy.unlisted.long": "در فهرست نوشته‌های همه‌جا نشان نده",
+  "privacy.unlisted.short": "فهرست‌نشده",
+  "reply_indicator.cancel": "لغو",
+  "report.heading": "گزارش تازه",
+  "report.placeholder": "توضیح اضافه",
+  "report.submit": "بفرست",
+  "report.target": "گزارش‌دادن",
+  "search.placeholder": "جستجو",
+  "search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
+  "status.cannot_reblog": "این نوشته را نمی‌شود بازبوقید",
+  "status.delete": "پاک‌کردن",
+  "status.favourite": "پسندیدن",
+  "status.load_more": "بیشتر نشان بده",
+  "status.media_hidden": "تصویر پنهان شده",
+  "status.mention": "از @{name} نام ببرید",
+  "status.open": "این نوشته را باز کن",
+  "status.reblog": "بوق",
+  "status.reblogged_by": "{name} بازبوقید",
+  "status.reply": "پاسخ",
+  "status.replyAll": "به نوشته پاسخ دهید",
+  "status.report": "@{name} را گزارش دهید",
+  "status.sensitive_toggle": "برای دیدن کلیک کنید",
+  "status.sensitive_warning": "محتوای حساس",
+  "status.show_less": "نهفتن",
+  "status.show_more": "نمایش",
+  "tabs_bar.compose": "بنویسید",
+  "tabs_bar.federated_timeline": "همگانی",
+  "tabs_bar.home": "خانه",
+  "tabs_bar.local_timeline": "محلی",
+  "tabs_bar.notifications": "اعلان‌ها",
+  "upload_area.title": "برای بارگذاری به این‌جا بکشید",
+  "upload_button.label": "افزودن تصویر",
+  "upload_form.undo": "واگردانی",
+  "upload_progress.label": "بارگذاری...",
+  "video_player.expand": "بازکردن ویدیو",
+  "video_player.toggle_sound": "تغییر صداداری",
+  "video_player.toggle_visible": "تغییر پیدایی",
+  "video_player.video_error": "ویدیو نمی‌تواند پخش شود"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
new file mode 100644
index 000000000..148a371ae
--- /dev/null
+++ b/app/javascript/mastodon/locales/fi.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Estä @{name}",
+  "account.disclaimer": "This user is from another instance. This number may be larger.",
+  "account.edit_profile": "Muokkaa",
+  "account.follow": "Seuraa",
+  "account.followers": "Seuraajia",
+  "account.follows": "Seuraa",
+  "account.follows_you": "Seuraa sinua",
+  "account.mention": "Mainitse @{name}",
+  "account.mute": "Mute @{name}",
+  "account.posts": "Postit",
+  "account.report": "Report @{name}",
+  "account.requested": "Odottaa hyväksyntää",
+  "account.unblock": "Salli @{name}",
+  "account.unfollow": "Lopeta seuraaminen",
+  "account.unmute": "Unmute @{name}",
+  "boost_modal.combo": "You can press {combo} to skip this next time",
+  "column.blocks": "Blocked users",
+  "column.community": "Paikallinen aikajana",
+  "column.favourites": "Favourites",
+  "column.follow_requests": "Follow requests",
+  "column.home": "Koti",
+  "column.mutes": "Muted users",
+  "column.notifications": "Ilmoitukset",
+  "column.public": "Yleinen aikajana",
+  "column_back_button.label": "Takaisin",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "Mitä sinulla on mielessä?",
+  "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.publish": "Toot",
+  "compose_form.sensitive": "Merkitse media herkäksi",
+  "compose_form.spoiler": "Piiloita teksti varoituksen taakse",
+  "compose_form.spoiler_placeholder": "Content warning",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Insert emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
+  "empty_column.hashtag": "There is nothing in this hashtag yet.",
+  "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.",
+  "empty_column.home.public_timeline": "the public timeline",
+  "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
+  "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
+  "follow_request.authorize": "Authorize",
+  "follow_request.reject": "Reject",
+  "getting_started.apps": "Various apps are available",
+  "getting_started.heading": "Aloitus",
+  "getting_started.open_source_notice": "Mastodon Mastodon on avoimen lähdekoodin ohjelma. Voit avustaa tai raportoida ongelmia GitHub palvelussa {github}. {apps}.",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.basic": "Basic",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "home.column_settings.show_reblogs": "Show boosts",
+  "home.column_settings.show_replies": "Show replies",
+  "home.settings": "Column settings",
+  "lightbox.close": "Sulje",
+  "loading_indicator.label": "Ladataan...",
+  "media_gallery.toggle_visible": "Toggle visibility",
+  "missing_indicator.label": "Not found",
+  "navigation_bar.blocks": "Blocked users",
+  "navigation_bar.community_timeline": "Paikallinen aikajana",
+  "navigation_bar.edit_profile": "Muokkaa profiilia",
+  "navigation_bar.favourites": "Favourites",
+  "navigation_bar.follow_requests": "Follow requests",
+  "navigation_bar.info": "Extended information",
+  "navigation_bar.logout": "Kirjaudu ulos",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.preferences": "Ominaisuudet",
+  "navigation_bar.public_timeline": "Yleinen aikajana",
+  "notification.favourite": "{name} tykkäsi statuksestasi",
+  "notification.follow": "{name} seurasi sinua",
+  "notification.reblog": "{name} buustasi statustasi",
+  "notifications.clear": "Clear notifications",
+  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
+  "notifications.column_settings.alert": "Työpöytä ilmoitukset",
+  "notifications.column_settings.favourite": "Tykkäyksiä:",
+  "notifications.column_settings.follow": "Uusia seuraajia:",
+  "notifications.column_settings.mention": "Mainintoja:",
+  "notifications.column_settings.reblog": "Buusteja:",
+  "notifications.column_settings.show": "Näytä sarakkeessa",
+  "notifications.column_settings.sound": "Play sound",
+  "notifications.settings": "Column settings",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Adjust status privacy",
+  "privacy.direct.long": "Post to mentioned users only",
+  "privacy.direct.short": "Direct",
+  "privacy.private.long": "Post to followers only",
+  "privacy.private.short": "Followers-only",
+  "privacy.public.long": "Post to public timelines",
+  "privacy.public.short": "Public",
+  "privacy.unlisted.long": "Do not show in public timelines",
+  "privacy.unlisted.short": "Unlisted",
+  "reply_indicator.cancel": "Peruuta",
+  "report.heading": "New report",
+  "report.placeholder": "Additional comments",
+  "report.submit": "Submit",
+  "report.target": "Reporting",
+  "search.placeholder": "Hae",
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Poista",
+  "status.favourite": "Tykkää",
+  "status.load_more": "Load more",
+  "status.media_hidden": "Media hidden",
+  "status.mention": "Mainitse @{name}",
+  "status.open": "Expand this status",
+  "status.reblog": "Buustaa",
+  "status.reblogged_by": "{name} buustasi",
+  "status.reply": "Vastaa",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Report @{name}",
+  "status.sensitive_toggle": "Klikkaa nähdäksesi",
+  "status.sensitive_warning": "Arkaluontoista sisältöä",
+  "status.show_less": "Show less",
+  "status.show_more": "Show more",
+  "tabs_bar.compose": "Luo",
+  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.home": "Koti",
+  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.notifications": "Ilmoitukset",
+  "upload_area.title": "Drag & drop to upload",
+  "upload_button.label": "Lisää mediaa",
+  "upload_form.undo": "Peru",
+  "upload_progress.label": "Uploading...",
+  "video_player.expand": "Expand video",
+  "video_player.toggle_sound": "Äänet päälle/pois",
+  "video_player.toggle_visible": "Toggle visibility",
+  "video_player.video_error": "Video could not be played"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
new file mode 100644
index 000000000..36a5b04c6
--- /dev/null
+++ b/app/javascript/mastodon/locales/fr.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Bloquer",
+  "account.disclaimer": "Ce compte est situé sur une autre instance. Les nombres peuvent être plus grands.",
+  "account.edit_profile": "Modifier le profil",
+  "account.follow": "Suivre",
+  "account.followers": "Abonné⋅e⋅s",
+  "account.follows": "Abonnements",
+  "account.follows_you": "Vous suit",
+  "account.mention": "Mentionner",
+  "account.mute": "Masquer",
+  "account.posts": "Statuts",
+  "account.report": "Signaler",
+  "account.requested": "Invitation envoyée",
+  "account.unblock": "Débloquer",
+  "account.unfollow": "Ne plus suivre",
+  "account.unmute": "Ne plus masquer",
+  "boost_modal.combo": "You can press {combo} to skip this next time",
+  "column.blocks": "Comptes bloqués",
+  "column.community": "Fil public local",
+  "column.favourites": "Favoris",
+  "column.follow_requests": "Demandes de suivi",
+  "column.home": "Accueil",
+  "column.mutes": "Muted users",
+  "column.notifications": "Notifications",
+  "column.public": "Fil public global",
+  "column_back_button.label": "Retour",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "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 ? Les statuts privés ne fonctionnent que sur les instances de Mastodon. 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.private": "Rendre privé",
+  "compose_form.publish": "Pouet",
+  "compose_form.sensitive": "Marquer le média comme délicat",
+  "compose_form.spoiler": "Masquer le texte derrière un avertissement",
+  "compose_form.spoiler_placeholder": "Avertissement",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Insérer un emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "Le fil public local est vide. Écrivez-donc quelque chose pour le remplir !",
+  "empty_column.hashtag": "Il n’y a encore aucun contenu relatif à ce hashtag",
+  "empty_column.home.public_timeline": "le fil public",
+  "empty_column.home": "Vous ne suivez encore personne. Visitez {public} ou bien utilisez la recherche pour vous connecter à d’autres utilisateurs⋅trices.",
+  "empty_column.notifications": "Vous n’avez pas encore de notification. Interagissez avec d’autres utilisateurs⋅trices pour débuter la conversation.",
+  "empty_column.public": "Il n’y a rien ici ! Écrivez quelque chose publiquement, ou bien suivez manuellement des utilisateurs⋅trices d’autres instances pour remplir le fil public.",
+  "follow_request.authorize": "Autoriser",
+  "follow_request.reject": "Rejeter",
+  "getting_started.apps": "Various apps are available",
+  "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.",
+  "home.column_settings.advanced": "Avancé",
+  "home.column_settings.basic": "Basique",
+  "home.column_settings.filter_regex": "Filtrer avec une expression rationnelle",
+  "home.column_settings.show_reblogs": "Afficher les partages",
+  "home.column_settings.show_replies": "Afficher les réponses",
+  "home.settings": "Paramètres de la colonne",
+  "lightbox.close": "Fermer",
+  "loading_indicator.label": "Chargement…",
+  "media_gallery.toggle_visible": "Modifier la visibilité",
+  "missing_indicator.label": "Non trouvé",
+  "navigation_bar.blocks": "Comptes bloqués",
+  "navigation_bar.community_timeline": "Fil public local",
+  "navigation_bar.edit_profile": "Modifier le profil",
+  "navigation_bar.favourites": "Favoris",
+  "navigation_bar.follow_requests": "Demandes de suivi",
+  "navigation_bar.info": "Plus d’informations",
+  "navigation_bar.logout": "Déconnexion",
+  "navigation_bar.mutes": "Comptes silencés",
+  "navigation_bar.preferences": "Préférences",
+  "navigation_bar.public_timeline": "Fil public global",
+  "notification.favourite": "{name} a ajouté à ses favoris :",
+  "notification.follow": "{name} vous suit.",
+  "notification.reblog": "{name} a partagé votre statut :",
+  "notifications.clear": "Nettoyer",
+  "notifications.clear_confirmation": "Voulez-vous vraiment supprimer toutes vos notifications ?",
+  "notifications.column_settings.alert": "Notifications locales",
+  "notifications.column_settings.favourite": "Favoris :",
+  "notifications.column_settings.follow": "Nouveaux abonné⋅e⋅s :",
+  "notifications.column_settings.mention": "Mentions :",
+  "notifications.column_settings.reblog": "Partages :",
+  "notifications.column_settings.show": "Afficher dans la colonne",
+  "notifications.column_settings.sound": "Émettre un son",
+  "notifications.settings": "Paramètres de la colonne",
+  "onboarding.done": "Done",
+  "onboarding.next": "Suivant",
+  "onboarding.page_five.public_timelines": "Le fil public global affiche les posts de tou⋅te⋅s les utilisateurs⋅trices suivi⋅es par les membres de {domain}. Le fil public local est identique mais se limite aux utilisateurs⋅trices de {domain}.",
+  "onboarding.page_four.home": "L’Accueil affiche les posts de tou⋅te⋅s les utilisateurs⋅trices que vous suivez",
+  "onboarding.page_four.notifications": "Les Notifications vous informent lorsque quelqu’un interagit avec vous",
+  "onboarding.page_one.federation": "Mastodon est un réseau social qui appartient à tou⋅te⋅s.",
+  "onboarding.page_one.handle": "Vous êtes sur {domain}, une des nombreuses instances indépendantes de Mastodon. Votre nom d’utilisateur⋅trice complet est {handle}",
+  "onboarding.page_one.welcome": "Bienvenue sur Mastodon !",
+  "onboarding.page_six.admin": "L’administrateur⋅trice de votre instance est {admin}",
+  "onboarding.page_six.almost_done": "Nous y sommes presque…",
+  "onboarding.page_six.apps_available": "De nombreuses {apps} sont disponibles pour iOS, Android et autres. Et maintenant… Bon Appetoot!",
+  "onboarding.page_six.github": "Mastodon est un logiciel libre, gratuit et open-source. Vous pouvez rapporter des bogues, suggérer des fonctionnalités, ou contribuer à son développement sur {github}.",
+  "onboarding.page_six.guidelines": "règles de la communauté",
+  "onboarding.page_six.read_guidelines": "S’il vous plaît, n’oubliez pas de lire les {guidelines} !",
+  "onboarding.page_six.various_app": "applications mobiles",
+  "onboarding.page_three.profile": "Modifiez votre profil pour changer votre avatar, votre description ainsi que votre nom. Vous y trouverez également d’autres préférences.",
+  "onboarding.page_three.search": "Utilisez la barre de recherche pour trouver des utilisateurs⋅trices et regarder des hashtags tels que {illustration} et {introductions}. Pour trouver quelqu’un qui n’est pas sur cette instance, utilisez son nom d’utilisateur⋅trice complet.",
+  "onboarding.page_two.compose": "Écrivez depuis la colonne de composition. Vous pouvez ajouter des images, changer les réglages de confidentialité, et ajouter des avertissements de contenu (Content Warning) grâce aux icônes en dessous.",
+  "onboarding.skip": "Passer",
+  "privacy.change": "Ajuster la confidentialité du message",
+  "privacy.direct.long": "N’afficher que pour les personnes mentionnées",
+  "privacy.direct.short": "Direct",
+  "privacy.private.long": "N’afficher que pour vos abonné⋅e⋅s",
+  "privacy.private.short": "Privé",
+  "privacy.public.long": "Afficher dans les fils publics",
+  "privacy.public.short": "Public",
+  "privacy.unlisted.long": "Ne pas afficher dans les fils publics",
+  "privacy.unlisted.short": "Non-listé",
+  "reply_indicator.cancel": "Annuler",
+  "report.heading": "Nouveau signalement",
+  "report.placeholder": "Commentaires additionnels",
+  "report.submit": "Envoyer",
+  "report.target": "Signalement",
+  "search.placeholder": "Rechercher",
+  "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Effacer",
+  "status.favourite": "Ajouter aux favoris",
+  "status.load_more": "Charger plus",
+  "status.media_hidden": "Média caché",
+  "status.mention": "Mentionner",
+  "status.open": "Déplier ce statut",
+  "status.reblog": "Partager",
+  "status.reblogged_by": "{name} a partagé :",
+  "status.reply": "Répondre",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Signaler @{name}",
+  "status.sensitive_toggle": "Cliquer pour dévoiler",
+  "status.sensitive_warning": "Contenu délicat",
+  "status.show_less": "Replier",
+  "status.show_more": "Déplier",
+  "tabs_bar.compose": "Composer",
+  "tabs_bar.federated_timeline": "Fil public global",
+  "tabs_bar.home": "Accueil",
+  "tabs_bar.local_timeline": "Fil public local",
+  "tabs_bar.notifications": "Notifications",
+  "upload_area.title": "Glissez et déposez pour envoyer",
+  "upload_button.label": "Joindre un média",
+  "upload_form.undo": "Annuler",
+  "upload_progress.label": "Envoi en cours…",
+  "video_player.expand": "Expand video",
+  "video_player.toggle_sound": "Mettre/Couper le son",
+  "video_player.toggle_visible": "Afficher/Cacher la vidéo",
+  "video_player.video_error": "Video could not be played"
+}
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
new file mode 100644
index 000000000..f8945dc1c
--- /dev/null
+++ b/app/javascript/mastodon/locales/he.json
@@ -0,0 +1,165 @@
+{
+  "account.block": "חסימת @{name}",
+  "account.disclaimer": "משתמש זה מגיע מקהילה אחרת. המספר הזה עשוי להיות גדול יותר.",
+  "account.edit_profile": "עריכת פרופיל",
+  "account.follow": "מעקב",
+  "account.followers": "עוקבים",
+  "account.follows_you": "במעקב אחריך",
+  "account.follows": "נעקבים",
+  "account.mention": "אזכור של @{name}",
+  "account.mute": "להשתיק את @{name}",
+  "account.posts": "הודעות",
+  "account.report": "לדווח על @{name}",
+  "account.requested": "בהמתנה לאישור",
+  "account.unblock": "הסרת חסימה מעל @{name}",
+  "account.unfollow": "הפסקת מעקב",
+  "account.unmute": "הפסקת השתקת @{name}",
+  "boost_modal.combo": "ניתן להקיש {combo} כדי לדלג בפעם הבאה",
+  "column.blocks": "חסימות",
+  "column.community": "פיד מקומי",
+  "column.favourites": "חיבובים",
+  "column.follow_requests": "בקשות מעקב",
+  "column.home": "בבית",
+  "column.mutes": "השתקות",
+  "column.notifications": "התראות",
+  "column.public": "בפרהסיה",
+  "column_back_button.label": "אחורה",
+  "column_subheading.navigation": "ניווט",
+  "column_subheading.settings": "אפשרויות",
+  "compose_form.lock_disclaimer": "חשבונך אינו {locked}. כל אחד יוכל לעקוב אחריך כדי לקרוא את הודעותיך המיועדות לעוקבים בלבד.",
+  "compose_form.lock_disclaimer.lock": "נעול",
+  "compose_form.placeholder": "מה עובר לך בראש?",
+  "compose_form.privacy_disclaimer": "הודעתך הפרטית תשלח למשתמשים על {domains}. האם ניתן לסמוך על {domainsCount, plural, one {שרת זה} other {שרתים אלו}}? פרטיות ההודעה קיימת רק על שרתי מסטודון. אם {domains} {domainsCount, plural, one {הוא לא שרת מסטודון} other {הם לא שרתי מסטודון}}, לא יהיה שום סימן שההודעה פרטית, והוא עשוי להיות מקודם או להחשף למשתמשים שלא ברשימת היעד.",
+  "compose_form.publish": "לחצרץ",
+  "compose_form.sensitive": "סימון תוכן כרגיש",
+  "compose_form.spoiler": "הסתרה מאחורי אזהרת תוכן",
+  "compose_form.spoiler_placeholder": "אזהרת תוכן",
+  "confirmation_modal.cancel": "ביטול",
+  "confirmations.block.confirm": "לחסום",
+  "confirmations.block.message": "לחסום את {name}?",
+  "confirmations.delete.confirm": "למחוק",
+  "confirmations.delete.message": "למחוק את ההודעה?",
+  "confirmations.mute.confirm": "להשתיק",
+  "confirmations.mute.message": "להשתיק את {name}?",
+  "emoji_button.activity": "פעילות",
+  "emoji_button.flags": "דגלים",
+  "emoji_button.food": "אוכל ושתיה",
+  "emoji_button.label": "הוספת אמוג'י",
+  "emoji_button.nature": "טבע",
+  "emoji_button.objects": "חפצים",
+  "emoji_button.people": "אנשים",
+  "emoji_button.search": "חיפוש...",
+  "emoji_button.symbols": "סמלים",
+  "emoji_button.travel": "טיולים ואתרים",
+  "empty_column.community": "טור הסביבה ריק. יש לפרסם משהו כדי שדברים יתרחילו להתגלגל!",
+  "empty_column.hashtag": "אין כלום בהאשתג הזה עדיין.",
+  "empty_column.home.public_timeline": "בפרהסיה",
+  "empty_column.home": "אף אחד לא במעקב עדיין. אפשר לבקר ב{public} או להשתמש בחיפוש כדי להתחיל ולהכיר חצוצרנים אחרים.",
+  "empty_column.notifications": "אין התראות עדיין. יאללה, הגיע הזמן להתחיל להתערבב!",
+  "empty_column.public": "אין פה כלום! כדי למלא את הטור הזה אפשר לכתוב משהו, או להתחיל לעקוב אחרי אנשים מקהילות אחרות.",
+  "follow_request.authorize": "קבלה",
+  "follow_request.reject": "דחיה",
+  "getting_started.apps": "קיים מבחר יישומונים לניידים",
+  "getting_started.heading": "על ההתחלה",
+  "getting_started.open_source_notice": "מסטודון היא תוכנה חופשית (בקוד פתוח). ניתן לתרום או לדווח על בעיות בגיטהאב: {github}. {apps}.",
+  "home.column_settings.advanced": "למתקדמים",
+  "home.column_settings.basic": "למתחילים",
+  "home.column_settings.filter_regex": "סינון באמצעות ביטויים רגולריים (regular expressions)",
+  "home.column_settings.show_reblogs": "הצגת הדהודים",
+  "home.column_settings.show_replies": "הצגת תגובות",
+  "home.settings": "הגדרות טור",
+  "lightbox.close": "סגירה",
+  "loading_indicator.label": "טוען...",
+  "media_gallery.toggle_visible": "נראה\\בלתי נראה",
+  "missing_indicator.label": "לא נמצא",
+  "navigation_bar.blocks": "חסימות",
+  "navigation_bar.community_timeline": "פיד מקומי",
+  "navigation_bar.edit_profile": "עריכת פרופיל",
+  "navigation_bar.favourites": "חיבובים",
+  "navigation_bar.follow_requests": "בקשות מעקב",
+  "navigation_bar.info": "מידע נוסף",
+  "navigation_bar.logout": "יציאה",
+  "navigation_bar.mutes": "השתקות",
+  "navigation_bar.preferences": "העדפות",
+  "navigation_bar.public_timeline": "בפרהסיה",
+  "notification.favourite": "חצרוצך חובב על ידי {name}",
+  "notification.follow": "{name} במעקב אחרייך",
+  "notification.mention": "אוזכרת ע\"י {name}",
+  "notification.reblog": "חצרוצך הודהד על ידי {name}",
+  "notifications.clear": "הסרת התראות",
+  "notifications.clear_confirmation": "להסיר את כל ההתראות? בטוח?",
+  "notifications.column_settings.alert": "התראות לשולחן העבודה",
+  "notifications.column_settings.favourite": "מחובבים:",
+  "notifications.column_settings.follow": "עוקבים חדשים:",
+  "notifications.column_settings.mention": "פניות:",
+  "notifications.column_settings.reblog": "הדהודים:",
+  "notifications.column_settings.show": "הצגה בטור",
+  "notifications.column_settings.sound": "שמע מופעל",
+  "notifications.settings": "הגדרות טור",
+  "onboarding.done": "יציאה",
+  "onboarding.next": "הלאה",
+  "onboarding.page_five.public_timelines": "ציר הזמן המקומי מראה הודעות פומביות מכל באי קהילת {domain}. ציר הזמן העולמי מראה הודעות פומביות מאת כי מי שבאי קהילת {domain} עוקבים אחריו. אלו צירי הזמן הפומביים, דרך נהדרת לגלות אנשים חדשים.",
+  "onboarding.page_four.home": "ציר זמן הבית מראה הודעות מהנעקבים שלך.",
+  "onboarding.page_four.notifications": "טור ההתראות מראה כשמישהו מתייחס להודעות שלך.",
+  "onboarding.page_one.federation": "מסטודון היא רשת של שרתים עצמאיים מצורפים ביחד לכדי רשת חברתית אחת גדולה. אנחנו מכנים את השרתים האלו: קהילות",
+  "onboarding.page_one.handle": "אתם בקהילה {domain}, ולכן מזהה המשתמש המלא שלכם הוא {handle}",
+  "onboarding.page_one.welcome": "ברוכים הבאים למסטודון!",
+  "onboarding.page_six.admin": "הקהילה מנוהלת בידי {admin}.",
+  "onboarding.page_six.almost_done": "כמעט סיימנו...",
+  "onboarding.page_six.appetoot": "בתותאבון!",
+  "onboarding.page_six.apps_available": "קיימים {apps} זמינים עבור אנדרואיד, אייפון ופלטפורמות נוספות.",
+  "onboarding.page_six.github": "מסטודון הוא תוכנה חופשית. ניתן לדווח על באגים, לבקש יכולות, או לתרום לקוד באתר {github}.",
+  "onboarding.page_six.guidelines": "חוקי הקהילה",
+  "onboarding.page_six.read_guidelines": "נא לקרוא את {guidelines} של {domain}!",
+  "onboarding.page_six.various_app": "יישומונים ניידים",
+  "onboarding.page_three.profile": "ץתחת 'עריכת פרופיל' ניתן להחליף את תמונת הפרופיל שלך, תיאור קצר, והשם המוצג. שם גם ניתן למצוא אפשרויות והעדפות נוספות.",
+  "onboarding.page_three.search": "בחלונית החיפוש ניתן לחפש אנשים והאשתגים, כמו למשל {illustration} או {introductions}. כדי למצוא מישהו שלא על האינסטנס המקומי, יש להשתמש בכינוי המשתמש המלא.",
+  "onboarding.page_two.compose": "הודעות כותבים מטור הכתיבה. ניתן לנעלות תמונות, לשנות הגדרות פרטיות, ולהוסיף אזהרות תוכן בעזרת האייקונים שמתחת.",
+  "onboarding.skip": "לדלג",
+  "privacy.change": "שינוי פרטיות ההודעה",
+  "privacy.direct.long": "הצג רק למי שהודעה זו פונה אליו",
+  "privacy.direct.short": "הודעה ישירה",
+  "privacy.private.long": "הצג לעוקבים מקומיים בלבד",
+  "privacy.private.short": "לעוקבים בלבד",
+  "privacy.public.long": "פרסם בפומבי",
+  "privacy.public.short": "פומבי",
+  "privacy.unlisted.long": "לא יופיע בפידים הציבוריים המשותפים",
+  "privacy.unlisted.short": "לא לפיד הכללי",
+  "reply_indicator.cancel": "ביטול",
+  "report.heading": "דווח חדש",
+  "report.placeholder": "הערות נוספות",
+  "report.submit": "שליחה",
+  "report.target": "דיווח",
+  "search.placeholder": "חיפוש",
+  "search.status_by": "הודעה מאת {name}",
+  "search_results.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}",
+  "status.cannot_reblog": "לא ניתן להדהד הודעה זו",
+  "status.delete": "מחיקה",
+  "status.favourite": "חיבוב",
+  "status.load_more": "עוד",
+  "status.media_hidden": "מדיה מוסתרת",
+  "status.mention": "פניה אל @{name}",
+  "status.open": "הרחבת הודעה",
+  "status.reblog": "הדהוד",
+  "status.reblogged_by": "הודהד על ידי {name}",
+  "status.reply": "תגובה",
+  "status.replyAll": "תגובה לכולם",
+  "status.report": "דיווח על @{name}",
+  "status.sensitive_warning": "תוכן רגיש",
+  "status.sensitive_toggle": "לחצו כדי לראות",
+  "status.show_less": "הראה פחות",
+  "status.show_more": "הראה יותר",
+  "tabs_bar.compose": "חיבור",
+  "tabs_bar.federated_timeline": "בפדרציה",
+  "tabs_bar.home": "בבית",
+  "tabs_bar.local_timeline": "פיד מקומי",
+  "tabs_bar.notifications": "התראות",
+  "upload_area.title": "ניתן להעלות על ידי Drag & drop",
+  "upload_button.label": "הוספת מדיה",
+  "upload_form.undo": "ביטול",
+  "upload_progress.label": "עולה...",
+  "video_player.expand": "הרחבת וידאו",
+  "video_player.toggle_sound": "הפעלת\\ביטול שמע",
+  "video_player.toggle_visible": "הפעלת\\ביטול תצוגה",
+  "video_player.video_error": "לא ניתן לנגן וידאו"
+}
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
new file mode 100644
index 000000000..45c3cb7f6
--- /dev/null
+++ b/app/javascript/mastodon/locales/hr.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Blokiraj @{name}",
+  "account.disclaimer": "Ovaj korisnik je sa druge instance. Ovaj broj bi mogao biti veći.",
+  "account.edit_profile": "Uredi profil",
+  "account.follow": "Slijedi",
+  "account.followers": "Sljedbenici",
+  "account.follows": "Slijedi",
+  "account.follows_you": "te slijedi",
+  "account.mention": "Spomeni @{name}",
+  "account.mute": "Utišaj @{name}",
+  "account.posts": "Postovi",
+  "account.report": "Prijavi @{name}",
+  "account.requested": "Čeka pristanak",
+  "account.unblock": "Deblokiraj @{name}",
+  "account.unfollow": "Prestani slijediti",
+  "account.unmute": "Poništi utišavanje @{name}",
+  "boost_modal.combo": "Možeš pritisnuti {combo} kako bi ovo preskočio sljedeći put",
+  "column.blocks": "Blokirani korisnici",
+  "column.community": "Lokalni timeline",
+  "column.favourites": "Favoriti",
+  "column.follow_requests": "Zahtjevi za slijeđenje",
+  "column.home": "Dom",
+  "column.mutes": "Muted users",
+  "column.notifications": "Notifikacije",
+  "column.public": "Federalni timeline",
+  "column_back_button.label": "Natrag",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "Što ti je na umu?",
+  "compose_form.privacy_disclaimer": "Tvoj privatni status će biti dostavljen spomenutim korisnicima na {domains}. Vjeruješ li {domainsCount, plural, one {that server} drugim {those servers}}? Privatnost postova radi samo na Mastodon instancama. Ako {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, neće biti indikacije da je tvoj post privatan, i mogao bit biti podignut ili biti učinjen vidljivim na drugi način neželjenim primateljima.",
+  "compose_form.publish": "Toot",
+  "compose_form.sensitive": "Označi media sadržaj kao osjetljiv",
+  "compose_form.spoiler": "Sakrij text iza upozorenja",
+  "compose_form.spoiler_placeholder": "Upozorenje o sadržaju",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Umetni smajlije",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "Lokalni timeline je prazan. Napiši nešto javno kako bi pokrenuo stvari!",
+  "empty_column.hashtag": "Još ne postoji ništa s ovim hashtagom.",
+  "empty_column.home": "Još ne slijediš nikoga. Posjeti {public} ili koristi tražilicu kako bi počeo i upoznao druge korisnike.",
+  "empty_column.home.public_timeline": "javni timeline",
+  "empty_column.notifications": "Još nemaš notifikacija. Komuniciraj sa drugima kako bi započeo razgovor.",
+  "empty_column.public": "Ovdje nema ništa! Napiši nešto javno, ili ručno slijedi korisnike sa drugih instanci kako bi popunio",
+  "follow_request.authorize": "Authoriziraj",
+  "follow_request.reject": "Odbij",
+  "getting_started.apps": "Dostupne su razne aplikacije",
+  "getting_started.heading": "Počnimo",
+  "getting_started.open_source_notice": "Mastodon je softver otvorenog koda. Možeš pridonijeti ili prijaviti probleme na GitHubu  {github}. {apps}.",
+  "home.column_settings.advanced": "Napredno",
+  "home.column_settings.basic": "Osnovno",
+  "home.column_settings.filter_regex": "Filtriraj s regularnim izrazima",
+  "home.column_settings.show_reblogs": "Pokaži boosts",
+  "home.column_settings.show_replies": "Pokaži odgovore",
+  "home.settings": "Postavke Stupca",
+  "lightbox.close": "Zatvori",
+  "loading_indicator.label": "Učitavam...",
+  "media_gallery.toggle_visible": "Preklopi vidljivost",
+  "missing_indicator.label": "Nije nađen",
+  "navigation_bar.blocks": "Blokirani korisnici",
+  "navigation_bar.community_timeline": "Lokalni timeline",
+  "navigation_bar.edit_profile": "Uredi profil",
+  "navigation_bar.favourites": "Favoriti",
+  "navigation_bar.follow_requests": "Zahtjevi za sljeđenje",
+  "navigation_bar.info": "Proširena informacija",
+  "navigation_bar.logout": "Odjavi se",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.preferences": "Postavke",
+  "navigation_bar.public_timeline": "Federalni timeline",
+  "notification.favourite": "{name} je lajkao tvoj status",
+  "notification.follow": "{name} te sada slijedi",
+  "notification.reblog": "{name} je podigao tvoj status",
+  "notifications.clear": "Očisti notifikacije",
+  "notifications.clear_confirmation": "Želiš li zaista obrisati sve svoje notifikacije?",
+  "notifications.column_settings.alert": "Desktop notifikacije",
+  "notifications.column_settings.favourite": "Favoriti:",
+  "notifications.column_settings.follow": "Novi sljedbenici:",
+  "notifications.column_settings.mention": "Spominjanja:",
+  "notifications.column_settings.reblog": "Boosts:",
+  "notifications.column_settings.show": "Prikaži u stupcu",
+  "notifications.column_settings.sound": "Sviraj zvuk",
+  "notifications.settings": "Postavke rubrike",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Podesi status privatnosti",
+  "privacy.direct.long": "Prikaži samo spomenutim korisnicima",
+  "privacy.direct.short": "Direktno",
+  "privacy.private.long": "Prikaži samo sljedbenicima",
+  "privacy.private.short": "Privatno",
+  "privacy.public.long": "Postaj na javne timeline",
+  "privacy.public.short": "Javno",
+  "privacy.unlisted.long": "Ne prikazuj u javnim timelineovima",
+  "privacy.unlisted.short": "Unlisted",
+  "reply_indicator.cancel": "Otkaži",
+  "report.heading": "Nova prijava",
+  "report.placeholder": "Dodatni komentari",
+  "report.submit": "Pošalji",
+  "report.target": "Prijavljivanje",
+  "search.placeholder": "Traži",
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Obriši",
+  "status.favourite": "Označi omiljenim",
+  "status.load_more": "Učitaj više",
+  "status.media_hidden": "Sakriven media sadržaj",
+  "status.mention": "Spomeni @{name}",
+  "status.open": "Proširi ovaj status",
+  "status.reblog": "Podigni",
+  "status.reblogged_by": "{name} je podigao",
+  "status.reply": "Odgovori",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Prijavi @{name}",
+  "status.sensitive_toggle": "Klikni da bi vidio",
+  "status.sensitive_warning": "Osjetljiv sadržaj",
+  "status.show_less": "Pokaži manje",
+  "status.show_more": "Pokaži više",
+  "tabs_bar.compose": "Sastavi",
+  "tabs_bar.federated_timeline": "Federalni",
+  "tabs_bar.home": "Dom",
+  "tabs_bar.local_timeline": "Lokalno",
+  "tabs_bar.notifications": "Notifikacije",
+  "upload_area.title": "Povuci & spusti kako bi uploadao",
+  "upload_button.label": "Dodaj media",
+  "upload_form.undo": "Poništi",
+  "upload_progress.label": "Uploadam...",
+  "video_player.expand": "Proširi video",
+  "video_player.toggle_sound": "Toggle zvuk",
+  "video_player.toggle_visible": "Preklopi vidljivost",
+  "video_player.video_error": "Video could not be played"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
new file mode 100644
index 000000000..e1d9d36be
--- /dev/null
+++ b/app/javascript/mastodon/locales/hu.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Blokkolás",
+  "account.disclaimer": "This user is from another instance. This number may be larger.",
+  "account.edit_profile": "Profil szerkesztése",
+  "account.follow": "Követés",
+  "account.followers": "Követők",
+  "account.follows": "Követve",
+  "account.follows_you": "Követnek téged",
+  "account.mention": "Említés",
+  "account.mute": "Mute @{name}",
+  "account.posts": "Posts",
+  "account.report": "Report @{name}",
+  "account.requested": "Awaiting approval",
+  "account.unblock": "Blokkolás levétele",
+  "account.unfollow": "Követés abbahagyása",
+  "account.unmute": "Unmute @{name}",
+  "boost_modal.combo": "You can press {combo} to skip this next time",
+  "column.blocks": "Blocked users",
+  "column.community": "Local timeline",
+  "column.favourites": "Favourites",
+  "column.follow_requests": "Follow requests",
+  "column.home": "Kezdőlap",
+  "column.mutes": "Muted users",
+  "column.notifications": "Értesítések",
+  "column.public": "Nyilvános",
+  "column_back_button.label": "Vissza",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "Mire gondolsz?",
+  "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.publish": "Tülk!",
+  "compose_form.sensitive": "Tartalom érzékenynek jelölése",
+  "compose_form.spoiler": "Hide text behind warning",
+  "compose_form.spoiler_placeholder": "Content warning",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Insert emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
+  "empty_column.hashtag": "There is nothing in this hashtag yet.",
+  "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.",
+  "empty_column.home.public_timeline": "the public timeline",
+  "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
+  "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
+  "follow_request.authorize": "Authorize",
+  "follow_request.reject": "Reject",
+  "getting_started.apps": "Various apps are available",
+  "getting_started.heading": "Első lépések",
+  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.basic": "Basic",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "home.column_settings.show_reblogs": "Show boosts",
+  "home.column_settings.show_replies": "Show replies",
+  "home.settings": "Column settings",
+  "lightbox.close": "Bezárás",
+  "loading_indicator.label": "Betöltés...",
+  "media_gallery.toggle_visible": "Toggle visibility",
+  "missing_indicator.label": "Not found",
+  "navigation_bar.blocks": "Blocked users",
+  "navigation_bar.community_timeline": "Local timeline",
+  "navigation_bar.edit_profile": "Profil szerkesztése",
+  "navigation_bar.favourites": "Favourites",
+  "navigation_bar.follow_requests": "Follow requests",
+  "navigation_bar.info": "Extended information",
+  "navigation_bar.logout": "Kijelentkezés",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.preferences": "Beállítások",
+  "navigation_bar.public_timeline": "Nyilvános időfolyam",
+  "notification.favourite": "{name} kedvencnek jelölte az állapotod",
+  "notification.follow": "{name} követ téged",
+  "notification.reblog": "{name} reblogolta az állapotod",
+  "notifications.clear": "Clear notifications",
+  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
+  "notifications.column_settings.alert": "Desktop notifications",
+  "notifications.column_settings.favourite": "Favourites:",
+  "notifications.column_settings.follow": "New followers:",
+  "notifications.column_settings.mention": "Mentions:",
+  "notifications.column_settings.reblog": "Boosts:",
+  "notifications.column_settings.show": "Show in column",
+  "notifications.column_settings.sound": "Play sound",
+  "notifications.settings": "Column settings",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Adjust status privacy",
+  "privacy.direct.long": "Post to mentioned users only",
+  "privacy.direct.short": "Direct",
+  "privacy.private.long": "Post to followers only",
+  "privacy.private.short": "Followers-only",
+  "privacy.public.long": "Post to public timelines",
+  "privacy.public.short": "Public",
+  "privacy.unlisted.long": "Do not show in public timelines",
+  "privacy.unlisted.short": "Unlisted",
+  "reply_indicator.cancel": "Mégsem",
+  "report.heading": "New report",
+  "report.placeholder": "Additional comments",
+  "report.submit": "Submit",
+  "report.target": "Reporting",
+  "search.placeholder": "Keresés",
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Törlés",
+  "status.favourite": "Kedvenc",
+  "status.load_more": "Load more",
+  "status.media_hidden": "Media hidden",
+  "status.mention": "Említés",
+  "status.open": "Expand this status",
+  "status.reblog": "Reblog",
+  "status.reblogged_by": "{name} reblogolta",
+  "status.reply": "Válasz",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Report @{name}",
+  "status.sensitive_toggle": "Katt a megtekintéshez",
+  "status.sensitive_warning": "Érzékeny tartalom",
+  "status.show_less": "Show less",
+  "status.show_more": "Show more",
+  "tabs_bar.compose": "Összeállítás",
+  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.home": "Kezdőlap",
+  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.notifications": "Notifications",
+  "upload_area.title": "Drag & drop to upload",
+  "upload_button.label": "Média hozzáadása",
+  "upload_form.undo": "Mégsem",
+  "upload_progress.label": "Uploading...",
+  "video_player.expand": "Expand video",
+  "video_player.toggle_sound": "Hang kapcsolása",
+  "video_player.toggle_visible": "Toggle visibility",
+  "video_player.video_error": "Video could not be played"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
new file mode 100644
index 000000000..d73915a36
--- /dev/null
+++ b/app/javascript/mastodon/locales/id.json
@@ -0,0 +1,167 @@
+{
+  "account.block": "Blokir @{name}",
+  "account.disclaimer": "Pengguna ini berasal dari server lain. Angka berikut mungkin lebih besar.",
+  "account.edit_profile": "Ubah profil",
+  "account.follow": "Ikuti",
+  "account.followers": "Pengikut",
+  "account.follows": "Mengikuti",
+  "account.follows_you": "Mengikuti anda",
+  "account.mention": "Balasan @{name}",
+  "account.mute": "Bisukan @{name}",
+  "account.posts": "Postingan",
+  "account.report": "Laporkan @{name}",
+  "account.requested": "Menunggu persetujuan",
+  "account.unblock": "Hapus blokir @{name}",
+  "account.unfollow": "Berhenti mengikuti",
+  "account.unmute": "Berhenti membisukan @{name}",
+  "boost_modal.combo": "Anda dapat menekan {combo} untuk melewati ini",
+  "column.blocks": "Pengguna diblokir",
+  "column.community": "Linimasa Lokal",
+  "column.favourites": "Favorit",
+  "column.follow_requests": "Permintaan mengikuti",
+  "column.home": "Beranda",
+  "column.mutes": "Pengguna dibisukan",
+  "column.notifications": "Notifikasi",
+  "column.public": "Linimasa gabunggan",
+  "column_back_button.label": "Kembali",
+  "column_subheading.navigation": "Navigasi",
+  "column_subheading.settings": "Pengaturan",
+  "compose_form.lock_disclaimer": "Akun anda tidak {locked}. Semua orang dapat mengikuti anda untuk melihat postingan khusus untuk pengikut anda.",
+  "compose_form.lock_disclaimer.lock": "dikunci",
+  "compose_form.placeholder": "Apa yang ada di pikiran anda?",
+  "compose_form.privacy_disclaimer": "Status pribadi anda akan dikirim ke pengguna yang disebut dalam {domains}. Apa anda mempercayai {domainsCount, plural, one {server tersebut} other {server tersebut}}? Privasi postingan hanya bekerja dalam server Mastodon. Jika {domains} {domainsCount, plural, one {bukan server Mastodon} other {bukan server Mastodon}}, akan ada indikasi bahwa postingan anda adalah postingan pribadi, dan dapat di-boost atau dapat dilihat oleh orang lain.",
+  "compose_form.publish": "Toot",
+  "compose_form.sensitive": "Tandai media sensitif",
+  "compose_form.spoiler": "Sembunyikan teks dibalik peringatan",
+  "compose_form.spoiler_placeholder": "Peringatan konten",
+  "confirmation_modal.cancel": "Batal",
+  "confirmations.block.confirm": "Blokir",
+  "confirmations.block.message": "Apa anda yakin ingin memblokir {name}?",
+  "confirmations.delete.confirm": "Hapus",
+  "confirmations.delete.message": "Apa anda yakin akan menghapus status ini?",
+  "confirmations.mute.confirm": "Bisukan",
+  "confirmations.mute.message": "Apa anda yakin ingin membisukan {name}?",
+  "emoji_button.activity": "Aktivitas",
+  "emoji_button.flags": "Bendera",
+  "emoji_button.food": "Makanan & Minuman",
+  "emoji_button.label": "Tambahkan emoji",
+  "emoji_button.nature": "Alam",
+  "emoji_button.objects": "Benda-benda",
+  "emoji_button.people": "Orang",
+  "emoji_button.search": "Cari...",
+  "emoji_button.symbols": "Simbol",
+  "emoji_button.travel": "Tempat Wisata",
+  "empty_column.community": "Linimasa lokal masih kosong. Tulis sesuatu secara publik dan buat roda berputar!",
+  "empty_column.hashtag": "Tidak ada apapun dalam hashtag ini.",
+  "empty_column.home": "Anda sedang tidak mengikuti siapapun. Kunjungi {public} atau gunakan pencarian untuk memulai dan bertemu pengguna lain.",
+  "empty_column.home.public_timeline": "linimasa publik",
+  "empty_column.notifications": "Anda tidak memiliki notifikasi apapun. Berinteraksi dengan orang lain untuk memulai percakapan.",
+  "empty_column.public": "Tidak ada apapun disini! Tulis sesuatu, atau ikuti pengguna lain dari server lain untuk mengisinya secara manual",
+  "follow_request.authorize": "Izinkan",
+  "follow_request.reject": "Tolak",
+  "getting_started.apps": "Tersedia dalam berbagai aplikasi",
+  "getting_started.heading": "Mulai",
+  "getting_started.open_source_notice": "Mastodon adalah perangkat lunak yang bersifat open source. Anda dapat berkontribusi atau melaporkan permasalahan/bug di Github {github}. {apps}.",
+  "home.column_settings.advanced": "Tingkat Lanjut",
+  "home.column_settings.basic": "Dasar",
+  "home.column_settings.filter_regex": "Penyaringan dengan Regular Expression",
+  "home.column_settings.show_reblogs": "Tampilkan Boost",
+  "home.column_settings.show_replies": "Tampilkan balasan",
+  "home.settings": "Pengaturan kolom",
+  "lightbox.close": "Tutup",
+  "loading_indicator.label": "Tunggu sebentar...",
+  "media_gallery.toggle_visible": "Tampil/Sembunyikan",
+  "missing_indicator.label": "Tidak ditemukan",
+  "navigation_bar.blocks": "Pengguna diblokir",
+  "navigation_bar.community_timeline": "Linimasa lokal",
+  "navigation_bar.edit_profile": "Ubah profil",
+  "navigation_bar.favourites": "Favorit",
+  "navigation_bar.follow_requests": "Permintaan mengikuti",
+  "navigation_bar.info": "Informasi selengkapnya",
+  "navigation_bar.logout": "Keluar",
+  "navigation_bar.mutes": "Pengguna dibisukan",
+  "navigation_bar.preferences": "Pengaturan",
+  "navigation_bar.public_timeline": "Linimasa gabungan",
+  "notification.favourite": "{name} menyukai status anda",
+  "notification.follow": "{name} mengikuti anda",
+  "notification.reblog": "{name} mem-boost status anda",
+  "notifications.clear": "Hapus notifikasi",
+  "notifications.clear_confirmation": "Apa anda yakin hendak menghapus semua notifikasi anda?",
+  "notifications.column_settings.alert": "Notifikasi desktop",
+  "notifications.column_settings.favourite": "Favorit:",
+  "notifications.column_settings.follow": "Pengikut baru:",
+  "notifications.column_settings.mention": "Balasan:",
+  "notifications.column_settings.reblog": "Boost:",
+  "notifications.column_settings.show": "Tampilkan dalam kolom",
+  "notifications.column_settings.sound": "Mainkan suara",
+  "notifications.settings": "Pengaturan kolom",
+  "onboarding.done": "Selesei",
+  "onboarding.next": "Selanjutnya",
+  "onboarding.page_five.public_timelines": "Linimasa lokal menampilkan semua postingan publik dari semua orang di {domain}. Linimasa gabungan menampilkan postingan publik dari semua orang yang diikuti oleh {domain}. Ini semua adalah Linimasa Publik, cara terbaik untuk bertemu orang lain.",
+  "onboarding.page_four.home": "Linimasa beranda menampilkan postingan dari orang-orang yang anda ikuti.",
+  "onboarding.page_four.notifications": "Kolom notifikasi menampilkan ketika seseorang berinteraksi dengan anda.",
+  "onboarding.page_one.federation": "Mastodon adalah jaringan dari beberapa server independen yang bergabung untuk membuat jejaring sosial yang besar.",
+  "onboarding.page_one.handle": "Ada berada dalam {domain}, jadi nama user lengkap anda adalah {handle}",
+  "onboarding.page_one.welcome": "Selamat datang di Mastodon!",
+  "onboarding.page_six.admin": "Admin serveer anda adalah {admin}.",
+  "onboarding.page_six.almost_done": "Hampir selesei...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "Ada beberapa apl yang tersedia untuk iOS, Android, dan platform lainnya.",
+  "onboarding.page_six.github": "Mastodon adalah software open-source. Anda bisa melaporkan bug, meminta fitur, atau berkontribusi dengan kode di {github}.",
+  "onboarding.page_six.guidelines": "pedoman komunitas",
+  "onboarding.page_six.read_guidelines": "Silakan baca {guidelines} {domain}!",
+  "onboarding.page_six.various_app": "apl handphone",
+  "onboarding.page_three.profile": "Ubah profil anda untuk mengganti avatar, bio, dan nama pengguna anda. Disitu, anda juga bisa mengatur opsi lainnya.",
+  "onboarding.page_three.search": "Gunakan kolom pencarian untuk mencari orang atau melihat hashtag, seperti {illustration} dan {introductions}. Untuk mencari pengguna yang tidak berada dalam server ini, gunakan nama pengguna mereka selengkapnya.",
+  "onboarding.page_two.compose": "Tulis postingan melalui kolom posting. Anda dapat mengunggah gambar, mengganti pengaturan privasi, dan menambahkan peringatan konten dengan ikon-ikon dibawah ini.",
+  "onboarding.skip": "Lewati",
+  "privacy.change": "Tentukan privasi status",
+  "privacy.direct.long": "Kirim hanya ke pengguna yang disebut",
+  "privacy.direct.short": "Langsung",
+  "privacy.private.long": "Kirim hanya ke pengikut",
+  "privacy.private.short": "Pribadi",
+  "privacy.public.long": "Kirim ke linimasa publik",
+  "privacy.public.short": "Publik",
+  "privacy.unlisted.long": "Tidak ditampilkan di linimasa publik",
+  "privacy.unlisted.short": "Tak Terdaftar",
+  "reply_indicator.cancel": "Batal",
+  "report.heading": "Laporan baru",
+  "report.placeholder": "Komentar tambahan",
+  "report.submit": "Kirim",
+  "report.target": "Melaporkan",
+  "search.status_by": "Status yang dibuat oleh {name}",
+  "search_results.total": "{count} {count, plural, one {hasil} other {hasil}}",
+  "status.cannot_reblog": "Postingan ini tidak dapat di-boost",
+  "search.placeholder": "Pencarian",
+  "search_results.total": "{count} {count, plural, one {hasil} other {hasil}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Hapus",
+  "status.favourite": "Difavoritkan",
+  "status.load_more": "Tampilkan semua",
+  "status.media_hidden": "Media disembunyikan",
+  "status.mention": "Balasan @{name}",
+  "status.open": "Tampilkan status ini",
+  "status.reblog": "Boost",
+  "status.reblogged_by": "di-boost {name}",
+  "status.reply": "Balas",
+  "status.replyAll": "Balas ke semua",
+  "status.report": "Laporkan @{name}",
+  "status.sensitive_toggle": "Klik untuk menampilkan",
+  "status.sensitive_warning": "Konten sensitif",
+  "status.show_less": "Tampilkan lebih sedikit",
+  "status.show_more": "Tampilkan semua",
+  "tabs_bar.compose": "Tulis",
+  "tabs_bar.federated_timeline": "Gabungan",
+  "tabs_bar.home": "Beranda",
+  "tabs_bar.local_timeline": "Lokal",
+  "tabs_bar.notifications": "Notifikasi",
+  "upload_area.title": "Seret & lepaskan untuk mengunggah",
+  "upload_button.label": "Tambahkan media",
+  "upload_form.undo": "Undo",
+  "upload_progress.label": "Mengunggah...",
+  "video_player.expand": "Tampilkan video",
+  "video_player.toggle_sound": "Suara",
+  "video_player.toggle_visible": "Tampilan",
+  "video_player.expand": "Tampilkan video",
+  "video_player.video_error": "Video tidak dapat diputar"
+}
diff --git a/app/javascript/mastodon/locales/index.js b/app/javascript/mastodon/locales/index.js
new file mode 100644
index 000000000..c4d580ff5
--- /dev/null
+++ b/app/javascript/mastodon/locales/index.js
@@ -0,0 +1,57 @@
+import ar from './ar.json';
+import en from './en.json';
+import de from './de.json';
+import es from './es.json';
+import fa from './fa.json';
+import he from './he.json';
+import hr from './hr.json';
+import hu from './hu.json';
+import io from './io.json';
+import it from './it.json';
+import fr from './fr.json';
+import nl from './nl.json';
+import no from './no.json';
+import oc from './oc.json';
+import pt from './pt.json';
+import pt_br from './pt-BR.json';
+import uk from './uk.json';
+import fi from './fi.json';
+import eo from './eo.json';
+import ru from './ru.json';
+import ja from './ja.json';
+import zh_hk from './zh-HK.json';
+import zh_cn from './zh-CN.json';
+import bg from './bg.json';
+import id from './id.json';
+
+const locales = {
+  ar,
+  en,
+  de,
+  es,
+  fa,
+  he,
+  hr,
+  hu,
+  io,
+  it,
+  fr,
+  nl,
+  no,
+  oc,
+  pt,
+  'pt-BR': pt_br,
+  uk,
+  fi,
+  eo,
+  ru,
+  ja,
+  'zh-HK': zh_hk,
+  'zh-CN': zh_cn,
+  bg,
+  id,
+};
+
+export default function getMessagesForLocale(locale) {
+  return locales[locale];
+};
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
new file mode 100644
index 000000000..bcf89cfc2
--- /dev/null
+++ b/app/javascript/mastodon/locales/io.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Blokusar @{name}",
+  "account.disclaimer": "Ca uzero esas de altra instaluro. Ca nombro forsan esas plu granda.",
+  "account.edit_profile": "Modifikar profilo",
+  "account.follow": "Sequar",
+  "account.followers": "Sequanti",
+  "account.follows": "Sequas",
+  "account.follows_you": "Sequas tu",
+  "account.mention": "Mencionar @{name}",
+  "account.mute": "Celar @{name}",
+  "account.posts": "Mesaji",
+  "account.report": "Denuncar @{name}",
+  "account.requested": "Vartante aprobo",
+  "account.unblock": "Desblokusar @{name}",
+  "account.unfollow": "Ne plus sequar",
+  "account.unmute": "Ne plus celar @{name}",
+  "boost_modal.combo": "Tu povas presar sur {combo} por omisar co en la venonta foyo",
+  "column.blocks": "Blokusita uzeri",
+  "column.community": "Lokala tempolineo",
+  "column.favourites": "Favorati",
+  "column.follow_requests": "Demandi di sequado",
+  "column.home": "Hemo",
+  "column.mutes": "Celita uzeri",
+  "column.notifications": "Savigi",
+  "column.public": "Federata tempolineo",
+  "column_back_button.label": "Retro",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "Quo esas en tua spirito?",
+  "compose_form.privacy_disclaimer": "Tua privata mesajo livresos a mencionata uzeri en {domains}. Ka tu fidas {domainsCount, plural, one {ta servero} other {ta serveri}}? Privateso di mesaji funcionas nur en instaluri di Mastodon. Se {domains} {domainsCount, plural, one {ne esas instaluro di Mastodon} other {ne esas instaluri di Mastodon}}, esos nula indiko, ke tua mesajo esas privata, ed ol povos repetesar od altre divenar videbla da nedezirinda recevanti.",
+  "compose_form.publish": "Siflar",
+  "compose_form.sensitive": "Markizar kontenajo kom trubliva",
+  "compose_form.spoiler": "Celar texto dop averto",
+  "compose_form.spoiler_placeholder": "Averto di kontenajo",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Insertar emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "La lokala tempolineo esas vakua. Skribez ulo publike por iniciar la agiveso!",
+  "empty_column.hashtag": "Esas ankore nulo en ta gretovorto.",
+  "empty_column.home": "Tu sequas ankore nulu. Vizitez {public} od uzez la serchilo por komencar e renkontrar altra uzeri.",
+  "empty_column.home.public_timeline": "la publika tempolineo",
+  "empty_column.notifications": "Tu havas ankore nula savigo. Komunikez kun altri por debutar la konverso.",
+  "empty_column.public": "Esas nulo hike! Skribez ulo publike, o manuale sequez uzeri de altra instaluri por plenigar ol.",
+  "follow_request.authorize": "Yurizar",
+  "follow_request.reject": "Refuzar",
+  "getting_started.apps": "Apliki diversa esas disponebla",
+  "getting_started.heading": "Debuto",
+  "getting_started.open_source_notice": "Mastodon esas programaro kun apertita kodexo. Tu povas kontributar o signalar problemi en GitHub ye {github}. {apps}.",
+  "home.column_settings.advanced": "Komplexa",
+  "home.column_settings.basic": "Simpla",
+  "home.column_settings.filter_regex": "Ekfiltrar per reguloza expresuri",
+  "home.column_settings.show_reblogs": "Montrar repeti",
+  "home.column_settings.show_replies": "Montrar respondi",
+  "home.settings": "Aranji di la kolumno",
+  "lightbox.close": "Klozar",
+  "loading_indicator.label": "Kargante...",
+  "media_gallery.toggle_visible": "Chanjar videbleso",
+  "missing_indicator.label": "Ne trovita",
+  "navigation_bar.blocks": "Blokusita uzeri",
+  "navigation_bar.community_timeline": "Lokala tempolineo",
+  "navigation_bar.edit_profile": "Modifikar profilo",
+  "navigation_bar.favourites": "Favorati",
+  "navigation_bar.follow_requests": "Demandi di sequado",
+  "navigation_bar.info": "Detaloza informi",
+  "navigation_bar.logout": "Ekirar",
+  "navigation_bar.mutes": "Celita uzeri",
+  "navigation_bar.preferences": "Preferi",
+  "navigation_bar.public_timeline": "Federata tempolineo",
+  "notification.favourite": "{name} favorizis tua mesajo",
+  "notification.follow": "{name} sequeskis tu",
+  "notification.reblog": "{name} repetis tua mesajo",
+  "notifications.clear": "Efacar savigi",
+  "notifications.clear_confirmation": "Ka tu esas certa, ke tu volas efacar omna tua savigi?",
+  "notifications.column_settings.alert": "Surtabla savigi",
+  "notifications.column_settings.favourite": "Favorati:",
+  "notifications.column_settings.follow": "Nova sequanti:",
+  "notifications.column_settings.mention": "Mencioni:",
+  "notifications.column_settings.reblog": "Repeti:",
+  "notifications.column_settings.show": "Montrar en kolumno",
+  "notifications.column_settings.sound": "Plear sono",
+  "notifications.settings": "Aranji di kolumno",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Aranjar privateso di mesaji",
+  "privacy.direct.long": "Sendar nur a mencionata uzeri",
+  "privacy.direct.short": "Direte",
+  "privacy.private.long": "Sendar nur a sequanti",
+  "privacy.private.short": "Private",
+  "privacy.public.long": "Sendar a publika tempolinei",
+  "privacy.public.short": "Publike",
+  "privacy.unlisted.long": "Ne montrar en publika tempolinei",
+  "privacy.unlisted.short": "Ne enlistigota",
+  "reply_indicator.cancel": "Nihiligar",
+  "report.heading": "Nova denunco",
+  "report.placeholder": "Plusa komenti",
+  "report.submit": "Sendar",
+  "report.target": "Denuncante",
+  "search.placeholder": "Serchez",
+  "search_results.total": "{count, number} {count, plural, one {rezulto} other {rezulti}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Efacar",
+  "status.favourite": "Favorizar",
+  "status.load_more": "Kargar pluse",
+  "status.media_hidden": "Kontenajo celita",
+  "status.mention": "Mencionar @{name}",
+  "status.open": "Detaligar ca mesajo",
+  "status.reblog": "Repetar",
+  "status.reblogged_by": "{name} repetita",
+  "status.reply": "Respondar",
+  "status.replyAll": "Respondar a filo",
+  "status.report": "Denuncar @{name}",
+  "status.sensitive_toggle": "Kliktar por vidar",
+  "status.sensitive_warning": "Trubliva kontenajo",
+  "status.show_less": "Montrar mine",
+  "status.show_more": "Montrar plue",
+  "tabs_bar.compose": "Kompozar",
+  "tabs_bar.federated_timeline": "Federata",
+  "tabs_bar.home": "Hemo",
+  "tabs_bar.local_timeline": "Lokala",
+  "tabs_bar.notifications": "Savigi",
+  "upload_area.title": "Tranar faligar por kargar",
+  "upload_button.label": "Adjuntar kontenajo",
+  "upload_form.undo": "Desfacar",
+  "upload_progress.label": "Kargante...",
+  "video_player.expand": "Extensar video",
+  "video_player.toggle_sound": "Acendar sono",
+  "video_player.toggle_visible": "Chanjar videbleso",
+  "video_player.video_error": "Video ne povus pleesar"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
new file mode 100644
index 000000000..0342f1e1f
--- /dev/null
+++ b/app/javascript/mastodon/locales/it.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Blocca @{name}",
+  "account.disclaimer": "Questo utente si trova su un altro server. Questo numero potrebbe essere maggiore.",
+  "account.edit_profile": "Modifica profilo",
+  "account.follow": "Segui",
+  "account.followers": "Seguaci",
+  "account.follows": "Segue",
+  "account.follows_you": "Ti segue",
+  "account.mention": "Menziona @{name}",
+  "account.mute": "Silenzia @{name}",
+  "account.posts": "Posts",
+  "account.report": "Segnala @{name}",
+  "account.requested": "In attesa di approvazione",
+  "account.unblock": "Sblocca @{name}",
+  "account.unfollow": "Non seguire",
+  "account.unmute": "Non silenziare @{name}",
+  "boost_modal.combo": "Puoi premere {combo} per saltare questo passaggio la prossima volta",
+  "column.blocks": "Utenti bloccati",
+  "column.community": "Timeline locale",
+  "column.favourites": "Apprezzati",
+  "column.follow_requests": "Richieste di amicizia",
+  "column.home": "Home",
+  "column.mutes": "Utenti silenziati",
+  "column.notifications": "Notifiche",
+  "column.public": "Timeline federata",
+  "column_back_button.label": "Indietro",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "A cosa stai pensando?",
+  "compose_form.privacy_disclaimer": "Il tuo status privato verrà condiviso con gli utenti menzionati su {domains}. Ti fidi di {domainsCount, plural, one {quel server} other {quei server}}? Le impostazioni sulla privacy valgono solo su server Mastodon. Se {domains} {domainsCount, plural, one {non è un server Mastodon} other {non sono server Mastodon}}, non ci saranno indicazioni sulla privacy del tuo status, e potrebbe essere condiviso o reso visibile a destinatari indesiderati.",
+  "compose_form.publish": "Toot",
+  "compose_form.sensitive": "Segnala file come sensibile",
+  "compose_form.spoiler": "Nascondi testo con avvertimento",
+  "compose_form.spoiler_placeholder": "Content warning",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Inserisci emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "La timeline locale è vuota. Condividi qualcosa pubblicamente per dare inizio alla festa!",
+  "empty_column.hashtag": "Non c'è ancora nessun post con questo hashtag.",
+  "empty_column.home": "Non stai ancora seguendo nessuno. Visita {public} o usa la ricerca per incontrare nuove persone.",
+  "empty_column.home.public_timeline": "la timeline pubblica",
+  "empty_column.notifications": "Non hai ancora nessuna notifica. Interagisci con altri per iniziare conversazioni.",
+  "empty_column.public": "Qui non c'è nulla! Scrivi qualcosa pubblicamente, o aggiungi utenti da altri server per riempire questo spazio.",
+  "follow_request.authorize": "Autorizza",
+  "follow_request.reject": "Rifiuta",
+  "getting_started.apps": "Sono disponibili diverse app",
+  "getting_started.heading": "Come iniziare",
+  "getting_started.open_source_notice": "Mastodon è un software open source. Puoi contribuire o segnalare errori su GitHub all'indirizzo {github}. {apps}.",
+  "home.column_settings.advanced": "Avanzato",
+  "home.column_settings.basic": "Semplice",
+  "home.column_settings.filter_regex": "Filtra con espressioni regolari",
+  "home.column_settings.show_reblogs": "Mostra post condivisi",
+  "home.column_settings.show_replies": "Mostra risposte",
+  "home.settings": "Impostazioni colonna",
+  "lightbox.close": "Chiudi",
+  "loading_indicator.label": "Carico...",
+  "media_gallery.toggle_visible": "Imposta visibilità",
+  "missing_indicator.label": "Non trovato",
+  "navigation_bar.blocks": "Utenti bloccati",
+  "navigation_bar.community_timeline": "Timeline locale",
+  "navigation_bar.edit_profile": "Modifica profilo",
+  "navigation_bar.favourites": "Apprezzati",
+  "navigation_bar.follow_requests": "Richieste di amicizia",
+  "navigation_bar.info": "Informazioni estese",
+  "navigation_bar.logout": "Logout",
+  "navigation_bar.mutes": "Utenti silenziati",
+  "navigation_bar.preferences": "Impostazioni",
+  "navigation_bar.public_timeline": "Timeline federata",
+  "notification.favourite": "{name} ha apprezzato il tuo post",
+  "notification.follow": "{name} ha iniziato a seguirti",
+  "notification.reblog": "{name} ha condiviso il tuo post",
+  "notifications.clear": "Cancella notifiche",
+  "notifications.clear_confirmation": "Vuoi davvero cancellare tutte le notifiche?",
+  "notifications.column_settings.alert": "Notifiche desktop",
+  "notifications.column_settings.favourite": "Apprezzati:",
+  "notifications.column_settings.follow": "Nuovi seguaci:",
+  "notifications.column_settings.mention": "Menzioni:",
+  "notifications.column_settings.reblog": "Post condivisi:",
+  "notifications.column_settings.show": "Mostra in colonna",
+  "notifications.column_settings.sound": "Riproduci suono",
+  "notifications.settings": "Impostazioni colonna",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Modifica privacy post",
+  "privacy.direct.long": "Invia solo a utenti menzionati",
+  "privacy.direct.short": "Diretto",
+  "privacy.private.long": "Invia solo ai seguaci",
+  "privacy.private.short": "Privato",
+  "privacy.public.long": "Invia alla timeline pubblica",
+  "privacy.public.short": "Pubblico",
+  "privacy.unlisted.long": "Non mostrare sulla timeline pubblica",
+  "privacy.unlisted.short": "Non elencato",
+  "reply_indicator.cancel": "Annulla",
+  "report.heading": "Nuova segnalazione",
+  "report.placeholder": "Commenti aggiuntivi",
+  "report.submit": "Invia",
+  "report.target": "Invio la segnalazione",
+  "search.placeholder": "Cerca",
+  "search_results.total": "{count} {count, plural, one {risultato} other {risultati}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Elimina",
+  "status.favourite": "Apprezzato",
+  "status.load_more": "Mostra di più",
+  "status.media_hidden": "Allegato nascosto",
+  "status.mention": "Nomina @{name}",
+  "status.open": "Espandi questo post",
+  "status.reblog": "Condividi",
+  "status.reblogged_by": "{name} ha condiviso",
+  "status.reply": "Rispondi",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Segnala @{name}",
+  "status.sensitive_toggle": "Clicca per vedere",
+  "status.sensitive_warning": "Materiale sensibile",
+  "status.show_less": "Mostra meno",
+  "status.show_more": "Mostra di più",
+  "tabs_bar.compose": "Scrivi",
+  "tabs_bar.federated_timeline": "Federazione",
+  "tabs_bar.home": "Home",
+  "tabs_bar.local_timeline": "Locale",
+  "tabs_bar.notifications": "Notifiche",
+  "upload_area.title": "Trascina per caricare",
+  "upload_button.label": "Aggiungi file multimediale",
+  "upload_form.undo": "Annulla",
+  "upload_progress.label": "Sto caricando...",
+  "video_player.expand": "Espandi video",
+  "video_player.toggle_sound": "Attiva suono",
+  "video_player.toggle_visible": "Attiva visibilità",
+  "video_player.video_error": "Il video non può essere riprodotto"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
new file mode 100644
index 000000000..c9ddde083
--- /dev/null
+++ b/app/javascript/mastodon/locales/ja.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "ブロック",
+  "account.disclaimer": "このユーザーは他のインスタンスに所属しているため、数字が正確で無い場合があります。",
+  "account.edit_profile": "プロフィールを編集",
+  "account.follow": "フォロー",
+  "account.followers": "フォロワー",
+  "account.follows": "フォロー",
+  "account.follows_you": "フォローされています",
+  "account.mention": "返信",
+  "account.mute": "ミュート",
+  "account.posts": "投稿",
+  "account.report": "通報",
+  "account.requested": "承認待ち",
+  "account.unblock": "ブロック解除",
+  "account.unfollow": "フォロー解除",
+  "account.unmute": "ミュート解除",
+  "boost_modal.combo": "次からは{combo}を押せば、これをスキップできます。",
+  "column.blocks": "ブロックしたユーザー",
+  "column.community": "ローカルタイムライン",
+  "column.favourites": "お気に入り",
+  "column.follow_requests": "フォローリクエスト",
+  "column.home": "ホーム",
+  "column.mutes": "ミュートしたユーザー",
+  "column.notifications": "通知",
+  "column.public": "連合タイムライン",
+  "column_back_button.label": "戻る",
+  "column_subheading.navigation": "ナビゲーション",
+  "column_subheading.settings": "設定",
+  "compose_form.lock_disclaimer": "あなたのアカウントは{locked}になっていません。誰でもあなたをフォローすることができ、フォロワー限定の投稿を見ることができます。",
+  "compose_form.lock_disclaimer.lock": "非公開",
+  "compose_form.placeholder": "今なにしてる?",
+  "compose_form.privacy_disclaimer": "あなたの非公開トゥートは返信先ユーザーが所属する {domains} に送信されます。{domainsCount, plural, one {このサーバー} other {これらのサーバー}}は信頼できますか?投稿のプライバシー保護はMastodonサーバー内でのみ有効です。 {domains} {domainsCount, plural, one {がMastodonインスタンス} other {がMastodonインスタンス}}でない場合、あなたの投稿がプライベートなものとして扱われず、ブーストされたり予期しないユーザーに見られる可能性があります。",
+  "compose_form.publish": "トゥート",
+  "compose_form.sensitive": "メディアを閲覧注意としてマークする",
+  "compose_form.spoiler": "テキストを隠す",
+  "compose_form.spoiler_placeholder": "警告",
+  "confirmation_modal.cancel": "キャンセル",
+  "confirmations.block.confirm": "ブロック",
+  "confirmations.block.message": "本当に {name} をブロックしますか?",
+  "confirmations.delete.confirm": "削除",
+  "confirmations.delete.message": "本当に削除しますか?",
+  "confirmations.mute.confirm": "ミュート",
+  "confirmations.mute.message": "本当に {name} をミュートしますか?",
+  "emoji_button.activity": "活動",
+  "emoji_button.flags": "国旗",
+  "emoji_button.food": "食べ物",
+  "emoji_button.label": "絵文字を追加",
+  "emoji_button.nature": "自然",
+  "emoji_button.objects": "物",
+  "emoji_button.people": "人々",
+  "emoji_button.search": "検索...",
+  "emoji_button.symbols": "記号",
+  "emoji_button.travel": "旅行と場所",
+  "empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!",
+  "empty_column.hashtag": "このハッシュタグはまだ使われていません。",
+  "empty_column.home": "まだ誰もフォローしていません。{public}を見に行くか、検索を使って他のユーザーを見つけましょう。",
+  "empty_column.home.public_timeline": "連合タイムライン",
+  "empty_column.notifications": "まだ通知がありません。他の人とふれ合って会話を始めましょう。",
+  "empty_column.public": "ここにはまだ何もありません!公開で何かを投稿したり、他のインスタンスのユーザーをフォローしたりしていっぱいにしましょう!",
+  "follow_request.authorize": "許可",
+  "follow_request.reject": "拒否",
+  "getting_started.apps": "さまざまなアプリで利用できます。",
+  "getting_started.heading": "スタート",
+  "getting_started.open_source_notice": "Mastodon はオープンソースソフトウェアです。誰でも GitHub({github})から開発に参加したり、問題を報告したりできます。 {apps}",
+  "home.column_settings.advanced": "上級者向け",
+  "home.column_settings.basic": "シンプル",
+  "home.column_settings.filter_regex": "正規表現でフィルター",
+  "home.column_settings.show_reblogs": "ブースト表示",
+  "home.column_settings.show_replies": "返信表示",
+  "home.settings": "カラム設定",
+  "lightbox.close": "閉じる",
+  "loading_indicator.label": "読み込み中...",
+  "media_gallery.toggle_visible": "表示切り替え",
+  "missing_indicator.label": "見つかりません",
+  "navigation_bar.blocks": "ブロックしたユーザー",
+  "navigation_bar.community_timeline": "ローカルタイムライン",
+  "navigation_bar.edit_profile": "プロフィールを編集",
+  "navigation_bar.favourites": "お気に入り",
+  "navigation_bar.follow_requests": "フォローリクエスト",
+  "navigation_bar.info": "サーバー情報",
+  "navigation_bar.logout": "ログアウト",
+  "navigation_bar.mutes": "ミュートしたユーザー",
+  "navigation_bar.preferences": "ユーザー設定",
+  "navigation_bar.public_timeline": "連合タイムライン",
+  "notification.favourite": "{name} さんがあなたのトゥートをお気に入りに登録しました",
+  "notification.follow": "{name} さんにフォローされました",
+  "notification.reblog": "{name} さんがあなたのトゥートをブーストしました",
+  "notifications.clear": "通知を消去",
+  "notifications.clear_confirmation": "本当に通知を消去しますか?",
+  "notifications.column_settings.alert": "デスクトップ通知",
+  "notifications.column_settings.favourite": "お気に入り",
+  "notifications.column_settings.follow": "新しいフォロワー",
+  "notifications.column_settings.mention": "返信",
+  "notifications.column_settings.reblog": "ブースト",
+  "notifications.column_settings.show": "カラムに表示",
+  "notifications.column_settings.sound": "通知音を再生",
+  "notifications.settings": "カラム設定",
+  "onboarding.done": "完了",
+  "onboarding.next": "次へ",
+  "onboarding.page_five.public_timelines": "連合タイムラインでは{domain}の人がフォローしているMastodon全体での公開投稿を表示します。同じくローカルタイムラインでは{domain}のみの公開投稿を表示します。",
+  "onboarding.page_four.home": "「ホーム」タイムラインではあなたがフォローしている人の投稿を表示します。",
+  "onboarding.page_four.notifications": "「通知」ではあなたへの他の人からの関わりを表示します。",
+  "onboarding.page_one.federation": "Mastodonは誰でも参加できるSNSです。",
+  "onboarding.page_one.handle": "あなたは今数あるMastodonインスタンスの1つである{domain}にいます。あなたのフルハンドルは{handle}です。",
+  "onboarding.page_one.welcome": "Mastodonへようこそ!",
+  "onboarding.page_six.admin": "あなたのインスタンスの管理者は{admin}です。",
+  "onboarding.page_six.almost_done": "以上です。",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "iOS、Androidあるいは他のプラットフォームで使える{apps}があります。",
+  "onboarding.page_six.github": "MastodonはOSSです。バグ報告や機能要望あるいは貢献を{github}から行なえます。",
+  "onboarding.page_six.guidelines": "コミュニティガイドライン",
+  "onboarding.page_six.read_guidelines": "{guidelines}を読むことを忘れないようにしてください。",
+  "onboarding.page_six.various_app": "様々なモバイルアプリ",
+  "onboarding.page_three.profile": "「プロフィールを編集」から、あなたの自己紹介や表示名を変更できます。またそこでは他の設定ができます。",
+  "onboarding.page_three.search": "検索バーで、{illustration}や{introductions}のように特定のハッシュタグの投稿を見たり、ユーザーを探したりできます。",
+  "onboarding.page_two.compose": "フォームから投稿できます。イメージや、公開範囲の設定や、表示時の警告の設定は下部のアイコンから行なえます。",
+  "onboarding.skip": "スキップ",
+  "privacy.change": "投稿のプライバシーを変更",
+  "privacy.direct.long": "メンションしたユーザーだけに公開",
+  "privacy.direct.short": "ダイレクト",
+  "privacy.private.long": "フォロワーだけに公開",
+  "privacy.private.short": "非公開",
+  "privacy.public.long": "公開TLに投稿する",
+  "privacy.public.short": "公開",
+  "privacy.unlisted.long": "公開TLで表示しない",
+  "privacy.unlisted.short": "未収載",
+  "reply_indicator.cancel": "キャンセル",
+  "report.heading": "新規通報",
+  "report.placeholder": "コメント",
+  "report.submit": "通報する",
+  "report.target": "問題のユーザー",
+  "search.placeholder": "検索",
+  "search_results.total": "{count, number} 件の結果",
+  "status.cannot_reblog": "この投稿はブーストできません",
+  "status.delete": "削除",
+  "status.favourite": "お気に入り",
+  "status.load_more": "もっと見る",
+  "status.media_hidden": "非表示のメデイア",
+  "status.mention": "返信",
+  "status.open": "詳細を表示",
+  "status.reblog": "ブースト",
+  "status.reblogged_by": "{name} さんにブーストされました",
+  "status.reply": "返信",
+  "status.replyAll": "全員に返信",
+  "status.report": "通報",
+  "status.sensitive_toggle": "クリックして表示",
+  "status.sensitive_warning": "閲覧注意",
+  "status.show_less": "隠す",
+  "status.show_more": "もっと見る",
+  "tabs_bar.compose": "投稿",
+  "tabs_bar.federated_timeline": "連合",
+  "tabs_bar.home": "ホーム",
+  "tabs_bar.local_timeline": "ローカル",
+  "tabs_bar.notifications": "通知",
+  "upload_area.title": "ドラッグ&ドロップでアップロード",
+  "upload_button.label": "メディアを追加",
+  "upload_form.undo": "やり直す",
+  "upload_progress.label": "アップロード中…",
+  "video_player.expand": "動画の詳細",
+  "video_player.toggle_sound": "音の切り替え",
+  "video_player.toggle_visible": "表示切り替え",
+  "video_player.video_error": "動画の再生に失敗しました"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
new file mode 100644
index 000000000..730ef246d
--- /dev/null
+++ b/app/javascript/mastodon/locales/nl.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Blokkeer @{name}",
+  "account.disclaimer": "This user is from another instance. This number may be larger.",
+  "account.edit_profile": "Profiel bewerken",
+  "account.follow": "Volgen",
+  "account.followers": "Volgers",
+  "account.follows": "Volgt",
+  "account.follows_you": "Volgt jou",
+  "account.mention": "Vermeld @{name}",
+  "account.mute": "Negeer @{name}",
+  "account.posts": "Berichten",
+  "account.report": "Rapporteer @{name}",
+  "account.requested": "Wacht op goedkeuring",
+  "account.unblock": "Deblokkeer @{name}",
+  "account.unfollow": "Ontvolgen",
+  "account.unmute": "Negeer @{name} niet meer",
+  "boost_modal.combo": "Je kunt {combo} klikken om dit de volgende keer over te slaan",
+  "column.blocks": "Geblokkeerde gebruikers",
+  "column.community": "Lokale tijdlijn",
+  "column.favourites": "Favorieten",
+  "column.follow_requests": "Follow requests",
+  "column.home": "Jouw tijdlijn",
+  "column.mutes": "Genegeerde gebruikers",
+  "column.notifications": "Meldingen",
+  "column.public": "Globale tijdlijn",
+  "column_back_button.label": "terug",
+  "column_subheading.navigation": "Navigatie",
+  "column_subheading.settings": "Instellingen",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "Wat wil je kwijt?",
+  "compose_form.privacy_disclaimer": "Jouw privétoot wordt afgeleverd aan de vermelde gebruikers op {domains}. Vertrouw jij {domainsCount, plural, one {die server} other {die servers}}? Het privé plaatsen van toots werkt alleen op Mastodon-servers. Wanneer {domains} {domainsCount, plural, one {geen Mastodon-server is} other {geen Mastodon-servers zijn}}, dan wordt er niet aangegeven dat de toot privé is, waardoor het kan worden geboost of op een andere manier zichtbaar wordt gemaakt voor mensen waarvoor het niet was bedoeld.",
+  "compose_form.publish": "Toot",
+  "compose_form.sensitive": "Media als gevoelig markeren",
+  "compose_form.spoiler": "Tekst achter waarschuwing verbergen",
+  "compose_form.spoiler_placeholder": "Waarschuwingstekst",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activiteiten",
+  "emoji_button.flags": "Vlaggen",
+  "emoji_button.food": "Eten en drinken",
+  "emoji_button.label": "Emoji toevoegen",
+  "emoji_button.nature": "Natuur",
+  "emoji_button.objects": "Voorwerpen",
+  "emoji_button.people": "Mensen",
+  "emoji_button.search": "Zoeken...",
+  "emoji_button.symbols": "Symbolen",
+  "emoji_button.travel": "Reizen en plekken",
+  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
+  "empty_column.hashtag": "There is nothing in this hashtag yet.",
+  "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.",
+  "empty_column.home.public_timeline": "the public timeline",
+  "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
+  "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
+  "follow_request.authorize": "Authorize",
+  "follow_request.reject": "Reject",
+  "getting_started.apps": "Er zijn meerdere apps beschikbaar",
+  "getting_started.heading": "Beginnen",
+  "getting_started.open_source_notice": "Mastodon is open-sourcesoftware. Je kunt bijdragen of problemen melden op GitHub via {github}. {apps}.",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.basic": "Basic",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "home.column_settings.show_reblogs": "Show boosts",
+  "home.column_settings.show_replies": "Show replies",
+  "home.settings": "Column settings",
+  "lightbox.close": "Sluiten",
+  "loading_indicator.label": "Laden…",
+  "media_gallery.toggle_visible": "Toggle visibility",
+  "missing_indicator.label": "Not found",
+  "navigation_bar.blocks": "Geblokkeerde gebruikers",
+  "navigation_bar.community_timeline": "Lokale tijdlijn",
+  "navigation_bar.edit_profile": "Profiel bewerken",
+  "navigation_bar.favourites": "Favorieten",
+  "navigation_bar.follow_requests": "Volgverzoeken",
+  "navigation_bar.info": "Uitgebreide informatie",
+  "navigation_bar.logout": "Afmelden",
+  "navigation_bar.mutes": "Genegeerde gebruikers",
+  "navigation_bar.preferences": "Instellingen",
+  "navigation_bar.public_timeline": "Globale tijdlijn",
+  "notification.favourite": "{name} markeerde jouw toot als favoriet",
+  "notification.follow": "{name} volgt jou nu",
+  "notification.reblog": "{name} boostte jouw toot",
+  "notifications.clear": "Meldingen verwijderen",
+  "notifications.clear_confirmation": "Weet je zeker dat je al jouw meldingen wilt verwijderen?",
+  "notifications.column_settings.alert": "Desktopmeldingen",
+  "notifications.column_settings.favourite": "Favorieten:",
+  "notifications.column_settings.follow": "Nieuwe volgers:",
+  "notifications.column_settings.mention": "Vermeldingen:",
+  "notifications.column_settings.reblog": "Boosts:",
+  "notifications.column_settings.show": "In kolom tonen",
+  "notifications.column_settings.sound": "Geluid afspelen",
+  "notifications.settings": "Kolom-instellingen",
+  "onboarding.done": "Done",
+  "onboarding.next": "Volgende",
+  "onboarding.page_five.public_timelines": "De lokale tijdlijn toont openbare toots van iedereen op {domain}. De globale tijdlijn toont openbare toots van iedereen die door gebruikers van {domain} worden gevolgd, dus ook mensen van andere Mastodon-servers. Dit zijn de openbare tijdlijnen en vormen een uitstekende manier om nieuwe mensen te ontdekken.",
+  "onboarding.page_four.home": "Jouw tijdlijn laat toots zien van mensen die jij volgt.",
+  "onboarding.page_four.notifications": "De kolom met meldingen toont alle interacties die je met andere Mastodon-gebruikers hebt.",
+  "onboarding.page_one.federation": "Mastodon is een netwerk van onafhankelijke servers die samen een groot sociaal netwerk vormen.",
+  "onboarding.page_one.handle": "Je bevindt je nu op {domain}, dus is jouw volledige Mastodon-adres {handle}",
+  "onboarding.page_one.welcome": "Welkom op Mastodon!",
+  "onboarding.page_six.admin": "De beheerder van jouw Mastodon-server is {admin}.",
+  "onboarding.page_six.almost_done": "Bijna klaar...",
+  "onboarding.page_six.appetoot": "Veel succes!",
+  "onboarding.page_six.apps_available": "Er zijn {apps} beschikbaar voor iOS, Android en andere platformen.",
+  "onboarding.page_six.github": "Mastodon kost niets, en is open-source- en vrije software. Je kan bugs melden, nieuwe mogelijkheden aanvragen en als ontwikkelaar meewerken op {github}.",
+  "onboarding.page_six.guidelines": "communityrichtlijnen",
+  "onboarding.page_six.read_guidelines": "Vergeet niet de {guidelines} van {domain} te lezen!",
+  "onboarding.page_six.various_app": "mobiele apps",
+  "onboarding.page_three.profile": "Bewerk jouw profiel om jouw avatar, bio en weergavenaam te veranderen. Daar vind je ook andere instellingen.",
+  "onboarding.page_three.search": "Gebruik de zoekbalk linksboven om andere mensen op Mastodon te vinden en om te zoeken op hashtags, zoals {illustration} en {introductions}. Om iemand te vinden die niet op deze Mastodon-server zit, moet je het volledige Mastodon-adres van deze persoon invoeren.",
+  "onboarding.page_two.compose": "Schrijf berichten (wij noemen dit toots) in het tekstvak in de linkerkolom. Je kan met de pictogrammen daaronder afbeeldingen uploaden, privacy-instellingen veranderen en je tekst een waarschuwing meegeven.",
+  "onboarding.skip": "Overslaan",
+  "privacy.change": "Privacy toot aanpassen",
+  "privacy.direct.long": "Toot alleen naar vermelde gebruikers",
+  "privacy.direct.short": "Direct",
+  "privacy.private.long": "Alleen aan volgers tonen",
+  "privacy.private.short": "Alleen volgers",
+  "privacy.public.long": "Op openbare tijdlijnen tonen",
+  "privacy.public.short": "Openbaar",
+  "privacy.unlisted.long": "Niet op openbare tijdlijnen tonen",
+  "privacy.unlisted.short": "Minder openbaar",
+  "reply_indicator.cancel": "Annuleren",
+  "report.heading": "New report",
+  "report.placeholder": "Additional comments",
+  "report.submit": "Submit",
+  "report.target": "Reporting",
+  "search.placeholder": "Zoeken",
+  "search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Verwijderen",
+  "status.favourite": "Favoriet",
+  "status.load_more": "Load more",
+  "status.media_hidden": "Media hidden",
+  "status.mention": "@{name} vermelden",
+  "status.open": "Expand this status",
+  "status.reblog": "Boost",
+  "status.reblogged_by": "{name} boostte",
+  "status.reply": "Reageren",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Report @{name}",
+  "status.sensitive_toggle": "Klik om te zien",
+  "status.sensitive_warning": "Gevoelige inhoud",
+  "status.show_less": "Minder tonen",
+  "status.show_more": "Meer tonen",
+  "tabs_bar.compose": "Schrijven",
+  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.home": "Jouw tijdlijn",
+  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.notifications": "Meldingen",
+  "upload_area.title": "Drag & drop to upload",
+  "upload_button.label": "Media toevoegen",
+  "upload_form.undo": "Ongedaan maken",
+  "upload_progress.label": "Uploading...",
+  "video_player.expand": "Expand video",
+  "video_player.toggle_sound": "Geluid in-/uitschakelen",
+  "video_player.toggle_visible": "Toggle visibility",
+  "video_player.video_error": "Video could not be played"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
new file mode 100644
index 000000000..25dee25a3
--- /dev/null
+++ b/app/javascript/mastodon/locales/no.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Blokkér @{name}",
+  "account.disclaimer": "Denne brukeren er fra en annen instans. Dette tallet kan være høyere.",
+  "account.edit_profile": "Rediger profil",
+  "account.follow": "Følg",
+  "account.followers": "Følgere",
+  "account.follows_you": "Følger deg",
+  "account.follows": "Følger",
+  "account.mention": "Nevn @{name}",
+  "account.mute": "Demp @{name}",
+  "account.posts": "Innlegg",
+  "account.report": "Rapportér @{name}",
+  "account.requested": "Venter på godkjennelse",
+  "account.unblock": "Avblokker @{name}",
+  "account.unfollow": "Avfølg",
+  "account.unmute": "Avdemp @{name}",
+  "boost_modal.combo": "You kan trykke {combo} for å hoppe over dette neste gang",
+  "column.blocks": "Blokkerte brukere",
+  "column.community": "Lokal tidslinje",
+  "column.favourites": "Likt",
+  "column.follow_requests": "Følgeforespørsler",
+  "column.home": "Hjem",
+  "column.mutes": "Muted users",
+  "column.notifications": "Varslinger",
+  "column.public": "Felles tidslinje",
+  "column_back_button.label": "Tilbake",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "Hva har du på hjertet?",
+  "compose_form.privacy_disclaimer": "Din private status vil leveres til nevnte brukere på {domains}. Stoler du på {domainsCount, plural, one {den serveren} other {de serverne}}? Synlighet fungerer kun på Mastodon-instanser. Hvis {domains} {domainsCount, plural, one {ike er en Mastodon-instans} other {ikke er Mastodon-instanser}}, vil det ikke indikeres at posten din er privat, og den kan kanskje bli fremhevd eller på annen måte bli synlig for uventede mottakere.",
+  "compose_form.publish": "Tut",
+  "compose_form.sensitive": "Merk media som følsomt",
+  "compose_form.spoiler": "Skjul tekst bak advarsel",
+  "compose_form.spoiler_placeholder": "Innholdsadvarsel",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Sett inn emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "Den lokale tidslinjen er tom. Skriv noe offentlig for å få snøballen til å rulle!",
+  "empty_column.hashtag": "Det er ingenting i denne hashtagen ennå.",
+  "empty_column.home": "Du har ikke fulgt noen ennå. Besøk {publlic} eller bruk søk for å komme i gang og møte andre brukere.",
+  "empty_column.home.public_timeline": "en offentlig tidslinje",
+  "empty_column.notifications": "Du har ingen varsler ennå. Kommuniser med andre for å begynne samtalen.",
+  "empty_column.public": "Det er ingenting her! Skriv noe offentlig, eller følg brukere manuelt fra andre instanser for å fylle den opp",
+  "follow_request.authorize": "Autorisér",
+  "follow_request.reject": "Avvis",
+  "getting_started.apps": "Diverse apper er tilgjengelige",
+  "getting_started.heading": "Kom i gang",
+  "getting_started.open_source_notice": "Mastodon er fri programvare. Du kan bidra eller rapportere problemer på GitHub på {github}. {apps}.",
+  "home.column_settings.advanced": "Advansert",
+  "home.column_settings.basic": "Enkel",
+  "home.column_settings.filter_regex": "Filtrér med regulære uttrykk",
+  "home.column_settings.show_reblogs": "Vis fremhevinger",
+  "home.column_settings.show_replies": "Vis svar",
+  "home.settings": "Kolonneinnstillinger",
+  "lightbox.close": "Lukk",
+  "loading_indicator.label": "Laster...",
+  "media_gallery.toggle_visible": "Veksle synlighet",
+  "missing_indicator.label": "Ikke funnet",
+  "navigation_bar.blocks": "Blokkerte brukere",
+  "navigation_bar.community_timeline": "Lokal tidslinje",
+  "navigation_bar.edit_profile": "Rediger profil",
+  "navigation_bar.favourites": "Likt",
+  "navigation_bar.follow_requests": "Følgeforespørsler",
+  "navigation_bar.info": "Utvidet informasjon",
+  "navigation_bar.logout": "Logg ut",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.preferences": "Preferanser",
+  "navigation_bar.public_timeline": "Felles tidslinje",
+  "notification.favourite": "{name} likte din status",
+  "notification.follow": "{name} fulgte deg",
+  "notification.reblog": "{name} fremhevde din status",
+  "notifications.clear": "Fjern varsler",
+  "notifications.clear_confirmation": "Er du sikker på at du vil fjerne alle dine varsler?",
+  "notifications.column_settings.alert": "Skrivebordsvarslinger",
+  "notifications.column_settings.favourite": "Likt:",
+  "notifications.column_settings.follow": "Nye følgere:",
+  "notifications.column_settings.mention": "Nevninger:",
+  "notifications.column_settings.reblog": "Fremhevinger:",
+  "notifications.column_settings.show": "Vis i kolonne",
+  "notifications.column_settings.sound": "Spill lyd",
+  "notifications.settings": "Kolonneinstillinger",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Justér synlighet",
+  "privacy.direct.long": "Post kun til nevnte brukere",
+  "privacy.direct.short": "Direkte",
+  "privacy.private.long": "Post kun til følgere",
+  "privacy.private.short": "Privat",
+  "privacy.public.long": "Post kun til offentlige tidslinjer",
+  "privacy.public.short": "Offentlig",
+  "privacy.unlisted.long": "Ikke vis i offentlige tidslinjer",
+  "privacy.unlisted.short": "Uoppført",
+  "reply_indicator.cancel": "Avbryt",
+  "report.heading": "Ny rapport",
+  "report.placeholder": "Tilleggskommentarer",
+  "report.submit": "Send inn",
+  "report.target": "Rapporterer",
+  "search.placeholder": "Søk",
+  "search_results.total": "{count, number} {count, plural, one {resultat} other {resultater}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Slett",
+  "status.favourite": "Lik",
+  "status.load_more": "Last mer",
+  "status.media_hidden": "Media skjult",
+  "status.mention": "Nevn @{name}",
+  "status.open": "Utvid denne statusen",
+  "status.reblog": "Fremhev",
+  "status.reblogged_by": "Fremhevd av {name}",
+  "status.reply": "Svar",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Rapporter @{name}",
+  "status.sensitive_toggle": "Klikk for å vise",
+  "status.sensitive_warning": "Følsomt innhold",
+  "status.show_less": "Vis mindre",
+  "status.show_more": "Vis mer",
+  "tabs_bar.compose": "Komponer",
+  "tabs_bar.federated_timeline": "Felles",
+  "tabs_bar.home": "Hjem",
+  "tabs_bar.local_timeline": "Lokal",
+  "tabs_bar.notifications": "Varslinger",
+  "upload_area.title": "Dra og slipp for å laste opp",
+  "upload_button.label": "Legg til media",
+  "upload_form.undo": "Angre",
+  "upload_progress.label": "Laster opp...",
+  "video_player.expand": "Utvid video",
+  "video_player.toggle_sound": "Veksle lyd",
+  "video_player.toggle_visible": "Veksle synlighet",
+  "video_player.video_error": "Video could not be played"
+}
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
new file mode 100644
index 000000000..5a23fe9f8
--- /dev/null
+++ b/app/javascript/mastodon/locales/oc.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Blocar",
+  "account.disclaimer": "Aqueste compte es sus una autra instància. Los nombres pòdon èsser mai grandes.",
+  "account.edit_profile": "Modificar lo perfil",
+  "account.follow": "Sègre",
+  "account.followers": "Abonats",
+  "account.follows": "Abonaments",
+  "account.follows_you": "Vos sèc",
+  "account.mention": "Mencionar",
+  "account.mute": "Rescondre",
+  "account.posts": "Estatuts",
+  "account.report": "Senhalar",
+  "account.requested": "Invitacion mandada",
+  "account.unblock": "Desblocar",
+  "account.unfollow": "Quitar de sègre",
+  "account.unmute": "Quitar de rescondre",
+  "boost_modal.combo": "You can press {combo} to skip this next time",
+  "column.blocks": "Personas blocadas",
+  "column.community": "Fil public local",
+  "column.favourites": "Favorits",
+  "column.follow_requests": "Demandas d’abonament",
+  "column.home": "Acuèlh",
+  "column.mutes": "Muted users",
+  "column.notifications": "Notificacions",
+  "column.public": "Fil public global",
+  "column_back_button.label": "Tornar",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "A de qué pensatz ?",
+  "compose_form.privacy_disclaimer": "Vòstre estatut privat serà enviat a las personas mencionadas sus {domains}. Vos fisatz d’aqueste{domainsCount, plural, one { servidor} other {s servidors}} per divulgar pas vòstre estatut ? Los estatuts privats foncionan pas que sus las instàncias a Mastodons. Se {domains} {domainsCount, plural, one {es pas una instància a Mastodon} other {son pas d'instàncias a Mastodon}}, i aurà pas d’indicacion disent que vòstre estatut es privat e poirà èsser partejat o èsser visible a de mond pas prevists",
+  "compose_form.publish": "Tut",
+  "compose_form.sensitive": "Marcar lo mèdia coma embarrassant",
+  "compose_form.spoiler": "Rescondre lo tèxte darrièr un avertiment",
+  "compose_form.spoiler_placeholder": "Avertiment",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Inserir un emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "Lo fil public local es void. Escribètz quicòm per lo garnir !",
+  "empty_column.hashtag": "I a pas encara de contengut ligat a aqueste hashtag",
+  "empty_column.home": "Pel moment segètz pas segun. Visitatz {public} o utilizatz la recèrca per vos connectar a d’autras personas.",
+  "empty_column.home.public_timeline": "lo fil public",
+  "empty_column.notifications": "Avètz pas encara de notificacions. Respondètz a qualqu’un per començar una conversacion.",
+  "empty_column.public": "I a pas res aquí ! Escribètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo fil public.",
+  "follow_request.authorize": "Autorizar",
+  "follow_request.reject": "Regetar",
+  "getting_started.apps": "Various apps are available",
+  "getting_started.heading": "Per començar",
+  "getting_started.open_source_notice": "Mastodon es un logicial liure. Podètz contribuir e mandar vòstres comentaris e rapòrt de bug via{github} sus GitHub.",
+  "home.column_settings.advanced": "Avançat",
+  "home.column_settings.basic": "Basic",
+  "home.column_settings.filter_regex": "Filtrar amb una expression racionala",
+  "home.column_settings.show_reblogs": "Mostrar los partatges",
+  "home.column_settings.show_replies": "Mostrar las responsas",
+  "home.settings": "Paramètres de la colomna",
+  "lightbox.close": "Tampar",
+  "loading_indicator.label": "Cargament…",
+  "media_gallery.toggle_visible": "Modificar la visibilitat",
+  "missing_indicator.label": "Pas trobat",
+  "navigation_bar.blocks": "Personas blocadas",
+  "navigation_bar.community_timeline": "Fil public local",
+  "navigation_bar.edit_profile": "Modificar lo perfil",
+  "navigation_bar.favourites": "Favorits",
+  "navigation_bar.follow_requests": "Demandas d'abonament",
+  "navigation_bar.info": "Mai informacions",
+  "navigation_bar.logout": "Desconnexion",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.preferences": "Preferéncias",
+  "navigation_bar.public_timeline": "Fil public global",
+  "notification.favourite": "{name} a apondut a sos favorits :",
+  "notification.follow": "{name} vos sèc.",
+  "notification.reblog": "{name} a partejat vòstre estatut :",
+  "notifications.clear": "Levar",
+  "notifications.clear_confirmation": "Volètz vertadièrament levar totas vòstras las notificacions ?",
+  "notifications.column_settings.alert": "Notificacions localas",
+  "notifications.column_settings.favourite": "Favorits :",
+  "notifications.column_settings.follow": "Nòus abonats :",
+  "notifications.column_settings.mention": "Mencions :",
+  "notifications.column_settings.reblog": "Partatges :",
+  "notifications.column_settings.show": "Mostrar dins la colomna",
+  "notifications.column_settings.sound": "Emetre un son",
+  "notifications.settings": "Paramètres de la colomna",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Ajustar la confidencialitat del messatge",
+  "privacy.direct.long": "Mostrar pas qu'a las personas mencionadas",
+  "privacy.direct.short": "Dirècte",
+  "privacy.private.long": "Mostrar pas qu'a vòstres abonats",
+  "privacy.private.short": "Privat",
+  "privacy.public.long": "Mostrar dins los fils publics",
+  "privacy.public.short": "Public",
+  "privacy.unlisted.long": "Mostrar pas dins los fils publics",
+  "privacy.unlisted.short": "Pas-listat",
+  "reply_indicator.cancel": "Anullar",
+  "report.heading": "Nòu senhalament",
+  "report.placeholder": "Comentaris addicionals",
+  "report.submit": "Mandat",
+  "report.target": "Senhalament",
+  "search.placeholder": "Recercar",
+  "search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Escafar",
+  "status.favourite": "Apondre als favorits",
+  "status.load_more": "Cargar mai",
+  "status.media_hidden": "Mèdia rescondut",
+  "status.mention": "Mencionar",
+  "status.open": "Desplegar aqueste estatut",
+  "status.reblog": "Partejar",
+  "status.reblogged_by": "{name} a partejat :",
+  "status.reply": "Respondre",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Senhalar @{name}",
+  "status.sensitive_toggle": "Clicar per mostrar",
+  "status.sensitive_warning": "Contengut embarrassant",
+  "status.show_less": "Tornar plegar",
+  "status.show_more": "Desplegar",
+  "tabs_bar.compose": "Compausar",
+  "tabs_bar.federated_timeline": "Fil public global",
+  "tabs_bar.home": "Acuèlh",
+  "tabs_bar.local_timeline": "Fil public local",
+  "tabs_bar.notifications": "Notifications",
+  "upload_area.title": "Lisatz e depausatz per mandar",
+  "upload_button.label": "Apondre un mèdia",
+  "upload_form.undo": "Anullar",
+  "upload_progress.label": "Mandadís…",
+  "video_player.expand": "Expand video",
+  "video_player.toggle_sound": "Activar/Desactivar lo son",
+  "video_player.toggle_visible": "Mostrar/Rescondre la vidèo",
+  "video_player.video_error": "Video could not be played"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
new file mode 100644
index 000000000..12e9f6b5f
--- /dev/null
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Bloquear @{name}",
+  "account.disclaimer": "Essa conta está localizado em outra instância. Os nomes podem ser maiores.",
+  "account.edit_profile": "Editar perfil",
+  "account.follow": "Seguir",
+  "account.followers": "Seguidores",
+  "account.follows": "Segue",
+  "account.follows_you": "É teu seguidor",
+  "account.mention": "Mencionar @{name}",
+  "account.mute": "Silenciar @{name}",
+  "account.posts": "Posts",
+  "account.report": "Denunciar @{name}",
+  "account.requested": "A aguardar aprovação",
+  "account.unblock": "Não bloquear @{name}",
+  "account.unfollow": "Deixar de seguir",
+  "account.unmute": "Não silenciar @{name}",
+  "boost_modal.combo": "Pode clicar {combo} para não voltar a ver",
+  "column.blocks": "Utilizadores Bloqueados",
+  "column.community": "Local",
+  "column.favourites": "Favoritos",
+  "column.follow_requests": "Seguidores Pendentes",
+  "column.home": "Home",
+  "column.mutes": "Utilizadores silenciados",
+  "column.notifications": "Notificações",
+  "column.public": "Global",
+  "column_back_button.label": "Voltar",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "Em que estás a pensar?",
+  "compose_form.privacy_disclaimer": "O teu conteúdo privado vai ser partilhado com os utilizadores do {domains}. Confias {domainsCount, plural, one {neste servidor} other {nestes servidores}}? A privacidade só funciona em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não existem indicadores da privacidade da tua partilha, e podem ser partilhados com outros.",
+  "compose_form.publish": "Publicar",
+  "compose_form.sensitive": "Marcar media como conteúdo sensível",
+  "compose_form.spoiler": "Esconder texto com aviso",
+  "compose_form.spoiler_placeholder": "Aviso de conteúdo",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Inserir Emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "Ainda não existem conteúdo local para mostrar!",
+  "empty_column.hashtag": "Ainda não existe qualquer conteúdo com essa hashtag",
+  "empty_column.home": "Ainda não segues qualquer utilizador. Visita {public} ou utiliza a pesquisa para procurar outros utilizadores.",
+  "empty_column.home.public_timeline": "global",
+  "empty_column.notifications": "Não tens notificações. Interage com outros utilizadores para iniciar uma conversa.",
+  "empty_column.public": "Não há nada aqui! Escreve algo publicamente ou segue outros utilizadores para ver aqui os conteúdos públicos.",
+  "follow_request.authorize": "Autorizar",
+  "follow_request.reject": "Rejeitar",
+  "getting_started.apps": "Existem várias aplicações disponíveis",
+  "getting_started.heading": "Primeiros passos",
+  "getting_started.open_source_notice": "Mastodon é software de fonte aberta. Podes contribuir ou repostar problemas no GitHub do projecto: {github}. {apps}.",
+  "home.column_settings.advanced": "Avançado",
+  "home.column_settings.basic": "Básico",
+  "home.column_settings.filter_regex": "Filtrar com uma expressão regular",
+  "home.column_settings.show_reblogs": "Mostrar as partilhas",
+  "home.column_settings.show_replies": "Mostrar as respostas",
+  "home.settings": "Parâmetros da listagem Home",
+  "lightbox.close": "Fechar",
+  "loading_indicator.label": "Carregando...",
+  "media_gallery.toggle_visible": "Esconder/Mostrar",
+  "missing_indicator.label": "Não encontrado",
+  "navigation_bar.blocks": "Utilizadores bloqueados",
+  "navigation_bar.community_timeline": "Local",
+  "navigation_bar.edit_profile": "Editar perfil",
+  "navigation_bar.favourites": "Favoritos",
+  "navigation_bar.follow_requests": "Seguidores pendentes",
+  "navigation_bar.info": "Mais informações",
+  "navigation_bar.logout": "Sair",
+  "navigation_bar.mutes": "Utilizadores silenciados",
+  "navigation_bar.preferences": "Preferências",
+  "navigation_bar.public_timeline": "Global",
+  "notification.favourite": "{name} adicionou o teu post aos favoritos",
+  "notification.follow": "{name} seguiu-te",
+  "notification.reblog": "{name} partilhou o teu post",
+  "notifications.clear": "Limpar notificações",
+  "notifications.clear_confirmation": "Queres mesmo limpar todas as notificações?",
+  "notifications.column_settings.alert": "Notificações no computador",
+  "notifications.column_settings.favourite": "Favoritos:",
+  "notifications.column_settings.follow": "Novos seguidores:",
+  "notifications.column_settings.mention": "Menções:",
+  "notifications.column_settings.reblog": "Partilhas:",
+  "notifications.column_settings.show": "Mostrar nas colunas",
+  "notifications.column_settings.sound": "Reproduzir som",
+  "notifications.settings": "Parâmetros da listagem de Notificações",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Ajustar a privacidade da mensagem",
+  "privacy.direct.long": "Apenas para utilizadores mencionados",
+  "privacy.direct.short": "Directo",
+  "privacy.private.long": "Apenas para os seguidores",
+  "privacy.private.short": "Privado",
+  "privacy.public.long": "Publicar em todos os feeds",
+  "privacy.public.short": "Público",
+  "privacy.unlisted.long": "Não publicar nos feeds públicos",
+  "privacy.unlisted.short": "Não listar",
+  "reply_indicator.cancel": "Cancelar",
+  "report.heading": "Nova denúncia",
+  "report.placeholder": "Comentários adicionais",
+  "report.submit": "Enviar",
+  "report.target": "Denunciar",
+  "search.placeholder": "Pesquisar",
+  "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Eliminar",
+  "status.favourite": "Adicionar aos favoritos",
+  "status.load_more": "Carregar mais",
+  "status.media_hidden": "Media escondida",
+  "status.mention": "Mencionar @{name}",
+  "status.open": "Expandir",
+  "status.reblog": "Partilhar",
+  "status.reblogged_by": "{name} partilhou",
+  "status.reply": "Responder",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Denúnciar @{name}",
+  "status.sensitive_toggle": "Clique para ver",
+  "status.sensitive_warning": "Conteúdo sensível",
+  "status.show_less": "Mostrar menos",
+  "status.show_more": "Mostrar mais",
+  "tabs_bar.compose": "Criar",
+  "tabs_bar.federated_timeline": "Global",
+  "tabs_bar.home": "Home",
+  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.notifications": "Notificações",
+  "upload_area.title": "Arraste e solte para enviar",
+  "upload_button.label": "Adicionar media",
+  "upload_form.undo": "Anular",
+  "upload_progress.label": "A gravar...",
+  "video_player.expand": "Expandir vídeo",
+  "video_player.toggle_sound": "Ligar/Desligar som",
+  "video_player.toggle_visible": "Ligar/Desligar vídeo",
+  "video_player.video_error": "Não é possível ver o vídeo"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
new file mode 100644
index 000000000..12e9f6b5f
--- /dev/null
+++ b/app/javascript/mastodon/locales/pt.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Bloquear @{name}",
+  "account.disclaimer": "Essa conta está localizado em outra instância. Os nomes podem ser maiores.",
+  "account.edit_profile": "Editar perfil",
+  "account.follow": "Seguir",
+  "account.followers": "Seguidores",
+  "account.follows": "Segue",
+  "account.follows_you": "É teu seguidor",
+  "account.mention": "Mencionar @{name}",
+  "account.mute": "Silenciar @{name}",
+  "account.posts": "Posts",
+  "account.report": "Denunciar @{name}",
+  "account.requested": "A aguardar aprovação",
+  "account.unblock": "Não bloquear @{name}",
+  "account.unfollow": "Deixar de seguir",
+  "account.unmute": "Não silenciar @{name}",
+  "boost_modal.combo": "Pode clicar {combo} para não voltar a ver",
+  "column.blocks": "Utilizadores Bloqueados",
+  "column.community": "Local",
+  "column.favourites": "Favoritos",
+  "column.follow_requests": "Seguidores Pendentes",
+  "column.home": "Home",
+  "column.mutes": "Utilizadores silenciados",
+  "column.notifications": "Notificações",
+  "column.public": "Global",
+  "column_back_button.label": "Voltar",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "Em que estás a pensar?",
+  "compose_form.privacy_disclaimer": "O teu conteúdo privado vai ser partilhado com os utilizadores do {domains}. Confias {domainsCount, plural, one {neste servidor} other {nestes servidores}}? A privacidade só funciona em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não existem indicadores da privacidade da tua partilha, e podem ser partilhados com outros.",
+  "compose_form.publish": "Publicar",
+  "compose_form.sensitive": "Marcar media como conteúdo sensível",
+  "compose_form.spoiler": "Esconder texto com aviso",
+  "compose_form.spoiler_placeholder": "Aviso de conteúdo",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Inserir Emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "Ainda não existem conteúdo local para mostrar!",
+  "empty_column.hashtag": "Ainda não existe qualquer conteúdo com essa hashtag",
+  "empty_column.home": "Ainda não segues qualquer utilizador. Visita {public} ou utiliza a pesquisa para procurar outros utilizadores.",
+  "empty_column.home.public_timeline": "global",
+  "empty_column.notifications": "Não tens notificações. Interage com outros utilizadores para iniciar uma conversa.",
+  "empty_column.public": "Não há nada aqui! Escreve algo publicamente ou segue outros utilizadores para ver aqui os conteúdos públicos.",
+  "follow_request.authorize": "Autorizar",
+  "follow_request.reject": "Rejeitar",
+  "getting_started.apps": "Existem várias aplicações disponíveis",
+  "getting_started.heading": "Primeiros passos",
+  "getting_started.open_source_notice": "Mastodon é software de fonte aberta. Podes contribuir ou repostar problemas no GitHub do projecto: {github}. {apps}.",
+  "home.column_settings.advanced": "Avançado",
+  "home.column_settings.basic": "Básico",
+  "home.column_settings.filter_regex": "Filtrar com uma expressão regular",
+  "home.column_settings.show_reblogs": "Mostrar as partilhas",
+  "home.column_settings.show_replies": "Mostrar as respostas",
+  "home.settings": "Parâmetros da listagem Home",
+  "lightbox.close": "Fechar",
+  "loading_indicator.label": "Carregando...",
+  "media_gallery.toggle_visible": "Esconder/Mostrar",
+  "missing_indicator.label": "Não encontrado",
+  "navigation_bar.blocks": "Utilizadores bloqueados",
+  "navigation_bar.community_timeline": "Local",
+  "navigation_bar.edit_profile": "Editar perfil",
+  "navigation_bar.favourites": "Favoritos",
+  "navigation_bar.follow_requests": "Seguidores pendentes",
+  "navigation_bar.info": "Mais informações",
+  "navigation_bar.logout": "Sair",
+  "navigation_bar.mutes": "Utilizadores silenciados",
+  "navigation_bar.preferences": "Preferências",
+  "navigation_bar.public_timeline": "Global",
+  "notification.favourite": "{name} adicionou o teu post aos favoritos",
+  "notification.follow": "{name} seguiu-te",
+  "notification.reblog": "{name} partilhou o teu post",
+  "notifications.clear": "Limpar notificações",
+  "notifications.clear_confirmation": "Queres mesmo limpar todas as notificações?",
+  "notifications.column_settings.alert": "Notificações no computador",
+  "notifications.column_settings.favourite": "Favoritos:",
+  "notifications.column_settings.follow": "Novos seguidores:",
+  "notifications.column_settings.mention": "Menções:",
+  "notifications.column_settings.reblog": "Partilhas:",
+  "notifications.column_settings.show": "Mostrar nas colunas",
+  "notifications.column_settings.sound": "Reproduzir som",
+  "notifications.settings": "Parâmetros da listagem de Notificações",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Ajustar a privacidade da mensagem",
+  "privacy.direct.long": "Apenas para utilizadores mencionados",
+  "privacy.direct.short": "Directo",
+  "privacy.private.long": "Apenas para os seguidores",
+  "privacy.private.short": "Privado",
+  "privacy.public.long": "Publicar em todos os feeds",
+  "privacy.public.short": "Público",
+  "privacy.unlisted.long": "Não publicar nos feeds públicos",
+  "privacy.unlisted.short": "Não listar",
+  "reply_indicator.cancel": "Cancelar",
+  "report.heading": "Nova denúncia",
+  "report.placeholder": "Comentários adicionais",
+  "report.submit": "Enviar",
+  "report.target": "Denunciar",
+  "search.placeholder": "Pesquisar",
+  "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Eliminar",
+  "status.favourite": "Adicionar aos favoritos",
+  "status.load_more": "Carregar mais",
+  "status.media_hidden": "Media escondida",
+  "status.mention": "Mencionar @{name}",
+  "status.open": "Expandir",
+  "status.reblog": "Partilhar",
+  "status.reblogged_by": "{name} partilhou",
+  "status.reply": "Responder",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Denúnciar @{name}",
+  "status.sensitive_toggle": "Clique para ver",
+  "status.sensitive_warning": "Conteúdo sensível",
+  "status.show_less": "Mostrar menos",
+  "status.show_more": "Mostrar mais",
+  "tabs_bar.compose": "Criar",
+  "tabs_bar.federated_timeline": "Global",
+  "tabs_bar.home": "Home",
+  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.notifications": "Notificações",
+  "upload_area.title": "Arraste e solte para enviar",
+  "upload_button.label": "Adicionar media",
+  "upload_form.undo": "Anular",
+  "upload_progress.label": "A gravar...",
+  "video_player.expand": "Expandir vídeo",
+  "video_player.toggle_sound": "Ligar/Desligar som",
+  "video_player.toggle_visible": "Ligar/Desligar vídeo",
+  "video_player.video_error": "Não é possível ver o vídeo"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
new file mode 100644
index 000000000..c13c95960
--- /dev/null
+++ b/app/javascript/mastodon/locales/ru.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Блокировать",
+  "account.disclaimer": "Это пользователь с другого узла. Число может быть больше.",
+  "account.edit_profile": "Изменить профиль",
+  "account.follow": "Подписаться",
+  "account.followers": "Подписаны",
+  "account.follows": "Подписки",
+  "account.follows_you": "Подписан(а) на Вас",
+  "account.mention": "Упомянуть",
+  "account.mute": "Заглушить",
+  "account.posts": "Посты",
+  "account.report": "Пожаловаться",
+  "account.requested": "Ожидает подтверждения",
+  "account.unblock": "Разблокировать",
+  "account.unfollow": "Отписаться",
+  "account.unmute": "Снять глушение",
+  "boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз",
+  "column.blocks": "Список блокировки",
+  "column.community": "Локальная лента",
+  "column.favourites": "Понравившееся",
+  "column.follow_requests": "Запросы на подписку",
+  "column.home": "Главная",
+  "column.mutes": "Список глушения",
+  "column.notifications": "Уведомления",
+  "column.public": "Глобальная лента",
+  "column_back_button.label": "Назад",
+  "column_subheading.navigation": "Навигация",
+  "column_subheading.settings": "Настройки",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "О чем Вы думаете?",
+  "compose_form.privacy_disclaimer": "Ваш приватный статус будет доставлен упомянутым пользователям на доменах {domains}. Доверяете ли вы {domainsCount, plural, one {этому серверу} other {этим серверам}}? Приватность постов работает только на узлах Mastodon. Если {domains} {domainsCount, plural, one {не является узлом Mastodon} other {не являются узлами Mastodon}}, приватность поста не будет указана, и он может оказаться продвинут или иным образом показан не обозначенным Вами пользователям.",
+  "compose_form.publish": "Трубить",
+  "compose_form.sensitive": "Отметить как чувствительный контент",
+  "compose_form.spoiler": "Скрыть текст за предупреждением",
+  "compose_form.spoiler_placeholder": "Предупреждение о скрытом тексте",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Занятия",
+  "emoji_button.flags": "Флаги",
+  "emoji_button.food": "Еда и напитки",
+  "emoji_button.label": "Вставить эмодзи",
+  "emoji_button.nature": "Природа",
+  "emoji_button.objects": "Предметы",
+  "emoji_button.people": "Люди",
+  "emoji_button.search": "Найти...",
+  "emoji_button.symbols": "Символы",
+  "emoji_button.travel": "Путешествия",
+  "empty_column.community": "Локальная лента пуста. Напишите что-нибудь, чтобы разогреть народ!",
+  "empty_column.hashtag": "Статусов с таким хэштегом еще не существует.",
+  "empty_column.home": "Пока Вы ни на кого не подписаны. Полистайте {public} или используйте поиск, чтобы освоиться и завести новые знакомства.",
+  "empty_column.home.public_timeline": "публичные ленты",
+  "empty_column.notifications": "У Вас еще нет уведомлений. Заведите знакомство с другими пользователями, чтобы начать разговор.",
+  "empty_column.public": "Здесь ничего нет! Опубликуйте что-нибудь или подпишитесь на пользователей с других узлов, чтобы заполнить ленту.",
+  "follow_request.authorize": "Авторизовать",
+  "follow_request.reject": "Отказать",
+  "getting_started.apps": "Доступны различные приложения.",
+  "getting_started.heading": "Добро пожаловать",
+  "getting_started.open_source_notice": "Mastodon - программа с открытым исходным кодом. Вы можете помочь проекту или сообщить о проблемах на GitHub по адресу {github}. {apps}.",
+  "home.column_settings.advanced": "Дополнительные",
+  "home.column_settings.basic": "Основные",
+  "home.column_settings.filter_regex": "Отфильтровать регулярным выражением",
+  "home.column_settings.show_reblogs": "Показывать продвижения",
+  "home.column_settings.show_replies": "Показывать ответы",
+  "home.settings": "Настройки колонки",
+  "lightbox.close": "Закрыть",
+  "loading_indicator.label": "Загрузка...",
+  "media_gallery.toggle_visible": "Показать/скрыть",
+  "missing_indicator.label": "Не найдено",
+  "navigation_bar.blocks": "Список блокировки",
+  "navigation_bar.community_timeline": "Локальная лента",
+  "navigation_bar.edit_profile": "Изменить профиль",
+  "navigation_bar.favourites": "Понравившееся",
+  "navigation_bar.follow_requests": "Запросы на подписку",
+  "navigation_bar.info": "Об узле",
+  "navigation_bar.logout": "Выйти",
+  "navigation_bar.mutes": "Список глушения",
+  "navigation_bar.preferences": "Опции",
+  "navigation_bar.public_timeline": "Глобальная лента",
+  "notification.favourite": "{name} понравился Ваш статус",
+  "notification.follow": "{name} подписался(-лась) на Вас",
+  "notification.reblog": "{name} продвинул(а) Ваш статус",
+  "notifications.clear": "Очистить уведомления",
+  "notifications.clear_confirmation": "Вы уверены, что хотите очистить все уведомления?",
+  "notifications.column_settings.alert": "Десктопные уведомления",
+  "notifications.column_settings.favourite": "Нравится:",
+  "notifications.column_settings.follow": "Новые подписчики:",
+  "notifications.column_settings.mention": "Упоминания:",
+  "notifications.column_settings.reblog": "Продвижения:",
+  "notifications.column_settings.show": "Показывать в колонке",
+  "notifications.column_settings.sound": "Проигрывать звук",
+  "notifications.settings": "Настройки колонки",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Изменить видимость статуса",
+  "privacy.direct.long": "Показать только упомянутым",
+  "privacy.direct.short": "Направленный",
+  "privacy.private.long": "Показать только подписчикам",
+  "privacy.private.short": "Приватный",
+  "privacy.public.long": "Показать в публичных лентах",
+  "privacy.public.short": "Публичный",
+  "privacy.unlisted.long": "Не показывать в лентах",
+  "privacy.unlisted.short": "Скрытый",
+  "reply_indicator.cancel": "Отмена",
+  "report.heading": "Новая жалоба",
+  "report.placeholder": "Комментарий",
+  "report.submit": "Отправить",
+  "report.target": "Жалуемся на",
+  "search.placeholder": "Поиск",
+  "search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}",
+  "status.cannot_reblog": "Этот статус не может быть продвинут",
+  "status.delete": "Удалить",
+  "status.favourite": "Нравится",
+  "status.load_more": "Показать еще",
+  "status.media_hidden": "Медиаконтент скрыт",
+  "status.mention": "Упомянуть @{name}",
+  "status.open": "Развернуть статус",
+  "status.reblog": "Продвинуть",
+  "status.reblogged_by": "{name} продвинул(а)",
+  "status.reply": "Ответить",
+  "status.replyAll": "Ответить на тред",
+  "status.report": "Пожаловаться",
+  "status.sensitive_toggle": "Нажмите для просмотра",
+  "status.sensitive_warning": "Чувствительный контент",
+  "status.show_less": "Свернуть",
+  "status.show_more": "Развернуть",
+  "tabs_bar.compose": "Написать",
+  "tabs_bar.federated_timeline": "Глобальная",
+  "tabs_bar.home": "Главная",
+  "tabs_bar.local_timeline": "Локальная",
+  "tabs_bar.notifications": "Уведомления",
+  "upload_area.title": "Перетащите сюда, чтобы загрузить",
+  "upload_button.label": "Добавить медиаконтент",
+  "upload_form.undo": "Отменить",
+  "upload_progress.label": "Загрузка...",
+  "video_player.expand": "Развернуть видео",
+  "video_player.toggle_sound": "Вкл./выкл. звук",
+  "video_player.toggle_visible": "Показать/скрыть",
+  "video_player.video_error": "Видео не может быть проиграно"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
new file mode 100644
index 000000000..fde28871d
--- /dev/null
+++ b/app/javascript/mastodon/locales/uk.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "Заблокувати",
+  "account.disclaimer": "This user is from another instance. This number may be larger.",
+  "account.edit_profile": "Налаштування профілю",
+  "account.follow": "Підписатися",
+  "account.followers": "Підписники",
+  "account.follows": "Підписки",
+  "account.follows_you": "Підписаний",
+  "account.mention": "Згадати",
+  "account.mute": "Mute @{name}",
+  "account.posts": "Пости",
+  "account.report": "Report @{name}",
+  "account.requested": "Awaiting approval",
+  "account.unblock": "Розблокувати",
+  "account.unfollow": "Відписатися",
+  "account.unmute": "Unmute @{name}",
+  "boost_modal.combo": "You can press {combo} to skip this next time",
+  "column.blocks": "Blocked users",
+  "column.community": "Local timeline",
+  "column.favourites": "Favourites",
+  "column.follow_requests": "Follow requests",
+  "column.home": "Головна",
+  "column.mutes": "Muted users",
+  "column.notifications": "Сповіщення",
+  "column.public": "Стіна",
+  "column_back_button.label": "Назад",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "Що у Вас на думці?",
+  "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.publish": "Дмухнути",
+  "compose_form.sensitive": "Непристойний зміст",
+  "compose_form.spoiler": "Hide text behind warning",
+  "compose_form.spoiler_placeholder": "Content warning",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Insert emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
+  "empty_column.hashtag": "There is nothing in this hashtag yet.",
+  "empty_column.home": "You aren't following anyone yet. Visit {public} or use search to get started and meet other users.",
+  "empty_column.home.public_timeline": "the public timeline",
+  "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
+  "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
+  "follow_request.authorize": "Authorize",
+  "follow_request.reject": "Reject",
+  "getting_started.apps": "Various apps are available",
+  "getting_started.heading": "Ласкаво просимо",
+  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.basic": "Basic",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "home.column_settings.show_reblogs": "Show boosts",
+  "home.column_settings.show_replies": "Show replies",
+  "home.settings": "Column settings",
+  "lightbox.close": "Закрити",
+  "loading_indicator.label": "Завантаження...",
+  "media_gallery.toggle_visible": "Toggle visibility",
+  "missing_indicator.label": "Not found",
+  "navigation_bar.blocks": "Blocked users",
+  "navigation_bar.community_timeline": "Local timeline",
+  "navigation_bar.edit_profile": "Редагувати профіль",
+  "navigation_bar.favourites": "Favourites",
+  "navigation_bar.follow_requests": "Follow requests",
+  "navigation_bar.info": "Extended information",
+  "navigation_bar.logout": "Вийти",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.preferences": "Налаштування",
+  "navigation_bar.public_timeline": "Публічна стіна",
+  "notification.favourite": "{name} сподобався ваш допис",
+  "notification.follow": "{name} підписався(-лась) на Вас",
+  "notification.reblog": "{name} передмухнув(-ла) Ваш статус",
+  "notifications.clear": "Clear notifications",
+  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
+  "notifications.column_settings.alert": "Desktop notifications",
+  "notifications.column_settings.favourite": "Favourites:",
+  "notifications.column_settings.follow": "New followers:",
+  "notifications.column_settings.mention": "Mentions:",
+  "notifications.column_settings.reblog": "Boosts:",
+  "notifications.column_settings.show": "Show in column",
+  "notifications.column_settings.sound": "Play sound",
+  "notifications.settings": "Column settings",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Adjust status privacy",
+  "privacy.direct.long": "Post to mentioned users only",
+  "privacy.direct.short": "Direct",
+  "privacy.private.long": "Post to followers only",
+  "privacy.private.short": "Followers-only",
+  "privacy.public.long": "Post to public timelines",
+  "privacy.public.short": "Public",
+  "privacy.unlisted.long": "Do not show in public timelines",
+  "privacy.unlisted.short": "Unlisted",
+  "reply_indicator.cancel": "Відмінити",
+  "report.heading": "New report",
+  "report.placeholder": "Additional comments",
+  "report.submit": "Submit",
+  "report.target": "Reporting",
+  "search.placeholder": "Пошук",
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Видалити",
+  "status.favourite": "Подобається",
+  "status.load_more": "Load more",
+  "status.media_hidden": "Media hidden",
+  "status.mention": "Згадати",
+  "status.open": "Expand this status",
+  "status.reblog": "Передмухнути",
+  "status.reblogged_by": "{name} передмухнув(-ла)",
+  "status.reply": "Відповісти",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Report @{name}",
+  "status.sensitive_toggle": "Натисніть, щоб подивитися",
+  "status.sensitive_warning": "Непристойний зміст",
+  "status.show_less": "Show less",
+  "status.show_more": "Show more",
+  "tabs_bar.compose": "Написати",
+  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.home": "Головна",
+  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.notifications": "Сповіщення",
+  "upload_area.title": "Drag & drop to upload",
+  "upload_button.label": "Додати медіа",
+  "upload_form.undo": "Відмінити",
+  "upload_progress.label": "Uploading...",
+  "video_player.expand": "Expand video",
+  "video_player.toggle_sound": "Увімкнути/вимкнути звук",
+  "video_player.toggle_visible": "Toggle visibility",
+  "video_player.video_error": "Video could not be played"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_ar.json b/app/javascript/mastodon/locales/whitelist_ar.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_ar.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_bg.json b/app/javascript/mastodon/locales/whitelist_bg.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_bg.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_de.json b/app/javascript/mastodon/locales/whitelist_de.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_de.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_en.json b/app/javascript/mastodon/locales/whitelist_en.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_en.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_eo.json b/app/javascript/mastodon/locales/whitelist_eo.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_eo.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_es.json b/app/javascript/mastodon/locales/whitelist_es.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_es.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_fa.json b/app/javascript/mastodon/locales/whitelist_fa.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_fa.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_fi.json b/app/javascript/mastodon/locales/whitelist_fi.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_fi.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_fr.json b/app/javascript/mastodon/locales/whitelist_fr.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_fr.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_hr.json b/app/javascript/mastodon/locales/whitelist_hr.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_hr.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_hu.json b/app/javascript/mastodon/locales/whitelist_hu.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_hu.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_id.json b/app/javascript/mastodon/locales/whitelist_id.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_id.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_io.json b/app/javascript/mastodon/locales/whitelist_io.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_io.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_it.json b/app/javascript/mastodon/locales/whitelist_it.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_it.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_ja.json b/app/javascript/mastodon/locales/whitelist_ja.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_ja.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_nl.json b/app/javascript/mastodon/locales/whitelist_nl.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_nl.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_no.json b/app/javascript/mastodon/locales/whitelist_no.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_no.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_oc.json b/app/javascript/mastodon/locales/whitelist_oc.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_oc.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_pt-BR.json b/app/javascript/mastodon/locales/whitelist_pt-BR.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_pt-BR.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_pt.json b/app/javascript/mastodon/locales/whitelist_pt.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_pt.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_ru.json b/app/javascript/mastodon/locales/whitelist_ru.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_ru.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_uk.json b/app/javascript/mastodon/locales/whitelist_uk.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_uk.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_zh-CN.json b/app/javascript/mastodon/locales/whitelist_zh-CN.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_zh-CN.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/whitelist_zh-HK.json b/app/javascript/mastodon/locales/whitelist_zh-HK.json
new file mode 100644
index 000000000..32960f8ce
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_zh-HK.json
@@ -0,0 +1,2 @@
+[
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
new file mode 100644
index 000000000..1e0d1fa58
--- /dev/null
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "屏蔽 @{name}",
+  "account.disclaimer": "由于这个账户处于另一个服务站,实际数字会比这个更多。",
+  "account.edit_profile": "修改个人资料",
+  "account.follow": "关注",
+  "account.followers": "关注者",
+  "account.follows": "正关注",
+  "account.follows_you": "关注你",
+  "account.mention": "提及 @{name}",
+  "account.mute": "将 @{name} 静音",
+  "account.posts": "嘟文",
+  "account.report": "举报 @{name}",
+  "account.requested": "等候审批",
+  "account.unblock": "解除对 @{name} 的屏蔽",
+  "account.unfollow": "取消关注",
+  "account.unmute": "取消 @{name} 的静音",
+  "boost_modal.combo": "如你想在下次路过时显示,请按{combo},",
+  "column.blocks": "屏蔽用户",
+  "column.community": "本站时间轴",
+  "column.favourites": "赞过的嘟文",
+  "column.follow_requests": "关注请求",
+  "column.home": "主页",
+  "column.mutes": "Muted users",
+  "column.notifications": "通知",
+  "column.public": "跨站公共时间轴",
+  "column_back_button.label": "返回",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "在想啥?",
+  "compose_form.privacy_disclaimer": "你的私人嘟文,将被发送至你所提及的 {domains} 用户。你是否信任{domainsCount, plural, one {这个网站} other {这些网站}}?请留意,嘟文隐私设置只适用于各 Mastodon 服务站,如果 {domains} {domainsCount, plural, one {不是 Mastodon 服务站} other {之中有些不是 Mastodon 服务站}},对方将无法收到这篇嘟文的隐私设置,然后可能被转嘟给不能预知的用户阅读。",
+  "compose_form.publish": "嘟嘟",
+  "compose_form.sensitive": "将媒体文件标示为“敏感内容”",
+  "compose_form.spoiler": "将部分文本藏于警告消息之后",
+  "compose_form.spoiler_placeholder": "敏感内容的警告消息",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "加入表情符号",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "本站时间轴暂时未有内容,快贴文来抢头香啊!",
+  "empty_column.hashtag": "这个标签暂时未有内容。",
+  "empty_column.home": "你还没有关注任何用户。快看看{public},向其他用户搭讪吧。",
+  "empty_column.home.public_timeline": "公共时间轴",
+  "empty_column.notifications": "你没有任何通知纪录,快向其他用户搭讪吧。",
+  "empty_column.public": "跨站公共时间轴暂时没有内容!快写一些公共的嘟文,或者关注另一些服务站的用户吧!你和本站、友站的交流,将决定这里出现的内容。",
+  "follow_request.authorize": "批准",
+  "follow_request.reject": "拒绝",
+  "getting_started.apps": "手机或桌面应用程序",
+  "getting_started.heading": "开始使用",
+  "getting_started.open_source_notice": "Mastodon 是一个开放源码的软件。你可以在官方 GitHub ({github}) 贡献或者回报问题。你亦可通过{apps}阅读 Mastodon 上的消息。",
+  "home.column_settings.advanced": "高端",
+  "home.column_settings.basic": "基本",
+  "home.column_settings.filter_regex": "使用正则表达式 (regex) 过滤",
+  "home.column_settings.show_reblogs": "显示被转的嘟文",
+  "home.column_settings.show_replies": "显示回应嘟文",
+  "home.settings": "字段设置",
+  "lightbox.close": "关闭",
+  "loading_indicator.label": "加载中……",
+  "media_gallery.toggle_visible": "打开或关上",
+  "missing_indicator.label": "找不到内容",
+  "navigation_bar.blocks": "被屏蔽的用户",
+  "navigation_bar.community_timeline": "本站时间轴",
+  "navigation_bar.edit_profile": "修改个人资料",
+  "navigation_bar.favourites": "赞的内容",
+  "navigation_bar.follow_requests": "关注请求",
+  "navigation_bar.info": "关于本服务站",
+  "navigation_bar.logout": "注销",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.preferences": "首选项",
+  "navigation_bar.public_timeline": "跨站公共时间轴",
+  "notification.favourite": "{name} 赞你的嘟文",
+  "notification.follow": "{name} 开始关注你",
+  "notification.reblog": "{name} 转嘟你的嘟文",
+  "notifications.clear": "清空通知纪录",
+  "notifications.clear_confirmation": "你确定要清空通知纪录吗?",
+  "notifications.column_settings.alert": "显示桌面通知",
+  "notifications.column_settings.favourite": "赞你的嘟文:",
+  "notifications.column_settings.follow": "关注你:",
+  "notifications.column_settings.mention": "提及你:",
+  "notifications.column_settings.reblog": "转你的嘟文:",
+  "notifications.column_settings.show": "在通知栏显示",
+  "notifications.column_settings.sound": "播放音效",
+  "notifications.settings": "字段设置",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "调整隐私设置",
+  "privacy.direct.long": "只有提及的用户能看到",
+  "privacy.direct.short": "私人消息",
+  "privacy.private.long": "只有关注你用户能看到",
+  "privacy.private.short": "关注者",
+  "privacy.public.long": "在公共时间轴显示",
+  "privacy.public.short": "公共",
+  "privacy.unlisted.long": "公开,但不在公共时间轴显示",
+  "privacy.unlisted.short": "公开",
+  "reply_indicator.cancel": "取消",
+  "report.heading": "举报",
+  "report.placeholder": "额外消息",
+  "report.submit": "提交",
+  "report.target": "Reporting",
+  "search.placeholder": "搜索",
+  "search_results.total": "{count, number} 项结果",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "删除",
+  "status.favourite": "赞",
+  "status.load_more": "加载更多",
+  "status.media_hidden": "隐藏媒体内容",
+  "status.mention": "提及 @{name}",
+  "status.open": "展开嘟文",
+  "status.reblog": "转嘟",
+  "status.reblogged_by": "{name} 转嘟",
+  "status.reply": "回应",
+  "status.replyAll": "Reply to thread",
+  "status.report": "举报 @{name}",
+  "status.sensitive_toggle": "点击显示",
+  "status.sensitive_warning": "敏感内容",
+  "status.show_less": "减少显示",
+  "status.show_more": "显示更多",
+  "tabs_bar.compose": "撰写",
+  "tabs_bar.federated_timeline": "跨站",
+  "tabs_bar.home": "主页",
+  "tabs_bar.local_timeline": "本站",
+  "tabs_bar.notifications": "通知",
+  "upload_area.title": "将文件拖放至此上传",
+  "upload_button.label": "上传媒体文件",
+  "upload_form.undo": "还原",
+  "upload_progress.label": "上传中……",
+  "video_player.expand": "展开影片",
+  "video_player.toggle_sound": "开关音效",
+  "video_player.toggle_visible": "打开或关上",
+  "video_player.video_error": "Video could not be played"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
new file mode 100644
index 000000000..772b7f8fb
--- /dev/null
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -0,0 +1,163 @@
+{
+  "account.block": "封鎖 @{name}",
+  "account.disclaimer": "由於這個用戶在另一個服務站,實際數字會比這個更多。",
+  "account.edit_profile": "修改個人資料",
+  "account.follow": "關注",
+  "account.followers": "關注的人",
+  "account.follows": "正在關注",
+  "account.follows_you": "關注你",
+  "account.mention": "提及 @{name}",
+  "account.mute": "將 @{name} 靜音",
+  "account.posts": "文章",
+  "account.report": "舉報 @{name}",
+  "account.requested": "等候審批",
+  "account.unblock": "解除對 @{name} 的封鎖",
+  "account.unfollow": "取消關注",
+  "account.unmute": "取消 @{name} 的靜音",
+  "boost_modal.combo": "如你想在下次路過這顯示,請按{combo},",
+  "column.blocks": "封鎖用戶",
+  "column.community": "本站時間軸",
+  "column.favourites": "喜歡的文章",
+  "column.follow_requests": "關注請求",
+  "column.home": "主頁",
+  "column.mutes": "Muted users",
+  "column.notifications": "通知",
+  "column.public": "跨站公共時間軸",
+  "column_back_button.label": "返回",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "你在想甚麼?",
+  "compose_form.privacy_disclaimer": "你的私人文章,將被遞送至你所提及的 {domains} 用戶。你是否信任{domainsCount, plural, one {這個網站} other {這些網站}}?請留意,文章私隱設定只適用於各 Mastodon 服務站,如果 {domains} {domainsCount, plural, one {不是 Mastodon 服務站} other {之中有些不是 Mastodon 服務站}},對方將無法收到這篇文章的私隱設定,然後可能被轉推給不能預知的用戶閱讀。",
+  "compose_form.publish": "發文",
+  "compose_form.sensitive": "將媒體檔案標示為「敏感內容」",
+  "compose_form.spoiler": "將部份文字藏於警告訊息之後",
+  "compose_form.spoiler_placeholder": "敏感警告訊息",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "emoji_button.activity": "Activity",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "加入表情符號",
+  "emoji_button.nature": "Nature",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.search": "Search...",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "本站時間軸暫時未有內容,快貼文來搶頭香啊!",
+  "empty_column.hashtag": "這個標籤暫時未有內容。",
+  "empty_column.home": "你還沒有關注任何用戶。快看看{public},向其他用戶搭訕吧。",
+  "empty_column.home.public_timeline": "公共時間軸",
+  "empty_column.notifications": "你沒有任何通知紀錄,快向其他用戶搭訕吧。",
+  "empty_column.public": "跨站公共時間軸暫時沒有內容!快寫一些公共的文章,或者關注另一些服務站的用戶吧!你和本站、友站的交流,將決定這裏出現的內容。",
+  "follow_request.authorize": "批准",
+  "follow_request.reject": "拒絕",
+  "getting_started.apps": "手機或桌面應用程式",
+  "getting_started.heading": "開始使用",
+  "getting_started.open_source_notice": "Mastodon 是一個開放源碼的軟件。你可以在官方 GitHub ({github}) 貢獻或者回報問題。你亦可透過{apps}閱讀 Mastodon 上的消息。",
+  "home.column_settings.advanced": "進階",
+  "home.column_settings.basic": "基本",
+  "home.column_settings.filter_regex": "使用正規表達式 (regular expression) 過濾",
+  "home.column_settings.show_reblogs": "顯示被轉推的文章",
+  "home.column_settings.show_replies": "顯示回應文章",
+  "home.settings": "欄位設定",
+  "lightbox.close": "Close",
+  "loading_indicator.label": "載入中...",
+  "media_gallery.toggle_visible": "打開或關上",
+  "missing_indicator.label": "找不到內容",
+  "navigation_bar.blocks": "被封鎖的用戶",
+  "navigation_bar.community_timeline": "本站時間軸",
+  "navigation_bar.edit_profile": "修改個人資料",
+  "navigation_bar.favourites": "喜歡的內容",
+  "navigation_bar.follow_requests": "關注請求",
+  "navigation_bar.info": "關於本服務站",
+  "navigation_bar.logout": "登出",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.preferences": "偏好設定",
+  "navigation_bar.public_timeline": "跨站公共時間軸",
+  "notification.favourite": "{name} 喜歡你的文章",
+  "notification.follow": "{name} 開始關注你",
+  "notification.reblog": "{name} 轉推你的文章",
+  "notifications.clear": "清空通知紀錄",
+  "notifications.clear_confirmation": "你確定要清空通知紀錄嗎?",
+  "notifications.column_settings.alert": "顯示桌面通知",
+  "notifications.column_settings.favourite": "喜歡你的文章:",
+  "notifications.column_settings.follow": "關注你:",
+  "notifications.column_settings.mention": "提及你:",
+  "notifications.column_settings.reblog": "轉推你的文章:",
+  "notifications.column_settings.show": "在通知欄顯示",
+  "notifications.column_settings.sound": "播放音效",
+  "notifications.settings": "欄位設定",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "調整私隱設定",
+  "privacy.direct.long": "只有提及的用戶能看到",
+  "privacy.direct.short": "私人訊息",
+  "privacy.private.long": "只有關注你用戶能看到",
+  "privacy.private.short": "關注者",
+  "privacy.public.long": "在公共時間軸顯示",
+  "privacy.public.short": "公共",
+  "privacy.unlisted.long": "公開,但不在公共時間軸顯示",
+  "privacy.unlisted.short": "公開",
+  "reply_indicator.cancel": "取消",
+  "report.heading": "舉報",
+  "report.placeholder": "額外訊息",
+  "report.submit": "提交",
+  "report.target": "Reporting",
+  "search.placeholder": "搜尋",
+  "search_results.total": "{count, number} 項結果",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "刪除",
+  "status.favourite": "喜歡",
+  "status.load_more": "載入更多",
+  "status.media_hidden": "隱藏媒體內容",
+  "status.mention": "提及 @{name}",
+  "status.open": "展開文章",
+  "status.reblog": "轉推",
+  "status.reblogged_by": "{name} 轉推",
+  "status.reply": "回應",
+  "status.replyAll": "Reply to thread",
+  "status.report": "舉報 @{name}",
+  "status.sensitive_toggle": "點擊顯示",
+  "status.sensitive_warning": "敏感內容",
+  "status.show_less": "減少顯示",
+  "status.show_more": "顯示更多",
+  "tabs_bar.compose": "撰寫",
+  "tabs_bar.federated_timeline": "跨站",
+  "tabs_bar.home": "主頁",
+  "tabs_bar.local_timeline": "本站",
+  "tabs_bar.notifications": "通知",
+  "upload_area.title": "將檔案拖放至此上載",
+  "upload_button.label": "上載媒體檔案",
+  "upload_form.undo": "還原",
+  "upload_progress.label": "上載中……",
+  "video_player.expand": "展開影片",
+  "video_player.toggle_sound": "開關音效",
+  "video_player.toggle_visible": "打開或關上",
+  "video_player.video_error": "Video could not be played"
+}
\ No newline at end of file
diff --git a/app/javascript/mastodon/middleware/errors.js b/app/javascript/mastodon/middleware/errors.js
new file mode 100644
index 000000000..9a51257cb
--- /dev/null
+++ b/app/javascript/mastodon/middleware/errors.js
@@ -0,0 +1,33 @@
+import { showAlert } from '../actions/alerts';
+
+const defaultSuccessSuffix = 'SUCCESS';
+const defaultFailSuffix = 'FAIL';
+
+export default function errorsMiddleware() {
+  return ({ dispatch }) => next => action => {
+    if (action.type && !action.skipAlert) {
+      const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');
+      const isSuccess = new RegExp(`${defaultSuccessSuffix}$`, 'g');
+
+      if (action.type.match(isFail)) {
+        if (action.error.response) {
+          const { data, status, statusText } = action.error.response;
+
+          let message = statusText;
+          let title   = `${status}`;
+
+          if (data.error) {
+            message = data.error;
+          }
+
+          dispatch(showAlert(title, message));
+        } else {
+          console.error(action.error); // eslint-disable-line no-console
+          dispatch(showAlert('Oops!', 'An unexpected error occurred.'));
+        }
+      }
+    }
+
+    return next(action);
+  };
+};
diff --git a/app/javascript/mastodon/middleware/loading_bar.js b/app/javascript/mastodon/middleware/loading_bar.js
new file mode 100644
index 000000000..a98f1bb2b
--- /dev/null
+++ b/app/javascript/mastodon/middleware/loading_bar.js
@@ -0,0 +1,25 @@
+import { showLoading, hideLoading } from 'react-redux-loading-bar';
+
+const defaultTypeSuffixes = ['PENDING', 'FULFILLED', 'REJECTED'];
+
+export default function loadingBarMiddleware(config = {}) {
+  const promiseTypeSuffixes = config.promiseTypeSuffixes || defaultTypeSuffixes;
+
+  return ({ dispatch }) => next => (action) => {
+    if (action.type && !action.skipLoading) {
+      const [PENDING, FULFILLED, REJECTED] = promiseTypeSuffixes;
+
+      const isPending = new RegExp(`${PENDING}$`, 'g');
+      const isFulfilled = new RegExp(`${FULFILLED}$`, 'g');
+      const isRejected = new RegExp(`${REJECTED}$`, 'g');
+
+      if (action.type.match(isPending)) {
+        dispatch(showLoading());
+      } else if (action.type.match(isFulfilled) || action.type.match(isRejected)) {
+        dispatch(hideLoading());
+      }
+    }
+
+    return next(action);
+  };
+};
diff --git a/app/javascript/mastodon/middleware/sounds.js b/app/javascript/mastodon/middleware/sounds.js
new file mode 100644
index 000000000..200efa3d7
--- /dev/null
+++ b/app/javascript/mastodon/middleware/sounds.js
@@ -0,0 +1,22 @@
+const play = audio => {
+  if (!audio.paused) {
+    audio.pause();
+    audio.fastSeek(0);
+  }
+
+  audio.play();
+};
+
+export default function soundsMiddleware() {
+  const soundCache = {
+    boop: new Audio(['/sounds/boop.mp3'])
+  };
+
+  return ({ dispatch }) => next => (action) => {
+    if (action.meta && action.meta.sound && soundCache[action.meta.sound]) {
+      play(soundCache[action.meta.sound]);
+    }
+
+    return next(action);
+  };
+};
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js
new file mode 100644
index 000000000..b3c2b6d88
--- /dev/null
+++ b/app/javascript/mastodon/reducers/accounts.js
@@ -0,0 +1,133 @@
+import {
+  ACCOUNT_FETCH_SUCCESS,
+  FOLLOWERS_FETCH_SUCCESS,
+  FOLLOWERS_EXPAND_SUCCESS,
+  FOLLOWING_FETCH_SUCCESS,
+  FOLLOWING_EXPAND_SUCCESS,
+  ACCOUNT_TIMELINE_FETCH_SUCCESS,
+  ACCOUNT_TIMELINE_EXPAND_SUCCESS,
+  FOLLOW_REQUESTS_FETCH_SUCCESS,
+  FOLLOW_REQUESTS_EXPAND_SUCCESS
+} from '../actions/accounts';
+import {
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_EXPAND_SUCCESS
+} from '../actions/blocks';
+import {
+  MUTES_FETCH_SUCCESS,
+  MUTES_EXPAND_SUCCESS
+} from '../actions/mutes';
+import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
+import {
+  REBLOG_SUCCESS,
+  UNREBLOG_SUCCESS,
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS,
+  REBLOGS_FETCH_SUCCESS,
+  FAVOURITES_FETCH_SUCCESS
+} from '../actions/interactions';
+import {
+  TIMELINE_REFRESH_SUCCESS,
+  TIMELINE_UPDATE,
+  TIMELINE_EXPAND_SUCCESS
+} from '../actions/timelines';
+import {
+  STATUS_FETCH_SUCCESS,
+  CONTEXT_FETCH_SUCCESS
+} from '../actions/statuses';
+import { SEARCH_FETCH_SUCCESS } from '../actions/search';
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_REFRESH_SUCCESS,
+  NOTIFICATIONS_EXPAND_SUCCESS
+} from '../actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
+import { STORE_HYDRATE } from '../actions/store';
+import Immutable from 'immutable';
+
+const normalizeAccount = (state, account) => {
+  account = { ...account };
+
+  delete account.followers_count;
+  delete account.following_count;
+  delete account.statuses_count;
+
+  return state.set(account.id, Immutable.fromJS(account))
+};
+
+const normalizeAccounts = (state, accounts) => {
+  accounts.forEach(account => {
+    state = normalizeAccount(state, account);
+  });
+
+  return state;
+};
+
+const normalizeAccountFromStatus = (state, status) => {
+  state = normalizeAccount(state, status.account);
+
+  if (status.reblog && status.reblog.account) {
+    state = normalizeAccount(state, status.reblog.account);
+  }
+
+  return state;
+};
+
+const normalizeAccountsFromStatuses = (state, statuses) => {
+  statuses.forEach(status => {
+    state = normalizeAccountFromStatus(state, status);
+  });
+
+  return state;
+};
+
+const initialState = Immutable.Map();
+
+export default function accounts(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('accounts'));
+  case ACCOUNT_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+    return normalizeAccount(state, action.account);
+  case FOLLOWERS_FETCH_SUCCESS:
+  case FOLLOWERS_EXPAND_SUCCESS:
+  case FOLLOWING_FETCH_SUCCESS:
+  case FOLLOWING_EXPAND_SUCCESS:
+  case REBLOGS_FETCH_SUCCESS:
+  case FAVOURITES_FETCH_SUCCESS:
+  case COMPOSE_SUGGESTIONS_READY:
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+  case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+  case BLOCKS_FETCH_SUCCESS:
+  case BLOCKS_EXPAND_SUCCESS:
+  case MUTES_FETCH_SUCCESS:
+  case MUTES_EXPAND_SUCCESS:
+    return normalizeAccounts(state, action.accounts);
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
+    return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
+  case TIMELINE_REFRESH_SUCCESS:
+  case TIMELINE_EXPAND_SUCCESS:
+  case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+  case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return normalizeAccountsFromStatuses(state, action.statuses);
+  case REBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+    return normalizeAccountFromStatus(state, action.response);
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+    return normalizeAccountFromStatus(state, action.status);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js
new file mode 100644
index 000000000..2afc6c3d9
--- /dev/null
+++ b/app/javascript/mastodon/reducers/accounts_counters.js
@@ -0,0 +1,135 @@
+import {
+  ACCOUNT_FETCH_SUCCESS,
+  FOLLOWERS_FETCH_SUCCESS,
+  FOLLOWERS_EXPAND_SUCCESS,
+  FOLLOWING_FETCH_SUCCESS,
+  FOLLOWING_EXPAND_SUCCESS,
+  ACCOUNT_TIMELINE_FETCH_SUCCESS,
+  ACCOUNT_TIMELINE_EXPAND_SUCCESS,
+  FOLLOW_REQUESTS_FETCH_SUCCESS,
+  FOLLOW_REQUESTS_EXPAND_SUCCESS,
+  ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_SUCCESS
+} from '../actions/accounts';
+import {
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_EXPAND_SUCCESS
+} from '../actions/blocks';
+import {
+  MUTES_FETCH_SUCCESS,
+  MUTES_EXPAND_SUCCESS
+} from '../actions/mutes';
+import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
+import {
+  REBLOG_SUCCESS,
+  UNREBLOG_SUCCESS,
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS,
+  REBLOGS_FETCH_SUCCESS,
+  FAVOURITES_FETCH_SUCCESS
+} from '../actions/interactions';
+import {
+  TIMELINE_REFRESH_SUCCESS,
+  TIMELINE_UPDATE,
+  TIMELINE_EXPAND_SUCCESS
+} from '../actions/timelines';
+import {
+  STATUS_FETCH_SUCCESS,
+  CONTEXT_FETCH_SUCCESS
+} from '../actions/statuses';
+import { SEARCH_FETCH_SUCCESS } from '../actions/search';
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_REFRESH_SUCCESS,
+  NOTIFICATIONS_EXPAND_SUCCESS
+} from '../actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
+import { STORE_HYDRATE } from '../actions/store';
+import Immutable from 'immutable';
+
+const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS({
+  followers_count: account.followers_count,
+  following_count: account.following_count,
+  statuses_count: account.statuses_count,
+}));
+
+const normalizeAccounts = (state, accounts) => {
+  accounts.forEach(account => {
+    state = normalizeAccount(state, account);
+  });
+
+  return state;
+};
+
+const normalizeAccountFromStatus = (state, status) => {
+  state = normalizeAccount(state, status.account);
+
+  if (status.reblog && status.reblog.account) {
+    state = normalizeAccount(state, status.reblog.account);
+  }
+
+  return state;
+};
+
+const normalizeAccountsFromStatuses = (state, statuses) => {
+  statuses.forEach(status => {
+    state = normalizeAccountFromStatus(state, status);
+  });
+
+  return state;
+};
+
+const initialState = Immutable.Map();
+
+export default function accountsCounters(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('accounts_counters'));
+  case ACCOUNT_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+    return normalizeAccount(state, action.account);
+  case FOLLOWERS_FETCH_SUCCESS:
+  case FOLLOWERS_EXPAND_SUCCESS:
+  case FOLLOWING_FETCH_SUCCESS:
+  case FOLLOWING_EXPAND_SUCCESS:
+  case REBLOGS_FETCH_SUCCESS:
+  case FAVOURITES_FETCH_SUCCESS:
+  case COMPOSE_SUGGESTIONS_READY:
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+  case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+  case BLOCKS_FETCH_SUCCESS:
+  case BLOCKS_EXPAND_SUCCESS:
+  case MUTES_FETCH_SUCCESS:
+  case MUTES_EXPAND_SUCCESS:
+    return normalizeAccounts(state, action.accounts);
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
+    return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
+  case TIMELINE_REFRESH_SUCCESS:
+  case TIMELINE_EXPAND_SUCCESS:
+  case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+  case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return normalizeAccountsFromStatuses(state, action.statuses);
+  case REBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+    return normalizeAccountFromStatus(state, action.response);
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+    return normalizeAccountFromStatus(state, action.status);
+  case ACCOUNT_FOLLOW_SUCCESS:
+    return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
+  case ACCOUNT_UNFOLLOW_SUCCESS:
+    return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/alerts.js b/app/javascript/mastodon/reducers/alerts.js
new file mode 100644
index 000000000..dc0145824
--- /dev/null
+++ b/app/javascript/mastodon/reducers/alerts.js
@@ -0,0 +1,25 @@
+import {
+  ALERT_SHOW,
+  ALERT_DISMISS,
+  ALERT_CLEAR
+} from '../actions/alerts';
+import Immutable from 'immutable';
+
+const initialState = Immutable.List([]);
+
+export default function alerts(state = initialState, action) {
+  switch(action.type) {
+  case ALERT_SHOW:
+    return state.push(Immutable.Map({
+      key: state.size > 0 ? state.last().get('key') + 1 : 0,
+      title: action.title,
+      message: action.message
+    }));
+  case ALERT_DISMISS:
+    return state.filterNot(item => item.get('key') === action.alert.key);
+  case ALERT_CLEAR:
+    return state.clear();
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/cards.js b/app/javascript/mastodon/reducers/cards.js
new file mode 100644
index 000000000..3c9395011
--- /dev/null
+++ b/app/javascript/mastodon/reducers/cards.js
@@ -0,0 +1,14 @@
+import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards';
+
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map();
+
+export default function cards(state = initialState, action) {
+  switch(action.type) {
+  case STATUS_CARD_FETCH_SUCCESS:
+    return state.set(action.id, Immutable.fromJS(action.card));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
new file mode 100644
index 000000000..c87384780
--- /dev/null
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -0,0 +1,232 @@
+import {
+  COMPOSE_MOUNT,
+  COMPOSE_UNMOUNT,
+  COMPOSE_CHANGE,
+  COMPOSE_REPLY,
+  COMPOSE_REPLY_CANCEL,
+  COMPOSE_MENTION,
+  COMPOSE_SUBMIT_REQUEST,
+  COMPOSE_SUBMIT_SUCCESS,
+  COMPOSE_SUBMIT_FAIL,
+  COMPOSE_UPLOAD_REQUEST,
+  COMPOSE_UPLOAD_SUCCESS,
+  COMPOSE_UPLOAD_FAIL,
+  COMPOSE_UPLOAD_UNDO,
+  COMPOSE_UPLOAD_PROGRESS,
+  COMPOSE_SUGGESTIONS_CLEAR,
+  COMPOSE_SUGGESTIONS_READY,
+  COMPOSE_SUGGESTION_SELECT,
+  COMPOSE_SENSITIVITY_CHANGE,
+  COMPOSE_SPOILERNESS_CHANGE,
+  COMPOSE_SPOILER_TEXT_CHANGE,
+  COMPOSE_VISIBILITY_CHANGE,
+  COMPOSE_LISTABILITY_CHANGE,
+  COMPOSE_EMOJI_INSERT
+} from '../actions/compose';
+import { TIMELINE_DELETE } from '../actions/timelines';
+import { STORE_HYDRATE } from '../actions/store';
+import Immutable from 'immutable';
+import uuid from '../uuid';
+
+const initialState = Immutable.Map({
+  mounted: false,
+  sensitive: false,
+  spoiler: false,
+  spoiler_text: '',
+  privacy: null,
+  text: '',
+  focusDate: null,
+  preselectDate: null,
+  in_reply_to: null,
+  is_submitting: false,
+  is_uploading: false,
+  progress: 0,
+  media_attachments: Immutable.List(),
+  suggestion_token: null,
+  suggestions: Immutable.List(),
+  me: null,
+  default_privacy: 'public',
+  resetFileKey: Math.floor((Math.random() * 0x10000)),
+  idempotencyKey: null
+});
+
+function statusToTextMentions(state, status) {
+  let set = Immutable.OrderedSet([]);
+  let me  = state.get('me');
+
+  if (status.getIn(['account', 'id']) !== me) {
+    set = set.add(`@${status.getIn(['account', 'acct'])} `);
+  }
+
+  return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
+};
+
+function clearAll(state) {
+  return state.withMutations(map => {
+    map.set('text', '');
+    map.set('spoiler', false);
+    map.set('spoiler_text', '');
+    map.set('is_submitting', false);
+    map.set('in_reply_to', null);
+    map.set('privacy', state.get('default_privacy'));
+    map.set('sensitive', false);
+    map.update('media_attachments', list => list.clear());
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+function appendMedia(state, media) {
+  return state.withMutations(map => {
+    map.update('media_attachments', list => list.push(media));
+    map.set('is_uploading', false);
+    map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
+    map.update('text', oldText => `${oldText.trim()} ${media.get('text_url')}`);
+    map.set('focusDate', new Date());
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+function removeMedia(state, mediaId) {
+  const media    = state.get('media_attachments').find(item => item.get('id') === mediaId);
+  const prevSize = state.get('media_attachments').size;
+
+  return state.withMutations(map => {
+    map.update('media_attachments', list => list.filterNot(item => item.get('id') === mediaId));
+    map.update('text', text => text.replace(media.get('text_url'), '').trim());
+    map.set('idempotencyKey', uuid());
+
+    if (prevSize === 1) {
+      map.set('sensitive', false);
+    }
+  });
+};
+
+const insertSuggestion = (state, position, token, completion) => {
+  return state.withMutations(map => {
+    map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
+    map.set('suggestion_token', null);
+    map.update('suggestions', Immutable.List(), list => list.clear());
+    map.set('focusDate', new Date());
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+const insertEmoji = (state, position, emojiData) => {
+  const emoji = emojiData.shortname;
+
+  return state.withMutations(map => {
+    map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);
+    map.set('focusDate', new Date());
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+const privacyPreference = (a, b) => {
+  if (a === 'direct' || b === 'direct') {
+    return 'direct';
+  } else if (a === 'private' || b === 'private') {
+    return 'private';
+  } else if (a === 'unlisted' || b === 'unlisted') {
+    return 'unlisted';
+  } else {
+    return 'public';
+  }
+};
+
+export default function compose(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return clearAll(state.merge(action.state.get('compose')));
+  case COMPOSE_MOUNT:
+    return state.set('mounted', true);
+  case COMPOSE_UNMOUNT:
+    return state.set('mounted', false);
+  case COMPOSE_SENSITIVITY_CHANGE:
+    return state
+      .set('sensitive', !state.get('sensitive'))
+      .set('idempotencyKey', uuid());
+  case COMPOSE_SPOILERNESS_CHANGE:
+    return state.withMutations(map => {
+      map.set('spoiler_text', '');
+      map.set('spoiler', !state.get('spoiler'));
+      map.set('idempotencyKey', uuid());
+    });
+  case COMPOSE_SPOILER_TEXT_CHANGE:
+    return state
+      .set('spoiler_text', action.text)
+      .set('idempotencyKey', uuid());
+  case COMPOSE_VISIBILITY_CHANGE:
+    return state
+      .set('privacy', action.value)
+      .set('idempotencyKey', uuid());
+  case COMPOSE_CHANGE:
+    return state
+      .set('text', action.text)
+      .set('idempotencyKey', uuid());
+  case COMPOSE_REPLY:
+    return state.withMutations(map => {
+      map.set('in_reply_to', action.status.get('id'));
+      map.set('text', statusToTextMentions(state, action.status));
+      map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
+      map.set('focusDate', new Date());
+      map.set('preselectDate', new Date());
+      map.set('idempotencyKey', uuid());
+
+      if (action.status.get('spoiler_text').length > 0) {
+        map.set('spoiler', true);
+        map.set('spoiler_text', action.status.get('spoiler_text'));
+      } else {
+        map.set('spoiler', false);
+        map.set('spoiler_text', '');
+      }
+    });
+  case COMPOSE_REPLY_CANCEL:
+    return state.withMutations(map => {
+      map.set('in_reply_to', null);
+      map.set('text', '');
+      map.set('spoiler', false);
+      map.set('spoiler_text', '');
+      map.set('privacy', state.get('default_privacy'));
+      map.set('idempotencyKey', uuid());
+    });
+  case COMPOSE_SUBMIT_REQUEST:
+    return state.set('is_submitting', true);
+  case COMPOSE_SUBMIT_SUCCESS:
+    return clearAll(state);
+  case COMPOSE_SUBMIT_FAIL:
+    return state.set('is_submitting', false);
+  case COMPOSE_UPLOAD_REQUEST:
+    return state.withMutations(map => {
+      map.set('is_uploading', true);
+    });
+  case COMPOSE_UPLOAD_SUCCESS:
+    return appendMedia(state, Immutable.fromJS(action.media));
+  case COMPOSE_UPLOAD_FAIL:
+    return state.set('is_uploading', false);
+  case COMPOSE_UPLOAD_UNDO:
+    return removeMedia(state, action.media_id);
+  case COMPOSE_UPLOAD_PROGRESS:
+    return state.set('progress', Math.round((action.loaded / action.total) * 100));
+  case COMPOSE_MENTION:
+    return state
+      .update('text', text => `${text}@${action.account.get('acct')} `)
+      .set('focusDate', new Date())
+      .set('idempotencyKey', uuid());
+  case COMPOSE_SUGGESTIONS_CLEAR:
+    return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
+  case COMPOSE_SUGGESTIONS_READY:
+    return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
+  case COMPOSE_SUGGESTION_SELECT:
+    return insertSuggestion(state, action.position, action.token, action.completion);
+  case TIMELINE_DELETE:
+    if (action.id === state.get('in_reply_to')) {
+      return state.set('in_reply_to', null);
+    } else {
+      return state;
+    }
+  case COMPOSE_EMOJI_INSERT:
+    return insertEmoji(state, action.position, action.emoji);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
new file mode 100644
index 000000000..f05067c47
--- /dev/null
+++ b/app/javascript/mastodon/reducers/index.js
@@ -0,0 +1,38 @@
+import { combineReducers } from 'redux-immutable';
+import timelines from './timelines';
+import meta from './meta';
+import compose from './compose';
+import alerts from './alerts';
+import { loadingBarReducer } from 'react-redux-loading-bar';
+import modal from './modal';
+import user_lists from './user_lists';
+import accounts from './accounts';
+import accounts_counters from './accounts_counters';
+import statuses from './statuses';
+import relationships from './relationships';
+import search from './search';
+import notifications from './notifications';
+import settings from './settings';
+import status_lists from './status_lists';
+import cards from './cards';
+import reports from './reports';
+
+export default combineReducers({
+  timelines,
+  meta,
+  compose,
+  alerts,
+  loadingBar: loadingBarReducer,
+  modal,
+  user_lists,
+  status_lists,
+  accounts,
+  accounts_counters,
+  statuses,
+  relationships,
+  search,
+  notifications,
+  settings,
+  cards,
+  reports
+});
diff --git a/app/javascript/mastodon/reducers/meta.js b/app/javascript/mastodon/reducers/meta.js
new file mode 100644
index 000000000..acf6d4be1
--- /dev/null
+++ b/app/javascript/mastodon/reducers/meta.js
@@ -0,0 +1,17 @@
+import { STORE_HYDRATE } from '../actions/store';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  streaming_api_base_url: null,
+  access_token: null,
+  me: null
+});
+
+export default function meta(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('meta'));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/modal.js b/app/javascript/mastodon/reducers/modal.js
new file mode 100644
index 000000000..3566820ef
--- /dev/null
+++ b/app/javascript/mastodon/reducers/modal.js
@@ -0,0 +1,18 @@
+import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal';
+import Immutable from 'immutable';
+
+const initialState = {
+  modalType: null,
+  modalProps: {}
+};
+
+export default function modal(state = initialState, action) {
+  switch(action.type) {
+  case MODAL_OPEN:
+    return { modalType: action.modalType, modalProps: action.modalProps };
+  case MODAL_CLOSE:
+    return initialState;
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
new file mode 100644
index 000000000..c567a3a59
--- /dev/null
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -0,0 +1,104 @@
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_REFRESH_SUCCESS,
+  NOTIFICATIONS_EXPAND_SUCCESS,
+  NOTIFICATIONS_REFRESH_REQUEST,
+  NOTIFICATIONS_EXPAND_REQUEST,
+  NOTIFICATIONS_REFRESH_FAIL,
+  NOTIFICATIONS_EXPAND_FAIL,
+  NOTIFICATIONS_CLEAR,
+  NOTIFICATIONS_SCROLL_TOP
+} from '../actions/notifications';
+import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  items: Immutable.List(),
+  next: null,
+  top: true,
+  unread: 0,
+  loaded: false,
+  isLoading: true
+});
+
+const notificationToMap = notification => Immutable.Map({
+  id: notification.id,
+  type: notification.type,
+  account: notification.account.id,
+  status: notification.status ? notification.status.id : null
+});
+
+const normalizeNotification = (state, notification) => {
+  if (!state.get('top')) {
+    state = state.update('unread', unread => unread + 1);
+  }
+
+  return state.update('items', list => list.unshift(notificationToMap(notification)));
+};
+
+const normalizeNotifications = (state, notifications, next) => {
+  let items    = Immutable.List();
+  const loaded = state.get('loaded');
+
+  notifications.forEach((n, i) => {
+    items = items.set(i, notificationToMap(n));
+  });
+
+  if (state.get('next') === null) {
+    state = state.set('next', next);
+  }
+
+  return state
+    .update('items', list => loaded ? items.concat(list) : list.concat(items))
+    .set('loaded', true)
+    .set('isLoading', false);
+};
+
+const appendNormalizedNotifications = (state, notifications, next) => {
+  let items = Immutable.List();
+
+  notifications.forEach((n, i) => {
+    items = items.set(i, notificationToMap(n));
+  });
+
+  return state
+    .update('items', list => list.concat(items))
+    .set('next', next)
+    .set('isLoading', false);
+};
+
+const filterNotifications = (state, relationship) => {
+  return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id));
+};
+
+const updateTop = (state, top) => {
+  if (top) {
+    state = state.set('unread', 0);
+  }
+
+  return state.set('top', top);
+};
+
+export default function notifications(state = initialState, action) {
+  switch(action.type) {
+  case NOTIFICATIONS_REFRESH_REQUEST:
+  case NOTIFICATIONS_EXPAND_REQUEST:
+  case NOTIFICATIONS_REFRESH_FAIL:
+  case NOTIFICATIONS_EXPAND_FAIL:
+    return state.set('isLoading', true);
+  case NOTIFICATIONS_SCROLL_TOP:
+    return updateTop(state, action.top);
+  case NOTIFICATIONS_UPDATE:
+    return normalizeNotification(state, action.notification);
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+    return normalizeNotifications(state, action.notifications, action.next);
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+    return appendNormalizedNotifications(state, action.notifications, action.next);
+  case ACCOUNT_BLOCK_SUCCESS:
+    return filterNotifications(state, action.relationship);
+  case NOTIFICATIONS_CLEAR:
+    return state.set('items', Immutable.List()).set('next', null);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
new file mode 100644
index 000000000..c65c48b43
--- /dev/null
+++ b/app/javascript/mastodon/reducers/relationships.js
@@ -0,0 +1,38 @@
+import {
+  ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_SUCCESS,
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_UNBLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+  ACCOUNT_UNMUTE_SUCCESS,
+  RELATIONSHIPS_FETCH_SUCCESS
+} from '../actions/accounts';
+import Immutable from 'immutable';
+
+const normalizeRelationship = (state, relationship) => state.set(relationship.id, Immutable.fromJS(relationship));
+
+const normalizeRelationships = (state, relationships) => {
+  relationships.forEach(relationship => {
+    state = normalizeRelationship(state, relationship);
+  });
+
+  return state;
+};
+
+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;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/reports.js b/app/javascript/mastodon/reducers/reports.js
new file mode 100644
index 000000000..eab004377
--- /dev/null
+++ b/app/javascript/mastodon/reducers/reports.js
@@ -0,0 +1,60 @@
+import {
+  REPORT_INIT,
+  REPORT_SUBMIT_REQUEST,
+  REPORT_SUBMIT_SUCCESS,
+  REPORT_SUBMIT_FAIL,
+  REPORT_CANCEL,
+  REPORT_STATUS_TOGGLE,
+  REPORT_COMMENT_CHANGE
+} from '../actions/reports';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  new: Immutable.Map({
+    isSubmitting: false,
+    account_id: null,
+    status_ids: Immutable.Set(),
+    comment: ''
+  })
+});
+
+export default function reports(state = initialState, action) {
+  switch(action.type) {
+  case REPORT_INIT:
+    return state.withMutations(map => {
+      map.setIn(['new', 'isSubmitting'], false);
+      map.setIn(['new', 'account_id'], action.account.get('id'));
+
+      if (state.getIn(['new', 'account_id']) !== action.account.get('id')) {
+        map.setIn(['new', 'status_ids'], action.status ? Immutable.Set([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : Immutable.Set());
+        map.setIn(['new', 'comment'], '');
+      } else {
+        map.updateIn(['new', 'status_ids'], Immutable.Set(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id'))));
+      }
+    });
+  case REPORT_STATUS_TOGGLE:
+    return state.updateIn(['new', 'status_ids'], Immutable.Set(), set => {
+      if (action.checked) {
+        return set.add(action.statusId);
+      }
+
+      return set.remove(action.statusId);
+    });
+  case REPORT_COMMENT_CHANGE:
+    return state.setIn(['new', 'comment'], action.comment);
+  case REPORT_SUBMIT_REQUEST:
+    return state.setIn(['new', 'isSubmitting'], true);
+  case REPORT_SUBMIT_FAIL:
+    return state.setIn(['new', 'isSubmitting'], false);
+  case REPORT_CANCEL:
+  case REPORT_SUBMIT_SUCCESS:
+    return state.withMutations(map => {
+      map.setIn(['new', 'account_id'], null);
+      map.setIn(['new', 'status_ids'], Immutable.Set());
+      map.setIn(['new', 'comment'], '');
+      map.setIn(['new', 'isSubmitting'], false);
+    });
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
new file mode 100644
index 000000000..b3fe6c7be
--- /dev/null
+++ b/app/javascript/mastodon/reducers/search.js
@@ -0,0 +1,96 @@
+import {
+  SEARCH_CHANGE,
+  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: '',
+  submitted: false,
+  hidden: false,
+  results: Immutable.Map()
+});
+
+const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => {
+  let newSuggestions = [];
+
+  if (accounts.length > 0) {
+    newSuggestions.push({
+      title: 'account',
+      items: accounts.map(item => ({
+        type: 'account',
+        id: item.id,
+        value: item.acct
+      }))
+    });
+  }
+
+  if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 || hashtags.length > 0) {
+    let hashtagItems = hashtags.map(item => ({
+      type: 'hashtag',
+      id: item,
+      value: `#${item}`
+    }));
+
+    if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 && !value.startsWith('http://') && !value.startsWith('https://') && hashtags.indexOf(value) === -1) {
+      hashtagItems.unshift({
+        type: 'hashtag',
+        id: value,
+        value: `#${value}`
+      });
+    }
+
+    if (hashtagItems.length > 0) {
+      newSuggestions.push({
+        title: 'hashtag',
+        items: hashtagItems
+      });
+    }
+  }
+
+  if (statuses.length > 0) {
+    newSuggestions.push({
+      title: 'status',
+      items: statuses.map(item => ({
+        type: 'status',
+        id: item.id,
+        value: item.id
+      }))
+    });
+  }
+
+  return state.withMutations(map => {
+    map.set('suggestions', newSuggestions);
+    map.set('loaded_value', value);
+  });
+};
+
+export default function search(state = initialState, action) {
+  switch(action.type) {
+  case SEARCH_CHANGE:
+    return state.set('value', action.value);
+  case SEARCH_CLEAR:
+    return state.withMutations(map => {
+      map.set('value', '');
+      map.set('results', Immutable.Map());
+      map.set('submitted', false);
+      map.set('hidden', false);
+    });
+  case SEARCH_SHOW:
+    return state.set('hidden', false);
+  case COMPOSE_REPLY:
+  case COMPOSE_MENTION:
+    return state.set('hidden', true);
+  case SEARCH_FETCH_SUCCESS:
+    return state.set('results', Immutable.Map({
+      accounts: Immutable.List(action.results.accounts.map(item => item.id)),
+      statuses: Immutable.List(action.results.statuses.map(item => item.id)),
+      hashtags: Immutable.List(action.results.hashtags)
+    })).set('submitted', true);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
new file mode 100644
index 000000000..b255aabc4
--- /dev/null
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -0,0 +1,52 @@
+import { SETTING_CHANGE } from '../actions/settings';
+import { STORE_HYDRATE } from '../actions/store';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  onboarded: false,
+
+  home: Immutable.Map({
+    shows: Immutable.Map({
+      reblog: true,
+      reply: true
+    }),
+
+    regex: Immutable.Map({
+      body: ''
+    })
+  }),
+
+  notifications: Immutable.Map({
+    alerts: Immutable.Map({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true
+    }),
+
+    shows: Immutable.Map({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true
+    }),
+
+    sounds: Immutable.Map({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true
+    })
+  })
+});
+
+export default function settings(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.mergeDeep(action.state.get('settings'));
+  case SETTING_CHANGE:
+    return state.setIn(action.key, action.value);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js
new file mode 100644
index 000000000..fd463cd63
--- /dev/null
+++ b/app/javascript/mastodon/reducers/status_lists.js
@@ -0,0 +1,39 @@
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  favourites: Immutable.Map({
+    next: null,
+    loaded: false,
+    items: Immutable.List()
+  })
+});
+
+const normalizeList = (state, listType, statuses, next) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('next', next);
+    map.set('loaded', true);
+    map.set('items', Immutable.List(statuses.map(item => item.id)));
+  }));
+};
+
+const appendToList = (state, listType, statuses, next) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('next', next);
+    map.set('items', map.get('items').concat(statuses.map(item => item.id)));
+  }));
+};
+
+export default function statusLists(state = initialState, action) {
+  switch(action.type) {
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+    return normalizeList(state, 'favourites', action.statuses, action.next);
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return appendToList(state, 'favourites', action.statuses, action.next);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
new file mode 100644
index 000000000..2002d2223
--- /dev/null
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -0,0 +1,124 @@
+import {
+  REBLOG_REQUEST,
+  REBLOG_SUCCESS,
+  REBLOG_FAIL,
+  UNREBLOG_SUCCESS,
+  FAVOURITE_REQUEST,
+  FAVOURITE_SUCCESS,
+  FAVOURITE_FAIL,
+  UNFAVOURITE_SUCCESS
+} from '../actions/interactions';
+import {
+  STATUS_FETCH_SUCCESS,
+  CONTEXT_FETCH_SUCCESS
+} from '../actions/statuses';
+import {
+  TIMELINE_REFRESH_SUCCESS,
+  TIMELINE_UPDATE,
+  TIMELINE_DELETE,
+  TIMELINE_EXPAND_SUCCESS
+} from '../actions/timelines';
+import {
+  ACCOUNT_TIMELINE_FETCH_SUCCESS,
+  ACCOUNT_TIMELINE_EXPAND_SUCCESS,
+  ACCOUNT_BLOCK_SUCCESS
+} from '../actions/accounts';
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_REFRESH_SUCCESS,
+  NOTIFICATIONS_EXPAND_SUCCESS
+} from '../actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
+import { SEARCH_FETCH_SUCCESS } from '../actions/search';
+import Immutable from 'immutable';
+
+const normalizeStatus = (state, status) => {
+  if (!status) {
+    return state;
+  }
+
+  const normalStatus   = { ...status };
+  normalStatus.account = status.account.id;
+
+  if (status.reblog && status.reblog.id) {
+    state               = normalizeStatus(state, status.reblog);
+    normalStatus.reblog = status.reblog.id;
+  }
+
+  const linebreakComplemented = status.content.replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+  normalStatus.unescaped_content = new DOMParser().parseFromString(linebreakComplemented, 'text/html').documentElement.textContent;
+
+  return state.update(status.id, Immutable.Map(), map => map.mergeDeep(Immutable.fromJS(normalStatus)));
+};
+
+const normalizeStatuses = (state, statuses) => {
+  statuses.forEach(status => {
+    state = normalizeStatus(state, status);
+  });
+
+  return state;
+};
+
+const deleteStatus = (state, id, references) => {
+  references.forEach(ref => {
+    state = deleteStatus(state, ref[0], []);
+  });
+
+  return state.delete(id);
+};
+
+const filterStatuses = (state, relationship) => {
+  state.forEach(status => {
+    if (status.get('account') !== relationship.id) {
+      return;
+    }
+
+    state = deleteStatus(state, status.get('id'), state.filter(item => item.get('reblog') === status.get('id')));
+  });
+
+  return state;
+};
+
+const initialState = Immutable.Map();
+
+export default function statuses(state = initialState, action) {
+  switch(action.type) {
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+    return normalizeStatus(state, action.status);
+  case REBLOG_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+    return normalizeStatus(state, action.response);
+  case FAVOURITE_REQUEST:
+    return state.setIn([action.status.get('id'), 'favourited'], true);
+  case FAVOURITE_FAIL:
+    return state.setIn([action.status.get('id'), 'favourited'], false);
+  case REBLOG_REQUEST:
+    return state.setIn([action.status.get('id'), 'reblogged'], true);
+  case REBLOG_FAIL:
+    return state.setIn([action.status.get('id'), 'reblogged'], false);
+  case TIMELINE_REFRESH_SUCCESS:
+  case TIMELINE_EXPAND_SUCCESS:
+  case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+  case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
+    return normalizeStatuses(state, action.statuses);
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.references);
+  case ACCOUNT_BLOCK_SUCCESS:
+    return filterStatuses(state, action.relationship);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
new file mode 100644
index 000000000..31e79f9f6
--- /dev/null
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -0,0 +1,317 @@
+import {
+  TIMELINE_REFRESH_REQUEST,
+  TIMELINE_REFRESH_SUCCESS,
+  TIMELINE_REFRESH_FAIL,
+  TIMELINE_UPDATE,
+  TIMELINE_DELETE,
+  TIMELINE_EXPAND_SUCCESS,
+  TIMELINE_EXPAND_REQUEST,
+  TIMELINE_EXPAND_FAIL,
+  TIMELINE_SCROLL_TOP,
+  TIMELINE_CONNECT,
+  TIMELINE_DISCONNECT
+} from '../actions/timelines';
+import {
+  REBLOG_SUCCESS,
+  UNREBLOG_SUCCESS,
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS
+} from '../actions/interactions';
+import {
+  ACCOUNT_TIMELINE_FETCH_REQUEST,
+  ACCOUNT_TIMELINE_FETCH_SUCCESS,
+  ACCOUNT_TIMELINE_FETCH_FAIL,
+  ACCOUNT_TIMELINE_EXPAND_REQUEST,
+  ACCOUNT_TIMELINE_EXPAND_SUCCESS,
+  ACCOUNT_TIMELINE_EXPAND_FAIL,
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS
+} from '../actions/accounts';
+import {
+  CONTEXT_FETCH_SUCCESS
+} from '../actions/statuses';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  home: Immutable.Map({
+    path: () => '/api/v1/timelines/home',
+    next: null,
+    isLoading: false,
+    online: false,
+    loaded: false,
+    top: true,
+    unread: 0,
+    items: Immutable.List()
+  }),
+
+  public: Immutable.Map({
+    path: () => '/api/v1/timelines/public',
+    next: null,
+    isLoading: false,
+    online: false,
+    loaded: false,
+    top: true,
+    unread: 0,
+    items: Immutable.List()
+  }),
+
+  community: Immutable.Map({
+    path: () => '/api/v1/timelines/public',
+    next: null,
+    params: { local: true },
+    isLoading: false,
+    online: false,
+    loaded: false,
+    top: true,
+    unread: 0,
+    items: Immutable.List()
+  }),
+
+  tag: Immutable.Map({
+    path: (id) => `/api/v1/timelines/tag/${id}`,
+    next: null,
+    isLoading: false,
+    id: null,
+    loaded: false,
+    top: true,
+    unread: 0,
+    items: Immutable.List()
+  }),
+
+  accounts_timelines: Immutable.Map(),
+  ancestors: Immutable.Map(),
+  descendants: Immutable.Map()
+});
+
+const normalizeStatus = (state, status) => {
+  const replyToId = status.get('in_reply_to_id');
+  const id        = status.get('id');
+
+  if (replyToId) {
+    if (!state.getIn(['descendants', replyToId], Immutable.List()).includes(id)) {
+      state = state.updateIn(['descendants', replyToId], Immutable.List(), set => set.push(id));
+    }
+
+    if (!state.getIn(['ancestors', id], Immutable.List()).includes(replyToId)) {
+      state = state.updateIn(['ancestors', id], Immutable.List(), set => set.push(replyToId));
+    }
+  }
+
+  return state;
+};
+
+const normalizeTimeline = (state, timeline, statuses, next) => {
+  let ids      = Immutable.List();
+  const loaded = state.getIn([timeline, 'loaded']);
+
+  statuses.forEach((status, i) => {
+    state = normalizeStatus(state, status);
+    ids   = ids.set(i, status.get('id'));
+  });
+
+  state = state.setIn([timeline, 'loaded'], true);
+  state = state.setIn([timeline, 'isLoading'], false);
+
+  if (state.getIn([timeline, 'next']) === null) {
+    state = state.setIn([timeline, 'next'], next);
+  }
+
+  return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? ids.concat(list) : ids));
+};
+
+const appendNormalizedTimeline = (state, timeline, statuses, next) => {
+  let moreIds = Immutable.List();
+
+  statuses.forEach((status, i) => {
+    state   = normalizeStatus(state, status);
+    moreIds = moreIds.set(i, status.get('id'));
+  });
+
+  state = state.setIn([timeline, 'isLoading'], false);
+  state = state.setIn([timeline, 'next'], next);
+
+  return state.updateIn([timeline, 'items'], Immutable.List(), list => list.concat(moreIds));
+};
+
+const normalizeAccountTimeline = (state, accountId, statuses, replace = false) => {
+  let ids = Immutable.List();
+
+  statuses.forEach((status, i) => {
+    state = normalizeStatus(state, status);
+    ids   = ids.set(i, status.get('id'));
+  });
+
+  return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map
+    .set('isLoading', false)
+    .set('loaded', true)
+    .set('next', true)
+    .update('items', Immutable.List(), list => (replace ? ids : ids.concat(list))));
+};
+
+const appendNormalizedAccountTimeline = (state, accountId, statuses, next) => {
+  let moreIds = Immutable.List([]);
+
+  statuses.forEach((status, i) => {
+    state   = normalizeStatus(state, status);
+    moreIds = moreIds.set(i, status.get('id'));
+  });
+
+  return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map
+    .set('isLoading', false)
+    .set('next', next)
+    .update('items', list => list.concat(moreIds)));
+};
+
+const updateTimeline = (state, timeline, status, references) => {
+  const top = state.getIn([timeline, 'top']);
+
+  state = normalizeStatus(state, status);
+
+  if (!top) {
+    state = state.updateIn([timeline, 'unread'], unread => unread + 1);
+  }
+
+  state = state.updateIn([timeline, 'items'], Immutable.List(), list => {
+    if (top && list.size > 40) {
+      list = list.take(20);
+    }
+
+    if (list.includes(status.get('id'))) {
+      return list;
+    }
+
+    const reblogOfId = status.getIn(['reblog', 'id'], null);
+
+    if (reblogOfId !== null) {
+      list = list.filterNot(itemId => references.includes(itemId));
+    }
+
+    return list.unshift(status.get('id'));
+  });
+
+  return state;
+};
+
+const deleteStatus = (state, id, accountId, references, reblogOf) => {
+  if (reblogOf) {
+    // If we are deleting a reblog, just replace reblog with its original
+    return state.updateIn(['home', 'items'], list => list.map(item => item === id ? reblogOf : item));
+  }
+
+  // Remove references from timelines
+  ['home', 'public', 'community', 'tag'].forEach(function (timeline) {
+    state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
+  });
+
+  // Remove references from account timelines
+  state = state.updateIn(['accounts_timelines', accountId, 'items'], Immutable.List([]), list => list.filterNot(item => item === id));
+
+  // Remove references from context
+  state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => {
+    state = state.updateIn(['ancestors', descendantId], Immutable.List(), list => list.filterNot(itemId => itemId === id));
+  });
+
+  state.getIn(['ancestors', id], Immutable.List()).forEach(ancestorId => {
+    state = state.updateIn(['descendants', ancestorId], Immutable.List(), list => list.filterNot(itemId => itemId === id));
+  });
+
+  state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', id]);
+
+  // Remove reblogs of deleted status
+  references.forEach(ref => {
+    state = deleteStatus(state, ref[0], ref[1], []);
+  });
+
+  return state;
+};
+
+const filterTimelines = (state, relationship, statuses) => {
+  let references;
+
+  statuses.forEach(status => {
+    if (status.get('account') !== relationship.id) {
+      return;
+    }
+
+    references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => [item.get('id'), item.get('account')]);
+    state = deleteStatus(state, status.get('id'), status.get('account'), references);
+  });
+
+  return state;
+};
+
+const normalizeContext = (state, id, ancestors, descendants) => {
+  const ancestorsIds   = ancestors.map(ancestor => ancestor.get('id'));
+  const descendantsIds = descendants.map(descendant => descendant.get('id'));
+
+  return state.withMutations(map => {
+    map.setIn(['ancestors', id], ancestorsIds);
+    map.setIn(['descendants', id], descendantsIds);
+  });
+};
+
+const resetTimeline = (state, timeline, id) => {
+  if (timeline === 'tag' && typeof id !== 'undefined' && state.getIn([timeline, 'id']) !== id) {
+    state = state.update(timeline, map => map
+        .set('id', id)
+        .set('isLoading', true)
+        .set('loaded', false)
+        .set('next', null)
+        .set('top', true)
+        .update('items', list => list.clear()));
+  } else {
+    state = state.setIn([timeline, 'isLoading'], true);
+  }
+
+  return state;
+};
+
+const updateTop = (state, timeline, top) => {
+  if (top) {
+    state = state.setIn([timeline, 'unread'], 0);
+  }
+
+  return state.setIn([timeline, 'top'], top);
+};
+
+export default function timelines(state = initialState, action) {
+  switch(action.type) {
+  case TIMELINE_REFRESH_REQUEST:
+  case TIMELINE_EXPAND_REQUEST:
+    return resetTimeline(state, action.timeline, action.id);
+  case TIMELINE_REFRESH_FAIL:
+  case TIMELINE_EXPAND_FAIL:
+    return state.setIn([action.timeline, 'isLoading'], false);
+  case TIMELINE_REFRESH_SUCCESS:
+    return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next);
+  case TIMELINE_EXPAND_SUCCESS:
+    return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next);
+  case TIMELINE_UPDATE:
+    return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
+  case CONTEXT_FETCH_SUCCESS:
+    return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
+  case ACCOUNT_TIMELINE_FETCH_REQUEST:
+  case ACCOUNT_TIMELINE_EXPAND_REQUEST:
+    return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', true));
+  case ACCOUNT_TIMELINE_FETCH_FAIL:
+  case ACCOUNT_TIMELINE_EXPAND_FAIL:
+    return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', false));
+  case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+    return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
+  case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+    return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.next);
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    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/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js
new file mode 100644
index 000000000..af9492119
--- /dev/null
+++ b/app/javascript/mastodon/reducers/user_lists.js
@@ -0,0 +1,80 @@
+import {
+  FOLLOWERS_FETCH_SUCCESS,
+  FOLLOWERS_EXPAND_SUCCESS,
+  FOLLOWING_FETCH_SUCCESS,
+  FOLLOWING_EXPAND_SUCCESS,
+  FOLLOW_REQUESTS_FETCH_SUCCESS,
+  FOLLOW_REQUESTS_EXPAND_SUCCESS,
+  FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+  FOLLOW_REQUEST_REJECT_SUCCESS
+} from '../actions/accounts';
+import {
+  REBLOGS_FETCH_SUCCESS,
+  FAVOURITES_FETCH_SUCCESS
+} from '../actions/interactions';
+import {
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_EXPAND_SUCCESS
+} from '../actions/blocks';
+import {
+  MUTES_FETCH_SUCCESS,
+  MUTES_EXPAND_SUCCESS
+} from '../actions/mutes';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  followers: Immutable.Map(),
+  following: Immutable.Map(),
+  reblogged_by: Immutable.Map(),
+  favourited_by: Immutable.Map(),
+  follow_requests: Immutable.Map(),
+  blocks: Immutable.Map(),
+  mutes: Immutable.Map()
+});
+
+const normalizeList = (state, type, id, accounts, next) => {
+  return state.setIn([type, id], Immutable.Map({
+    next,
+    items: Immutable.List(accounts.map(item => item.id))
+  }));
+};
+
+const appendToList = (state, type, id, accounts, next) => {
+  return state.updateIn([type, id], map => {
+    return map.set('next', next).update('items', list => list.concat(accounts.map(item => item.id)));
+  });
+};
+
+export default function userLists(state = initialState, action) {
+  switch(action.type) {
+  case FOLLOWERS_FETCH_SUCCESS:
+    return normalizeList(state, 'followers', action.id, action.accounts, action.next);
+  case FOLLOWERS_EXPAND_SUCCESS:
+    return appendToList(state, 'followers', action.id, action.accounts, action.next);
+  case FOLLOWING_FETCH_SUCCESS:
+    return normalizeList(state, 'following', action.id, action.accounts, action.next);
+  case FOLLOWING_EXPAND_SUCCESS:
+    return appendToList(state, 'following', action.id, action.accounts, action.next);
+  case REBLOGS_FETCH_SUCCESS:
+    return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
+  case FAVOURITES_FETCH_SUCCESS:
+    return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+    return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+  case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+    return state.updateIn(['follow_requests', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+  case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
+  case FOLLOW_REQUEST_REJECT_SUCCESS:
+    return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
+  case BLOCKS_FETCH_SUCCESS:
+    return state.setIn(['blocks', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
+  case BLOCKS_EXPAND_SUCCESS:
+    return state.updateIn(['blocks', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
+  case MUTES_FETCH_SUCCESS:
+    return state.setIn(['mutes', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
+  case MUTES_EXPAND_SUCCESS:
+    return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/rtl.js b/app/javascript/mastodon/rtl.js
new file mode 100644
index 000000000..8f14bb338
--- /dev/null
+++ b/app/javascript/mastodon/rtl.js
@@ -0,0 +1,27 @@
+// U+0590  to U+05FF  - Hebrew
+// U+0600  to U+06FF  - Arabic
+// U+0700  to U+074F  - Syriac
+// U+0750  to U+077F  - Arabic Supplement
+// U+0780  to U+07BF  - Thaana
+// U+07C0  to U+07FF  - N'Ko
+// U+0800  to U+083F  - Samaritan
+// U+08A0  to U+08FF  - Arabic Extended-A
+// U+FB1D  to U+FB4F  - Hebrew presentation forms
+// U+FB50  to U+FDFF  - Arabic presentation forms A
+// U+FE70  to U+FEFF  - Arabic presentation forms B
+
+const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
+
+export function isRtl(text) {
+  if (text.length === 0) {
+    return false;
+  }
+
+  const matches = text.match(rtlChars);
+
+  if (!matches) {
+    return false;
+  }
+
+  return matches.length / text.trim().length > 0.3;
+};
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
new file mode 100644
index 000000000..7a75e2660
--- /dev/null
+++ b/app/javascript/mastodon/selectors/index.js
@@ -0,0 +1,73 @@
+import { createSelector } from 'reselect';
+import Immutable from 'immutable';
+
+const getStatuses = state => state.get('statuses');
+const getAccounts = state => state.get('accounts');
+
+const getAccountBase         = (state, id) => state.getIn(['accounts', id], null);
+const getAccountCounters     = (state, id) => state.getIn(['accounts_counters', id], null);
+const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null);
+
+export const makeGetAccount = () => {
+  return createSelector([getAccountBase, getAccountCounters, getAccountRelationship], (base, counters, relationship) => {
+    if (base === null) {
+      return null;
+    }
+
+    return base.merge(counters).set('relationship', relationship);
+  });
+};
+
+export const makeGetStatus = () => {
+  return createSelector(
+    [
+      (state, id) => state.getIn(['statuses', id]),
+      (state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
+      (state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
+      (state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
+    ],
+
+    (statusBase, statusReblog, accountBase, accountReblog) => {
+      if (!statusBase) {
+        return null;
+      }
+
+      if (statusReblog) {
+        statusReblog = statusReblog.set('account', accountReblog);
+      } else {
+        statusReblog = null;
+      }
+
+      return statusBase.withMutations(map => {
+        map.set('reblog', statusReblog);
+        map.set('account', accountBase);
+      });
+    }
+  );
+};
+
+const getAlertsBase = state => state.get('alerts');
+
+export const getAlerts = createSelector([getAlertsBase], (base) => {
+  let arr = [];
+
+  base.forEach(item => {
+    arr.push({
+      message: item.get('message'),
+      title: item.get('title'),
+      key: item.get('key'),
+      dismissAfter: 5000
+    });
+  });
+
+  return arr;
+});
+
+export const makeGetNotification = () => {
+  return createSelector([
+    (_, base)             => base,
+    (state, _, accountId) => state.getIn(['accounts', accountId])
+  ], (base, account) => {
+    return base.set('account', account);
+  });
+};
diff --git a/app/javascript/mastodon/store/configureStore.js b/app/javascript/mastodon/store/configureStore.js
new file mode 100644
index 000000000..a92d756f5
--- /dev/null
+++ b/app/javascript/mastodon/store/configureStore.js
@@ -0,0 +1,16 @@
+import { createStore, applyMiddleware, compose } from 'redux';
+import thunk from 'redux-thunk';
+import appReducer from '../reducers';
+import loadingBarMiddleware from '../middleware/loading_bar';
+import errorsMiddleware from '../middleware/errors';
+import soundsMiddleware from '../middleware/sounds';
+import Immutable from 'immutable';
+
+export default function configureStore() {
+  return createStore(appReducer, compose(applyMiddleware(
+    thunk,
+    loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
+    errorsMiddleware(),
+    soundsMiddleware()
+  ), window.devToolsExtension ? window.devToolsExtension() : f => f));
+};
diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js
new file mode 100644
index 000000000..08da71607
--- /dev/null
+++ b/app/javascript/mastodon/stream.js
@@ -0,0 +1,22 @@
+import WebSocketClient from 'websocket.js';
+
+const createWebSocketURL = (url) => {
+  const a = document.createElement('a');
+
+  a.href     = url;
+  a.href     = a.href;
+  a.protocol = a.protocol.replace('http', 'ws');
+
+  return a.href;
+};
+
+export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
+  const ws = new WebSocketClient(`${createWebSocketURL(streamingAPIBaseURL)}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`);
+
+  ws.onopen      = connected;
+  ws.onmessage   = e => received(JSON.parse(e.data));
+  ws.onclose     = disconnected;
+  ws.onreconnect = reconnected;
+
+  return ws;
+};
diff --git a/app/javascript/mastodon/uuid.js b/app/javascript/mastodon/uuid.js
new file mode 100644
index 000000000..be1899305
--- /dev/null
+++ b/app/javascript/mastodon/uuid.js
@@ -0,0 +1,3 @@
+export default function uuid(a) {
+  return a ? (a^Math.random() * 16 >> a / 4).toString(16) : ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, uuid);
+};