about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/helpers/jsonld_helper.rb6
-rw-r--r--app/javascript/mastodon/actions/accounts.js42
-rw-r--r--app/javascript/mastodon/actions/blocks.js3
-rw-r--r--app/javascript/mastodon/actions/compose.js22
-rw-r--r--app/javascript/mastodon/actions/favourites.js3
-rw-r--r--app/javascript/mastodon/actions/importer/index.js76
-rw-r--r--app/javascript/mastodon/actions/importer/normalizer.js46
-rw-r--r--app/javascript/mastodon/actions/interactions.js39
-rw-r--r--app/javascript/mastodon/actions/lists.js14
-rw-r--r--app/javascript/mastodon/actions/mutes.js3
-rw-r--r--app/javascript/mastodon/actions/notifications.js87
-rw-r--r--app/javascript/mastodon/actions/pin_statuses.js2
-rw-r--r--app/javascript/mastodon/actions/search.js11
-rw-r--r--app/javascript/mastodon/actions/statuses.js65
-rw-r--r--app/javascript/mastodon/actions/store.js2
-rw-r--r--app/javascript/mastodon/actions/streaming.js13
-rw-r--r--app/javascript/mastodon/actions/timelines.js114
-rw-r--r--app/javascript/mastodon/components/load_more.js5
-rw-r--r--app/javascript/mastodon/components/media_gallery.js6
-rw-r--r--app/javascript/mastodon/components/modal_root.js84
-rw-r--r--app/javascript/mastodon/components/scrollable_list.js6
-rw-r--r--app/javascript/mastodon/components/status_list.js37
-rw-r--r--app/javascript/mastodon/containers/media_galleries_container.js68
-rw-r--r--app/javascript/mastodon/containers/media_gallery_container.js34
-rw-r--r--app/javascript/mastodon/db/async.js28
-rw-r--r--app/javascript/mastodon/db/modifier.js93
-rw-r--r--app/javascript/mastodon/features/account_gallery/index.js51
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js2
-rw-r--r--app/javascript/mastodon/features/account_timeline/index.js18
-rw-r--r--app/javascript/mastodon/features/community_timeline/index.js13
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/index.js15
-rw-r--r--app/javascript/mastodon/features/home_timeline/index.js12
-rw-r--r--app/javascript/mastodon/features/list_timeline/index.js10
-rw-r--r--app/javascript/mastodon/features/notifications/index.js49
-rw-r--r--app/javascript/mastodon/features/public_timeline/index.js13
-rw-r--r--app/javascript/mastodon/features/standalone/community_timeline/index.js13
-rw-r--r--app/javascript/mastodon/features/standalone/hashtag_timeline/index.js13
-rw-r--r--app/javascript/mastodon/features/standalone/public_timeline/index.js13
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js77
-rw-r--r--app/javascript/mastodon/features/ui/components/report_modal.js6
-rw-r--r--app/javascript/mastodon/features/ui/containers/notifications_container.js19
-rw-r--r--app/javascript/mastodon/features/ui/containers/status_list_container.js6
-rw-r--r--app/javascript/mastodon/features/ui/index.js8
-rw-r--r--app/javascript/mastodon/locales/ar.json2
-rw-r--r--app/javascript/mastodon/locales/bg.json2
-rw-r--r--app/javascript/mastodon/locales/ca.json24
-rw-r--r--app/javascript/mastodon/locales/de.json2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json15
-rw-r--r--app/javascript/mastodon/locales/en.json4
-rw-r--r--app/javascript/mastodon/locales/eo.json2
-rw-r--r--app/javascript/mastodon/locales/es.json2
-rw-r--r--app/javascript/mastodon/locales/fa.json2
-rw-r--r--app/javascript/mastodon/locales/fi.json2
-rw-r--r--app/javascript/mastodon/locales/fr.json2
-rw-r--r--app/javascript/mastodon/locales/gl.json2
-rw-r--r--app/javascript/mastodon/locales/he.json2
-rw-r--r--app/javascript/mastodon/locales/hr.json2
-rw-r--r--app/javascript/mastodon/locales/hu.json2
-rw-r--r--app/javascript/mastodon/locales/hy.json2
-rw-r--r--app/javascript/mastodon/locales/id.json2
-rw-r--r--app/javascript/mastodon/locales/io.json2
-rw-r--r--app/javascript/mastodon/locales/it.json2
-rw-r--r--app/javascript/mastodon/locales/ja.json2
-rw-r--r--app/javascript/mastodon/locales/ko.json2
-rw-r--r--app/javascript/mastodon/locales/nl.json2
-rw-r--r--app/javascript/mastodon/locales/no.json2
-rw-r--r--app/javascript/mastodon/locales/oc.json2
-rw-r--r--app/javascript/mastodon/locales/pl.json2
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json2
-rw-r--r--app/javascript/mastodon/locales/pt.json2
-rw-r--r--app/javascript/mastodon/locales/ru.json2
-rw-r--r--app/javascript/mastodon/locales/sk.json2
-rw-r--r--app/javascript/mastodon/locales/sr-Latn.json2
-rw-r--r--app/javascript/mastodon/locales/sr.json2
-rw-r--r--app/javascript/mastodon/locales/sv.json2
-rw-r--r--app/javascript/mastodon/locales/th.json2
-rw-r--r--app/javascript/mastodon/locales/tr.json2
-rw-r--r--app/javascript/mastodon/locales/uk.json2
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json2
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json2
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json2
-rw-r--r--app/javascript/mastodon/middleware/errors.js8
-rw-r--r--app/javascript/mastodon/reducers/accounts.js125
-rw-r--r--app/javascript/mastodon/reducers/accounts_counters.js112
-rw-r--r--app/javascript/mastodon/reducers/notifications.js68
-rw-r--r--app/javascript/mastodon/reducers/statuses.js99
-rw-r--r--app/javascript/mastodon/reducers/timelines.js65
-rw-r--r--app/javascript/mastodon/stream.js6
-rw-r--r--app/javascript/packs/public.js16
-rw-r--r--app/javascript/styles/mastodon/components.scss5
-rw-r--r--app/javascript/styles/mastodon/containers.scss4
-rw-r--r--app/lib/provider_discovery.rb17
-rw-r--r--app/lib/request.rb16
-rw-r--r--app/models/concerns/remotable.rb34
-rw-r--r--app/models/notification.rb8
-rw-r--r--app/services/fetch_atom_service.rb47
-rw-r--r--app/services/fetch_link_card_service.rb21
-rw-r--r--app/services/resolve_account_service.rb9
-rw-r--r--app/services/send_interaction_service.rb8
-rw-r--r--app/services/subscribe_service.rb36
-rw-r--r--app/services/unsubscribe_service.rb7
-rw-r--r--app/views/authorize_follows/_post_follow_actions.html.haml4
-rw-r--r--app/views/authorize_follows/show.html.haml8
-rw-r--r--app/views/authorize_follows/success.html.haml5
-rw-r--r--app/views/invites/_form.html.haml2
-rw-r--r--app/workers/activitypub/delivery_worker.rb15
-rw-r--r--app/workers/pubsubhubbub/confirmation_worker.rb18
-rw-r--r--app/workers/pubsubhubbub/delivery_worker.rb17
108 files changed, 1146 insertions, 994 deletions
diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb
index 9530ad9f3..957a2cbc9 100644
--- a/app/helpers/jsonld_helper.rb
+++ b/app/helpers/jsonld_helper.rb
@@ -60,9 +60,9 @@ module JsonLdHelper
   end
 
   def fetch_resource_without_id_validation(uri)
-    response = build_request(uri).perform
-    return if response.code != 200
-    body_to_json(response.to_s)
+    build_request(uri).perform do |response|
+      response.code == 200 ? body_to_json(response.to_s) : nil
+    end
   end
 
   def body_to_json(body)
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index f63325658..1d1947aca 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -1,4 +1,6 @@
 import api, { getLinks } from '../api';
+import asyncDB from '../db/async';
+import { importAccount, importFetchedAccount, importFetchedAccounts } from './importer';
 
 export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
 export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
@@ -64,6 +66,24 @@ export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
 export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
 export const FOLLOW_REQUEST_REJECT_FAIL    = 'FOLLOW_REQUEST_REJECT_FAIL';
 
+function getFromDB(dispatch, getState, index, id) {
+  return new Promise((resolve, reject) => {
+    const request = index.get(id);
+
+    request.onerror = reject;
+
+    request.onsuccess = () => {
+      if (!request.result) {
+        reject();
+        return;
+      }
+
+      dispatch(importAccount(request.result));
+      resolve(request.result.moved && getFromDB(dispatch, getState, index, request.result.moved));
+    };
+  });
+}
+
 export function fetchAccount(id) {
   return (dispatch, getState) => {
     dispatch(fetchRelationships([id]));
@@ -74,9 +94,16 @@ export function fetchAccount(id) {
 
     dispatch(fetchAccountRequest(id));
 
-    api(getState).get(`/api/v1/accounts/${id}`).then(response => {
-      dispatch(fetchAccountSuccess(response.data));
-    }).catch(error => {
+    asyncDB.then(db => getFromDB(
+      dispatch,
+      getState,
+      db.transaction('accounts', 'read').objectStore('accounts').index('id'),
+      id
+    )).catch(() => api(getState).get(`/api/v1/accounts/${id}`).then(response => {
+      dispatch(importFetchedAccount(response.data));
+    })).then(() => {
+      dispatch(fetchAccountSuccess());
+    }, error => {
       dispatch(fetchAccountFail(id, error));
     });
   };
@@ -89,10 +116,9 @@ export function fetchAccountRequest(id) {
   };
 };
 
-export function fetchAccountSuccess(account) {
+export function fetchAccountSuccess() {
   return {
     type: ACCOUNT_FETCH_SUCCESS,
-    account,
   };
 };
 
@@ -319,6 +345,7 @@ export function fetchFollowers(id) {
     api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
 
+      dispatch(importFetchedAccounts(response.data));
       dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null));
       dispatch(fetchRelationships(response.data.map(item => item.id)));
     }).catch(error => {
@@ -364,6 +391,7 @@ export function expandFollowers(id) {
     api(getState).get(url).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
 
+      dispatch(importFetchedAccounts(response.data));
       dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null));
       dispatch(fetchRelationships(response.data.map(item => item.id)));
     }).catch(error => {
@@ -403,6 +431,7 @@ export function fetchFollowing(id) {
     api(getState).get(`/api/v1/accounts/${id}/following`).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
 
+      dispatch(importFetchedAccounts(response.data));
       dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null));
       dispatch(fetchRelationships(response.data.map(item => item.id)));
     }).catch(error => {
@@ -448,6 +477,7 @@ export function expandFollowing(id) {
     api(getState).get(url).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
 
+      dispatch(importFetchedAccounts(response.data));
       dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null));
       dispatch(fetchRelationships(response.data.map(item => item.id)));
     }).catch(error => {
@@ -529,6 +559,7 @@ export function fetchFollowRequests() {
 
     api(getState).get('/api/v1/follow_requests').then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(importFetchedAccounts(response.data));
       dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null));
     }).catch(error => dispatch(fetchFollowRequestsFail(error)));
   };
@@ -567,6 +598,7 @@ export function expandFollowRequests() {
 
     api(getState).get(url).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(importFetchedAccounts(response.data));
       dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null));
     }).catch(error => dispatch(expandFollowRequestsFail(error)));
   };
diff --git a/app/javascript/mastodon/actions/blocks.js b/app/javascript/mastodon/actions/blocks.js
index 553283a71..7000f5a71 100644
--- a/app/javascript/mastodon/actions/blocks.js
+++ b/app/javascript/mastodon/actions/blocks.js
@@ -1,5 +1,6 @@
 import api, { getLinks } from '../api';
 import { fetchRelationships } from './accounts';
+import { importFetchedAccounts } from './importer';
 
 export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
 export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
@@ -15,6 +16,7 @@ export function fetchBlocks() {
 
     api(getState).get('/api/v1/blocks').then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(importFetchedAccounts(response.data));
       dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null));
       dispatch(fetchRelationships(response.data.map(item => item.id)));
     }).catch(error => dispatch(fetchBlocksFail(error)));
@@ -54,6 +56,7 @@ export function expandBlocks() {
 
     api(getState).get(url).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(importFetchedAccounts(response.data));
       dispatch(expandBlocksSuccess(response.data, next ? next.uri : null));
       dispatch(fetchRelationships(response.data.map(item => item.id)));
     }).catch(error => dispatch(expandBlocksFail(error)));
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 1371f22b2..5e7cdd270 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -4,13 +4,8 @@ import { throttle } from 'lodash';
 import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
 import { tagHistory } from '../settings';
 import { useEmoji } from './emojis';
-
-import {
-  updateTimeline,
-  refreshHomeTimeline,
-  refreshCommunityTimeline,
-  refreshPublicTimeline,
-} from './timelines';
+import { importFetchedAccounts } from './importer';
+import { updateTimeline } from './timelines';
 
 let cancelFetchComposeSuggestionsAccounts;
 
@@ -124,19 +119,17 @@ 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');
       }
     }).catch(function (error) {
       dispatch(submitComposeFail(error));
@@ -282,6 +275,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) =>
       limit: 4,
     },
   }).then(response => {
+    dispatch(importFetchedAccounts(response.data));
     dispatch(readyComposeSuggestionsAccounts(token, response.data));
   });
 }, 200, { leading: true, trailing: true });
diff --git a/app/javascript/mastodon/actions/favourites.js b/app/javascript/mastodon/actions/favourites.js
index 93094c526..124cf8c44 100644
--- a/app/javascript/mastodon/actions/favourites.js
+++ b/app/javascript/mastodon/actions/favourites.js
@@ -1,4 +1,5 @@
 import api, { getLinks } from '../api';
+import { importFetchedStatuses } from './importer';
 
 export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
 export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
