about summary refs log tree commit diff
path: root/app/javascript/flavours
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2022-01-31 12:50:14 -0600
committerStarfall <us@starfall.systems>2022-01-31 12:50:14 -0600
commit17265f47f8f931e70699088dd8bd2a1c7b78112b (patch)
treea1dde2630cd8e481cc4c5d047c4af241a251def0 /app/javascript/flavours
parent129962006c2ebcd195561ac556887dc87d32081c (diff)
parentd6f3261c6cb810ea4eb6f74b9ee62af0d94cbd52 (diff)
Merge branch 'glitchsoc'
Diffstat (limited to 'app/javascript/flavours')
-rw-r--r--app/javascript/flavours/glitch/actions/accounts.js32
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js69
-rw-r--r--app/javascript/flavours/glitch/actions/importer/normalizer.js9
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js6
-rw-r--r--app/javascript/flavours/glitch/actions/statuses.js3
-rw-r--r--app/javascript/flavours/glitch/actions/streaming.js4
-rw-r--r--app/javascript/flavours/glitch/components/account.js4
-rw-r--r--app/javascript/flavours/glitch/components/admin/Counter.js116
-rw-r--r--app/javascript/flavours/glitch/components/admin/Dimension.js93
-rw-r--r--app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js159
-rw-r--r--app/javascript/flavours/glitch/components/admin/Retention.js151
-rw-r--r--app/javascript/flavours/glitch/components/admin/Trends.js73
-rw-r--r--app/javascript/flavours/glitch/components/attachment_list.js36
-rw-r--r--app/javascript/flavours/glitch/components/avatar_composite.js2
-rw-r--r--app/javascript/flavours/glitch/components/column_header.js6
-rw-r--r--app/javascript/flavours/glitch/components/display_name.js4
-rw-r--r--app/javascript/flavours/glitch/components/error_boundary.js8
-rw-r--r--app/javascript/flavours/glitch/components/hashtag.js61
-rw-r--r--app/javascript/flavours/glitch/components/modal_root.js34
-rw-r--r--app/javascript/flavours/glitch/components/poll.js27
-rw-r--r--app/javascript/flavours/glitch/components/scrollable_list.js12
-rw-r--r--app/javascript/flavours/glitch/components/skeleton.js11
-rw-r--r--app/javascript/flavours/glitch/components/status.js72
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js14
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js67
-rw-r--r--app/javascript/flavours/glitch/components/status_header.js6
-rw-r--r--app/javascript/flavours/glitch/components/status_icons.js31
-rw-r--r--app/javascript/flavours/glitch/components/status_list.js1
-rw-r--r--app/javascript/flavours/glitch/components/status_prepend.js2
-rw-r--r--app/javascript/flavours/glitch/containers/admin_component.js26
-rw-r--r--app/javascript/flavours/glitch/containers/mastodon.js28
-rw-r--r--app/javascript/flavours/glitch/containers/media_container.js2
-rw-r--r--app/javascript/flavours/glitch/containers/scroll_container.js18
-rw-r--r--app/javascript/flavours/glitch/features/account/components/action_bar.js6
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/index.js73
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/header.js6
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js2
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/index.js65
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.js3
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/header.js6
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/navigation_bar.js4
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search_results.js18
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js1
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/header_container.js1
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_container.js5
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js4
-rw-r--r--app/javascript/flavours/glitch/features/directory/components/account_card.js2
-rw-r--r--app/javascript/flavours/glitch/features/directory/index.js7
-rw-r--r--app/javascript/flavours/glitch/features/follow_recommendations/components/account.js2
-rw-r--r--app/javascript/flavours/glitch/features/follow_recommendations/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js2
-rw-r--r--app/javascript/flavours/glitch/features/followers/index.js73
-rw-r--r--app/javascript/flavours/glitch/features/following/index.js73
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/components/announcements.js6
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/components/trends.js2
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/index.js12
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js4
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js19
-rw-r--r--app/javascript/flavours/glitch/features/lists/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/column_settings.js15
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow.js4
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow_request.js6
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js4
-rw-r--r--app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js8
-rw-r--r--app/javascript/flavours/glitch/features/picture_in_picture/components/header.js2
-rw-r--r--app/javascript/flavours/glitch/features/status/components/action_bar.js3
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js67
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js17
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/boost_modal.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.js4
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js9
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/favourite_modal.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js108
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/link_footer.js3
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/list_panel.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/media_modal.js9
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/modal_root.js31
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/navigation_panel.js8
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/tabs_bar.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/modal_container.js20
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js53
-rw-r--r--app/javascript/flavours/glitch/locales/ja.js79
-rw-r--r--app/javascript/flavours/glitch/locales/ko.js196
-rw-r--r--app/javascript/flavours/glitch/locales/zh-CN.js198
-rw-r--r--app/javascript/flavours/glitch/packs/admin.js48
-rw-r--r--app/javascript/flavours/glitch/packs/public.js8
-rw-r--r--app/javascript/flavours/glitch/reducers/accounts_map.js15
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js38
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/modal.js17
-rw-r--r--app/javascript/flavours/glitch/styles/accounts.scss42
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss530
-rw-r--r--app/javascript/flavours/glitch/styles/components/columns.scss19
-rw-r--r--app/javascript/flavours/glitch/styles/components/drawer.scss76
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss9
-rw-r--r--app/javascript/flavours/glitch/styles/components/media.scss3
-rw-r--r--app/javascript/flavours/glitch/styles/components/search.scss64
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss12
-rw-r--r--app/javascript/flavours/glitch/styles/containers.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/contrast/diff.scss13
-rw-r--r--app/javascript/flavours/glitch/styles/dashboard.scss71
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss4
-rw-r--r--app/javascript/flavours/glitch/styles/polls.scss15
-rw-r--r--app/javascript/flavours/glitch/styles/rtl.scss21
-rw-r--r--app/javascript/flavours/glitch/styles/tables.scss5
-rw-r--r--app/javascript/flavours/glitch/styles/widgets.scss18
-rw-r--r--app/javascript/flavours/glitch/theme.yml2
-rw-r--r--app/javascript/flavours/glitch/util/api.js26
-rw-r--r--app/javascript/flavours/glitch/util/backend_links.js2
-rw-r--r--app/javascript/flavours/glitch/util/initial_state.js2
-rw-r--r--app/javascript/flavours/glitch/util/numbers.js8
-rw-r--r--app/javascript/flavours/vanilla/theme.yml2
113 files changed, 2865 insertions, 658 deletions
diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js
index 912a3d179..0cf64e076 100644
--- a/app/javascript/flavours/glitch/actions/accounts.js
+++ b/app/javascript/flavours/glitch/actions/accounts.js
@@ -5,6 +5,10 @@ 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_LOOKUP_REQUEST = 'ACCOUNT_LOOKUP_REQUEST';
+export const ACCOUNT_LOOKUP_SUCCESS = 'ACCOUNT_LOOKUP_SUCCESS';
+export const ACCOUNT_LOOKUP_FAIL    = 'ACCOUNT_LOOKUP_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';
@@ -104,6 +108,34 @@ export function fetchAccount(id) {
   };
 };
 
