about summary refs log tree commit diff
path: root/app/javascript/flavours
diff options
context:
space:
mode:
authorSpencer Alves <impiaaa@gmail.com>2018-05-31 21:33:16 -0700
committerSpencer Alves <impiaaa@gmail.com>2018-05-31 21:33:16 -0700
commit7d2e6429c27c5ddc8ef3d2366c44329092e07f77 (patch)
tree7cfd2035f69616a369b2f3762ce9cefe61c2bd22 /app/javascript/flavours
parentf2ff167c1a8df9b2521d33fcca15b8d5c67c50b1 (diff)
parente396fbfe3bf4d2a404e78e73cff1a609dd0a9bfb (diff)
Merge branch 'glitch' into thread-icon
Diffstat (limited to 'app/javascript/flavours')
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js55
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js84
-rw-r--r--app/javascript/flavours/glitch/actions/push_notifications/registerer.js7
-rw-r--r--app/javascript/flavours/glitch/actions/search.js2
-rw-r--r--app/javascript/flavours/glitch/actions/streaming.js16
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js118
-rw-r--r--app/javascript/flavours/glitch/components/load_gap.js33
-rw-r--r--app/javascript/flavours/glitch/components/load_more.js5
-rw-r--r--app/javascript/flavours/glitch/components/media_gallery.js11
-rw-r--r--app/javascript/flavours/glitch/components/modal_root.js3
-rw-r--r--app/javascript/flavours/glitch/components/scrollable_list.js16
-rw-r--r--app/javascript/flavours/glitch/components/status.js6
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js7
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js12
-rw-r--r--app/javascript/flavours/glitch/components/status_list.js21
-rw-r--r--app/javascript/flavours/glitch/containers/card_container.js18
-rw-r--r--app/javascript/flavours/glitch/containers/media_container.js90
-rw-r--r--app/javascript/flavours/glitch/containers/media_galleries_container.js68
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js5
-rw-r--r--app/javascript/flavours/glitch/containers/timeline_container.js12
-rw-r--r--app/javascript/flavours/glitch/containers/video_container.js26
-rw-r--r--app/javascript/flavours/glitch/features/account/components/action_bar.js3
-rw-r--r--app/javascript/flavours/glitch/features/account/components/header.js47
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/index.js57
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/header.js6
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js13
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/index.js24
-rw-r--r--app/javascript/flavours/glitch/features/bookmarked_statuses/index.js4
-rw-r--r--app/javascript/flavours/glitch/features/community_timeline/index.js13
-rw-r--r--app/javascript/flavours/glitch/features/composer/direct_warning/index.js53
-rw-r--r--app/javascript/flavours/glitch/features/composer/index.js29
-rw-r--r--app/javascript/flavours/glitch/features/composer/reply/index.js1
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/index.js13
-rw-r--r--app/javascript/flavours/glitch/features/domain_blocks/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/drawer/results/index.js6
-rw-r--r--app/javascript/flavours/glitch/features/favourited_statuses/index.js4
-rw-r--r--app/javascript/flavours/glitch/features/getting_started_misc/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/index.js15
-rw-r--r--app/javascript/flavours/glitch/features/home_timeline/index.js12
-rw-r--r--app/javascript/flavours/glitch/features/list_timeline/index.js10
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js30
-rw-r--r--app/javascript/flavours/glitch/features/public_timeline/index.js13
-rw-r--r--app/javascript/flavours/glitch/features/standalone/community_timeline/index.js71
-rw-r--r--app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js25
-rw-r--r--app/javascript/flavours/glitch/features/standalone/public_timeline/index.js25
-rw-r--r--app/javascript/flavours/glitch/features/status/components/action_bar.js10
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js4
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/media_modal.js17
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/modal_root.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/report_modal.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/status_list_container.js11
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js8
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js24
-rw-r--r--app/javascript/flavours/glitch/packs/public.js29
-rw-r--r--app/javascript/flavours/glitch/reducers/accounts.js14
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js25
-rw-r--r--app/javascript/flavours/glitch/reducers/domain_lists.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/notifications.js66
-rw-r--r--app/javascript/flavours/glitch/reducers/search.js7
-rw-r--r--app/javascript/flavours/glitch/reducers/timelines.js66
-rw-r--r--app/javascript/flavours/glitch/service_worker/entry.js10
-rw-r--r--app/javascript/flavours/glitch/service_worker/web_push_notifications.js159
-rw-r--r--app/javascript/flavours/glitch/styles/about.scss76
-rw-r--r--app/javascript/flavours/glitch/styles/accounts.scss13
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss119
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss9
-rw-r--r--app/javascript/flavours/glitch/styles/components/drawer.scss34
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss6
-rw-r--r--app/javascript/flavours/glitch/styles/components/local_settings.scss7
-rw-r--r--app/javascript/flavours/glitch/styles/components/media.scss4
-rw-r--r--app/javascript/flavours/glitch/styles/components/metadata.scss32
-rw-r--r--app/javascript/flavours/glitch/styles/containers.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/footer.scss10
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss25
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light.scss218
-rw-r--r--app/javascript/flavours/glitch/styles/metadata.scss55
-rw-r--r--app/javascript/flavours/glitch/styles/rtl.scss16
-rw-r--r--app/javascript/flavours/glitch/styles/stream_entries.scss38
-rw-r--r--app/javascript/flavours/glitch/styles/tables.scss127
-rw-r--r--app/javascript/flavours/glitch/util/compare_id.js10
-rw-r--r--app/javascript/flavours/glitch/util/html.js6
-rw-r--r--app/javascript/flavours/glitch/util/resize_image.js116
-rw-r--r--app/javascript/flavours/glitch/util/stream.js29
84 files changed, 1581 insertions, 932 deletions
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 8a6ca3699..b7f706a83 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -3,14 +3,9 @@ import { CancelToken } from 'axios';
 import { throttle } from 'lodash';
 import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light';
 import { useEmoji } from './emojis';
+import resizeImage from 'flavours/glitch/util/resize_image';
 
-import {
-  updateTimeline,
-  refreshHomeTimeline,
-  refreshCommunityTimeline,
-  refreshPublicTimeline,
-  refreshDirectTimeline,
-} from './timelines';
+import { updateTimeline } from './timelines';
 
 let cancelFetchComposeSuggestionsAccounts;
 
@@ -21,6 +16,7 @@ export const COMPOSE_SUBMIT_SUCCESS  = 'COMPOSE_SUBMIT_SUCCESS';
 export const COMPOSE_SUBMIT_FAIL     = 'COMPOSE_SUBMIT_FAIL';
 export const COMPOSE_REPLY           = 'COMPOSE_REPLY';
 export const COMPOSE_REPLY_CANCEL    = 'COMPOSE_REPLY_CANCEL';
+export const COMPOSE_DIRECT          = 'COMPOSE_DIRECT';
 export const COMPOSE_MENTION         = 'COMPOSE_MENTION';
 export const COMPOSE_RESET           = 'COMPOSE_RESET';
 export const COMPOSE_UPLOAD_REQUEST  = 'COMPOSE_UPLOAD_REQUEST';
@@ -102,6 +98,19 @@ export function mentionCompose(account, router) {
   };
 };
 
+export function directCompose(account, router) {
+  return (dispatch, getState) => {
+    dispatch({
+      type: COMPOSE_DIRECT,
+      account: account,
+    });
+
+    if (!getState().getIn(['compose', 'mounted'])) {
+      router.push('/statuses/new');
+    }
+  };
+};
+
 export function submitCompose() {
   return function (dispatch, getState) {
     let status = getState().getIn(['compose', 'text'], '');
@@ -136,21 +145,19 @@ export function submitCompose() {
 
       // To make the app more responsive, immediately get the status into the columns
 
-      const insertOrRefresh = (timelineId, refreshAction) => {
-        if (getState().getIn(['timelines', timelineId, 'online'])) {
+      const insertIfOnline = (timelineId) => {
+        if (getState().getIn(['timelines', timelineId, 'items', 0]) !== null) {
           dispatch(updateTimeline(timelineId, { ...response.data }));
-        } else if (getState().getIn(['timelines', timelineId, 'loaded'])) {
-          dispatch(refreshAction());
         }
       };
 
-      insertOrRefresh('home', refreshHomeTimeline);
+      insertIfOnline('home');
 
       if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
-        insertOrRefresh('community', refreshCommunityTimeline);
-        insertOrRefresh('public', refreshPublicTimeline);
+        insertIfOnline('community');
+        insertIfOnline('public');
       } else if (response.data.visibility === 'direct') {
-        insertOrRefresh('direct', refreshDirectTimeline);
+        insertIfOnline('direct');
       }
     }).catch(function (error) {
       dispatch(submitComposeFail(error));
@@ -193,18 +200,14 @@ export function uploadCompose(files) {
 
     dispatch(uploadComposeRequest());
 
-    let data = new FormData();
-    data.append('file', files[0]);
+    resizeImage(files[0]).then(file => {
+      const data = new FormData();
+      data.append('file', file);
 
-    api(getState).post('/api/v1/media', data, {
-      onUploadProgress: function (e) {
-        dispatch(uploadComposeProgress(e.loaded, e.total));
-      },
-    }).then(function (response) {
-      dispatch(uploadComposeSuccess(response.data));
-    }).catch(function (error) {
-      dispatch(uploadComposeFail(error));
-    });
+      return api(getState).post('/api/v1/media', data, {
+        onUploadProgress: ({ loaded, total }) => dispatch(uploadComposeProgress(loaded, total)),
+      }).then(({ data }) => dispatch(uploadComposeSuccess(data)));
+    }).catch(error => dispatch(uploadComposeFail(error)));
   };
 };
 
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index cf27eff90..68c46a732 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -1,8 +1,8 @@
 import api, { getLinks } from 'flavours/glitch/util/api';
-import { List as ImmutableList } from 'immutable';
 import IntlMessageFormat from 'intl-messageformat';
 import { fetchRelationships } from './accounts';
 import { defineMessages } from 'react-intl';
+import { unescapeHTML } from 'flavours/glitch/util/html';
 
 export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
 
@@ -17,10 +17,6 @@ export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR
 // Mark one for delete
 export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE';
 
-export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
-export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
-export const NOTIFICATIONS_REFRESH_FAIL    = 'NOTIFICATIONS_REFRESH_FAIL';
-
 export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
 export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
 export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL';
@@ -40,13 +36,6 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
   }
 };
 
-const unescapeHTML = (html) => {
-  const wrapper = document.createElement('div');
-  html = html.replace(/<br \/>|<br>|\n/g, ' ');
-  wrapper.innerHTML = html;
-  return wrapper.textContent;
-};
-
 export function updateNotifications(notification, intlMessages, intlLocale) {
   return (dispatch, getState) => {
     const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
@@ -78,84 +67,37 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
 
 const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
 
-export function refreshNotifications() {
-  return (dispatch, getState) => {
-    const params = {};
-    const ids    = getState().getIn(['notifications', 'items']);
-
-    let skipLoading = false;
-
-    if (ids.size > 0) {
-      params.since_id = ids.first().get('id');
-    }
-
-    if (getState().getIn(['notifications', 'loaded'])) {
-      skipLoading = true;
-    }
-
-    params.exclude_types = excludeTypesFromSettings(getState());
-
-    dispatch(refreshNotificationsRequest(skipLoading));
-
-    api(getState).get('/api/v1/notifications', { params }).then(response => {
-      const next = getLinks(response).refs.find(link => link.rel === 'next');
-
-      dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null));
-      fetchRelatedRelationships(dispatch, response.data);
-    }).catch(error => {
-      dispatch(refreshNotificationsFail(error, skipLoading));
-    });
-  };
-};
-
-export function refreshNotificationsRequest(skipLoading) {
-  return {
-    type: NOTIFICATIONS_REFRESH_REQUEST,
-    skipLoading,
-  };
-};
 
-export function refreshNotificationsSuccess(notifications, skipLoading, next) {
-  return {
-    type: NOTIFICATIONS_REFRESH_SUCCESS,
-    notifications,
-    accounts: notifications.map(item => item.account),
-    statuses: notifications.map(item => item.status).filter(status => !!status),
-    skipLoading,
-    next,
-  };
-};
+const noOp = () => {};
 
-export function refreshNotificationsFail(error, skipLoading) {
-  return {
-    type: NOTIFICATIONS_REFRESH_FAIL,
-    error,
-    skipLoading,
-  };
-};
-
-export function expandNotifications() {
+export function expandNotifications({ maxId } = {}, done = noOp) {
   return (dispatch, getState) => {
-    const items  = getState().getIn(['notifications', 'items'], ImmutableList());
+    const notifications = getState().get('notifications');
 
-    if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) {
+    if (notifications.get('isLoading')) {
+      done();
       return;
     }
 
     const params = {
-      max_id: items.last().get('id'),
-      limit: 20,
+      max_id: maxId,
       exclude_types: excludeTypesFromSettings(getState()),
     };
 
+    if (!maxId && notifications.get('items').size > 0) {
+      params.since_id = notifications.getIn(['items', 0]);
+    }
+
     dispatch(expandNotificationsRequest());
 
     api(getState).get('/api/v1/notifications', { params }).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
       fetchRelatedRelationships(dispatch, response.data);
+      done();
     }).catch(error => {
       dispatch(expandNotificationsFail(error));
+      done();
     });
   };
 };
diff --git a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js
index 5ad11f73f..91f442415 100644
--- a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js
+++ b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js
@@ -56,13 +56,6 @@ export function register () {
     dispatch(setBrowserSupport(supportsPushNotifications));
     const me = getState().getIn(['meta', 'me']);
 
-    if (me && !pushNotificationsSetting.get(me)) {
-      const alerts = getState().getIn(['push_notifications', 'alerts']);
-      if (alerts) {
-        pushNotificationsSetting.set(me, { alerts: alerts });
-      }
-    }
-
     if (supportsPushNotifications) {
       if (!getApplicationServerKey()) {
         console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
diff --git a/app/javascript/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js
index e86bd848e..13885c600 100644
--- a/app/javascript/flavours/glitch/actions/search.js
+++ b/app/javascript/flavours/glitch/actions/search.js
@@ -1,4 +1,5 @@
 import api from 'flavours/glitch/util/api';
+import { fetchRelationships } from './accounts';
 
 export const SEARCH_CHANGE = 'SEARCH_CHANGE';
 export const SEARCH_CLEAR  = 'SEARCH_CLEAR';
@@ -38,6 +39,7 @@ export function submitSearch() {
       },
     }).then(response => {
       dispatch(fetchSearchSuccess(response.data));
+      dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
     }).catch(error => {
       dispatch(fetchSearchFail(error));
     });
diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js
index ae51e8349..6e34d0be6 100644
--- a/app/javascript/flavours/glitch/actions/streaming.js
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -2,11 +2,10 @@ import { connectStream } from 'flavours/glitch/util/stream';
 import {
   updateTimeline,
   deleteFromTimelines,
-  refreshHomeTimeline,
-  connectTimeline,
+  expandHomeTimeline,
   disconnectTimeline,
 } from './timelines';
-import { updateNotifications, refreshNotifications } from './notifications';
+import { updateNotifications, expandNotifications } from './notifications';
 import { getLocale } from 'mastodon/locales';
 
 const { messages } = getLocale();
@@ -16,10 +15,6 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
   return connectStream (path, pollingRefresh, (dispatch, getState) => {
     const locale = getState().getIn(['meta', 'locale']);
     return {
-      onConnect() {
-        dispatch(connectTimeline(timelineId));
-      },
-
       onDisconnect() {
         dispatch(disconnectTimeline(timelineId));
       },
@@ -41,10 +36,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
   });
 }
 
-function refreshHomeTimelineAndNotification (dispatch) {
-  dispatch(refreshHomeTimeline());
-  dispatch(refreshNotifications());
-}
+const refreshHomeTimelineAndNotification = (dispatch, done) => {
+  dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done))));
+};
 
 export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
 export const connectCommunityStream = () => connectTimelineStream('community', 'public:local');
diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js
index d99c6d98b..9597fe89d 100644
--- a/app/javascript/flavours/glitch/actions/timelines.js
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -4,32 +4,16 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE';
 export const TIMELINE_DELETE  = 'TIMELINE_DELETE';
 
-export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST';
-export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS';
-export const TIMELINE_REFRESH_FAIL    = 'TIMELINE_REFRESH_FAIL';
-
 export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
 export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
 export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL';
 
 export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 
-export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT';
 export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
 
 export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
 