@@ -18,6 +19,7 @@ export function fetchFavouritedStatuses() {
 
     api(getState).get('/api/v1/favourites').then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(importFetchedStatuses(response.data));
       dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
     }).catch(error => {
       dispatch(fetchFavouritedStatusesFail(error));
@@ -58,6 +60,7 @@ export function expandFavouritedStatuses() {
 
     api(getState).get(url).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(importFetchedStatuses(response.data));
       dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
     }).catch(error => {
       dispatch(expandFavouritedStatusesFail(error));
diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js
new file mode 100644
index 000000000..d1ea40c36
--- /dev/null
+++ b/app/javascript/mastodon/actions/importer/index.js
@@ -0,0 +1,76 @@
+import { putAccounts, putStatuses } from '../../db/modifier';
+import { normalizeAccount, normalizeStatus } from './normalizer';
+
+export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
+export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
+export const STATUS_IMPORT = 'STATUS_IMPORT';
+export const STATUSES_IMPORT = 'STATUSES_IMPORT';
+
+function pushUnique(array, object) {
+  if (array.every(element => element.id !== object.id)) {
+    array.push(object);
+  }
+}
+
+export function importAccount(account) {
+  return { type: ACCOUNT_IMPORT, account };
+}
+
+export function importAccounts(accounts) {
+  return { type: ACCOUNTS_IMPORT, accounts };
+}
+
+export function importStatus(status) {
+  return { type: STATUS_IMPORT, status };
+}
+
+export function importStatuses(statuses) {
+  return { type: STATUSES_IMPORT, statuses };
+}
+
+export function importFetchedAccount(account) {
+  return importFetchedAccounts([account]);
+}
+
+export function importFetchedAccounts(accounts) {
+  const normalAccounts = [];
+
+  function processAccount(account) {
+    pushUnique(normalAccounts, normalizeAccount(account));
+
+    if (account.moved) {
+      processAccount(account);
+    }
+  }
+
+  accounts.forEach(processAccount);
+  putAccounts(normalAccounts);
+
+  return importAccounts(normalAccounts);
+}
+
+export function importFetchedStatus(status) {
+  return importFetchedStatuses([status]);
+}
+
+export function importFetchedStatuses(statuses) {
+  return (dispatch, getState) => {
+    const accounts = [];
+    const normalStatuses = [];
+
+    function processStatus(status) {
+      pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id])));
+      pushUnique(accounts, status.account);
+
+      if (status.reblog && status.reblog.id) {
+        processStatus(status.reblog);
+      }
+    }
+
+    statuses.forEach(processStatus);
+    putStatuses(normalStatuses);
+
+    dispatch(importFetchedAccounts(accounts));
+    dispatch(importStatuses(normalStatuses));
+  };
+}
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
new file mode 100644
index 000000000..c88f6946f
--- /dev/null
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -0,0 +1,46 @@
+import escapeTextContentForBrowser from 'escape-html';
+import emojify from '../../features/emoji/emoji';
+
+const domParser = new DOMParser();
+
+export function normalizeAccount(account) {
+  account = { ...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);
+
+  return account;
+}
+
+export function normalizeStatus(status, normalOldStatus) {
+  const normalStatus   = { ...status };
+  normalStatus.account = status.account.id;
+
+  if (status.reblog && status.reblog.id) {
+    normalStatus.reblog = status.reblog.id;
+  }
+
+  // Only calculate these values when status first encountered
+  // Otherwise keep the ones already in the reducer
+  if (normalOldStatus) {
+    normalStatus.search_index = normalOldStatus.get('search_index');
+    normalStatus.contentHtml = normalOldStatus.get('contentHtml');
+    normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
+    normalStatus.hidden = normalOldStatus.get('hidden');
+  } else {
+    const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+
+    const emojiMap = normalStatus.emojis.reduce((obj, emoji) => {
+      obj[`:${emoji.shortcode}:`] = emoji;
+      return obj;
+    }, {});
+
+    normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+    normalStatus.contentHtml  = emojify(normalStatus.content, emojiMap);
+    normalStatus.spoilerHtml  = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap);
+    normalStatus.hidden       = normalStatus.sensitive;
+  }
+
+  return normalStatus;
+}
diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js
index 10e66910a..2dc4c574c 100644
--- a/app/javascript/mastodon/actions/interactions.js
+++ b/app/javascript/mastodon/actions/interactions.js
@@ -1,4 +1,5 @@
 import api from '../api';
+import { importFetchedAccounts, importFetchedStatus } from './importer';
 
 export const REBLOG_REQUEST = 'REBLOG_REQUEST';
 export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
@@ -39,7 +40,8 @@ export function reblog(status) {
     api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) {
       // The reblog API method returns a new status wrapped around the original. In this case we are only
       // interested in how the original is modified, hence passing it skipping the wrapper
-      dispatch(reblogSuccess(status, response.data.reblog));
+      dispatch(importFetchedStatus(response.data.reblog));
+      dispatch(reblogSuccess(status));
     }).catch(function (error) {
       dispatch(reblogFail(status, error));
     });
@@ -51,7 +53,8 @@ export function unreblog(status) {
     dispatch(unreblogRequest(status));
 
     api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => {
-      dispatch(unreblogSuccess(status, response.data));
+      dispatch(importFetchedStatus(response.data));
+      dispatch(unreblogSuccess(status));
     }).catch(error => {
       dispatch(unreblogFail(status, error));
     });
@@ -66,11 +69,10 @@ export function reblogRequest(status) {
   };
 };
 
-export function reblogSuccess(status, response) {
+export function reblogSuccess(status) {
   return {
     type: REBLOG_SUCCESS,
     status: status,
-    response: response,
     skipLoading: true,
   };
 };
@@ -92,11 +94,10 @@ export function unreblogRequest(status) {
   };
 };
 
-export function unreblogSuccess(status, response) {
+export function unreblogSuccess(status) {
   return {
     type: UNREBLOG_SUCCESS,
     status: status,
-    response: response,
     skipLoading: true,
   };
 };
@@ -115,7 +116,8 @@ export function favourite(status) {
     dispatch(favouriteRequest(status));
 
     api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) {
-      dispatch(favouriteSuccess(status, response.data));
+      dispatch(importFetchedStatus(response.data));
+      dispatch(favouriteSuccess(status));
     }).catch(function (error) {
       dispatch(favouriteFail(status, error));
     });
@@ -127,7 +129,8 @@ export function unfavourite(status) {
     dispatch(unfavouriteRequest(status));
 
     api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => {
-      dispatch(unfavouriteSuccess(status, response.data));
+      dispatch(importFetchedStatus(response.data));
+      dispatch(unfavouriteSuccess(status));
     }).catch(error => {
       dispatch(unfavouriteFail(status, error));
     });
@@ -142,11 +145,10 @@ export function favouriteRequest(status) {
   };
 };
 
-export function favouriteSuccess(status, response) {
+export function favouriteSuccess(status) {
   return {
     type: FAVOURITE_SUCCESS,
     status: status,
-    response: response,
     skipLoading: true,
   };
 };
@@ -168,11 +170,10 @@ export function unfavouriteRequest(status) {
   };
 };
 
-export function unfavouriteSuccess(status, response) {
+export function unfavouriteSuccess(status) {
   return {
     type: UNFAVOURITE_SUCCESS,
     status: status,
-    response: response,
     skipLoading: true,
   };
 };
@@ -191,6 +192,7 @@ export function fetchReblogs(id) {
     dispatch(fetchReblogsRequest(id));
 
     api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
+      dispatch(importFetchedAccounts(response.data));
       dispatch(fetchReblogsSuccess(id, response.data));
     }).catch(error => {
       dispatch(fetchReblogsFail(id, error));
@@ -225,6 +227,7 @@ export function fetchFavourites(id) {
     dispatch(fetchFavouritesRequest(id));
 
     api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
+      dispatch(importFetchedAccounts(response.data));
       dispatch(fetchFavouritesSuccess(id, response.data));
     }).catch(error => {
       dispatch(fetchFavouritesFail(id, error));
@@ -259,7 +262,8 @@ export function pin(status) {
     dispatch(pinRequest(status));
 
     api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
-      dispatch(pinSuccess(status, response.data));
+      dispatch(importFetchedStatus(response.data));
+      dispatch(pinSuccess(status));
     }).catch(error => {
       dispatch(pinFail(status, error));
     });
@@ -274,11 +278,10 @@ export function pinRequest(status) {
   };
 };
 
-export function pinSuccess(status, response) {
+export function pinSuccess(status) {
   return {
     type: PIN_SUCCESS,
     status,
-    response,
     skipLoading: true,
   };
 };
@@ -297,7 +300,8 @@ export function unpin (status) {
     dispatch(unpinRequest(status));
 
     api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
-      dispatch(unpinSuccess(status, response.data));
+      dispatch(importFetchedStatus(response.data));
+      dispatch(unpinSuccess(status));
     }).catch(error => {
       dispatch(unpinFail(status, error));
     });
@@ -312,11 +316,10 @@ export function unpinRequest(status) {
   };
 };
 
-export function unpinSuccess(status, response) {
+export function unpinSuccess(status) {
   return {
     type: UNPIN_SUCCESS,
     status,
-    response,
     skipLoading: true,
   };
 };
diff --git a/app/javascript/mastodon/actions/lists.js b/app/javascript/mastodon/actions/lists.js
index 4c8f9b186..12d60e3a3 100644
--- a/app/javascript/mastodon/actions/lists.js
+++ b/app/javascript/mastodon/actions/lists.js
@@ -1,4 +1,5 @@
 import api from '../api';
+import { importFetchedAccounts } from './importer';
 
 export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
 export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
@@ -200,9 +201,10 @@ export const deleteListFail = (id, error) => ({
 export const fetchListAccounts = listId => (dispatch, getState) => {
   dispatch(fetchListAccountsRequest(listId));
 
-  api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } })
-    .then(({ data }) => dispatch(fetchListAccountsSuccess(listId, data)))
-    .catch(err => dispatch(fetchListAccountsFail(listId, err)));
+  api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => {
+    dispatch(importFetchedAccounts(data));
+    dispatch(fetchListAccountsSuccess(listId, data));
+  }).catch(err => dispatch(fetchListAccountsFail(listId, err)));
 };
 
 export const fetchListAccountsRequest = id => ({
@@ -231,8 +233,10 @@ export const fetchListSuggestions = q => (dispatch, getState) => {
     following: true,
   };
 
-  api(getState).get('/api/v1/accounts/search', { params })
-    .then(({ data }) => dispatch(fetchListSuggestionsReady(q, data)));
+  api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => {
+    dispatch(importFetchedAccounts(data));
+    dispatch(fetchListSuggestionsReady(q, data));
+  });
 };
 
 export const fetchListSuggestionsReady = (query, accounts) => ({
diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js
index daa76a8f7..9f645faee 100644
--- a/app/javascript/mastodon/actions/mutes.js
+++ b/app/javascript/mastodon/actions/mutes.js
@@ -1,5 +1,6 @@
 import api, { getLinks } from '../api';
 import { fetchRelationships } from './accounts';
+import { importFetchedAccounts } from './importer';
 import { openModal } from './modal';
 
 export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
@@ -19,6 +20,7 @@ export function fetchMutes() {
 
     api(getState).get('/api/v1/mutes').then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(importFetchedAccounts(response.data));
       dispatch(fetchMutesSuccess(response.data, next ? next.uri : null));
       dispatch(fetchRelationships(response.data.map(item => item.id)));
     }).catch(error => dispatch(fetchMutesFail(error)));
@@ -58,6 +60,7 @@ export function expandMutes() {
 
     api(getState).get(url).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(importFetchedAccounts(response.data));
       dispatch(expandMutesSuccess(response.data, next ? next.uri : null));
       dispatch(fetchRelationships(response.data.map(item => item.id)));
     }).catch(error => dispatch(expandMutesFail(error)));
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index cf9242d0f..7267b85bd 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -1,15 +1,16 @@
 import api, { getLinks } from '../api';
-import { List as ImmutableList } from 'immutable';
 import IntlMessageFormat from 'intl-messageformat';
 import { fetchRelationships } from './accounts';
+import {
+  importFetchedAccount,
+  importFetchedAccounts,
+  importFetchedStatus,
+  importFetchedStatuses,
+} from './importer';
 import { defineMessages } from 'react-intl';
 
 export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
 
-export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
-export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
-export const NOTIFICATIONS_REFRESH_FAIL    = 'NOTIFICATIONS_REFRESH_FAIL';
-
 export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
 export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
 export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL';
@@ -41,11 +42,12 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
     const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
     const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
 
+    dispatch(importFetchedAccount(notification.account));
+    dispatch(importFetchedStatus(notification.status));
+
     dispatch({
       type: NOTIFICATIONS_UPDATE,
       notification,
-      account: notification.account,
-      status: notification.status,
       meta: playSound ? { sound: 'boop' } : undefined,
     });
 
@@ -67,73 +69,14 @@ 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,
-  };
-};
-
-export function refreshNotificationsFail(error, skipLoading) {
-  return {
-    type: NOTIFICATIONS_REFRESH_FAIL,
-    error,
-    skipLoading,
-  };
-};
-
-export function expandNotifications() {
+export function expandNotifications({ maxId } = {}) {
   return (dispatch, getState) => {
-    const items  = getState().getIn(['notifications', 'items'], ImmutableList());
-
-    if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) {
+    if (getState().getIn(['notifications', 'isLoading'])) {
       return;
     }
 
     const params = {
-      max_id: items.last().get('id'),
-      limit: 20,
+      max_id: maxId,
       exclude_types: excludeTypesFromSettings(getState()),
     };
 
@@ -141,6 +84,10 @@ export function expandNotifications() {
 
     api(getState).get('/api/v1/notifications', { params }).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(importFetchedAccounts(response.data.map(item => item.account)));
+      dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
+
       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
       fetchRelatedRelationships(dispatch, response.data);
     }).catch(error => {
@@ -159,8 +106,6 @@ export function expandNotificationsSuccess(notifications, next) {
   return {
     type: NOTIFICATIONS_EXPAND_SUCCESS,
     notifications,
-    accounts: notifications.map(item => item.account),
-    statuses: notifications.map(item => item.status).filter(status => !!status),
     next,
   };
 };
diff --git a/app/javascript/mastodon/actions/pin_statuses.js b/app/javascript/mastodon/actions/pin_statuses.js
index 3f40f6c2d..77abba7b5 100644
--- a/app/javascript/mastodon/actions/pin_statuses.js
+++ b/app/javascript/mastodon/actions/pin_statuses.js
@@ -1,4 +1,5 @@
 import api from '../api';
+import { importFetchedStatuses } from './importer';
 
 export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
 export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
@@ -11,6 +12,7 @@ export function fetchPinnedStatuses() {
     dispatch(fetchPinnedStatusesRequest());
 
     api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => {
+      dispatch(importFetchedStatuses(response.data));
       dispatch(fetchPinnedStatusesSuccess(response.data, null));
     }).catch(error => {
       dispatch(fetchPinnedStatusesFail(error));
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
index 73cb106ec..882c1709e 100644
--- a/app/javascript/mastodon/actions/search.js
+++ b/app/javascript/mastodon/actions/search.js
@@ -1,5 +1,6 @@
 import api from '../api';
 import { fetchRelationships } from './accounts';
+import { importFetchedAccounts, importFetchedStatuses } from './importer';
 
 export const SEARCH_CHANGE = 'SEARCH_CHANGE';
 export const SEARCH_CLEAR  = 'SEARCH_CLEAR';
@@ -38,6 +39,14 @@ export function submitSearch() {
         resolve: true,
       },
     }).then(response => {
+      if (response.data.accounts) {
+        dispatch(importFetchedAccounts(response.data.accounts));
+      }
+
+      if (response.data.statuses) {
+        dispatch(importFetchedStatuses(response.data.statuses));
+      }
+
       dispatch(fetchSearchSuccess(response.data));
       dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
     }).catch(error => {
@@ -56,8 +65,6 @@ export function fetchSearchSuccess(results) {
   return {
     type: SEARCH_FETCH_SUCCESS,
     results,
-    accounts: results.accounts,
-    statuses: results.statuses,
   };
 };
 
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 073f09883..dcd813dd9 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -1,7 +1,10 @@
 import api from '../api';
+import asyncDB from '../db/async';
+import { evictStatus } from '../db/modifier';
 
 import { deleteFromTimelines } from './timelines';
 import { fetchStatusCard } from './cards';
+import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus } from './importer';
 
 export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
 export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
@@ -34,6 +37,48 @@ export function fetchStatusRequest(id, skipLoading) {
   };
 };
 
+function getFromDB(dispatch, getState, accountIndex, index, id) {
+  return new Promise((resolve, reject) => {
+    const request = index.get(id);
+
+    request.onerror = reject;
+
+    request.onsuccess = () => {
+      const promises = [];
+
+      if (!request.result) {
+        reject();
+        return;
+      }
+
+      dispatch(importStatus(request.result));
+
+      if (getState().getIn(['accounts', request.result.account], null) === null) {
+        promises.push(new Promise((accountResolve, accountReject) => {
+          const accountRequest = accountIndex.get(request.result.account);
+
+          accountRequest.onerror = accountReject;
+          accountRequest.onsuccess = () => {
+            if (!request.result) {
+              accountReject();
+              return;
+            }
+
+            dispatch(importAccount(accountRequest.result));
+            accountResolve();
+          };
+        }));
+      }
+
+      if (request.result.reblog && getState().getIn(['statuses', request.result.reblog], null) === null) {
+        promises.push(getFromDB(dispatch, getState, accountIndex, index, request.result.reblog));
+      }
+
+      resolve(Promise.all(promises));
+    };
+  });
+}
+
 export function fetchStatus(id) {
   return (dispatch, getState) => {
     const skipLoading = getState().getIn(['statuses', id], null) !== null;
@@ -47,18 +92,26 @@ export function fetchStatus(id) {
 
     dispatch(fetchStatusRequest(id, skipLoading));
 
-    api(getState).get(`/api/v1/statuses/${id}`).then(response => {
-      dispatch(fetchStatusSuccess(response.data, skipLoading));
-    }).catch(error => {
+    asyncDB.then(db => {
+      const transaction = db.transaction(['accounts', 'statuses'], 'read');
+      const accountIndex = transaction.objectStore('accounts').index('id');
+      const index = transaction.objectStore('statuses').index('id');
+
+      return getFromDB(dispatch, getState, accountIndex, index, id);
+    }).then(() => {
+      dispatch(fetchStatusSuccess(skipLoading));
+    }, () => api(getState).get(`/api/v1/statuses/${id}`).then(response => {
+      dispatch(importFetchedStatus(response.data));
+      dispatch(fetchStatusSuccess(skipLoading));
+    })).catch(error => {
       dispatch(fetchStatusFail(id, error, skipLoading));
     });
   };
 };
 
-export function fetchStatusSuccess(status, skipLoading) {
+export function fetchStatusSuccess(skipLoading) {
   return {
     type: STATUS_FETCH_SUCCESS,
-    status,
     skipLoading,
   };
 };
@@ -78,6 +131,7 @@ export function deleteStatus(id) {
     dispatch(deleteStatusRequest(id));
 
     api(getState).delete(`/api/v1/statuses/${id}`).then(() => {
+      evictStatus(id);
       dispatch(deleteStatusSuccess(id));
       dispatch(deleteFromTimelines(id));
     }).catch(error => {
@@ -113,6 +167,7 @@ export function fetchContext(id) {
     dispatch(fetchContextRequest(id));
 
     api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
+      dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants)));
       dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
 
     }).catch(error => {
diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js
index 2dd94a998..34dcafc51 100644
--- a/app/javascript/mastodon/actions/store.js
+++ b/app/javascript/mastodon/actions/store.js
@@ -1,5 +1,6 @@
 import { Iterable, fromJS } from 'immutable';
 import { hydrateCompose } from './compose';
+import { importFetchedAccounts } from './importer';
 
 export const STORE_HYDRATE = 'STORE_HYDRATE';
 export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
@@ -18,5 +19,6 @@ export function hydrateStore(rawState) {
     });
 
     dispatch(hydrateCompose());
+    dispatch(importFetchedAccounts(Object.values(rawState.accounts)));
   };
 };
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index c22152edd..f76510cdb 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -2,11 +2,10 @@ import { connectStream } from '../stream';
 import {
   updateTimeline,
   deleteFromTimelines,
-  refreshHomeTimeline,
-  connectTimeline,
+  expandHomeTimeline,
   disconnectTimeline,
 } from './timelines';
-import { updateNotifications, refreshNotifications } from './notifications';
+import { updateNotifications, expandNotifications } from './notifications';
 import { getLocale } from '../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));
       },
@@ -42,8 +37,8 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
 }
 
 function refreshHomeTimelineAndNotification (dispatch) {
-  dispatch(refreshHomeTimeline());
-  dispatch(refreshNotifications());
+  dispatch(expandHomeTimeline());
+  dispatch(expandNotifications());
 }
 
 export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index f0ab16a2d..5be07126d 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -1,35 +1,20 @@
+import { importFetchedStatus, importFetchedStatuses } from './importer';
 import api, { getLinks } from '../api';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import { Map as ImmutableMap } 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) : [];
@@ -44,6 +29,8 @@ export function updateTimeline(timeline, status) {
       }
     }
 
+    dispatch(importFetchedStatus(status));
+
     dispatch({
       type: TIMELINE_UPDATE,
       timeline,
@@ -77,95 +64,34 @@ 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;
-    }
-
-    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 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 = {}) {
   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')) {
       return;
     }
 
-    params.max_id = ids.last();
-    params.limit  = 10;
-
     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(importFetchedStatuses(response.data));
+      dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206));
     }).catch(error => {
       dispatch(expandTimelineFail(timelineId, error));
     });
   };
 };
 