+export const lookupAccount = acct => (dispatch, getState) => {
+  dispatch(lookupAccountRequest(acct));
+
+  api(getState).get('/api/v1/accounts/lookup', { params: { acct } }).then(response => {
+    dispatch(fetchRelationships([response.data.id]));
+    dispatch(importFetchedAccount(response.data));
+    dispatch(lookupAccountSuccess());
+  }).catch(error => {
+    dispatch(lookupAccountFail(acct, error));
+  });
+};
+
+export const lookupAccountRequest = (acct) => ({
+  type: ACCOUNT_LOOKUP_REQUEST,
+  acct,
+});
+
+export const lookupAccountSuccess = () => ({
+  type: ACCOUNT_LOOKUP_SUCCESS,
+});
+
+export const lookupAccountFail = (acct, error) => ({
+  type: ACCOUNT_LOOKUP_FAIL,
+  acct,
+  error,
+  skipAlert: true,
+});
+
 export function fetchAccountRequest(id) {
   return {
     type: ACCOUNT_FETCH_REQUEST,
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index f83738093..261c72b2a 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -10,6 +10,7 @@ import { importFetchedAccounts } from './importer';
 import { updateTimeline } from './timelines';
 import { showAlertForError } from './alerts';
 import { showAlert } from './alerts';
+import { openModal } from './modal';
 import { defineMessages } from 'react-intl';
 
 let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags;
@@ -38,6 +39,7 @@ export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS';
 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_SUGGESTION_IGNORE = 'COMPOSE_SUGGESTION_IGNORE';
 export const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
 
 export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
@@ -68,6 +70,11 @@ export const COMPOSE_POLL_OPTION_CHANGE   = 'COMPOSE_POLL_OPTION_CHANGE';
 export const COMPOSE_POLL_OPTION_REMOVE   = 'COMPOSE_POLL_OPTION_REMOVE';
 export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE';
 
+export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL';
+
+export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION';
+export const COMPOSE_CHANGE_MEDIA_FOCUS       = 'COMPOSE_CHANGE_MEDIA_FOCUS';
+
 const messages = defineMessages({
   uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
   uploadErrorPoll:  { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
@@ -77,7 +84,7 @@ const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
 
 export const ensureComposeIsVisible = (getState, routerHistory) => {
   if (!getState().getIn(['compose', 'mounted']) && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) {
-    routerHistory.push('/statuses/new');
+    routerHistory.push('/publish');
   }
 };
 
@@ -170,7 +177,8 @@ export function submitCompose(routerHistory) {
         'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
       },
     }).then(function (response) {
-      if (routerHistory && routerHistory.location.pathname === '/statuses/new'
+      if (routerHistory
+          && (routerHistory.location.pathname === '/publish' || routerHistory.location.pathname === '/statuses/new')
           && window.history.state
           && !getState().getIn(['compose', 'advanced_options', 'threaded_mode'])) {
         routerHistory.goBack();
@@ -278,12 +286,15 @@ export function uploadCompose(files) {
           if (status === 200) {
             dispatch(uploadComposeSuccess(data, f));
           } else if (status === 202) {
+            let tryCount = 1;
             const poll = () => {
               api(getState).get(`/api/v1/media/${data.id}`).then(response => {
                 if (response.status === 200) {
                   dispatch(uploadComposeSuccess(response.data, f));
                 } else if (response.status === 206) {
-                  setTimeout(() => poll(), 1000);
+                  let retryAfter = (Math.log2(tryCount) || 1) * 1000;
+                  tryCount += 1;
+                  setTimeout(() => poll(), retryAfter);
                 }
               }).catch(error => dispatch(uploadComposeFail(error)));
             };
@@ -339,6 +350,32 @@ export const uploadThumbnailFail = error => ({
   skipLoading: true,
 });
 
+export function initMediaEditModal(id) {
+  return dispatch => {
+    dispatch({
+      type: INIT_MEDIA_EDIT_MODAL,
+      id,
+    });
+
+    dispatch(openModal('FOCAL_POINT', { id }));
+  };
+};
+
+export function onChangeMediaDescription(description) {
+  return {
+    type: COMPOSE_CHANGE_MEDIA_DESCRIPTION,
+    description,
+  };
+};
+
+export function onChangeMediaFocus(focusX, focusY) {
+  return {
+    type: COMPOSE_CHANGE_MEDIA_FOCUS,
+    focusX,
+    focusY,
+  };
+};
+
 export function changeUploadCompose(id, params) {
   return (dispatch, getState) => {
     dispatch(changeUploadComposeRequest());
@@ -529,13 +566,25 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
       completion = '@' + getState().getIn(['accounts', suggestion.id, 'acct']);
     }
 
-    dispatch({
-      type: COMPOSE_SUGGESTION_SELECT,
-      position,
-      token,
-      completion,
-      path,
-    });
+    // We don't want to replace hashtags that vary only in case due to accessibility, but we need to fire off an event so that
+    // the suggestions are dismissed and the cursor moves forward.
+    if (suggestion.type !== 'hashtag' || token.slice(1).localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) !== 0) {
+      dispatch({
+        type: COMPOSE_SUGGESTION_SELECT,
+        position,
+        token,
+        completion,
+        path,
+      });
+    } else {
+      dispatch({
+        type: COMPOSE_SUGGESTION_IGNORE,
+        position,
+        token,
+        completion,
+        path,
+      });
+    }
   };
 };
 
diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js
index 9b3bd0d56..bda15a9b0 100644
--- a/app/javascript/flavours/glitch/actions/importer/normalizer.js
+++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js
@@ -54,15 +54,16 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.poll = status.poll.id;
   }
 
-  // Only calculate these values when status first encountered
-  // Otherwise keep the ones already in the reducer
-  if (normalOldStatus) {
+  // Only calculate these values when status first encountered and
+  // when the underlying values change. Otherwise keep the ones
+  // already in the reducer
+  if (normalOldStatus && normalOldStatus.get('content') === normalStatus.content && normalOldStatus.get('spoiler_text') === normalStatus.spoiler_text) {
     normalStatus.search_index = normalOldStatus.get('search_index');
     normalStatus.contentHtml = normalOldStatus.get('contentHtml');
     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
   } else {
     const spoilerText   = normalStatus.spoiler_text || '';
-    const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+    const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
     const emojiMap      = makeEmojiMap(normalStatus);
 
     normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index bd3a34e5d..4b00ea632 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -1,6 +1,6 @@
 import api, { getLinks } from 'flavours/glitch/util/api';
 import IntlMessageFormat from 'intl-messageformat';
-import { fetchRelationships } from './accounts';
+import { fetchFollowRequests, fetchRelationships } from './accounts';
 import {
   importFetchedAccount,
   importFetchedAccounts,
@@ -90,6 +90,10 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
       filtered = regex && regex.test(searchIndex);
     }
 
+    if (['follow_request'].includes(notification.type)) {
+      dispatch(fetchFollowRequests());
+    }
+
     dispatch(submitMarkers());
 
     if (showInColumn) {
diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js
index 4d2bda78b..7db357df1 100644
--- a/app/javascript/flavours/glitch/actions/statuses.js
+++ b/app/javascript/flavours/glitch/actions/statuses.js
@@ -128,6 +128,9 @@ export function deleteStatusFail(id, error) {
   };
 };
 
+export const updateStatus = status => dispatch =>
+  dispatch(importFetchedStatus(status));
+
 export function fetchContext(id) {
   return (dispatch, getState) => {
     dispatch(fetchContextRequest(id));
diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js
index 35db5dcc9..223924534 100644
--- a/app/javascript/flavours/glitch/actions/streaming.js
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -10,6 +10,7 @@ import {
 } from './timelines';
 import { updateNotifications, expandNotifications } from './notifications';
 import { updateConversations } from './conversations';
+import { updateStatus } from './statuses';
 import {
   fetchAnnouncements,
   updateAnnouncements,
@@ -75,6 +76,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
         case 'update':
           dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));
           break;
+        case 'status.update':
+          dispatch(updateStatus(JSON.parse(data.payload)));
+          break;
         case 'delete':
           dispatch(deleteFromTimelines(data.payload));
           break;
diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js
index 20313535b..396a36ea0 100644
--- a/app/javascript/flavours/glitch/components/account.js
+++ b/app/javascript/flavours/glitch/components/account.js
@@ -128,7 +128,7 @@ class Account extends ImmutablePureComponent {
       <Permalink
         className='account small'
         href={account.get('url')}
-        to={`/accounts/${account.get('id')}`}
+        to={`/@${account.get('acct')}`}
       >
         <div className='account__avatar-wrapper'>
           <Avatar
@@ -144,7 +144,7 @@ class Account extends ImmutablePureComponent {
     ) : (
       <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')}`}>
+          <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
             {mute_expires_at}
             <DisplayName account={account} />
diff --git a/app/javascript/flavours/glitch/components/admin/Counter.js b/app/javascript/flavours/glitch/components/admin/Counter.js
new file mode 100644
index 000000000..2bc9ce482
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Counter.js
@@ -0,0 +1,116 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { FormattedNumber } from 'react-intl';
+import { Sparklines, SparklinesCurve } from 'react-sparklines';
+import classNames from 'classnames';
+import Skeleton from 'flavours/glitch/components/skeleton';
+
+const percIncrease = (a, b) => {
+  let percent;
+
+  if (b !== 0) {
+    if (a !== 0) {
+      percent = (b - a) / a;
+    } else {
+      percent = 1;
+    }
+  } else if (b === 0 && a === 0) {
+    percent = 0;
+  } else {
+    percent = - 1;
+  }
+
+  return percent;
+};
+
+export default class Counter extends React.PureComponent {
+
+  static propTypes = {
+    measure: PropTypes.string.isRequired,
+    start_at: PropTypes.string.isRequired,
+    end_at: PropTypes.string.isRequired,
+    label: PropTypes.string.isRequired,
+    href: PropTypes.string,
+    params: PropTypes.object,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { measure, start_at, end_at, params } = this.props;
+
+    api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at, [measure]: params }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { label, href } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <React.Fragment>
+          <span className='sparkline__value__total'><Skeleton width={43} /></span>
+          <span className='sparkline__value__change'><Skeleton width={43} /></span>
+        </React.Fragment>
+      );
+    } else {
+      const measure = data[0];
+      const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1);
+
+      content = (
+        <React.Fragment>
+          <span className='sparkline__value__total'><FormattedNumber value={measure.total} /></span>
+          <span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>
+        </React.Fragment>
+      );
+    }
+
+    const inner = (
+      <React.Fragment>
+        <div className='sparkline__value'>
+          {content}
+        </div>
+
+        <div className='sparkline__label'>
+          {label}
+        </div>
+
+        <div className='sparkline__graph'>
+          {!loading && (
+            <Sparklines width={259} height={55} data={data[0].data.map(x => x.value * 1)}>
+              <SparklinesCurve />
+            </Sparklines>
+          )}
+        </div>
+      </React.Fragment>
+    );
+
+    if (href) {
+      return (
+        <a href={href} className='sparkline'>
+          {inner}
+        </a>
+      );
+    } else {
+      return (
+        <div className='sparkline'>
+          {inner}
+        </div>
+      );
+    }
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/Dimension.js b/app/javascript/flavours/glitch/components/admin/Dimension.js
new file mode 100644
index 000000000..a924d093c
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Dimension.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { FormattedNumber } from 'react-intl';
+import { roundTo10 } from 'flavours/glitch/util/numbers';
+import Skeleton from 'flavours/glitch/components/skeleton';
+
+export default class Dimension extends React.PureComponent {
+
+  static propTypes = {
+    dimension: PropTypes.string.isRequired,
+    start_at: PropTypes.string.isRequired,
+    end_at: PropTypes.string.isRequired,
+    limit: PropTypes.number.isRequired,
+    label: PropTypes.string.isRequired,
+    params: PropTypes.object,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { start_at, end_at, dimension, limit, params } = this.props;
+
+    api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit, [dimension]: params }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { label, limit } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <table>
+          <tbody>
+            {Array.from(Array(limit)).map((_, i) => (
+              <tr className='dimension__item' key={i}>
+                <td className='dimension__item__key'>
+                  <Skeleton width={100} />
+                </td>
+
+                <td className='dimension__item__value'>
+                  <Skeleton width={60} />
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    } else {
+      const sum = data[0].data.reduce((sum, cur) => sum + (cur.value * 1), 0);
+
+      content = (
+        <table>
+          <tbody>
+            {data[0].data.map(item => (
+              <tr className='dimension__item' key={item.key}>
+                <td className='dimension__item__key'>
+                  <span className={`dimension__item__indicator dimension__item__indicator--${roundTo10(((item.value * 1) / sum) * 100)}`} />
+                  <span title={item.key}>{item.human_key}</span>
+                </td>
+
+                <td className='dimension__item__value'>
+                  {typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={item.value} />}
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    }
+
+    return (
+      <div className='dimension'>
+        <h4>{label}</h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js b/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js
new file mode 100644
index 000000000..0f2a4fe36
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/ReportReasonSelector.js
@@ -0,0 +1,159 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { injectIntl, defineMessages } from 'react-intl';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  other: { id: 'report.categories.other', defaultMessage: 'Other' },
+  spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
+  violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
+});
+
+class Category extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    text: PropTypes.string.isRequired,
+    selected: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onSelect: PropTypes.func,
+    children: PropTypes.node,
+  };
+
+  handleClick = () => {
+    const { id, disabled, onSelect } = this.props;
+
+    if (!disabled) {
+      onSelect(id);
+    }
+  };
+
+  render () {
+    const { id, text, disabled, selected, children } = this.props;
+
+    return (
+      <div tabIndex='0' role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}>
+        {selected && <input type='hidden' name='report[category]' value={id} />}
+
+        <div className='report-reason-selector__category__label'>
+          <span className={classNames('poll__input', { active: selected, disabled })} />
+          {text}
+        </div>
+
+        {(selected && children) && (
+          <div className='report-reason-selector__category__rules'>
+            {children}
+          </div>
+        )}
+      </div>
+    );
+  }
+
+}
+
+class Rule extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    text: PropTypes.string.isRequired,
+    selected: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onToggle: PropTypes.func,
+  };
+
+  handleClick = () => {
+    const { id, disabled, onToggle } = this.props;
+
+    if (!disabled) {
+      onToggle(id);
+    }
+  };
+
+  render () {
+    const { id, text, disabled, selected } = this.props;
+
+    return (
+      <div tabIndex='0' role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}>
+        <span className={classNames('poll__input', { checkbox: true, active: selected, disabled })} />
+        {selected && <input type='hidden' name='report[rule_ids][]' value={id} />}
+        {text}
+      </div>
+    );
+  }
+
+}
+
+export default @injectIntl
+class ReportReasonSelector extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    category: PropTypes.string.isRequired,
+    rule_ids: PropTypes.arrayOf(PropTypes.string),
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    category: this.props.category,
+    rule_ids: this.props.rule_ids || [],
+    rules: [],
+  };
+
+  componentDidMount() {
+    api().get('/api/v1/instance').then(res => {
+      this.setState({
+        rules: res.data.rules,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  _save = () => {
+    const { id, disabled } = this.props;
+    const { category, rule_ids } = this.state;
+
+    if (disabled) {
+      return;
+    }
+
+    api().put(`/api/v1/admin/reports/${id}`, {
+      category,
+      rule_ids,
+    }).catch(err => {
+      console.error(err);
+    });
+  };
+
+  handleSelect = id => {
+    this.setState({ category: id }, () => this._save());
+  };
+
+  handleToggle = id => {
+    const { rule_ids } = this.state;
+
+    if (rule_ids.includes(id)) {
+      this.setState({ rule_ids: rule_ids.filter(x => x !== id ) }, () => this._save());
+    } else {
+      this.setState({ rule_ids: [...rule_ids, id] }, () => this._save());
+    }
+  };
+
+  render () {
+    const { disabled, intl } = this.props;
+    const { rules, category, rule_ids } = this.state;
+
+    return (
+      <div className='report-reason-selector'>
+        <Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} />
+        <Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} />
+        <Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}>
+          {rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)}
+        </Category>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/Retention.js b/app/javascript/flavours/glitch/components/admin/Retention.js
new file mode 100644
index 000000000..6d7e4b279
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Retention.js
@@ -0,0 +1,151 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
+import classNames from 'classnames';
+import { roundTo10 } from 'flavours/glitch/util/numbers';
+
+const dateForCohort = cohort => {
+  switch(cohort.frequency) {
+  case 'day':
+    return <FormattedDate value={cohort.period} month='long' day='2-digit' />;
+  default:
+    return <FormattedDate value={cohort.period} month='long' year='numeric' />;
+  }
+};
+
+export default class Retention extends React.PureComponent {
+
+  static propTypes = {
+    start_at: PropTypes.string,
+    end_at: PropTypes.string,
+    frequency: PropTypes.string,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { start_at, end_at, frequency } = this.props;
+
+    api().post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { loading, data } = this.state;
+    const { frequency } = this.props;
+
+    let content;
+
+    if (loading) {
+      content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />;
+    } else {
+      content = (
+        <table className='retention__table'>
+          <thead>
+            <tr>
+              <th>
+                <div className='retention__table__date retention__table__label'>
+                  <FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' />
+                </div>
+              </th>
+
+              <th>
+                <div className='retention__table__number retention__table__label'>
+                  <FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' />
+                </div>
+              </th>
+
+              {data[0].data.slice(1).map((retention, i) => (
+                <th key={retention.date}>
+                  <div className='retention__table__number retention__table__label'>
+                    {i + 1}
+                  </div>
+                </th>
+              ))}
+            </tr>
+
+            <tr>
+              <td>
+                <div className='retention__table__date retention__table__average'>
+                  <FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' />
+                </div>
+              </td>
+
+              <td>
+                <div className='retention__table__size'>
+                  <FormattedNumber value={data.reduce((sum, cohort, i) => sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} />
+                </div>
+              </td>
+
+              {data[0].data.slice(1).map((retention, i) => {
+                const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].rate - sum)/(k + 1) : sum, 0);
+
+                return (
+                  <td key={retention.date}>
+                    <div className={classNames('retention__table__box', 'retention__table__average', `retention__table__box--${roundTo10(average * 100)}`)}>
+                      <FormattedNumber value={average} style='percent' />
+                    </div>
+                  </td>
+                );
+              })}
+            </tr>
+          </thead>
+
+          <tbody>
+            {data.slice(0, -1).map(cohort => (
+              <tr key={cohort.period}>
+                <td>
+                  <div className='retention__table__date'>
+                    {dateForCohort(cohort)}
+                  </div>
+                </td>
+
+                <td>
+                  <div className='retention__table__size'>
+                    <FormattedNumber value={cohort.data[0].value} />
+                  </div>
+                </td>
+
+                {cohort.data.slice(1).map(retention => (
+                  <td key={retention.date}>
+                    <div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.rate * 100)}`)}>
+                      <FormattedNumber value={retention.rate} style='percent' />
+                    </div>
+                  </td>
+                ))}
+              </tr>
+            ))}
+          </tbody>
+        </table>
+      );
+    }
+
+    let title = null;
+    switch(frequency) {
+    case 'day':
+      title = <FormattedMessage id='admin.dashboard.daily_retention' defaultMessage='User retention rate by day after sign-up' />;
+      break;
+    default:
+      title = <FormattedMessage id='admin.dashboard.monthly_retention' defaultMessage='User retention rate by month after sign-up' />;
+    };
+
+    return (
+      <div className='retention'>
+        <h4>{title}</h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/admin/Trends.js b/app/javascript/flavours/glitch/components/admin/Trends.js
new file mode 100644
index 000000000..60e367f00
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/admin/Trends.js
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'flavours/glitch/util/api';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Hashtag from 'flavours/glitch/components/hashtag';
+
+export default class Trends extends React.PureComponent {
+
+  static propTypes = {
+    limit: PropTypes.number.isRequired,
+  };
+
+  state = {
+    loading: true,
+    data: null,
+  };
+
+  componentDidMount () {
+    const { limit } = this.props;
+
+    api().get('/api/v1/admin/trends/tags', { params: { limit } }).then(res => {
+      this.setState({
+        loading: false,
+        data: res.data,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  render () {
+    const { limit } = this.props;
+    const { loading, data } = this.state;
+
+    let content;
+
+    if (loading) {
+      content = (
+        <div>
+          {Array.from(Array(limit)).map((_, i) => (
+            <Hashtag key={i} />
+          ))}
+        </div>
+      );
+    } else {
+      content = (
+        <div>
+          {data.map(hashtag => (
+            <Hashtag
+              key={hashtag.name}
+              name={hashtag.name}
+              href={`/admin/tags/${hashtag.id}`}
+              people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
+              uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
+              history={hashtag.history.reverse().map(day => day.uses)}
+              className={classNames(hashtag.requires_review && 'trends__item--requires-review', !hashtag.trendable && !hashtag.requires_review && 'trends__item--disabled')}
+            />
+          ))}
+        </div>
+      );
+    }
+
+    return (
+      <div className='trends trends--compact'>
+        <h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
+
+        {content}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/attachment_list.js b/app/javascript/flavours/glitch/components/attachment_list.js
index 68d8d29c7..68b80b19f 100644
--- a/app/javascript/flavours/glitch/components/attachment_list.js
+++ b/app/javascript/flavours/glitch/components/attachment_list.js
@@ -2,6 +2,8 @@ import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
 
 const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
@@ -16,29 +18,13 @@ export default class AttachmentList extends ImmutablePureComponent {
   render () {
     const { media, compact } = this.props;
 
-    if (compact) {
-      return (
-        <div className='attachment-list compact'>
-          <ul className='attachment-list__list'>
-            {media.map(attachment => {
-              const displayUrl = attachment.get('remote_url') || attachment.get('url');
-
-              return (
-                <li key={attachment.get('id')}>
-                  <a href={displayUrl} target='_blank' rel='noopener noreferrer'><Icon id='link' /> {filename(displayUrl)}</a>
-                </li>
-              );
-            })}
-          </ul>
-        </div>
-      );
-    }
-
     return (
-      <div className='attachment-list'>
-        <div className='attachment-list__icon'>
-          <Icon id='link' />
-        </div>
+      <div className={classNames('attachment-list', { compact })}>
+        {!compact && (
+          <div className='attachment-list__icon'>
+            <Icon id='link' />
+          </div>
+        )}
 
         <ul className='attachment-list__list'>
           {media.map(attachment => {
@@ -46,7 +32,11 @@ export default class AttachmentList extends ImmutablePureComponent {
 
             return (
               <li key={attachment.get('id')}>
-                <a href={displayUrl} target='_blank' rel='noopener noreferrer'>{filename(displayUrl)}</a>
+                <a href={displayUrl} target='_blank' rel='noopener noreferrer'>
+                  {compact && <Icon id='link' />}
+                  {compact && ' ' }
+                  {displayUrl ? filename(displayUrl) : <FormattedMessage id='attachments_list.unprocessed' defaultMessage='(unprocessed)' />}
+                </a>
               </li>
             );
           })}
diff --git a/app/javascript/flavours/glitch/components/avatar_composite.js b/app/javascript/flavours/glitch/components/avatar_composite.js
index 125b51c44..e30dfe68a 100644
--- a/app/javascript/flavours/glitch/components/avatar_composite.js
+++ b/app/javascript/flavours/glitch/components/avatar_composite.js
@@ -82,7 +82,7 @@ export default class AvatarComposite extends React.PureComponent {
       <a
         href={account.get('url')}
         target='_blank'
-        onClick={(e) => this.props.onAccountClick(account.get('id'), e)}
+        onClick={(e) => this.props.onAccountClick(account.get('acct'), e)}
         title={`@${account.get('acct')}`}
         key={account.get('id')}
       >
diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js
index ccd0714f1..500612093 100644
--- a/app/javascript/flavours/glitch/components/column_header.js
+++ b/app/javascript/flavours/glitch/components/column_header.js
@@ -124,8 +124,8 @@ class ColumnHeader extends React.PureComponent {
 
       moveButtons = (
         <div key='move-buttons' className='column-header__setting-arrows'>
-          <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button>
-          <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
+          <button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button>
+          <button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
         </div>
       );
     } else if (multiColumn && this.props.onPin) {
@@ -146,8 +146,8 @@ class ColumnHeader extends React.PureComponent {
     ];
 
     if (multiColumn) {
-      collapsedContent.push(moveButtons);
       collapsedContent.push(pinButton);
+      collapsedContent.push(moveButtons);
     }
 
     if (children || (multiColumn && this.props.onPin)) {
diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js
index ad978a2c6..9c7da744e 100644
--- a/app/javascript/flavours/glitch/components/display_name.js
+++ b/app/javascript/flavours/glitch/components/display_name.js
@@ -61,7 +61,7 @@ export default class DisplayName extends React.PureComponent {
         <a
           href={a.get('url')}
           target='_blank'
-          onClick={(e) => onAccountClick(a.get('id'), e)}
+          onClick={(e) => onAccountClick(a.get('acct'), e)}
           title={`@${a.get('acct')}`}
           rel='noopener noreferrer'
         >
@@ -76,7 +76,7 @@ export default class DisplayName extends React.PureComponent {
       }
 
       suffix = (
-        <a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('id'), e)} rel='noopener noreferrer'>
+        <a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('acct'), e)} rel='noopener noreferrer'>
           <span className='display-name__account'>@{acct}</span>
         </a>
       );
diff --git a/app/javascript/flavours/glitch/components/error_boundary.js b/app/javascript/flavours/glitch/components/error_boundary.js
index 8e6cd1461..4537bde1d 100644
--- a/app/javascript/flavours/glitch/components/error_boundary.js
+++ b/app/javascript/flavours/glitch/components/error_boundary.js
@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { FormattedMessage } from 'react-intl';
+import { source_url } from 'flavours/glitch/util/initial_state';
 import { preferencesLink } from 'flavours/glitch/util/backend_links';
 import StackTrace from 'stacktrace-js';
 
@@ -64,6 +65,11 @@ export default class ErrorBoundary extends React.PureComponent {
       debugInfo += 'React component stack\n---------------------\n\n```\n' + componentStack.toString() + '\n```';
     }
 
+    let issueTracker = source_url;
+    if (source_url.match(/^https:\/\/github\.com\/[^/]+\/[^/]+\/?$/)) {
+      issueTracker = source_url + '/issues';
+    }
+
     return (
       <div tabIndex='-1'>
         <div className='error-boundary'>
@@ -84,7 +90,7 @@ export default class ErrorBoundary extends React.PureComponent {
               <FormattedMessage
                 id='web_app_crash.report_issue'
                 defaultMessage='Report a bug in the {issuetracker}'
-                values={{ issuetracker: <a href='https://github.com/glitch-soc/mastodon/issues' rel='noopener noreferrer' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
+                values={{ issuetracker: <a href={issueTracker} rel='noopener noreferrer' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
               />
               { debugInfo !== '' && (
                 <details>
diff --git a/app/javascript/flavours/glitch/components/hashtag.js b/app/javascript/flavours/glitch/components/hashtag.js
index 24c595ed7..769185a2b 100644
--- a/app/javascript/flavours/glitch/components/hashtag.js
+++ b/app/javascript/flavours/glitch/components/hashtag.js
@@ -6,6 +6,8 @@ import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import Permalink from './permalink';
 import ShortNumber from 'flavours/glitch/components/short_number';
+import Skeleton from 'flavours/glitch/components/skeleton';
+import classNames from 'classnames';
 
 class SilentErrorBoundary extends React.Component {
 
@@ -47,45 +49,38 @@ const accountsCountRenderer = (displayNumber, pluralReady) => (
   />
 );
 
-const Hashtag = ({ hashtag }) => (
-  <div className='trends__item'>
+export const ImmutableHashtag = ({ hashtag }) => (
+  <Hashtag
+    name={hashtag.get('name')}
+    href={hashtag.get('url')}
+    to={`/tags/${hashtag.get('name')}`}
+    people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
+    uses={hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1}
+    history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
+  />
+);
+
+ImmutableHashtag.propTypes = {
+  hashtag: ImmutablePropTypes.map.isRequired,
+};
+
+const Hashtag = ({ name, href, to, people, uses, history, className }) => (
+  <div className={classNames('trends__item', className)}>
     <div className='trends__item__name'>
-      <Permalink
-        href={hashtag.get('url')}
-        to={`/timelines/tag/${hashtag.get('name')}`}
-      >
-        #<span>{hashtag.get('name')}</span>
+      <Permalink href={href} to={to}>
+        {name ? <React.Fragment>#<span>{name}</span></React.Fragment> : <Skeleton width={50} />}
       </Permalink>
 
-      <ShortNumber
-        value={
-          hashtag.getIn(['history', 0, 'accounts']) * 1 +
-          hashtag.getIn(['history', 1, 'accounts']) * 1
-        }
-        renderer={accountsCountRenderer}
-      />
+      {typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}
     </div>
 
     <div className='trends__item__current'>
-      <ShortNumber
-        value={
-          hashtag.getIn(['history', 0, 'uses']) * 1 +
-          hashtag.getIn(['history', 1, 'uses']) * 1
-        }
-      />
+      {typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />}
     </div>
 
     <div className='trends__item__sparkline'>
       <SilentErrorBoundary>
-        <Sparklines
-          width={50}
-          height={28}
-          data={hashtag
-            .get('history')
-            .reverse()
-            .map((day) => day.get('uses'))
-            .toArray()}
-        >
+        <Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
           <SparklinesCurve style={{ fill: 'none' }} />
         </Sparklines>
       </SilentErrorBoundary>
@@ -94,7 +89,13 @@ const Hashtag = ({ hashtag }) => (
 );
 
 Hashtag.propTypes = {
-  hashtag: ImmutablePropTypes.map.isRequired,
+  name: PropTypes.string,
+  href: PropTypes.string,
+  to: PropTypes.string,
+  people: PropTypes.number,
+  uses: PropTypes.number,
+  history: PropTypes.arrayOf(PropTypes.number),
+  className: PropTypes.string,
 };
 
 export default Hashtag;
diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js
index 913234d32..7b5a630e5 100644
--- a/app/javascript/flavours/glitch/components/modal_root.js
+++ b/app/javascript/flavours/glitch/components/modal_root.js
@@ -76,10 +76,13 @@ export default class ModalRoot extends React.PureComponent {
         this.activeElement = null;
       }).catch(console.error);
 
-      this.handleModalClose();
+      this._handleModalClose();
     }
     if (this.props.children && !prevProps.children) {
-      this.handleModalOpen();
+      this._handleModalOpen();
+    }
+    if (this.props.children) {
+      this._ensureHistoryBuffer();
     }
   }
 
@@ -88,22 +91,29 @@ export default class ModalRoot extends React.PureComponent {
     window.removeEventListener('keydown', this.handleKeyDown);
   }
 
-  handleModalClose () {
+  _handleModalOpen () {
+    this._modalHistoryKey = Date.now();
+    this.unlistenHistory = this.history.listen((_, action) => {
+      if (action === 'POP') {
+        this.props.onClose();
+      }
+    });
+  }
+
+  _handleModalClose () {
     this.unlistenHistory();
 
-    const state = this.history.location.state;
-    if (state && state.mastodonModalOpen) {
+    const { state } = this.history.location;
+    if (state && state.mastodonModalKey === this._modalHistoryKey) {
       this.history.goBack();
     }
   }
 
-  handleModalOpen () {
-    const history = this.history;
-    const state   = {...history.location.state, mastodonModalOpen: true};
-    history.push(history.location.pathname, state);
-    this.unlistenHistory = history.listen(() => {
-      this.props.onClose();
-    });
+  _ensureHistoryBuffer () {
+    const { pathname, state } = this.history.location;
+    if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
+      this.history.push(pathname, { ...state, mastodonModalKey: this._modalHistoryKey });
+    }
   }
 
   getSiblings = () => {
diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js
index f230823cc..970b00705 100644
--- a/app/javascript/flavours/glitch/components/poll.js
+++ b/app/javascript/flavours/glitch/components/poll.js
@@ -12,8 +12,18 @@ import RelativeTimestamp from './relative_timestamp';
 import Icon from 'flavours/glitch/components/icon';
 
 const messages = defineMessages({
-  closed: { id: 'poll.closed', defaultMessage: 'Closed' },
-  voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' },
+  closed: {
+    id: 'poll.closed',
+    defaultMessage: 'Closed',
+  },
+  voted: {
+    id: 'poll.voted',
+    defaultMessage: 'You voted for this answer',
+  },
+  votes: {
+    id: 'poll.votes',
+    defaultMessage: '{votes, plural, one {# vote} other {# votes}}',
+  },
 });
 
 const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
@@ -148,9 +158,16 @@ class Poll extends ImmutablePureComponent {
               data-index={optionIndex}
             />
           )}
-          {showResults && <span className='poll__number'>
-            {Math.round(percent)}%
-          </span>}
+          {showResults && (
+            <span
+              className='poll__number'
+              title={intl.formatMessage(messages.votes, {
+                votes: option.get('votes_count'),
+              })}
+            >
+              {Math.round(percent)}%
+            </span>
+          )}
 
           <span
             className='poll__option__text translate'
diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js
index cc8d9f1f3..16f13afa4 100644
--- a/app/javascript/flavours/glitch/components/scrollable_list.js
+++ b/app/javascript/flavours/glitch/components/scrollable_list.js
@@ -1,5 +1,5 @@
 import React, { PureComponent } from 'react';
-import { ScrollContainer } from 'react-router-scroll-4';
+import ScrollContainer from 'flavours/glitch/containers/scroll_container';
 import PropTypes from 'prop-types';
 import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container';
 import LoadMore from './load_more';
@@ -34,7 +34,6 @@ class ScrollableList extends PureComponent {
     onScrollToTop: PropTypes.func,
     onScroll: PropTypes.func,
     trackScroll: PropTypes.bool,
-    shouldUpdateScroll: PropTypes.func,
     isLoading: PropTypes.bool,
     showLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
@@ -264,11 +263,6 @@ class ScrollableList extends PureComponent {
     this.props.onLoadMore();
   }
 
-  defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
-    if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false;
-    return !(location.state && location.state.mastodonModalOpen);
-  }
-
   handleLoadPending = e => {
     e.preventDefault();
     this.props.onLoadPending();
@@ -282,7 +276,7 @@ class ScrollableList extends PureComponent {
   }
 
   render () {
-    const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
+    const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
@@ -348,7 +342,7 @@ class ScrollableList extends PureComponent {
 
     if (trackScroll) {
       return (
-        <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll || this.defaultShouldUpdateScroll}>
+        <ScrollContainer scrollKey={scrollKey}>
           {scrollableArea}
         </ScrollContainer>
       );
diff --git a/app/javascript/flavours/glitch/components/skeleton.js b/app/javascript/flavours/glitch/components/skeleton.js
new file mode 100644
index 000000000..09093e99c
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/skeleton.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const Skeleton = ({ width, height }) => <span className='skeleton' style={{ width, height }}>&zwnj;</span>;
+
+Skeleton.propTypes = {
+  width: PropTypes.number,
+  height: PropTypes.number,
+};
+
+export default Skeleton;
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 782fd918e..02ff9ab28 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -346,7 +346,9 @@ class Status extends ImmutablePureComponent {
         return;
       } else {
         if (destination === undefined) {
-          destination = `/statuses/${
+          destination = `/@${
+            status.getIn(['reblog', 'account', 'acct'], status.getIn(['account', 'acct']))
+          }/${
             status.getIn(['reblog', 'id'], status.get('id'))
           }`;
         }
@@ -362,16 +364,6 @@ class Status extends ImmutablePureComponent {
     this.setState({ showMedia: !this.state.showMedia });
   }
 
-  handleAccountClick = (e) => {
-    if (this.context.router && e.button === 0) {
-      const id = e.currentTarget.getAttribute('data-id');
-      e.preventDefault();
-      let state = {...this.context.router.history.location.state};
-      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-      this.context.router.history.push(`/accounts/${id}`, state);
-    }
-  }
-
   handleExpandedToggle = () => {
     if (this.props.status.get('spoiler_text')) {
       this.setExpansion(!this.state.isExpanded);
@@ -433,13 +425,14 @@ class Status extends ImmutablePureComponent {
   handleHotkeyOpen = () => {
     let state = {...this.context.router.history.location.state};
     state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-    this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state);
+    const status = this.props.status;
+    this.context.router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`, state);
   }
 
   handleHotkeyOpenProfile = () => {
     let state = {...this.context.router.history.location.state};
     state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+    this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
   }
 
   handleHotkeyMoveUp = e => {
@@ -516,8 +509,8 @@ class Status extends ImmutablePureComponent {
     const { isExpanded, isCollapsed, forceFilter } = this.state;
     let background = null;
     let attachments = null;
-    let media = null;
-    let mediaIcon = null;
+    let media = [];
+    let mediaIcons = [];
 
     if (status === null) {
       return null;
@@ -543,9 +536,8 @@ class Status extends ImmutablePureComponent {
       return (
         <HotKeys handlers={handlers}>
           <div ref={this.handleRef} className='status focusable' tabIndex='0'>
-            {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
-            {' '}
-            {status.get('content')}
+            <span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
+            <span>{status.get('content')}</span>
           </div>
         </HotKeys>
       );
@@ -587,25 +579,27 @@ class Status extends ImmutablePureComponent {
     //  After we have generated our appropriate media element and stored it in
     //  `media`, we snatch the thumbnail to use as our `background` if media
     //  backgrounds for collapsed statuses are enabled.
+
     attachments = status.get('media_attachments');
     if (status.get('poll')) {
-      media = <PollContainer pollId={status.get('poll')} />;
-      mediaIcon = 'tasks';
-    } else if (usingPiP) {
-      media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
-      mediaIcon = 'video-camera';
+      media.push(<PollContainer pollId={status.get('poll')} />);
+      mediaIcons.push('tasks');
+    }
+    if (usingPiP) {
+      media.push(<PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />);
+      mediaIcons.push('video-camera');
     } else if (attachments.size > 0) {
       if (muted || attachments.some(item => item.get('type') === 'unknown')) {
-        media = (
+        media.push(
           <AttachmentList
             compact
             media={status.get('media_attachments')}
-          />
+          />,
         );
       } else if (attachments.getIn([0, 'type']) === 'audio') {
         const attachment = status.getIn(['media_attachments', 0]);
 
-        media = (
+        media.push(
           <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
             {Component => (
               <Component
@@ -622,13 +616,13 @@ class Status extends ImmutablePureComponent {
                 deployPictureInPicture={this.handleDeployPictureInPicture}
               />
             )}
-          </Bundle>
+          </Bundle>,
         );
-        mediaIcon = 'music';
+        mediaIcons.push('music');
       } else if (attachments.getIn([0, 'type']) === 'video') {
         const attachment = status.getIn(['media_attachments', 0]);
 
-        media = (
+        media.push(
           <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
             {Component => (<Component
               preview={attachment.get('preview_url')}
@@ -648,11 +642,11 @@ class Status extends ImmutablePureComponent {
               visible={this.state.showMedia}
               onToggleVisibility={this.handleToggleMediaVisibility}
             />)}
-          </Bundle>
+          </Bundle>,
         );
-        mediaIcon = 'video-camera';
+        mediaIcons.push('video-camera');
       } else {  //  Media type is 'image' or 'gifv'
-        media = (
+        media.push(
           <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
             {Component => (
               <Component
@@ -668,16 +662,16 @@ class Status extends ImmutablePureComponent {
                 onToggleVisibility={this.handleToggleMediaVisibility}
               />
             )}
-          </Bundle>
+          </Bundle>,
         );
-        mediaIcon = 'picture-o';
+        mediaIcons.push('picture-o');
       }
 
       if (!status.get('sensitive') && !(status.get('spoiler_text').length > 0) && settings.getIn(['collapsed', 'backgrounds', 'preview_images'])) {
         background = attachments.getIn([0, 'preview_url']);
       }
     } else if (status.get('card') && settings.get('inline_preview_cards')) {
-      media = (
+      media.push(
         <Card
           onOpenMedia={this.handleOpenMedia}
           card={status.get('card')}
@@ -685,9 +679,9 @@ class Status extends ImmutablePureComponent {
           cacheWidth={this.props.cacheMediaWidth}
           defaultWidth={this.props.cachedMediaWidth}
           sensitive={status.get('sensitive')}
-        />
+        />,
       );
-      mediaIcon = 'link';
+      mediaIcons.push('link');
     }
 
     //  Here we prepare extra data-* attributes for CSS selectors.
@@ -754,7 +748,7 @@ class Status extends ImmutablePureComponent {
             </span>
             <StatusIcons
               status={status}
-              mediaIcon={mediaIcon}
+              mediaIcons={mediaIcons}
               collapsible={settings.getIn(['collapsed', 'enabled'])}
               collapsed={isCollapsed}
               setCollapsed={setCollapsed}
@@ -764,7 +758,7 @@ class Status extends ImmutablePureComponent {
           <StatusContent
             status={status}
             media={media}
-            mediaIcon={mediaIcon}
+            mediaIcons={mediaIcons}
             expanded={isExpanded}
             onExpandedToggle={this.handleExpandedToggle}
             parseClick={parseClick}
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index 74bfd948e..ae67c6116 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -38,6 +38,7 @@ const messages = defineMessages({
   admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
   copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
   hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
+  edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
 });
 
 export default @injectIntl
@@ -147,11 +148,11 @@ class StatusActionBar extends ImmutablePureComponent {
 
   handleOpen = () => {
     let state = {...this.context.router.history.location.state};
-    if (state.mastodonModalOpen) {
-      this.context.router.history.replace(`/statuses/${this.props.status.get('id')}`, { mastodonBackSteps: (state.mastodonBackSteps || 0) + 1 });
+    if (state.mastodonModalKey) {
+      this.context.router.history.replace(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`, { mastodonBackSteps: (state.mastodonBackSteps || 0) + 1 });
     } else {
       state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-      this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state);
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`, state);
     }
   }
 
@@ -196,6 +197,7 @@ class StatusActionBar extends ImmutablePureComponent {
     const anonymousAccess    = !me;
     const mutingConversation = status.get('muted');
     const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
+    const pinnableStatus     = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
     const writtenByMe        = status.getIn(['account', 'id']) === me;
 
     let menu = [];
@@ -212,7 +214,7 @@ class StatusActionBar extends ImmutablePureComponent {
 
     menu.push(null);
 
-    if (writtenByMe && publicStatus) {
+    if (writtenByMe && pinnableStatus) {
       menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
       menu.push(null);
     }
@@ -323,7 +325,9 @@ class StatusActionBar extends ImmutablePureComponent {
           </div>,
         ]}
 
-        <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+        <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
+          <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
+        </a>
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index 34ff97305..1d32b35e5 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -69,8 +69,8 @@ export default class StatusContent extends React.PureComponent {
     expanded: PropTypes.bool,
     collapsed: PropTypes.bool,
     onExpandedToggle: PropTypes.func,
-    media: PropTypes.element,
-    mediaIcon: PropTypes.string,
+    media: PropTypes.node,
+    mediaIcons: PropTypes.arrayOf(PropTypes.string),
     parseClick: PropTypes.func,
     disabled: PropTypes.bool,
     onUpdate: PropTypes.func,
@@ -197,7 +197,7 @@ export default class StatusContent extends React.PureComponent {
 
   onMentionClick = (mention, e) => {
     if (this.props.parseClick) {
-      this.props.parseClick(e, `/accounts/${mention.get('id')}`);
+      this.props.parseClick(e, `/@${mention.get('acct')}`);
     }
   }
 
@@ -205,7 +205,7 @@ export default class StatusContent extends React.PureComponent {
     hashtag = hashtag.replace(/^#/, '');
 
     if (this.props.parseClick) {
-      this.props.parseClick(e, `/timelines/tag/${hashtag}`);
+      this.props.parseClick(e, `/tags/${hashtag}`);
     }
   }
 
@@ -224,8 +224,8 @@ export default class StatusContent extends React.PureComponent {
     const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
 
     let element = e.target;
-    while (element) {
-      if (['button', 'video', 'a', 'label', 'canvas'].includes(element.localName)) {
+    while (element !== e.currentTarget) {
+      if (['button', 'video', 'a', 'label', 'canvas'].includes(element.localName) || element.getAttribute('role') === 'button') {
         return;
       }
       element = element.parentNode;
@@ -256,7 +256,7 @@ export default class StatusContent extends React.PureComponent {
     const {
       status,
       media,
-      mediaIcon,
+      mediaIcons,
       parseClick,
       disabled,
       tagLinks,
@@ -277,7 +277,7 @@ export default class StatusContent extends React.PureComponent {
 
       const mentionLinks = status.get('mentions').map(item => (
         <Permalink
-          to={`/accounts/${item.get('id')}`}
+          to={`/@${item.get('acct')}`}
           href={item.get('url')}
           key={item.get('id')}
           className='mention'
@@ -286,28 +286,37 @@ export default class StatusContent extends React.PureComponent {
         </Permalink>
       )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
 
-      const toggleText = hidden ? [
-        <FormattedMessage
-          id='status.show_more'
-          defaultMessage='Show more'
-          key='0'
-        />,
-        mediaIcon ? (
-          <Icon
-            fixedWidth
-            className='status__content__spoiler-icon'
-            id={mediaIcon}
-            aria-hidden='true'
-            key='1'
+      let toggleText = null;
+      if (hidden) {
+        toggleText = [
+          <FormattedMessage
+            id='status.show_more'
+            defaultMessage='Show more'
+            key='0'
+          />,
+        ];
+        if (mediaIcons) {
+          mediaIcons.forEach((mediaIcon, idx) => {
+            toggleText.push(
+              <Icon
+                fixedWidth
+                className='status__content__spoiler-icon'
+                id={mediaIcon}
+                aria-hidden='true'
+                key={`icon-${idx}`}
+              />,
+            );
+          });
+        }
+      } else {
+        toggleText = (
+          <FormattedMessage
+            id='status.show_less'
+            defaultMessage='Show less'
+            key='0'
           />
-        ) : null,
-      ] : [
-        <FormattedMessage
-          id='status.show_less'
-          defaultMessage='Show less'
-          key='0'
-        />,
-      ];
+        );
+      }
 
       if (hidden) {
         mentionsPlaceholder = <div>{mentionLinks}</div>;
diff --git a/app/javascript/flavours/glitch/components/status_header.js b/app/javascript/flavours/glitch/components/status_header.js
index 06296e124..cc476139b 100644
--- a/app/javascript/flavours/glitch/components/status_header.js
+++ b/app/javascript/flavours/glitch/components/status_header.js
@@ -19,14 +19,14 @@ export default class StatusHeader extends React.PureComponent {
   };
 
   //  Handles clicks on account name/image
-  handleClick = (id, e) => {
+  handleClick = (acct, e) => {
     const { parseClick } = this.props;
-    parseClick(e, `/accounts/${id}`);
+    parseClick(e, `/@${acct}`);
   }
 
   handleAccountClick = (e) => {
     const { status } = this.props;
-    this.handleClick(status.getIn(['account', 'id']), e);
+    this.handleClick(status.getIn(['account', 'acct']), e);
   }
 
   //  Rendering.
diff --git a/app/javascript/flavours/glitch/components/status_icons.js b/app/javascript/flavours/glitch/components/status_icons.js
index f4d0a7405..e66947f4a 100644
--- a/app/javascript/flavours/glitch/components/status_icons.js
+++ b/app/javascript/flavours/glitch/components/status_icons.js
@@ -27,7 +27,7 @@ class StatusIcons extends React.PureComponent {
 
   static propTypes = {
     status: ImmutablePropTypes.map.isRequired,
-    mediaIcon: PropTypes.string,
+    mediaIcons: PropTypes.arrayOf(PropTypes.string),
     collapsible: PropTypes.bool,
     collapsed: PropTypes.bool,
     directMessage: PropTypes.bool,
@@ -44,8 +44,8 @@ class StatusIcons extends React.PureComponent {
     }
   }
 
-  mediaIconTitleText () {
-    const { intl, mediaIcon } = this.props;
+  mediaIconTitleText (mediaIcon) {
+    const { intl } = this.props;
 
     switch (mediaIcon) {
       case 'link':
@@ -61,11 +61,24 @@ class StatusIcons extends React.PureComponent {
     }
   }
 
+  renderIcon (mediaIcon) {
+    return (
+      <Icon
+        fixedWidth
+        className='status__media-icon'
+        key={`media-icon--${mediaIcon}`}
+        id={mediaIcon}
+        aria-hidden='true'
+        title={this.mediaIconTitleText(mediaIcon)}
+      />
+    );
+  }
+
   //  Rendering.
   render () {
     const {
       status,
-      mediaIcon,
+      mediaIcons,
       collapsible,
       collapsed,
       directMessage,
@@ -90,15 +103,7 @@ class StatusIcons extends React.PureComponent {
             aria-hidden='true'
             title={intl.formatMessage(messages.localOnly)}
           />}
-        {mediaIcon ? (
-          <Icon
-            fixedWidth
-            className='status__media-icon'
-            id={mediaIcon}
-            aria-hidden='true'
-            title={this.mediaIconTitleText()}
-          />
-        ) : null}
+        { !!mediaIcons && mediaIcons.map(icon => this.renderIcon(icon)) }
         {!directMessage && <VisibilityIcon visibility={status.get('visibility')} />}
         {collapsible ? (
           <IconButton
diff --git a/app/javascript/flavours/glitch/components/status_list.js b/app/javascript/flavours/glitch/components/status_list.js
index 60cc23f4b..9095e087e 100644
--- a/app/javascript/flavours/glitch/components/status_list.js
+++ b/app/javascript/flavours/glitch/components/status_list.js
@@ -18,7 +18,6 @@ export default class StatusList extends ImmutablePureComponent {
     onScrollToTop: PropTypes.func,
     onScroll: PropTypes.func,
     trackScroll: PropTypes.bool,
-    shouldUpdateScroll: PropTypes.func,
     isLoading: PropTypes.bool,
     isPartial: PropTypes.bool,
     hasMore: PropTypes.bool,
diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js
index af6acdef9..5a00f232e 100644
--- a/app/javascript/flavours/glitch/components/status_prepend.js
+++ b/app/javascript/flavours/glitch/components/status_prepend.js
@@ -17,7 +17,7 @@ export default class StatusPrepend extends React.PureComponent {
 
   handleClick = (e) => {
     const { account, parseClick } = this.props;
-    parseClick(e, `/accounts/${account.get('id')}`);
+    parseClick(e, `/@${account.get('acct')}`);
   }
 
   Message = () => {
diff --git a/app/javascript/flavours/glitch/containers/admin_component.js b/app/javascript/flavours/glitch/containers/admin_component.js
new file mode 100644
index 000000000..64dabac8b
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/admin_component.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+export default class AdminComponent extends React.PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+    children: PropTypes.node.isRequired,
+  };
+
+  render () {
+    const { locale, children } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        {children}
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js
index bcdd9b54e..de8ea8ee2 100644
--- a/app/javascript/flavours/glitch/containers/mastodon.js
+++ b/app/javascript/flavours/glitch/containers/mastodon.js
@@ -23,14 +23,38 @@ store.dispatch(hydrateAction);
 // load custom emojis
 store.dispatch(fetchCustomEmojis());
 
+const createIdentityContext = state => ({
+  signedIn: !!state.meta.me,
+  accountId: state.meta.me,
+  accessToken: state.meta.access_token,
+});
+
 export default class Mastodon extends React.PureComponent {
 
   static propTypes = {
     locale: PropTypes.string.isRequired,
   };
 
+  static childContextTypes = {
+    identity: PropTypes.shape({
+      signedIn: PropTypes.bool.isRequired,
+      accountId: PropTypes.string,
+      accessToken: PropTypes.string,
+    }).isRequired,
+  };
+
+  identity = createIdentityContext(initialState);
+
+  getChildContext() {
+    return {
+      identity: this.identity,
+    };
+  }
+
   componentDidMount() {
-    this.disconnect = store.dispatch(connectUserStream());
+    if (this.identity.signedIn) {
+      this.disconnect = store.dispatch(connectUserStream());
+    }
   }
 
   componentWillUnmount () {
@@ -41,7 +65,7 @@ export default class Mastodon extends React.PureComponent {
   }
 
   shouldUpdateScroll (_, { location }) {
-    return !(location.state && location.state.mastodonModalOpen);
+    return !(location.state?.mastodonModalKey);
   }
 
   render () {
diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js
index 8657b8064..1ddbc706b 100644
--- a/app/javascript/flavours/glitch/containers/media_container.js
+++ b/app/javascript/flavours/glitch/containers/media_container.js
@@ -7,7 +7,7 @@ import { getLocale } from 'mastodon/locales';
 import { getScrollbarWidth } from 'flavours/glitch/util/scrollbar';
 import MediaGallery from 'flavours/glitch/components/media_gallery';
 import Poll from 'flavours/glitch/components/poll';
-import Hashtag from 'flavours/glitch/components/hashtag';
+import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
 import ModalRoot from 'flavours/glitch/components/modal_root';
 import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
 import Video from 'flavours/glitch/features/video';
diff --git a/app/javascript/flavours/glitch/containers/scroll_container.js b/app/javascript/flavours/glitch/containers/scroll_container.js
new file mode 100644
index 000000000..d21ff6368
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/scroll_container.js
@@ -0,0 +1,18 @@
+import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4';
+
+// ScrollContainer is used to automatically scroll to the top when pushing a
+// new history state and remembering the scroll position when going back.
+// There are a few things we need to do differently, though.
+const defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
+  // If the change is caused by opening a modal, do not scroll to top
+  return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
+};
+
+export default
+class ScrollContainer extends OriginalScrollContainer {
+
+  static defaultProps = {
+    shouldUpdateScroll: defaultShouldUpdateScroll,
+  };
+
+}
diff --git a/app/javascript/flavours/glitch/features/account/components/action_bar.js b/app/javascript/flavours/glitch/features/account/components/action_bar.js
index 2d4cc7f49..23120d57e 100644
--- a/app/javascript/flavours/glitch/features/account/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js
@@ -62,17 +62,17 @@ class ActionBar extends React.PureComponent {
 
         <div className='account__action-bar'>
           <div className='account__action-bar-links'>
-            <NavLink isActive={this.isStatusesPageActive} activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
+            <NavLink isActive={this.isStatusesPageActive} activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}`}>
               <FormattedMessage id='account.posts' defaultMessage='Posts' />
               <strong><FormattedNumber value={account.get('statuses_count')} /></strong>
             </NavLink>
 
-            <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
+            <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}/following`}>
               <FormattedMessage id='account.follows' defaultMessage='Follows' />
               <strong><FormattedNumber value={account.get('following_count')} /></strong>
             </NavLink>
 
-            <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
+            <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}/followers`}>
               <FormattedMessage id='account.followers' defaultMessage='Followers' />
               <strong>{ account.get('followers_count') < 0 ? '-' : <FormattedNumber value={account.get('followers_count')} /> }</strong>
             </NavLink>
diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js
index 2a43d1ed2..8df1bf4ca 100644
--- a/app/javascript/flavours/glitch/features/account_gallery/index.js
+++ b/app/javascript/flavours/glitch/features/account_gallery/index.js
@@ -2,7 +2,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import { fetchAccount } from 'flavours/glitch/actions/accounts';
+import { lookupAccount, fetchAccount } from 'flavours/glitch/actions/accounts';
 import { expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines';
 import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
 import Column from 'flavours/glitch/features/ui/components/column';
@@ -11,18 +11,29 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import { getAccountGallery } from 'flavours/glitch/selectors';
 import MediaItem from './components/media_item';
 import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container';
-import { ScrollContainer } from 'react-router-scroll-4';
+import ScrollContainer from 'flavours/glitch/containers/scroll_container';
 import LoadMore from 'flavours/glitch/components/load_more';
 import MissingIndicator from 'flavours/glitch/components/missing_indicator';
 import { openModal } from 'flavours/glitch/actions/modal';
 
-const mapStateToProps = (state, props) => ({
-  isAccount: !!state.getIn(['accounts', props.params.accountId]),
-  attachments: getAccountGallery(state, props.params.accountId),
-  isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
-  hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
-  suspended: state.getIn(['accounts', props.params.accountId, 'suspended'], false),
-});
+const mapStateToProps = (state, { params: { acct, id } }) => {
+  const accountId = id || state.getIn(['accounts_map', acct]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
+  return {
+    accountId,
+    isAccount: !!state.getIn(['accounts', accountId]),
+    attachments: getAccountGallery(state, accountId),
+    isLoading: state.getIn(['timelines', `account:${accountId}:media`, 'isLoading']),
+    hasMore: state.getIn(['timelines', `account:${accountId}:media`, 'hasMore']),
+    suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+  };
+};
 
 class LoadMoreMedia extends ImmutablePureComponent {
 
@@ -50,7 +61,11 @@ export default @connect(mapStateToProps)
 class AccountGallery extends ImmutablePureComponent {
 
   static propTypes = {
-    params: PropTypes.object.isRequired,
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
     attachments: ImmutablePropTypes.list.isRequired,
     isLoading: PropTypes.bool,
@@ -64,15 +79,30 @@ class AccountGallery extends ImmutablePureComponent {
     width: 323,
   };
 
+  _load () {
+    const { accountId, isAccount, dispatch } = this.props;
+
+    if (!isAccount) dispatch(fetchAccount(accountId));
+    dispatch(expandAccountMediaTimeline(accountId));
+  }
+
   componentDidMount () {
-    this.props.dispatch(fetchAccount(this.props.params.accountId));
-    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
+    }
   }
 
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(nextProps.params.accountId));
-      this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
     }
   }
 
@@ -96,7 +126,7 @@ class AccountGallery extends ImmutablePureComponent {
   }
 
   handleLoadMore = maxId => {
-    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
+    this.props.dispatch(expandAccountMediaTimeline(this.props.accountId, { maxId }));
   };
 
   handleLoadOlder = e => {
@@ -104,11 +134,6 @@ class AccountGallery extends ImmutablePureComponent {
     this.handleScrollToBottom();
   }
 
-  shouldUpdateScroll = (prevRouterProps, { location }) => {
-    if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false;
-    return !(location.state && location.state.mastodonModalOpen);
-  }
-
   setColumnRef = c => {
     this.column = c;
   }
@@ -165,9 +190,9 @@ class AccountGallery extends ImmutablePureComponent {
       <Column ref={this.setColumnRef}>
         <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
 
-        <ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}>
+        <ScrollContainer scrollKey='account_gallery'>
           <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
-            <HeaderContainer accountId={this.props.params.accountId} />
+            <HeaderContainer accountId={this.props.accountId} />
 
             {suspended ? (
               <div className='empty-column-indicator'>
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.js b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
index a6b57d331..e70f011b7 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/components/header.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
@@ -128,9 +128,9 @@ export default class Header extends ImmutablePureComponent {
 
         {!hideTabs && (
           <div className='account__section-headline'>
-            <NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink>
-            <NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink>
-            <NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
+            <NavLink exact to={`/@${account.get('acct')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink>
+            <NavLink exact to={`/@${account.get('acct')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink>
+            <NavLink exact to={`/@${account.get('acct')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
           </div>
         )}
       </div>
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js
index fcaa7b494..308407e94 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js
@@ -23,7 +23,7 @@ export default class MovedNote extends ImmutablePureComponent {
       e.preventDefault();
       let state = {...this.context.router.history.location.state};
       state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-      this.context.router.history.push(`/accounts/${this.props.to.get('id')}`, state);
+      this.context.router.history.push(`/@${this.props.to.get('acct')}`, state);
     }
 
     e.stopPropagation();
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js
index 0d24980a9..776687486 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.js
@@ -2,7 +2,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import { fetchAccount } from 'flavours/glitch/actions/accounts';
+import { lookupAccount, fetchAccount } from 'flavours/glitch/actions/accounts';
 import { expandAccountFeaturedTimeline, expandAccountTimeline } from 'flavours/glitch/actions/timelines';
 import StatusList from '../../components/status_list';
 import LoadingIndicator from '../../components/loading_indicator';
@@ -19,10 +19,19 @@ import TimelineHint from 'flavours/glitch/components/timeline_hint';
 
 const emptyList = ImmutableList();
 
-const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => {
+const mapStateToProps = (state, { params: { acct, id }, withReplies = false }) => {
+  const accountId = id || state.getIn(['accounts_map', acct]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
   const path = withReplies ? `${accountId}:with_replies` : accountId;
 
   return {
+    accountId,
     remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
     remoteUrl: state.getIn(['accounts', accountId, 'url']),
     isAccount: !!state.getIn(['accounts', accountId]),
@@ -46,7 +55,11 @@ export default @connect(mapStateToProps)
 class AccountTimeline extends ImmutablePureComponent {
 
   static propTypes = {
-    params: PropTypes.object.isRequired,
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
     statusIds: ImmutablePropTypes.list,
     featuredStatusIds: ImmutablePropTypes.list,
@@ -60,25 +73,47 @@ class AccountTimeline extends ImmutablePureComponent {
     multiColumn: PropTypes.bool,
   };
 
-  componentWillMount () {
-    const { params: { accountId }, withReplies } = this.props;
+  _load () {
+    const { accountId, withReplies, dispatch } = this.props;
 
-    this.props.dispatch(fetchAccount(accountId));
-    this.props.dispatch(fetchAccountIdentityProofs(accountId));
+    dispatch(fetchAccount(accountId));
+    dispatch(fetchAccountIdentityProofs(accountId));
     if (!withReplies) {
-      this.props.dispatch(expandAccountFeaturedTimeline(accountId));
+      dispatch(expandAccountFeaturedTimeline(accountId));
+    }
+    dispatch(expandAccountTimeline(accountId, { withReplies }));
+  }
+
+  componentDidMount () {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
     }
-    this.props.dispatch(expandAccountTimeline(accountId, { withReplies }));
   }
 
   componentWillReceiveProps (nextProps) {
+    const { dispatch } = this.props;
+
     if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {
-      this.props.dispatch(fetchAccount(nextProps.params.accountId));
-      this.props.dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
+      dispatch(fetchAccount(nextProps.params.accountId));
+      dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
       if (!nextProps.withReplies) {
-        this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
+        dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
       }
-      this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
+      dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
     }
   }
 
@@ -87,7 +122,7 @@ class AccountTimeline extends ImmutablePureComponent {
   }
 
   handleLoadMore = maxId => {
-    this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies }));
+    this.props.dispatch(expandAccountTimeline(this.props.accountId, { maxId, withReplies: this.props.withReplies }));
   }
 
   setRef = c => {
@@ -131,7 +166,7 @@ class AccountTimeline extends ImmutablePureComponent {
         <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
 
         <StatusList
-          prepend={<HeaderContainer accountId={this.props.params.accountId} />}
+          prepend={<HeaderContainer accountId={this.props.accountId} />}
           alwaysPrepend
           append={remoteMessage}
           scrollKey='account_timeline'
diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
index d4804a3c2..5dfc119c1 100644
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
@@ -58,6 +58,7 @@ class ComposeForm extends ImmutablePureComponent {
     onPickEmoji: PropTypes.func,
     showSearch: PropTypes.bool,
     anyMedia: PropTypes.bool,
+    isInReply: PropTypes.bool,
     singleColumn: PropTypes.bool,
 
     advancedOptions: ImmutablePropTypes.map,
@@ -233,7 +234,7 @@ class ComposeForm extends ImmutablePureComponent {
     //  Caret/selection handling.
     if (focusDate !== prevProps.focusDate) {
       switch (true) {
-      case preselectDate !== prevProps.preselectDate && preselectOnReply:
+      case preselectDate !== prevProps.preselectDate && this.props.isInReply && preselectOnReply:
         selectionStart = text.search(/\s/) + 1;
         selectionEnd = text.length;
         break;
diff --git a/app/javascript/flavours/glitch/features/compose/components/header.js b/app/javascript/flavours/glitch/features/compose/components/header.js
index 5b456b717..c6e4cc36b 100644
--- a/app/javascript/flavours/glitch/features/compose/components/header.js
+++ b/app/javascript/flavours/glitch/features/compose/components/header.js
@@ -87,7 +87,7 @@ class Header extends ImmutablePureComponent {
           <Link
             aria-label={intl.formatMessage(messages.home_timeline)}
             title={intl.formatMessage(messages.home_timeline)}
-            to='/timelines/home'
+            to='/home'
           ><Icon id='home' /></Link>
         ))}
         {renderForColumn('NOTIFICATIONS', (
@@ -106,14 +106,14 @@ class Header extends ImmutablePureComponent {
           <Link
             aria-label={intl.formatMessage(messages.community)}
             title={intl.formatMessage(messages.community)}
-            to='/timelines/public/local'
+            to='/public/local'
           ><Icon id='users' /></Link>
         ))}
         {renderForColumn('PUBLIC', (
           <Link
             aria-label={intl.formatMessage(messages.public)}
             title={intl.formatMessage(messages.public)}
-            to='/timelines/public'
+            to='/public'
           ><Icon id='globe' /></Link>
         ))}
         <a
diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
index f6bfbdd1e..595ca5512 100644
--- a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
+++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
@@ -15,13 +15,13 @@ export default class NavigationBar extends ImmutablePureComponent {
   render () {
     return (
       <div className='drawer--account'>
-        <Permalink className='avatar' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+        <Permalink className='avatar' href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
           <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
           <Avatar account={this.props.account} size={48} />
         </Permalink>
 
         <div className='navigation-bar__profile'>
-          <Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+          <Permalink className='acct' href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
             <strong>@{this.props.account.get('acct')}</strong>
           </Permalink>
 
diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/compose/components/search_results.js
index a0f86a06a..cbc1f35e5 100644
--- a/app/javascript/flavours/glitch/features/compose/components/search_results.js
+++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js
@@ -5,7 +5,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import AccountContainer from 'flavours/glitch/containers/account_container';
 import StatusContainer from 'flavours/glitch/containers/status_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import Hashtag from 'flavours/glitch/components/hashtag';
+import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
 import Icon from 'flavours/glitch/components/icon';
 import { searchEnabled } from 'flavours/glitch/util/initial_state';
 import LoadMore from 'flavours/glitch/components/load_more';
@@ -71,7 +71,7 @@ class SearchResults extends ImmutablePureComponent {
       );
     } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
       statuses = (
-        <section>
+        <section className='search-results__section'>
           <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
 
           <div className='search-results__info'>
@@ -87,7 +87,7 @@ class SearchResults extends ImmutablePureComponent {
     if (results.get('accounts') && results.get('accounts').size > 0) {
       count   += results.get('accounts').size;
       accounts = (
-        <section>
+        <section className='search-results__section'>
           <h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
 
           {results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)}
@@ -100,7 +100,7 @@ class SearchResults extends ImmutablePureComponent {
     if (results.get('statuses') && results.get('statuses').size > 0) {
       count   += results.get('statuses').size;
       statuses = (
-        <section>
+        <section className='search-results__section'>
           <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
 
           {results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId}/>)}
@@ -113,7 +113,7 @@ class SearchResults extends ImmutablePureComponent {
     if (results.get('hashtags') && results.get('hashtags').size > 0) {
       count += results.get('hashtags').size;
       hashtags = (
-        <section>
+        <section className='search-results__section'>
           <h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
 
           {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
@@ -131,11 +131,9 @@ class SearchResults extends ImmutablePureComponent {
           <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
         </header>
 
-        <div className='search-results__contents'>
-          {accounts}
-          {statuses}
-          {hashtags}
-        </div>
+        {accounts}
+        {statuses}
+        {hashtags}
       </div>
     );
   };
diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
index fcd2caf1b..8eff8a36b 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
@@ -68,6 +68,7 @@ function mapStateToProps (state) {
     spoilersAlwaysOn: spoilersAlwaysOn,
     mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
     preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
+    isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
   };
 };
 
diff --git a/app/javascript/flavours/glitch/features/compose/containers/header_container.js b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
index b4dcb4d56..2f0da48c8 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/header_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
@@ -27,6 +27,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(openModal('CONFIRM', {
       message: intl.formatMessage(messages.logoutMessage),
       confirm: intl.formatMessage(messages.logoutConfirm),
+      closeWhenConfirm: false,
       onConfirm: () => logOut(),
     }));
   },
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
index f687fae99..f3ca4ce7b 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
@@ -1,7 +1,6 @@
 import { connect } from 'react-redux';
 import Upload from '../components/upload';
-import { undoUploadCompose } from 'flavours/glitch/actions/compose';
-import { openModal } from 'flavours/glitch/actions/modal';
+import { undoUploadCompose, initMediaEditModal } from 'flavours/glitch/actions/compose';
 import { submitCompose } from 'flavours/glitch/actions/compose';
 
 const mapStateToProps = (state, { id }) => ({
@@ -15,7 +14,7 @@ const mapDispatchToProps = dispatch => ({
   },
 
   onOpenFocalPoint: id => {
-    dispatch(openModal('FOCAL_POINT', { id }));
+    dispatch(initMediaEditModal(id));
   },
 
   onSubmit (router) {
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js
index 98b48cd90..202d96676 100644
--- a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js
+++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js
@@ -104,7 +104,7 @@ class Conversation extends ImmutablePureComponent {
       markRead();
     }
 
-    this.context.router.history.push(`/statuses/${lastStatus.get('id')}`);
+    this.context.router.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
   }
 
   handleMarkAsRead = () => {
@@ -163,7 +163,7 @@ class Conversation extends ImmutablePureComponent {
 
     menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
 
-    const names = accounts.map(a => <Permalink to={`/accounts/${a.get('id')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
+    const names = accounts.map(a => <Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
 
     const handlers = {
       reply: this.handleReply,
diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.js b/app/javascript/flavours/glitch/features/directory/components/account_card.js
index 9fe84c10b..2a3fd1ecf 100644
--- a/app/javascript/flavours/glitch/features/directory/components/account_card.js
+++ b/app/javascript/flavours/glitch/features/directory/components/account_card.js
@@ -213,7 +213,7 @@ class AccountCard extends ImmutablePureComponent {
           <Permalink
             className='directory__card__bar__name'
             href={account.get('url')}
-            to={`/accounts/${account.get('id')}`}
+            to={`/@${account.get('acct')}`}
           >
             <Avatar account={account} size={48} />
             <DisplayName account={account} />
diff --git a/app/javascript/flavours/glitch/features/directory/index.js b/app/javascript/flavours/glitch/features/directory/index.js
index 858a8fa55..cde5926e0 100644
--- a/app/javascript/flavours/glitch/features/directory/index.js
+++ b/app/javascript/flavours/glitch/features/directory/index.js
@@ -12,7 +12,7 @@ import AccountCard from './components/account_card';
 import RadioButton from 'flavours/glitch/components/radio_button';
 import classNames from 'classnames';
 import LoadMore from 'flavours/glitch/components/load_more';
-import { ScrollContainer } from 'react-router-scroll-4';
+import ScrollContainer from 'flavours/glitch/containers/scroll_container';
 
 const messages = defineMessages({
   title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
@@ -40,7 +40,6 @@ class Directory extends React.PureComponent {
     isLoading: PropTypes.bool,
     accountIds: ImmutablePropTypes.list.isRequired,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     columnId: PropTypes.string,
     intl: PropTypes.object.isRequired,
     multiColumn: PropTypes.bool,
@@ -125,7 +124,7 @@ class Directory extends React.PureComponent {
   }
 
   render () {
-    const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props;
+    const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
     const { order, local }  = this.getParams(this.props, this.state);
     const pinned = !!columnId;
 
@@ -163,7 +162,7 @@ class Directory extends React.PureComponent {
           multiColumn={multiColumn}
         />
 
-        {multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea}
+        {multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
       </Column>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/follow_recommendations/components/account.js b/app/javascript/flavours/glitch/features/follow_recommendations/components/account.js
index 046d03a9b..2c668da3e 100644
--- a/app/javascript/flavours/glitch/features/follow_recommendations/components/account.js
+++ b/app/javascript/flavours/glitch/features/follow_recommendations/components/account.js
@@ -66,7 +66,7 @@ class Account extends ImmutablePureComponent {
     return (
       <div className='account follow-recommendations-account'>
         <div className='account__wrapper'>
-          <Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+          <Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
 
             <DisplayName account={account} />
diff --git a/app/javascript/flavours/glitch/features/follow_recommendations/index.js b/app/javascript/flavours/glitch/features/follow_recommendations/index.js
index fee4d8757..d050e3cc7 100644
--- a/app/javascript/flavours/glitch/features/follow_recommendations/index.js
+++ b/app/javascript/flavours/glitch/features/follow_recommendations/index.js
@@ -68,7 +68,7 @@ class FollowRecommendations extends ImmutablePureComponent {
       }
     }));
 
-    router.history.push('/timelines/home');
+    router.history.push('/home');
   }
 
   render () {
diff --git a/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js
index eb9f3db7e..cbe7a1032 100644
--- a/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js
+++ b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js
@@ -30,7 +30,7 @@ class AccountAuthorize extends ImmutablePureComponent {
     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'>
+          <Permalink href={account.get('url')} to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
             <div className='account-authorize__avatar'><Avatar account={account} size={48} /></div>
             <DisplayName account={account} />
           </Permalink>
diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js
index 25f7f05b4..978436dcc 100644
--- a/app/javascript/flavours/glitch/features/followers/index.js
+++ b/app/javascript/flavours/glitch/features/followers/index.js
@@ -5,6 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import { debounce } from 'lodash';
 import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
 import {
+  lookupAccount,
   fetchAccount,
   fetchFollowers,
   expandFollowers,
@@ -19,14 +20,25 @@ import MissingIndicator from 'flavours/glitch/components/missing_indicator';
 import ScrollableList from 'flavours/glitch/components/scrollable_list';
 import TimelineHint from 'flavours/glitch/components/timeline_hint';
 
-const mapStateToProps = (state, props) => ({
-  remote: !!(state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username'])),
-  remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
-  isAccount: !!state.getIn(['accounts', props.params.accountId]),
-  accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
-  hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
-  isLoading: state.getIn(['user_lists', 'followers', props.params.accountId, 'isLoading'], true),
-});
+const mapStateToProps = (state, { params: { acct, id } }) => {
+  const accountId = id || state.getIn(['accounts_map', acct]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
+  return {
+    accountId,
+    remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
+    remoteUrl: state.getIn(['accounts', accountId, 'url']),
+    isAccount: !!state.getIn(['accounts', accountId]),
+    accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']),
+    hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
+    isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
+  };
+};
 
 const RemoteHint = ({ url }) => (
   <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} />
@@ -40,7 +52,11 @@ export default @connect(mapStateToProps)
 class Followers extends ImmutablePureComponent {
 
   static propTypes = {
-    params: PropTypes.object.isRequired,
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
     accountIds: ImmutablePropTypes.list,
     hasMore: PropTypes.bool,
@@ -51,32 +67,45 @@ class Followers extends ImmutablePureComponent {
     multiColumn: PropTypes.bool,
   };
 
-  componentWillMount () {
-    if (!this.props.accountIds) {
-      this.props.dispatch(fetchAccount(this.props.params.accountId));
-      this.props.dispatch(fetchFollowers(this.props.params.accountId));
-    }
+  _load () {
+    const { accountId, isAccount, dispatch } = this.props;
+
+    if (!isAccount) dispatch(fetchAccount(accountId));
+    dispatch(fetchFollowers(accountId));
   }
 
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(nextProps.params.accountId));
-      this.props.dispatch(fetchFollowers(nextProps.params.accountId));
+  componentDidMount () {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
     }
   }
 
-  handleHeaderClick = () => {
-    this.column.scrollTop();
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
+    }
   }
 
   handleLoadMore = debounce(() => {
-    this.props.dispatch(expandFollowers(this.props.params.accountId));
+    this.props.dispatch(expandFollowers(this.props.accountId));
   }, 300, { leading: true });
 
   setRef = c => {
     this.column = c;
   }
 
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
   render () {
     const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
 
@@ -115,7 +144,7 @@ class Followers extends ImmutablePureComponent {
           hasMore={hasMore}
           isLoading={isLoading}
           onLoadMore={this.handleLoadMore}
-          prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
+          prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
           alwaysPrepend
           append={remoteMessage}
           emptyMessage={emptyMessage}
diff --git a/app/javascript/flavours/glitch/features/following/index.js b/app/javascript/flavours/glitch/features/following/index.js
index 968829fd5..446a19894 100644
--- a/app/javascript/flavours/glitch/features/following/index.js
+++ b/app/javascript/flavours/glitch/features/following/index.js
@@ -5,6 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import { debounce } from 'lodash';
 import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
 import {
+  lookupAccount,
   fetchAccount,
   fetchFollowing,
   expandFollowing,
@@ -19,14 +20,25 @@ import MissingIndicator from 'flavours/glitch/components/missing_indicator';
 import ScrollableList from 'flavours/glitch/components/scrollable_list';
 import TimelineHint from 'flavours/glitch/components/timeline_hint';
 
-const mapStateToProps = (state, props) => ({
-  remote: !!(state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username'])),
-  remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
-  isAccount: !!state.getIn(['accounts', props.params.accountId]),
-  accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
-  hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
-  isLoading: state.getIn(['user_lists', 'following', props.params.accountId, 'isLoading'], true),
-});
+const mapStateToProps = (state, { params: { acct, id } }) => {
+  const accountId = id || state.getIn(['accounts_map', acct]);
+
+  if (!accountId) {
+    return {
+      isLoading: true,
+    };
+  }
+
+  return {
+    accountId,
+    remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
+    remoteUrl: state.getIn(['accounts', accountId, 'url']),
+    isAccount: !!state.getIn(['accounts', accountId]),
+    accountIds: state.getIn(['user_lists', 'following', accountId, 'items']),
+    hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
+    isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
+  };
+};
 
 const RemoteHint = ({ url }) => (
   <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} />
@@ -40,7 +52,11 @@ export default @connect(mapStateToProps)
 class Following extends ImmutablePureComponent {
 
   static propTypes = {
-    params: PropTypes.object.isRequired,
+    params: PropTypes.shape({
+      acct: PropTypes.string,
+      id: PropTypes.string,
+    }).isRequired,
+    accountId: PropTypes.string,
     dispatch: PropTypes.func.isRequired,
     accountIds: ImmutablePropTypes.list,
     hasMore: PropTypes.bool,
@@ -51,32 +67,45 @@ class Following extends ImmutablePureComponent {
     multiColumn: PropTypes.bool,
   };
 
-  componentWillMount () {
-    if (!this.props.accountIds) {
-      this.props.dispatch(fetchAccount(this.props.params.accountId));
-      this.props.dispatch(fetchFollowing(this.props.params.accountId));
-    }
+  _load () {
+    const { accountId, isAccount, dispatch } = this.props;
+
+    if (!isAccount) dispatch(fetchAccount(accountId));
+    dispatch(fetchFollowing(accountId));
   }
 
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(nextProps.params.accountId));
-      this.props.dispatch(fetchFollowing(nextProps.params.accountId));
+  componentDidMount () {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (accountId) {
+      this._load();
+    } else {
+      dispatch(lookupAccount(acct));
     }
   }
 
-  handleHeaderClick = () => {
-    this.column.scrollTop();
+  componentDidUpdate (prevProps) {
+    const { params: { acct }, accountId, dispatch } = this.props;
+
+    if (prevProps.accountId !== accountId && accountId) {
+      this._load();
+    } else if (prevProps.params.acct !== acct) {
+      dispatch(lookupAccount(acct));
+    }
   }
 
   handleLoadMore = debounce(() => {
-    this.props.dispatch(expandFollowing(this.props.params.accountId));
+    this.props.dispatch(expandFollowing(this.props.accountId));
   }, 300, { leading: true });
 
   setRef = c => {
     this.column = c;
   }
 
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
   render () {
     const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
 
@@ -115,7 +144,7 @@ class Following extends ImmutablePureComponent {
           hasMore={hasMore}
           isLoading={isLoading}
           onLoadMore={this.handleLoadMore}
-          prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
+          prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
           alwaysPrepend
           append={remoteMessage}
           emptyMessage={emptyMessage}
diff --git a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
index 4eebe83ef..2f6d2de5c 100644
--- a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
+++ b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
@@ -87,7 +87,7 @@ class Content extends ImmutablePureComponent {
   onMentionClick = (mention, e) => {
     if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
-      this.context.router.history.push(`/accounts/${mention.get('id')}`);
+      this.context.router.history.push(`/@${mention.get('acct')}`);
     }
   }
 
@@ -96,14 +96,14 @@ class Content extends ImmutablePureComponent {
 
     if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
-      this.context.router.history.push(`/timelines/tag/${hashtag}`);
+      this.context.router.history.push(`/tags/${hashtag}`);
     }
   }
 
   onStatusClick = (status, e) => {
     if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       e.preventDefault();
-      this.context.router.history.push(`/statuses/${status.get('id')}`);
+      this.context.router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
     }
   }
 
diff --git a/app/javascript/flavours/glitch/features/getting_started/components/trends.js b/app/javascript/flavours/glitch/features/getting_started/components/trends.js
index c60f78f7e..5158f6689 100644
--- a/app/javascript/flavours/glitch/features/getting_started/components/trends.js
+++ b/app/javascript/flavours/glitch/features/getting_started/components/trends.js
@@ -2,7 +2,7 @@ import React from 'react';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import Hashtag from 'flavours/glitch/components/hashtag';
+import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
 import { FormattedMessage } from 'react-intl';
 
 export default class Trends extends ImmutablePureComponent {
diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js
index b4549fdf8..56750fd88 100644
--- a/app/javascript/flavours/glitch/features/getting_started/index.js
+++ b/app/javascript/flavours/glitch/features/getting_started/index.js
@@ -107,7 +107,7 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
     const { fetchFollowRequests, multiColumn } = this.props;
 
     if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) {
-      this.context.router.history.replace('/timelines/home');
+      this.context.router.history.replace('/home');
       return;
     }
 
@@ -122,7 +122,7 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
 
     if (multiColumn) {
       if (!columns.find(item => item.get('id') === 'HOME')) {
-        navItems.push(<ColumnLink key='0' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/timelines/home' />);
+        navItems.push(<ColumnLink key='0' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/home' />);
       }
 
       if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) {
@@ -130,16 +130,16 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
       }
 
       if (!columns.find(item => item.get('id') === 'COMMUNITY')) {
-        navItems.push(<ColumnLink key='2' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />);
+        navItems.push(<ColumnLink key='2' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/public/local' />);
       }
 
       if (!columns.find(item => item.get('id') === 'PUBLIC')) {
-        navItems.push(<ColumnLink key='3' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />);
+        navItems.push(<ColumnLink key='3' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/public' />);
       }
     }
 
     if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
-      navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />);
+      navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/conversations' />);
     }
 
     if (!multiColumn || !columns.find(item => item.get('id') === 'BOOKMARKS')) {
@@ -160,7 +160,7 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
       <div key='9'>
         <ColumnLink key='10' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
         {lists.filter(list => !columns.find(item => item.get('id') === 'LIST' && item.getIn(['params', 'id']) === list.get('id'))).map(list =>
-          <ColumnLink key={(11 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
+          <ColumnLink key={(11 + Number(list.get('id'))).toString()} to={`/lists/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
         )}
       </div>,
     ]);
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js
index de1127b0d..142118cef 100644
--- a/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js
@@ -33,8 +33,8 @@ class ColumnSettings extends React.PureComponent {
   tags (mode) {
     let tags = this.props.settings.getIn(['tags', mode]) || [];
 
-    if (tags.toJSON) {
-      return tags.toJSON();
+    if (tags.toJS) {
+      return tags.toJS();
     } else {
       return tags;
     }
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
index de1db692d..1cf527573 100644
--- a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
@@ -11,21 +11,22 @@ const mapStateToProps = (state, { columnId }) => {
     return {};
   }
 
-  return { settings: columns.get(index).get('params') };
+  return {
+    settings: columns.get(index).get('params'),
+    onLoad (value) {
+      return api(() => state).get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
+        return (response.data.hashtags || []).map((tag) => {
+          return { value: tag.name, label: `#${tag.name}` };
+        });
+      });
+    },
+  };
 };
 
 const mapDispatchToProps = (dispatch, { columnId }) => ({
   onChange (key, value) {
     dispatch(changeColumnParams(columnId, key, value));
   },
-
-  onLoad (value) {
-    return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
-      return (response.data.hashtags || []).map((tag) => {
-        return { value: tag.name, label: `#${tag.name}` };
-      });
-    });
-  },
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/lists/index.js b/app/javascript/flavours/glitch/features/lists/index.js
index e384f301b..b92389d82 100644
--- a/app/javascript/flavours/glitch/features/lists/index.js
+++ b/app/javascript/flavours/glitch/features/lists/index.js
@@ -73,7 +73,7 @@ class Lists extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {lists.map(list =>
-            <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />,
+            <ColumnLink key={list.get('id')} to={`/lists/${list.get('id')}`} icon='list-ul' text={list.get('title')} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
index c01a21e3b..95250c6ed 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
@@ -27,11 +27,12 @@ export default class ColumnSettings extends React.PureComponent {
   render () {
     const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission, onRequestNotificationPermission } = this.props;
 
-    const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
+    const unreadMarkersShowStr = <FormattedMessage id='notifications.column_settings.unread_notifications.highlight' defaultMessage='Highlight unread notifications' />;
+    const filterBarShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show_bar' defaultMessage='Show filter bar' />;
     const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
-    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' />;
+    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' />;
 
     const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
     const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
@@ -58,11 +59,11 @@ export default class ColumnSettings extends React.PureComponent {
 
         <div role='group' aria-labelledby='notifications-unread-markers'>
           <span id='notifications-unread-markers' className='column-settings__section'>
-            <FormattedMessage id='notifications.column_settings.unread_markers.category' defaultMessage='Unread notification markers' />
+            <FormattedMessage id='notifications.column_settings.unread_notifications.category' defaultMessage='Unread notifications' />
           </span>
 
           <div className='column-settings__row'>
-            <SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={filterShowStr} />
+            <SettingToggle id='unread-notification-markers' prefix='notifications' settings={settings} settingPath={['showUnread']} onChange={onChange} label={unreadMarkersShowStr} />
           </div>
         </div>
 
@@ -72,7 +73,7 @@ export default class ColumnSettings extends React.PureComponent {
           </span>
 
           <div className='column-settings__row'>
-            <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
+            <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterBarShowStr} />
             <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
           </div>
         </div>
diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.js b/app/javascript/flavours/glitch/features/notifications/components/follow.js
index 0d3162fc9..b8fad19d0 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/follow.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js
@@ -39,7 +39,7 @@ export default class NotificationFollow extends ImmutablePureComponent {
 
   handleOpenProfile = () => {
     const { notification } = this.props;
-    this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
+    this.context.router.history.push(`/@${notification.getIn(['account', 'acct'])}`);
   }
 
   handleMention = e => {
@@ -70,7 +70,7 @@ export default class NotificationFollow extends ImmutablePureComponent {
         className='notification__display-name'
         href={account.get('url')}
         title={account.get('acct')}
-        to={`/accounts/${account.get('id')}`}
+        to={`/@${account.get('acct')}`}
         dangerouslySetInnerHTML={{ __html: displayName }}
       /></bdi>
     );
diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow_request.js b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js
index f351c1035..69b92a06f 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/follow_request.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js
@@ -45,7 +45,7 @@ class FollowRequest extends ImmutablePureComponent {
 
   handleOpenProfile = () => {
     const { notification } = this.props;
-    this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
+    this.context.router.history.push(`/@${notification.getIn(['account', 'acct'])}`);
   }
 
   handleMention = e => {
@@ -89,7 +89,7 @@ class FollowRequest extends ImmutablePureComponent {
         className='notification__display-name'
         href={account.get('url')}
         title={account.get('acct')}
-        to={`/accounts/${account.get('id')}`}
+        to={`/@${account.get('acct')}`}
         dangerouslySetInnerHTML={{ __html: displayName }}
       /></bdi>
     );
@@ -111,7 +111,7 @@ class FollowRequest extends ImmutablePureComponent {
 
           <div className='account'>
             <div className='account__wrapper'>
-              <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+              <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>
                 <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
                 <DisplayName account={account} />
               </Permalink>
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 6fc951e37..075e729b1 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -99,7 +99,6 @@ class Notifications extends React.PureComponent {
     notifications: ImmutablePropTypes.list.isRequired,
     showFilterBar: PropTypes.bool.isRequired,
     dispatch: PropTypes.func.isRequired,
-    shouldUpdateScroll: PropTypes.func,
     intl: PropTypes.object.isRequired,
     isLoading: PropTypes.bool,
     isUnread: PropTypes.bool,
@@ -220,7 +219,7 @@ class Notifications extends React.PureComponent {
   }
 
   render () {
-    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
+    const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
     const { notifCleaning, notifCleaningActive } = this.props;
     const { animatingNCD } = this.state;
     const pinned = !!columnId;
@@ -273,7 +272,6 @@ class Notifications extends React.PureComponent {
         onLoadPending={this.handleLoadPending}
         onScrollToTop={this.handleScrollToTop}
         onScroll={this.handleScroll}
-        shouldUpdateScroll={shouldUpdateScroll}
         bindToDocument={!multiColumn}
       >
         {scrollableContent}
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js
index fcb2df527..e01d277a1 100644
--- a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js
@@ -116,9 +116,13 @@ class Footer extends ImmutablePureComponent {
       return;
     }
 
-    const { status } = this.props;
+    const { status, onClose } = this.props;
 
-    router.history.push(`/statuses/${status.get('id')}`);
+    if (onClose) {
+      onClose();
+    }
+
+    router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
   }
 
   render () {
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js
index 28526ca88..26f2da374 100644
--- a/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js
@@ -34,7 +34,7 @@ class Header extends ImmutablePureComponent {
 
     return (
       <div className='picture-in-picture__header'>
-        <Link to={`/statuses/${statusId}`} className='picture-in-picture__header__account'>
+        <Link to={`/@${account.get('acct')}/${statusId}`} className='picture-in-picture__header__account'>
           <Avatar account={account} size={36} />
           <DisplayName account={account} />
         </Link>
diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js
index 6ed5f3865..eb4583026 100644
--- a/app/javascript/flavours/glitch/features/status/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js
@@ -146,6 +146,7 @@ class ActionBar extends React.PureComponent {
     const { status, intl } = this.props;
 
     const publicStatus       = ['public', 'unlisted'].includes(status.get('visibility'));
+    const pinnableStatus     = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
     const mutingConversation = status.get('muted');
     const writtenByMe        = status.getIn(['account', 'id']) === me;
 
@@ -158,7 +159,7 @@ class ActionBar extends React.PureComponent {
     }
 
     if (writtenByMe) {
-      if (publicStatus) {
+      if (pinnableStatus) {
         menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
         menu.push(null);
       }
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index 4cc1d1af5..4b3a6aaaa 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -7,7 +7,7 @@ import StatusContent from 'flavours/glitch/components/status_content';
 import MediaGallery from 'flavours/glitch/components/media_gallery';
 import AttachmentList from 'flavours/glitch/components/attachment_list';
 import { Link } from 'react-router-dom';
-import { FormattedDate } from 'react-intl';
+import { injectIntl, FormattedDate, FormattedMessage } from 'react-intl';
 import Card from './card';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Video from 'flavours/glitch/features/video';
@@ -20,7 +20,8 @@ import Icon from 'flavours/glitch/components/icon';
 import AnimatedNumber from 'flavours/glitch/components/animated_number';
 import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
 
-export default class DetailedStatus extends ImmutablePureComponent {
+export default @injectIntl
+class DetailedStatus extends ImmutablePureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
@@ -40,6 +41,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
     showMedia: PropTypes.bool,
     usingPiP: PropTypes.bool,
     onToggleMediaVisibility: PropTypes.func,
+    intl: PropTypes.object.isRequired,
   };
 
   state = {
@@ -51,7 +53,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
       e.preventDefault();
       let state = {...this.context.router.history.location.state};
       state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
     }
 
     e.stopPropagation();
@@ -111,7 +113,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
 
   render () {
     const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
-    const { expanded, onToggleHidden, settings, usingPiP } = this.props;
+    const { expanded, onToggleHidden, settings, usingPiP, intl } = this.props;
     const outerStyle = { boxSizing: 'border-box' };
     const { compact } = this.props;
 
@@ -119,30 +121,32 @@ export default class DetailedStatus extends ImmutablePureComponent {
       return null;
     }
 
-    let media           = null;
-    let mediaIcon       = null;
+    let media           = [];
+    let mediaIcons      = [];
     let applicationLink = '';
     let reblogLink = '';
     let reblogIcon = 'retweet';
     let favouriteLink = '';
+    let edited = '';
 
     if (this.props.measureHeight) {
       outerStyle.height = `${this.state.height}px`;
     }
 
     if (status.get('poll')) {
-      media = <PollContainer pollId={status.get('poll')} />;
-      mediaIcon = 'tasks';
-    } else if (usingPiP) {
-      media = <PictureInPicturePlaceholder />;
-      mediaIcon = 'video-camera';
+      media.push(<PollContainer pollId={status.get('poll')} />);
+      mediaIcons.push('tasks');
+    }
+    if (usingPiP) {
+      media.push(<PictureInPicturePlaceholder />);
+      mediaIcons.push('video-camera');
     } else 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')} />;
+        media.push(<AttachmentList media={status.get('media_attachments')} />);
       } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
         const attachment = status.getIn(['media_attachments', 0]);
 
-        media = (
+        media.push(
           <Audio
             src={attachment.get('url')}
             alt={attachment.get('description')}
@@ -152,12 +156,12 @@ export default class DetailedStatus extends ImmutablePureComponent {
             foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
             accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
             height={150}
-          />
+          />,
         );
-        mediaIcon = 'music';
+        mediaIcons.push('music');
       } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
         const attachment = status.getIn(['media_attachments', 0]);
-        media = (
+        media.push(
           <Video
             preview={attachment.get('preview_url')}
             frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
@@ -173,11 +177,11 @@ export default class DetailedStatus extends ImmutablePureComponent {
             autoplay
             visible={this.props.showMedia}
             onToggleVisibility={this.props.onToggleMediaVisibility}
-          />
+          />,
         );
-        mediaIcon = 'video-camera';
+        mediaIcons.push('video-camera');
       } else {
-        media = (
+        media.push(
           <MediaGallery
             standalone
             sensitive={status.get('sensitive')}
@@ -188,13 +192,13 @@ export default class DetailedStatus extends ImmutablePureComponent {
             onOpenMedia={this.props.onOpenMedia}
             visible={this.props.showMedia}
             onToggleVisibility={this.props.onToggleMediaVisibility}
-          />
+          />,
         );
-        mediaIcon = 'picture-o';
+        mediaIcons.push('picture-o');
       }
     } else if (status.get('card')) {
-      media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />;
-      mediaIcon = 'link';
+      media.push(<Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />);
+      mediaIcons.push('link');
     }
 
     if (status.get('application')) {
@@ -215,7 +219,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
       reblogLink = (
         <React.Fragment>
           <React.Fragment> · </React.Fragment>
-          <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
+          <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'>
             <Icon id={reblogIcon} />
             <span className='detailed-status__reblogs'>
               <AnimatedNumber value={status.get('reblogs_count')} />
@@ -239,7 +243,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
 
     if (this.context.router) {
       favouriteLink = (
-        <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
+        <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/favourites`} className='detailed-status__link'>
           <Icon id='star' />
           <span className='detailed-status__favorites'>
             <AnimatedNumber value={status.get('favourites_count')} />
@@ -257,6 +261,15 @@ export default class DetailedStatus extends ImmutablePureComponent {
       );
     }
 
+    if (status.get('edited_at')) {
+      edited = (
+        <React.Fragment>
+          <React.Fragment> · </React.Fragment>
+          <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(status.get('edited_at'), { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} />
+        </React.Fragment>
+      );
+    }
+
     return (
       <div style={outerStyle}>
         <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
@@ -268,7 +281,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
           <StatusContent
             status={status}
             media={media}
-            mediaIcon={mediaIcon}
+            mediaIcons={mediaIcons}
             expanded={expanded}
             collapsed={false}
             onExpandedToggle={onToggleHidden}
@@ -282,7 +295,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
           <div className='detailed-status__meta'>
             <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
               <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
-            </a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
+            </a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
           </div>
         </div>
       </div>
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 513a6227f..12ea407ad 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -32,7 +32,7 @@ import { initBlockModal } from 'flavours/glitch/actions/blocks';
 import { initReport } from 'flavours/glitch/actions/reports';
 import { initBoostModal } from 'flavours/glitch/actions/boosts';
 import { makeGetStatus } from 'flavours/glitch/selectors';
-import { ScrollContainer } from 'react-router-scroll-4';
+import ScrollContainer from 'flavours/glitch/containers/scroll_container';
 import ColumnBackButton from 'flavours/glitch/components/column_back_button';
 import ColumnHeader from '../../components/column_header';
 import StatusContainer from 'flavours/glitch/containers/status_container';
@@ -70,7 +70,7 @@ const makeMapStateToProps = () => {
     ancestorsIds = ancestorsIds.withMutations(mutable => {
       let id = statusId;
 
-      while (id) {
+      while (id && !mutable.includes(id)) {
         mutable.unshift(id);
         id = inReplyTos.get(id);
       }
@@ -88,7 +88,7 @@ const makeMapStateToProps = () => {
     const ids = [statusId];
 
     while (ids.length > 0) {
-      let id        = ids.shift();
+      let id        = ids.pop();
       const replies = contextReplies.get(id);
 
       if (statusId !== id) {
@@ -97,7 +97,7 @@ const makeMapStateToProps = () => {
 
       if (replies) {
         replies.reverse().forEach(reply => {
-          ids.unshift(reply);
+          if (!ids.includes(reply) && !descendantsIds.includes(reply) && statusId !== reply) ids.push(reply);
         });
       }
     }
@@ -405,7 +405,7 @@ class Status extends ImmutablePureComponent {
   handleHotkeyOpenProfile = () => {
     let state = {...this.context.router.history.location.state};
     state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+    this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
   }
 
   handleMoveUp = id => {
@@ -507,11 +507,6 @@ class Status extends ImmutablePureComponent {
     this.setState({ fullscreen: isFullscreen() });
   }
 
-  shouldUpdateScroll = (prevRouterProps, { location }) => {
-    if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false;
-    return !(location.state && location.state.mastodonModalOpen);
-  }
-
   render () {
     let ancestors, descendants;
     const { setExpansion } = this;
@@ -562,7 +557,7 @@ class Status extends ImmutablePureComponent {
           )}
         />
 
-        <ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll}>
+        <ScrollContainer scrollKey='thread'>
           <div className={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}>
             {ancestors}
 
diff --git a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
index c4af25599..c23aec5ee 100644
--- a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
@@ -69,7 +69,7 @@ class BoostModal extends ImmutablePureComponent {
       this.props.onClose();
       let state = {...this.context.router.history.location.state};
       state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
     }
   }
 
diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
index d4e0bedac..e39a31e5d 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -47,7 +47,7 @@ const componentMap = {
   'DIRECTORY': Directory,
 };
 
-const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started|^\/start/);
+const shouldHideFAB = path => path.match(/^\/statuses\/|^\/@[^/]+\/\d+|^\/publish|^\/search|^\/getting-started|^\/start/);
 
 const messages = defineMessages({
   publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
@@ -216,7 +216,7 @@ class ColumnsArea extends ImmutablePureComponent {
     const columnIndex = getIndex(this.context.router.history.location.pathname);
 
     if (singleColumn) {
-      const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
+      const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/publish' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
 
       const content = columnIndex !== -1 ? (
         <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={!swipeToChangeColumns}>
diff --git a/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js
index 47a49c0c7..a665b9fb1 100644
--- a/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js
@@ -13,16 +13,23 @@ class ConfirmationModal extends React.PureComponent {
     onConfirm: PropTypes.func.isRequired,
     secondary: PropTypes.string,
     onSecondary: PropTypes.func,
+    closeWhenConfirm: PropTypes.bool,
     onDoNotAsk: PropTypes.func,
     intl: PropTypes.object.isRequired,
   };
 
+  static defaultProps = {
+    closeWhenConfirm: true,
+  };
+
   componentDidMount() {
     this.button.focus();
   }
 
   handleClick = () => {
-    this.props.onClose();
+    if (this.props.closeWhenConfirm) {
+      this.props.onClose();
+    }
     this.props.onConfirm();
     if (this.props.onDoNotAsk && this.doNotAskCheckbox.checked) {
       this.props.onDoNotAsk();
diff --git a/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
index ea1d7876e..9b7f9d1fb 100644
--- a/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
@@ -49,7 +49,7 @@ class FavouriteModal extends ImmutablePureComponent {
       this.props.onClose();
       let state = {...this.context.router.history.location.state};
       state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
-      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+      this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`, state);
     }
   }
 
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
index b7ec63333..5a4baa5a1 100644
--- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { connect } from 'react-redux';
 import classNames from 'classnames';
-import { changeUploadCompose, uploadThumbnail } from 'flavours/glitch/actions/compose';
+import { changeUploadCompose, uploadThumbnail, onChangeMediaDescription, onChangeMediaFocus } from 'flavours/glitch/actions/compose';
 import { getPointerPosition } from 'flavours/glitch/features/video';
 import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import IconButton from 'flavours/glitch/components/icon_button';
@@ -27,14 +27,22 @@ import { assetHost } from 'flavours/glitch/util/config';
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
   apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
+  applying: { id: 'upload_modal.applying', defaultMessage: 'Applying…' },
   placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
   chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' },
+  discardMessage: { id: 'confirmations.discard_edit_media.message', defaultMessage: 'You have unsaved changes to the media description or preview, discard them anyway?' },
+  discardConfirm: { id: 'confirmations.discard_edit_media.confirm', defaultMessage: 'Discard' },
 });
 
 const mapStateToProps = (state, { id }) => ({
   media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
   account: state.getIn(['accounts', me]),
   isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
+  description: state.getIn(['compose', 'media_modal', 'description']),
+  focusX: state.getIn(['compose', 'media_modal', 'focusX']),
+  focusY: state.getIn(['compose', 'media_modal', 'focusY']),
+  dirty: state.getIn(['compose', 'media_modal', 'dirty']),
+  is_changing_upload: state.getIn(['compose', 'is_changing_upload']),
 });
 
 const mapDispatchToProps = (dispatch, { id }) => ({
@@ -43,6 +51,14 @@ const mapDispatchToProps = (dispatch, { id }) => ({
     dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
   },
 
+  onChangeDescription: (description) => {
+    dispatch(onChangeMediaDescription(description));
+  },
+
+  onChangeFocus: (focusX, focusY) => {
+    dispatch(onChangeMediaFocus(focusX, focusY));
+  },
+
   onSelectThumbnail: files => {
     dispatch(uploadThumbnail(id, files[0]));
   },
@@ -83,8 +99,8 @@ class ImageLoader extends React.PureComponent {
 
 }
 
-export default @connect(mapStateToProps, mapDispatchToProps)
-@injectIntl
+export default @connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })
+@(component => injectIntl(component, { withRef: true }))
 class FocalPointModal extends ImmutablePureComponent {
 
   static propTypes = {
@@ -92,34 +108,21 @@ class FocalPointModal extends ImmutablePureComponent {
     account: ImmutablePropTypes.map.isRequired,
     isUploadingThumbnail: PropTypes.bool,
     onSave: PropTypes.func.isRequired,
+    onChangeDescription: PropTypes.func.isRequired,
+    onChangeFocus: PropTypes.func.isRequired,
     onSelectThumbnail: PropTypes.func.isRequired,
     onClose: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
   };
 
   state = {
-    x: 0,
-    y: 0,
-    focusX: 0,
-    focusY: 0,
     dragging: false,
-    description: '',
     dirty: false,
     progress: 0,
     loading: true,
     ocrStatus: '',
   };
 
-  componentWillMount () {
-    this.updatePositionFromMedia(this.props.media);
-  }
-
-  componentWillReceiveProps (nextProps) {
-    if (this.props.media.get('id') !== nextProps.media.get('id')) {
-      this.updatePositionFromMedia(nextProps.media);
-    }
-  }
-
   componentWillUnmount () {
     document.removeEventListener('mousemove', this.handleMouseMove);
     document.removeEventListener('mouseup', this.handleMouseUp);
@@ -164,54 +167,37 @@ class FocalPointModal extends ImmutablePureComponent {
     const focusX   = (x - .5) *  2;
     const focusY   = (y - .5) * -2;
 
-    this.setState({ x, y, focusX, focusY, dirty: true });
-  }
-
-  updatePositionFromMedia = media => {
-    const focusX      = media.getIn(['meta', 'focus', 'x']);
-    const focusY      = media.getIn(['meta', 'focus', 'y']);
-    const description = media.get('description') || '';
-
-    if (focusX && focusY) {
-      const x = (focusX /  2) + .5;
-      const y = (focusY / -2) + .5;
-
-      this.setState({
-        x,
-        y,
-        focusX,
-        focusY,
-        description,
-        dirty: false,
-      });
-    } else {
-      this.setState({
-        x: 0.5,
-        y: 0.5,
-        focusX: 0,
-        focusY: 0,
-        description,
-        dirty: false,
-      });
-    }
+    this.props.onChangeFocus(focusX, focusY);
   }
 
   handleChange = e => {
-    this.setState({ description: e.target.value, dirty: true });
+    this.props.onChangeDescription(e.target.value);
   }
 
   handleKeyDown = (e) => {
     if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
       e.preventDefault();
       e.stopPropagation();
-      this.setState({ description: e.target.value, dirty: true });
+      this.props.onChangeDescription(e.target.value);
       this.handleSubmit();
     }
   }
 
   handleSubmit = () => {
-    this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
-    this.props.onClose();
+    this.props.onSave(this.props.description, this.props.focusX, this.props.focusY);
+  }
+
+  getCloseConfirmationMessage = () => {
+    const { intl, dirty } = this.props;
+
+    if (dirty) {
+      return {
+        message: intl.formatMessage(messages.discardMessage),
+        confirm: intl.formatMessage(messages.discardConfirm),
+      };
+    } else {
+      return null;
+    }
   }
 
   setRef = c => {
@@ -257,7 +243,8 @@ class FocalPointModal extends ImmutablePureComponent {
         await worker.loadLanguage('eng');
         await worker.initialize('eng');
         const { data: { text } } = await worker.recognize(media_url);
-        this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false });
+        this.setState({ detecting: false });
+        this.props.onChangeDescription(removeExtraLineBreaks(text));
         await worker.terminate();
       })().catch((e) => {
         if (refreshCache) {
@@ -274,7 +261,6 @@ class FocalPointModal extends ImmutablePureComponent {
 
   handleThumbnailChange = e => {
     if (e.target.files.length > 0) {
-      this.setState({ dirty: true });
       this.props.onSelectThumbnail(e.target.files);
     }
   }
@@ -288,8 +274,10 @@ class FocalPointModal extends ImmutablePureComponent {
   }
 
   render () {
-    const { media, intl, account, onClose, isUploadingThumbnail } = this.props;
-    const { x, y, dragging, description, dirty, detecting, progress, ocrStatus } = this.state;
+    const { media, intl, account, onClose, isUploadingThumbnail, description, focusX, focusY, dirty, is_changing_upload } = this.props;
+    const { dragging, detecting, progress, ocrStatus } = this.state;
+    const x = (focusX /  2) + .5;
+    const y = (focusY / -2) + .5;
 
     const width  = media.getIn(['meta', 'original', 'width']) || null;
     const height = media.getIn(['meta', 'original', 'height']) || null;
@@ -344,7 +332,7 @@ class FocalPointModal extends ImmutablePureComponent {
                     accept='image/png,image/jpeg'
                     onChange={this.handleThumbnailChange}
                     style={{ display: 'none' }}
-                    disabled={isUploadingThumbnail}
+                    disabled={isUploadingThumbnail || is_changing_upload}
                   />
                 </label>
 
@@ -363,7 +351,7 @@ class FocalPointModal extends ImmutablePureComponent {
                 value={detecting ? '…' : description}
                 onChange={this.handleChange}
                 onKeyDown={this.handleKeyDown}
-                disabled={detecting}
+                disabled={detecting || is_changing_upload}
                 autoFocus
               />
 
@@ -373,11 +361,11 @@ class FocalPointModal extends ImmutablePureComponent {
             </div>
 
             <div className='setting-text__toolbar'>
-              <button disabled={detecting || media.get('type') !== 'image'} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button>
+              <button disabled={detecting || media.get('type') !== 'image' || is_changing_upload} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button>
               <CharacterCounter max={1500} text={detecting ? '' : description} />
             </div>
 
-            <Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
+            <Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500 || is_changing_upload} text={intl.formatMessage(is_changing_upload ? messages.applying : messages.apply)} onClick={this.handleSubmit} />
           </div>
 
           <div className='focal-point-modal__content'>
diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.js b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
index 533d9e09b..5d566e516 100644
--- a/app/javascript/flavours/glitch/features/ui/components/link_footer.js
+++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
@@ -3,7 +3,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import { Link } from 'react-router-dom';
-import { invitesEnabled, version, limitedFederationMode, repository, source_url } from 'flavours/glitch/util/initial_state';
+import { invitesEnabled, limitedFederationMode, version, repository, source_url } from 'flavours/glitch/util/initial_state';
 import { signOutLink, securityLink } from 'flavours/glitch/util/backend_links';
 import { logOut } from 'flavours/glitch/util/log_out';
 import { openModal } from 'flavours/glitch/actions/modal';
@@ -18,6 +18,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(openModal('CONFIRM', {
       message: intl.formatMessage(messages.logoutMessage),
       confirm: intl.formatMessage(messages.logoutConfirm),
+      closeWhenConfirm: false,
       onConfirm: () => logOut(),
     }));
   },
diff --git a/app/javascript/flavours/glitch/features/ui/components/list_panel.js b/app/javascript/flavours/glitch/features/ui/components/list_panel.js
index 354e35027..e61234283 100644
--- a/app/javascript/flavours/glitch/features/ui/components/list_panel.js
+++ b/app/javascript/flavours/glitch/features/ui/components/list_panel.js
@@ -46,7 +46,7 @@ class ListPanel extends ImmutablePureComponent {
         <hr />
 
         {lists.map(list => (
-          <NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/timelines/list/${list.get('id')}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.get('title')}</NavLink>
+          <NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/lists/${list.get('id')}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.get('title')}</NavLink>
         ))}
       </div>
     );
diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
index a8cbb837e..6974aab26 100644
--- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
@@ -112,15 +112,6 @@ class MediaModal extends ImmutablePureComponent {
     }));
   };
 
-  handleStatusClick = e => {
-    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
-      e.preventDefault();
-      this.context.router.history.push(`/statuses/${this.props.statusId}`);
-    }
-
-    this._sendBackgroundColor();
-  }
-
   componentDidUpdate (prevProps, prevState) {
     if (prevState.index !== this.state.index) {
       this._sendBackgroundColor();
diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
index 0fd70de34..62bb167a0 100644
--- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
@@ -59,12 +59,8 @@ export default class ModalRoot extends React.PureComponent {
     backgroundColor: null,
   };
 
-  getSnapshotBeforeUpdate () {
-    return { visible: !!this.props.type };
-  }
-
-  componentDidUpdate (prevProps, prevState, { visible }) {
-    if (visible) {
+  componentDidUpdate () {
+    if (!!this.props.type) {
       document.body.classList.add('with-modals--active');
       document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
     } else {
@@ -87,16 +83,33 @@ export default class ModalRoot extends React.PureComponent {
     return <BundleModalError {...props} onClose={onClose} />;
   }
 
+  handleClose = () => {
+    const { onClose } = this.props;
+    let message = null;
+    try {
+      message = this._modal?.getWrappedInstance?.().getCloseConfirmationMessage?.();
+    } catch (_) {
+      // injectIntl defines `getWrappedInstance` but errors out if `withRef`
+      // isn't set.
+      // This would be much smoother with react-intl 3+ and `forwardRef`.
+    }
+    onClose(message);
+  }
+
+  setModalRef = (c) => {
+    this._modal = c;
+  }
+
   render () {
-    const { type, props, onClose } = this.props;
+    const { type, props } = this.props;
     const { backgroundColor } = this.state;
     const visible = !!type;
 
     return (
-      <Base backgroundColor={backgroundColor} onClose={onClose} noEsc={props ? props.noEsc : false}>
+      <Base backgroundColor={backgroundColor} onClose={this.handleClose} noEsc={props ? props.noEsc : false}>
         {visible && (
           <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
-            {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={onClose} />}
+            {(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
           </BundleContainer>
         )}
       </Base>
diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
index 50e7d5c48..2dcd535ca 100644
--- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
+++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
@@ -11,12 +11,12 @@ import TrendsContainer from 'flavours/glitch/features/getting_started/containers
 
 const NavigationPanel = ({ onOpenSettings }) => (
   <div className='navigation-panel'>
-    <NavLink className='column-link column-link--transparent' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>
     <FollowRequestsNavLink />
-    <NavLink className='column-link column-link--transparent' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
-    <NavLink className='column-link column-link--transparent' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
-    <NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
+    <NavLink className='column-link column-link--transparent' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/conversations'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
     <NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' id='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink>
     {profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></NavLink>}
     <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
diff --git a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
index 8f993520a..82a1ee4a4 100644
--- a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
@@ -10,7 +10,7 @@ import ComposeForm from 'flavours/glitch/features/compose/components/compose_for
 import DrawerAccount from 'flavours/glitch/features/compose/components/navigation_bar';
 import Search from 'flavours/glitch/features/compose/components/search';
 import ColumnHeader from './column_header';
-import { me } from 'flavours/glitch/util/initial_state';
+import { me, source_url } from 'flavours/glitch/util/initial_state';
 
 const noop = () => { };
 
@@ -79,7 +79,7 @@ const PageThree = ({ intl, myAccount }) => (
       </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.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='/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/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>
 );
@@ -130,7 +130,7 @@ const PageSix = ({ admin, domain }) => {
   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> }} />
+        <FormattedMessage id='onboarding.page_six.admin' defaultMessage="Your instance's admin is {admin}." values={{ admin: <Permalink href={admin.get('url')} to={`/@${admin.get('acct')}`}>@{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>
diff --git a/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js
index a67405215..55cc84f5e 100644
--- a/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js
+++ b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js
@@ -8,10 +8,10 @@ import Icon from 'flavours/glitch/components/icon';
 import NotificationsCounterIcon from './notifications_counter_icon';
 
 export const links = [
-  <NavLink className='tabs-bar__link' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
+  <NavLink className='tabs-bar__link' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
   <NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
-  <NavLink className='tabs-bar__link' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
-  <NavLink className='tabs-bar__link' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
+  <NavLink className='tabs-bar__link' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
+  <NavLink className='tabs-bar__link' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
   <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>,
   <NavLink className='tabs-bar__link' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>,
 ];
diff --git a/app/javascript/flavours/glitch/features/ui/containers/modal_container.js b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js
index f074002e4..039aabd8a 100644
--- a/app/javascript/flavours/glitch/features/ui/containers/modal_container.js
+++ b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js
@@ -1,15 +1,25 @@
 import { connect } from 'react-redux';
-import { closeModal } from 'flavours/glitch/actions/modal';
+import { openModal, closeModal } from 'flavours/glitch/actions/modal';
 import ModalRoot from '../components/modal_root';
 
 const mapStateToProps = state => ({
-  type: state.get('modal').modalType,
-  props: state.get('modal').modalProps,
+  type: state.getIn(['modal', 0, 'modalType'], null),
+  props: state.getIn(['modal', 0, 'modalProps'], {}),
 });
 
 const mapDispatchToProps = dispatch => ({
-  onClose () {
-    dispatch(closeModal());
+  onClose (confirmationMessage) {
+    if (confirmationMessage) {
+      dispatch(
+        openModal('CONFIRM', {
+          message: confirmationMessage.message,
+          confirm: confirmationMessage.confirm,
+          onConfirm: () => dispatch(closeModal()),
+        }),
+      );
+    } else {
+      dispatch(closeModal());
+    }
   },
 });
 
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 1149eb14e..7ca1adf7c 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -78,6 +78,7 @@ const mapStateToProps = state => ({
   hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']),
   moved: state.getIn(['accounts', me, 'moved']) && state.getIn(['accounts', state.getIn(['accounts', me, 'moved'])]),
   firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
+  username: state.getIn(['accounts', me, 'username']),
 });
 
 const keyMap = {
@@ -190,7 +191,7 @@ class SwitchingColumnsArea extends React.PureComponent {
   render () {
     const { children, navbarUnder } = this.props;
     const singleColumn = this.state.mobile;
-    const redirect = singleColumn ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
+    const redirect = singleColumn ? <Redirect from='/' to='/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
 
     return (
       <ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn} navbarUnder={navbarUnder}>
@@ -198,33 +199,40 @@ class SwitchingColumnsArea extends React.PureComponent {
           {redirect}
           <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
           <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
-          <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
-          <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
-          <WrappedRoute path='/timelines/public/local' exact component={CommunityTimeline} content={children} />
-          <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
-          <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
-          <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />
 
+          <WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
+          <WrappedRoute path={['/public', '/timelines/public']} exact component={PublicTimeline} content={children} />
+          <WrappedRoute path={['/public/local', '/timelines/public/local']} exact component={CommunityTimeline} content={children} />
+          <WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
+          <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
+          <WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
           <WrappedRoute path='/notifications' component={Notifications} content={children} />
           <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
+
           <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
           <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
 
           <WrappedRoute path='/start' component={FollowRecommendations} content={children} />
           <WrappedRoute path='/search' component={Search} content={children} />
-          <WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
-
-          <WrappedRoute path='/statuses/new' component={Compose} content={children} />
+          <WrappedRoute path='/directory' component={Directory} content={children} />
+          <WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
+
+          <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
+          <WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
+          <WrappedRoute path={['/@:acct/followers', '/accounts/:id/followers']} component={Followers} content={children} />
+          <WrappedRoute path={['/@:acct/following', '/accounts/:id/following']} component={Following} content={children} />
+          <WrappedRoute path={['/@:acct/media', '/accounts/:id/media']} component={AccountGallery} content={children} />
+          <WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} />
+          <WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
+          <WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} />
+
+          {/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
+          <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
+          <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />
           <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
           <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
           <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
 
-          <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
-          <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
-          <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
-          <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
-          <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
-
           <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
           <WrappedRoute path='/blocks' component={Blocks} content={children} />
           <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
@@ -265,6 +273,7 @@ class UI extends React.Component {
     showFaviconBadge: PropTypes.bool,
     moved: PropTypes.map,
     firstLaunch: PropTypes.bool,
+    username: PropTypes.string,
   };
 
   state = {
@@ -517,7 +526,7 @@ class UI extends React.Component {
   }
 
   handleHotkeyGoToHome = () => {
-    this.props.history.push('/timelines/home');
+    this.props.history.push('/home');
   }
 
   handleHotkeyGoToNotifications = () => {
@@ -525,15 +534,15 @@ class UI extends React.Component {
   }
 
   handleHotkeyGoToLocal = () => {
-    this.props.history.push('/timelines/public/local');
+    this.props.history.push('/public/local');
   }
 
   handleHotkeyGoToFederated = () => {
-    this.props.history.push('/timelines/public');
+    this.props.history.push('/public');
   }
 
   handleHotkeyGoToDirect = () => {
-    this.props.history.push('/timelines/direct');
+    this.props.history.push('/conversations');
   }
 
   handleHotkeyGoToStart = () => {
@@ -549,7 +558,7 @@ class UI extends React.Component {
   }
 
   handleHotkeyGoToProfile = () => {
-    this.props.history.push(`/accounts/${me}`);
+    this.props.history.push(`/@${this.props.username}`);
   }
 
   handleHotkeyGoToBlocked = () => {
@@ -616,7 +625,7 @@ class UI extends React.Component {
               id='moved_to_warning'
               defaultMessage='This account is marked as moved to {moved_to_link}, and may thus not accept new follows.'
               values={{ moved_to_link: (
-                <PermaLink href={moved.get('url')} to={`/accounts/${moved.get('id')}`}>
+                <PermaLink href={moved.get('url')} to={`/@${moved.get('acct')}`}>
                   @{moved.get('acct')}
                 </PermaLink>
               )}}
diff --git a/app/javascript/flavours/glitch/locales/ja.js b/app/javascript/flavours/glitch/locales/ja.js
index c323956c6..0ca5e5fc7 100644
--- a/app/javascript/flavours/glitch/locales/ja.js
+++ b/app/javascript/flavours/glitch/locales/ja.js
@@ -7,6 +7,7 @@ const messages = {
   'layout.desktop': 'デスクトップ',
   'layout.single': 'モバイル',
   'navigation_bar.app_settings': 'アプリ設定',
+  'navigation_bar.featured_users': '紹介しているアカウント',
   'getting_started.onboarding': '解説を表示',
   'onboarding.page_one.federation': '{domain}はMastodonのインスタンスです。Mastodonとは、独立したサーバが連携して作るソーシャルネットワークです。これらのサーバーをインスタンスと呼びます。',
   'onboarding.page_one.welcome': '{domain}へようこそ!',
@@ -20,7 +21,7 @@ const messages = {
   'settings.auto_collapse_reblogs': 'ブースト',
   'settings.auto_collapse_replies': '返信',
   'settings.close': '閉じる',
-  'settings.collapsed_statuses': 'トゥート',
+  'settings.collapsed_statuses': 'トゥート折りたたみ',
   'settings.confirm_missing_media_description': '画像に対する補助記載がないときに投稿前の警告を表示する',
   'settings.content_warnings': 'コンテンツワーニング',
   'settings.content_warnings_filter': '説明に指定した文字が含まれているものを自動で展開しないようにする',
@@ -53,12 +54,15 @@ const messages = {
   'status.collapse': '折りたたむ',
   'status.uncollapse': '折りたたみを解除',
 
-  'confirmations.missing_media_description.message': '少なくとも1つの画像に視聴覚障害者のための画像説明が付与されていません。すべての画像に対して説明を付与することを望みます。',
+  'confirmations.missing_media_description.message': '少なくとも1つの画像に視覚障害者のための画像説明が付与されていません。すべての画像に対して説明を付与することを望みます。',
   'confirmations.missing_media_description.confirm': 'このまま投稿',
+  'confirmations.missing_media_description.edit': 'メディアを編集',
 
   'favourite_modal.combo': '次からは {combo} を押せば、これをスキップできます。',
 
   'home.column_settings.show_direct': 'DMを表示',
+  'home.column_settings.advanced': '高度',
+  'home.column_settings.filter_regex': '正規表現でフィルター',
 
   'notification.markForDeletion': '選択',
   'notifications.clear': '通知を全てクリアする',
@@ -71,8 +75,8 @@ const messages = {
   'notification_purge.btn_apply': '選択したものを\n削除',
 
   'compose.attach.upload': 'ファイルをアップロード',
-  'compose.attach.doodle': '落書きをする',
-  'compose.attach': 'アタッチ...',
+  'compose.attach.doodle': 'お絵描きをする',
+  'compose.attach': '添付...',
 
   'advanced_options.local-only.short': 'ローカル限定',
   'advanced_options.local-only.long': '他のインスタンスには投稿されません',
@@ -84,7 +88,70 @@ const messages = {
 
   'navigation_bar.direct': 'ダイレクトメッセージ',
   'navigation_bar.bookmarks': 'ブックマーク',
-  'column.bookmarks': 'ブックマーク'
+  'column.bookmarks': 'ブックマーク',
+
+  'account.add_account_note': '@{name}のメモを追加',
+  'account.disclaimer_full': 'このユーザー情報は不正確な可能性があります。',
+  'account.follows': 'フォロー',
+  'account.suspended_disclaimer_full': 'このユーザーはモデレータにより停止されました。',
+  'account.view_full_profile': '正確な情報を見る',
+  'account_note.cancel': 'キャンセル',
+  'account_note.edit': '編集',
+  'account_note.glitch_placeholder': 'メモがありません',
+  'account_note.save': '保存',
+  'boost_modal.missing_description': 'このトゥートには少なくとも1つの画像に説明が付与されていません',
+  'community.column_settings.allow_local_only': 'ローカル限定投稿を表示する',
+  'compose.content-type.html': 'HTML',
+  'compose.content-type.markdown': 'マークダウン',
+  'compose.content-type.plain': 'プレーンテキスト',
+  'compose_form.poll.multiple_choices': '複数回答を許可',
+  'compose_form.poll.single_choice': '単一回答を許可',
+  'compose_form.spoiler': '本文は警告の後ろに隠す',
+  'confirmation_modal.do_not_ask_again': 'もう1度尋ねない',
+  'confirmations.discard_edit_media.confirm': '破棄',
+  'confirmations.discard_edit_media.message': 'メディアの説明・プレビューに保存していない変更があります。破棄してもよろしいですか?',
+  'confirmations.unfilter': 'このフィルターされたトゥートについての情報',
+  'confirmations.unfilter.author': '筆者',
+  'confirmations.unfilter.confirm': '見る',
+  'confirmations.unfilter.edit_filter': 'フィルターを編集',
+  'confirmations.unfilter.filters': '適用されたフィルター',
+  'content-type.change': 'コンテンツ形式を変更',
+  'direct.conversations_mode': '会話',
+  'direct.timeline_mode': 'タイムライン',
+  'endorsed_accounts_editor.endorsed_accounts': '紹介しているユーザー',
+  'keyboard_shortcuts.bookmark': 'ブックマーク',
+  'keyboard_shortcuts.secondary_toot': 'セカンダリートゥートの公開範囲でトゥートする',
+  'keyboard_shortcuts.toggle_collapse': '折りたたむ/折りたたみを解除',
+  'moved_to_warning': 'このアカウント{moved_to_link}に引っ越したため、新しいフォロワーを受け入れていません。',
+  'settings.show_action_bar': 'アクションバーを表示',
+  'settings.filtering_behavior': 'フィルターの振る舞い',
+  'settings.filtering_behavior.cw': '警告文にフィルターされた単語を付加して表示します',
+  'settings.filtering_behavior.drop': 'フィルターされたトゥートを完全に隠します',
+  'settings.filtering_behavior.hide': '\'フィルターされました\'とその理由を確認するボタンを表示する',
+  'settings.filtering_behavior.upstream': '\'フィルターされました\'とバニラMastodonと同じように表示する',
+  'settings.filters': 'フィルター',
+  'settings.hicolor_privacy_icons': 'ハイカラーの公開範囲アイコン',
+  'settings.hicolor_privacy_icons.hint': '公開範囲アイコンを明るく表示し見分けやすい色にします',
+  'settings.confirm_boost_missing_media_description': 'メディアの説明が欠けているトゥートをブーストする前に確認ダイアログを表示する',
+  'settings.tag_misleading_links': '誤解を招くリンクにタグをつける',
+  'settings.tag_misleading_links.hint': '明示的に言及していないすべてのリンクに、リンクターゲットホストを含む視覚的な表示を追加します',
+  'settings.rewrite_mentions': '表示されたトゥートの返信先表示を書き換える',
+  'settings.rewrite_mentions_acct': 'ユーザー名とドメイン名(アカウントがリモートの場合)を表示するように書き換える',
+  'settings.rewrite_mentions_no': '書き換えない',
+  'settings.rewrite_mentions_username': 'ユーザー名を表示するように書き換える',
+  'settings.swipe_to_change_columns': 'スワイプでカラムを切り替え可能にする(モバイルのみ)',
+  'settings.prepend_cw_re': '返信するとき警告に "re: "を付加する',
+  'settings.preselect_on_reply': '返信するときユーザー名を事前選択する',
+  'settings.confirm_before_clearing_draft': '作成しているメッセージが上書きされる前に確認ダイアログを表示する',
+  'settings.show_content_type_choice': 'トゥートを書くときコンテンツ形式の選択ボタンを表示する',
+  'settings.inline_preview_cards': '外部リンクに埋め込みプレビューを有効にする',
+  'settings.media_reveal_behind_cw': '既定で警告指定されているトゥートの閲覧注意メディアを表示する',
+  'settings.pop_in_left': '左',
+  'settings.pop_in_player': 'ポップインプレイヤーを有効化する',
+  'settings.pop_in_position': 'ポップインプレーヤーの位置:',
+  'settings.pop_in_right': '右',
+  'status.show_filter_reason': '(理由を見る)',
+
 };
 
-export default Object.assign({}, inherited, messages);
\ No newline at end of file
+export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/ko.js b/app/javascript/flavours/glitch/locales/ko.js
index 3b55f89b9..b67fec187 100644
--- a/app/javascript/flavours/glitch/locales/ko.js
+++ b/app/javascript/flavours/glitch/locales/ko.js
@@ -1,7 +1,201 @@
 import inherited from 'mastodon/locales/ko.json';
 
 const messages = {
-  //  No translations available.
+  'account.add_account_note': '@{name} 님에 대한 메모 추가',
+  'account.disclaimer_full': '아래에 있는 정보들은 사용자의 프로필을 완벽하게 나타내지 못하고 있을 수도 있습니다.',
+  'account.follows': '팔로우',
+  'account.suspended_disclaimer_full': '이 사용자는 중재자에 의해 정지되었습니다.',
+  'account.view_full_profile': '전체 프로필 보기',
+  'account_note.cancel': '취소',
+  'account_note.edit': '편집',
+  'account_note.glitch_placeholder': '코멘트가 없습니다',
+  'account_note.save': '저장',
+  'advanced_options.icon_title': '고급 옵션',
+  'advanced_options.local-only.long': '다른 서버에 게시하지 않기',
+  'advanced_options.local-only.short': '로컬 전용',
+  'advanced_options.local-only.tooltip': '이 게시물은 로컬 전용입니다',
+  'advanced_options.threaded_mode.long': '글을 작성하고 자동으로 답글 열기',
+  'advanced_options.threaded_mode.short': '글타래 모드',
+  'advanced_options.threaded_mode.tooltip': '글타래 모드 활성화됨',
+  'boost_modal.missing_description': '이 게시물은 설명이 없는 미디어를 포함하고 있습니다',
+  'column.favourited_by': '즐겨찾기 한 사람',
+  'column.heading': '기타',
+  'column.reblogged_by': '부스트 한 사람',
+  'column.subheading': '다양한 옵션',
+  'column.toot': '게시물과 답글',
+  'column_header.profile': '프로필',
+  'column_subheading.lists': '리스트',
+  'column_subheading.navigation': '탐색',
+  'community.column_settings.allow_local_only': '로컬 전용 글 보기',
+  'compose.attach': '첨부…',
+  'compose.attach.doodle': '뭔가 그려보세요',
+  'compose.attach.upload': '파일 업로드',
+  'compose.content-type.html': 'HTML',
+  'compose.content-type.markdown': '마크다운',
+  'compose.content-type.plain': '일반 텍스트',
+  'compose_form.poll.multiple_choices': '여러 개 선택 가능',
+  'compose_form.poll.single_choice': '하나만 선택 가능',
+  'compose_form.spoiler': '경고 메시지로 숨기기',
+  'confirmation_modal.do_not_ask_again': '다음부터 확인창을 띄우지 않기',
+  'confirmations.discard_edit_media.confirm': '취소',
+  'confirmations.discard_edit_media.message': '저장하지 않은 미디어 설명이나 미리보기가 있습니다, 그냥 닫을까요?',
+  'confirmations.missing_media_description.confirm': '그냥 보내기',
+  'confirmations.missing_media_description.edit': '미디어 편집',
+  'confirmations.missing_media_description.message': '하나 이상의 미디어에 대해 설명을 작성하지 않았습니다. 시각장애인을 위해 모든 미디어에 설명을 추가하는 것을 고려해주세요.',
+  'confirmations.unfilter': '이 필터링 된 글에 대한 정보',
+  'confirmations.unfilter.author': '작성자',
+  'confirmations.unfilter.confirm': '보기',
+  'confirmations.unfilter.edit_filter': '필터 편집',
+  'confirmations.unfilter.filters': '적용된 {count, plural, one {필터} other {필터들}}',
+  'content-type.change': '콘텐트 타입',
+  'direct.conversations_mode': '대화',
+  'direct.timeline_mode': '타임라인',
+  'endorsed_accounts_editor.endorsed_accounts': '추천하는 계정들',
+  'favourite_modal.combo': '다음엔 {combo}를 눌러 건너뛸 수 있습니다',
+  'getting_started.onboarding': '둘러보기',
+  'getting_started.open_source_notice': '글리치는 {Mastodon}의 자유 오픈소스 포크버전입니다. {github}에서 문제를 리포팅 하거나 기여를 할 수 있습니다.',
+  'home.column_settings.advanced': '고급',
+  'home.column_settings.filter_regex': '정규표현식으로 필터',
+  'home.column_settings.show_direct': 'DM 보여주기',
+  'home.settings': '컬럼 설정',
+  'keyboard_shortcuts.bookmark': '북마크',
+  'keyboard_shortcuts.secondary_toot': '보조 프라이버시 설정으로 글 보내기',
+  'keyboard_shortcuts.toggle_collapse': '글 접거나 펼치기',
+  'layout.auto': '자동',
+  'layout.current_is': '현재 레이아웃:',
+  'layout.desktop': '데스크탑',
+  'layout.hint.auto': '“고급 웹 인터페이스 활성화” 설정과 화면 크기에 따라 자동으로 레이아웃을 고릅니다.',
+  'layout.hint.desktop': '“고급 웹 인터페이스 활성화” 설정이나 화면 크기에 관계 없이 멀티 컬럼 레이아웃을 사용합니다.',
+  'layout.hint.single': '“고급 웹 인터페이스 활성화” 설정이나 화면 크기에 관계 없이 싱글 컬럼 레이아웃을 사용합니다.',
+  'layout.single': '모바일',
+  'media_gallery.sensitive': '민감함',
+  'moved_to_warning': '이 계정은 {moved_to_link}로 이동한 것으로 표시되었고, 새 팔로우를 받지 않는 것 같습니다.',
+  'navigation_bar.app_settings': '앱 설정',
+  'navigation_bar.featured_users': '추천된 계정들',
+  'navigation_bar.misc': '다양한 옵션들',
+  'notification.markForDeletion': '삭제하기 위해 표시',
+  'notification_purge.btn_all': '전체선택',
+  'notification_purge.btn_apply': '선택된 알림 삭제',
+  'notification_purge.btn_invert': '선택반전',
+  'notification_purge.btn_none': '전체선택해제',
+  'notification_purge.start': '알림 삭제모드로 들어가기',
+  'notifications.clear': '내 알림 모두 지우기',
+  'notifications.marked_clear': '선택된 알림 모두 삭제',
+  'notifications.marked_clear_confirmation': '정말로 선택된 알림들을 영구적으로 삭제할까요?',
+  'onboarding.done': '완료',
+  'onboarding.next': '다음',
+  'onboarding.page_five.public_timelines': '로컬 타임라인은 {domain}에 있는 모든 사람의 공개글을 보여줍니다. 연합 타임라인은 {domain}에 있는 사람들이 팔로우 하는 모든 사람의 공개글을 보여줍니다. 이것들은 공개 타임라인이라고 불리며, 새로운 사람들을 발견할 수 있는 좋은 방법입니다.',
+  'onboarding.page_four.home': '홈 타임라인은 당신이 팔로우 한 사람들의 글을 보여줍니다.',
+  'onboarding.page_four.notifications': '알림 컬럼은 누군가가 당신과 상호작용한 것들을 보여줍니다.',
+  'onboarding.page_one.federation': '{domain}은 마스토돈의 \'인스턴스\'입니다. 마스토돈은 하나의 거대한 소셜 네트워크를 만들기 위해 참여한 서버들의 네트워크입니다. 우린 이 서버들을 인스턴스라고 부릅니다.',
+  'onboarding.page_one.handle': '당신은 {domain}에 속해 있으며, 전체 핸들은 {handle} 입니다.',
+  'onboarding.page_one.welcome': '{domain}에 오신 것을 환영합니다!',
+  'onboarding.page_six.admin': '우리 서버의 관리자는 {admin} 님입니다.',
+  'onboarding.page_six.almost_done': '거의 다 되었습니다…',
+  'onboarding.page_six.appetoot': '본 아페툿!',
+  'onboarding.page_six.apps_available': 'iOS, 안드로이드, 그리고 다른 플랫폼들을 위한 {apps}이 존재합니다.',
+  'onboarding.page_six.github': '{domain}은 글리치를 통해 구동 됩니다. 글리치는 {Mastodon}의 {fork}입니다, 그리고 어떤 마스토돈 인스턴스나 앱과도 호환 됩니다. 글리치는 완전한 자유 오픈소스입니다. {github}에서 버그를 리포팅 하거나, 기능을 제안하거나, 코드를 기여할 수 있습니다.',
+  'onboarding.page_six.guidelines': '커뮤니티 가이드라인',
+  'onboarding.page_six.read_guidelines': '{domain}의 {guidelines}을 읽어주세요!',
+  'onboarding.page_six.various_app': '모바일 앱',
+  'onboarding.page_three.profile': '프로필을 수정해 아바타, 바이오, 표시되는 이름을 설정하세요. 거기에서 다른 설정들도 찾을 수 있습니다.',
+  'onboarding.page_three.search': '검색창을 사용해 사람들과 해시태그를 찾아보세요. 예를 들면 {illustration}이라든지 {introcustions} 같은 것으로요. 이 인스턴스에 있지 않은 사람을 찾으려면, 전체 핸들을 사용하세요.',
+  'onboarding.page_two.compose': '작성 컬럼에서 게시물을 작성하세요. 그림을 업로드 할 수 있고, 공개설정을 바꿀 수도 있으며, 아래 아이콘을 통해 열람주의 텍스트를 설정할 수 있습니다.',
+  'onboarding.skip': '건너뛰기',
+  'settings.always_show_spoilers_field': '열람주의 항목을 언제나 활성화',
+  'settings.auto_collapse': '자동으로 접기',
+  'settings.auto_collapse_all': '모두',
+  'settings.auto_collapse_lengthy': '긴 글',
+  'settings.auto_collapse_media': '미디어 포함 글',
+  'settings.auto_collapse_notifications': '알림',
+  'settings.auto_collapse_reblogs': '부스트',
+  'settings.auto_collapse_replies': '답글',
+  'settings.close': '닫기',
+  'settings.collapsed_statuses': '접힌 글',
+  'settings.compose_box_opts': '작성 상자',
+  'settings.confirm_before_clearing_draft': '작성 중인 메시지를 덮어씌우기 전에 확인창을 보여주기',
+  'settings.confirm_boost_missing_media_description': '미디어 설명이 없는 글을 부스트하려 할 때 확인창을 보여주기',
+  'settings.confirm_missing_media_description': '미디어 설명이 없는 글을 작성하려 할 때 확인창을 보여주기',
+  'settings.content_warnings': '열람주의',
+  'settings.content_warnings.regexp': '정규표현식',
+  'settings.content_warnings_filter': '자동으로 펼치지 않을 열람주의 문구:',
+  'settings.enable_collapsed': '접힌 글 활성화',
+  'settings.enable_content_warnings_auto_unfold': '자동으로 열람주의 펼치기',
+  'settings.filtering_behavior': '필터링 동작',
+  'settings.filtering_behavior.cw': '게시물을 보여주되, 필터된 단어를 열람주의에 추가합니다',
+  'settings.filtering_behavior.drop': '완전히 숨깁니다',
+  'settings.filtering_behavior.hide': '\'필터됨\'이라고 표시하고 이유를 표시하는 버튼을 추가합니다',
+  'settings.filtering_behavior.upstream': '\'필터됨\'이라고 일반 마스토돈처럼 표시합니다',
+  'settings.filters': '필터',
+  'settings.general': '일반',
+  'settings.hicolor_privacy_icons': '높은 채도의 공개설정 아이콘',
+  'settings.hicolor_privacy_icons.hint': '공개설정 아이콘들을 밝고 구분하기 쉬운 색으로 표시합니다',
+  'settings.image_backgrounds': '이미지 배경',
+  'settings.image_backgrounds_media': '접힌 글의 미디어 미리보기',
+  'settings.image_backgrounds_users': '접힌 글에 이미지 배경 주기',
+  'settings.inline_preview_cards': '외부 링크에 대한 미리보기 카드를 같이 표시',
+  'settings.layout': '레이아웃:',
+  'settings.layout_opts': '레이아웃 옵션',
+  'settings.media': '미디어',
+  'settings.media_fullwidth': '최대폭 미디어 미리보기',
+  'settings.media_letterbox': '레터박스 미디어',
+  'settings.media_letterbox_hint': '확대하고 자르는 대신 축소하고 레터박스에 넣어 이미지를 보여줍니다',
+  'settings.media_reveal_behind_cw': '열람주의로 가려진 미디어를 기본으로 펼쳐 둡니다',
+  'settings.navbar_under': '내비바를 하단에 (모바일 전용)',
+  'settings.notifications.favicon_badge': '읽지 않은 알림 파비콘 배지',
+  'settings.notifications.favicon_badge.hint': '읽지 않은 알림 배지를 파비콘에 추가합니다',
+  'settings.notifications.tab_badge': '읽지 않은 알림 배지',
+  'settings.notifications.tab_badge.hint': '알림 컬럼이 열려 있지 않을 때 알림 컬럼에 알림이 있다는 배지를 표시합니다',
+  'settings.notifications_opts': '알림 옵션',
+  'settings.pop_in_left': '왼쪽',
+  'settings.pop_in_player': '떠있는 재생기 활성화',
+  'settings.pop_in_position': '떠있는 재생기 위치:',
+  'settings.pop_in_right': '오른쪽',
+  'settings.preferences': '사용자 설정',
+  'settings.prepend_cw_re': '열람주의가 달린 글에 답장을 할 때 열람주의 문구 앞에 “re: ”를 추가합니다',
+  'settings.preselect_on_reply': '답글 달 때 사용자명 미리 선택',
+  'settings.preselect_on_reply_hint': '답글을 달 때 이미 멘션 된 사람의 사용자명을 미리 블럭으로 설정해 놓습니다',
+  'settings.rewrite_mentions': '표시되는 게시물의 멘션 표시 바꾸기',
+  'settings.rewrite_mentions_acct': '사용자명과 도메인으로 바꾸기(계정이 원격일 때)',
+  'settings.rewrite_mentions_no': '멘션을 그대로 두기',
+  'settings.rewrite_mentions_username': '사용자명으로 바꾸기',
+  'settings.show_action_bar': '접힌 글에 액션 버튼들 보이기',
+  'settings.show_content_type_choice': '글을 작성할 때 콘텐트 타입을 고를 수 있도록 합니다',
+  'settings.show_reply_counter': '대략적인 답글 개수를 표시합니다',
+  'settings.side_arm': '보조 작성 버튼:',
+  'settings.side_arm.none': '없음',
+  'settings.side_arm_reply_mode': '답글을 작성할 때:',
+  'settings.side_arm_reply_mode.copy': '답글을 달려는 글의 공개설정을 복사합니다',
+  'settings.side_arm_reply_mode.keep': '보조 작성 버튼의 공개설정을 유지합니다',
+  'settings.side_arm_reply_mode.restrict': '답글을 달려는 글의 공개설정에 맞게 제한합니다',
+  'settings.swipe_to_change_columns': '스와이프하여 컬럼간 전환을 허용합니다 (모바일 전용)',
+  'settings.tag_misleading_links': '오해의 소지가 있는 링크를 표시합니다',
+  'settings.tag_misleading_links.hint': '링크에 명시적으로 주소가 없는 경우엔 대상 호스트를 보이도록 표시합니다',
+  'settings.wide_view': '넓은 뷰 (데스크탑 모드 전용)',
+  'settings.wide_view_hint': '컬럼들을 늘려서 활용 가능한 공간을 사용합니다.',
+  'status.collapse': '접기',
+  'status.has_audio': '소리 파일이 첨부되어 있습니다',
+  'status.has_pictures': '그림 파일이 첨부되어 있습니다',
+  'status.has_preview_card': '미리보기 카드가 첨부되어 있습니다',
+  'status.has_video': '영상이 첨부되어 있습니다',
+  'status.hide': '글 가리기',
+  'status.in_reply_to': '이 글은 답글입니다',
+  'status.is_poll': '이 글은 설문입니다',
+  'status.local_only': '당신의 서버에서만 보입니다',
+  'status.sensitive_toggle': '클릭해서 보기',
+  'status.show_filter_reason': '(이유 보기)',
+  'status.uncollapse': '펼치기',
+  'upload_modal.applying': '적용중…',
+  'web_app_crash.change_your_settings': '{settings}을 바꾸세요',
+  'web_app_crash.content': '이것들을 시도해 볼 수 있습니다:',
+  'web_app_crash.debug_info': '디버그 정보',
+  'web_app_crash.disable_addons': '브라우저 애드온이나 기본 번역 도구를 비활성화 합니다',
+  'web_app_crash.issue_tracker': '이슈 트래커',
+  'web_app_crash.reload': '새로고침',
+  'web_app_crash.reload_page': '이 페이지를 {reload}',
+  'web_app_crash.report_issue': '{issuetracker}에 버그 제보',
+  'web_app_crash.settings': '설정',
+  'web_app_crash.title': '죄송합니다, 하지만 마스토돈 앱이 뭔가 잘못되었습니다.',
 };
 
 export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/zh-CN.js b/app/javascript/flavours/glitch/locales/zh-CN.js
index 944588e02..21a68fc01 100644
--- a/app/javascript/flavours/glitch/locales/zh-CN.js
+++ b/app/javascript/flavours/glitch/locales/zh-CN.js
@@ -1,7 +1,201 @@
 import inherited from 'mastodon/locales/zh-CN.json';
 
 const messages = {
-  //  No translations available.
+  'account.add_account_note': '为 @{name} 添加备注',
+  'account.disclaimer_full': '以下信息可能无法完整代表你的个人资料。',
+  'account.follows': '正在关注',
+  'account.suspended_disclaimer_full': '该用户已被封禁。',
+  'account.view_full_profile': '查看完整资料',
+  'account_note.cancel': '取消',
+  'account_note.edit': '编辑',
+  'account_note.glitch_placeholder': '暂无备注',
+  'account_note.save': '保存',
+  'advanced_options.icon_title': '高级选项',
+  'advanced_options.local-only.long': '不要发布嘟文到其他实例',
+  'advanced_options.local-only.short': '本地模式',
+  'advanced_options.local-only.tooltip': '这条嘟文仅限于本实例',
+  'advanced_options.threaded_mode.long': '发嘟时自动打开回复',
+  'advanced_options.threaded_mode.short': '线程模式',
+  'advanced_options.threaded_mode.tooltip': '线程模式已启用',
+  'boost_modal.missing_description': '这条嘟文未包含媒体描述',
+  'column.favourited_by': '喜欢',
+  'column.heading': '标题',
+  'column.reblogged_by': '转嘟',
+  'column.subheading': '其他选项',
+  'column.toot': '嘟文和回复',
+  'column_header.profile': '个人资料',
+  'column_subheading.lists': '列表',
+  'column_subheading.navigation': '导航',
+  'community.column_settings.allow_local_only': '只显示本地模式嘟文',
+  'compose.attach': '附上...',
+  'compose.attach.doodle': '画点什么',
+  'compose.attach.upload': '上传文件',
+  'compose.content-type.html': 'HTML',
+  'compose.content-type.markdown': 'Markdown',
+  'compose.content-type.plain': '纯文本',
+  'compose_form.poll.multiple_choices': '允许多选',
+  'compose_form.poll.single_choice': '允许单选',
+  'compose_form.spoiler': '隐藏为内容警告',
+  'confirmation_modal.do_not_ask_again': '下次不显示确认窗口',
+  'confirmations.discard_edit_media.confirm': '确认',
+  'confirmations.discard_edit_media.message': '有未保存的媒体描述或预览,确认要关闭?',
+  'confirmations.missing_media_description.confirm': '确认',
+  'confirmations.missing_media_description.edit': '编辑',
+  'confirmations.missing_media_description.message': '你没有为一种或多种媒体撰写描述。请考虑为视障人士添加描述。',
+  'confirmations.unfilter': '关于此过滤后嘟文的信息',
+  'confirmations.unfilter.author': '作者',
+  'confirmations.unfilter.confirm': '查看',
+  'confirmations.unfilter.edit_filter': '编辑过滤器',
+  'confirmations.unfilter.filters': '应用 {count, plural, one {过滤器} other {过滤器}}',
+  'content-type.change': '内容类型 ',
+  'direct.conversations_mode': '对话模式',
+  'direct.timeline_mode': '时间线模式',
+  'endorsed_accounts_editor.endorsed_accounts': '推荐用户',
+  'favourite_modal.combo': '下次你可以按 {combo} 跳过这个',
+  'getting_started.onboarding': '参观一下',
+  'getting_started.open_source_notice': 'Glitchsoc 是由 {Mastodon} 分叉出来的免费开源软件。你可以在 GitHub 上贡献或报告问题,地址是 {github}。',
+  'home.column_settings.advanced': '高级',
+  'home.column_settings.filter_regex': '按正则表达式过滤',
+  'home.column_settings.show_direct': '显示私信',
+  'home.settings': '列表设置',
+  'keyboard_shortcuts.bookmark': '书签',
+  'keyboard_shortcuts.secondary_toot': '使用二级隐私设置发送嘟文',
+  'keyboard_shortcuts.toggle_collapse': '折叠或展开嘟文',
+  'layout.auto': '自动模式',
+  'layout.current_is': '你目前的布局是:',
+  'layout.desktop': '桌面模式',
+  'layout.hint.auto': '根据“启用高级 Web 界面”设置和屏幕大小自动选择布局。',
+  'layout.hint.desktop': '“使用多列布局,无论“启用高级 Web 界面”设置和屏幕大小如何。',
+  'layout.hint.single': '使用单列布局,无论“启用高级 Web 界面”设置和屏幕大小如何。',
+  'layout.single': '移动模式',
+  'media_gallery.sensitive': '敏感内容',
+  'moved_to_warning': '此帐户已被标记为移至 {moved_to_link},并且似乎没有收到新关注者。',
+  'navigation_bar.app_settings': '应用选项',
+  'navigation_bar.featured_users': '推荐用户',
+  'navigation_bar.misc': '杂项',
+  'notification.markForDeletion': '标记以删除',
+  'notification_purge.btn_all': '全选',
+  'notification_purge.btn_apply': '清除已选',
+  'notification_purge.btn_invert': '反向选择',
+  'notification_purge.btn_none': '取消全选',
+  'notification_purge.start': '进入通知清除模式',
+  'notifications.clear': '清除所有通知',
+  'notifications.marked_clear': '清除选择的通知',
+  'notifications.marked_clear_confirmation': '你确定要永久清除所有选择的通知吗?',
+  'onboarding.done': '完成',
+  'onboarding.next': '下一个',
+  'onboarding.page_five.public_timelines': '本地时间线显示来自 {domain} 中所有人的公开嘟文。跨站时间线显示了 {domain} 用户关注的每个人的公开嘟文。这些被称为公共时间线,是发现新朋友的好方法。',
+  'onboarding.page_four.home': '你的主页时间线会显示你关注的人的嘟文。',
+  'onboarding.page_four.notifications': '通知栏显示某人与你互动的内容。',
+  'onboarding.page_one.federation': '{domain} 是 Mastodon 的一个“实例”。Mastodon 是一个由独立服务器组成的,通过不断联合形成的社交网络。我们称这些服务器为实例。',
+  'onboarding.page_one.handle': '你位于 {domain},因此你的完整用户名是 {handle} 。',
+  'onboarding.page_one.welcome': '欢迎来到 {domain}!',
+  'onboarding.page_six.admin': '实例的管理员是 {admin}。',
+  'onboarding.page_six.almost_done': '就快完成了...',
+  'onboarding.page_six.appetoot': '尽情享用吧!',
+  'onboarding.page_six.apps_available': '有适用于 iOS、Android 和其他平台的应用程序。',
+  'onboarding.page_six.github': '{domain} 在 Glitchsoc 上运行。Glitchsoc 是 {Mastodon} 的一个友好 {fork},与任何 Mastodon 实例或应用兼容。Glitchsoc 是完全免费和开源的。你可以在 {github} 上报告错误、请求功能或贡献代码。',
+  'onboarding.page_six.guidelines': '社区准则',
+  'onboarding.page_six.read_guidelines': '请阅读 {domain} 的 {guidelines}!',
+  'onboarding.page_six.various_app': '应用程序',
+  'onboarding.page_three.profile': '编辑你的个人资料,更改你的头像、个人简介和昵称。在那里,你还会发现其他设置。',
+  'onboarding.page_three.search': '使用搜索栏查找用户并查看标签,例如 #illustration 和 #introductions。要查找不在此实例中的用户,请使用他们的完整用户名。',
+  'onboarding.page_two.compose': '在撰写框中撰写嘟文。你可以使用下方图标上传图像、更改隐私设置和添加内容警告。',
+  'onboarding.skip': '跳过',
+  'settings.always_show_spoilers_field': '始终显示内容警告框',
+  'settings.auto_collapse': '自动折叠',
+  'settings.auto_collapse_all': '所有',
+  'settings.auto_collapse_lengthy': '长嘟文',
+  'settings.auto_collapse_media': '带媒体文件的嘟文',
+  'settings.auto_collapse_notifications': '通知',
+  'settings.auto_collapse_reblogs': '转嘟',
+  'settings.auto_collapse_replies': '回复',
+  'settings.close': '关闭',
+  'settings.collapsed_statuses': '折叠嘟文',
+  'settings.compose_box_opts': '撰写框',
+  'settings.confirm_before_clearing_draft': '在覆盖正在写入的嘟文之前显示确认对话框',
+  'settings.confirm_boost_missing_media_description': '在转嘟缺少媒体描述的嘟文之前显示确认对话框',
+  'settings.confirm_missing_media_description': '在发送缺少媒体描述的嘟文之前显示确认对话框',
+  'settings.content_warnings': '内容警告',
+  'settings.content_warnings.regexp': '正则表达式',
+  'settings.content_warnings_filter': '不会自动展开的内容警告:',
+  'settings.enable_collapsed': '启用折叠嘟文',
+  'settings.enable_content_warnings_auto_unfold': '自动展开内容警告',
+  'settings.filtering_behavior': '过滤器行为',
+  'settings.filtering_behavior.cw': '仍然显示嘟文,并在内容警告中添加过滤词',
+  'settings.filtering_behavior.drop': '完全隐藏过滤的嘟文',
+  'settings.filtering_behavior.hide': '显示“已过滤”并添加一个按钮来显示原因',
+  'settings.filtering_behavior.upstream': '像原版 Mastodon 一样显示“已过滤”',
+  'settings.filters': '过滤器',
+  'settings.general': '一般',
+  'settings.hicolor_privacy_icons': '彩色隐私图标 ',
+  'settings.hicolor_privacy_icons.hint': '以明亮且易于区分的颜色显示隐私图标',
+  'settings.image_backgrounds': '图片背景',
+  'settings.image_backgrounds_media': '预览折叠嘟文的媒体文件',
+  'settings.image_backgrounds_users': '为折叠嘟文附加图片背景',
+  'settings.inline_preview_cards': '外部链接的内嵌预览卡片',
+  'settings.layout': '布局:',
+  'settings.layout_opts': '布局选项',
+  'settings.media': '媒体',
+  'settings.media_fullwidth': '全宽媒体预览',
+  'settings.media_letterbox': '信箱媒体',
+  'settings.media_letterbox_hint': '缩小媒体以填充图像容器而不是拉伸和裁剪它们',
+  'settings.media_reveal_behind_cw': '默认显示内容警告后的敏感媒体',
+  'settings.navbar_under': '底部导航栏(仅限于移动模式)',
+  'settings.notifications.favicon_badge': '未读通知网站图标',
+  'settings.notifications.favicon_badge.hint': '将未读通知添加到网站图标',
+  'settings.notifications.tab_badge': '未读通知图标',
+  'settings.notifications.tab_badge.hint': '当通知栏未打开时,显示未读通知图标',
+  'settings.notifications_opts': '通知选项',
+  'settings.pop_in_left': '左边',
+  'settings.pop_in_player': '启用悬浮播放器',
+  'settings.pop_in_position': '悬浮播放器位置:',
+  'settings.pop_in_right': '右边',
+  'settings.preferences': '用户选项',
+  'settings.prepend_cw_re': '回复时在内容警告前加上“re:”',
+  'settings.preselect_on_reply': '回复时预先选择用户名',
+  'settings.preselect_on_reply_hint': '回复与多个参与者的对话时,预先选择第一个用户名',
+  'settings.rewrite_mentions': '重写嘟文中的提及',
+  'settings.rewrite_mentions_acct': '重写为用户名和域名(当帐户为远程时)',
+  'settings.rewrite_mentions_no': '不要重写',
+  'settings.rewrite_mentions_username': '重写为用户名',
+  'settings.show_action_bar': '在折叠的嘟文中显示操作按钮',
+  'settings.show_content_type_choice': '允许你在撰写嘟文时选择格式类型',
+  'settings.show_reply_counter': '显示回复的大致数量',
+  'settings.side_arm': '辅助发嘟按钮:',
+  'settings.side_arm.none': '无',
+  'settings.side_arm_reply_mode': '当回复嘟文时:',
+  'settings.side_arm_reply_mode.copy': '复制被回复嘟文的隐私设置',
+  'settings.side_arm_reply_mode.keep': '保留辅助发嘟按钮以设置隐私',
+  'settings.side_arm_reply_mode.restrict': '将隐私设置限制为正在回复的那条嘟文',
+  'settings.swipe_to_change_columns': '允许滑动以在列之间切换(仅限移动模式)',
+  'settings.tag_misleading_links': '标记误导性链接',
+  'settings.tag_misleading_links.hint': '将带有目标网页链接的视觉指示添加到每个未明确的链接',
+  'settings.wide_view': '宽视图(仅限于桌面模式)',
+  'settings.wide_view_hint': '拉伸列宽以更好地填充可用空间。',
+  'status.collapse': '折叠',
+  'status.has_audio': '附带音频文件',
+  'status.has_pictures': '附带图片文件',
+  'status.has_preview_card': '附带预览卡片',
+  'status.has_video': '附带视频文件',
+  'status.hide': '隐藏内容',
+  'status.in_reply_to': '此嘟文是回复',
+  'status.is_poll': '此嘟文是投票',
+  'status.local_only': '此嘟文仅本实例可见',
+  'status.sensitive_toggle': '点击查看',
+  'status.show_filter_reason': '(显示原因)',
+  'status.uncollapse': '不折叠',
+  'upload_modal.applying': '正在应用...',
+  'web_app_crash.change_your_settings': '更改 {settings}',
+  'web_app_crash.content': '你可以尝试这些:',
+  'web_app_crash.debug_info': '调试信息',
+  'web_app_crash.disable_addons': '禁用浏览器插件或本地翻译工具',
+  'web_app_crash.issue_tracker': '问题追踪器',
+  'web_app_crash.reload': '刷新',
+  'web_app_crash.reload_page': '{reload} 此页面',
+  'web_app_crash.report_issue': '将错误报告给 {issuetracker}',
+  'web_app_crash.settings': '设置',
+  'web_app_crash.title': '抱歉,Mastodon 出了点问题。',
 };
 
-export default Object.assign({}, inherited, messages);
+export default Object.assign({}, inherited, messages);
\ No newline at end of file
diff --git a/app/javascript/flavours/glitch/packs/admin.js b/app/javascript/flavours/glitch/packs/admin.js
new file mode 100644
index 000000000..b26df932c
--- /dev/null
+++ b/app/javascript/flavours/glitch/packs/admin.js
@@ -0,0 +1,48 @@
+import 'packs/public-path';
+import loadPolyfills from 'flavours/glitch/util/load_polyfills';
+import ready from 'flavours/glitch/util/ready';
+import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';
+
+function main() {
+  const { delegate } = require('@rails/ujs');
+
+  ready(() => {
+    const React    = require('react');
+    const ReactDOM = require('react-dom');
+
+    [].forEach.call(document.querySelectorAll('[data-admin-component]'), element => {
+      const componentName  = element.getAttribute('data-admin-component');
+      const { locale, ...componentProps } = JSON.parse(element.getAttribute('data-props'));
+
+      import('flavours/glitch/containers/admin_component').then(({ default: AdminComponent }) => {
+        return import('flavours/glitch/components/admin/' + componentName).then(({ default: Component }) => {
+          ReactDOM.render((
+            <AdminComponent locale={locale}>
+              <Component {...componentProps} />
+            </AdminComponent>
+          ), element);
+        });
+      }).catch(error => {
+        console.error(error);
+      });
+    });
+  });
+
+  delegate(document, '.sidebar__toggle__icon', 'click', () => {
+    const target = document.querySelector('.sidebar ul');
+
+    if (target.style.display === 'block') {
+      target.style.display = 'none';
+    } else {
+      target.style.display = 'block';
+    }
+  });
+}
+
+loadPolyfills()
+  .then(main)
+  .then(loadKeyboardExtensions)
+  .catch(error => {
+    console.error(error);
+
+  });
diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js
index dccdbc8d0..a92f3d5a8 100644
--- a/app/javascript/flavours/glitch/packs/public.js
+++ b/app/javascript/flavours/glitch/packs/public.js
@@ -99,7 +99,9 @@ function main() {
     delegate(document, '#registration_user_password_confirmation,#registration_user_password', 'input', () => {
       const password = document.getElementById('registration_user_password');
       const confirmation = document.getElementById('registration_user_password_confirmation');
-      if (password.value && password.value !== confirmation.value) {
+      if (confirmation.value && confirmation.value.length > password.maxLength) {
+        confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
+      } else if (password.value && password.value !== confirmation.value) {
         confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
       } else {
         confirmation.setCustomValidity('');
@@ -111,7 +113,9 @@ function main() {
       const confirmation = document.getElementById('user_password_confirmation');
       if (!confirmation) return;
 
-      if (password.value && password.value !== confirmation.value) {
+      if (confirmation.value && confirmation.value.length > password.maxLength) {
+        confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
+      } else if (password.value && password.value !== confirmation.value) {
         confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
       } else {
         confirmation.setCustomValidity('');
diff --git a/app/javascript/flavours/glitch/reducers/accounts_map.js b/app/javascript/flavours/glitch/reducers/accounts_map.js
new file mode 100644
index 000000000..e0d42e9cd
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/accounts_map.js
@@ -0,0 +1,15 @@
+import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
+import { Map as ImmutableMap } from 'immutable';
+
+const initialState = ImmutableMap();
+
+export default function accountsMap(state = initialState, action) {
+  switch(action.type) {
+  case ACCOUNT_IMPORT:
+    return state.set(action.account.acct, action.account.id);
+  case ACCOUNTS_IMPORT:
+    return state.withMutations(map => action.accounts.forEach(account => map.set(account.acct, account.id)));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index e989401d8..d2ea0a924 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -22,6 +22,7 @@ import {
   COMPOSE_SUGGESTIONS_CLEAR,
   COMPOSE_SUGGESTIONS_READY,
   COMPOSE_SUGGESTION_SELECT,
+  COMPOSE_SUGGESTION_IGNORE,
   COMPOSE_SUGGESTION_TAGS_UPDATE,
   COMPOSE_TAG_HISTORY_UPDATE,
   COMPOSE_ADVANCED_OPTIONS_CHANGE,
@@ -42,6 +43,9 @@ import {
   COMPOSE_POLL_OPTION_CHANGE,
   COMPOSE_POLL_OPTION_REMOVE,
   COMPOSE_POLL_SETTINGS_CHANGE,
+  INIT_MEDIA_EDIT_MODAL,
+  COMPOSE_CHANGE_MEDIA_DESCRIPTION,
+  COMPOSE_CHANGE_MEDIA_FOCUS,
 } from 'flavours/glitch/actions/compose';
 import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
 import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
@@ -97,6 +101,13 @@ const initialState = ImmutableMap({
   resetFileKey: Math.floor((Math.random() * 0x10000)),
   idempotencyKey: null,
   tagHistory: ImmutableList(),
+  media_modal: ImmutableMap({
+    id: null,
+    description: '',
+    focusX: 0,
+    focusY: 0,
+    dirty: false,
+  }),
   doodle: ImmutableMap({
     fg: 'rgb(  0,    0,    0)',
     bg: 'rgb(255,  255,  255)',
@@ -242,6 +253,17 @@ const insertSuggestion = (state, position, token, completion, path) => {
   });
 };
 
+const ignoreSuggestion = (state, position, token, completion, path) => {
+  return state.withMutations(map => {
+    map.updateIn(path, oldText => `${oldText.slice(0, position + token.length)} ${oldText.slice(position + token.length)}`);
+    map.set('suggestion_token', null);
+    map.set('suggestions', ImmutableList());
+    map.set('focusDate', new Date());
+    map.set('caretPosition', position + token.length + 1);
+    map.set('idempotencyKey', uuid());
+  });
+};
+
 const sortHashtagsByUse = (state, tags) => {
   const personalHistory = state.get('tagHistory');
 
@@ -455,6 +477,19 @@ export default function compose(state = initialState, action) {
 
         return item;
       }));
+  case INIT_MEDIA_EDIT_MODAL:
+    const media =  state.get('media_attachments').find(item => item.get('id') === action.id);
+    return state.set('media_modal', ImmutableMap({
+      id: action.id,
+      description: media.get('description') || '',
+      focusX: media.getIn(['meta', 'focus', 'x'], 0),
+      focusY: media.getIn(['meta', 'focus', 'y'], 0),
+      dirty: false,
+    }));
+  case COMPOSE_CHANGE_MEDIA_DESCRIPTION:
+    return state.setIn(['media_modal', 'description'], action.description).setIn(['media_modal', 'dirty'], true);
+  case COMPOSE_CHANGE_MEDIA_FOCUS:
+    return state.setIn(['media_modal', 'focusX'], action.focusX).setIn(['media_modal', 'focusY'], action.focusY).setIn(['media_modal', 'dirty'], true);
   case COMPOSE_MENTION:
     return state.withMutations(map => {
       map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
@@ -476,6 +511,8 @@ export default function compose(state = initialState, action) {
     return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token);
   case COMPOSE_SUGGESTION_SELECT:
     return insertSuggestion(state, action.position, action.token, action.completion, action.path);
+  case COMPOSE_SUGGESTION_IGNORE:
+    return ignoreSuggestion(state, action.position, action.token, action.completion, action.path);
   case COMPOSE_SUGGESTION_TAGS_UPDATE:
     return updateSuggestionTags(state, action.token);
   case COMPOSE_TAG_HISTORY_UPDATE:
@@ -491,6 +528,7 @@ export default function compose(state = initialState, action) {
   case COMPOSE_UPLOAD_CHANGE_SUCCESS:
     return state
       .set('is_changing_upload', false)
+      .setIn(['media_modal', 'dirty'], false)
       .update('media_attachments', list => list.map(item => {
         if (item.get('id') === action.media.id) {
           return fromJS(action.media);
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index c452e834c..7d7fe6fd3 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -40,6 +40,7 @@ import announcements from './announcements';
 import markers from './markers';
 import account_notes from './account_notes';
 import picture_in_picture from './picture_in_picture';
+import accounts_map from './accounts_map';
 
 const reducers = {
   announcements,
@@ -54,6 +55,7 @@ const reducers = {
   status_lists,
   accounts,
   accounts_counters,
+  accounts_map,
   statuses,
   relationships,
   settings,
diff --git a/app/javascript/flavours/glitch/reducers/modal.js b/app/javascript/flavours/glitch/reducers/modal.js
index 52b05d69b..ae205c6d5 100644
--- a/app/javascript/flavours/glitch/reducers/modal.js
+++ b/app/javascript/flavours/glitch/reducers/modal.js
@@ -1,19 +1,18 @@
 import { MODAL_OPEN, MODAL_CLOSE } from 'flavours/glitch/actions/modal';
 import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
+import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from 'flavours/glitch/actions/compose';
+import { Stack as ImmutableStack, Map as ImmutableMap } from 'immutable';
 
-const initialState = {
-  modalType: null,
-  modalProps: {},
-};
-
-export default function modal(state = initialState, action) {
+export default function modal(state = ImmutableStack(), action) {
   switch(action.type) {
   case MODAL_OPEN:
-    return { modalType: action.modalType, modalProps: action.modalProps };
+    return state.unshift(ImmutableMap({ modalType: action.modalType, modalProps: action.modalProps }));
   case MODAL_CLOSE:
-    return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state;
+    return (action.modalType === undefined || action.modalType === state.getIn([0, 'modalType'])) ? state.shift() : state;
+  case COMPOSE_UPLOAD_CHANGE_SUCCESS:
+    return state.getIn([0, 'modalType']) === 'FOCAL_POINT' ? state.shift() : state;
   case TIMELINE_DELETE:
-    return (state.modalProps.statusId === action.id) ? initialState : state;
+    return state.filterNot((modal) => modal.get('modalProps').statusId === action.id);
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/styles/accounts.scss b/app/javascript/flavours/glitch/styles/accounts.scss
index d66a2b237..920aa6331 100644
--- a/app/javascript/flavours/glitch/styles/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/accounts.scss
@@ -326,3 +326,45 @@
     margin-top: 10px;
   }
 }
+
+.batch-table__row--muted {
+  color: lighten($ui-base-color, 26%);
+}
+
+.batch-table__row--muted .pending-account__header,
+.batch-table__row--muted .accounts-table {
+  &,
+  a,
+  strong {
+    color: lighten($ui-base-color, 26%);
+  }
+}
+
+.batch-table__row--muted .accounts-table {
+  tbody td.accounts-table__extra,
+  &__count,
+  &__count small {
+    color: lighten($ui-base-color, 26%);
+  }
+}
+
+.batch-table__row--attention {
+  color: $gold-star;
+}
+
+.batch-table__row--attention .pending-account__header,
+.batch-table__row--attention .accounts-table {
+  &,
+  a,
+  strong {
+    color: $gold-star;
+  }
+}
+
+.batch-table__row--attention .accounts-table {
+  tbody td.accounts-table__extra,
+  &__count,
+  &__count small {
+    color: $gold-star;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
index 4801a4644..92061585a 100644
--- a/app/javascript/flavours/glitch/styles/admin.scss
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -1,3 +1,5 @@
+@use "sass:math";
+
 $no-columns-breakpoint: 600px;
 $sidebar-width: 240px;
 $content-width: 840px;
@@ -593,39 +595,44 @@ body,
 
 .log-entry {
   line-height: 20px;
-  padding: 15px 0;
+  padding: 15px;
+  padding-left: 15px * 2 + 40px;
   background: $ui-base-color;
-  border-bottom: 1px solid lighten($ui-base-color, 4%);
+  border-bottom: 1px solid darken($ui-base-color, 8%);
+  position: relative;
+
+  &:first-child {
+    border-top-left-radius: 4px;
+    border-top-right-radius: 4px;
+  }
 
   &:last-child {
+    border-bottom-left-radius: 4px;
+    border-bottom-right-radius: 4px;
     border-bottom: 0;
   }
 
+  &:hover {
+    background: lighten($ui-base-color, 4%);
+  }
+
   &__header {
-    display: flex;
-    justify-content: flex-start;
-    align-items: center;
     color: $darker-text-color;
     font-size: 14px;
-    padding: 0 10px;
   }
 
   &__avatar {
-    margin-right: 10px;
+    position: absolute;
+    left: 15px;
+    top: 15px;
 
     .avatar {
-      display: block;
-      margin: 0;
-      border-radius: 50%;
+      border-radius: 4px;
       width: 40px;
       height: 40px;
     }
   }
 
-  &__content {
-    max-width: calc(100% - 90px);
-  }
-
   &__title {
     word-wrap: break-word;
   }
@@ -641,6 +648,14 @@ body,
     text-decoration: none;
     font-weight: 500;
   }
+
+  a {
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
 }
 
 a.name-tag,
@@ -669,8 +684,9 @@ a.inline-name-tag,
 
 a.name-tag,
 .name-tag {
-  display: flex;
+  display: inline-flex;
   align-items: center;
+  vertical-align: top;
 
   .avatar {
     display: block;
@@ -845,6 +861,7 @@ a.name-tag,
     padding: 0 5px;
     margin-bottom: 10px;
     flex: 1 0 50%;
+    max-width: 100%;
   }
 
   .account__header__fields,
@@ -925,10 +942,489 @@ a.name-tag,
   }
 }
 
+.dashboard__counters.admin-account-counters {
+  margin-top: 10px;
+}
+
 .account-badges {
   margin: -2px 0;
 }
 
-.dashboard__counters.admin-account-counters {
-  margin-top: 10px;
+.retention {
+  overflow: auto;
+
+  > h4 {
+    position: sticky;
+    left: 0;
+  }
+
+  &__table {
+    &__number {
+      color: $secondary-text-color;
+      padding: 10px;
+    }
+
+    &__date {
+      white-space: nowrap;
+      padding: 10px 0;
+      text-align: left;
+      min-width: 120px;
+
+      &.retention__table__average {
+        font-weight: 700;
+      }
+    }
+
+    &__size {
+      text-align: center;
+      padding: 10px;
+    }
+
+    &__label {
+      font-weight: 700;
+      color: $darker-text-color;
+    }
+
+    &__box {
+      box-sizing: border-box;
+      background: $ui-highlight-color;
+      padding: 10px;
+      font-weight: 500;
+      color: $primary-text-color;
+      width: 52px;
+      margin: 1px;
+
+      @for $i from 0 through 10 {
+        &--#{10 * $i} {
+          background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10)));
+        }
+      }
+    }
+  }
+}
+
+.sparkline {
+  display: block;
+  text-decoration: none;
+  background: lighten($ui-base-color, 4%);
+  border-radius: 4px;
+  padding: 0;
+  position: relative;
+  padding-bottom: 55px + 20px;
+  overflow: hidden;
+
+  &__value {
+    display: flex;
+    line-height: 33px;
+    align-items: flex-end;
+    padding: 20px;
+    padding-bottom: 10px;
+
+    &__total {
+      display: block;
+      margin-right: 10px;
+      font-weight: 500;
+      font-size: 28px;
+      color: $primary-text-color;
+    }
+
+    &__change {
+      display: block;
+      font-weight: 500;
+      font-size: 18px;
+      color: $darker-text-color;
+      margin-bottom: -3px;
+
+      &.positive {
+        color: $valid-value-color;
+      }
+
+      &.negative {
+        color: $error-value-color;
+      }
+    }
+  }
+
+  &__label {
+    padding: 0 20px;
+    padding-bottom: 10px;
+    text-transform: uppercase;
+    color: $darker-text-color;
+    font-weight: 500;
+  }
+
+  &__graph {
+    position: absolute;
+    bottom: 0;
+    width: 100%;
+
+    svg {
+      display: block;
+      margin: 0;
+    }
+
+    path:first-child {
+      fill: rgba($highlight-text-color, 0.25) !important;
+      fill-opacity: 1 !important;
+    }
+
+    path:last-child {
+      stroke: lighten($highlight-text-color, 6%) !important;
+      fill: none !important;
+    }
+  }
+}
+
+a.sparkline {
+  &:hover,
+  &:focus,
+  &:active {
+    background: lighten($ui-base-color, 6%);
+  }
+}
+
+.skeleton {
+  background-color: lighten($ui-base-color, 8%);
+  background-image: linear-gradient(90deg, lighten($ui-base-color, 8%), lighten($ui-base-color, 12%), lighten($ui-base-color, 8%));
+  background-size: 200px 100%;
+  background-repeat: no-repeat;
+  border-radius: 4px;
+  display: inline-block;
+  line-height: 1;
+  width: 100%;
+  animation: skeleton 1.2s ease-in-out infinite;
+}
+
+@keyframes skeleton {
+  0% {
+    background-position: -200px 0;
+  }
+
+  100% {
+    background-position: calc(200px + 100%) 0;
+  }
+}
+
+.dimension {
+  table {
+    width: 100%;
+  }
+
+  &__item {
+    border-bottom: 1px solid lighten($ui-base-color, 4%);
+
+    &__key {
+      font-weight: 500;
+      padding: 11px 10px;
+    }
+
+    &__value {
+      text-align: right;
+      color: $darker-text-color;
+      padding: 11px 10px;
+    }
+
+    &__indicator {
+      display: inline-block;
+      width: 8px;
+      height: 8px;
+      border-radius: 50%;
+      background: $ui-highlight-color;
+      margin-right: 10px;
+
+      @for $i from 0 through 10 {
+        &--#{10 * $i} {
+          background-color: rgba($ui-highlight-color, 1 * (math.div(max(1, $i), 10)));
+        }
+      }
+    }
+
+    &:last-child {
+      border-bottom: 0;
+    }
+  }
+}
+
+.report-reason-selector {
+  border-radius: 4px;
+  background: $ui-base-color;
+  margin-bottom: 20px;
+
+  &__category {
+    cursor: pointer;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    &__label {
+      padding: 15px;
+    }
+
+    &__rules {
+      margin-left: 30px;
+    }
+  }
+
+  &__rule {
+    cursor: pointer;
+    padding: 15px;
+  }
+}
+
+.report-header {
+  display: grid;
+  grid-gap: 15px;
+  grid-template-columns: minmax(0, 1fr) 300px;
+
+  &__details {
+    &__item {
+      border-bottom: 1px solid lighten($ui-base-color, 8%);
+      padding: 15px 0;
+
+      &:last-child {
+        border-bottom: 0;
+      }
+
+      &__header {
+        font-weight: 600;
+        padding: 4px 0;
+      }
+    }
+
+    &--horizontal {
+      display: grid;
+      grid-auto-columns: minmax(0, 1fr);
+      grid-auto-flow: column;
+
+      .report-header__details__item {
+        border-bottom: 0;
+      }
+    }
+  }
+}
+
+.account-card {
+  background: $ui-base-color;
+  border-radius: 4px;
+
+  &__header {
+    padding: 4px;
+    border-radius: 4px;
+    height: 128px;
+
+    img {
+      display: block;
+      margin: 0;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      background: darken($ui-base-color, 8%);
+    }
+  }
+
+  &__title {
+    margin-top: -25px;
+    display: flex;
+    align-items: flex-end;
+
+    &__avatar {
+      padding: 15px;
+
+      img {
+        display: block;
+        margin: 0;
+        width: 56px;
+        height: 56px;
+        background: darken($ui-base-color, 8%);
+        border-radius: 8px;
+      }
+    }
+
+    .display-name {
+      color: $darker-text-color;
+      padding-bottom: 15px;
+      font-size: 15px;
+
+      bdi {
+        display: block;
+        color: $primary-text-color;
+        font-weight: 500;
+      }
+    }
+  }
+
+  &__bio {
+    padding: 0 15px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    word-wrap: break-word;
+    max-height: 18px * 2;
+    position: relative;
+
+    &::after {
+      display: block;
+      content: "";
+      width: 50px;
+      height: 18px;
+      position: absolute;
+      bottom: 0;
+      right: 15px;
+      background: linear-gradient(to left, $ui-base-color, transparent);
+      pointer-events: none;
+    }
+  }
+
+  &__actions {
+    display: flex;
+    align-items: center;
+    padding-top: 10px;
+
+    &__button {
+      flex: 0 0 auto;
+      padding: 0 15px;
+    }
+  }
+
+  &__counters {
+    flex: 1 1 auto;
+    display: grid;
+    grid-auto-columns: minmax(0, 1fr);
+    grid-auto-flow: column;
+
+    &__item {
+      padding: 15px;
+      text-align: center;
+      color: $primary-text-color;
+      font-weight: 600;
+      font-size: 15px;
+
+      small {
+        display: block;
+        color: $darker-text-color;
+        font-weight: 400;
+        font-size: 13px;
+      }
+    }
+  }
+}
+
+.report-notes {
+  margin-bottom: 20px;
+
+  &__item {
+    background: $ui-base-color;
+    position: relative;
+    padding: 15px;
+    padding-left: 15px * 2 + 40px;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:first-child {
+      border-top-left-radius: 4px;
+      border-top-right-radius: 4px;
+    }
+
+    &:last-child {
+      border-bottom-left-radius: 4px;
+      border-bottom-right-radius: 4px;
+      border-bottom: 0;
+    }
+
+    &:hover {
+      background-color: lighten($ui-base-color, 4%);
+    }
+
+    &__avatar {
+      position: absolute;
+      left: 15px;
+      top: 15px;
+      border-radius: 4px;
+      width: 40px;
+      height: 40px;
+    }
+
+    &__header {
+      color: $darker-text-color;
+      font-size: 15px;
+      line-height: 20px;
+      margin-bottom: 4px;
+
+      .username a {
+        color: $primary-text-color;
+        font-weight: 500;
+        text-decoration: none;
+        margin-right: 5px;
+
+        &:hover,
+        &:focus,
+        &:active {
+          text-decoration: underline;
+        }
+      }
+
+      time {
+        margin-left: 5px;
+        vertical-align: baseline;
+      }
+    }
+
+    &__content {
+      font-size: 15px;
+      line-height: 20px;
+      word-wrap: break-word;
+      font-weight: 400;
+      color: $primary-text-color;
+
+      p {
+        margin-bottom: 20px;
+        white-space: pre-wrap;
+        unicode-bidi: plaintext;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+      }
+    }
+
+    &__actions {
+      position: absolute;
+      top: 15px;
+      right: 15px;
+      text-align: right;
+    }
+  }
+}
+
+.report-actions {
+  border: 1px solid darken($ui-base-color, 8%);
+
+  &__item {
+    display: flex;
+    align-items: center;
+    line-height: 18px;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    &__button {
+      flex: 0 0 auto;
+      width: 100px;
+      padding: 15px;
+      padding-right: 0;
+
+      .button {
+        display: block;
+        width: 100%;
+      }
+    }
+
+    &__description {
+      padding: 15px;
+      font-size: 14px;
+      color: $dark-text-color;
+    }
+  }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss
index ad17ed4b0..512a04376 100644
--- a/app/javascript/flavours/glitch/styles/components/columns.scss
+++ b/app/javascript/flavours/glitch/styles/components/columns.scss
@@ -437,12 +437,17 @@
 }
 
 .column-header__setting-btn {
-  &:hover {
+  &:hover,
+  &:focus {
     color: $darker-text-color;
     text-decoration: underline;
   }
 }
 
+.column-header__collapsible__extra + .column-header__setting-btn {
+  padding-top: 5px;
+}
+
 .column-header__permission-btn {
   display: inline;
   font-weight: inherit;
@@ -453,10 +458,15 @@
   float: right;
 
   .column-header__setting-btn {
-    padding: 0 10px;
+    padding: 5px;
+
+    &:first-child {
+      padding-right: 7px;
+    }
 
     &:last-child {
-      padding-right: 0;
+      padding-left: 7px;
+      margin-left: 5px;
     }
   }
 }
@@ -718,7 +728,8 @@
     }
 
     &__multi-value__label,
-    &__input {
+    &__input,
+    &__input-container {
       color: $darker-text-color;
     }
 
diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss
index edc16e250..dfb9dc595 100644
--- a/app/javascript/flavours/glitch/styles/components/drawer.scss
+++ b/app/javascript/flavours/glitch/styles/components/drawer.scss
@@ -124,20 +124,22 @@
 }
 
 .drawer--results {
-  background: $ui-base-color;
-  overflow: hidden;
-  display: flex;
-  flex-direction: column;
-  flex: 1 1 auto;
+  overflow-x: hidden;
+  overflow-y: scroll;
+}
 
-  & > header {
-    color: $dark-text-color;
-    background: lighten($ui-base-color, 2%);
+.search-results__section {
+  margin-bottom: 5px;
+
+  h5 {
+    background: darken($ui-base-color, 4%);
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+    cursor: default;
+    display: flex;
     padding: 15px;
     font-weight: 500;
     font-size: 16px;
-    cursor: default;
-    flex: 0 0 auto;
+    color: $dark-text-color;
 
     .fa {
       display: inline-block;
@@ -145,48 +147,22 @@
     }
   }
 
-  & > .search-results__contents {
-    overflow-x: hidden;
-    overflow-y: scroll;
-    flex: 1 1 auto;
-
-    & > section {
-      margin-bottom: 5px;
-
-      h5 {
-        background: darken($ui-base-color, 4%);
-        border-bottom: 1px solid lighten($ui-base-color, 8%);
-        cursor: default;
-        display: flex;
-        padding: 15px;
-        font-weight: 500;
-        font-size: 16px;
-        color: $dark-text-color;
-
-        .fa {
-          display: inline-block;
-          margin-right: 5px;
-        }
-      }
+  .account:last-child,
+  & > div:last-child .status {
+    border-bottom: 0;
+  }
 
-      .account:last-child,
-      & > div:last-child .status {
-        border-bottom: 0;
-      }
+  & > .hashtag {
+    display: block;
+    padding: 10px;
+    color: $secondary-text-color;
+    text-decoration: none;
 
-      & > .hashtag {
-        display: block;
-        padding: 10px;
-        color: $secondary-text-color;
-        text-decoration: none;
-
-        &:hover,
-        &:active,
-        &:focus {
-          color: lighten($secondary-text-color, 4%);
-          text-decoration: underline;
-        }
-      }
+    &:hover,
+    &:active,
+    &:focus {
+      color: lighten($secondary-text-color, 4%);
+      text-decoration: underline;
     }
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 24f750e1d..2656890d7 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -829,7 +829,7 @@
   transition: background-color 0.2s ease;
 }
 
-.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
+.react-toggle:is(:hover, :focus-within):not(.react-toggle--disabled) .react-toggle-track {
   background-color: darken($ui-base-color, 10%);
 }
 
@@ -837,7 +837,7 @@
   background-color: $ui-highlight-color;
 }
 
-.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track {
+.react-toggle--checked:is(:hover, :focus-within):not(.react-toggle--disabled) .react-toggle-track {
   background-color: lighten($ui-highlight-color, 10%);
 }
 
@@ -977,13 +977,13 @@
     }
 
     @media screen and (max-height: 810px) {
-      .trends__item:nth-child(3) {
+      .trends__item:nth-of-type(3) {
         display: none;
       }
     }
 
     @media screen and (max-height: 720px) {
-      .trends__item:nth-child(2) {
+      .trends__item:nth-of-type(2) {
         display: none;
       }
     }
@@ -1040,6 +1040,7 @@
   background: transparent;
   border: 0;
   border-bottom: 2px solid $ui-primary-color;
+  outline: 0;
   box-sizing: border-box;
   display: block;
   font-family: inherit;
diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss
index 855cd07a9..8a551be73 100644
--- a/app/javascript/flavours/glitch/styles/components/media.scss
+++ b/app/javascript/flavours/glitch/styles/components/media.scss
@@ -400,7 +400,8 @@
     opacity: 0.2;
   }
 
-  .video-player__buttons button {
+  .video-player__buttons button,
+  .video-player__buttons a {
     color: currentColor;
     opacity: 0.75;
 
diff --git a/app/javascript/flavours/glitch/styles/components/search.scss b/app/javascript/flavours/glitch/styles/components/search.scss
index eec2e64d6..f7415368b 100644
--- a/app/javascript/flavours/glitch/styles/components/search.scss
+++ b/app/javascript/flavours/glitch/styles/components/search.scss
@@ -94,10 +94,15 @@
 .search-results__header {
   color: $dark-text-color;
   background: lighten($ui-base-color, 2%);
-  border-bottom: 1px solid darken($ui-base-color, 4%);
-  padding: 15px 10px;
-  font-size: 14px;
+  padding: 15px;
   font-weight: 500;
+  font-size: 16px;
+  cursor: default;
+
+  .fa {
+    display: inline-block;
+    margin-right: 5px;
+  }
 }
 
 .search-results__info {
@@ -166,7 +171,6 @@
     &__current {
       flex: 0 0 auto;
       font-size: 24px;
-      line-height: 36px;
       font-weight: 500;
       text-align: right;
       padding-right: 15px;
@@ -188,5 +192,57 @@
         fill: none !important;
       }
     }
+
+    &--requires-review {
+      .trends__item__name {
+        color: $gold-star;
+
+        a {
+          color: $gold-star;
+        }
+      }
+
+      .trends__item__current {
+        color: $gold-star;
+      }
+
+      .trends__item__sparkline {
+        path:first-child {
+          fill: rgba($gold-star, 0.25) !important;
+        }
+
+        path:last-child {
+          stroke: lighten($gold-star, 6%) !important;
+        }
+      }
+    }
+
+    &--disabled {
+      .trends__item__name {
+        color: lighten($ui-base-color, 12%);
+
+        a {
+          color: lighten($ui-base-color, 12%);
+        }
+      }
+
+      .trends__item__current {
+        color: lighten($ui-base-color, 12%);
+      }
+
+      .trends__item__sparkline {
+        path:first-child {
+          fill: rgba(lighten($ui-base-color, 12%), 0.25) !important;
+        }
+
+        path:last-child {
+          stroke: lighten(lighten($ui-base-color, 12%), 6%) !important;
+        }
+      }
+    }
+  }
+
+  &--compact &__item {
+    padding: 10px;
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index 69c9a6fe3..d9154e4c7 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -205,6 +205,17 @@
   }
 }
 
+.status__content__edited-label {
+  display: block;
+  cursor: default;
+  font-size: 15px;
+  line-height: 20px;
+  padding: 0;
+  padding-top: 8px;
+  color: $dark-text-color;
+  font-weight: 500;
+}
+
 .status__content__spoiler-link {
   display: inline-block;
   border-radius: 2px;
@@ -1103,6 +1114,7 @@ a.status-card.compact:hover {
     &__account {
       display: flex;
       text-decoration: none;
+      overflow: hidden;
     }
 
     .account__avatar {
diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss
index 63374f3c3..eb72eab28 100644
--- a/app/javascript/flavours/glitch/styles/containers.scss
+++ b/app/javascript/flavours/glitch/styles/containers.scss
@@ -44,7 +44,7 @@
 }
 
 .compose-standalone {
-  .compose-form {
+  .composer {
     width: 400px;
     margin: 0 auto;
     padding: 20px 0;
diff --git a/app/javascript/flavours/glitch/styles/contrast/diff.scss b/app/javascript/flavours/glitch/styles/contrast/diff.scss
index 0f3a6cc6d..9bd31cd7e 100644
--- a/app/javascript/flavours/glitch/styles/contrast/diff.scss
+++ b/app/javascript/flavours/glitch/styles/contrast/diff.scss
@@ -1,17 +1,4 @@
 // components.scss
-.compose-form {
-  .compose-form__modifiers {
-    .compose-form__upload {
-      &-description {
-        input {
-          &::placeholder {
-            opacity: 1.0;
-          }
-        }
-      }
-    }
-  }
-}
 
 .rich-formatting a,
 .rich-formatting p a,
diff --git a/app/javascript/flavours/glitch/styles/dashboard.scss b/app/javascript/flavours/glitch/styles/dashboard.scss
index c0944d417..0a881bc10 100644
--- a/app/javascript/flavours/glitch/styles/dashboard.scss
+++ b/app/javascript/flavours/glitch/styles/dashboard.scss
@@ -56,23 +56,70 @@
   }
 }
 
-.dashboard__widgets {
-  display: flex;
-  flex-wrap: wrap;
-  margin: 0 -5px;
+.dashboard {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
+  grid-gap: 10px;
 
-  & > div {
-    flex: 0 0 33.333%;
-    margin-bottom: 20px;
+  @media screen and (max-width: 1350px) {
+    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  }
 
-    & > div {
-      padding: 0 5px;
+  &__item {
+    &--span-double-column {
+      grid-column: span 2;
+    }
+
+    &--span-double-row {
+      grid-row: span 2;
+    }
+
+    h4 {
+      padding-top: 20px;
     }
   }
 
-  a:not(.name-tag) {
-    color: $ui-secondary-color;
-    font-weight: 500;
+  &__quick-access {
+    display: flex;
+    align-items: baseline;
+    border-radius: 4px;
+    background: $ui-highlight-color;
+    color: $primary-text-color;
+    transition: all 100ms ease-in;
+    font-size: 14px;
+    padding: 0 16px;
+    line-height: 36px;
+    height: 36px;
     text-decoration: none;
+    margin-bottom: 4px;
+
+    &:active,
+    &:focus,
+    &:hover {
+      background-color: lighten($ui-highlight-color, 10%);
+      transition: all 200ms ease-out;
+    }
+
+    &.positive {
+      background: lighten($ui-base-color, 4%);
+      color: $valid-value-color;
+    }
+
+    &.negative {
+      background: lighten($ui-base-color, 4%);
+      color: $error-value-color;
+    }
+
+    span {
+      flex: 1 1 auto;
+    }
+
+    .fa {
+      flex: 0 0 auto;
+    }
+
+    strong {
+      font-weight: 700;
+    }
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index 0d8c35a764..034350525 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -1052,3 +1052,7 @@ code {
     display: none;
   }
 }
+
+.simple_form .h-captcha {
+  text-align: center;
+}
diff --git a/app/javascript/flavours/glitch/styles/polls.scss b/app/javascript/flavours/glitch/styles/polls.scss
index 5fc41ed9e..a2cdecf06 100644
--- a/app/javascript/flavours/glitch/styles/polls.scss
+++ b/app/javascript/flavours/glitch/styles/polls.scss
@@ -150,6 +150,21 @@
     &:active {
       outline: 0 !important;
     }
+
+    &.disabled {
+      border-color: $dark-text-color;
+
+      &.active {
+        background: $dark-text-color;
+      }
+
+      &:active,
+      &:focus,
+      &:hover {
+        border-color: $dark-text-color;
+        border-width: 1px;
+      }
+    }
   }
 
   &__number {
diff --git a/app/javascript/flavours/glitch/styles/rtl.scss b/app/javascript/flavours/glitch/styles/rtl.scss
index f6a90d271..afa05d93e 100644
--- a/app/javascript/flavours/glitch/styles/rtl.scss
+++ b/app/javascript/flavours/glitch/styles/rtl.scss
@@ -51,7 +51,7 @@ body.rtl {
     margin-left: 5px;
   }
 
-  .compose-form .compose-form__buttons-wrapper .character-counter__wrapper {
+  .composer .compose--counter-wrapper {
     margin-right: 0;
     margin-left: 4px;
   }
@@ -112,6 +112,20 @@ body.rtl {
 
   .column-header__setting-arrows {
     float: left;
+
+    .column-header__setting-btn {
+      &:first-child {
+        padding-left: 7px;
+        padding-right: 5px;
+      }
+
+      &:last-child {
+        padding-right: 7px;
+        padding-left: 5px;
+        margin-right: 5px;
+        margin-left: 0;
+      }
+    }
   }
 
   .setting-toggle__label {
@@ -428,11 +442,6 @@ body.rtl {
     margin-left: 5px;
   }
 
-  .column-header__setting-arrows .column-header__setting-btn:last-child {
-    padding-left: 0;
-    padding-right: 10px;
-  }
-
   .simple_form .input.radio_buttons .radio > label input {
     left: auto;
     right: 0;
diff --git a/app/javascript/flavours/glitch/styles/tables.scss b/app/javascript/flavours/glitch/styles/tables.scss
index ec2ee7c1c..12c84a6c9 100644
--- a/app/javascript/flavours/glitch/styles/tables.scss
+++ b/app/javascript/flavours/glitch/styles/tables.scss
@@ -237,6 +237,11 @@ a.table-action-link {
         flex: 1 1 auto;
       }
 
+      &__quote {
+        padding: 12px;
+        padding-top: 0;
+      }
+
       &__extra {
         flex: 0 0 auto;
         text-align: right;
diff --git a/app/javascript/flavours/glitch/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss
index 06bf55e1e..a88f3b2c7 100644
--- a/app/javascript/flavours/glitch/styles/widgets.scss
+++ b/app/javascript/flavours/glitch/styles/widgets.scss
@@ -434,6 +434,24 @@
     }
   }
 
+  tbody td.accounts-table__extra {
+    width: 120px;
+    text-align: right;
+    color: $darker-text-color;
+    padding-right: 16px;
+
+    a {
+      text-decoration: none;
+      color: inherit;
+
+      &:focus,
+      &:hover,
+      &:active {
+        text-decoration: underline;
+      }
+    }
+  }
+
   &__comment {
     width: 50%;
     vertical-align: initial !important;
diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml
index 2a98e4c29..ee2b699b2 100644
--- a/app/javascript/flavours/glitch/theme.yml
+++ b/app/javascript/flavours/glitch/theme.yml
@@ -1,7 +1,7 @@
 #  (REQUIRED) The location of the pack files.
 pack:
   about: packs/about.js
-  admin: packs/public.js
+  admin: packs/admin.js
   auth: packs/public.js
   common:
     filename: packs/common.js
diff --git a/app/javascript/flavours/glitch/util/api.js b/app/javascript/flavours/glitch/util/api.js
index c59a24518..90d8465ef 100644
--- a/app/javascript/flavours/glitch/util/api.js
+++ b/app/javascript/flavours/glitch/util/api.js
@@ -12,21 +12,35 @@ export const getLinks = response => {
   return LinkHeader.parse(value);
 };
 
-let csrfHeader = {};
+const csrfHeader = {};
 
-function setCSRFHeader() {
+const setCSRFHeader = () => {
   const csrfToken = document.querySelector('meta[name=csrf-token]');
+
   if (csrfToken) {
     csrfHeader['X-CSRF-Token'] = csrfToken.content;
   }
-}
+};
 
 ready(setCSRFHeader);
 
+const authorizationHeaderFromState = getState => {
+  const accessToken = getState && getState().getIn(['meta', 'access_token'], '');
+
+  if (!accessToken) {
+    return {};
+  }
+
+  return {
+    'Authorization': `Bearer ${accessToken}`,
+  };
+};
+
 export default getState => axios.create({
-  headers: Object.assign(csrfHeader, getState ? {
-    'Authorization': `Bearer ${getState().getIn(['meta', 'access_token'], '')}`,
-  } : {}),
+  headers: {
+    ...csrfHeader,
+    ...authorizationHeaderFromState(getState),
+  },
 
   transformResponse: [function (data) {
     try {
diff --git a/app/javascript/flavours/glitch/util/backend_links.js b/app/javascript/flavours/glitch/util/backend_links.js
index 0fb378cc1..2e5111a7f 100644
--- a/app/javascript/flavours/glitch/util/backend_links.js
+++ b/app/javascript/flavours/glitch/util/backend_links.js
@@ -3,7 +3,7 @@ export const profileLink = '/settings/profile';
 export const signOutLink = '/auth/sign_out';
 export const termsLink = '/terms';
 export const accountAdminLink = (id) => `/admin/accounts/${id}`;
-export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`;
+export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses?id=${status_id}`;
 export const filterEditLink = (id) => `/filters/${id}/edit`;
 export const relationshipsLink = '/relationships';
 export const securityLink = '/auth/edit';
diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js
index 370d982d2..7154e020b 100644
--- a/app/javascript/flavours/glitch/util/initial_state.js
+++ b/app/javascript/flavours/glitch/util/initial_state.js
@@ -25,6 +25,8 @@ export const maxChars = (initialState && initialState.max_toot_chars) || 500;
 export const pollLimits = (initialState && initialState.poll_limits);
 export const invitesEnabled = getMeta('invites_enabled');
 export const limitedFederationMode = getMeta('limited_federation_mode');
+export const repository = getMeta('repository');
+export const source_url = getMeta('source_url');
 export const version = getMeta('version');
 export const mascot = getMeta('mascot');
 export const profile_directory = getMeta('profile_directory');
diff --git a/app/javascript/flavours/glitch/util/numbers.js b/app/javascript/flavours/glitch/util/numbers.js
index 6f2505cae..6ef563ad8 100644
--- a/app/javascript/flavours/glitch/util/numbers.js
+++ b/app/javascript/flavours/glitch/util/numbers.js
@@ -69,3 +69,11 @@ export function pluralReady(sourceNumber, division) {
 
   return Math.trunc(sourceNumber / closestScale) * closestScale;
 }
+
+/**
+ * @param {number} num
+ * @returns {number}
+ */
+export function roundTo10(num) {
+  return Math.round(num * 0.1) / 0.1;
+}
diff --git a/app/javascript/flavours/vanilla/theme.yml b/app/javascript/flavours/vanilla/theme.yml
index 74e9fb1b5..3263fd7d4 100644
--- a/app/javascript/flavours/vanilla/theme.yml
+++ b/app/javascript/flavours/vanilla/theme.yml
@@ -1,7 +1,7 @@
 #  (REQUIRED) The location of the pack files inside `pack_directory`.
 pack:
   about: about.js
-  admin: public.js
+  admin: admin.js
   auth: public.js
   common:
     filename: common.js