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

* Add react-intl-translations-manager

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

* Adjust tests and dependencies

* Fix production deployments

* Fix tests

* More optimizations

* Improve travis cache for npm stuff

* Re-run travis

* Add back support for custom.scss as before

* Remove offline-plugin and babili

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

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

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

* Add react definitions to places that use JSX

* Add Procfile.dev for running rails, webpack and streaming API at the same time
Diffstat (limited to 'app/javascript/mastodon/reducers')
-rw-r--r--app/javascript/mastodon/reducers/accounts.js133
-rw-r--r--app/javascript/mastodon/reducers/accounts_counters.js135
-rw-r--r--app/javascript/mastodon/reducers/alerts.js25
-rw-r--r--app/javascript/mastodon/reducers/cards.js14
-rw-r--r--app/javascript/mastodon/reducers/compose.js232
-rw-r--r--app/javascript/mastodon/reducers/index.js38
-rw-r--r--app/javascript/mastodon/reducers/meta.js17
-rw-r--r--app/javascript/mastodon/reducers/modal.js18
-rw-r--r--app/javascript/mastodon/reducers/notifications.js104
-rw-r--r--app/javascript/mastodon/reducers/relationships.js38
-rw-r--r--app/javascript/mastodon/reducers/reports.js60
-rw-r--r--app/javascript/mastodon/reducers/search.js96
-rw-r--r--app/javascript/mastodon/reducers/settings.js52
-rw-r--r--app/javascript/mastodon/reducers/status_lists.js39
-rw-r--r--app/javascript/mastodon/reducers/statuses.js124
-rw-r--r--app/javascript/mastodon/reducers/timelines.js317
-rw-r--r--app/javascript/mastodon/reducers/user_lists.js80
17 files changed, 1522 insertions, 0 deletions
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js
new file mode 100644
index 000000000..b3c2b6d88
--- /dev/null
+++ b/app/javascript/mastodon/reducers/accounts.js
@@ -0,0 +1,133 @@
+import {
+  ACCOUNT_FETCH_SUCCESS,
+  FOLLOWERS_FETCH_SUCCESS,
+  FOLLOWERS_EXPAND_SUCCESS,
+  FOLLOWING_FETCH_SUCCESS,
+  FOLLOWING_EXPAND_SUCCESS,
+  ACCOUNT_TIMELINE_FETCH_SUCCESS,
+  ACCOUNT_TIMELINE_EXPAND_SUCCESS,
+  FOLLOW_REQUESTS_FETCH_SUCCESS,
+  FOLLOW_REQUESTS_EXPAND_SUCCESS
+} from '../actions/accounts';
+import {
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_EXPAND_SUCCESS
+} from '../actions/blocks';
+import {
+  MUTES_FETCH_SUCCESS,
+  MUTES_EXPAND_SUCCESS
+} from '../actions/mutes';
+import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
+import {
+  REBLOG_SUCCESS,
+  UNREBLOG_SUCCESS,
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS,
+  REBLOGS_FETCH_SUCCESS,
+  FAVOURITES_FETCH_SUCCESS
+} from '../actions/interactions';
+import {
+  TIMELINE_REFRESH_SUCCESS,
+  TIMELINE_UPDATE,
+  TIMELINE_EXPAND_SUCCESS
+} from '../actions/timelines';
+import {
+  STATUS_FETCH_SUCCESS,
+  CONTEXT_FETCH_SUCCESS
+} from '../actions/statuses';
+import { SEARCH_FETCH_SUCCESS } from '../actions/search';
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_REFRESH_SUCCESS,
+  NOTIFICATIONS_EXPAND_SUCCESS
+} from '../actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
+import { STORE_HYDRATE } from '../actions/store';
+import Immutable from 'immutable';
+
+const normalizeAccount = (state, account) => {
+  account = { ...account };
+
+  delete account.followers_count;
+  delete account.following_count;
+  delete account.statuses_count;
+
+  return state.set(account.id, Immutable.fromJS(account))
+};
+
+const normalizeAccounts = (state, accounts) => {
+  accounts.forEach(account => {
+    state = normalizeAccount(state, account);
+  });
+
+  return state;
+};
+
+const normalizeAccountFromStatus = (state, status) => {
+  state = normalizeAccount(state, status.account);
+
+  if (status.reblog && status.reblog.account) {
+    state = normalizeAccount(state, status.reblog.account);
+  }
+
+  return state;
+};
+
+const normalizeAccountsFromStatuses = (state, statuses) => {
+  statuses.forEach(status => {
+    state = normalizeAccountFromStatus(state, status);
+  });
+
+  return state;
+};
+
+const initialState = Immutable.Map();
+
+export default function accounts(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('accounts'));
+  case ACCOUNT_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+    return normalizeAccount(state, action.account);
+  case FOLLOWERS_FETCH_SUCCESS:
+  case FOLLOWERS_EXPAND_SUCCESS:
+  case FOLLOWING_FETCH_SUCCESS:
+  case FOLLOWING_EXPAND_SUCCESS:
+  case REBLOGS_FETCH_SUCCESS:
+  case FAVOURITES_FETCH_SUCCESS:
+  case COMPOSE_SUGGESTIONS_READY:
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+  case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+  case BLOCKS_FETCH_SUCCESS:
+  case BLOCKS_EXPAND_SUCCESS:
+  case MUTES_FETCH_SUCCESS:
+  case MUTES_EXPAND_SUCCESS:
+    return normalizeAccounts(state, action.accounts);
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
+    return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
+  case TIMELINE_REFRESH_SUCCESS:
+  case TIMELINE_EXPAND_SUCCESS:
+  case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+  case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return normalizeAccountsFromStatuses(state, action.statuses);
+  case REBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+    return normalizeAccountFromStatus(state, action.response);
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+    return normalizeAccountFromStatus(state, action.status);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js
new file mode 100644
index 000000000..2afc6c3d9
--- /dev/null
+++ b/app/javascript/mastodon/reducers/accounts_counters.js
@@ -0,0 +1,135 @@
+import {
+  ACCOUNT_FETCH_SUCCESS,
+  FOLLOWERS_FETCH_SUCCESS,
+  FOLLOWERS_EXPAND_SUCCESS,
+  FOLLOWING_FETCH_SUCCESS,
+  FOLLOWING_EXPAND_SUCCESS,
+  ACCOUNT_TIMELINE_FETCH_SUCCESS,
+  ACCOUNT_TIMELINE_EXPAND_SUCCESS,
+  FOLLOW_REQUESTS_FETCH_SUCCESS,
+  FOLLOW_REQUESTS_EXPAND_SUCCESS,
+  ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_SUCCESS
+} from '../actions/accounts';
+import {
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_EXPAND_SUCCESS
+} from '../actions/blocks';
+import {
+  MUTES_FETCH_SUCCESS,
+  MUTES_EXPAND_SUCCESS
+} from '../actions/mutes';
+import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose';
+import {
+  REBLOG_SUCCESS,
+  UNREBLOG_SUCCESS,
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS,
+  REBLOGS_FETCH_SUCCESS,
+  FAVOURITES_FETCH_SUCCESS
+} from '../actions/interactions';
+import {
+  TIMELINE_REFRESH_SUCCESS,
+  TIMELINE_UPDATE,
+  TIMELINE_EXPAND_SUCCESS
+} from '../actions/timelines';
+import {
+  STATUS_FETCH_SUCCESS,
+  CONTEXT_FETCH_SUCCESS
+} from '../actions/statuses';
+import { SEARCH_FETCH_SUCCESS } from '../actions/search';
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_REFRESH_SUCCESS,
+  NOTIFICATIONS_EXPAND_SUCCESS
+} from '../actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
+import { STORE_HYDRATE } from '../actions/store';
+import Immutable from 'immutable';
+
+const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS({
+  followers_count: account.followers_count,
+  following_count: account.following_count,
+  statuses_count: account.statuses_count,
+}));
+
+const normalizeAccounts = (state, accounts) => {
+  accounts.forEach(account => {
+    state = normalizeAccount(state, account);
+  });
+
+  return state;
+};
+
+const normalizeAccountFromStatus = (state, status) => {
+  state = normalizeAccount(state, status.account);
+
+  if (status.reblog && status.reblog.account) {
+    state = normalizeAccount(state, status.reblog.account);
+  }
+
+  return state;
+};
+
+const normalizeAccountsFromStatuses = (state, statuses) => {
+  statuses.forEach(status => {
+    state = normalizeAccountFromStatus(state, status);
+  });
+
+  return state;
+};
+
+const initialState = Immutable.Map();
+
+export default function accountsCounters(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('accounts_counters'));
+  case ACCOUNT_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+    return normalizeAccount(state, action.account);
+  case FOLLOWERS_FETCH_SUCCESS:
+  case FOLLOWERS_EXPAND_SUCCESS:
+  case FOLLOWING_FETCH_SUCCESS:
+  case FOLLOWING_EXPAND_SUCCESS:
+  case REBLOGS_FETCH_SUCCESS:
+  case FAVOURITES_FETCH_SUCCESS:
+  case COMPOSE_SUGGESTIONS_READY:
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+  case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+  case BLOCKS_FETCH_SUCCESS:
+  case BLOCKS_EXPAND_SUCCESS:
+  case MUTES_FETCH_SUCCESS:
+  case MUTES_EXPAND_SUCCESS:
+    return normalizeAccounts(state, action.accounts);
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
+    return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
+  case TIMELINE_REFRESH_SUCCESS:
+  case TIMELINE_EXPAND_SUCCESS:
+  case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+  case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return normalizeAccountsFromStatuses(state, action.statuses);
+  case REBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+    return normalizeAccountFromStatus(state, action.response);
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+    return normalizeAccountFromStatus(state, action.status);
+  case ACCOUNT_FOLLOW_SUCCESS:
+    return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
+  case ACCOUNT_UNFOLLOW_SUCCESS:
+    return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/alerts.js b/app/javascript/mastodon/reducers/alerts.js
new file mode 100644
index 000000000..dc0145824
--- /dev/null
+++ b/app/javascript/mastodon/reducers/alerts.js
@@ -0,0 +1,25 @@
+import {
+  ALERT_SHOW,
+  ALERT_DISMISS,
+  ALERT_CLEAR
+} from '../actions/alerts';
+import Immutable from 'immutable';
+
+const initialState = Immutable.List([]);
+
+export default function alerts(state = initialState, action) {
+  switch(action.type) {
+  case ALERT_SHOW:
+    return state.push(Immutable.Map({
+      key: state.size > 0 ? state.last().get('key') + 1 : 0,
+      title: action.title,
+      message: action.message
+    }));
+  case ALERT_DISMISS:
+    return state.filterNot(item => item.get('key') === action.alert.key);
+  case ALERT_CLEAR:
+    return state.clear();
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/cards.js b/app/javascript/mastodon/reducers/cards.js
new file mode 100644
index 000000000..3c9395011
--- /dev/null
+++ b/app/javascript/mastodon/reducers/cards.js
@@ -0,0 +1,14 @@
+import { STATUS_CARD_FETCH_SUCCESS } from '../actions/cards';
+
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map();
+
+export default function cards(state = initialState, action) {
+  switch(action.type) {
+  case STATUS_CARD_FETCH_SUCCESS:
+    return state.set(action.id, Immutable.fromJS(action.card));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
new file mode 100644
index 000000000..c87384780
--- /dev/null
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -0,0 +1,232 @@
+import {
+  COMPOSE_MOUNT,
+  COMPOSE_UNMOUNT,
+  COMPOSE_CHANGE,
+  COMPOSE_REPLY,
+  COMPOSE_REPLY_CANCEL,
+  COMPOSE_MENTION,
+  COMPOSE_SUBMIT_REQUEST,
+  COMPOSE_SUBMIT_SUCCESS,
+  COMPOSE_SUBMIT_FAIL,
+  COMPOSE_UPLOAD_REQUEST,
+  COMPOSE_UPLOAD_SUCCESS,
+  COMPOSE_UPLOAD_FAIL,
+  COMPOSE_UPLOAD_UNDO,
+  COMPOSE_UPLOAD_PROGRESS,
+  COMPOSE_SUGGESTIONS_CLEAR,
+  COMPOSE_SUGGESTIONS_READY,
+  COMPOSE_SUGGESTION_SELECT,
+  COMPOSE_SENSITIVITY_CHANGE,
+  COMPOSE_SPOILERNESS_CHANGE,
+  COMPOSE_SPOILER_TEXT_CHANGE,
+  COMPOSE_VISIBILITY_CHANGE,
+  COMPOSE_LISTABILITY_CHANGE,
+  COMPOSE_EMOJI_INSERT
+} from '../actions/compose';
+import { TIMELINE_DELETE } from '../actions/timelines';
+import { STORE_HYDRATE } from '../actions/store';
+import Immutable from 'immutable';
+import uuid from '../uuid';
+
+const initialState = Immutable.Map({
+  mounted: false,
+  sensitive: false,
+  spoiler: false,
+  spoiler_text: '',
+  privacy: null,
+  text: '',
+  focusDate: null,
+  preselectDate: null,
+  in_reply_to: null,
+  is_submitting: false,
+  is_uploading: false,
+  progress: 0,
+  media_attachments: Immutable.List(),
+  suggestion_token: null,
+  suggestions: Immutable.List(),
+  me: null,
+  default_privacy: 'public',
+  resetFileKey: Math.floor((Math.random() * 0x10000)),
+  idempotencyKey: null
+});
+
+function statusToTextMentions(state, status) {
+  let set = Immutable.OrderedSet([]);
+  let me  = state.get('me');
+
+  if (status.getIn(['account', 'id']) !== me) {
+    set = set.add(`@${status.getIn(['account', 'acct'])} `);
+  }
+
+  return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
+};
+
+function clearAll(state) {
+  return state.withMutations(map => {
+    map.set('text', '');
+    map.set('spoiler', false);
+    map.set('spoiler_text', '');
+    map.set('is_submitting', false);
+    map.set('in_reply_to', null);
+    map.set('privacy', state.get('default_privacy'));
+    map.set('sensitive', false);
+    map.update('media_attachments', list => list.clear());
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+function appendMedia(state, media) {
+  return state.withMutations(map => {
+    map.update('media_attachments', list => list.push(media));
+    map.set('is_uploading', false);
+    map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
+    map.update('text', oldText => `${oldText.trim()} ${media.get('text_url')}`);
+    map.set('focusDate', new Date());
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+function removeMedia(state, mediaId) {
+  const media    = state.get('media_attachments').find(item => item.get('id') === mediaId);
+  const prevSize = state.get('media_attachments').size;
+
+  return state.withMutations(map => {
+    map.update('media_attachments', list => list.filterNot(item => item.get('id') === mediaId));
+    map.update('text', text => text.replace(media.get('text_url'), '').trim());
+    map.set('idempotencyKey', uuid());
+
+    if (prevSize === 1) {
+      map.set('sensitive', false);
+    }
+  });
+};
+
+const insertSuggestion = (state, position, token, completion) => {
+  return state.withMutations(map => {
+    map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
+    map.set('suggestion_token', null);
+    map.update('suggestions', Immutable.List(), list => list.clear());
+    map.set('focusDate', new Date());
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+const insertEmoji = (state, position, emojiData) => {
+  const emoji = emojiData.shortname;
+
+  return state.withMutations(map => {
+    map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);
+    map.set('focusDate', new Date());
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+const privacyPreference = (a, b) => {
+  if (a === 'direct' || b === 'direct') {
+    return 'direct';
+  } else if (a === 'private' || b === 'private') {
+    return 'private';
+  } else if (a === 'unlisted' || b === 'unlisted') {
+    return 'unlisted';
+  } else {
+    return 'public';
+  }
+};
+
+export default function compose(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return clearAll(state.merge(action.state.get('compose')));
+  case COMPOSE_MOUNT:
+    return state.set('mounted', true);
+  case COMPOSE_UNMOUNT:
+    return state.set('mounted', false);
+  case COMPOSE_SENSITIVITY_CHANGE:
+    return state
+      .set('sensitive', !state.get('sensitive'))
+      .set('idempotencyKey', uuid());
+  case COMPOSE_SPOILERNESS_CHANGE:
+    return state.withMutations(map => {
+      map.set('spoiler_text', '');
+      map.set('spoiler', !state.get('spoiler'));
+      map.set('idempotencyKey', uuid());
+    });
+  case COMPOSE_SPOILER_TEXT_CHANGE:
+    return state
+      .set('spoiler_text', action.text)
+      .set('idempotencyKey', uuid());
+  case COMPOSE_VISIBILITY_CHANGE:
+    return state
+      .set('privacy', action.value)
+      .set('idempotencyKey', uuid());
+  case COMPOSE_CHANGE:
+    return state
+      .set('text', action.text)
+      .set('idempotencyKey', uuid());
+  case COMPOSE_REPLY:
+    return state.withMutations(map => {
+      map.set('in_reply_to', action.status.get('id'));
+      map.set('text', statusToTextMentions(state, action.status));
+      map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
+      map.set('focusDate', new Date());
+      map.set('preselectDate', new Date());
+      map.set('idempotencyKey', uuid());
+
+      if (action.status.get('spoiler_text').length > 0) {
+        map.set('spoiler', true);
+        map.set('spoiler_text', action.status.get('spoiler_text'));
+      } else {
+        map.set('spoiler', false);
+        map.set('spoiler_text', '');
+      }
+    });
+  case COMPOSE_REPLY_CANCEL:
+    return state.withMutations(map => {
+      map.set('in_reply_to', null);
+      map.set('text', '');
+      map.set('spoiler', false);
+      map.set('spoiler_text', '');
+      map.set('privacy', state.get('default_privacy'));
+      map.set('idempotencyKey', uuid());
+    });
+  case COMPOSE_SUBMIT_REQUEST:
+    return state.set('is_submitting', true);
+  case COMPOSE_SUBMIT_SUCCESS:
+    return clearAll(state);
+  case COMPOSE_SUBMIT_FAIL:
+    return state.set('is_submitting', false);
+  case COMPOSE_UPLOAD_REQUEST:
+    return state.withMutations(map => {
+      map.set('is_uploading', true);
+    });
+  case COMPOSE_UPLOAD_SUCCESS:
+    return appendMedia(state, Immutable.fromJS(action.media));
+  case COMPOSE_UPLOAD_FAIL:
+    return state.set('is_uploading', false);
+  case COMPOSE_UPLOAD_UNDO:
+    return removeMedia(state, action.media_id);
+  case COMPOSE_UPLOAD_PROGRESS:
+    return state.set('progress', Math.round((action.loaded / action.total) * 100));
+  case COMPOSE_MENTION:
+    return state
+      .update('text', text => `${text}@${action.account.get('acct')} `)
+      .set('focusDate', new Date())
+      .set('idempotencyKey', uuid());
+  case COMPOSE_SUGGESTIONS_CLEAR:
+    return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
+  case COMPOSE_SUGGESTIONS_READY:
+    return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))).set('suggestion_token', action.token);
+  case COMPOSE_SUGGESTION_SELECT:
+    return insertSuggestion(state, action.position, action.token, action.completion);
+  case TIMELINE_DELETE:
+    if (action.id === state.get('in_reply_to')) {
+      return state.set('in_reply_to', null);
+    } else {
+      return state;
+    }
+  case COMPOSE_EMOJI_INSERT:
+    return insertEmoji(state, action.position, action.emoji);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
new file mode 100644
index 000000000..f05067c47
--- /dev/null
+++ b/app/javascript/mastodon/reducers/index.js
@@ -0,0 +1,38 @@
+import { combineReducers } from 'redux-immutable';
+import timelines from './timelines';
+import meta from './meta';
+import compose from './compose';
+import alerts from './alerts';
+import { loadingBarReducer } from 'react-redux-loading-bar';
+import modal from './modal';
+import user_lists from './user_lists';
+import accounts from './accounts';
+import accounts_counters from './accounts_counters';
+import statuses from './statuses';
+import relationships from './relationships';
+import search from './search';
+import notifications from './notifications';
+import settings from './settings';
+import status_lists from './status_lists';
+import cards from './cards';
+import reports from './reports';
+
+export default combineReducers({
+  timelines,
+  meta,
+  compose,
+  alerts,
+  loadingBar: loadingBarReducer,
+  modal,
+  user_lists,
+  status_lists,
+  accounts,
+  accounts_counters,
+  statuses,
+  relationships,
+  search,
+  notifications,
+  settings,
+  cards,
+  reports
+});
diff --git a/app/javascript/mastodon/reducers/meta.js b/app/javascript/mastodon/reducers/meta.js
new file mode 100644
index 000000000..acf6d4be1
--- /dev/null
+++ b/app/javascript/mastodon/reducers/meta.js
@@ -0,0 +1,17 @@
+import { STORE_HYDRATE } from '../actions/store';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  streaming_api_base_url: null,
+  access_token: null,
+  me: null
+});
+
+export default function meta(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('meta'));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/modal.js b/app/javascript/mastodon/reducers/modal.js
new file mode 100644
index 000000000..3566820ef
--- /dev/null
+++ b/app/javascript/mastodon/reducers/modal.js
@@ -0,0 +1,18 @@
+import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal';
+import Immutable from 'immutable';
+
+const initialState = {
+  modalType: null,
+  modalProps: {}
+};
+
+export default function modal(state = initialState, action) {
+  switch(action.type) {
+  case MODAL_OPEN:
+    return { modalType: action.modalType, modalProps: action.modalProps };
+  case MODAL_CLOSE:
+    return initialState;
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
new file mode 100644
index 000000000..c567a3a59
--- /dev/null
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -0,0 +1,104 @@
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_REFRESH_SUCCESS,
+  NOTIFICATIONS_EXPAND_SUCCESS,
+  NOTIFICATIONS_REFRESH_REQUEST,
+  NOTIFICATIONS_EXPAND_REQUEST,
+  NOTIFICATIONS_REFRESH_FAIL,
+  NOTIFICATIONS_EXPAND_FAIL,
+  NOTIFICATIONS_CLEAR,
+  NOTIFICATIONS_SCROLL_TOP
+} from '../actions/notifications';
+import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  items: Immutable.List(),
+  next: null,
+  top: true,
+  unread: 0,
+  loaded: false,
+  isLoading: true
+});
+
+const notificationToMap = notification => Immutable.Map({
+  id: notification.id,
+  type: notification.type,
+  account: notification.account.id,
+  status: notification.status ? notification.status.id : null
+});
+
+const normalizeNotification = (state, notification) => {
+  if (!state.get('top')) {
+    state = state.update('unread', unread => unread + 1);
+  }
+
+  return state.update('items', list => list.unshift(notificationToMap(notification)));
+};
+
+const normalizeNotifications = (state, notifications, next) => {
+  let items    = Immutable.List();
+  const loaded = state.get('loaded');
+
+  notifications.forEach((n, i) => {
+    items = items.set(i, notificationToMap(n));
+  });
+
+  if (state.get('next') === null) {
+    state = state.set('next', next);
+  }
+
+  return state
+    .update('items', list => loaded ? items.concat(list) : list.concat(items))
+    .set('loaded', true)
+    .set('isLoading', false);
+};
+
+const appendNormalizedNotifications = (state, notifications, next) => {
+  let items = Immutable.List();
+
+  notifications.forEach((n, i) => {
+    items = items.set(i, notificationToMap(n));
+  });
+
+  return state
+    .update('items', list => list.concat(items))
+    .set('next', next)
+    .set('isLoading', false);
+};
+
+const filterNotifications = (state, relationship) => {
+  return state.update('items', list => list.filterNot(item => item.get('account') === relationship.id));
+};
+
+const updateTop = (state, top) => {
+  if (top) {
+    state = state.set('unread', 0);
+  }
+
+  return state.set('top', top);
+};
+
+export default function notifications(state = initialState, action) {
+  switch(action.type) {
+  case NOTIFICATIONS_REFRESH_REQUEST:
+  case NOTIFICATIONS_EXPAND_REQUEST:
+  case NOTIFICATIONS_REFRESH_FAIL:
+  case NOTIFICATIONS_EXPAND_FAIL:
+    return state.set('isLoading', true);
+  case NOTIFICATIONS_SCROLL_TOP:
+    return updateTop(state, action.top);
+  case NOTIFICATIONS_UPDATE:
+    return normalizeNotification(state, action.notification);
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+    return normalizeNotifications(state, action.notifications, action.next);
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+    return appendNormalizedNotifications(state, action.notifications, action.next);
+  case ACCOUNT_BLOCK_SUCCESS:
+    return filterNotifications(state, action.relationship);
+  case NOTIFICATIONS_CLEAR:
+    return state.set('items', Immutable.List()).set('next', null);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
new file mode 100644
index 000000000..c65c48b43
--- /dev/null
+++ b/app/javascript/mastodon/reducers/relationships.js
@@ -0,0 +1,38 @@
+import {
+  ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_SUCCESS,
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_UNBLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+  ACCOUNT_UNMUTE_SUCCESS,
+  RELATIONSHIPS_FETCH_SUCCESS
+} from '../actions/accounts';
+import Immutable from 'immutable';
+
+const normalizeRelationship = (state, relationship) => state.set(relationship.id, Immutable.fromJS(relationship));
+
+const normalizeRelationships = (state, relationships) => {
+  relationships.forEach(relationship => {
+    state = normalizeRelationship(state, relationship);
+  });
+
+  return state;
+};
+
+const initialState = Immutable.Map();
+
+export default function relationships(state = initialState, action) {
+  switch(action.type) {
+  case ACCOUNT_FOLLOW_SUCCESS:
+  case ACCOUNT_UNFOLLOW_SUCCESS:
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_UNBLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+  case ACCOUNT_UNMUTE_SUCCESS:
+    return normalizeRelationship(state, action.relationship);
+  case RELATIONSHIPS_FETCH_SUCCESS:
+    return normalizeRelationships(state, action.relationships);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/reports.js b/app/javascript/mastodon/reducers/reports.js
new file mode 100644
index 000000000..eab004377
--- /dev/null
+++ b/app/javascript/mastodon/reducers/reports.js
@@ -0,0 +1,60 @@
+import {
+  REPORT_INIT,
+  REPORT_SUBMIT_REQUEST,
+  REPORT_SUBMIT_SUCCESS,
+  REPORT_SUBMIT_FAIL,
+  REPORT_CANCEL,
+  REPORT_STATUS_TOGGLE,
+  REPORT_COMMENT_CHANGE
+} from '../actions/reports';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  new: Immutable.Map({
+    isSubmitting: false,
+    account_id: null,
+    status_ids: Immutable.Set(),
+    comment: ''
+  })
+});
+
+export default function reports(state = initialState, action) {
+  switch(action.type) {
+  case REPORT_INIT:
+    return state.withMutations(map => {
+      map.setIn(['new', 'isSubmitting'], false);
+      map.setIn(['new', 'account_id'], action.account.get('id'));
+
+      if (state.getIn(['new', 'account_id']) !== action.account.get('id')) {
+        map.setIn(['new', 'status_ids'], action.status ? Immutable.Set([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : Immutable.Set());
+        map.setIn(['new', 'comment'], '');
+      } else {
+        map.updateIn(['new', 'status_ids'], Immutable.Set(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id'))));
+      }
+    });
+  case REPORT_STATUS_TOGGLE:
+    return state.updateIn(['new', 'status_ids'], Immutable.Set(), set => {
+      if (action.checked) {
+        return set.add(action.statusId);
+      }
+
+      return set.remove(action.statusId);
+    });
+  case REPORT_COMMENT_CHANGE:
+    return state.setIn(['new', 'comment'], action.comment);
+  case REPORT_SUBMIT_REQUEST:
+    return state.setIn(['new', 'isSubmitting'], true);
+  case REPORT_SUBMIT_FAIL:
+    return state.setIn(['new', 'isSubmitting'], false);
+  case REPORT_CANCEL:
+  case REPORT_SUBMIT_SUCCESS:
+    return state.withMutations(map => {
+      map.setIn(['new', 'account_id'], null);
+      map.setIn(['new', 'status_ids'], Immutable.Set());
+      map.setIn(['new', 'comment'], '');
+      map.setIn(['new', 'isSubmitting'], false);
+    });
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
new file mode 100644
index 000000000..b3fe6c7be
--- /dev/null
+++ b/app/javascript/mastodon/reducers/search.js
@@ -0,0 +1,96 @@
+import {
+  SEARCH_CHANGE,
+  SEARCH_CLEAR,
+  SEARCH_FETCH_SUCCESS,
+  SEARCH_SHOW
+} from '../actions/search';
+import { COMPOSE_MENTION, COMPOSE_REPLY } from '../actions/compose';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  value: '',
+  submitted: false,
+  hidden: false,
+  results: Immutable.Map()
+});
+
+const normalizeSuggestions = (state, value, accounts, hashtags, statuses) => {
+  let newSuggestions = [];
+
+  if (accounts.length > 0) {
+    newSuggestions.push({
+      title: 'account',
+      items: accounts.map(item => ({
+        type: 'account',
+        id: item.id,
+        value: item.acct
+      }))
+    });
+  }
+
+  if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 || hashtags.length > 0) {
+    let hashtagItems = hashtags.map(item => ({
+      type: 'hashtag',
+      id: item,
+      value: `#${item}`
+    }));
+
+    if (value.indexOf('@') === -1 && value.indexOf(' ') === -1 && !value.startsWith('http://') && !value.startsWith('https://') && hashtags.indexOf(value) === -1) {
+      hashtagItems.unshift({
+        type: 'hashtag',
+        id: value,
+        value: `#${value}`
+      });
+    }
+
+    if (hashtagItems.length > 0) {
+      newSuggestions.push({
+        title: 'hashtag',
+        items: hashtagItems
+      });
+    }
+  }
+
+  if (statuses.length > 0) {
+    newSuggestions.push({
+      title: 'status',
+      items: statuses.map(item => ({
+        type: 'status',
+        id: item.id,
+        value: item.id
+      }))
+    });
+  }
+
+  return state.withMutations(map => {
+    map.set('suggestions', newSuggestions);
+    map.set('loaded_value', value);
+  });
+};
+
+export default function search(state = initialState, action) {
+  switch(action.type) {
+  case SEARCH_CHANGE:
+    return state.set('value', action.value);
+  case SEARCH_CLEAR:
+    return state.withMutations(map => {
+      map.set('value', '');
+      map.set('results', Immutable.Map());
+      map.set('submitted', false);
+      map.set('hidden', false);
+    });
+  case SEARCH_SHOW:
+    return state.set('hidden', false);
+  case COMPOSE_REPLY:
+  case COMPOSE_MENTION:
+    return state.set('hidden', true);
+  case SEARCH_FETCH_SUCCESS:
+    return state.set('results', Immutable.Map({
+      accounts: Immutable.List(action.results.accounts.map(item => item.id)),
+      statuses: Immutable.List(action.results.statuses.map(item => item.id)),
+      hashtags: Immutable.List(action.results.hashtags)
+    })).set('submitted', true);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
new file mode 100644
index 000000000..b255aabc4
--- /dev/null
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -0,0 +1,52 @@
+import { SETTING_CHANGE } from '../actions/settings';
+import { STORE_HYDRATE } from '../actions/store';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  onboarded: false,
+
+  home: Immutable.Map({
+    shows: Immutable.Map({
+      reblog: true,
+      reply: true
+    }),
+
+    regex: Immutable.Map({
+      body: ''
+    })
+  }),
+
+  notifications: Immutable.Map({
+    alerts: Immutable.Map({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true
+    }),
+
+    shows: Immutable.Map({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true
+    }),
+
+    sounds: Immutable.Map({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true
+    })
+  })
+});
+
+export default function settings(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.mergeDeep(action.state.get('settings'));
+  case SETTING_CHANGE:
+    return state.setIn(action.key, action.value);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js
new file mode 100644
index 000000000..fd463cd63
--- /dev/null
+++ b/app/javascript/mastodon/reducers/status_lists.js
@@ -0,0 +1,39 @@
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  favourites: Immutable.Map({
+    next: null,
+    loaded: false,
+    items: Immutable.List()
+  })
+});
+
+const normalizeList = (state, listType, statuses, next) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('next', next);
+    map.set('loaded', true);
+    map.set('items', Immutable.List(statuses.map(item => item.id)));
+  }));
+};
+
+const appendToList = (state, listType, statuses, next) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('next', next);
+    map.set('items', map.get('items').concat(statuses.map(item => item.id)));
+  }));
+};
+
+export default function statusLists(state = initialState, action) {
+  switch(action.type) {
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+    return normalizeList(state, 'favourites', action.statuses, action.next);
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+    return appendToList(state, 'favourites', action.statuses, action.next);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
new file mode 100644
index 000000000..2002d2223
--- /dev/null
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -0,0 +1,124 @@
+import {
+  REBLOG_REQUEST,
+  REBLOG_SUCCESS,
+  REBLOG_FAIL,
+  UNREBLOG_SUCCESS,
+  FAVOURITE_REQUEST,
+  FAVOURITE_SUCCESS,
+  FAVOURITE_FAIL,
+  UNFAVOURITE_SUCCESS
+} from '../actions/interactions';
+import {
+  STATUS_FETCH_SUCCESS,
+  CONTEXT_FETCH_SUCCESS
+} from '../actions/statuses';
+import {
+  TIMELINE_REFRESH_SUCCESS,
+  TIMELINE_UPDATE,
+  TIMELINE_DELETE,
+  TIMELINE_EXPAND_SUCCESS
+} from '../actions/timelines';
+import {
+  ACCOUNT_TIMELINE_FETCH_SUCCESS,
+  ACCOUNT_TIMELINE_EXPAND_SUCCESS,
+  ACCOUNT_BLOCK_SUCCESS
+} from '../actions/accounts';
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_REFRESH_SUCCESS,
+  NOTIFICATIONS_EXPAND_SUCCESS
+} from '../actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS
+} from '../actions/favourites';
+import { SEARCH_FETCH_SUCCESS } from '../actions/search';
+import Immutable from 'immutable';
+
+const normalizeStatus = (state, status) => {
+  if (!status) {
+    return state;
+  }
+
+  const normalStatus   = { ...status };
+  normalStatus.account = status.account.id;
+
+  if (status.reblog && status.reblog.id) {
+    state               = normalizeStatus(state, status.reblog);
+    normalStatus.reblog = status.reblog.id;
+  }
+
+  const linebreakComplemented = status.content.replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+  normalStatus.unescaped_content = new DOMParser().parseFromString(linebreakComplemented, 'text/html').documentElement.textContent;
+
+  return state.update(status.id, Immutable.Map(), map => map.mergeDeep(Immutable.fromJS(normalStatus)));
+};
+
+const normalizeStatuses = (state, statuses) => {
+  statuses.forEach(status => {
+    state = normalizeStatus(state, status);
+  });
+
+  return state;
+};
+
+const deleteStatus = (state, id, references) => {
+  references.forEach(ref => {
+    state = deleteStatus(state, ref[0], []);
+  });
+
+  return state.delete(id);
+};
+
+const filterStatuses = (state, relationship) => {
+  state.forEach(status => {
+    if (status.get('account') !== relationship.id) {
+      return;
+    }
+
+    state = deleteStatus(state, status.get('id'), state.filter(item => item.get('reblog') === status.get('id')));
+  });
+
+  return state;
+};
+
+const initialState = Immutable.Map();
+
+export default function statuses(state = initialState, action) {
+  switch(action.type) {
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+    return normalizeStatus(state, action.status);
+  case REBLOG_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+    return normalizeStatus(state, action.response);
+  case FAVOURITE_REQUEST:
+    return state.setIn([action.status.get('id'), 'favourited'], true);
+  case FAVOURITE_FAIL:
+    return state.setIn([action.status.get('id'), 'favourited'], false);
+  case REBLOG_REQUEST:
+    return state.setIn([action.status.get('id'), 'reblogged'], true);
+  case REBLOG_FAIL:
+    return state.setIn([action.status.get('id'), 'reblogged'], false);
+  case TIMELINE_REFRESH_SUCCESS:
+  case TIMELINE_EXPAND_SUCCESS:
+  case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+  case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case NOTIFICATIONS_REFRESH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
+    return normalizeStatuses(state, action.statuses);
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.references);
+  case ACCOUNT_BLOCK_SUCCESS:
+    return filterStatuses(state, action.relationship);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
new file mode 100644
index 000000000..31e79f9f6
--- /dev/null
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -0,0 +1,317 @@
+import {
+  TIMELINE_REFRESH_REQUEST,
+  TIMELINE_REFRESH_SUCCESS,
+  TIMELINE_REFRESH_FAIL,
+  TIMELINE_UPDATE,
+  TIMELINE_DELETE,
+  TIMELINE_EXPAND_SUCCESS,
+  TIMELINE_EXPAND_REQUEST,
+  TIMELINE_EXPAND_FAIL,
+  TIMELINE_SCROLL_TOP,
+  TIMELINE_CONNECT,
+  TIMELINE_DISCONNECT
+} from '../actions/timelines';
+import {
+  REBLOG_SUCCESS,
+  UNREBLOG_SUCCESS,
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS
+} from '../actions/interactions';
+import {
+  ACCOUNT_TIMELINE_FETCH_REQUEST,
+  ACCOUNT_TIMELINE_FETCH_SUCCESS,
+  ACCOUNT_TIMELINE_FETCH_FAIL,
+  ACCOUNT_TIMELINE_EXPAND_REQUEST,
+  ACCOUNT_TIMELINE_EXPAND_SUCCESS,
+  ACCOUNT_TIMELINE_EXPAND_FAIL,
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS
+} from '../actions/accounts';
+import {
+  CONTEXT_FETCH_SUCCESS
+} from '../actions/statuses';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  home: Immutable.Map({
+    path: () => '/api/v1/timelines/home',
+    next: null,
+    isLoading: false,
+    online: false,
+    loaded: false,
+    top: true,
+    unread: 0,
+    items: Immutable.List()
+  }),
+
+  public: Immutable.Map({
+    path: () => '/api/v1/timelines/public',
+    next: null,
+    isLoading: false,
+    online: false,
+    loaded: false,
+    top: true,
+    unread: 0,
+    items: Immutable.List()
+  }),
+
+  community: Immutable.Map({
+    path: () => '/api/v1/timelines/public',
+    next: null,
+    params: { local: true },
+    isLoading: false,
+    online: false,
+    loaded: false,
+    top: true,
+    unread: 0,
+    items: Immutable.List()
+  }),
+
+  tag: Immutable.Map({
+    path: (id) => `/api/v1/timelines/tag/${id}`,
+    next: null,
+    isLoading: false,
+    id: null,
+    loaded: false,
+    top: true,
+    unread: 0,
+    items: Immutable.List()
+  }),
+
+  accounts_timelines: Immutable.Map(),
+  ancestors: Immutable.Map(),
+  descendants: Immutable.Map()
+});
+
+const normalizeStatus = (state, status) => {
+  const replyToId = status.get('in_reply_to_id');
+  const id        = status.get('id');
+
+  if (replyToId) {
+    if (!state.getIn(['descendants', replyToId], Immutable.List()).includes(id)) {
+      state = state.updateIn(['descendants', replyToId], Immutable.List(), set => set.push(id));
+    }
+
+    if (!state.getIn(['ancestors', id], Immutable.List()).includes(replyToId)) {
+      state = state.updateIn(['ancestors', id], Immutable.List(), set => set.push(replyToId));
+    }
+  }
+
+  return state;
+};
+
+const normalizeTimeline = (state, timeline, statuses, next) => {
+  let ids      = Immutable.List();
+  const loaded = state.getIn([timeline, 'loaded']);
+
+  statuses.forEach((status, i) => {
+    state = normalizeStatus(state, status);
+    ids   = ids.set(i, status.get('id'));
+  });
+
+  state = state.setIn([timeline, 'loaded'], true);
+  state = state.setIn([timeline, 'isLoading'], false);
+
+  if (state.getIn([timeline, 'next']) === null) {
+    state = state.setIn([timeline, 'next'], next);
+  }
+
+  return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? ids.concat(list) : ids));
+};
+
+const appendNormalizedTimeline = (state, timeline, statuses, next) => {
+  let moreIds = Immutable.List();
+
+  statuses.forEach((status, i) => {
+    state   = normalizeStatus(state, status);
+    moreIds = moreIds.set(i, status.get('id'));
+  });
+
+  state = state.setIn([timeline, 'isLoading'], false);
+  state = state.setIn([timeline, 'next'], next);
+
+  return state.updateIn([timeline, 'items'], Immutable.List(), list => list.concat(moreIds));
+};
+
+const normalizeAccountTimeline = (state, accountId, statuses, replace = false) => {
+  let ids = Immutable.List();
+
+  statuses.forEach((status, i) => {
+    state = normalizeStatus(state, status);
+    ids   = ids.set(i, status.get('id'));
+  });
+
+  return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map
+    .set('isLoading', false)
+    .set('loaded', true)
+    .set('next', true)
+    .update('items', Immutable.List(), list => (replace ? ids : ids.concat(list))));
+};
+
+const appendNormalizedAccountTimeline = (state, accountId, statuses, next) => {
+  let moreIds = Immutable.List([]);
+
+  statuses.forEach((status, i) => {
+    state   = normalizeStatus(state, status);
+    moreIds = moreIds.set(i, status.get('id'));
+  });
+
+  return state.updateIn(['accounts_timelines', accountId], Immutable.Map(), map => map
+    .set('isLoading', false)
+    .set('next', next)
+    .update('items', list => list.concat(moreIds)));
+};
+
+const updateTimeline = (state, timeline, status, references) => {
+  const top = state.getIn([timeline, 'top']);
+
+  state = normalizeStatus(state, status);
+
+  if (!top) {
+    state = state.updateIn([timeline, 'unread'], unread => unread + 1);
+  }
+
+  state = state.updateIn([timeline, 'items'], Immutable.List(), list => {
+    if (top && list.size > 40) {
+      list = list.take(20);
+    }
+
+    if (list.includes(status.get('id'))) {
+      return list;
+    }
+
+    const reblogOfId = status.getIn(['reblog', 'id'], null);
+
+    if (reblogOfId !== null) {
+      list = list.filterNot(itemId => references.includes(itemId));
+    }
+
+    return list.unshift(status.get('id'));
+  });
+
+  return state;
+};
+
+const deleteStatus = (state, id, accountId, references, reblogOf) => {
+  if (reblogOf) {
+    // If we are deleting a reblog, just replace reblog with its original
+    return state.updateIn(['home', 'items'], list => list.map(item => item === id ? reblogOf : item));
+  }
+
+  // Remove references from timelines
+  ['home', 'public', 'community', 'tag'].forEach(function (timeline) {
+    state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
+  });
+
+  // Remove references from account timelines
+  state = state.updateIn(['accounts_timelines', accountId, 'items'], Immutable.List([]), list => list.filterNot(item => item === id));
+
+  // Remove references from context
+  state.getIn(['descendants', id], Immutable.List()).forEach(descendantId => {
+    state = state.updateIn(['ancestors', descendantId], Immutable.List(), list => list.filterNot(itemId => itemId === id));
+  });
+
+  state.getIn(['ancestors', id], Immutable.List()).forEach(ancestorId => {
+    state = state.updateIn(['descendants', ancestorId], Immutable.List(), list => list.filterNot(itemId => itemId === id));
+  });
+
+  state = state.deleteIn(['descendants', id]).deleteIn(['ancestors', id]);
+
+  // Remove reblogs of deleted status
+  references.forEach(ref => {
+    state = deleteStatus(state, ref[0], ref[1], []);
+  });
+
+  return state;
+};
+
+const filterTimelines = (state, relationship, statuses) => {
+  let references;
+
+  statuses.forEach(status => {
+    if (status.get('account') !== relationship.id) {
+      return;
+    }
+
+    references = statuses.filter(item => item.get('reblog') === status.get('id')).map(item => [item.get('id'), item.get('account')]);
+    state = deleteStatus(state, status.get('id'), status.get('account'), references);
+  });
+
+  return state;
+};
+
+const normalizeContext = (state, id, ancestors, descendants) => {
+  const ancestorsIds   = ancestors.map(ancestor => ancestor.get('id'));
+  const descendantsIds = descendants.map(descendant => descendant.get('id'));
+
+  return state.withMutations(map => {
+    map.setIn(['ancestors', id], ancestorsIds);
+    map.setIn(['descendants', id], descendantsIds);
+  });
+};
+
+const resetTimeline = (state, timeline, id) => {
+  if (timeline === 'tag' && typeof id !== 'undefined' && state.getIn([timeline, 'id']) !== id) {
+    state = state.update(timeline, map => map
+        .set('id', id)
+        .set('isLoading', true)
+        .set('loaded', false)
+        .set('next', null)
+        .set('top', true)
+        .update('items', list => list.clear()));
+  } else {
+    state = state.setIn([timeline, 'isLoading'], true);
+  }
+
+  return state;
+};
+
+const updateTop = (state, timeline, top) => {
+  if (top) {
+    state = state.setIn([timeline, 'unread'], 0);
+  }
+
+  return state.setIn([timeline, 'top'], top);
+};
+
+export default function timelines(state = initialState, action) {
+  switch(action.type) {
+  case TIMELINE_REFRESH_REQUEST:
+  case TIMELINE_EXPAND_REQUEST:
+    return resetTimeline(state, action.timeline, action.id);
+  case TIMELINE_REFRESH_FAIL:
+  case TIMELINE_EXPAND_FAIL:
+    return state.setIn([action.timeline, 'isLoading'], false);
+  case TIMELINE_REFRESH_SUCCESS:
+    return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next);
+  case TIMELINE_EXPAND_SUCCESS:
+    return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.next);
+  case TIMELINE_UPDATE:
+    return updateTimeline(state, action.timeline, Immutable.fromJS(action.status), action.references);
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
+  case CONTEXT_FETCH_SUCCESS:
+    return normalizeContext(state, action.id, Immutable.fromJS(action.ancestors), Immutable.fromJS(action.descendants));
+  case ACCOUNT_TIMELINE_FETCH_REQUEST:
+  case ACCOUNT_TIMELINE_EXPAND_REQUEST:
+    return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', true));
+  case ACCOUNT_TIMELINE_FETCH_FAIL:
+  case ACCOUNT_TIMELINE_EXPAND_FAIL:
+    return state.updateIn(['accounts_timelines', action.id], Immutable.Map(), map => map.set('isLoading', false));
+  case ACCOUNT_TIMELINE_FETCH_SUCCESS:
+    return normalizeAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.replace);
+  case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
+    return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses), action.next);
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return filterTimelines(state, action.relationship, action.statuses);
+  case TIMELINE_SCROLL_TOP:
+    return updateTop(state, action.timeline, action.top);
+  case TIMELINE_CONNECT:
+    return state.setIn([action.timeline, 'online'], true);
+  case TIMELINE_DISCONNECT:
+    return state.setIn([action.timeline, 'online'], false);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js
new file mode 100644
index 000000000..af9492119
--- /dev/null
+++ b/app/javascript/mastodon/reducers/user_lists.js
@@ -0,0 +1,80 @@
+import {
+  FOLLOWERS_FETCH_SUCCESS,
+  FOLLOWERS_EXPAND_SUCCESS,
+  FOLLOWING_FETCH_SUCCESS,
+  FOLLOWING_EXPAND_SUCCESS,
+  FOLLOW_REQUESTS_FETCH_SUCCESS,
+  FOLLOW_REQUESTS_EXPAND_SUCCESS,
+  FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+  FOLLOW_REQUEST_REJECT_SUCCESS
+} from '../actions/accounts';
+import {
+  REBLOGS_FETCH_SUCCESS,
+  FAVOURITES_FETCH_SUCCESS
+} from '../actions/interactions';
+import {
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_EXPAND_SUCCESS
+} from '../actions/blocks';
+import {
+  MUTES_FETCH_SUCCESS,
+  MUTES_EXPAND_SUCCESS
+} from '../actions/mutes';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  followers: Immutable.Map(),
+  following: Immutable.Map(),
+  reblogged_by: Immutable.Map(),
+  favourited_by: Immutable.Map(),
+  follow_requests: Immutable.Map(),
+  blocks: Immutable.Map(),
+  mutes: Immutable.Map()
+});
+
+const normalizeList = (state, type, id, accounts, next) => {
+  return state.setIn([type, id], Immutable.Map({
+    next,
+    items: Immutable.List(accounts.map(item => item.id))
+  }));
+};
+
+const appendToList = (state, type, id, accounts, next) => {
+  return state.updateIn([type, id], map => {
+    return map.set('next', next).update('items', list => list.concat(accounts.map(item => item.id)));
+  });
+};
+
+export default function userLists(state = initialState, action) {
+  switch(action.type) {
+  case FOLLOWERS_FETCH_SUCCESS:
+    return normalizeList(state, 'followers', action.id, action.accounts, action.next);
+  case FOLLOWERS_EXPAND_SUCCESS:
+    return appendToList(state, 'followers', action.id, action.accounts, action.next);
+  case FOLLOWING_FETCH_SUCCESS:
+    return normalizeList(state, 'following', action.id, action.accounts, action.next);
+  case FOLLOWING_EXPAND_SUCCESS:
+    return appendToList(state, 'following', action.id, action.accounts, action.next);
+  case REBLOGS_FETCH_SUCCESS:
+    return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
+  case FAVOURITES_FETCH_SUCCESS:
+    return state.setIn(['favourited_by', action.id], Immutable.List(action.accounts.map(item => item.id)));
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+    return state.setIn(['follow_requests', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+  case FOLLOW_REQUESTS_EXPAND_SUCCESS:
+    return state.updateIn(['follow_requests', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['follow_requests', 'next'], action.next);
+  case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
+  case FOLLOW_REQUEST_REJECT_SUCCESS:
+    return state.updateIn(['follow_requests', 'items'], list => list.filterNot(item => item === action.id));
+  case BLOCKS_FETCH_SUCCESS:
+    return state.setIn(['blocks', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
+  case BLOCKS_EXPAND_SUCCESS:
+    return state.updateIn(['blocks', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['blocks', 'next'], action.next);
+  case MUTES_FETCH_SUCCESS:
+    return state.setIn(['mutes', 'items'], Immutable.List(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
+  case MUTES_EXPAND_SUCCESS:
+    return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next);
+  default:
+    return state;
+  }
+};