-export function refreshTimelineSuccess(timeline, statuses, skipLoading, next, partial) {
-  return {
-    type: TIMELINE_REFRESH_SUCCESS,
-    timeline,
-    statuses,
-    skipLoading,
-    next,
-    partial,
-  };
-};
-
 export function updateTimeline(timeline, status) {
   return (dispatch, getState) => {
     const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
@@ -77,97 +61,43 @@ export function deleteFromTimelines(id) {
   };
 };
 
-export function refreshTimelineRequest(timeline, skipLoading) {
-  return {
-    type: TIMELINE_REFRESH_REQUEST,
-    timeline,
-    skipLoading,
-  };
-};
-
-export function refreshTimeline(timelineId, path, params = {}) {
-  return function (dispatch, getState) {
-    const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
-
-    if (timeline.get('isLoading') || (timeline.get('online') && !timeline.get('isPartial'))) {
-      return;
-    }
-
-    const ids      = timeline.get('items', ImmutableList());
-    const newestId = ids.size > 0 ? ids.first() : null;
-
-    let skipLoading = timeline.get('loaded');
-
-    if (newestId !== null) {
-      params.since_id = newestId;
-    }
+const noOp = () => {};
 
-    dispatch(refreshTimelineRequest(timelineId, skipLoading));
-
-    api(getState).get(path, { params }).then(response => {
-      if (response.status === 206) {
-        dispatch(refreshTimelineSuccess(timelineId, [], skipLoading, null, true));
-      } else {
-        const next = getLinks(response).refs.find(link => link.rel === 'next');
-        dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null, false));
-      }
-    }).catch(error => {
-      dispatch(refreshTimelineFail(timelineId, error, skipLoading));
-    });
-  };
-};
-
-export const refreshHomeTimeline            = () => refreshTimeline('home', '/api/v1/timelines/home');
-export const refreshPublicTimeline          = () => refreshTimeline('public', '/api/v1/timelines/public');
-export const refreshCommunityTimeline       = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true });
-export const refreshDirectTimeline          = () => refreshTimeline('direct', '/api/v1/timelines/direct');
-export const refreshAccountTimeline         = (accountId, withReplies) => refreshTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies });
-export const refreshAccountFeaturedTimeline = accountId => refreshTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
-export const refreshAccountMediaTimeline    = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
-export const refreshHashtagTimeline         = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
-export const refreshListTimeline            = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`);
-
-export function refreshTimelineFail(timeline, error, skipLoading) {
-  return {
-    type: TIMELINE_REFRESH_FAIL,
-    timeline,
-    error,
-    skipLoading,
-    skipAlert: error.response && error.response.status === 404,
-  };
-};
-
-export function expandTimeline(timelineId, path, params = {}) {
+export function expandTimeline(timelineId, path, params = {}, done = noOp) {
   return (dispatch, getState) => {
     const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
-    const ids      = timeline.get('items', ImmutableList());
 
-    if (timeline.get('isLoading') || ids.size === 0) {
+    if (timeline.get('isLoading')) {
+      done();
       return;
     }
 
-    params.max_id = ids.last();
-    params.limit  = 10;
+    if (!params.max_id && timeline.get('items', ImmutableList()).size > 0) {
+      params.since_id = timeline.getIn(['items', 0]);
+    }
 
     dispatch(expandTimelineRequest(timelineId));
 
     api(getState).get(path, { params }).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
-      dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null));
+      dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206));
+      done();
     }).catch(error => {
       dispatch(expandTimelineFail(timelineId, error));
+      done();
     });
   };
 };
 
-export const expandHomeTimeline         = () => expandTimeline('home', '/api/v1/timelines/home');
-export const expandPublicTimeline       = () => expandTimeline('public', '/api/v1/timelines/public');
-export const expandCommunityTimeline    = () => expandTimeline('community', '/api/v1/timelines/public', { local: true });
-export const expandDirectTimeline       = () => expandTimeline('direct', '/api/v1/timelines/direct');
-export const expandAccountTimeline      = (accountId, withReplies) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies })
-export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
-export const expandHashtagTimeline      = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
-export const expandListTimeline         = id => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`);
+export const expandHomeTimeline         = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
+export const expandPublicTimeline       = ({ maxId } = {}, done = noOp) => expandTimeline('public', '/api/v1/timelines/public', { max_id: maxId }, done);
+export const expandCommunityTimeline    = ({ maxId } = {}, done = noOp) => expandTimeline('community', '/api/v1/timelines/public', { local: true, max_id: maxId }, done);
+export const expandDirectTimeline       = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
+export const expandAccountTimeline      = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
+export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
+export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
+export const expandHashtagTimeline      = (hashtag, { maxId } = {}, done = noOp) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId }, done);
+export const expandListTimeline         = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
 
 export function expandTimelineRequest(timeline) {
   return {
@@ -176,12 +106,13 @@ export function expandTimelineRequest(timeline) {
   };
 };
 
-export function expandTimelineSuccess(timeline, statuses, next) {
+export function expandTimelineSuccess(timeline, statuses, next, partial) {
   return {
     type: TIMELINE_EXPAND_SUCCESS,
     timeline,
     statuses,
     next,
+    partial,
   };
 };
 
@@ -201,13 +132,6 @@ export function scrollTopTimeline(timeline, top) {
   };
 };
 
-export function connectTimeline(timeline) {
-  return {
-    type: TIMELINE_CONNECT,
-    timeline,
-  };
-};
-
 export function disconnectTimeline(timeline) {
   return {
     type: TIMELINE_DISCONNECT,
diff --git a/app/javascript/flavours/glitch/components/load_gap.js b/app/javascript/flavours/glitch/components/load_gap.js
new file mode 100644
index 000000000..012303ae1
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/load_gap.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
+});
+
+@injectIntl
+export default class LoadGap extends React.PureComponent {
+
+  static propTypes = {
+    disabled: PropTypes.bool,
+    maxId: PropTypes.string,
+    onClick: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.onClick(this.props.maxId);
+  }
+
+  render () {
+    const { disabled, intl } = this.props;
+
+    return (
+      <button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}>
+        <i className='fa fa-ellipsis-h' />
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/load_more.js b/app/javascript/flavours/glitch/components/load_more.js
index c4c8c94a2..389c3e1e1 100644
--- a/app/javascript/flavours/glitch/components/load_more.js
+++ b/app/javascript/flavours/glitch/components/load_more.js
@@ -6,6 +6,7 @@ export default class LoadMore extends React.PureComponent {
 
   static propTypes = {
     onClick: PropTypes.func,
+    disabled: PropTypes.bool,
     visible: PropTypes.bool,
   }
 
@@ -14,10 +15,10 @@ export default class LoadMore extends React.PureComponent {
   }
 
   render() {
-    const { visible } = this.props;
+    const { disabled, visible } = this.props;
 
     return (
-      <button className='load-more' disabled={!visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}>
+      <button className='load-more' disabled={disabled || !visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}>
         <FormattedMessage id='status.load_more' defaultMessage='Load more' />
       </button>
     );
diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js
index 7f5150f7b..d90f9bdb4 100644
--- a/app/javascript/flavours/glitch/components/media_gallery.js
+++ b/app/javascript/flavours/glitch/components/media_gallery.js
@@ -40,6 +40,7 @@ class Item extends React.PureComponent {
     size: PropTypes.number.isRequired,
     letterbox: PropTypes.bool,
     onClick: PropTypes.func.isRequired,
+    displayWidth: PropTypes.number,
   };
 
   static defaultProps = {
@@ -78,7 +79,7 @@ class Item extends React.PureComponent {
   }
 
   render () {
-    const { attachment, index, size, standalone, letterbox } = this.props;
+    const { attachment, index, size, standalone, letterbox, displayWidth } = this.props;
 
     let width  = 50;
     let height = 100;
@@ -141,7 +142,7 @@ class Item extends React.PureComponent {
       const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
 
       const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
-      const sizes  = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
+      const sizes  = hasSize ? `${displayWidth * (width / 100)}px` : null;
 
       const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
       const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
@@ -235,7 +236,7 @@ export default class MediaGallery extends React.PureComponent {
   }
 
   handleRef = (node) => {
-    if (node && this.isStandaloneEligible()) {
+    if (node /*&& this.isStandaloneEligible()*/) {
       // offsetWidth triggers a layout, so only calculate when we need to
       this.setState({
         width: node.offsetWidth,
@@ -272,9 +273,9 @@ export default class MediaGallery extends React.PureComponent {
       );
     } else {
       if (this.isStandaloneEligible()) {
-        children = <Item standalone attachment={media.get(0)} onClick={this.handleClick} />;
+        children = <Item standalone attachment={media.get(0)} onClick={this.handleClick} displayWidth={width} />;
       } else {
-        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} />);
+        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} />);
       }
     }
 
diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js
index 789e117c7..89f81f58e 100644
--- a/app/javascript/flavours/glitch/components/modal_root.js
+++ b/app/javascript/flavours/glitch/components/modal_root.js
@@ -6,6 +6,7 @@ export default class ModalRoot extends React.PureComponent {
   static propTypes = {
     children: PropTypes.node,
     onClose: PropTypes.func.isRequired,
+    noEsc: PropTypes.bool,
   };
 
   state = {
@@ -16,7 +17,7 @@ export default class ModalRoot extends React.PureComponent {
 
   handleKeyUp = (e) => {
     if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
-         && !!this.props.children && !this.props.props.noEsc) {
+         && !!this.props.children && !this.props.noEsc) {
       this.props.onClose();
     }
   }
diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js
index df3ace4c1..b96b4dd98 100644
--- a/app/javascript/flavours/glitch/components/scrollable_list.js
+++ b/app/javascript/flavours/glitch/components/scrollable_list.js
@@ -17,7 +17,7 @@ export default class ScrollableList extends PureComponent {
 
   static propTypes = {
     scrollKey: PropTypes.string.isRequired,
-    onScrollToBottom: PropTypes.func,
+    onLoadMore: PropTypes.func,
     onScrollToTop: PropTypes.func,
     onScroll: PropTypes.func,
     trackScroll: PropTypes.bool,
@@ -44,9 +44,11 @@ export default class ScrollableList extends PureComponent {
       const { scrollTop, scrollHeight, clientHeight } = this.node;
       const offset = scrollHeight - scrollTop - clientHeight;
 
-      if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
-        this.props.onScrollToBottom();
-      } else if (scrollTop < 100 && this.props.onScrollToTop) {
+      if (400 > offset && this.props.onLoadMore && !this.props.isLoading) {
+        this.props.onLoadMore();
+      }
+
+      if (scrollTop < 100 && this.props.onScrollToTop) {
         this.props.onScrollToTop();
       } else if (this.props.onScroll) {
         this.props.onScroll();
@@ -144,15 +146,15 @@ export default class ScrollableList extends PureComponent {
 
   handleLoadMore = (e) => {
     e.preventDefault();
-    this.props.onScrollToBottom();
+    this.props.onLoadMore();
   }
 
   render () {
-    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
+    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
-    const loadMore     = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
+    const loadMore     = (hasMore && childrenCount > 0 && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
     let scrollableArea = null;
 
     if (isLoading || childrenCount > 0 || !emptyMessage) {
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index bff396f04..c93705266 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -32,6 +32,8 @@ export default class Status extends ImmutablePureComponent {
     onFavourite: PropTypes.func,
     onReblog: PropTypes.func,
     onDelete: PropTypes.func,
+    onDirect: PropTypes.func,
+    onMention: PropTypes.func,
     onPin: PropTypes.func,
     onOpenMedia: PropTypes.func,
     onOpenVideo: PropTypes.func,
@@ -257,8 +259,8 @@ export default class Status extends ImmutablePureComponent {
     }
   };
 
-  handleOpenVideo = startTime => {
-    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+  handleOpenVideo = (media, startTime) => {
+    this.props.onOpenVideo(media, startTime);
   }
 
   handleHotkeyReply = e => {
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index da6e4e6ba..6ae4bc08d 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -10,6 +10,7 @@ import RelativeTimestamp from './relative_timestamp';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
+  direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
   mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
   block: { id: 'account.block', defaultMessage: 'Block @{name}' },
@@ -44,6 +45,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
     onFavourite: PropTypes.func,
     onReblog: PropTypes.func,
     onDelete: PropTypes.func,
+    onDirect: PropTypes.func,
     onMention: PropTypes.func,
     onMute: PropTypes.func,
     onBlock: PropTypes.func,
@@ -98,6 +100,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     this.props.onMention(this.props.status.get('account'), this.context.router.history);
   }
 
+  handleDirectClick = () => {
+    this.props.onDirect(this.props.status.get('account'), this.context.router.history);
+  }
+
   handleMuteClick = () => {
     this.props.onMute(this.props.status.get('account'));
   }
@@ -157,6 +163,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
       menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index 32b0770cb..6542df65b 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -98,7 +98,7 @@ export default class StatusContent extends React.PureComponent {
     const [ startX, startY ] = this.startXY;
     const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
 
-    if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
+    if (e.target.localName === 'button' || e.target.localName == 'video' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
       return;
     }
 
@@ -188,11 +188,9 @@ export default class StatusContent extends React.PureComponent {
       }
 
       return (
-        <div className={classNames} tabIndex='0'>
+        <div className={classNames} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
           <p
             style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
-            onMouseDown={this.handleMouseDown}
-            onMouseUp={this.handleMouseUp}
           >
             <span dangerouslySetInnerHTML={spoilerContent} />
             {' '}
@@ -208,8 +206,6 @@ export default class StatusContent extends React.PureComponent {
               ref={this.setRef}
               style={directionStyle}
               tabIndex={!hidden ? 0 : null}
-              onMouseDown={this.handleMouseDown}
-              onMouseUp={this.handleMouseUp}
               dangerouslySetInnerHTML={content}
             />
             {media}
@@ -222,12 +218,12 @@ export default class StatusContent extends React.PureComponent {
         <div
           className={classNames}
           style={directionStyle}
+          onMouseDown={this.handleMouseDown}
+          onMouseUp={this.handleMouseUp}
           tabIndex='0'
         >
           <div
             ref={this.setRef}
-            onMouseDown={this.handleMouseDown}
-            onMouseUp={this.handleMouseUp}
             dangerouslySetInnerHTML={content}
             tabIndex='0'
           />
diff --git a/app/javascript/flavours/glitch/components/status_list.js b/app/javascript/flavours/glitch/components/status_list.js
index 2b35d6f3d..33bc7a959 100644
--- a/app/javascript/flavours/glitch/components/status_list.js
+++ b/app/javascript/flavours/glitch/components/status_list.js
@@ -1,8 +1,10 @@
+import { debounce } from 'lodash';
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import StatusContainer from 'flavours/glitch/containers/status_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import LoadGap from './load_gap';
 import ScrollableList from './scrollable_list';
 import { FormattedMessage } from 'react-intl';
 
@@ -12,7 +14,7 @@ export default class StatusList extends ImmutablePureComponent {
     scrollKey: PropTypes.string.isRequired,
     statusIds: ImmutablePropTypes.list.isRequired,
     featuredStatusIds: ImmutablePropTypes.list,
-    onScrollToBottom: PropTypes.func,
+    onLoadMore: PropTypes.func,
     onScrollToTop: PropTypes.func,
     onScroll: PropTypes.func,
     trackScroll: PropTypes.bool,
@@ -50,6 +52,10 @@ export default class StatusList extends ImmutablePureComponent {
     this._selectChild(elementIndex);
   }
 
+  handleLoadOlder = debounce(() => {
+    this.props.onLoadMore(this.props.statusIds.last());
+  }, 300, { leading: true })
+
   _selectChild (index) {
     const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
 
@@ -63,7 +69,7 @@ export default class StatusList extends ImmutablePureComponent {
   }
 
   render () {
-    const { statusIds, featuredStatusIds, ...other }  = this.props;
+    const { statusIds, featuredStatusIds, onLoadMore, ...other }  = this.props;
     const { isLoading, isPartial } = other;
 
     if (isPartial) {
@@ -82,7 +88,14 @@ export default class StatusList extends ImmutablePureComponent {
     }
 
     let scrollableContent = (isLoading || statusIds.size > 0) ? (
-      statusIds.map(statusId => (
+      statusIds.map((statusId, index) => statusId === null ? (
+        <LoadGap
+          key={'gap:' + statusIds.get(index + 1)}
+          disabled={isLoading}
+          maxId={index > 0 ? statusIds.get(index - 1) : null}
+          onClick={onLoadMore}
+        />
+      ) : (
         <StatusContainer
           key={statusId}
           id={statusId}
@@ -105,7 +118,7 @@ export default class StatusList extends ImmutablePureComponent {
     }
 
     return (
-      <ScrollableList {...other} ref={this.setRef}>
+      <ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
         {scrollableContent}
       </ScrollableList>
     );
diff --git a/app/javascript/flavours/glitch/containers/card_container.js b/app/javascript/flavours/glitch/containers/card_container.js
deleted file mode 100644
index dec7df522..000000000
--- a/app/javascript/flavours/glitch/containers/card_container.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Card from 'flavours/glitch/features/status/components/card';
-import { fromJS } from 'immutable';
-
-export default class CardContainer extends React.PureComponent {
-
-  static propTypes = {
-    locale: PropTypes.string,
-    card: PropTypes.array.isRequired,
-  };
-
-  render () {
-    const { card, ...props } = this.props;
-    return <Card card={fromJS(card)} {...props} />;
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js
new file mode 100644
index 000000000..0e1904132
--- /dev/null
+++ b/app/javascript/flavours/glitch/containers/media_container.js
@@ -0,0 +1,90 @@
+import React, { PureComponent, Fragment } from 'react';
+import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from 'mastodon/locales';
+import MediaGallery from 'flavours/glitch/components/media_gallery';
+import Video from 'flavours/glitch/features/video';
+import Card from 'flavours/glitch/features/status/components/card';
+import ModalRoot from 'flavours/glitch/components/modal_root';
+import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
+import { List as ImmutableList, fromJS } from 'immutable';
+
+const { localeData, messages } = getLocale();
+addLocaleData(localeData);
+
+const MEDIA_COMPONENTS = { MediaGallery, Video, Card };
+
+export default class MediaContainer extends PureComponent {
+
+  static propTypes = {
+    locale: PropTypes.string.isRequired,
+    components: PropTypes.object.isRequired,
+  };
+
+  state = {
+    media: null,
+    index: null,
+    time: null,
+  };
+
+  handleOpenMedia = (media, index) => {
+    document.body.classList.add('media-standalone__body');
+    this.setState({ media, index });
+  }
+
+  handleOpenVideo = (video, time) => {
+    const media = ImmutableList([video]);
+
+    document.body.classList.add('media-standalone__body');
+    this.setState({ media, time });
+  }
+
+  handleCloseMedia = () => {
+    document.body.classList.remove('media-standalone__body');
+    this.setState({ media: null, index: null, time: null });
+  }
+
+  render () {
+    const { locale, components } = this.props;
+
+    return (
+      <IntlProvider locale={locale} messages={messages}>
+        <Fragment>
+          {[].map.call(components, (component, i) => {
+            const componentName = component.getAttribute('data-component');
+            const Component = MEDIA_COMPONENTS[componentName];
+            const { media, card, ...props } = JSON.parse(component.getAttribute('data-props'));
+
+            Object.assign(props, {
+              ...(media ? { media: fromJS(media) } : {}),
+              ...(card  ? { card:  fromJS(card)  } : {}),
+
+              ...(componentName === 'Video' ? {
+                onOpenVideo: this.handleOpenVideo,
+              } : {
+                onOpenMedia: this.handleOpenMedia,
+              }),
+            });
+
+            return ReactDOM.createPortal(
+              <Component {...props} key={`media-${i}`} />,
+              component,
+            );
+          })}
+          <ModalRoot onClose={this.handleCloseMedia}>
+            {this.state.media && (
+              <MediaModal
+                media={this.state.media}
+                index={this.state.index || 0}
+                time={this.state.time}
+                onClose={this.handleCloseMedia}
+              />
+            )}
+          </ModalRoot>
+        </Fragment>
+      </IntlProvider>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/containers/media_galleries_container.js b/app/javascript/flavours/glitch/containers/media_galleries_container.js
deleted file mode 100644
index a69457882..000000000
--- a/app/javascript/flavours/glitch/containers/media_galleries_container.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import PropTypes from 'prop-types';
-import { IntlProvider, addLocaleData } from 'react-intl';
-import { getLocale } from 'mastodon/locales';
-import MediaGallery from 'flavours/glitch/components/media_gallery';
-import ModalRoot from 'flavours/glitch/components/modal_root';
-import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
-import { fromJS } from 'immutable';
-
-const { localeData, messages } = getLocale();
-addLocaleData(localeData);
-
-export default class MediaGalleriesContainer extends React.PureComponent {
-
-  static propTypes = {
-    locale: PropTypes.string.isRequired,
-    galleries: PropTypes.object.isRequired,
-  };
-
-  state = {
-    media: null,
-    index: null,
-  };
-
-  handleOpenMedia = (media, index) => {
-    document.body.classList.add('media-gallery-standalone__body');
-    this.setState({ media, index });
-  }
-
-  handleCloseMedia = () => {
-    document.body.classList.remove('media-gallery-standalone__body');
-    this.setState({ media: null, index: null });
-  }
-
-  render () {
-    const { locale, galleries } = this.props;
-
-    return (
-      <IntlProvider locale={locale} messages={messages}>
-        <React.Fragment>
-          {[].map.call(galleries, gallery => {
-            const { media, ...props } = JSON.parse(gallery.getAttribute('data-props'));
-
-            return ReactDOM.createPortal(
-              <MediaGallery
-                {...props}
-                media={fromJS(media)}
-                onOpenMedia={this.handleOpenMedia}
-              />,
-              gallery
-            );
-          })}
-          <ModalRoot onClose={this.handleCloseMedia}>
-            {this.state.media === null || this.state.index === null ? null : (
-              <MediaModal
-                media={this.state.media}
-                index={this.state.index}
-                onClose={this.handleCloseMedia}
-              />
-            )}
-          </ModalRoot>
-        </React.Fragment>
-      </IntlProvider>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index 3fc6a6a79..acec00e12 100644
--- a/app/javascript/flavours/glitch/containers/status_container.js
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -5,6 +5,7 @@ import { makeGetStatus } from 'flavours/glitch/selectors';
 import {
   replyCompose,
   mentionCompose,
+  directCompose,
 } from 'flavours/glitch/actions/compose';
 import {
   reblog,
@@ -131,6 +132,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
   onMention (account, router) {
     dispatch(mentionCompose(account, router));
   },
diff --git a/app/javascript/flavours/glitch/containers/timeline_container.js b/app/javascript/flavours/glitch/containers/timeline_container.js
index c5ffe1b63..56669a49a 100644
--- a/app/javascript/flavours/glitch/containers/timeline_container.js
+++ b/app/javascript/flavours/glitch/containers/timeline_container.js
@@ -6,6 +6,7 @@ import { hydrateStore } from 'flavours/glitch/actions/store';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import { getLocale } from 'mastodon/locales';
 import PublicTimeline from 'flavours/glitch/features/standalone/public_timeline';
+import CommunityTimeline from 'flavours/glitch/features/standalone/community_timeline';
 import HashtagTimeline from 'flavours/glitch/features/standalone/hashtag_timeline';
 import initialState from 'flavours/glitch/util/initial_state';
 
@@ -23,17 +24,24 @@ export default class TimelineContainer extends React.PureComponent {
   static propTypes = {
     locale: PropTypes.string.isRequired,
     hashtag: PropTypes.string,
+    showPublicTimeline: PropTypes.bool.isRequired,
+  };
+
+  static defaultProps = {
+    showPublicTimeline: initialState.settings.known_fediverse,
   };
 
   render () {
-    const { locale, hashtag } = this.props;
+    const { locale, hashtag, showPublicTimeline } = this.props;
 
     let timeline;
 
     if (hashtag) {
       timeline = <HashtagTimeline hashtag={hashtag} />;
-    } else {
+    } else if (showPublicTimeline) {
       timeline = <PublicTimeline />;
+    } else {
+      timeline = <CommunityTimeline />;
     }
 
     return (
diff --git a/app/javascript/flavours/glitch/containers/video_container.js b/app/javascript/flavours/glitch/containers/video_container.js
deleted file mode 100644
index b206e9a10..000000000
--- a/app/javascript/flavours/glitch/containers/video_container.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { IntlProvider, addLocaleData } from 'react-intl';
-import { getLocale } from 'mastodon/locales';
-import Video from 'flavours/glitch/features/video';
-
-const { localeData, messages } = getLocale();
-addLocaleData(localeData);
-
-export default class VideoContainer extends React.PureComponent {
-
-  static propTypes = {
-    locale: PropTypes.string.isRequired,
-  };
-
-  render () {
-    const { locale, ...props } = this.props;
-
-    return (
-      <IntlProvider locale={locale} messages={messages}>
-        <Video {...props} />
-      </IntlProvider>
-    );
-  }
-
-}
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 fb90722f3..8b95c08f2 100644
--- a/app/javascript/flavours/glitch/features/account/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js
@@ -8,6 +8,7 @@ import { me } from 'flavours/glitch/util/initial_state';
 
 const messages = defineMessages({
   mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
+  direct: { id: 'account.direct', defaultMessage: 'Direct message @{name}' },
   edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -32,6 +33,7 @@ export default class ActionBar extends React.PureComponent {
     onFollow: PropTypes.func,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
+    onDirect: PropTypes.func.isRequired,
     onReblogToggle: PropTypes.func.isRequired,
     onReport: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
@@ -53,6 +55,7 @@ export default class ActionBar extends React.PureComponent {
     let extraInfo = '';
 
     menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+    menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
 
     if ('share' in navigator) {
       menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
index 7a0a2dfa9..464c73c9a 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.js
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -38,6 +38,8 @@ export default class Header extends ImmutablePureComponent {
 
     let displayName = account.get('display_name_html');
     let fields      = account.get('fields');
+    let badge       = account.get('bot') ? (<div className='roles'><div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div></div>) : null;
+
     let info        = '';
     let mutingInfo  = '';
     let actionBtn   = '';
@@ -99,38 +101,31 @@ export default class Header extends ImmutablePureComponent {
 
             <span className='account__header__display-name' dangerouslySetInnerHTML={{ __html: displayName }} />
             <span className='account__header__username'>@{account.get('acct')} {account.get('locked') ? <i className='fa fa-lock' /> : null}</span>
+
+            {badge}
+
             <div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} />
 
             {fields.size > 0 && (
-              <table className='account__header__fields'>
-                <tbody>
-                  {fields.map((pair, i) => (
-                    <tr key={i}>
-                      <th dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} />
-                      <td dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
-                   </tr>
-                  ))}
-                </tbody>
-              </table>
+              <div className='account__header__fields'>
+                {fields.map((pair, i) => (
+                  <dl key={i}>
+                    <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
+                    <dd dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} title={pair.get('value_plain')} />
+                 </dl>
+                ))}
+              </div>
             )}
 
             {fields.size == 0 && metadata.length && (
-              <table className='account__header__fields'>
-                <tbody>
-                  {(() => {
-                    let data = [];
-                    for (let i = 0; i < metadata.length; i++) {
-                      data.push(
-                        <tr key={i}>
-                          <th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
-                          <td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
-                        </tr>
-                      );
-                    }
-                    return data;
-                  })()}
-                </tbody>
-              </table>
+              <div className='account__header__fields'>
+                {metadata.map((pair, i) => (
+                  <dl key={i}>
+                    <dt dangerouslySetInnerHTML={{ __html: emojify(pair[0]) }} title={pair[0]} />
+                    <dd dangerouslySetInnerHTML={{ __html: emojify(pair[1]) }} title={pair[1]} />
+                  </dl>
+                ))}
+              </div>
             ) || null}
 
             {info}
diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js
index 63ff98deb..ebd23a971 100644
--- a/app/javascript/flavours/glitch/features/account_gallery/index.js
+++ b/app/javascript/flavours/glitch/features/account_gallery/index.js
@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import { fetchAccount } from 'flavours/glitch/actions/accounts';
-import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines';
+import { expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines';
 import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
 import Column from 'flavours/glitch/features/ui/components/column';
 import ColumnBackButton from 'flavours/glitch/components/column_back_button';
@@ -17,9 +17,31 @@ import LoadMore from 'flavours/glitch/components/load_more';
 const mapStateToProps = (state, props) => ({
   medias: getAccountGallery(state, props.params.accountId),
   isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
-  hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
+  hasMore:   state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
 });
 
+class LoadMoreMedia extends ImmutablePureComponent {
+
+  static propTypes = {
+    maxId: PropTypes.string,
+    onLoadMore: PropTypes.func.isRequired,
+  };
+
+  handleLoadMore = () => {
+    this.props.onLoadMore(this.props.maxId);
+  }
+
+  render () {
+    return (
+      <LoadMore
+        disabled={this.props.disabled}
+        onLoadMore={this.handleLoadMore}
+      />
+    );
+  }
+
+}
+
 @connect(mapStateToProps)
 export default class AccountGallery extends ImmutablePureComponent {
 
@@ -33,19 +55,19 @@ export default class AccountGallery extends ImmutablePureComponent {
 
   componentDidMount () {
     this.props.dispatch(fetchAccount(this.props.params.accountId));
-    this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
+    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
       this.props.dispatch(fetchAccount(nextProps.params.accountId));
-      this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
+      this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
     }
   }
 
   handleScrollToBottom = () => {
     if (this.props.hasMore) {
-      this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
+      this.handleLoadMore(this.props.medias.last().getIn(['status', 'id']));
     }
   }
 
@@ -58,7 +80,11 @@ export default class AccountGallery extends ImmutablePureComponent {
     }
   }
 
-  handleLoadMore = (e) => {
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
+  };
+
+  handleLoadOlder = (e) => {
     e.preventDefault();
     this.handleScrollToBottom();
   }
@@ -66,7 +92,7 @@ export default class AccountGallery extends ImmutablePureComponent {
   render () {
     const { medias, isLoading, hasMore } = this.props;
 
-    let loadMore = null;
+    let loadOlder = null;
 
     if (!medias && isLoading) {
       return (
@@ -77,7 +103,7 @@ export default class AccountGallery extends ImmutablePureComponent {
     }
 
     if (!isLoading && medias.size > 0 && hasMore) {
-      loadMore = <LoadMore onClick={this.handleLoadMore} />;
+      loadOlder = <LoadMore onClick={this.handleLoadOlder} />;
     }
 
     return (
@@ -89,13 +115,18 @@ export default class AccountGallery extends ImmutablePureComponent {
             <HeaderContainer accountId={this.props.params.accountId} />
 
             <div className='account-gallery__container'>
-              {medias.map(media =>
-                (<MediaItem
+              {medias.map((media, index) => media === null ? (
+                <LoadMoreMedia
+                  key={'more:' + medias.getIn(index + 1, 'id')}
+                  maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
+                />
+              ) : (
+                <MediaItem
                   key={media.get('id')}
                   media={media}
-                />)
-              )}
-              {loadMore}
+                />
+              ))}
+              {loadOlder}
             </div>
           </div>
         </ScrollContainer>
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 39a1850d7..a1434b8dd 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/components/header.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
@@ -16,6 +16,7 @@ export default class Header extends ImmutablePureComponent {
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
+    onDirect: PropTypes.func.isRequired,
     onReblogToggle: PropTypes.func.isRequired,
     onReport: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
@@ -40,6 +41,10 @@ export default class Header extends ImmutablePureComponent {
     this.props.onMention(this.props.account, this.context.router.history);
   }
 
+  handleDirect = () => {
+    this.props.onDirect(this.props.account, this.context.router.history);
+  }
+
   handleReport = () => {
     this.props.onReport(this.props.account);
   }
@@ -89,6 +94,7 @@ export default class Header extends ImmutablePureComponent {
           account={account}
           onBlock={this.handleBlock}
           onMention={this.handleMention}
+          onDirect={this.handleDirect}
           onReblogToggle={this.handleReblogToggle}
           onReport={this.handleReport}
           onMute={this.handleMute}
diff --git a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
index 848119c63..fb0edfa88 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
@@ -9,7 +9,10 @@ import {
   unblockAccount,
   unmuteAccount,
 } from 'flavours/glitch/actions/accounts';
-import { mentionCompose } from 'flavours/glitch/actions/compose';
+import {
+  mentionCompose,
+  directCompose
+} from 'flavours/glitch/actions/compose';
 import { initMuteModal } from 'flavours/glitch/actions/mutes';
 import { initReport } from 'flavours/glitch/actions/reports';
 import { openModal } from 'flavours/glitch/actions/modal';
@@ -67,6 +70,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(mentionCompose(account, router));
   },
 
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
   onReblogToggle (account) {
     if (account.getIn(['relationship', 'showing_reblogs'])) {
       dispatch(followAccount(account.get('id'), false));
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js
index fbb16dff9..2216f9153 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.js
@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import { fetchAccount } from 'flavours/glitch/actions/accounts';
-import { refreshAccountTimeline, refreshAccountFeaturedTimeline, expandAccountTimeline } from 'flavours/glitch/actions/timelines';
+import { expandAccountFeaturedTimeline, expandAccountTimeline } from 'flavours/glitch/actions/timelines';
 import StatusList from '../../components/status_list';
 import LoadingIndicator from '../../components/loading_indicator';
 import Column from '../ui/components/column';
@@ -19,7 +19,7 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
     statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()),
     featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()),
     isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
-    hasMore: !!state.getIn(['timelines', `account:${path}`, 'next']),
+    hasMore:   state.getIn(['timelines', `account:${path}`, 'hasMore']),
   };
 };
 
@@ -40,22 +40,24 @@ export default class AccountTimeline extends ImmutablePureComponent {
     const { params: { accountId }, withReplies } = this.props;
 
     this.props.dispatch(fetchAccount(accountId));
-    this.props.dispatch(refreshAccountFeaturedTimeline(accountId));
-    this.props.dispatch(refreshAccountTimeline(accountId, withReplies));
+    if (!withReplies) {
+      this.props.dispatch(expandAccountFeaturedTimeline(accountId));
+    }
+    this.props.dispatch(expandAccountTimeline(accountId, { withReplies }));
   }
 
   componentWillReceiveProps (nextProps) {
     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(refreshAccountFeaturedTimeline(nextProps.params.accountId));
-      this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId, nextProps.params.withReplies));
+      if (!nextProps.withReplies) {
+        this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
+      }
+      this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
     }
   }
 
-  handleScrollToBottom = () => {
-    if (!this.props.isLoading && this.props.hasMore) {
-      this.props.dispatch(expandAccountTimeline(this.props.params.accountId, this.props.withReplies));
-    }
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies }));
   }
 
   render () {
@@ -80,7 +82,7 @@ export default class AccountTimeline extends ImmutablePureComponent {
           featuredStatusIds={featuredStatusIds}
           isLoading={isLoading}
           hasMore={hasMore}
-          onScrollToBottom={this.handleScrollToBottom}
+          onLoadMore={this.handleLoadMore}
         />
       </Column>
     );
diff --git a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js
index dae5caf1d..9468ad81d 100644
--- a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js
+++ b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js
@@ -62,7 +62,7 @@ export default class Bookmarks extends ImmutablePureComponent {
     this.column = c;
   }
 
-  handleScrollToBottom = debounce(() => {
+  handleLoadMore = debounce(() => {
     this.props.dispatch(expandBookmarkedStatuses());
   }, 300, { leading: true })
 
@@ -89,7 +89,7 @@ export default class Bookmarks extends ImmutablePureComponent {
           scrollKey={`bookmarked_statuses-${columnId}`}
           hasMore={hasMore}
           isLoading={isLoading}
-          onScrollToBottom={this.handleScrollToBottom}
+          onLoadMore={this.handleLoadMore}
         />
       </Column>
     );
diff --git a/app/javascript/flavours/glitch/features/community_timeline/index.js b/app/javascript/flavours/glitch/features/community_timeline/index.js
index 55355414f..b5843ca16 100644
--- a/app/javascript/flavours/glitch/features/community_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/community_timeline/index.js
@@ -4,10 +4,7 @@ import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
-import {
-  refreshCommunityTimeline,
-  expandCommunityTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
@@ -55,7 +52,7 @@ export default class CommunityTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(refreshCommunityTimeline());
+    dispatch(expandCommunityTimeline());
     this.disconnect = dispatch(connectCommunityStream());
   }
 
@@ -70,8 +67,8 @@ export default class CommunityTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandCommunityTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandCommunityTimeline({ maxId }));
   }
 
   render () {
@@ -97,7 +94,7 @@ export default class CommunityTimeline extends React.PureComponent {
           trackScroll={!pinned}
           scrollKey={`community_timeline-${columnId}`}
           timelineId='community'
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
         />
       </Column>
diff --git a/app/javascript/flavours/glitch/features/composer/direct_warning/index.js b/app/javascript/flavours/glitch/features/composer/direct_warning/index.js
new file mode 100644
index 000000000..d1febdd1b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/direct_warning/index.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { defineMessages, FormattedMessage } from 'react-intl';
+
+//  This is the spring used with our motion.
+const motionSpring = spring(1, { damping: 35, stiffness: 400 });
+
+//  Messages.
+const messages = defineMessages({
+  disclaimer: {
+    defaultMessage: 'This toot will only be sent to all the mentioned users.',
+    id: 'compose_form.direct_message_warning',
+  },
+  learn_more: {
+    defaultMessage: 'Learn more',
+    id: 'compose_form.direct_message_warning_learn_more'
+  }
+});
+
+//  The component.
+export default function ComposerDirectWarning () {
+  return (
+    <Motion
+      defaultStyle={{
+        opacity: 0,
+        scaleX: 0.85,
+        scaleY: 0.75,
+      }}
+      style={{
+        opacity: motionSpring,
+        scaleX: motionSpring,
+        scaleY: motionSpring,
+      }}
+    >
+      {({ opacity, scaleX, scaleY }) => (
+        <div
+          className='composer--warning'
+          style={{
+            opacity: opacity,
+            transform: `scale(${scaleX}, ${scaleY})`,
+          }}
+        >
+          <span>
+            <FormattedMessage {...messages.disclaimer} /> <a href='/terms' target='_blank'><FormattedMessage {...messages.learn_more} /></a>
+          </span>
+        </div>
+      )}
+    </Motion>
+  );
+}
+
+ComposerDirectWarning.propTypes = {};
diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js
index 3aa283628..21b03be39 100644
--- a/app/javascript/flavours/glitch/features/composer/index.js
+++ b/app/javascript/flavours/glitch/features/composer/index.js
@@ -39,6 +39,7 @@ import ComposerTextarea from './textarea';
 import ComposerUploadForm from './upload_form';
 import ComposerWarning from './warning';
 import ComposerHashtagWarning from './hashtag_warning';
+import ComposerDirectWarning from './direct_warning';
 
 //  Utils.
 import { countableText } from 'flavours/glitch/util/counter';
@@ -55,6 +56,7 @@ function mapStateToProps (state) {
     advancedOptions: state.getIn(['compose', 'advanced_options']),
     amUnlocked: !state.getIn(['accounts', me, 'locked']),
     focusDate: state.getIn(['compose', 'focusDate']),
+    caretPosition: state.getIn(['compose', 'caretPosition']),
     isSubmitting: state.getIn(['compose', 'is_submitting']),
     isUploading: state.getIn(['compose', 'is_uploading']),
     layout: state.getIn(['local_settings', 'layout']),
@@ -116,7 +118,6 @@ const handlers = {
   handleEmoji (data) {
     const { textarea: { selectionStart } } = this;
     const { onInsertEmoji } = this.props;
-    this.caretPos = selectionStart + data.native.length + 1;
     if (onInsertEmoji) {
       onInsertEmoji(selectionStart, data);
     }
@@ -138,7 +139,6 @@ const handlers = {
   //  Selects a suggestion from the autofill.
   handleSelect (tokenStart, token, value) {
     const { onSelectSuggestion } = this.props;
-    this.caretPos = null;
     if (onSelectSuggestion) {
       onSelectSuggestion(tokenStart, token, value);
     }
@@ -190,20 +190,9 @@ class Composer extends React.Component {
     assignHandlers(this, handlers);
 
     //  Instance variables.
-    this.caretPos = null;
     this.textarea = null;
   }
 
-  //  If this is the update where we've finished uploading,
-  //  save the last caret position so we can restore it below!
-  componentWillReceiveProps (nextProps) {
-    const { textarea } = this;
-    const { isUploading } = this.props;
-    if (textarea && isUploading && !nextProps.isUploading) {
-      this.caretPos = textarea.selectionStart;
-    }
-  }
-
   //  Tells our state the composer has been mounted.
   componentDidMount () {
     const { onMount } = this.props;
@@ -227,17 +216,13 @@ class Composer extends React.Component {
   //      - Replying to more than one user, selects any usernames past
   //        the first; this provides a convenient shortcut to drop
   //        everyone else from the conversation.
-  // - If we've just finished uploading an image, and have a saved
-  //   caret position, restores the cursor to that position after the
-  //   text changes.
   componentDidUpdate (prevProps) {
     const {
-      caretPos,
       textarea,
     } = this;
     const {
       focusDate,
-      isUploading,
+      caretPosition,
       isSubmitting,
       preselectDate,
       text,
@@ -245,14 +230,14 @@ class Composer extends React.Component {
     let selectionEnd, selectionStart;
 
     //  Caret/selection handling.
-    if (focusDate !== prevProps.focusDate || (prevProps.isUploading && !isUploading && !isNaN(caretPos) && caretPos !== null)) {
+    if (focusDate !== prevProps.focusDate) {
       switch (true) {
       case preselectDate !== prevProps.preselectDate:
         selectionStart = text.search(/\s/) + 1;
         selectionEnd = text.length;
         break;
-      case !isNaN(caretPos) && caretPos !== null:
-        selectionStart = selectionEnd = caretPos;
+      case !isNaN(caretPosition) && caretPosition !== null:
+        selectionStart = selectionEnd = caretPosition;
         break;
       default:
         selectionStart = selectionEnd = text.length;
@@ -326,6 +311,7 @@ class Composer extends React.Component {
           onSubmit={handleSubmit}
           text={spoilerText}
         />
+        {privacy === 'direct' ? <ComposerDirectWarning /> : null}
         {privacy === 'private' && amUnlocked ? <ComposerWarning /> : null}
         {privacy !== 'public' && APPROX_HASHTAG_RE.test(text) ? <ComposerHashtagWarning /> : null}
         {replyContent ? (
@@ -408,6 +394,7 @@ Composer.propTypes = {
   advancedOptions: ImmutablePropTypes.map,
   amUnlocked: PropTypes.bool,
   focusDate: PropTypes.instanceOf(Date),
+  caretPosition: PropTypes.number,
   isSubmitting: PropTypes.bool,
   isUploading: PropTypes.bool,
   layout: PropTypes.string,
diff --git a/app/javascript/flavours/glitch/features/composer/reply/index.js b/app/javascript/flavours/glitch/features/composer/reply/index.js
index 0b8ceddee..0500a75d0 100644
--- a/app/javascript/flavours/glitch/features/composer/reply/index.js
+++ b/app/javascript/flavours/glitch/features/composer/reply/index.js
@@ -58,6 +58,7 @@ export default class ComposerReply extends React.PureComponent {
             icon='times'
             onClick={handleClick}
             title={intl.formatMessage(messages.cancel)}
+            inverted
           />
           {account ? (
             <AccountContainer
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/index.js b/app/javascript/flavours/glitch/features/direct_timeline/index.js
index 81096c0ec..418db7c79 100644
--- a/app/javascript/flavours/glitch/features/direct_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/direct_timeline/index.js
@@ -4,10 +4,7 @@ import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
-import {
-  refreshDirectTimeline,
-  expandDirectTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandDirectTimeline } from 'flavours/glitch/actions/timelines';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
@@ -55,7 +52,7 @@ export default class DirectTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(refreshDirectTimeline());
+    dispatch(expandDirectTimeline());
     this.disconnect = dispatch(connectDirectStream());
   }
 
@@ -70,8 +67,8 @@ export default class DirectTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandDirectTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandDirectTimeline({ maxId }));
   }
 
   render () {
@@ -97,7 +94,7 @@ export default class DirectTimeline extends React.PureComponent {
           trackScroll={!pinned}
           scrollKey={`direct_timeline-${columnId}`}
           timelineId='direct'
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
         />
       </Column>
diff --git a/app/javascript/flavours/glitch/features/domain_blocks/index.js b/app/javascript/flavours/glitch/features/domain_blocks/index.js
index b17c47e91..3b29e2a26 100644
--- a/app/javascript/flavours/glitch/features/domain_blocks/index.js
+++ b/app/javascript/flavours/glitch/features/domain_blocks/index.js
@@ -52,7 +52,7 @@ export default class Blocks extends ImmutablePureComponent {
     }
 
     return (
-      <Column icon='ban' heading={intl.formatMessage(messages.heading)}>
+      <Column icon='minus-circle' heading={intl.formatMessage(messages.heading)}>
         <ColumnBackButtonSlim />
         <ScrollableList scrollKey='domain_blocks' onLoadMore={this.handleLoadMore}>
           {domains.map(domain =>
diff --git a/app/javascript/flavours/glitch/features/drawer/results/index.js b/app/javascript/flavours/glitch/features/drawer/results/index.js
index f2a79eb59..23dc0e3cf 100644
--- a/app/javascript/flavours/glitch/features/drawer/results/index.js
+++ b/app/javascript/flavours/glitch/features/drawer/results/index.js
@@ -68,6 +68,8 @@ export default function DrawerResults ({
           </header>
           {accounts && accounts.size ? (
             <section>
+              <h5><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
+
               {accounts.map(
                 accountId => (
                   <AccountContainer
@@ -80,6 +82,8 @@ export default function DrawerResults ({
           ) : null}
           {statuses && statuses.size ? (
             <section>
+              <h5><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
+
               {statuses.map(
                 statusId => (
                   <StatusContainer
@@ -92,6 +96,8 @@ export default function DrawerResults ({
           ) : null}
           {hashtags && hashtags.size ? (
             <section>
+              <h5><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
+
               {hashtags.map(
                 hashtag => (
                   <Link
diff --git a/app/javascript/flavours/glitch/features/favourited_statuses/index.js b/app/javascript/flavours/glitch/features/favourited_statuses/index.js
index 301a5ae4f..d8fa1b84e 100644
--- a/app/javascript/flavours/glitch/features/favourited_statuses/index.js
+++ b/app/javascript/flavours/glitch/features/favourited_statuses/index.js
@@ -62,7 +62,7 @@ export default class Favourites extends ImmutablePureComponent {
     this.column = c;
   }
 
-  handleScrollToBottom = debounce(() => {
+  handleLoadMore = debounce(() => {
     this.props.dispatch(expandFavouritedStatuses());
   }, 300, { leading: true })
 
@@ -89,7 +89,7 @@ export default class Favourites extends ImmutablePureComponent {
           scrollKey={`favourited_statuses-${columnId}`}
           hasMore={hasMore}
           isLoading={isLoading}
-          onScrollToBottom={this.handleScrollToBottom}
+          onLoadMore={this.handleLoadMore}
         />
       </Column>
     );
diff --git a/app/javascript/flavours/glitch/features/getting_started_misc/index.js b/app/javascript/flavours/glitch/features/getting_started_misc/index.js
index 77c44c273..b67e6f97f 100644
--- a/app/javascript/flavours/glitch/features/getting_started_misc/index.js
+++ b/app/javascript/flavours/glitch/features/getting_started_misc/index.js
@@ -50,7 +50,7 @@ export default class gettingStartedMisc extends ImmutablePureComponent {
           <ColumnLink key='20' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />
           <ColumnLink key='21' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
           <ColumnLink key='22' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
-          <ColumnLink icon='ban' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />
+          <ColumnLink icon='minus-circle' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />
           <ColumnLink key='23' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />
           <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
           <ColumnLink icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} />
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
index 9f3c9bec7..8f77ed42b 100644
--- a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
@@ -4,10 +4,7 @@ import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
-import {
-  refreshHashtagTimeline,
-  expandHashtagTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandHashtagTimeline } from 'flavours/glitch/actions/timelines';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { FormattedMessage } from 'react-intl';
 import { connectHashtagStream } from 'flavours/glitch/actions/streaming';
@@ -61,13 +58,13 @@ export default class HashtagTimeline extends React.PureComponent {
     const { dispatch } = this.props;
     const { id } = this.props.params;
 
-    dispatch(refreshHashtagTimeline(id));
+    dispatch(expandHashtagTimeline(id));
     this._subscribe(dispatch, id);
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.id !== this.props.params.id) {
-      this.props.dispatch(refreshHashtagTimeline(nextProps.params.id));
+      this.props.dispatch(expandHashtagTimeline(nextProps.params.id));
       this._unsubscribe();
       this._subscribe(this.props.dispatch, nextProps.params.id);
     }
@@ -81,8 +78,8 @@ export default class HashtagTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandHashtagTimeline(this.props.params.id));
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandHashtagTimeline(this.props.params.id, { maxId }));
   }
 
   render () {
@@ -108,7 +105,7 @@ export default class HashtagTimeline extends React.PureComponent {
           trackScroll={!pinned}
           scrollKey={`hashtag_timeline-${columnId}`}
           timelineId={`hashtag:${id}`}
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
         />
       </Column>
diff --git a/app/javascript/flavours/glitch/features/home_timeline/index.js b/app/javascript/flavours/glitch/features/home_timeline/index.js
index c20c0244a..3650ffc6d 100644
--- a/app/javascript/flavours/glitch/features/home_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/home_timeline/index.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import { connect } from 'react-redux';
-import { expandHomeTimeline, refreshHomeTimeline } from 'flavours/glitch/actions/timelines';
+import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
 import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
 import Column from 'flavours/glitch/components/column';
@@ -16,7 +16,7 @@ const messages = defineMessages({
 
 const mapStateToProps = state => ({
   hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
-  isPartial: state.getIn(['timelines', 'home', 'isPartial'], false),
+  isPartial: state.getIn(['timelines', 'home', 'items', 0], null) === null,
 });
 
 @connect(mapStateToProps)
@@ -55,8 +55,8 @@ export default class HomeTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandHomeTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandHomeTimeline({ maxId }));
   }
 
   componentDidMount () {
@@ -78,7 +78,7 @@ export default class HomeTimeline extends React.PureComponent {
       return;
     } else if (!wasPartial && isPartial) {
       this.polling = setInterval(() => {
-        dispatch(refreshHomeTimeline());
+        dispatch(expandHomeTimeline());
       }, 3000);
     } else if (wasPartial && !isPartial) {
       this._stopPolling();
@@ -114,7 +114,7 @@ export default class HomeTimeline extends React.PureComponent {
         <StatusListContainer
           trackScroll={!pinned}
           scrollKey={`home_timeline-${columnId}`}
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           timelineId='home'
           emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Visit {public} or use search to get started and meet other users.' values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />}
         />
diff --git a/app/javascript/flavours/glitch/features/list_timeline/index.js b/app/javascript/flavours/glitch/features/list_timeline/index.js
index f9476d92d..07edf45aa 100644
--- a/app/javascript/flavours/glitch/features/list_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/list_timeline/index.js
@@ -8,7 +8,7 @@ import ColumnHeader from 'flavours/glitch/components/column_header';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import { connectListStream } from 'flavours/glitch/actions/streaming';
-import { refreshListTimeline, expandListTimeline } from 'flavours/glitch/actions/timelines';
+import { expandListTimeline } from 'flavours/glitch/actions/timelines';
 import { fetchList, deleteList } from 'flavours/glitch/actions/lists';
 import { openModal } from 'flavours/glitch/actions/modal';
 import MissingIndicator from 'flavours/glitch/components/missing_indicator';
@@ -67,7 +67,7 @@ export default class ListTimeline extends React.PureComponent {
     const { id } = this.props.params;
 
     dispatch(fetchList(id));
-    dispatch(refreshListTimeline(id));
+    dispatch(expandListTimeline(id));
 
     this.disconnect = dispatch(connectListStream(id));
   }
@@ -83,9 +83,9 @@ export default class ListTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
+  handleLoadMore = maxId => {
     const { id } = this.props.params;
-    this.props.dispatch(expandListTimeline(id));
+    this.props.dispatch(expandListTimeline(id, { maxId }));
   }
 
   handleEditClick = () => {
@@ -164,7 +164,7 @@ export default class ListTimeline extends React.PureComponent {
           trackScroll={!pinned}
           scrollKey={`list_timeline-${columnId}`}
           timelineId={`list:${id}`}
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet.' />}
         />
       </Column>
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 12b0b5b83..266d6807d 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -17,6 +17,7 @@ import { createSelector } from 'reselect';
 import { List as ImmutableList } from 'immutable';
 import { debounce } from 'lodash';
 import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import LoadGap from 'flavours/glitch/components/load_gap';
 
 const messages = defineMessages({
   title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@@ -25,14 +26,14 @@ const messages = defineMessages({
 const getNotifications = createSelector([
   state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
   state => state.getIn(['notifications', 'items']),
-], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
+], (excludedTypes, notifications) => notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))));
 
 const mapStateToProps = state => ({
   notifications: getNotifications(state),
   localSettings:  state.get('local_settings'),
   isLoading: state.getIn(['notifications', 'isLoading'], true),
   isUnread: state.getIn(['notifications', 'unread']) > 0,
-  hasMore: !!state.getIn(['notifications', 'next']),
+  hasMore: state.getIn(['notifications', 'hasMore']),
   notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
 });
 
@@ -67,9 +68,13 @@ export default class Notifications extends React.PureComponent {
     trackScroll: true,
   };
 
-  handleScrollToBottom = debounce(() => {
-    this.props.dispatch(scrollTopNotifications(false));
-    this.props.dispatch(expandNotifications());
+  handleLoadGap = (maxId) => {
+    this.props.dispatch(expandNotifications({ maxId }));
+  };
+
+  handleLoadOlder = debounce(() => {
+    const last = this.props.notifications.last();
+    this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
   }, 300, { leading: true });
 
   handleScrollToTop = debounce(() => {
@@ -104,12 +109,12 @@ export default class Notifications extends React.PureComponent {
   }
 
   handleMoveUp = id => {
-    const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1;
+    const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
     this._selectChild(elementIndex);
   }
 
   handleMoveDown = id => {
-    const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1;
+    const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
     this._selectChild(elementIndex);
   }
 
@@ -131,7 +136,14 @@ export default class Notifications extends React.PureComponent {
     if (isLoading && this.scrollableContent) {
       scrollableContent = this.scrollableContent;
     } else if (notifications.size > 0 || hasMore) {
-      scrollableContent = notifications.map((item) => (
+      scrollableContent = notifications.map((item, index) => item === null ? (
+        <LoadGap
+          key={'gap:' + notifications.getIn([index + 1, 'id'])}
+          disabled={isLoading}
+          maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
+          onClick={this.handleLoadGap}
+        />
+      ) : (
         <NotificationContainer
           key={item.get('id')}
           notification={item}
@@ -153,7 +165,7 @@ export default class Notifications extends React.PureComponent {
         isLoading={isLoading}
         hasMore={hasMore}
         emptyMessage={emptyMessage}
-        onScrollToBottom={this.handleScrollToBottom}
+        onLoadMore={this.handleLoadOlder}
         onScrollToTop={this.handleScrollToTop}
         onScroll={this.handleScroll}
         shouldUpdateScroll={shouldUpdateScroll}
diff --git a/app/javascript/flavours/glitch/features/public_timeline/index.js b/app/javascript/flavours/glitch/features/public_timeline/index.js
index bbdd4612e..a6c0b1688 100644
--- a/app/javascript/flavours/glitch/features/public_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/public_timeline/index.js
@@ -4,10 +4,7 @@ import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
-import {
-  refreshPublicTimeline,
-  expandPublicTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandPublicTimeline } from 'flavours/glitch/actions/timelines';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
@@ -55,7 +52,7 @@ export default class PublicTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(refreshPublicTimeline());
+    dispatch(expandPublicTimeline());
     this.disconnect = dispatch(connectPublicStream());
   }
 
@@ -70,8 +67,8 @@ export default class PublicTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandPublicTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandPublicTimeline({ maxId }));
   }
 
   render () {
@@ -95,7 +92,7 @@ export default class PublicTimeline extends React.PureComponent {
 
         <StatusListContainer
           timelineId='public'
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           trackScroll={!pinned}
           scrollKey={`public_timeline-${columnId}`}
           emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />}
diff --git a/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js
new file mode 100644
index 000000000..c488f9541
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { defineMessages, injectIntl } from 'react-intl';
+import { connectCommunityStream } from 'flavours/glitch/actions/streaming';
+
+const messages = defineMessages({
+  title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
+});
+
+@connect()
+@injectIntl
+export default class CommunityTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(expandCommunityTimeline());
+    this.disconnect = dispatch(connectCommunityStream());
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandCommunityTimeline({ maxId }));
+  }
+
+  render () {
+    const { intl } = this.props;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='users'
+          title={intl.formatMessage(messages.title)}
+          onClick={this.handleHeaderClick}
+        />
+
+        <StatusListContainer
+          timelineId='community'
+          onLoadMore={this.handleLoadMore}
+          scrollKey='standalone_public_timeline'
+          trackScroll={false}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
index 0ad2cef80..dc02f1c91 100644
--- a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
@@ -2,12 +2,10 @@ import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
-import {
-  refreshHashtagTimeline,
-  expandHashtagTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandHashtagTimeline } from 'flavours/glitch/actions/timelines';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
+import { connectHashtagStream } from 'flavours/glitch/actions/streaming';
 
 @connect()
 export default class HashtagTimeline extends React.PureComponent {
@@ -28,22 +26,19 @@ export default class HashtagTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch, hashtag } = this.props;
 
-    dispatch(refreshHashtagTimeline(hashtag));
-
-    this.polling = setInterval(() => {
-      dispatch(refreshHashtagTimeline(hashtag));
-    }, 10000);
+    dispatch(expandHashtagTimeline(hashtag));
+    this.disconnect = dispatch(connectHashtagStream(hashtag));
   }
 
   componentWillUnmount () {
-    if (typeof this.polling !== 'undefined') {
-      clearInterval(this.polling);
-      this.polling = null;
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
     }
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandHashtagTimeline(this.props.hashtag));
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
   }
 
   render () {
@@ -61,7 +56,7 @@ export default class HashtagTimeline extends React.PureComponent {
           trackScroll={false}
           scrollKey='standalone_hashtag_timeline'
           timelineId={`hashtag:${hashtag}`}
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
         />
       </Column>
     );
diff --git a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
index 717f6fcaf..0b4238485 100644
--- a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
@@ -2,13 +2,11 @@ import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
-import {
-  refreshPublicTimeline,
-  expandPublicTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandPublicTimeline } from 'flavours/glitch/actions/timelines';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
 import { defineMessages, injectIntl } from 'react-intl';
+import { connectPublicStream } from 'flavours/glitch/actions/streaming';
 
 const messages = defineMessages({
   title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
@@ -34,22 +32,19 @@ export default class PublicTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(refreshPublicTimeline());
-
-    this.polling = setInterval(() => {
-      dispatch(refreshPublicTimeline());
-    }, 3000);
+    dispatch(expandPublicTimeline());
+    this.disconnect = dispatch(connectPublicStream());
   }
 
   componentWillUnmount () {
-    if (typeof this.polling !== 'undefined') {
-      clearInterval(this.polling);
-      this.polling = null;
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
     }
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandPublicTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandPublicTimeline({ maxId }));
   }
 
   render () {
@@ -65,7 +60,7 @@ export default class PublicTimeline extends React.PureComponent {
 
         <StatusListContainer
           timelineId='public'
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           scrollKey='standalone_public_timeline'
           trackScroll={false}
         />
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 1ea0fa421..ef8805377 100644
--- a/app/javascript/flavours/glitch/features/status/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js
@@ -8,6 +8,7 @@ import { me } from 'flavours/glitch/util/initial_state';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
+  direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
   mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
   reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
@@ -43,6 +44,7 @@ export default class ActionBar extends React.PureComponent {
     onMuteConversation: PropTypes.func,
     onBlock: PropTypes.func,
     onDelete: PropTypes.func.isRequired,
+    onDirect: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
     onReport: PropTypes.func,
     onPin: PropTypes.func,
@@ -70,6 +72,10 @@ export default class ActionBar extends React.PureComponent {
     this.props.onDelete(this.props.status);
   }
 
+  handleDirectClick = () => {
+    this.props.onDirect(this.props.status.get('account'), this.context.router.history);
+  }
+
   handleMentionClick = () => {
     this.props.onMention(this.props.status.get('account'), this.context.router.history);
   }
@@ -115,6 +121,7 @@ export default class ActionBar extends React.PureComponent {
 
     if (publicStatus) {
       menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+      menu.push(null);
     }
 
     if (me === status.getIn(['account', 'id'])) {
@@ -128,6 +135,7 @@ export default class ActionBar extends React.PureComponent {
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
       menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
@@ -149,7 +157,7 @@ export default class ActionBar extends React.PureComponent {
       <div className='detailed-status__action-bar'>
         <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
         <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblog_message)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
-        <div className='detailed-status__button'><IconButton animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
+        <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
         {shareButton}
         <div className='detailed-status__button'><IconButton active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
 
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 16f7ae830..5cfc9dfae 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -37,8 +37,8 @@ export default class DetailedStatus extends ImmutablePureComponent {
     e.stopPropagation();
   }
 
-  handleOpenVideo = startTime => {
-    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+  handleOpenVideo = (media, startTime) => {
+    this.props.onOpenVideo(media, startTime);
   }
 
   render () {
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 7e1658dbb..6c9da8e3e 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -21,6 +21,7 @@ import {
 import {
   replyCompose,
   mentionCompose,
+  directCompose,
 } from 'flavours/glitch/actions/compose';
 import { blockAccount } from 'flavours/glitch/actions/accounts';
 import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses';
@@ -170,6 +171,10 @@ export default class Status extends ImmutablePureComponent {
     }
   }
 
+  handleDirectClick = (account, router) => {
+    this.props.dispatch(directCompose(account, router));
+  }
+
   handleMentionClick = (account, router) => {
     this.props.dispatch(mentionCompose(account, router));
   }
@@ -399,6 +404,7 @@ export default class Status extends ImmutablePureComponent {
                   onReblog={this.handleReblogClick}
                   onBookmark={this.handleBookmarkClick}
                   onDelete={this.handleDeleteClick}
+                  onDirect={this.handleDirectClick}
                   onMention={this.handleMentionClick}
                   onMute={this.handleMuteClick}
                   onMuteConversation={this.handleConversationMuteClick}
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 6ab6770ed..bffe3b1f7 100644
--- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
@@ -2,6 +2,7 @@ import React from 'react';
 import ReactSwipeableViews from 'react-swipeable-views';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
+import Video from 'flavours/glitch/features/video';
 import ExtendedVideoPlayer from 'flavours/glitch/components/extended_video_player';
 import classNames from 'classnames';
 import { defineMessages, injectIntl } from 'react-intl';
@@ -112,6 +113,22 @@ export default class MediaModal extends ImmutablePureComponent {
             onClick={this.toggleNavigation}
           />
         );
+      } else if (image.get('type') === 'video') {
+        const { time } = this.props;
+
+        return (
+          <Video
+            preview={image.get('preview_url')}
+            src={image.get('url')}
+            width={image.get('width')}
+            height={image.get('height')}
+            startTime={time || 0}
+            onCloseVideo={onClose}
+            detailed
+            description={image.get('description')}
+            key={image.get('url')}
+          />
+        );
       } else if (image.get('type') === 'gifv') {
         return (
           <ExtendedVideoPlayer
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 320c039a4..7e9980ef7 100644
--- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
@@ -59,7 +59,7 @@ export default class ModalRoot extends React.PureComponent {
     const visible = !!type;
 
     return (
-      <Base onClose={onClose}>
+      <Base onClose={onClose} noEsc={props ? props.noEsc : false}>
         {visible && (
           <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
             {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.js b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
index 3b7a5ff20..ff81522a8 100644
--- a/app/javascript/flavours/glitch/features/ui/components/report_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import { changeReportComment, changeReportForward, submitReport } from 'flavours/glitch/actions/reports';
-import { refreshAccountTimeline } from 'flavours/glitch/actions/timelines';
+import { expandAccountTimeline } from 'flavours/glitch/actions/timelines';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { makeGetAccount } from 'flavours/glitch/selectors';
@@ -64,12 +64,12 @@ export default class ReportModal extends ImmutablePureComponent {
   }
 
   componentDidMount () {
-    this.props.dispatch(refreshAccountTimeline(this.props.account.get('id'), true));
+    this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { withReplies: true }));
   }
 
   componentWillReceiveProps (nextProps) {
     if (this.props.account !== nextProps.account && nextProps.account) {
-      this.props.dispatch(refreshAccountTimeline(nextProps.account.get('id'), true));
+      this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { withReplies: true }));
     }
   }
 
diff --git a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
index f85a2eeb8..e0c017f82 100644
--- a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
+++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
@@ -21,6 +21,8 @@ const makeGetStatusIds = () => createSelector([
   }
 
   return statusIds.filter(id => {
+    if (id === null) return true;
+
     const statusForId = statuses.get(id);
     let showStatus    = true;
 
@@ -52,18 +54,13 @@ const makeMapStateToProps = () => {
     statusIds: getStatusIds(state, { type: timelineId }),
     isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
     isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
-    hasMore: !!state.getIn(['timelines', timelineId, 'next']),
+    hasMore:   state.getIn(['timelines', timelineId, 'hasMore']),
   });
 
   return mapStateToProps;
 };
 
-const mapDispatchToProps = (dispatch, { timelineId, loadMore }) => ({
-
-  onScrollToBottom: debounce(() => {
-    dispatch(scrollTopTimeline(timelineId, false));
-    loadMore();
-  }, 300, { leading: true }),
+const mapDispatchToProps = (dispatch, { timelineId }) => ({
 
   onScrollToTop: debounce(() => {
     dispatch(scrollTopTimeline(timelineId, true));
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index e4b69cb3b..0e3a83bb6 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -9,8 +9,8 @@ import { Redirect, withRouter } from 'react-router-dom';
 import { isMobile } from 'flavours/glitch/util/is_mobile';
 import { debounce } from 'lodash';
 import { uploadCompose, resetCompose } from 'flavours/glitch/actions/compose';
-import { refreshHomeTimeline } from 'flavours/glitch/actions/timelines';
-import { refreshNotifications } from 'flavours/glitch/actions/notifications';
+import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
+import { expandNotifications } from 'flavours/glitch/actions/notifications';
 import { clearHeight } from 'flavours/glitch/actions/height_cache';
 import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers';
 import UploadArea from './components/upload_area';
@@ -219,8 +219,8 @@ export default class UI extends React.Component {
       navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
     }
 
-    this.props.dispatch(refreshHomeTimeline());
-    this.props.dispatch(refreshNotifications());
+    this.props.dispatch(expandHomeTimeline());
+    this.props.dispatch(expandNotifications());
   }
 
   componentDidMount () {
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
index 3be6e19f7..e9e095e26 100644
--- a/app/javascript/flavours/glitch/features/video/index.js
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { fromJS } from 'immutable';
 import { throttle } from 'lodash';
 import classNames from 'classnames';
 import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen';
@@ -133,6 +134,8 @@ export default class Video extends React.PureComponent {
     this.seek = c;
   }
 
+  handleClickRoot = e => e.stopPropagation();
+
   handlePlay = () => {
     this.setState({ paused: false });
   }
@@ -246,8 +249,17 @@ export default class Video extends React.PureComponent {
   }
 
   handleOpenVideo = () => {
+    const { src, preview, width, height } = this.props;
+    const media = fromJS({
+      type: 'video',
+      url: src,
+      preview_url: preview,
+      width,
+      height,
+    });
+
     this.video.pause();
-    this.props.onOpenVideo(this.video.currentTime);
+    this.props.onOpenVideo(media, this.video.currentTime);
   }
 
   handleCloseVideo = () => {
@@ -279,7 +291,15 @@ export default class Video extends React.PureComponent {
     }
 
     return (
-      <div className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth })} style={playerStyle} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+      <div
+        className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth })}
+        style={playerStyle}
+        ref={this.setPlayerRef}
+        onMouseEnter={this.handleMouseEnter}
+        onMouseLeave={this.handleMouseLeave}
+        onClick={this.handleClickRoot}
+        tabIndex={0}
+      >
         <video
           ref={this.setVideoRef}
           src={src}
diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js
index ed685b6b7..78e8f1053 100644
--- a/app/javascript/flavours/glitch/packs/public.js
+++ b/app/javascript/flavours/glitch/packs/public.js
@@ -6,8 +6,6 @@ function main() {
   const emojify = require('flavours/glitch/util/emoji').default;
   const { getLocale } = require('locales');
   const { localeData } = getLocale();
-  const VideoContainer = require('flavours/glitch/containers/video_container').default;
-  const CardContainer = require('flavours/glitch/containers/card_container').default;
   const React = require('react');
   const ReactDOM = require('react-dom');
 
@@ -52,24 +50,15 @@ function main() {
       });
     });
 
-    [].forEach.call(document.querySelectorAll('[data-component="Video"]'), (content) => {
-      const props = JSON.parse(content.getAttribute('data-props'));
-      ReactDOM.render(<VideoContainer locale={locale} {...props} />, content);
-    });
-
-    [].forEach.call(document.querySelectorAll('[data-component="Card"]'), (content) => {
-      const props = JSON.parse(content.getAttribute('data-props'));
-      ReactDOM.render(<CardContainer locale={locale} {...props} />, content);
-    });
-
-    const mediaGalleries = document.querySelectorAll('[data-component="MediaGallery"]');
-
-    if (mediaGalleries.length > 0) {
-      const MediaGalleriesContainer = require('flavours/glitch/containers/media_galleries_container').default;
-      const content = document.createElement('div');
-
-      ReactDOM.render(<MediaGalleriesContainer locale={locale} galleries={mediaGalleries} />, content);
-      document.body.appendChild(content);
+    const reactComponents = document.querySelectorAll('[data-component]');
+    if (reactComponents.length > 0) {
+      import(/* webpackChunkName: "containers/media_container" */ 'flavours/glitch/containers/media_container')
+        .then(({ default: MediaContainer }) => {
+          const content = document.createElement('div');
+          ReactDOM.render(<MediaContainer locale={locale} components={reactComponents} />, content);
+          document.body.appendChild(content);
+        })
+        .catch(error => console.error(error));
     }
   });
 }
diff --git a/app/javascript/flavours/glitch/reducers/accounts.js b/app/javascript/flavours/glitch/reducers/accounts.js
index 23fbd999c..86f4970c9 100644
--- a/app/javascript/flavours/glitch/reducers/accounts.js
+++ b/app/javascript/flavours/glitch/reducers/accounts.js
@@ -57,6 +57,12 @@ import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
 import emojify from 'flavours/glitch/util/emoji';
 import { Map as ImmutableMap, fromJS } from 'immutable';
 import escapeTextContentForBrowser from 'escape-html';
+import { unescapeHTML } from 'flavours/glitch/util/html';
+
+const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
+  obj[`:${emoji.shortcode}:`] = emoji;
+  return obj;
+}, {});
 
 const normalizeAccount = (state, account) => {
   account = { ...account };
@@ -65,15 +71,17 @@ const normalizeAccount = (state, account) => {
   delete account.following_count;
   delete account.statuses_count;
 
+  const emojiMap = makeEmojiMap(account);
   const displayName = account.display_name.length === 0 ? account.username : account.display_name;
-  account.display_name_html = emojify(escapeTextContentForBrowser(displayName));
-  account.note_emojified = emojify(account.note);
+  account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
+  account.note_emojified = emojify(account.note, emojiMap);
 
   if (account.fields) {
     account.fields = account.fields.map(pair => ({
       ...pair,
       name_emojified: emojify(escapeTextContentForBrowser(pair.name)),
-      value_emojified: emojify(pair.value),
+      value_emojified: emojify(pair.value, emojiMap),
+      value_plain: unescapeHTML(pair.value),
     }));
   }
 
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index 8973c7713..24a8af86f 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -5,6 +5,7 @@ import {
   COMPOSE_CYCLE_ELEFRIEND,
   COMPOSE_REPLY,
   COMPOSE_REPLY_CANCEL,
+  COMPOSE_DIRECT,
   COMPOSE_MENTION,
   COMPOSE_SUBMIT_REQUEST,
   COMPOSE_SUBMIT_SUCCESS,
@@ -55,6 +56,7 @@ const initialState = ImmutableMap({
   privacy: null,
   text: '',
   focusDate: null,
+  caretPosition: null,
   preselectDate: null,
   in_reply_to: null,
   is_submitting: false,
@@ -147,6 +149,7 @@ function continueThread (state, status) {
     map.update('media_attachments', list => list.clear());
     map.set('idempotencyKey', uuid());
     map.set('focusDate', new Date());
+    map.set('caretPosition', null);
     map.set('preselectDate', new Date());
   });
 }
@@ -158,7 +161,6 @@ function appendMedia(state, media) {
     map.update('media_attachments', list => list.push(media));
     map.set('is_uploading', false);
     map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
-    map.set('focusDate', new Date());
     map.set('idempotencyKey', uuid());
 
     if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
@@ -186,6 +188,7 @@ const insertSuggestion = (state, position, token, completion) => {
     map.set('suggestion_token', null);
     map.update('suggestions', ImmutableList(), list => list.clear());
     map.set('focusDate', new Date());
+    map.set('caretPosition', position + completion.length + 1);
     map.set('idempotencyKey', uuid());
   });
 };
@@ -196,6 +199,7 @@ const insertEmoji = (state, position, emojiData) => {
   return state.withMutations(map => {
     map.update('text', oldText => `${oldText.slice(0, position)}${emoji}\u200B${oldText.slice(position)}`);
     map.set('focusDate', new Date());
+    map.set('caretPosition', position + emoji.length + 1);
     map.set('idempotencyKey', uuid());
   });
 };
@@ -277,6 +281,7 @@ export default function compose(state = initialState, action) {
         map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(action.status.get('content')) }))
       );
       map.set('focusDate', new Date());
+      map.set('caretPosition', null);
       map.set('preselectDate', new Date());
       map.set('idempotencyKey', uuid());
 
@@ -321,10 +326,20 @@ export default function compose(state = initialState, action) {
   case COMPOSE_UPLOAD_PROGRESS:
     return state.set('progress', Math.round((action.loaded / action.total) * 100));
   case COMPOSE_MENTION:
-    return state
-      .update('text', text => `${text}@${action.account.get('acct')} `)
-      .set('focusDate', new Date())
-      .set('idempotencyKey', uuid());
+    return state.withMutations(map => {
+      map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
+      map.set('focusDate', new Date());
+      map.set('caretPosition', null);
+      map.set('idempotencyKey', uuid());
+    });
+  case COMPOSE_DIRECT:
+    return state.withMutations(map => {
+      map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
+      map.set('privacy', 'direct');
+      map.set('focusDate', new Date());
+      map.set('caretPosition', null);
+      map.set('idempotencyKey', uuid());
+    });
   case COMPOSE_SUGGESTIONS_CLEAR:
     return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
   case COMPOSE_SUGGESTIONS_READY:
diff --git a/app/javascript/flavours/glitch/reducers/domain_lists.js b/app/javascript/flavours/glitch/reducers/domain_lists.js
index a9e3519f3..eff97fbd6 100644
--- a/app/javascript/flavours/glitch/reducers/domain_lists.js
+++ b/app/javascript/flavours/glitch/reducers/domain_lists.js
@@ -6,7 +6,9 @@ import {
 import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
 
 const initialState = ImmutableMap({
-  blocks: ImmutableMap(),
+  blocks: ImmutableMap({
+    items: ImmutableOrderedSet(),
+  }),
 });
 
 export default function domainLists(state = initialState, action) {
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js
index fb2b3f549..dc820b476 100644
--- a/app/javascript/flavours/glitch/reducers/notifications.js
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -1,10 +1,7 @@
 import {
   NOTIFICATIONS_UPDATE,
-  NOTIFICATIONS_REFRESH_SUCCESS,
   NOTIFICATIONS_EXPAND_SUCCESS,
-  NOTIFICATIONS_REFRESH_REQUEST,
   NOTIFICATIONS_EXPAND_REQUEST,
-  NOTIFICATIONS_REFRESH_FAIL,
   NOTIFICATIONS_EXPAND_FAIL,
   NOTIFICATIONS_CLEAR,
   NOTIFICATIONS_SCROLL_TOP,
@@ -19,16 +16,16 @@ import {
   ACCOUNT_BLOCK_SUCCESS,
   ACCOUNT_MUTE_SUCCESS,
 } from 'flavours/glitch/actions/accounts';
-import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
+import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from 'flavours/glitch/actions/timelines';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import compareId from 'flavours/glitch/util/compare_id';
 
 const initialState = ImmutableMap({
   items: ImmutableList(),
-  next: null,
+  hasMore: true,
   top: true,
   unread: 0,
-  loaded: false,
-  isLoading: true,
+  isLoading: false,
   cleaningMode: false,
   // notification removal mark of new notifs loaded whilst cleaningMode is true.
   markNewForDelete: false,
@@ -58,39 +55,38 @@ const normalizeNotification = (state, notification) => {
   });
 };
 
-const normalizeNotifications = (state, notifications, next) => {
-  let items    = ImmutableList();
-  const loaded = state.get('loaded');
+const expandNormalizedNotifications = (state, notifications, next) => {
+  let items = ImmutableList();
 
   notifications.forEach((n, i) => {
     items = items.set(i, notificationToMap(state, n));
   });
 
-  if (state.get('next') === null) {
-    state = state.set('next', next);
-  }
+  return state.withMutations(mutable => {
+    if (!items.isEmpty()) {
+      mutable.update('items', list => {
+        const lastIndex = 1 + list.findLastIndex(
+          item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id'))
+        );
 
-  return state
-    .update('items', list => loaded ? items.concat(list) : list.concat(items))
-    .set('loaded', true)
-    .set('isLoading', false);
-};
+        const firstIndex = 1 + list.take(lastIndex).findLastIndex(
+          item => item !== null && compareId(item.get('id'), items.first().get('id')) > 0
+        );
 
-const appendNormalizedNotifications = (state, notifications, next) => {
-  let items = ImmutableList();
+        return list.take(firstIndex).concat(items, list.skip(lastIndex));
+      });
+    }
 
-  notifications.forEach((n, i) => {
-    items = items.set(i, notificationToMap(state, n));
-  });
+    if (!next) {
+      mutable.set('hasMore', true);
+    }
 
-  return state
-    .update('items', list => list.concat(items))
-    .set('next', next)
-    .set('isLoading', false);
+    mutable.set('isLoading', false);
+  });
 };
 
 const filterNotifications = (state, relationship) => {
-  return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id));
+  return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id));
 };
 
 const updateTop = (state, top) => {
@@ -102,7 +98,7 @@ const updateTop = (state, top) => {
 };
 
 const deleteByStatus = (state, statusId) => {
-  return state.update('items', list => list.filterNot(item => item.get('status') === statusId));
+  return state.update('items', list => list.filterNot(item => item !== null && item.get('status') === statusId));
 };
 
 const markForDelete = (state, notificationId, yes) => {
@@ -137,29 +133,29 @@ export default function notifications(state = initialState, action) {
   let st;
 
   switch(action.type) {
-  case NOTIFICATIONS_REFRESH_REQUEST:
   case NOTIFICATIONS_EXPAND_REQUEST:
   case NOTIFICATIONS_DELETE_MARKED_REQUEST:
     return state.set('isLoading', true);
   case NOTIFICATIONS_DELETE_MARKED_FAIL:
-  case NOTIFICATIONS_REFRESH_FAIL:
   case NOTIFICATIONS_EXPAND_FAIL:
     return state.set('isLoading', false);
   case NOTIFICATIONS_SCROLL_TOP:
     return updateTop(state, action.top);
   case NOTIFICATIONS_UPDATE:
     return normalizeNotification(state, action.notification);
-  case NOTIFICATIONS_REFRESH_SUCCESS:
-    return normalizeNotifications(state, action.notifications, action.next);
   case NOTIFICATIONS_EXPAND_SUCCESS:
-    return appendNormalizedNotifications(state, action.notifications, action.next);
+    return expandNormalizedNotifications(state, action.notifications, action.next);
   case ACCOUNT_BLOCK_SUCCESS:
   case ACCOUNT_MUTE_SUCCESS:
     return filterNotifications(state, action.relationship);
   case NOTIFICATIONS_CLEAR:
-    return state.set('items', ImmutableList()).set('next', null);
+    return state.set('items', ImmutableList()).set('hasMore', false);
   case TIMELINE_DELETE:
     return deleteByStatus(state, action.id);
+  case TIMELINE_DISCONNECT:
+    return action.timeline === 'home' ?
+      state.update('items', items => items.first() ? items.unshift(null) : items) :
+      state;
 
   case NOTIFICATION_MARK_FOR_DELETE:
     return markForDelete(state, action.id, action.yes);
diff --git a/app/javascript/flavours/glitch/reducers/search.js b/app/javascript/flavours/glitch/reducers/search.js
index f9bf92098..dc6be97e2 100644
--- a/app/javascript/flavours/glitch/reducers/search.js
+++ b/app/javascript/flavours/glitch/reducers/search.js
@@ -4,7 +4,11 @@ import {
   SEARCH_FETCH_SUCCESS,
   SEARCH_SHOW,
 } from 'flavours/glitch/actions/search';
-import { COMPOSE_MENTION, COMPOSE_REPLY } from 'flavours/glitch/actions/compose';
+import {
+  COMPOSE_MENTION,
+  COMPOSE_REPLY,
+  COMPOSE_DIRECT,
+} from 'flavours/glitch/actions/compose';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
 const initialState = ImmutableMap({
@@ -29,6 +33,7 @@ export default function search(state = initialState, action) {
     return state.set('hidden', false);
   case COMPOSE_REPLY:
   case COMPOSE_MENTION:
+  case COMPOSE_DIRECT:
     return state.set('hidden', true);
   case SEARCH_FETCH_SUCCESS:
     return state.set('results', ImmutableMap({
diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js
index c4ae2bc97..19e400b19 100644
--- a/app/javascript/flavours/glitch/reducers/timelines.js
+++ b/app/javascript/flavours/glitch/reducers/timelines.js
@@ -1,14 +1,10 @@
 import {
-  TIMELINE_REFRESH_REQUEST,
-  TIMELINE_REFRESH_SUCCESS,
-  TIMELINE_REFRESH_FAIL,
   TIMELINE_UPDATE,
   TIMELINE_DELETE,
   TIMELINE_EXPAND_SUCCESS,
   TIMELINE_EXPAND_REQUEST,
   TIMELINE_EXPAND_FAIL,
   TIMELINE_SCROLL_TOP,
-  TIMELINE_CONNECT,
   TIMELINE_DISCONNECT,
 } from 'flavours/glitch/actions/timelines';
 import {
@@ -17,42 +13,39 @@ import {
   ACCOUNT_UNFOLLOW_SUCCESS,
 } from 'flavours/glitch/actions/accounts';
 import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+import compareId from 'flavours/glitch/util/compare_id';
 
 const initialState = ImmutableMap();
 
 const initialTimeline = ImmutableMap({
   unread: 0,
-  online: false,
   top: true,
-  loaded: false,
   isLoading: false,
-  next: false,
+  hasMore: true,
   items: ImmutableList(),
 });
 
-const normalizeTimeline = (state, timeline, statuses, next, isPartial) => {
-  const oldIds    = state.getIn([timeline, 'items'], ImmutableList());
-  const ids       = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
-  const wasLoaded = state.getIn([timeline, 'loaded']);
-  const hadNext   = state.getIn([timeline, 'next']);
-
-  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
-    mMap.set('loaded', true);
-    mMap.set('isLoading', false);
-    if (!hadNext) mMap.set('next', next);
-    mMap.set('items', wasLoaded ? ids.concat(oldIds) : oldIds.concat(ids));
-    mMap.set('isPartial', isPartial);
-  }));
-};
-
-const appendNormalizedTimeline = (state, timeline, statuses, next) => {
-  const oldIds = state.getIn([timeline, 'items'], ImmutableList());
-  const ids    = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
-
+const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial) => {
   return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
     mMap.set('isLoading', false);
-    mMap.set('next', next);
-    mMap.set('items', oldIds.concat(ids));
+    if (!next) mMap.set('hasMore', false);
+
+    if (!statuses.isEmpty()) {
+      mMap.update('items', ImmutableList(), oldIds => {
+        const newIds = statuses.map(status => status.get('id'));
+        const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1;
+        const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0);
+
+        if (firstIndex < 0) {
+          return (isPartial ? newIds.unshift(null) : newIds).concat(oldIds.skip(lastIndex));
+        }
+
+        return oldIds.take(firstIndex + 1).concat(
+          isPartial && oldIds.get(firstIndex) !== null ? newIds.unshift(null) : newIds,
+          oldIds.skip(lastIndex)
+        );
+      });
+    }
   }));
 };
 
@@ -119,16 +112,12 @@ const updateTop = (state, timeline, top) => {
 
 export default function timelines(state = initialState, action) {
   switch(action.type) {
-  case TIMELINE_REFRESH_REQUEST:
   case TIMELINE_EXPAND_REQUEST:
     return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true));
-  case TIMELINE_REFRESH_FAIL:
   case TIMELINE_EXPAND_FAIL:
     return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false));
-  case TIMELINE_REFRESH_SUCCESS:
-    return normalizeTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial);
   case TIMELINE_EXPAND_SUCCESS:
-    return appendNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next);
+    return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial);
   case TIMELINE_UPDATE:
     return updateTimeline(state, action.timeline, fromJS(action.status), action.references);
   case TIMELINE_DELETE:
@@ -140,10 +129,15 @@ export default function timelines(state = initialState, action) {
     return filterTimeline('home', state, action.relationship, action.statuses);
   case TIMELINE_SCROLL_TOP:
     return updateTop(state, action.timeline, action.top);
-  case TIMELINE_CONNECT:
-    return state.update(action.timeline, initialTimeline, map => map.set('online', true));
   case TIMELINE_DISCONNECT:
-    return state.update(action.timeline, initialTimeline, map => map.set('online', false));
+    return state.update(
+      action.timeline,
+      initialTimeline,
+      map => map.update(
+        'items',
+        items => items.first() ? items.unshift(null) : items
+      )
+    );
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/service_worker/entry.js b/app/javascript/flavours/glitch/service_worker/entry.js
deleted file mode 100644
index eea4cfc3c..000000000
--- a/app/javascript/flavours/glitch/service_worker/entry.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import './web_push_notifications';
-
-// Cause a new version of a registered Service Worker to replace an existing one
-// that is already installed, and replace the currently active worker on open pages.
-self.addEventListener('install', function(event) {
-  event.waitUntil(self.skipWaiting());
-});
-self.addEventListener('activate', function(event) {
-  event.waitUntil(self.clients.claim());
-});
diff --git a/app/javascript/flavours/glitch/service_worker/web_push_notifications.js b/app/javascript/flavours/glitch/service_worker/web_push_notifications.js
deleted file mode 100644
index f63cff335..000000000
--- a/app/javascript/flavours/glitch/service_worker/web_push_notifications.js
+++ /dev/null
@@ -1,159 +0,0 @@
-const MAX_NOTIFICATIONS = 5;
-const GROUP_TAG = 'tag';
-
-// Avoid loading intl-messageformat and dealing with locales in the ServiceWorker
-const formatGroupTitle = (message, count) => message.replace('%{count}', count);
-
-const notify = options =>
-  self.registration.getNotifications().then(notifications => {
-    if (notifications.length === MAX_NOTIFICATIONS) {
-      // Reached the maximum number of notifications, proceed with grouping
-      const group = {
-        title: formatGroupTitle(options.data.message, notifications.length + 1),
-        body: notifications
-          .sort((n1, n2) => n1.timestamp < n2.timestamp)
-          .map(notification => notification.title).join('\n'),
-        badge: '/badge.png',
-        icon: '/android-chrome-192x192.png',
-        tag: GROUP_TAG,
-        data: {
-          url: (new URL('/web/notifications', self.location)).href,
-          count: notifications.length + 1,
-          message: options.data.message,
-        },
-      };
-
-      notifications.forEach(notification => notification.close());
-
-      return self.registration.showNotification(group.title, group);
-    } else if (notifications.length === 1 && notifications[0].tag === GROUP_TAG) {
-      // Already grouped, proceed with appending the notification to the group
-      const group = cloneNotification(notifications[0]);
-
-      group.title = formatGroupTitle(group.data.message, group.data.count + 1);
-      group.body  = `${options.title}\n${group.body}`;
-      group.data  = { ...group.data, count: group.data.count + 1 };
-
-      return self.registration.showNotification(group.title, group);
-    }
-
-    return self.registration.showNotification(options.title, options);
-  });
-
-const handlePush = (event) => {
-  const options = event.data.json();
-
-  options.body      = options.data.nsfw || options.data.content;
-  options.dir       = options.data.dir;
-  options.image     = options.image || undefined; // Null results in a network request (404)
-  options.timestamp = options.timestamp && new Date(options.timestamp);
-
-  const expandAction = options.data.actions.find(action => action.todo === 'expand');
-
-  if (expandAction) {
-    options.actions          = [expandAction];
-    options.hiddenActions    = options.data.actions.filter(action => action !== expandAction);
-    options.data.hiddenImage = options.image;
-    options.image            = undefined;
-  } else {
-    options.actions = options.data.actions;
-  }
-
-  event.waitUntil(notify(options));
-};
-
-const cloneNotification = (notification) => {
-  const clone = {  };
-
-  for(var k in notification) {
-    clone[k] = notification[k];
-  }
-
-  return clone;
-};
-
-const expandNotification = (notification) => {
-  const nextNotification = cloneNotification(notification);
-
-  nextNotification.body    = notification.data.content;
-  nextNotification.image   = notification.data.hiddenImage;
-  nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand');
-
-  return self.registration.showNotification(nextNotification.title, nextNotification);
-};
-
-const makeRequest = (notification, action) =>
-  fetch(action.action, {
-    headers: {
-      'Authorization': `Bearer ${notification.data.access_token}`,
-      'Content-Type': 'application/json',
-    },
-    method: action.method,
-    credentials: 'include',
-  });
-
-const findBestClient = clients => {
-  const focusedClient = clients.find(client => client.focused);
-  const visibleClient = clients.find(client => client.visibilityState === 'visible');
-
-  return focusedClient || visibleClient || clients[0];
-};
-
-const openUrl = url =>
-  self.clients.matchAll({ type: 'window' }).then(clientList => {
-    if (clientList.length !== 0) {
-      const webClients = clientList.filter(client => /\/web\//.test(client.url));
-
-      if (webClients.length !== 0) {
-        const client       = findBestClient(webClients);
-        const { pathname } = new URL(url);
-
-        if (pathname.startsWith('/web/')) {
-          return client.focus().then(client => client.postMessage({
-            type: 'navigate',
-            path: pathname.slice('/web/'.length - 1),
-          }));
-        }
-      } else if ('navigate' in clientList[0]) { // Chrome 42-48 does not support navigate
-        const client = findBestClient(clientList);
-
-        return client.navigate(url).then(client => client.focus());
-      }
-    }
-
-    return self.clients.openWindow(url);
-  });
-
-const removeActionFromNotification = (notification, action) => {
-  const actions          = notification.actions.filter(act => act.action !== action.action);
-  const nextNotification = cloneNotification(notification);
-
-  nextNotification.actions = actions;
-
-  return self.registration.showNotification(nextNotification.title, nextNotification);
-};
-
-const handleNotificationClick = (event) => {
-  const reactToNotificationClick = new Promise((resolve, reject) => {
-    if (event.action) {
-      const action = event.notification.data.actions.find(({ action }) => action === event.action);
-
-      if (action.todo === 'expand') {
-        resolve(expandNotification(event.notification));
-      } else if (action.todo === 'request') {
-        resolve(makeRequest(event.notification, action)
-          .then(() => removeActionFromNotification(event.notification, action)));
-      } else {
-        reject(`Unknown action: ${action.todo}`);
-      }
-    } else {
-      event.notification.close();
-      resolve(openUrl(event.notification.data.url));
-    }
-  });
-
-  event.waitUntil(reactToNotificationClick);
-};
-
-self.addEventListener('push', handlePush);
-self.addEventListener('notificationclick', handleNotificationClick);
diff --git a/app/javascript/flavours/glitch/styles/about.scss b/app/javascript/flavours/glitch/styles/about.scss
index 55f31266f..c9c0e3081 100644
--- a/app/javascript/flavours/glitch/styles/about.scss
+++ b/app/javascript/flavours/glitch/styles/about.scss
@@ -322,6 +322,11 @@ $small-breakpoint: 960px;
     border: 0;
     border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
     margin: 20px 0;
+
+    &.spacer {
+      height: 1px;
+      border: 0;
+    }
   }
 
   .container-alt {
@@ -681,6 +686,54 @@ $small-breakpoint: 960px;
       margin-bottom: 0;
     }
 
+    .account {
+      border-bottom: 0;
+      padding: 0;
+
+      &__display-name {
+        align-items: center;
+        display: flex;
+        margin-right: 5px;
+      }
+
+      div.account__display-name {
+        &:hover {
+          .display-name strong {
+            text-decoration: none;
+          }
+        }
+
+        .account__avatar {
+          cursor: default;
+        }
+      }
+
+      &__avatar-wrapper {
+        margin-left: 0;
+        flex: 0 0 auto;
+      }
+
+      &__avatar {
+        width: 44px;
+        height: 44px;
+        background-size: 44px 44px;
+      }
+
+      .display-name {
+        font-size: 15px;
+
+        &__account {
+          font-size: 14px;
+        }
+      }
+    }
+
+    @media screen and (max-width: $small-breakpoint) {
+      .contact {
+        margin-top: 30px;
+      }
+    }
+
     @media screen and (max-width: $column-breakpoint) {
       padding: 25px 20px;
     }
@@ -816,6 +869,8 @@ $small-breakpoint: 960px;
       font-size: 16px;
       line-height: inherit;
       font-weight: inherit;
+      margin: 0;
+      padding: 0;
     }
 
     .column {
@@ -852,8 +907,13 @@ $small-breakpoint: 960px;
   }
 
   &__features {
+    & > p {
+      padding-right: 60px;
+    }
+
     .features-list {
-      margin: 40px 0 !important;
+      margin: 40px 0;
+      margin-top: 30px;
     }
 
     &__action {
@@ -862,17 +922,11 @@ $small-breakpoint: 960px;
   }
 
   .features-list {
-    margin-top: 20px;
-
     .features-list__row {
       display: flex;
       padding: 10px 0;
       justify-content: space-between;
 
-      &:first-child {
-        padding-top: 0;
-      }
-
       .visual {
         flex: 0 0 auto;
         display: flex;
@@ -898,6 +952,14 @@ $small-breakpoint: 960px;
         }
       }
     }
+
+    @media screen and (min-width: $small-breakpoint) {
+      display: grid;
+      grid-gap: 30px;
+      grid-template-columns: 1fr 1fr;
+      grid-auto-columns: 50%;
+      grid-auto-rows: max-content;
+    }
   }
 
   .extended-description {
diff --git a/app/javascript/flavours/glitch/styles/accounts.scss b/app/javascript/flavours/glitch/styles/accounts.scss
index eff964e50..efff59ff6 100644
--- a/app/javascript/flavours/glitch/styles/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/accounts.scss
@@ -326,6 +326,15 @@
   z-index: 2;
   position: relative;
 
+  &.empty img {
+    position: absolute;
+    opacity: 0.2;
+    height: 200px;
+    left: 0;
+    bottom: 0;
+    pointer-events: none;
+  }
+
   @media screen and (max-width: 740px) {
     border-radius: 0;
     box-shadow: none;
@@ -445,8 +454,8 @@
   font-size: 14px;
   font-weight: 500;
   text-align: center;
-  padding: 60px 0;
-  padding-top: 55px;
+  padding: 130px 0;
+  padding-top: 125px;
   margin: 0 auto;
   cursor: default;
 }
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
index b077df145..7fe5e4a19 100644
--- a/app/javascript/flavours/glitch/styles/admin.scss
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -141,14 +141,15 @@
     }
 
     hr {
-      margin: 20px 0;
+      width: 100%;
+      height: 0;
       border: 0;
-      background: transparent;
-      border-bottom: 1px solid $ui-base-color;
+      border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
+      margin: 20px 0;
 
-      &.section-break {
-        margin: 30px 0;
-        border-bottom: 2px solid $ui-base-lighter-color;
+      &.spacer {
+        height: 1px;
+        border: 0;
       }
     }
 
@@ -351,34 +352,9 @@
   }
 }
 
-.report-note__comment {
-  margin-bottom: 20px;
-}
-
-.report-note__form {
-  margin-bottom: 20px;
-
-  .report-note__textarea {
-    box-sizing: border-box;
-    border: 0;
-    padding: 7px 4px;
-    margin-bottom: 10px;
-    font-size: 16px;
-    color: $inverted-text-color;
-    display: block;
-    width: 100%;
-    outline: 0;
-    font-family: inherit;
-    resize: vertical;
-  }
-
-  .report-note__buttons {
-    text-align: right;
-  }
-
-  .report-note__button {
-    margin: 0 0 5px 5px;
-  }
+.simple_form.new_report_note,
+.simple_form.new_account_moderation_note {
+  max-width: 100%;
 }
 
 .batch-form-box {
@@ -406,13 +382,6 @@
   }
 }
 
-.batch-checkbox,
-.batch-checkbox-all {
-  display: flex;
-  align-items: center;
-  margin-right: 5px;
-}
-
 .back-link {
   margin-bottom: 10px;
   font-size: 14px;
@@ -432,7 +401,7 @@
 }
 
 .log-entry {
-  margin-bottom: 8px;
+  margin-bottom: 20px;
   line-height: 20px;
 
   &__header {
@@ -530,6 +499,31 @@
   }
 }
 
+a.name-tag,
+.name-tag,
+a.inline-name-tag,
+.inline-name-tag {
+  text-decoration: none;
+  color: $secondary-text-color;
+
+  .username {
+    font-weight: 500;
+  }
+
+  &.suspended {
+    .username {
+      text-decoration: line-through;
+      color: lighten($error-red, 12%);
+    }
+
+    .avatar {
+      filter: grayscale(100%);
+      opacity: 0.8;
+    }
+  }
+}
+
+a.name-tag,
 .name-tag {
   display: flex;
   align-items: center;
@@ -541,7 +535,46 @@
     border-radius: 50%;
   }
 
-  .username {
+  &.suspended {
+    .avatar {
+      filter: grayscale(100%);
+      opacity: 0.8;
+    }
+  }
+}
+
+.speech-bubble {
+  margin-bottom: 20px;
+  border-left: 4px solid $ui-highlight-color;
+
+  &.positive {
+    border-left-color: $success-green;
+  }
+
+  &.negative {
+    border-left-color: lighten($error-red, 12%);
+  }
+
+  &__bubble {
+    padding: 16px;
+    padding-left: 14px;
+    font-size: 15px;
+    line-height: 20px;
+    border-radius: 4px 4px 4px 0;
+    position: relative;
     font-weight: 500;
+
+    a {
+      color: $darker-text-color;
+    }
+  }
+
+  &__owner {
+    padding: 8px;
+    padding-left: 12px;
+  }
+
+  time {
+    color: $dark-text-color;
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
index 84d3f6ade..dadfa6d57 100644
--- a/app/javascript/flavours/glitch/styles/components/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -32,7 +32,8 @@
 
 .account__avatar-wrapper {
   float: left;
-  margin: 6px 16px 6px 6px;
+  margin-left: 12px;
+  margin-right: 12px;
 }
 
 .account__avatar {
@@ -509,3 +510,9 @@
     margin-bottom: 0;
   }
 }
+
+.account__header .roles {
+  margin-top: 20px;
+  margin-bottom: 20px;
+  padding: 0 15px;
+}
diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss
index df239dba7..0432b233a 100644
--- a/app/javascript/flavours/glitch/styles/components/drawer.scss
+++ b/app/javascript/flavours/glitch/styles/components/drawer.scss
@@ -266,6 +266,40 @@
 
   & > section {
     background: $ui-base-color;
+    margin-bottom: 20px;
+
+    h5 {
+      position: relative;
+
+      &::before {
+        content: "";
+        display: block;
+        position: absolute;
+        left: 0;
+        right: 0;
+        top: 50%;
+        width: 100%;
+        height: 0;
+        border-top: 1px solid lighten($ui-base-color, 8%);
+      }
+
+      span {
+        display: inline-block;
+        background: $ui-base-color;
+        color: $darker-text-color;
+        font-size: 14px;
+        font-weight: 500;
+        padding: 10px;
+        position: relative;
+        z-index: 1;
+        cursor: default;
+      }
+    }
+
+    .account:last-child,
+    & > div:last-child .status {
+      border-bottom: 0;
+    }
 
     & > .hashtag {
       display: block;
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 6f3338605..0fa940766 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -848,6 +848,10 @@
   }
 }
 
+.load-gap {
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+}
+
 .missing-indicator {
   padding-top: 20px + 48px;
 
@@ -894,7 +898,7 @@
     width: 30px;
     height: 30px;
     font-size: 20px;
-    color: $inverted-text-color;
+    color: $darker-text-color;
     text-shadow: 0 0 5px black;
     display: flex;
     justify-content: center;
diff --git a/app/javascript/flavours/glitch/styles/components/local_settings.scss b/app/javascript/flavours/glitch/styles/components/local_settings.scss
index 9e1606e99..9cd4e1fbe 100644
--- a/app/javascript/flavours/glitch/styles/components/local_settings.scss
+++ b/app/javascript/flavours/glitch/styles/components/local_settings.scss
@@ -35,8 +35,8 @@
   display: block;
   padding: 15px 20px;
   color: inherit;
-  background: $primary-text-color;
-  border-bottom: 1px $ui-primary-color solid;
+  background: lighten($ui-secondary-color, 8%);
+  border-bottom: 1px $ui-secondary-color solid;
   cursor: pointer;
   text-decoration: none;
   outline: none;
@@ -58,8 +58,7 @@
 }
 
 .glitch.local-settings__navigation {
-  background: $simple-background-color;
-  color: $inverted-text-color;
+  background: lighten($ui-secondary-color, 8%);
   width: 200px;
   font-size: 15px;
   line-height: 20px;
diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss
index 90674612d..5a49c07fa 100644
--- a/app/javascript/flavours/glitch/styles/components/media.scss
+++ b/app/javascript/flavours/glitch/styles/components/media.scss
@@ -279,6 +279,10 @@
   background: $base-shadow-color;
   max-width: 100%;
 
+  &:focus {
+    outline: 0;
+  }
+
   .detailed-status & {
     width: 100%;
     height: 100%;
diff --git a/app/javascript/flavours/glitch/styles/components/metadata.scss b/app/javascript/flavours/glitch/styles/components/metadata.scss
index fa1a4bc34..29a6330e9 100644
--- a/app/javascript/flavours/glitch/styles/components/metadata.scss
+++ b/app/javascript/flavours/glitch/styles/components/metadata.scss
@@ -2,7 +2,6 @@
   font-size: 15px;
   line-height: 20px;
   overflow: hidden;
-  border-collapse: collapse;
   margin: 20px -10px -20px;
   border-bottom: 0;
 
@@ -14,35 +13,36 @@
     }
   }
 
-  tr {
+  dl {
     border-top: 1px solid lighten($ui-base-color, 8%);
-    text-align: center;
+    display: flex;
   }
 
-  th, td {
+  dt,
+  dd {
+    box-sizing: border-box;
     padding: 14px 20px;
-    vertical-align: middle;
-
-    & > div {
-      max-height: 40px;
-      overflow-y: auto;
-      white-space: pre-wrap;
-      text-overflow: ellipsis;
-    }
+    text-align: center;
+    max-height: 48px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
   }
 
-  th {
+  dt {
     color: $darker-text-color;
     background: lighten($ui-base-color, 13%);
-    max-width: 120px;
+    width: 120px;
+    flex: 0 0 auto;
+    font-weight: 500;
 
     a {
       color: $primary-text-color;
     }
   }
 
-  td {
-    flex: auto;
+  dd {
+    flex: 1 1 auto;
     color: $primary-text-color;
     background: $ui-base-color;
 
diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss
index 9d5ab66a4..ac648c868 100644
--- a/app/javascript/flavours/glitch/styles/containers.scss
+++ b/app/javascript/flavours/glitch/styles/containers.scss
@@ -60,7 +60,7 @@
   }
 }
 
-.media-gallery-standalone__body {
+.media-standalone__body {
   overflow: hidden;
 }
 
diff --git a/app/javascript/flavours/glitch/styles/footer.scss b/app/javascript/flavours/glitch/styles/footer.scss
index ba2a06954..fe2d40c0c 100644
--- a/app/javascript/flavours/glitch/styles/footer.scss
+++ b/app/javascript/flavours/glitch/styles/footer.scss
@@ -4,7 +4,7 @@
   font-size: 12px;
   color: $darker-text-color;
 
-  .domain {
+  .footer__domain {
     font-weight: 500;
 
     a {
@@ -26,5 +26,13 @@
         text-decoration: none;
       }
     }
+
+    img {
+      margin: 0 4px;
+      position: relative;
+      bottom: -1px;
+      height: 18px;
+      vertical-align: top;
+    }
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index 0b12742a9..f97890187 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -280,6 +280,11 @@ code {
   .actions {
     margin-top: 30px;
     display: flex;
+
+    &.actions--top {
+      margin-top: 0;
+      margin-bottom: 30px;
+    }
   }
 
   button,
@@ -563,9 +568,27 @@ code {
 
 .post-follow-actions {
   text-align: center;
-  color: $ui-primary-color;
+  color: $darker-text-color;
 
   div {
     margin-bottom: 4px;
   }
 }
+
+.alternative-login {
+  margin-top: 20px;
+  margin-bottom: 20px;
+
+  h4 {
+    font-size: 16px;
+    color: $primary-text-color;
+    text-align: center;
+    margin-bottom: 20px;
+    border: 0;
+    padding: 0;
+  }
+
+  .button {
+    display: block;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light.scss b/app/javascript/flavours/glitch/styles/mastodon-light.scss
new file mode 100644
index 000000000..029d5dde2
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/mastodon-light.scss
@@ -0,0 +1,218 @@
+// Set variables
+$ui-base-color: #d9e1e8;
+$ui-base-lighter-color: darken($ui-base-color, 57%);
+$ui-highlight-color: #2b90d9;
+$ui-primary-color: darken($ui-highlight-color, 28%);
+$ui-secondary-color: #282c37;
+
+$primary-text-color: black;
+$base-overlay-background: $ui-base-color;
+
+$login-button-color: white;
+$account-background-color: white;
+
+// Import defaults
+@import 'index';
+
+// Change the color of the log in button
+.button {
+  &.button-alternative-2 {
+    color: $login-button-color;
+  }
+}
+
+// Change columns' default background colors
+.column {
+  > .scrollable {
+    background: lighten($ui-base-color, 13%);
+  }
+}
+
+.status.collapsed .status__content:after {
+  background: linear-gradient(rgba(lighten($ui-base-color, 13%), 0), rgba(lighten($ui-base-color, 13%), 1));
+}
+
+.drawer__inner {
+  background: $ui-base-color;
+}
+
+.drawer > .contents {
+  background: $ui-base-color url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color(lighten($ui-base-color, 13%))}"/></svg>') no-repeat bottom / 100% auto !important;
+
+  .mastodon {
+    filter: contrast(75%) brightness(75%) !important;
+  }
+}
+
+// Change the default appearance of the content warning button
+.status__content {
+
+  .status__content__spoiler-link {
+
+    background: darken($ui-base-color, 30%);
+
+    &:hover {
+      background: darken($ui-base-color, 35%);
+      text-decoration: none;
+    }
+
+  }
+
+}
+
+// Change the default appearance of the action buttons
+.icon-button {
+
+  &:hover,
+  &:active,
+  &:focus {
+    color: darken($ui-base-color, 40%);
+    transition: color 200ms ease-out;
+  }
+
+  &.disabled {
+    color: darken($ui-base-color, 30%);
+  }
+
+}
+
+.status {
+  &.status-direct {
+    .icon-button.disabled {
+      color: darken($ui-base-color, 30%);
+    }
+  }
+}
+
+// Change the colors used in the dropdown menu
+.dropdown-menu {
+  background: $ui-base-color;
+}
+
+.dropdown-menu__arrow {
+
+  &.left {
+    border-left-color: $ui-base-color;
+  }
+
+  &.top {
+    border-top-color: $ui-base-color;
+  }
+
+  &.bottom {
+    border-bottom-color: $ui-base-color;
+  }
+
+  &.right {
+    border-right-color: $ui-base-color;
+  }
+
+}
+
+.dropdown-menu__item {
+  a {
+    background: $ui-base-color;
+    color: $ui-secondary-color;
+  }
+}
+
+// Change the default color of several parts of the compose form
+.composer {
+
+  .composer--spoiler input, .composer--textarea textarea {
+    color: darken($ui-base-color, 80%);
+
+    &:disabled { background: darken($simple-background-color, 10%) }
+
+    &::placeholder {
+      color: darken($ui-base-color, 70%);
+    }
+  }
+
+  strong {
+    color: lighten($ui-secondary-color, 65%);
+  }
+
+  .composer--options {
+    background: darken($ui-base-color, 10%);
+    box-shadow: unset;
+  }
+
+  .composer--options--dropdown--content--item {
+    color: $ui-primary-color;
+    
+    strong {
+      color: $ui-primary-color;
+    }
+
+  }
+
+}
+
+// Change the default color used for the text in an empty column or on the error column
+.empty-column-indicator,
+.error-column {
+  color: darken($ui-base-color, 60%);
+}
+
+// Change the default colors used on some parts of the profile pages
+.activity-stream-tabs {
+
+  background: $account-background-color;
+
+  a {
+    &.active {
+      color: $ui-primary-color;
+      }
+  }
+
+}
+
+.activity-stream {
+
+  .entry {
+    background: $account-background-color;
+  }
+
+  .status.light {
+
+    .status__content {
+      color: $primary-text-color;
+    }
+
+    .display-name {
+      strong {
+        color: $primary-text-color;
+      }
+    }
+
+  }
+
+}
+
+.accounts-grid {
+  .account-grid-card {
+
+    .controls {
+      .icon-button {
+        color: $ui-secondary-color;
+      }
+    }
+
+    .name {
+      a {
+        color: $primary-text-color;
+      }
+    }
+
+    .username {
+      color: $ui-secondary-color;
+    }
+
+    .account__header__content {
+      color: $primary-text-color;
+    }
+
+  }
+}
+
diff --git a/app/javascript/flavours/glitch/styles/metadata.scss b/app/javascript/flavours/glitch/styles/metadata.scss
index b66cce3c1..280848959 100644
--- a/app/javascript/flavours/glitch/styles/metadata.scss
+++ b/app/javascript/flavours/glitch/styles/metadata.scss
@@ -1,43 +1,56 @@
 .account__header__fields {
   $meta-table-border: lighten($ui-base-color, 8%);
-
-  border-collapse: collapse;
   padding: 0;
   margin: 15px -15px -15px -15px;
   border: 0 none;
   border-top: 1px solid $meta-table-border;
   border-bottom: 1px solid $meta-table-border;
+  font-size: 14px;
+  line-height: 20px;
 
-  td, th {
-    padding: 15px;
-    border: 0 none;
+  dl {
+    display: flex;
     border-bottom: 1px solid $meta-table-border;
-    vertical-align: middle;
   }
 
-  tr:last-child {
-    td, th {
-      border-bottom: 0 none;
-    }
-  }
-
-  td {
-    color: $ui-primary-color;
+  dt,
+  dd {
+    box-sizing: border-box;
+    padding: 14px;
     text-align: center;
-    width:100%;
-    padding-left: 0;
+    max-height: 48px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
   }
 
-  th {
+  dt {
     padding-left: 15px;
-    font-weight: bold;
+    font-weight: 500;
     text-align: center;
-    width: 94px;
-    color: $ui-secondary-color;
+    width: 120px;
+    flex: 0 0 auto;
+    color: $secondary-text-color;
     background: darken($ui-base-color, 8%);
   }
 
+  dd {
+    flex: 1 1 auto;
+    color: $darker-text-color;
+  }
+
   a {
-    color: $classic-highlight-color;
+    color: $highlight-text-color;
+    text-decoration: none;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
+
+  dl:last-child {
+    border-bottom: 0;
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/rtl.scss b/app/javascript/flavours/glitch/styles/rtl.scss
index 77420c84b..e9099a9e9 100644
--- a/app/javascript/flavours/glitch/styles/rtl.scss
+++ b/app/javascript/flavours/glitch/styles/rtl.scss
@@ -1,6 +1,22 @@
 body.rtl {
   direction: rtl;
 
+  .column-header > button {
+    text-align: right;
+    padding-left: 0;
+    padding-right: 15px;
+  }
+
+  .landing-page__logo {
+    margin-right: 0;
+    margin-left: 20px;
+  }
+
+  .landing-page .features-list .features-list__row .visual {
+    margin-left: 0;
+    margin-right: 15px;
+  }
+
   .column-link__icon,
   .column-header__icon {
     margin-right: 0;
diff --git a/app/javascript/flavours/glitch/styles/stream_entries.scss b/app/javascript/flavours/glitch/styles/stream_entries.scss
index b505c1580..40963ae84 100644
--- a/app/javascript/flavours/glitch/styles/stream_entries.scss
+++ b/app/javascript/flavours/glitch/styles/stream_entries.scss
@@ -6,7 +6,8 @@
     background: $simple-background-color;
 
     .detailed-status.light,
-    .status.light {
+    .status.light,
+    .more.light {
       border-bottom: 1px solid $ui-secondary-color;
       animation: none;
     }
@@ -65,6 +66,10 @@
     }
   }
 
+  .media-gallery__gifv__label {
+    bottom: 9px;
+  }
+
   .status.light {
     padding: 14px 14px 14px (48px + 14px * 2);
     position: relative;
@@ -145,10 +150,10 @@
 
       a.status__content__spoiler-link {
         color: $primary-text-color;
-        background: $ui-primary-color;
+        background: $ui-base-color;
 
         &:hover {
-          background: lighten($ui-primary-color, 8%);
+          background: lighten($ui-base-color, 8%);
         }
       }
     }
@@ -215,10 +220,10 @@
 
       a.status__content__spoiler-link {
         color: $primary-text-color;
-        background: $ui-primary-color;
+        background: $ui-base-color;
 
         &:hover {
-          background: lighten($ui-primary-color, 8%);
+          background: lighten($ui-base-color, 8%);
         }
       }
     }
@@ -261,16 +266,8 @@
   }
 
   .media-spoiler {
-    background: $ui-primary-color;
-    color: $inverted-text-color;
-    transition: all 100ms linear;
-
-    &:hover,
-    &:active,
-    &:focus {
-      background: darken($ui-primary-color, 5%);
-      color: unset;
-    }
+    background: $ui-base-color;
+    color: $darker-text-color;
   }
 
   .pre-header {
@@ -299,6 +296,17 @@
       text-decoration: underline;
     }
   }
+
+  .more {
+    color: $darker-text-color;
+    display: block;
+    padding: 14px;
+    text-align: center;
+
+    &:not(:hover) {
+      text-decoration: none;
+    }
+  }
 }
 
 .embed {
diff --git a/app/javascript/flavours/glitch/styles/tables.scss b/app/javascript/flavours/glitch/styles/tables.scss
index c12d84f1c..982bfd990 100644
--- a/app/javascript/flavours/glitch/styles/tables.scss
+++ b/app/javascript/flavours/glitch/styles/tables.scss
@@ -1,3 +1,9 @@
+@keyframes Swag {
+  0% { background-position: 0% 0%; }
+  50% { background-position: 100% 0%; }
+  100% { background-position: 200% 0%; }
+}
+
 .table {
   width: 100%;
   max-width: 100%;
@@ -11,6 +17,7 @@
     vertical-align: top;
     border-top: 1px solid $ui-base-color;
     text-align: left;
+    background: darken($ui-base-color, 4%);
   }
 
   & > thead > tr > th {
@@ -48,9 +55,38 @@
     }
   }
 
-  &.inline-table > tbody > tr:nth-child(odd) > td,
-  &.inline-table > tbody > tr:nth-child(odd) > th {
-    background: transparent;
+  &.inline-table {
+    & > tbody > tr:nth-child(odd) {
+      & > td,
+      & > th {
+        background: transparent;
+      }
+    }
+
+    & > tbody > tr:first-child {
+      & > td,
+      & > th {
+        border-top: 0;
+      }
+    }
+  }
+
+  &.batch-table {
+    & > thead > tr > th {
+      background: $ui-base-color;
+      border-top: 1px solid darken($ui-base-color, 8%);
+      border-bottom: 1px solid darken($ui-base-color, 8%);
+
+      &:first-child {
+        border-radius: 4px 0 0;
+        border-left: 1px solid darken($ui-base-color, 8%);
+      }
+
+      &:last-child {
+        border-radius: 0 4px 0 0;
+        border-right: 1px solid darken($ui-base-color, 8%);
+      }
+    }
   }
 }
 
@@ -63,6 +99,13 @@ samp {
   font-family: 'mastodon-font-monospace', monospace;
 }
 
+button.table-action-link {
+  background: transparent;
+  border: 0;
+  font: inherit;
+}
+
+button.table-action-link,
 a.table-action-link {
   text-decoration: none;
   display: inline-block;
@@ -79,4 +122,82 @@ a.table-action-link {
     font-weight: 400;
     margin-right: 5px;
   }
+
+  &:first-child {
+    padding-left: 0;
+  }
+}
+
+.batch-table {
+  &__toolbar,
+  &__row {
+    display: flex;
+
+    &__select {
+      box-sizing: border-box;
+      padding: 8px 16px;
+      cursor: pointer;
+      min-height: 100%;
+
+      input {
+        margin-top: 8px;
+      }
+    }
+
+    &__actions,
+    &__content {
+      padding: 8px 0;
+      padding-right: 16px;
+      flex: 1 1 auto;
+    }
+  }
+
+  &__toolbar {
+    border: 1px solid darken($ui-base-color, 8%);
+    background: $ui-base-color;
+    border-radius: 4px 0 0;
+    height: 47px;
+    align-items: center;
+
+    &__actions {
+      text-align: right;
+      padding-right: 16px - 5px;
+    }
+  }
+
+  &__row {
+    border: 1px solid darken($ui-base-color, 8%);
+    border-top: 0;
+    background: darken($ui-base-color, 4%);
+
+    &:hover {
+      background: darken($ui-base-color, 2%);
+    }
+
+    &:nth-child(even) {
+      background: $ui-base-color;
+
+      &:hover {
+        background: lighten($ui-base-color, 2%);
+      }
+    }
+
+    &__content {
+      padding-top: 12px;
+      padding-bottom: 16px;
+    }
+  }
+
+  .status__content {
+    padding-top: 0;
+
+    strong {
+      font-weight: 700;
+      background: linear-gradient(to right, orange , yellow, green, cyan, blue, violet,orange , yellow, green, cyan, blue, violet);
+      background-size: 200% 100%;
+      background-clip: text;
+      color: transparent;
+      animation: Swag 2s linear 0s infinite;
+    }
+  }
 }
diff --git a/app/javascript/flavours/glitch/util/compare_id.js b/app/javascript/flavours/glitch/util/compare_id.js
new file mode 100644
index 000000000..aaff66481
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/compare_id.js
@@ -0,0 +1,10 @@
+export default function compareId(id1, id2) {
+  if (id1 === id2) {
+    return 0;
+  }
+  if (id1.length === id2.length) {
+    return id1 > id2 ? 1 : -1;
+  } else {
+    return id1.length > id2.length ? 1 : -1;
+  }
+}
diff --git a/app/javascript/flavours/glitch/util/html.js b/app/javascript/flavours/glitch/util/html.js
new file mode 100644
index 000000000..0b646ce58
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/html.js
@@ -0,0 +1,6 @@
+export const unescapeHTML = (html) => {
+  const wrapper = document.createElement('div');
+  html = html.replace(/<br \/>|<br>|\n/g, ' ');
+  wrapper.innerHTML = html;
+  return wrapper.textContent;
+};
diff --git a/app/javascript/flavours/glitch/util/resize_image.js b/app/javascript/flavours/glitch/util/resize_image.js
new file mode 100644
index 000000000..279a858ca
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/resize_image.js
@@ -0,0 +1,116 @@
+import EXIF from 'exif-js';
+
+const MAX_IMAGE_DIMENSION = 1280;
+
+const getImageUrl = inputFile => new Promise((resolve, reject) => {
+  if (window.URL && URL.createObjectURL) {
+    try {
+      resolve(URL.createObjectURL(inputFile));
+    } catch (error) {
+      reject(error);
+    }
+    return;
+  }
+
+  const reader = new FileReader();
+  reader.onerror = (...args) => reject(...args);
+  reader.onload  = ({ target }) => resolve(target.result);
+
+  reader.readAsDataURL(inputFile);
+});
+
+const loadImage = inputFile => new Promise((resolve, reject) => {
+  getImageUrl(inputFile).then(url => {
+    const img = new Image();
+
+    img.onerror = (...args) => reject(...args);
+    img.onload  = () => resolve(img);
+
+    img.src = url;
+  }).catch(reject);
+});
+
+const getOrientation = (img, type = 'image/png') => new Promise(resolve => {
+  if (type !== 'image/jpeg') {
+    resolve(1);
+    return;
+  }
+
+  EXIF.getData(img, () => {
+    const orientation = EXIF.getTag(img, 'Orientation');
+    resolve(orientation);
+  });
+});
+
+const processImage = (img, { width, height, orientation, type = 'image/png' }) => new Promise(resolve => {
+  const canvas  = document.createElement('canvas');
+
+  if (4 < orientation && orientation < 9) {
+    canvas.width  = height;
+    canvas.height = width;
+  } else {
+    canvas.width  = width;
+    canvas.height = height;
+  }
+
+  const context = canvas.getContext('2d');
+
+  switch (orientation) {
+  case 2: context.transform(-1, 0, 0, 1, width, 0); break;
+  case 3: context.transform(-1, 0, 0, -1, width, height); break;
+  case 4: context.transform(1, 0, 0, -1, 0, height); break;
+  case 5: context.transform(0, 1, 1, 0, 0, 0); break;
+  case 6: context.transform(0, 1, -1, 0, height, 0); break;
+  case 7: context.transform(0, -1, -1, 0, height, width); break;
+  case 8: context.transform(0, -1, 1, 0, 0, width); break;
+  }
+
+  context.drawImage(img, 0, 0, width, height);
+
+  canvas.toBlob(resolve, type);
+});
+
+const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) => {
+  const { width, height } = img;
+
+  let newWidth, newHeight;
+
+  if (width > height) {
+    newHeight = height * MAX_IMAGE_DIMENSION / width;
+    newWidth  = MAX_IMAGE_DIMENSION;
+  } else if (height > width) {
+    newWidth  = width * MAX_IMAGE_DIMENSION / height;
+    newHeight = MAX_IMAGE_DIMENSION;
+  } else {
+    newWidth  = MAX_IMAGE_DIMENSION;
+    newHeight = MAX_IMAGE_DIMENSION;
+  }
+
+  getOrientation(img, type)
+    .then(orientation => processImage(img, {
+      width: newWidth,
+      height: newHeight,
+      orientation,
+      type,
+    }))
+    .then(resolve)
+    .catch(reject);
+});
+
+export default inputFile => new Promise((resolve, reject) => {
+  if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') {
+    resolve(inputFile);
+    return;
+  }
+
+  loadImage(inputFile).then(img => {
+    if (img.width < MAX_IMAGE_DIMENSION && img.height < MAX_IMAGE_DIMENSION) {
+      resolve(inputFile);
+      return;
+    }
+
+    resizeImage(img, inputFile.type)
+      .then(resolve)
+      .catch(() => resolve(inputFile));
+  }).catch(reject);
+});
diff --git a/app/javascript/flavours/glitch/util/stream.js b/app/javascript/flavours/glitch/util/stream.js
index 36c68ffc5..9928d0dd7 100644
--- a/app/javascript/flavours/glitch/util/stream.js
+++ b/app/javascript/flavours/glitch/util/stream.js
@@ -1,21 +1,24 @@
 import WebSocketClient from 'websocket.js';
 
-export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) {
+const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max));
+
+export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onDisconnect() {}, onReceive() {} })) {
   return (dispatch, getState) => {
     const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
     const accessToken = getState().getIn(['meta', 'access_token']);
-    const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);
+    const { onDisconnect, onReceive } = callbacks(dispatch, getState);
+
     let polling = null;
 
     const setupPolling = () => {
-      polling = setInterval(() => {
-        pollingRefresh(dispatch);
-      }, 20000);
+      pollingRefresh(dispatch, () => {
+        polling = setTimeout(() => setupPolling(), 20000 + randomIntUpTo(20000));
+      });
     };
 
     const clearPolling = () => {
       if (polling) {
-        clearInterval(polling);
+        clearTimeout(polling);
         polling = null;
       }
     };
@@ -25,13 +28,13 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
         if (pollingRefresh) {
           clearPolling();
         }
-        onConnect();
       },
 
       disconnected () {
         if (pollingRefresh) {
-          setupPolling();
+          polling = setTimeout(() => setupPolling(), randomIntUpTo(40000));
         }
+
         onDisconnect();
       },
 
@@ -44,7 +47,6 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
           clearPolling();
           pollingRefresh(dispatch);
         }
-        onConnect();
       },
 
     });
@@ -53,6 +55,7 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
       if (subscription) {
         subscription.close();
       }
+
       clearPolling();
     };
 
@@ -62,7 +65,13 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
 
 
 export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
-  const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?access_token=${accessToken}&stream=${stream}`);
+  const params = [ `stream=${stream}` ];
+
+  if (accessToken !== null) {
+    params.push(`access_token=${accessToken}`);
+  }
+
+  const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`);
 
   ws.onopen      = connected;
   ws.onmessage   = e => received(JSON.parse(e.data));