-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 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 } = {}) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId });
+export const expandPublicTimeline       = ({ maxId } = {}) => expandTimeline('public', '/api/v1/timelines/public', { max_id: maxId });
+export const expandCommunityTimeline    = ({ maxId } = {}) => expandTimeline('community', '/api/v1/timelines/public', { local: true, max_id: maxId });
+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 } = {}) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId });
+export const expandListTimeline         = (id, { maxId } = {}) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId });
 
 export function expandTimelineRequest(timeline) {
   return {
@@ -174,12 +100,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,
   };
 };
 
@@ -199,13 +126,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/mastodon/components/load_more.js b/app/javascript/mastodon/components/load_more.js
index c4c8c94a2..389c3e1e1 100644
--- a/app/javascript/mastodon/components/load_more.js
+++ b/app/javascript/mastodon/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/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index 1cef029d8..13e1fcc52 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -14,10 +14,6 @@ const messages = defineMessages({
 
 class Item extends React.PureComponent {
 
-  static contextTypes = {
-    router: PropTypes.object,
-  };
-
   static propTypes = {
     attachment: ImmutablePropTypes.map.isRequired,
     standalone: PropTypes.bool,
@@ -53,7 +49,7 @@ class Item extends React.PureComponent {
   handleClick = (e) => {
     const { index, onClick } = this.props;
 
-    if (this.context.router && e.button === 0) {
+    if (e.button === 0) {
       e.preventDefault();
       onClick(index);
     }
diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js
new file mode 100644
index 000000000..114f74937
--- /dev/null
+++ b/app/javascript/mastodon/components/modal_root.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class ModalRoot extends React.PureComponent {
+
+  static propTypes = {
+    children: PropTypes.node,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  state = {
+    revealed: !!this.props.children,
+  };
+
+  activeElement = this.state.revealed ? document.activeElement : null;
+
+  handleKeyUp = (e) => {
+    if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
+         && !!this.props.children) {
+      this.props.onClose();
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (!!nextProps.children && !this.props.children) {
+      this.activeElement = document.activeElement;
+
+      this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
+    } else if (!nextProps.children) {
+      this.setState({ revealed: false });
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (!this.props.children && !!prevProps.children) {
+      this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
+      this.activeElement.focus();
+      this.activeElement = null;
+    }
+    if (this.props.children) {
+      requestAnimationFrame(() => {
+        this.setState({ revealed: true });
+      });
+    }
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  }
+
+  getSiblings = () => {
+    return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
+  }
+
+  setRef = ref => {
+    this.node = ref;
+  }
+
+  render () {
+    const { children, onClose } = this.props;
+    const { revealed } = this.state;
+    const visible = !!children;
+
+    if (!visible) {
+      return (
+        <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} />
+      );
+    }
+
+    return (
+      <div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}>
+        <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
+          <div role='presentation' className='modal-root__overlay' onClick={onClose} />
+          <div role='dialog' className='modal-root__container'>{children}</div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
index ac3e404df..ee07106f7 100644
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -17,7 +17,7 @@ export default class ScrollableList extends PureComponent {
 
   static propTypes = {
     scrollKey: PropTypes.string.isRequired,
-    onLoadMore: PropTypes.func.isRequired,
+    onLoadMore: PropTypes.func,
     onScrollToTop: PropTypes.func,
     onScroll: PropTypes.func,
     trackScroll: PropTypes.bool,
@@ -148,11 +148,11 @@ export default class ScrollableList extends PureComponent {
   }
 
   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/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 3bebf702c..8c2673f30 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -1,11 +1,31 @@
+import { debounce } from 'lodash';
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import StatusContainer from '../containers/status_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import LoadMore from './load_more';
 import ScrollableList from './scrollable_list';
 import { FormattedMessage } from 'react-intl';
 
+class LoadGap extends ImmutablePureComponent {
+
+  static propTypes = {
+    disabled: PropTypes.bool,
+    maxId: PropTypes.string,
+    onClick: PropTypes.func.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.onClick(this.props.maxId);
+  }
+
+  render () {
+    return <LoadMore onClick={this.handleClick} disabled={this.props.disabled} />;
+  }
+
+}
+
 export default class StatusList extends ImmutablePureComponent {
 
   static propTypes = {
@@ -38,6 +58,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`);
 
@@ -51,7 +75,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) {
@@ -70,7 +94,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}
@@ -93,7 +124,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/mastodon/containers/media_galleries_container.js b/app/javascript/mastodon/containers/media_galleries_container.js
new file mode 100644
index 000000000..d77bd688b
--- /dev/null
+++ b/app/javascript/mastodon/containers/media_galleries_container.js
@@ -0,0 +1,68 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+import { IntlProvider, addLocaleData } from 'react-intl';
+import { getLocale } from '../locales';
+import MediaGallery from '../components/media_gallery';
+import ModalRoot from '../components/modal_root';
+import MediaModal from '../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/mastodon/containers/media_gallery_container.js b/app/javascript/mastodon/containers/media_gallery_container.js
deleted file mode 100644
index 812c3d4e5..000000000
--- a/app/javascript/mastodon/containers/media_gallery_container.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { IntlProvider, addLocaleData } from 'react-intl';
-import { getLocale } from '../locales';
-import MediaGallery from '../components/media_gallery';
-import { fromJS } from 'immutable';
-
-const { localeData, messages } = getLocale();
-addLocaleData(localeData);
-
-export default class MediaGalleryContainer extends React.PureComponent {
-
-  static propTypes = {
-    locale: PropTypes.string.isRequired,
-    media: PropTypes.array.isRequired,
-  };
-
-  handleOpenMedia = () => {}
-
-  render () {
-    const { locale, media, ...props } = this.props;
-
-    return (
-      <IntlProvider locale={locale} messages={messages}>
-        <MediaGallery
-          {...props}
-          media={fromJS(media)}
-          onOpenMedia={this.handleOpenMedia}
-        />
-      </IntlProvider>
-    );
-  }
-
-}
diff --git a/app/javascript/mastodon/db/async.js b/app/javascript/mastodon/db/async.js
new file mode 100644
index 000000000..e08fc3f3d
--- /dev/null
+++ b/app/javascript/mastodon/db/async.js
@@ -0,0 +1,28 @@
+import { me } from '../initial_state';
+
+export default new Promise((resolve, reject) => {
+  // Microsoft Edge 17 does not support getAll according to:
+  // Catalog of standard and vendor APIs across browsers - Microsoft Edge Development
+  // https://developer.microsoft.com/en-us/microsoft-edge/platform/catalog/?q=specName%3Aindexeddb
+  if (!me || !('getAll' in IDBObjectStore.prototype)) {
+    reject();
+    return;
+  }
+
+  const request = indexedDB.open('mastodon:' + me);
+
+  request.onerror = reject;
+  request.onsuccess = ({ target }) => resolve(target.result);
+
+  request.onupgradeneeded = ({ target }) => {
+    const accounts = target.result.createObjectStore('accounts', { autoIncrement: true });
+    const statuses = target.result.createObjectStore('statuses', { autoIncrement: true });
+
+    accounts.createIndex('id', 'id', { unique: true });
+    accounts.createIndex('moved', 'moved');
+
+    statuses.createIndex('id', 'id', { unique: true });
+    statuses.createIndex('account', 'account');
+    statuses.createIndex('reblog', 'reblog');
+  };
+});
diff --git a/app/javascript/mastodon/db/modifier.js b/app/javascript/mastodon/db/modifier.js
new file mode 100644
index 000000000..eb951905a
--- /dev/null
+++ b/app/javascript/mastodon/db/modifier.js
@@ -0,0 +1,93 @@
+import asyncDB from './async';
+
+const limit = 1024;
+
+function put(name, objects, callback) {
+  asyncDB.then(db => {
+    const putTransaction = db.transaction(name, 'readwrite');
+    const putStore = putTransaction.objectStore(name);
+    const putIndex = putStore.index('id');
+
+    objects.forEach(object => {
+      function add() {
+        putStore.add(object);
+      }
+
+      putIndex.getKey(object.id).onsuccess = retrieval => {
+        if (retrieval.target.result) {
+          putStore.delete(retrieval.target.result).onsuccess = add;
+        } else {
+          add();
+        }
+      };
+    });
+
+    putTransaction.oncomplete = () => {
+      const readTransaction = db.transaction(name, 'readonly');
+      const readStore = readTransaction.objectStore(name);
+
+      readStore.count().onsuccess = count => {
+        const excess = count.target.result - limit;
+
+        if (excess > 0) {
+          readStore.getAll(null, excess).onsuccess =
+            retrieval => callback(retrieval.target.result.map(({ id }) => id));
+        }
+      };
+    };
+  });
+}
+
+export function evictAccounts(ids) {
+  asyncDB.then(db => {
+    const transaction = db.transaction(['accounts', 'statuses'], 'readwrite');
+    const accounts = transaction.objectStore('accounts');
+    const accountsIdIndex = accounts.index('id');
+    const accountsMovedIndex = accounts.index('moved');
+    const statuses = transaction.objectStore('statuses');
+    const statusesIndex = statuses.index('account');
+
+    function evict(toEvict) {
+      toEvict.forEach(id => {
+        accountsMovedIndex.getAllKeys(id).onsuccess =
+          ({ target }) => evict(target.result);
+
+        statusesIndex.getAll(id).onsuccess =
+          ({ target }) => evictStatuses(target.result.map(({ id }) => id));
+
+        accountsIdIndex.getKey(id).onsuccess =
+          ({ target }) => target.result && accounts.delete(target.result);
+      });
+    }
+
+    evict(ids);
+  });
+}
+
+export function evictStatus(id) {
+  return evictStatuses([id]);
+}
+
+export function evictStatuses(ids) {
+  asyncDB.then(db => {
+    const store = db.transaction('statuses', 'readwrite').objectStore('statuses');
+    const idIndex = store.index('id');
+    const reblogIndex = store.index('reblog');
+
+    ids.forEach(id => {
+      reblogIndex.getAllKeys(id).onsuccess =
+        ({ target }) => target.result.forEach(reblogKey => store.delete(reblogKey));
+
+      idIndex.getKey(id).onsuccess =
+        ({ target }) => target.result && store.delete(target.result);
+    });
+  });
+}
+
+export function putAccounts(records) {
+  put('accounts', records, evictAccounts);
+}
+
+export function putStatuses(records) {
+  put('statuses', records, evictStatuses);
+}
diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js
index 4b408256a..9a40d139c 100644
--- a/app/javascript/mastodon/features/account_gallery/index.js
+++ b/app/javascript/mastodon/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 '../../actions/accounts';
-import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from '../../actions/timelines';
+import { expandAccountMediaTimeline } from '../../actions/timelines';
 import LoadingIndicator from '../../components/loading_indicator';
 import Column from '../ui/components/column';
 import ColumnBackButton from '../../components/column_back_button';
@@ -17,9 +17,31 @@ import LoadMore from '../../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().get('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 => (
+              {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/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 9d594fb0c..6b88a7a0c 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -99,7 +99,7 @@ export default class Header extends ImmutablePureComponent {
         {!hideTabs && (
           <div className='account__section-headline'>
             <NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink>
-            <NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink>
+            <NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots and replies' /></NavLink>
             <NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
           </div>
         )}
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
index 5e21cf7c6..d329bac5c 100644
--- a/app/javascript/mastodon/features/account_timeline/index.js
+++ b/app/javascript/mastodon/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 '../../actions/accounts';
-import { refreshAccountTimeline, refreshAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines';
+import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../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']),
   };
 };
 
@@ -41,25 +41,23 @@ export default class AccountTimeline extends ImmutablePureComponent {
 
     this.props.dispatch(fetchAccount(accountId));
     if (!withReplies) {
-      this.props.dispatch(refreshAccountFeaturedTimeline(accountId));
+      this.props.dispatch(expandAccountFeaturedTimeline(accountId));
     }
-    this.props.dispatch(refreshAccountTimeline(accountId, withReplies));
+    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));
       if (!nextProps.withReplies) {
-        this.props.dispatch(refreshAccountFeaturedTimeline(nextProps.params.accountId));
+        this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
       }
-      this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId, nextProps.params.withReplies));
+      this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
     }
   }
 
-  handleLoadMore = () => {
-    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 () {
diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js
index 596a89412..870474ed5 100644
--- a/app/javascript/mastodon/features/community_timeline/index.js
+++ b/app/javascript/mastodon/features/community_timeline/index.js
@@ -4,10 +4,7 @@ import PropTypes from 'prop-types';
 import StatusListContainer from '../ui/containers/status_list_container';
 import Column from '../../components/column';
 import ColumnHeader from '../../components/column_header';
-import {
-  refreshCommunityTimeline,
-  expandCommunityTimeline,
-} from '../../actions/timelines';
+import { expandCommunityTimeline } from '../../actions/timelines';
 import { addColumn, removeColumn, moveColumn } from '../../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/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js
index 5fe21ce90..374615ac7 100644
--- a/app/javascript/mastodon/features/hashtag_timeline/index.js
+++ b/app/javascript/mastodon/features/hashtag_timeline/index.js
@@ -4,10 +4,7 @@ import PropTypes from 'prop-types';
 import StatusListContainer from '../ui/containers/status_list_container';
 import Column from '../../components/column';
 import ColumnHeader from '../../components/column_header';
-import {
-  refreshHashtagTimeline,
-  expandHashtagTimeline,
-} from '../../actions/timelines';
+import { expandHashtagTimeline } from '../../actions/timelines';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import { FormattedMessage } from 'react-intl';
 import { connectHashtagStream } from '../../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/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js
index 31f5a3c8b..db6bbdec1 100644
--- a/app/javascript/mastodon/features/home_timeline/index.js
+++ b/app/javascript/mastodon/features/home_timeline/index.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import { connect } from 'react-redux';
-import { expandHomeTimeline, refreshHomeTimeline } from '../../actions/timelines';
+import { expandHomeTimeline } from '../../actions/timelines';
 import PropTypes from 'prop-types';
 import StatusListContainer from '../ui/containers/status_list_container';
 import Column from '../../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/mastodon/features/list_timeline/index.js b/app/javascript/mastodon/features/list_timeline/index.js
index 3b97ac62a..9a1e3c6d6 100644
--- a/app/javascript/mastodon/features/list_timeline/index.js
+++ b/app/javascript/mastodon/features/list_timeline/index.js
@@ -8,7 +8,7 @@ import ColumnHeader from '../../components/column_header';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import { connectListStream } from '../../actions/streaming';
-import { refreshListTimeline, expandListTimeline } from '../../actions/timelines';
+import { expandListTimeline } from '../../actions/timelines';
 import { fetchList, deleteList } from '../../actions/lists';
 import { openModal } from '../../actions/modal';
 import MissingIndicator from '../../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. When members of this list post new statuses, they will appear here.' />}
         />
       </Column>
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index cb9d025ea..9a6fb45c8 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -13,6 +13,7 @@ import { createSelector } from 'reselect';
 import { List as ImmutableList } from 'immutable';
 import { debounce } from 'lodash';
 import ScrollableList from '../../components/scrollable_list';
+import LoadMore from '../../components/load_more';
 
 const messages = defineMessages({
   title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@@ -21,13 +22,31 @@ 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'))));
+
+class LoadGap extends React.PureComponent {
+
+  static propTypes = {
+    disabled: PropTypes.bool,
+    maxId: PropTypes.string,
+    onClick: PropTypes.func.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.onClick(this.props.maxId);
+  }
+
+  render () {
+    return <LoadMore onClick={this.handleClick} disabled={this.props.disabled} />;
+  }
+
+}
 
 const mapStateToProps = state => ({
   notifications: getNotifications(state),
   isLoading: state.getIn(['notifications', 'isLoading'], true),
   isUnread: state.getIn(['notifications', 'unread']) > 0,
-  hasMore: !!state.getIn(['notifications', 'next']),
+  hasMore: state.getIn(['notifications', 'hasMore']),
 });
 
 @connect(mapStateToProps)
@@ -51,14 +70,19 @@ export default class Notifications extends React.PureComponent {
   };
 
   componentWillUnmount () {
-    this.handleLoadMore.cancel();
+    this.handleLoadOlder.cancel();
     this.handleScrollToTop.cancel();
     this.handleScroll.cancel();
     this.props.dispatch(scrollTopNotifications(false));
   }
 
-  handleLoadMore = debounce(() => {
-    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(() => {
@@ -93,12 +117,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);
   }
 
@@ -120,7 +144,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}
@@ -142,7 +173,7 @@ export default class Notifications extends React.PureComponent {
         isLoading={isLoading}
         hasMore={hasMore}
         emptyMessage={emptyMessage}
-        onLoadMore={this.handleLoadMore}
+        onLoadMore={this.handleLoadOlder}
         onScrollToTop={this.handleScrollToTop}
         onScroll={this.handleScroll}
         shouldUpdateScroll={shouldUpdateScroll}
diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js
index 193489c63..5a88f7601 100644
--- a/app/javascript/mastodon/features/public_timeline/index.js
+++ b/app/javascript/mastodon/features/public_timeline/index.js
@@ -4,10 +4,7 @@ import PropTypes from 'prop-types';
 import StatusListContainer from '../ui/containers/status_list_container';
 import Column from '../../components/column';
 import ColumnHeader from '../../components/column_header';
-import {
-  refreshPublicTimeline,
-  expandPublicTimeline,
-} from '../../actions/timelines';
+import { expandPublicTimeline } from '../../actions/timelines';
 import { addColumn, removeColumn, moveColumn } from '../../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/mastodon/features/standalone/community_timeline/index.js b/app/javascript/mastodon/features/standalone/community_timeline/index.js
index 51e50e1f5..629d058a2 100644
--- a/app/javascript/mastodon/features/standalone/community_timeline/index.js
+++ b/app/javascript/mastodon/features/standalone/community_timeline/index.js
@@ -2,10 +2,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import StatusListContainer from '../../ui/containers/status_list_container';
-import {
-  refreshCommunityTimeline,
-  expandCommunityTimeline,
-} from '../../../actions/timelines';
+import { expandCommunityTimeline } from '../../../actions/timelines';
 import Column from '../../../components/column';
 import ColumnHeader from '../../../components/column_header';
 import { defineMessages, injectIntl } from 'react-intl';
@@ -35,7 +32,7 @@ export default class CommunityTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(refreshCommunityTimeline());
+    dispatch(expandCommunityTimeline());
     this.disconnect = dispatch(connectCommunityStream());
   }
 
@@ -46,8 +43,8 @@ export default class CommunityTimeline extends React.PureComponent {
     }
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandCommunityTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandCommunityTimeline({ maxId }));
   }
 
   render () {
@@ -63,7 +60,7 @@ export default class CommunityTimeline extends React.PureComponent {
 
         <StatusListContainer
           timelineId='community'
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           scrollKey='standalone_public_timeline'
           trackScroll={false}
         />
diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
index f14be2aaf..931ca2a32 100644
--- a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
+++ b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
@@ -2,10 +2,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import StatusListContainer from '../../ui/containers/status_list_container';
-import {
-  refreshHashtagTimeline,
-  expandHashtagTimeline,
-} from '../../../actions/timelines';
+import { expandHashtagTimeline } from '../../../actions/timelines';
 import Column from '../../../components/column';
 import ColumnHeader from '../../../components/column_header';
 import { connectHashtagStream } from '../../../actions/streaming';
@@ -29,7 +26,7 @@ export default class HashtagTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch, hashtag } = this.props;
 
-    dispatch(refreshHashtagTimeline(hashtag));
+    dispatch(expandHashtagTimeline(hashtag));
     this.disconnect = dispatch(connectHashtagStream(hashtag));
   }
 
@@ -40,8 +37,8 @@ export default class HashtagTimeline extends React.PureComponent {
     }
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandHashtagTimeline(this.props.hashtag));
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
   }
 
   render () {
@@ -59,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/mastodon/features/standalone/public_timeline/index.js b/app/javascript/mastodon/features/standalone/public_timeline/index.js
index 5805d1a10..1236cb927 100644
--- a/app/javascript/mastodon/features/standalone/public_timeline/index.js
+++ b/app/javascript/mastodon/features/standalone/public_timeline/index.js
@@ -2,10 +2,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import StatusListContainer from '../../ui/containers/status_list_container';
-import {
-  refreshPublicTimeline,
-  expandPublicTimeline,
-} from '../../../actions/timelines';
+import { expandPublicTimeline } from '../../../actions/timelines';
 import Column from '../../../components/column';
 import ColumnHeader from '../../../components/column_header';
 import { defineMessages, injectIntl } from 'react-intl';
@@ -35,7 +32,7 @@ export default class PublicTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(refreshPublicTimeline());
+    dispatch(expandPublicTimeline());
     this.disconnect = dispatch(connectPublicStream());
   }
 
@@ -46,8 +43,8 @@ export default class PublicTimeline extends React.PureComponent {
     }
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandPublicTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandPublicTimeline({ maxId }));
   }
 
   render () {
@@ -63,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/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index 20bf21153..4185cba32 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import Base from '../../../components/modal_root';
 import BundleContainer from '../containers/bundle_container';
 import BundleModalError from './bundle_modal_error';
 import ModalLoading from './modal_loading';
@@ -39,56 +40,6 @@ export default class ModalRoot extends React.PureComponent {
     onClose: PropTypes.func.isRequired,
   };
 
-  state = {
-    revealed: false,
-  };
-
-  handleKeyUp = (e) => {
-    if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
-         && !!this.props.type) {
-      this.props.onClose();
-    }
-  }
-
-  componentDidMount () {
-    window.addEventListener('keyup', this.handleKeyUp, false);
-  }
-
-  componentWillReceiveProps (nextProps) {
-    if (!!nextProps.type && !this.props.type) {
-      this.activeElement = document.activeElement;
-
-      this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
-    } else if (!nextProps.type) {
-      this.setState({ revealed: false });
-    }
-  }
-
-  componentDidUpdate (prevProps) {
-    if (!this.props.type && !!prevProps.type) {
-      this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
-      this.activeElement.focus();
-      this.activeElement = null;
-    }
-    if (this.props.type) {
-      requestAnimationFrame(() => {
-        this.setState({ revealed: true });
-      });
-    }
-  }
-
-  componentWillUnmount () {
-    window.removeEventListener('keyup', this.handleKeyUp);
-  }
-
-  getSiblings = () => {
-    return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
-  }
-
-  setRef = ref => {
-    this.node = ref;
-  }
-
   renderLoading = modalId => () => {
     return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
   }
@@ -101,28 +52,16 @@ export default class ModalRoot extends React.PureComponent {
 
   render () {
     const { type, props, onClose } = this.props;
-    const { revealed } = this.state;
     const visible = !!type;
 
-    if (!visible) {
-      return (
-        <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} />
-      );
-    }
-
     return (
-      <div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}>
-        <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
-          <div role='presentation' className='modal-root__overlay' onClick={onClose} />
-          <div role='dialog' className='modal-root__container'>
-            {visible && (
-              <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
-                {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
-              </BundleContainer>
-            )}
-          </div>
-        </div>
-      </div>
+      <Base onClose={onClose}>
+        {visible && (
+          <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
+            {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
+          </BundleContainer>
+        )}
+      </Base>
     );
   }
 
diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js
index 3ae97646f..8a55c553c 100644
--- a/app/javascript/mastodon/features/ui/components/report_modal.js
+++ b/app/javascript/mastodon/features/ui/components/report_modal.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import { changeReportComment, changeReportForward, submitReport } from '../../../actions/reports';
-import { refreshAccountTimeline } from '../../../actions/timelines';
+import { expandAccountTimeline } from '../../../actions/timelines';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { makeGetAccount } from '../../../selectors';
@@ -64,12 +64,12 @@ export default class ReportModal extends ImmutablePureComponent {
   }
 
   componentDidMount () {
-    this.props.dispatch(refreshAccountTimeline(this.props.account.get('id')));
+    this.props.dispatch(expandAccountTimeline(this.props.account.get('id')));
   }
 
   componentWillReceiveProps (nextProps) {
     if (this.props.account !== nextProps.account && nextProps.account) {
-      this.props.dispatch(refreshAccountTimeline(nextProps.account.get('id')));
+      this.props.dispatch(expandAccountTimeline(nextProps.account.get('id')));
     }
   }
 
diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js
index 5924197f1..b60a0216f 100644
--- a/app/javascript/mastodon/features/ui/containers/notifications_container.js
+++ b/app/javascript/mastodon/features/ui/containers/notifications_container.js
@@ -1,11 +1,22 @@
+import { injectIntl } from 'react-intl';
 import { connect } from 'react-redux';
 import { NotificationStack } from 'react-notification';
 import { dismissAlert } from '../../../actions/alerts';
 import { getAlerts } from '../../../selectors';
 
-const mapStateToProps = state => ({
-  notifications: getAlerts(state),
-});
+const mapStateToProps = (state, { intl }) => {
+  const notifications = getAlerts(state);
+
+  notifications.forEach(notification => ['title', 'message'].forEach(key => {
+    const value = notification[key];
+
+    if (typeof value === 'object') {
+      notification[key] = intl.formatMessage(value);
+    }
+  }));
+
+  return { notifications };
+};
 
 const mapDispatchToProps = (dispatch) => {
   return {
@@ -15,4 +26,4 @@ const mapDispatchToProps = (dispatch) => {
   };
 };
 
-export default connect(mapStateToProps, mapDispatchToProps)(NotificationStack);
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack));
diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js
index fc2867cf0..4efacda65 100644
--- a/app/javascript/mastodon/features/ui/containers/status_list_container.js
+++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js
@@ -48,15 +48,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 }) => ({
-
-  onLoadMore: debounce(loadMore, 300, { leading: true }),
+const mapDispatchToProps = (dispatch, { timelineId }) => ({
 
   onScrollToTop: debounce(() => {
     dispatch(scrollTopTimeline(timelineId, true));
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 6cf00222a..b6a2a6cfc 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -10,8 +10,8 @@ import { Redirect, withRouter } from 'react-router-dom';
 import { isMobile } from '../../is_mobile';
 import { debounce } from 'lodash';
 import { uploadCompose, resetCompose } from '../../actions/compose';
-import { refreshHomeTimeline } from '../../actions/timelines';
-import { refreshNotifications } from '../../actions/notifications';
+import { expandHomeTimeline } from '../../actions/timelines';
+import { expandNotifications } from '../../actions/notifications';
 import { clearHeight } from '../../actions/height_cache';
 import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
 import UploadArea from './components/upload_area';
@@ -284,8 +284,8 @@ export default class UI extends React.PureComponent {
       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/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index 73680a1a1..3d9620793 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -28,6 +28,8 @@
   "account.unmute": "إلغاء الكتم عن @{name}",
   "account.unmute_notifications": "إلغاء كتم إخطارات @{name}",
   "account.view_full_profile": "عرض الملف الشخصي كاملا",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "يمكنك ضغط {combo} لتخطّي هذه في المرّة القادمة",
   "bundle_column_error.body": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.",
   "bundle_column_error.retry": "إعادة المحاولة",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index 1dee16748..39eb05f2a 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -28,6 +28,8 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
   "bundle_column_error.retry": "Try again",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 4923c1032..33545d86f 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -1,9 +1,9 @@
 {
   "account.block": "Bloca @{name}",
   "account.block_domain": "Amaga-ho tot de {domain}",
-  "account.blocked": "Blocked",
+  "account.blocked": "Bloquejat",
   "account.disclaimer_full": "La informació següent pot reflectir incompleta el perfil de l'usuari.",
-  "account.domain_blocked": "Domain hidden",
+  "account.domain_blocked": "Domini ocult",
   "account.edit_profile": "Edita el perfil",
   "account.follow": "Segueix",
   "account.followers": "Seguidors",
@@ -15,7 +15,7 @@
   "account.moved_to": "{name} s'ha mogut a:",
   "account.mute": "Silencia @{name}",
   "account.mute_notifications": "Notificacions desactivades de @{name}",
-  "account.muted": "Muted",
+  "account.muted": "Silenciat",
   "account.posts": "Toots",
   "account.posts_with_replies": "Toots amb respostes",
   "account.report": "Informe @{name}",
@@ -28,6 +28,8 @@
   "account.unmute": "Treure silenci de @{name}",
   "account.unmute_notifications": "Activar notificacions de @{name}",
   "account.view_full_profile": "Mostra el perfil complet",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Pots premer {combo} per saltar-te això el proper cop",
   "bundle_column_error.body": "S'ha produït un error en carregar aquest component.",
   "bundle_column_error.retry": "Torna-ho a provar",
@@ -60,10 +62,10 @@
   "compose_form.placeholder": "En què estàs pensant?",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.marked": "Media is marked as sensitive",
-  "compose_form.sensitive.unmarked": "Media is not marked as sensitive",
-  "compose_form.spoiler.marked": "Text is hidden behind warning",
-  "compose_form.spoiler.unmarked": "Text is not hidden",
+  "compose_form.sensitive.marked": "Mèdia marcat com a sensible",
+  "compose_form.sensitive.unmarked": "Mèdia no està marcat com a sensible",
+  "compose_form.spoiler.marked": "Text ocult sota l'avís",
+  "compose_form.spoiler.unmarked": "Text no ocult",
   "compose_form.spoiler_placeholder": "Escriu l'avís aquí",
   "confirmation_modal.cancel": "Cancel·la",
   "confirmations.block.confirm": "Bloca",
@@ -221,7 +223,7 @@
   "report.target": "Informes",
   "search.placeholder": "Cercar",
   "search_popout.search_format": "Format de cerca avançada",
-  "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
+  "search_popout.tips.full_text": "Text simple recupera publicacions que has escrit, les marcades com a favorites, les impulsades o en les que has estat esmentat, així com usuaris, noms d'usuari i etiquetes.",
   "search_popout.tips.hashtag": "etiqueta",
   "search_popout.tips.status": "status",
   "search_popout.tips.text": "El text simple retorna coincidències amb els noms de visualització, els noms d'usuari i els hashtags",
@@ -244,7 +246,7 @@
   "status.mute_conversation": "Silenciar conversació",
   "status.open": "Ampliar aquest estat",
   "status.pin": "Fixat en el perfil",
-  "status.pinned": "Pinned toot",
+  "status.pinned": "Toot fixat",
   "status.reblog": "Impuls",
   "status.reblogged_by": "{name} ha retootejat",
   "status.reply": "Respondre",
@@ -254,9 +256,9 @@
   "status.sensitive_warning": "Contingut sensible",
   "status.share": "Compartir",
   "status.show_less": "Mostra menys",
-  "status.show_less_all": "Show less for all",
+  "status.show_less_all": "Mostra menys per a tot",
   "status.show_more": "Mostra més",
-  "status.show_more_all": "Show more for all",
+  "status.show_more_all": "Mostra més per a tot",
   "status.unmute_conversation": "Activar conversació",
   "status.unpin": "Deslliga del perfil",
   "tabs_bar.federated_timeline": "Federada",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index e0fc0ee85..7bdb6a3c6 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -28,6 +28,8 @@
   "account.unmute": "@{name} nicht mehr stummschalten",
   "account.unmute_notifications": "Benachrichtigungen von @{name} einschalten",
   "account.view_full_profile": "Vollständiges Profil anzeigen",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen",
   "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.",
   "bundle_column_error.retry": "Erneut versuchen",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index b61fc5eaf..2120009ac 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -326,7 +326,7 @@
         "id": "account.posts"
       },
       {
-        "defaultMessage": "Toots with replies",
+        "defaultMessage": "Toots and replies",
         "id": "account.posts_with_replies"
       },
       {
@@ -1747,5 +1747,18 @@
       }
     ],
     "path": "app/javascript/mastodon/features/video/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Oops!",
+        "id": "alert.unexpected.title"
+      },
+      {
+        "defaultMessage": "An unexpected error occurred.",
+        "id": "alert.unexpected.message"
+      }
+    ],
+    "path": "app/javascript/mastodon/middleware/errors.json"
   }
 ]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 67453e170..d2133b1f6 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -17,7 +17,7 @@
   "account.mute_notifications": "Mute notifications from @{name}",
   "account.muted": "Muted",
   "account.posts": "Toots",
-  "account.posts_with_replies": "Toots with replies",
+  "account.posts_with_replies": "Toots and replies",
   "account.report": "Report @{name}",
   "account.requested": "Awaiting approval. Click to cancel follow request",
   "account.share": "Share @{name}'s profile",
@@ -28,6 +28,8 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
   "bundle_column_error.retry": "Try again",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index fd687e8b1..35d9edf2b 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -28,6 +28,8 @@
   "account.unmute": "Malsilentigi @{name}",
   "account.unmute_notifications": "Malsilentigi sciigojn de @{name}",
   "account.view_full_profile": "Vidi plenan profilon",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Vi povas premi {combo} por preterpasi sekvafoje",
   "bundle_column_error.body": "Io misfunkciis en la ŝargado de ĉi tiu elemento.",
   "bundle_column_error.retry": "Bonvolu reprovi",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index 2107a1525..e69938b0f 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -28,6 +28,8 @@
   "account.unmute": "Dejar de silenciar a @{name}",
   "account.unmute_notifications": "Dejar de silenciar las notificaciones de @{name}",
   "account.view_full_profile": "Ver perfil completo",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Puedes presionar {combo} para saltear este aviso la próxima vez",
   "bundle_column_error.body": "Algo salió mal al cargar este componente.",
   "bundle_column_error.retry": "Inténtalo de nuevo",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 455dc5d9f..c9695d0a4 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -28,6 +28,8 @@
   "account.unmute": "باصدا کردن @{name}",
   "account.unmute_notifications": "باصداکردن اعلان‌ها از طرف @{name}",
   "account.view_full_profile": "نمایش نمایهٔ کامل",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید",
   "bundle_column_error.body": "هنگام بازکردن این بخش خطایی رخ داد.",
   "bundle_column_error.retry": "تلاش دوباره",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index 1741445ed..cbdffec10 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -28,6 +28,8 @@
   "account.unmute": "Poista mykistys käyttäjältä @{name}",
   "account.unmute_notifications": "Poista mykistys käyttäjän @{name} ilmoituksilta",
   "account.view_full_profile": "Näytä koko profiili",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Voit painaa näppäimiä {combo} ohittaaksesi tämän ensi kerralla",
   "bundle_column_error.body": "Jokin meni vikaan tätä komponenttia ladatessa.",
   "bundle_column_error.retry": "Yritä uudestaan",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 40fd6163e..8c56a7558 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -28,6 +28,8 @@
   "account.unmute": "Ne plus masquer",
   "account.unmute_notifications": "Réactiver les notifications de @{name}",
   "account.view_full_profile": "Afficher le profil complet",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Vous pouvez appuyer sur {combo} pour pouvoir passer ceci, la prochaine fois",
   "bundle_column_error.body": "Une erreur s’est produite lors du chargement de ce composant.",
   "bundle_column_error.retry": "Réessayer",
diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json
index edfb9cfcb..c5cedd60a 100644
--- a/app/javascript/mastodon/locales/gl.json
+++ b/app/javascript/mastodon/locales/gl.json
@@ -28,6 +28,8 @@
   "account.unmute": "Non acalar @{name}",
   "account.unmute_notifications": "Desbloquear as notificacións de @{name}",
   "account.view_full_profile": "Ver o perfil completo",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Pulse {combo} para saltar esto a próxima vez",
   "bundle_column_error.body": "Houbo un fallo mentras se cargaba este compoñente.",
   "bundle_column_error.retry": "Inténteo de novo",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index b637ae414..fe6f9bbb1 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -28,6 +28,8 @@
   "account.unmute": "הפסקת השתקת @{name}",
   "account.unmute_notifications": "להפסיק הסתרת הודעות מעם @{name}",
   "account.view_full_profile": "הראה אודות מלאות",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "ניתן להקיש {combo} כדי לדלג בפעם הבאה",
   "bundle_column_error.body": "משהו השתבש בעת הצגת הרכיב הזה.",
   "bundle_column_error.retry": "לנסות שוב",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index 4b64d796d..11cd1bff2 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -28,6 +28,8 @@
   "account.unmute": "Poništi utišavanje @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Možeš pritisnuti {combo} kako bi ovo preskočio sljedeći put",
   "bundle_column_error.body": "Something went wrong while loading this component.",
   "bundle_column_error.retry": "Try again",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index 79888e41e..1ea65768a 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -28,6 +28,8 @@
   "account.unmute": "@{name} kinémítása",
   "account.unmute_notifications": "@{name} értesítéseinek kinémítása",
   "account.view_full_profile": "Teljes profil megtekintése",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Megnyomhatod {combo}, hogy átugord következő alkalommal",
   "bundle_column_error.body": "Hiba történt a komponens betöltése közben.",
   "bundle_column_error.retry": "Próbálja újra",
diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json
index 932ff1565..e9638bf96 100644
--- a/app/javascript/mastodon/locales/hy.json
+++ b/app/javascript/mastodon/locales/hy.json
@@ -28,6 +28,8 @@
   "account.unmute": "Ապալռեցնել @{name}֊ին",
   "account.unmute_notifications": "Միացնել ծանուցումները @{name}֊ից",
   "account.view_full_profile": "Դիտել ամբողջական տարբերակը։",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Կարող ես սեղմել {combo}՝ սա հաջորդ անգամ բաց թողնելու համար",
   "bundle_column_error.body": "Այս բաղադրիչը բեռնելու ընթացքում ինչ֊որ բան խափանվեց։",
   "bundle_column_error.retry": "Կրկին փորձել",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index bc4294679..c8d8ebe76 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -28,6 +28,8 @@
   "account.unmute": "Berhenti membisukan @{name}",
   "account.unmute_notifications": "Munculkan notifikasi dari @{name}",
   "account.view_full_profile": "Lihat profil lengkap",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Anda dapat menekan {combo} untuk melewati ini",
   "bundle_column_error.body": "Kesalahan terjadi saat memuat komponen ini.",
   "bundle_column_error.retry": "Coba lagi",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index 5ea982f46..a2e9af8ef 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -28,6 +28,8 @@
   "account.unmute": "Ne plus celar @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Tu povas presar sur {combo} por omisar co en la venonta foyo",
   "bundle_column_error.body": "Something went wrong while loading this component.",
   "bundle_column_error.retry": "Try again",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 068598de2..40ea9b26d 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -28,6 +28,8 @@
   "account.unmute": "Non silenziare @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Puoi premere {combo} per saltare questo passaggio la prossima volta",
   "bundle_column_error.body": "Something went wrong while loading this component.",
   "bundle_column_error.retry": "Try again",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 3bf00fbc3..2e55af510 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -28,6 +28,8 @@
   "account.unmute": "@{name}さんのミュートを解除",
   "account.unmute_notifications": "@{name}さんからの通知を受け取る",
   "account.view_full_profile": "全ての情報を見る",
+  "alert.unexpected.message": "不明なエラーが発生しました",
+  "alert.unexpected.title": "エラー",
   "boost_modal.combo": "次からは{combo}を押せばスキップできます",
   "bundle_column_error.body": "コンポーネントの読み込み中に問題が発生しました。",
   "bundle_column_error.retry": "再試行",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 532c1f04d..bde4397f3 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -28,6 +28,8 @@
   "account.unmute": "뮤트 해제",
   "account.unmute_notifications": "@{name}의 알림 뮤트 해제",
   "account.view_full_profile": "전체 프로필 보기",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "다음부터 {combo}를 누르면 이 과정을 건너뛸 수 있습니다.",
   "bundle_column_error.body": "컴포넌트를 불러오는 과정에서 문제가 발생했습니다.",
   "bundle_column_error.retry": "다시 시도",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index a83971f00..140be0dca 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -28,6 +28,8 @@
   "account.unmute": "@{name} niet meer negeren",
   "account.unmute_notifications": "@{name} meldingen niet meer negeren",
   "account.view_full_profile": "Volledig profiel tonen",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Je kunt {combo} klikken om dit de volgende keer over te slaan",
   "bundle_column_error.body": "Tijdens het laden van dit onderdeel is er iets fout gegaan.",
   "bundle_column_error.retry": "Opnieuw proberen",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index aaad033e2..4d6ac133e 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -28,6 +28,8 @@
   "account.unmute": "Avdemp @{name}",
   "account.unmute_notifications": "Vis varsler fra @{name}",
   "account.view_full_profile": "Vis hele profilen",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "You kan trykke {combo} for å hoppe over dette neste gang",
   "bundle_column_error.body": "Noe gikk galt mens denne komponenten lastet.",
   "bundle_column_error.retry": "Prøv igjen",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index f93fe29f6..24dfa9375 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -28,6 +28,8 @@
   "account.unmute": "Quitar de rescondre @{name}",
   "account.unmute_notifications": "Mostrar las notificacions de @{name}",
   "account.view_full_profile": "Veire lo perfil complèt",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Podètz botar {combo} per passar aquò lo còp que ven",
   "bundle_column_error.body": "Quicòm a fach mèuca pendent lo cargament d’aqueste compausant.",
   "bundle_column_error.retry": "Tornar ensajar",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index 3feb44ad9..dcd2d12b3 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -28,6 +28,8 @@
   "account.unmute": "Cofnij wyciszenie @{name}",
   "account.unmute_notifications": "Cofnij wyciszenie powiadomień od @{name}",
   "account.view_full_profile": "Wyświetl pełny profil",
+  "alert.unexpected.message": "Wystąpił nieoczekiwany błąd.",
+  "alert.unexpected.title": "O nie!",
   "boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem",
   "bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.",
   "bundle_column_error.retry": "Spróbuj ponownie",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index c90fb37a0..dcaeaced9 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -28,6 +28,8 @@
   "account.unmute": "Não silenciar @{name}",
   "account.unmute_notifications": "Retirar silêncio das notificações vindas de @{name}",
   "account.view_full_profile": "Ver perfil completo",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Você pode pressionar {combo} para ignorar este diálogo na próxima vez",
   "bundle_column_error.body": "Algo de errado aconteceu enquanto este componente era carregado.",
   "bundle_column_error.retry": "Tente novamente",
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index 3b20cf4e6..4725a82da 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -28,6 +28,8 @@
   "account.unmute": "Não silenciar @{name}",
   "account.unmute_notifications": "Deixar de silenciar @{name}",
   "account.view_full_profile": "Ver perfil completo",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Pode clicar {combo} para não voltar a ver",
   "bundle_column_error.body": "Algo de errado aconteceu enquanto este componente era carregado.",
   "bundle_column_error.retry": "Tente de novo",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index ec21b5d55..8e7d36659 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -28,6 +28,8 @@
   "account.unmute": "Снять глушение",
   "account.unmute_notifications": "Показывать уведомления от @{name}",
   "account.view_full_profile": "Показать полный профиль",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз",
   "bundle_column_error.body": "Что-то пошло не так при загрузке этого компонента.",
   "bundle_column_error.retry": "Попробовать снова",
diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json
index 683f2aadb..e3b323943 100644
--- a/app/javascript/mastodon/locales/sk.json
+++ b/app/javascript/mastodon/locales/sk.json
@@ -28,6 +28,8 @@
   "account.unmute": "Prestať ignorovať @{name}",
   "account.unmute_notifications": "Odtĺmiť notifikácie od @{name}",
   "account.view_full_profile": "Pozri celý profil",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Nabudúce môžete kliknúť {combo} aby ste preskočili",
   "bundle_column_error.body": "Nastala chyba pri načítaní tohto komponentu.",
   "bundle_column_error.retry": "Skúste znova",
diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json
index c6512cda4..d38e8e3af 100644
--- a/app/javascript/mastodon/locales/sr-Latn.json
+++ b/app/javascript/mastodon/locales/sr-Latn.json
@@ -28,6 +28,8 @@
   "account.unmute": "Ukloni ućutkavanje korisniku @{name}",
   "account.unmute_notifications": "Uključi nazad obaveštenja od korisnika @{name}",
   "account.view_full_profile": "Vidi ceo profil",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Možete pritisnuti {combo} da preskočite ovo sledeći put",
   "bundle_column_error.body": "Nešto je pošlo po zlu prilikom učitavanja ove komponente.",
   "bundle_column_error.retry": "Pokušajte ponovo",
diff --git a/app/javascript/mastodon/locales/sr.json b/app/javascript/mastodon/locales/sr.json
index 93fbe5960..3be0c89ee 100644
--- a/app/javascript/mastodon/locales/sr.json
+++ b/app/javascript/mastodon/locales/sr.json
@@ -28,6 +28,8 @@
   "account.unmute": "Уклони ућуткавање кориснику @{name}",
   "account.unmute_notifications": "Укључи назад обавештења од корисника @{name}",
   "account.view_full_profile": "Види цео профил",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Можете притиснути {combo} да прескочите ово следећи пут",
   "bundle_column_error.body": "Нешто је пошло по злу приликом учитавања ове компоненте.",
   "bundle_column_error.retry": "Покушајте поново",
diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json
index 4fa129173..a13ba9847 100644
--- a/app/javascript/mastodon/locales/sv.json
+++ b/app/javascript/mastodon/locales/sv.json
@@ -28,6 +28,8 @@
   "account.unmute": "Ta bort tystad @{name}",
   "account.unmute_notifications": "Återaktivera notifikationer från @{name}",
   "account.view_full_profile": "Visa hela profilen",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Du kan trycka {combo} för att slippa denna nästa gång",
   "bundle_column_error.body": "Något gick fel när du laddade denna komponent.",
   "bundle_column_error.retry": "Försök igen",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index 95a933b40..59ff10b46 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -28,6 +28,8 @@
   "account.unmute": "Unmute @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
   "bundle_column_error.retry": "Try again",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index baaa5c97a..e83af319e 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -28,6 +28,8 @@
   "account.unmute": "Sesi aç @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Bir dahaki sefere {combo} tuşuna basabilirsiniz",
   "bundle_column_error.body": "Something went wrong while loading this component.",
   "bundle_column_error.retry": "Try again",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index 1755c55b4..accc2d027 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -28,6 +28,8 @@
   "account.unmute": "Зняти глушення",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Ви можете натиснути {combo}, щоб пропустити це наступного разу",
   "bundle_column_error.body": "Something went wrong while loading this component.",
   "bundle_column_error.retry": "Try again",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index d031c85f3..b9a912fb0 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -28,6 +28,8 @@
   "account.unmute": "不再隐藏 @{name}",
   "account.unmute_notifications": "不再隐藏来自 @{name} 的通知",
   "account.view_full_profile": "查看完整资料",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "下次按住 {combo} 即可跳过此提示",
   "bundle_column_error.body": "载入这个组件时发生了错误。",
   "bundle_column_error.retry": "重试",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index d3ad238ad..91b1d00af 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -28,6 +28,8 @@
   "account.unmute": "取消 @{name} 的靜音",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "查看完整資料",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "如你想在下次路過這顯示,請按{combo},",
   "bundle_column_error.body": "加載本組件出錯。",
   "bundle_column_error.retry": "重試",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 3a5eade41..7e845c650 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -28,6 +28,8 @@
   "account.unmute": "不再消音 @{name}",
   "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "查看完整資訊",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "下次你可以按 {combo} 來跳過",
   "bundle_column_error.body": "加載本組件出錯。",
   "bundle_column_error.retry": "重試",
diff --git a/app/javascript/mastodon/middleware/errors.js b/app/javascript/mastodon/middleware/errors.js
index b2c5f0898..72e5631e6 100644
--- a/app/javascript/mastodon/middleware/errors.js
+++ b/app/javascript/mastodon/middleware/errors.js
@@ -1,7 +1,13 @@
+import { defineMessages } from 'react-intl';
 import { showAlert } from '../actions/alerts';
 
 const defaultFailSuffix = 'FAIL';
 
+const messages = defineMessages({
+  unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
+  unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
+});
+
 export default function errorsMiddleware() {
   return ({ dispatch }) => next => action => {
     if (action.type && !action.skipAlert) {
@@ -21,7 +27,7 @@ export default function errorsMiddleware() {
           dispatch(showAlert(title, message));
         } else {
           console.error(action.error);
-          dispatch(showAlert('Oops!', 'An unexpected error occurred.'));
+          dispatch(showAlert(messages.unexpectedTitle, messages.unexpectedMessage));
         }
       }
     }
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js
index 47e6d2330..530ed8e60 100644
--- a/app/javascript/mastodon/reducers/accounts.js
+++ b/app/javascript/mastodon/reducers/accounts.js
@@ -1,56 +1,7 @@
-import {
-  ACCOUNT_FETCH_SUCCESS,
-  FOLLOWERS_FETCH_SUCCESS,
-  FOLLOWERS_EXPAND_SUCCESS,
-  FOLLOWING_FETCH_SUCCESS,
-  FOLLOWING_EXPAND_SUCCESS,
-  FOLLOW_REQUESTS_FETCH_SUCCESS,
-  FOLLOW_REQUESTS_EXPAND_SUCCESS,
-} from '../actions/accounts';
-import {
-  BLOCKS_FETCH_SUCCESS,
-  BLOCKS_EXPAND_SUCCESS,
-} from '../actions/blocks';
-import {
-  MUTES_FETCH_SUCCESS,
-  MUTES_EXPAND_SUCCESS,
-} from '../actions/mutes';
-import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
-import {
-  REBLOG_SUCCESS,
-  UNREBLOG_SUCCESS,
-  FAVOURITE_SUCCESS,
-  UNFAVOURITE_SUCCESS,
-  REBLOGS_FETCH_SUCCESS,
-  FAVOURITES_FETCH_SUCCESS,
-} from '../actions/interactions';
-import {
-  TIMELINE_REFRESH_SUCCESS,
-  TIMELINE_UPDATE,
-  TIMELINE_EXPAND_SUCCESS,
-} from '../actions/timelines';
-import {
-  STATUS_FETCH_SUCCESS,
-  CONTEXT_FETCH_SUCCESS,
-} from '../actions/statuses';
-import { SEARCH_FETCH_SUCCESS } from '../actions/search';
-import {
-  NOTIFICATIONS_UPDATE,
-  NOTIFICATIONS_REFRESH_SUCCESS,
-  NOTIFICATIONS_EXPAND_SUCCESS,
-} from '../actions/notifications';
-import {
-  FAVOURITED_STATUSES_FETCH_SUCCESS,
-  FAVOURITED_STATUSES_EXPAND_SUCCESS,
-} from '../actions/favourites';
-import {
-  LIST_ACCOUNTS_FETCH_SUCCESS,
-  LIST_EDITOR_SUGGESTIONS_READY,
-} from '../actions/lists';
-import { STORE_HYDRATE } from '../actions/store';
-import emojify from '../features/emoji/emoji';
+import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
 import { Map as ImmutableMap, fromJS } from 'immutable';
-import escapeTextContentForBrowser from 'escape-html';
+
+const initialState = ImmutableMap();
 
 const normalizeAccount = (state, account) => {
   account = { ...account };
@@ -59,15 +10,6 @@ const normalizeAccount = (state, account) => {
   delete account.following_count;
   delete account.statuses_count;
 
-  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);
-
-  if (account.moved) {
-    state = normalizeAccount(state, account.moved);
-    account.moved = account.moved.id;
-  }
-
   return state.set(account.id, fromJS(account));
 };
 
@@ -79,67 +21,12 @@ const normalizeAccounts = (state, accounts) => {
   return state;
 };
 
-const normalizeAccountFromStatus = (state, status) => {
-  state = normalizeAccount(state, status.account);
-
-  if (status.reblog && status.reblog.account) {
-    state = normalizeAccount(state, status.reblog.account);
-  }
-
-  return state;
-};
-
-const normalizeAccountsFromStatuses = (state, statuses) => {
-  statuses.forEach(status => {
-    state = normalizeAccountFromStatus(state, status);
-  });
-
-  return state;
-};
-
-const initialState = ImmutableMap();
-
 export default function accounts(state = initialState, action) {
   switch(action.type) {
-  case STORE_HYDRATE:
-    return normalizeAccounts(state, Object.values(action.state.get('accounts').toJS()));
-  case ACCOUNT_FETCH_SUCCESS:
-  case NOTIFICATIONS_UPDATE:
+  case ACCOUNT_IMPORT:
     return normalizeAccount(state, action.account);
-  case FOLLOWERS_FETCH_SUCCESS:
-  case FOLLOWERS_EXPAND_SUCCESS:
-  case FOLLOWING_FETCH_SUCCESS:
-  case FOLLOWING_EXPAND_SUCCESS:
-  case REBLOGS_FETCH_SUCCESS:
-  case FAVOURITES_FETCH_SUCCESS:
-  case COMPOSE_SUGGESTIONS_READY:
-  case FOLLOW_REQUESTS_FETCH_SUCCESS:
-  case FOLLOW_REQUESTS_EXPAND_SUCCESS:
-  case BLOCKS_FETCH_SUCCESS:
-  case BLOCKS_EXPAND_SUCCESS:
-  case MUTES_FETCH_SUCCESS:
-  case MUTES_EXPAND_SUCCESS:
-  case LIST_ACCOUNTS_FETCH_SUCCESS:
-  case LIST_EDITOR_SUGGESTIONS_READY:
-    return action.accounts ? normalizeAccounts(state, action.accounts) : state;
-  case NOTIFICATIONS_REFRESH_SUCCESS:
-  case NOTIFICATIONS_EXPAND_SUCCESS:
-  case SEARCH_FETCH_SUCCESS:
-    return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
-  case TIMELINE_REFRESH_SUCCESS:
-  case TIMELINE_EXPAND_SUCCESS:
-  case CONTEXT_FETCH_SUCCESS:
-  case FAVOURITED_STATUSES_FETCH_SUCCESS:
-  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
-    return normalizeAccountsFromStatuses(state, action.statuses);
-  case REBLOG_SUCCESS:
-  case FAVOURITE_SUCCESS:
-  case UNREBLOG_SUCCESS:
-  case UNFAVOURITE_SUCCESS:
-    return normalizeAccountFromStatus(state, action.response);
-  case TIMELINE_UPDATE:
-  case STATUS_FETCH_SUCCESS:
-    return normalizeAccountFromStatus(state, action.status);
+  case ACCOUNTS_IMPORT:
+    return normalizeAccounts(state, action.accounts);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js
index a93fa4245..9ebf72af9 100644
--- a/app/javascript/mastodon/reducers/accounts_counters.js
+++ b/app/javascript/mastodon/reducers/accounts_counters.js
@@ -1,55 +1,8 @@
 import {
-  ACCOUNT_FETCH_SUCCESS,
-  FOLLOWERS_FETCH_SUCCESS,
-  FOLLOWERS_EXPAND_SUCCESS,
-  FOLLOWING_FETCH_SUCCESS,
-  FOLLOWING_EXPAND_SUCCESS,
-  FOLLOW_REQUESTS_FETCH_SUCCESS,
-  FOLLOW_REQUESTS_EXPAND_SUCCESS,
   ACCOUNT_FOLLOW_SUCCESS,
   ACCOUNT_UNFOLLOW_SUCCESS,
 } from '../actions/accounts';
-import {
-  BLOCKS_FETCH_SUCCESS,
-  BLOCKS_EXPAND_SUCCESS,
-} from '../actions/blocks';
-import {
-  MUTES_FETCH_SUCCESS,
-  MUTES_EXPAND_SUCCESS,
-} from '../actions/mutes';
-import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
-import {
-  REBLOG_SUCCESS,
-  UNREBLOG_SUCCESS,
-  FAVOURITE_SUCCESS,
-  UNFAVOURITE_SUCCESS,
-  REBLOGS_FETCH_SUCCESS,
-  FAVOURITES_FETCH_SUCCESS,
-} from '../actions/interactions';
-import {
-  TIMELINE_REFRESH_SUCCESS,
-  TIMELINE_UPDATE,
-  TIMELINE_EXPAND_SUCCESS,
-} from '../actions/timelines';
-import {
-  STATUS_FETCH_SUCCESS,
-  CONTEXT_FETCH_SUCCESS,
-} from '../actions/statuses';
-import { SEARCH_FETCH_SUCCESS } from '../actions/search';
-import {
-  NOTIFICATIONS_UPDATE,
-  NOTIFICATIONS_REFRESH_SUCCESS,
-  NOTIFICATIONS_EXPAND_SUCCESS,
-} from '../actions/notifications';
-import {
-  FAVOURITED_STATUSES_FETCH_SUCCESS,
-  FAVOURITED_STATUSES_EXPAND_SUCCESS,
-} from '../actions/favourites';
-import {
-  LIST_ACCOUNTS_FETCH_SUCCESS,
-  LIST_EDITOR_SUGGESTIONS_READY,
-} from '../actions/lists';
-import { STORE_HYDRATE } from '../actions/store';
+import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
 import { Map as ImmutableMap, fromJS } from 'immutable';
 
 const normalizeAccount = (state, account) => state.set(account.id, fromJS({
@@ -66,71 +19,14 @@ const normalizeAccounts = (state, accounts) => {
   return state;
 };
 
-const normalizeAccountFromStatus = (state, status) => {
-  state = normalizeAccount(state, status.account);
-
-  if (status.reblog && status.reblog.account) {
-    state = normalizeAccount(state, status.reblog.account);
-  }
-
-  return state;
-};
-
-const normalizeAccountsFromStatuses = (state, statuses) => {
-  statuses.forEach(status => {
-    state = normalizeAccountFromStatus(state, status);
-  });
-
-  return state;
-};
-
 const initialState = ImmutableMap();
 
 export default function accountsCounters(state = initialState, action) {
   switch(action.type) {
-  case STORE_HYDRATE:
-    return state.merge(action.state.get('accounts').map(item => fromJS({
-      followers_count: item.get('followers_count'),
-      following_count: item.get('following_count'),
-      statuses_count: item.get('statuses_count'),
-    })));
-  case ACCOUNT_FETCH_SUCCESS:
-  case NOTIFICATIONS_UPDATE:
+  case ACCOUNT_IMPORT:
     return normalizeAccount(state, action.account);
-  case FOLLOWERS_FETCH_SUCCESS:
-  case FOLLOWERS_EXPAND_SUCCESS:
-  case FOLLOWING_FETCH_SUCCESS:
-  case FOLLOWING_EXPAND_SUCCESS:
-  case REBLOGS_FETCH_SUCCESS:
-  case FAVOURITES_FETCH_SUCCESS:
-  case COMPOSE_SUGGESTIONS_READY:
-  case FOLLOW_REQUESTS_FETCH_SUCCESS:
-  case FOLLOW_REQUESTS_EXPAND_SUCCESS:
-  case BLOCKS_FETCH_SUCCESS:
-  case BLOCKS_EXPAND_SUCCESS:
-  case MUTES_FETCH_SUCCESS:
-  case MUTES_EXPAND_SUCCESS:
-  case LIST_ACCOUNTS_FETCH_SUCCESS:
-  case LIST_EDITOR_SUGGESTIONS_READY:
-    return action.accounts ? normalizeAccounts(state, action.accounts) : state;
-  case NOTIFICATIONS_REFRESH_SUCCESS:
-  case NOTIFICATIONS_EXPAND_SUCCESS:
-  case SEARCH_FETCH_SUCCESS:
-    return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
-  case TIMELINE_REFRESH_SUCCESS:
-  case TIMELINE_EXPAND_SUCCESS:
-  case CONTEXT_FETCH_SUCCESS:
-  case FAVOURITED_STATUSES_FETCH_SUCCESS:
-  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
-    return normalizeAccountsFromStatuses(state, action.statuses);
-  case REBLOG_SUCCESS:
-  case FAVOURITE_SUCCESS:
-  case UNREBLOG_SUCCESS:
-  case UNFAVOURITE_SUCCESS:
-    return normalizeAccountFromStatus(state, action.response);
-  case TIMELINE_UPDATE:
-  case STATUS_FETCH_SUCCESS:
-    return normalizeAccountFromStatus(state, action.status);
+  case ACCOUNTS_IMPORT:
+    return normalizeAccounts(state, action.accounts);
   case ACCOUNT_FOLLOW_SUCCESS:
     return action.alreadyFollowing ? state :
       state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index 264db4f55..f023984b8 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/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,
@@ -13,16 +10,15 @@ import {
   ACCOUNT_BLOCK_SUCCESS,
   ACCOUNT_MUTE_SUCCESS,
 } from '../actions/accounts';
-import { TIMELINE_DELETE } from '../actions/timelines';
+import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
 const initialState = ImmutableMap({
   items: ImmutableList(),
-  next: null,
+  hasMore: true,
   top: true,
   unread: 0,
-  loaded: false,
-  isLoading: true,
+  isLoading: false,
 });
 
 const notificationToMap = notification => ImmutableMap({
@@ -48,35 +44,41 @@ const normalizeNotification = (state, notification) => {
   });
 };
 
-const normalizeNotifications = (state, notifications, next) => {
-  let items    = ImmutableList();
-  const loaded = state.get('loaded');
+const newer = (m, n) => {
+  const mId = m.get('id');
+  const nId = n.get('id');
 
-  notifications.forEach((n, i) => {
-    items = items.set(i, notificationToMap(n));
-  });
-
-  if (state.get('next') === null) {
-    state = state.set('next', next);
-  }
-
-  return state
-    .update('items', list => loaded ? items.concat(list) : list.concat(items))
-    .set('loaded', true)
-    .set('isLoading', false);
+  return mId.length === nId.length ? mId > nId : mId.length > nId.length;
 };
 
-const appendNormalizedNotifications = (state, notifications, next) => {
+const expandNormalizedNotifications = (state, notifications, next) => {
   let items = ImmutableList();
 
   notifications.forEach((n, i) => {
     items = items.set(i, notificationToMap(n));
   });
 
-  return state
-    .update('items', list => list.concat(items))
-    .set('next', next)
-    .set('isLoading', false);
+  return state.withMutations(mutable => {
+    if (!items.isEmpty()) {
+      mutable.update('items', list => {
+        const lastIndex = 1 + list.findLastIndex(
+          item => item !== null && (newer(item, items.last()) || item.get('id') === items.last().get('id'))
+        );
+
+        const firstIndex = 1 + list.take(lastIndex).findLastIndex(
+          item => item !== null && newer(item, items.first())
+        );
+
+        return list.take(firstIndex).concat(items, list.skip(lastIndex));
+      });
+    }
+
+    if (!next) {
+      mutable.set('hasMore', true);
+    }
+
+    mutable.set('isLoading', false);
+  });
 };
 
 const filterNotifications = (state, relationship) => {
@@ -97,27 +99,27 @@ const deleteByStatus = (state, statusId) => {
 
 export default function notifications(state = initialState, action) {
   switch(action.type) {
-  case NOTIFICATIONS_REFRESH_REQUEST:
   case NOTIFICATIONS_EXPAND_REQUEST:
     return state.set('isLoading', true);
-  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;
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index 7b3141623..3abe69bce 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -1,87 +1,23 @@
 import {
   REBLOG_REQUEST,
-  REBLOG_SUCCESS,
   REBLOG_FAIL,
-  UNREBLOG_SUCCESS,
   FAVOURITE_REQUEST,
-  FAVOURITE_SUCCESS,
   FAVOURITE_FAIL,
-  UNFAVOURITE_SUCCESS,
-  PIN_SUCCESS,
-  UNPIN_SUCCESS,
 } from '../actions/interactions';
 import {
-  STATUS_FETCH_SUCCESS,
-  CONTEXT_FETCH_SUCCESS,
   STATUS_MUTE_SUCCESS,
   STATUS_UNMUTE_SUCCESS,
   STATUS_REVEAL,
   STATUS_HIDE,
 } from '../actions/statuses';
-import {
-  TIMELINE_REFRESH_SUCCESS,
-  TIMELINE_UPDATE,
-  TIMELINE_DELETE,
-  TIMELINE_EXPAND_SUCCESS,
-} from '../actions/timelines';
-import {
-  NOTIFICATIONS_UPDATE,
-  NOTIFICATIONS_REFRESH_SUCCESS,
-  NOTIFICATIONS_EXPAND_SUCCESS,
-} from '../actions/notifications';
-import {
-  FAVOURITED_STATUSES_FETCH_SUCCESS,
-  FAVOURITED_STATUSES_EXPAND_SUCCESS,
-} from '../actions/favourites';
-import {
-  PINNED_STATUSES_FETCH_SUCCESS,
-} from '../actions/pin_statuses';
-import { SEARCH_FETCH_SUCCESS } from '../actions/search';
-import emojify from '../features/emoji/emoji';
+import { TIMELINE_DELETE } from '../actions/timelines';
+import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
 import { Map as ImmutableMap, fromJS } from 'immutable';
-import escapeTextContentForBrowser from 'escape-html';
-
-const domParser = new DOMParser();
 
-const normalizeStatus = (state, status) => {
-  if (!status) {
-    return state;
-  }
-
-  const normalStatus   = { ...status };
-  normalStatus.account = status.account.id;
-
-  if (status.reblog && status.reblog.id) {
-    state               = normalizeStatus(state, status.reblog);
-    normalStatus.reblog = status.reblog.id;
-  }
+const importStatus = (state, status) => state.set(status.id, fromJS(status));
 
-  // Only calculate these values when status first encountered
-  // Otherwise keep the ones already in the reducer
-  if (!state.has(status.id)) {
-    const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
-
-    const emojiMap = normalStatus.emojis.reduce((obj, emoji) => {
-      obj[`:${emoji.shortcode}:`] = emoji;
-      return obj;
-    }, {});
-
-    normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
-    normalStatus.contentHtml  = emojify(normalStatus.content, emojiMap);
-    normalStatus.spoilerHtml  = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap);
-    normalStatus.hidden       = normalStatus.sensitive;
-  }
-
-  return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus)));
-};
-
-const normalizeStatuses = (state, statuses) => {
-  statuses.forEach(status => {
-    state = normalizeStatus(state, status);
-  });
-
-  return state;
-};
+const importStatuses = (state, statuses) =>
+  state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status)));
 
 const deleteStatus = (state, id, references) => {
   references.forEach(ref => {
@@ -95,17 +31,10 @@ const initialState = ImmutableMap();
 
 export default function statuses(state = initialState, action) {
   switch(action.type) {
-  case TIMELINE_UPDATE:
-  case STATUS_FETCH_SUCCESS:
-  case NOTIFICATIONS_UPDATE:
-    return normalizeStatus(state, action.status);
-  case REBLOG_SUCCESS:
-  case UNREBLOG_SUCCESS:
-  case FAVOURITE_SUCCESS:
-  case UNFAVOURITE_SUCCESS:
-  case PIN_SUCCESS:
-  case UNPIN_SUCCESS:
-    return normalizeStatus(state, action.response);
+  case STATUS_IMPORT:
+    return importStatus(state, action.status);
+  case STATUSES_IMPORT:
+    return importStatuses(state, action.statuses);
   case FAVOURITE_REQUEST:
     return state.setIn([action.status.get('id'), 'favourited'], true);
   case FAVOURITE_FAIL:
@@ -126,16 +55,6 @@ export default function statuses(state = initialState, action) {
     return state.withMutations(map => {
       action.ids.forEach(id => map.setIn([id, 'hidden'], true));
     });
-  case TIMELINE_REFRESH_SUCCESS:
-  case TIMELINE_EXPAND_SUCCESS:
-  case CONTEXT_FETCH_SUCCESS:
-  case NOTIFICATIONS_REFRESH_SUCCESS:
-  case NOTIFICATIONS_EXPAND_SUCCESS:
-  case FAVOURITED_STATUSES_FETCH_SUCCESS:
-  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
-  case PINNED_STATUSES_FETCH_SUCCESS:
-  case SEARCH_FETCH_SUCCESS:
-    return normalizeStatuses(state, action.statuses);
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.references);
   default:
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
index 9a10bcc59..f795e7e08 100644
--- a/app/javascript/mastodon/reducers/timelines.js
+++ b/app/javascript/mastodon/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 '../actions/timelines';
 import {
@@ -22,37 +18,33 @@ 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 && id >= newIds.last()) + 1;
+        const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && id > newIds.first());
+
+        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)
+        );
+      });
+    }
   }));
 };
 
@@ -118,16 +110,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));
   case TIMELINE_DELETE:
@@ -139,10 +127,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 : items.unshift(null)
+      )
+    );
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js
index 9a6f4f26d..6c67ba275 100644
--- a/app/javascript/mastodon/stream.js
+++ b/app/javascript/mastodon/stream.js
@@ -1,10 +1,10 @@
 import WebSocketClient from 'websocket.js';
 
-export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) {
+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 = () => {
@@ -25,7 +25,6 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
         if (pollingRefresh) {
           clearPolling();
         }
-        onConnect();
       },
 
       disconnected () {
@@ -44,7 +43,6 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
           clearPolling();
           pollingRefresh(dispatch);
         }
-        onConnect();
       },
 
     });
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 3472af6c1..3377c2329 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -7,7 +7,6 @@ function main() {
   const { getLocale } = require('../mastodon/locales');
   const { localeData } = getLocale();
   const VideoContainer = require('../mastodon/containers/video_container').default;
-  const MediaGalleryContainer = require('../mastodon/containers/media_gallery_container').default;
   const CardContainer = require('../mastodon/containers/card_container').default;
   const React = require('react');
   const ReactDOM = require('react-dom');
@@ -58,15 +57,20 @@ function main() {
       ReactDOM.render(<VideoContainer locale={locale} {...props} />, content);
     });
 
-    [].forEach.call(document.querySelectorAll('[data-component="MediaGallery"]'), (content) => {
-      const props = JSON.parse(content.getAttribute('data-props'));
-      ReactDOM.render(<MediaGalleryContainer 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('../mastodon/containers/media_galleries_container').default;
+      const content = document.createElement('div');
+
+      ReactDOM.render(<MediaGalleriesContainer locale={locale} galleries={mediaGalleries} />, content);
+      document.body.appendChild(content);
+    }
   });
 }
 
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 98c5ccbca..5c6189bae 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -3375,13 +3375,14 @@ a.status-card {
 }
 
 .modal-root {
+  position: relative;
   transition: opacity 0.3s linear;
   will-change: opacity;
   z-index: 9999;
 }
 
 .modal-root__overlay {
-  position: absolute;
+  position: fixed;
   top: 0;
   left: 0;
   right: 0;
@@ -3390,7 +3391,7 @@ a.status-card {
 }
 
 .modal-root__container {
-  position: absolute;
+  position: fixed;
   top: 0;
   left: 0;
   width: 100%;
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index 6fa1fa38f..e761f58eb 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -60,6 +60,10 @@
   }
 }
 
+.media-gallery-standalone__body {
+  overflow: hidden;
+}
+
 .account-header {
   width: 400px;
   margin: 0 auto;
diff --git a/app/lib/provider_discovery.rb b/app/lib/provider_discovery.rb
index 5732e4fcb..bbd3a2d43 100644
--- a/app/lib/provider_discovery.rb
+++ b/app/lib/provider_discovery.rb
@@ -13,15 +13,14 @@ class ProviderDiscovery < OEmbed::ProviderDiscovery
     def discover_provider(url, **options)
       format = options[:format]
 
-      if options[:html]
-        html = Nokogiri::HTML(options[:html])
-      else
-        res = Request.new(:get, url).perform
-
-        raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html'
-
-        html = Nokogiri::HTML(res.to_s)
-      end
+      html = if options[:html]
+               Nokogiri::HTML(options[:html])
+             else
+               Request.new(:get, url).perform do |res|
+                 raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html'
+                 Nokogiri::HTML(res.to_s)
+               end
+             end
 
       if format.nil? || format == :json
         provider_endpoint ||= html.at_xpath('//link[@type="application/json+oembed"]')&.attribute('href')&.value
diff --git a/app/lib/request.rb b/app/lib/request.rb
index 298fb9528..8a127c65f 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -33,9 +33,17 @@ class Request
   end
 
   def perform
-    http_client.headers(headers).public_send(@verb, @url.to_s, @options)
-  rescue => e
-    raise e.class, "#{e.message} on #{@url}", e.backtrace[0]
+    begin
+      response = http_client.headers(headers).public_send(@verb, @url.to_s, @options)
+    rescue => e
+      raise e.class, "#{e.message} on #{@url}", e.backtrace[0]
+    end
+
+    begin
+      yield response
+    ensure
+      http_client.close
+    end
   end
 
   def headers
@@ -88,7 +96,7 @@ class Request
   end
 
   def http_client
-    HTTP.timeout(:per_operation, timeout).follow(max_hops: 2)
+    @http_client ||= HTTP.timeout(:per_operation, timeout).follow(max_hops: 2)
   end
 
   class Socket < TCPSocket
diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb
index 69685ec83..0f18c5d96 100644
--- a/app/models/concerns/remotable.rb
+++ b/app/models/concerns/remotable.rb
@@ -21,23 +21,23 @@ module Remotable
         return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[attribute_name] == url
 
         begin
-          response = Request.new(:get, url).perform
-
-          return if response.code != 200
-
-          matches  = response.headers['content-disposition']&.match(/filename="([^"]*)"/)
-          filename = matches.nil? ? parsed_url.path.split('/').last : matches[1]
-          basename = SecureRandom.hex(8)
-          extname = if filename.nil?
-                      ''
-                    else
-                      File.extname(filename)
-                    end
-
-          send("#{attachment_name}=", StringIO.new(response.to_s))
-          send("#{attachment_name}_file_name=", basename + extname)
-
-          self[attribute_name] = url if has_attribute?(attribute_name)
+          Request.new(:get, url).perform do |response|
+            next if response.code != 200
+
+            matches  = response.headers['content-disposition']&.match(/filename="([^"]*)"/)
+            filename = matches.nil? ? parsed_url.path.split('/').last : matches[1]
+            basename = SecureRandom.hex(8)
+            extname = if filename.nil?
+                        ''
+                      else
+                        File.extname(filename)
+                      end
+
+            send("#{attachment_name}=", StringIO.new(response.to_s))
+            send("#{attachment_name}_file_name=", basename + extname)
+
+            self[attribute_name] = url if has_attribute?(attribute_name)
+          end
         rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError => e
           Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
           nil
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 7f8dae5ec..be9964087 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -4,12 +4,12 @@
 # Table name: notifications
 #
 #  id              :integer          not null, primary key
-#  activity_id     :integer
-#  activity_type   :string
+#  activity_id     :integer          not null
+#  activity_type   :string           not null
 #  created_at      :datetime         not null
 #  updated_at      :datetime         not null
-#  account_id      :integer
-#  from_account_id :integer
+#  account_id      :integer          not null
+#  from_account_id :integer          not null
 #
 
 class Notification < ApplicationRecord
diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb
index c07859845..48ad5dcd3 100644
--- a/app/services/fetch_atom_service.rb
+++ b/app/services/fetch_atom_service.rb
@@ -24,43 +24,44 @@ class FetchAtomService < BaseService
 
   def process(url, terminal = false)
     @url = url
-    perform_request
-    process_response(terminal)
+    perform_request { |response| process_response(response, terminal) }
   end
 
-  def perform_request
+  def perform_request(&block)
     accept = 'text/html'
     accept = 'application/activity+json, application/ld+json, application/atom+xml, ' + accept unless @unsupported_activity
 
-    @response = Request.new(:get, @url)
-                       .add_headers('Accept' => accept)
-                       .perform
+    Request.new(:get, @url).add_headers('Accept' => accept).perform(&block)
   end
 
-  def process_response(terminal = false)
-    return nil if @response.code != 200
+  def process_response(response, terminal = false)
+    return nil if response.code != 200
 
-    if @response.mime_type == 'application/atom+xml'
-      [@url, { prefetched_body: @response.to_s }, :ostatus]
-    elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type)
-      json = body_to_json(@response.to_s)
+    if response.mime_type == 'application/atom+xml'
+      [@url, { prefetched_body: response.to_s }, :ostatus]
+    elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(response.mime_type)
+      json = body_to_json(response.to_s)
       if supported_context?(json) && json['type'] == 'Person' && json['inbox'].present?
-        [json['id'], { prefetched_body: @response.to_s, id: true }, :activitypub]
+        [json['id'], { prefetched_body: response.to_s, id: true }, :activitypub]
       elsif supported_context?(json) && json['type'] == 'Note'
-        [json['id'], { prefetched_body: @response.to_s, id: true }, :activitypub]
+        [json['id'], { prefetched_body: response.to_s, id: true }, :activitypub]
       else
         @unsupported_activity = true
         nil
       end
-    elsif @response['Link'] && !terminal && link_header.find_link(%w(rel alternate))
-      process_headers
-    elsif @response.mime_type == 'text/html' && !terminal
-      process_html
+    elsif !terminal
+      link_header = response['Link'] && parse_link_header(response)
+
+      if link_header&.find_link(%w(rel alternate))
+        process_link_headers(link_header)
+      elsif response.mime_type == 'text/html'
+        process_html(response)
+      end
     end
   end
 
-  def process_html
-    page = Nokogiri::HTML(@response.to_s)
+  def process_html(response)
+    page = Nokogiri::HTML(response.to_s)
 
     json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) }
     atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
@@ -71,7 +72,7 @@ class FetchAtomService < BaseService
     result
   end
 
-  def process_headers
+  def process_link_headers(link_header)
     json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'])
     atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml))
 
@@ -81,7 +82,7 @@ class FetchAtomService < BaseService
     result
   end
 
-  def link_header
-    @link_header ||= LinkHeader.parse(@response['Link'].is_a?(Array) ? @response['Link'].first : @response['Link'])
+  def parse_link_header(response)
+    LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link'])
   end
 end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 8f252e64c..26deb5ecc 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -36,15 +36,24 @@ class FetchLinkCardService < BaseService
 
   def process_url
     @card ||= PreviewCard.new(url: @url)
-    res     = Request.new(:head, @url).perform
 
-    return if res.code != 405 && (res.code != 200 || res.mime_type != 'text/html')
+    failed = Request.new(:head, @url).perform do |res|
+      res.code != 405 && (res.code != 200 || res.mime_type != 'text/html')
+    end
 
-    @response = Request.new(:get, @url).perform
+    return if failed
 
-    return if @response.code != 200 || @response.mime_type != 'text/html'
+    Request.new(:get, @url).perform do |res|
+      if res.code == 200 && res.mime_type == 'text/html'
+        @html = res.to_s
+        @html_charset = res.charset
+      else
+        @html = nil
+        @html_charset = nil
+      end
+    end
 
-    @html = @response.to_s
+    return if @html.nil?
 
     attempt_oembed || attempt_opengraph
   end
@@ -118,7 +127,7 @@ class FetchLinkCardService < BaseService
     detector = CharlockHolmes::EncodingDetector.new
     detector.strip_tags = true
 
-    guess = detector.detect(@html, @response.charset)
+    guess = detector.detect(@html, @html_charset)
     page  = Nokogiri::HTML(@html, nil, guess&.fetch(:encoding, nil))
 
     if meta_property(page, 'twitter:player')
diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb
index fd6d30605..034821dc0 100644
--- a/app/services/resolve_account_service.rb
+++ b/app/services/resolve_account_service.rb
@@ -179,11 +179,10 @@ class ResolveAccountService < BaseService
   def atom_body
     return @atom_body if defined?(@atom_body)
 
-    response = Request.new(:get, atom_url).perform
-
-    raise Mastodon::UnexpectedResponseError, response unless response.code == 200
-
-    @atom_body = response.to_s
+    @atom_body = Request.new(:get, atom_url).perform do |response|
+      raise Mastodon::UnexpectedResponseError, response unless response.code == 200
+      response.to_s
+    end
   end
 
   def actor_json
diff --git a/app/services/send_interaction_service.rb b/app/services/send_interaction_service.rb
index fabba8a3e..3419043e5 100644
--- a/app/services/send_interaction_service.rb
+++ b/app/services/send_interaction_service.rb
@@ -12,11 +12,9 @@ class SendInteractionService < BaseService
 
     return if !target_account.ostatus? || block_notification?
 
-    delivery = build_request.perform
-
-    raise Mastodon::UnexpectedResponseError, delivery unless delivery.code > 199 && delivery.code < 300
-
-    delivery.connection&.close
+    build_request.perform do |delivery|
+      raise Mastodon::UnexpectedResponseError, delivery unless delivery.code > 199 && delivery.code < 300
+    end
   end
 
   private
diff --git a/app/services/subscribe_service.rb b/app/services/subscribe_service.rb
index 2f725e2ec..2893b5410 100644
--- a/app/services/subscribe_service.rb
+++ b/app/services/subscribe_service.rb
@@ -6,21 +6,21 @@ class SubscribeService < BaseService
 
     @account        = account
     @account.secret = SecureRandom.hex
-    @response       = build_request.perform
-
-    if response_failed_permanently?
-      # We're not allowed to subscribe. Fail and move on.
-      @account.secret = ''
-      @account.save!
-    elsif response_successful?
-      # The subscription will be confirmed asynchronously.
-      @account.save!
-    else
-      # The response was either a 429 rate limit, or a 5xx error.
-      # We need to retry at a later time. Fail loudly!
-      raise Mastodon::UnexpectedResponseError, @response
+
+    build_request.perform do |response|
+      if response_failed_permanently? response
+        # We're not allowed to subscribe. Fail and move on.
+        @account.secret = ''
+        @account.save!
+      elsif response_successful? response
+        # The subscription will be confirmed asynchronously.
+        @account.save!
+      else
+        # The response was either a 429 rate limit, or a 5xx error.
+        # We need to retry at a later time. Fail loudly!
+        raise Mastodon::UnexpectedResponseError, response
+      end
     end
-    @response.connection&.close
   end
 
   private
@@ -47,12 +47,12 @@ class SubscribeService < BaseService
   end
 
   # Any response in the 3xx or 4xx range, except for 429 (rate limit)
-  def response_failed_permanently?
-    (@response.status.redirect? || @response.status.client_error?) && !@response.status.too_many_requests?
+  def response_failed_permanently?(response)
+    (response.status.redirect? || response.status.client_error?) && !response.status.too_many_requests?
   end
 
   # Any response in the 2xx range
-  def response_successful?
-    @response.status.success?
+  def response_successful?(response)
+    response.status.success?
   end
 end
diff --git a/app/services/unsubscribe_service.rb b/app/services/unsubscribe_service.rb
index 01f5c6b7a..95c1fb4fc 100644
--- a/app/services/unsubscribe_service.rb
+++ b/app/services/unsubscribe_service.rb
@@ -7,10 +7,9 @@ class UnsubscribeService < BaseService
     @account = account
 
     begin
-      @response = build_request.perform
-
-      Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{@response.status}" unless @response.status.success?
-      @response.connection&.close
+      build_request.perform do |response|
+        Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{response.status}" unless response.status.success?
+      end
     rescue HTTP::Error, OpenSSL::SSL::SSLError => e
       Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{e}"
     end
diff --git a/app/views/authorize_follows/_post_follow_actions.html.haml b/app/views/authorize_follows/_post_follow_actions.html.haml
new file mode 100644
index 000000000..2a9c062e9
--- /dev/null
+++ b/app/views/authorize_follows/_post_follow_actions.html.haml
@@ -0,0 +1,4 @@
+.post-follow-actions
+  %div= link_to t('authorize_follow.post_follow.web'), web_url("accounts/#{@account.id}"), class: 'button button--block'
+  %div= link_to t('authorize_follow.post_follow.return'), TagManager.instance.url_for(@account), class: 'button button--block'
+  %div= t('authorize_follow.post_follow.close')
diff --git a/app/views/authorize_follows/show.html.haml b/app/views/authorize_follows/show.html.haml
index f7a8f72d2..a1fd01dd6 100644
--- a/app/views/authorize_follows/show.html.haml
+++ b/app/views/authorize_follows/show.html.haml
@@ -5,7 +5,13 @@
   .follow-prompt
     = render 'card', account: @account
 
-  - unless current_account.following?(@account)
+  - if current_account.following?(@account)
+    .flash-message
+      %strong
+        = t('authorize_follow.already_following')
+    = render 'post_follow_actions'
+
+  - else
     = form_tag authorize_follow_path, method: :post, class: 'simple_form' do
       = hidden_field_tag :acct, @account.acct
       = button_tag t('authorize_follow.follow'), type: :submit
diff --git a/app/views/authorize_follows/success.html.haml b/app/views/authorize_follows/success.html.haml
index 63ff3bcf1..fa59b24b8 100644
--- a/app/views/authorize_follows/success.html.haml
+++ b/app/views/authorize_follows/success.html.haml
@@ -10,7 +10,4 @@
 
     = render 'card', account: @account
 
-  .post-follow-actions
-    %div= link_to t('authorize_follow.post_follow.web'), web_url("accounts/#{@account.id}"), class: 'button button--block'
-    %div= link_to t('authorize_follow.post_follow.return'), TagManager.instance.url_for(@account), class: 'button button--block'
-    %div= t('authorize_follow.post_follow.close')
+  = render 'post_follow_actions'
diff --git a/app/views/invites/_form.html.haml b/app/views/invites/_form.html.haml
index a01cf5946..3f0871f47 100644
--- a/app/views/invites/_form.html.haml
+++ b/app/views/invites/_form.html.haml
@@ -3,7 +3,7 @@
 
   .fields-group
     = f.input :max_uses, wrapper: :with_label, collection: [1, 5, 10, 25, 50, 100], label_method: lambda { |num| I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt')
-    = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt')
+    = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt')
 
   .actions
     = f.button :button, t('invites.generate'), type: :submit
diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb
index 4763856ac..e6cfd0d07 100644
--- a/app/workers/activitypub/delivery_worker.rb
+++ b/app/workers/activitypub/delivery_worker.rb
@@ -12,11 +12,10 @@ class ActivityPub::DeliveryWorker
     @source_account = Account.find(source_account_id)
     @inbox_url      = inbox_url
 
-    perform_request
+    perform_request do |response|
+      raise Mastodon::UnexpectedResponseError, response unless response_successful? response
+    end
 
-    raise Mastodon::UnexpectedResponseError, @response unless response_successful?
-
-    @response.connection&.close
     failure_tracker.track_success!
   rescue => e
     failure_tracker.track_failure!
@@ -31,12 +30,12 @@ class ActivityPub::DeliveryWorker
     request.add_headers(HEADERS)
   end
 
-  def perform_request
-    @response = build_request.perform
+  def perform_request(&block)
+    build_request.perform(&block)
   end
 
-  def response_successful?
-    @response.code > 199 && @response.code < 300
+  def response_successful?(response)
+    response.code > 199 && response.code < 300
   end
 
   def failure_tracker
diff --git a/app/workers/pubsubhubbub/confirmation_worker.rb b/app/workers/pubsubhubbub/confirmation_worker.rb
index e1ccfb99c..cc2d1225b 100644
--- a/app/workers/pubsubhubbub/confirmation_worker.rb
+++ b/app/workers/pubsubhubbub/confirmation_worker.rb
@@ -21,8 +21,8 @@ class Pubsubhubbub::ConfirmationWorker
   def process_confirmation
     prepare_subscription
 
-    confirm_callback
-    logger.debug "Confirming PuSH subscription for #{subscription.callback_url} with challenge #{challenge}: #{callback_response_body}"
+    callback_get_with_params
+    logger.debug "Confirming PuSH subscription for #{subscription.callback_url} with challenge #{challenge}: #{@callback_response_body}"
 
     update_subscription
   end
@@ -44,7 +44,7 @@ class Pubsubhubbub::ConfirmationWorker
   end
 
   def response_matches_challenge?
-    callback_response_body == challenge
+    @callback_response_body == challenge
   end
 
   def subscribing?
@@ -55,16 +55,10 @@ class Pubsubhubbub::ConfirmationWorker
     mode == 'unsubscribe'
   end
 
-  def confirm_callback
-    @_confirm_callback ||= callback_get_with_params
-  end
-
   def callback_get_with_params
-    Request.new(:get, subscription.callback_url, params: callback_params).perform
-  end
-
-  def callback_response_body
-    confirm_callback.body.to_s
+    Request.new(:get, subscription.callback_url, params: callback_params).perform do |response|
+      @callback_response_body = response.body.to_s
+    end
   end
 
   def callback_params
diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb
index a9174edd2..619bfa48a 100644
--- a/app/workers/pubsubhubbub/delivery_worker.rb
+++ b/app/workers/pubsubhubbub/delivery_worker.rb
@@ -23,22 +23,17 @@ class Pubsubhubbub::DeliveryWorker
   private
 
   def process_delivery
-    payload_delivery
+    callback_post_payload do |payload_delivery|
+      raise Mastodon::UnexpectedResponseError, payload_delivery unless response_successful? payload_delivery
+    end
 
-    raise Mastodon::UnexpectedResponseError, payload_delivery unless response_successful?
-
-    payload_delivery.connection&.close
     subscription.touch(:last_successful_delivery_at)
   end
 
-  def payload_delivery
-    @_payload_delivery ||= callback_post_payload
-  end
-
-  def callback_post_payload
+  def callback_post_payload(&block)
     request = Request.new(:post, subscription.callback_url, body: payload)
     request.add_headers(headers)
-    request.perform
+    request.perform(&block)
   end
 
   def blocked_domain?
@@ -80,7 +75,7 @@ class Pubsubhubbub::DeliveryWorker
     OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), subscription.secret, payload)
   end
 
-  def response_successful?
+  def response_successful?(payload_delivery)
     payload_delivery.code > 199 && payload_delivery.code < 300
   end
 end