about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorAkihiko Odaki <akihiko.odaki.4i@stu.hosei.ac.jp>2018-03-24 21:06:27 +0900
committerEugen Rochko <eugen@zeonfederated.com>2018-03-24 13:06:27 +0100
commitfe398a098e9990ee3146e70be9e2cda6227274b8 (patch)
treed4fcb38f3dfa7b448907153ea4c9182088dc797c /app
parent28384c1771ccaa600e429f41cb2e19234961a9bd (diff)
Store objects to IndexedDB (#6826)
Diffstat (limited to 'app')
-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.js2
-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.js22
-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/timelines.js5
-rw-r--r--app/javascript/mastodon/db/async.js28
-rw-r--r--app/javascript/mastodon/db/modifier.js93
-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/statuses.js95
20 files changed, 433 insertions, 355 deletions
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..8e13209b8 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -4,6 +4,7 @@ import { throttle } from 'lodash';
 import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
 import { tagHistory } from '../settings';
 import { useEmoji } from './emojis';
+import { importFetchedAccounts } from './importer';
 
 import {
   updateTimeline,
@@ -282,6 +283,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..a664cd978 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -2,6 +2,12 @@ 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';
@@ -41,11 +47,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,
     });
 
@@ -89,6 +96,9 @@ export function refreshNotifications() {
     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(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null));
       fetchRelatedRelationships(dispatch, response.data);
     }).catch(error => {
@@ -108,8 +118,6 @@ 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,
   };
@@ -141,6 +149,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 +171,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/timelines.js b/app/javascript/mastodon/actions/timelines.js
index f0ab16a2d..e5748b4e7 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -1,3 +1,4 @@
+import { importFetchedStatus, importFetchedStatuses } from './importer';
 import api, { getLinks } from '../api';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
@@ -44,6 +45,8 @@ export function updateTimeline(timeline, status) {
       }
     }
 
+    dispatch(importFetchedStatus(status));
+
     dispatch({
       type: TIMELINE_UPDATE,
       timeline,
@@ -109,6 +112,7 @@ export function refreshTimeline(timelineId, path, params = {}) {
         dispatch(refreshTimelineSuccess(timelineId, [], skipLoading, null, true));
       } else {
         const next = getLinks(response).refs.find(link => link.rel === 'next');
+        dispatch(importFetchedStatuses(response.data));
         dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null, false));
       }
     }).catch(error => {
@@ -152,6 +156,7 @@ export function expandTimeline(timelineId, path, params = {}) {
 
     api(getState).get(path, { params }).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(importFetchedStatuses(response.data));
       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null));
     }).catch(error => {
       dispatch(expandTimelineFail(timelineId, error));
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/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/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index 7b3141623..fc4b4900e 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -1,87 +1,25 @@
 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 { 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;
-  }
-
-  // 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;
-    }, {});
+const importStatus = (state, status) => state.set(status.id, fromJS(status));
 
-    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 +33,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 +57,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: