about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/reducers
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch/reducers')
-rw-r--r--app/javascript/flavours/glitch/reducers/accounts.js172
-rw-r--r--app/javascript/flavours/glitch/reducers/accounts_counters.js150
-rw-r--r--app/javascript/flavours/glitch/reducers/alerts.js25
-rw-r--r--app/javascript/flavours/glitch/reducers/cards.js14
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js427
-rw-r--r--app/javascript/flavours/glitch/reducers/contexts.js80
-rw-r--r--app/javascript/flavours/glitch/reducers/custom_emojis.js15
-rw-r--r--app/javascript/flavours/glitch/reducers/domain_lists.js25
-rw-r--r--app/javascript/flavours/glitch/reducers/dropdown_menu.js18
-rw-r--r--app/javascript/flavours/glitch/reducers/filters.js11
-rw-r--r--app/javascript/flavours/glitch/reducers/height_cache.js23
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js66
-rw-r--r--app/javascript/flavours/glitch/reducers/list_editor.js89
-rw-r--r--app/javascript/flavours/glitch/reducers/lists.js37
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js59
-rw-r--r--app/javascript/flavours/glitch/reducers/media_attachments.js15
-rw-r--r--app/javascript/flavours/glitch/reducers/meta.js16
-rw-r--r--app/javascript/flavours/glitch/reducers/modal.js17
-rw-r--r--app/javascript/flavours/glitch/reducers/mutes.js29
-rw-r--r--app/javascript/flavours/glitch/reducers/notifications.js245
-rw-r--r--app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js57
-rw-r--r--app/javascript/flavours/glitch/reducers/push_notifications.js51
-rw-r--r--app/javascript/flavours/glitch/reducers/relationships.js58
-rw-r--r--app/javascript/flavours/glitch/reducers/reports.js64
-rw-r--r--app/javascript/flavours/glitch/reducers/search.js47
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js127
-rw-r--r--app/javascript/flavours/glitch/reducers/status_lists.js116
-rw-r--r--app/javascript/flavours/glitch/reducers/statuses.js149
-rw-r--r--app/javascript/flavours/glitch/reducers/timelines.js144
-rw-r--r--app/javascript/flavours/glitch/reducers/user_lists.js80
30 files changed, 2426 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/reducers/accounts.js b/app/javascript/flavours/glitch/reducers/accounts.js
new file mode 100644
index 000000000..c2f016a87
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/accounts.js
@@ -0,0 +1,172 @@
+import {
+  ACCOUNT_FETCH_SUCCESS,
+  FOLLOWERS_FETCH_SUCCESS,
+  FOLLOWERS_EXPAND_SUCCESS,
+  FOLLOWING_FETCH_SUCCESS,
+  FOLLOWING_EXPAND_SUCCESS,
+  FOLLOW_REQUESTS_FETCH_SUCCESS,
+  FOLLOW_REQUESTS_EXPAND_SUCCESS,
+  PINNED_ACCOUNTS_FETCH_SUCCESS,
+  PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY,
+} from 'flavours/glitch/actions/accounts';
+import {
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/blocks';
+import {
+  MUTES_FETCH_SUCCESS,
+  MUTES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/mutes';
+import { COMPOSE_SUGGESTIONS_READY } from 'flavours/glitch/actions/compose';
+import {
+  REBLOG_SUCCESS,
+  UNREBLOG_SUCCESS,
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS,
+  BOOKMARK_SUCCESS,
+  UNBOOKMARK_SUCCESS,
+  REBLOGS_FETCH_SUCCESS,
+  FAVOURITES_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/interactions';
+import {
+  TIMELINE_UPDATE,
+  TIMELINE_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/timelines';
+import {
+  STATUS_FETCH_SUCCESS,
+  CONTEXT_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/statuses';
+import { SEARCH_FETCH_SUCCESS } from 'flavours/glitch/actions/search';
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/favourites';
+import {
+  BOOKMARKED_STATUSES_FETCH_SUCCESS,
+  BOOKMARKED_STATUSES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/bookmarks';
+import {
+  LIST_ACCOUNTS_FETCH_SUCCESS,
+  LIST_EDITOR_SUGGESTIONS_READY,
+} from 'flavours/glitch/actions/lists';
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import emojify from 'flavours/glitch/util/emoji';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
+import { unescapeHTML } from 'flavours/glitch/util/html';
+
+const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
+  obj[`:${emoji.shortcode}:`] = emoji;
+  return obj;
+}, {});
+
+const normalizeAccount = (state, account) => {
+  account = { ...account };
+
+  delete account.followers_count;
+  delete account.following_count;
+  delete account.statuses_count;
+
+  const emojiMap = makeEmojiMap(account);
+  const displayName = account.display_name.length === 0 ? account.username : account.display_name;
+  account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
+  account.note_emojified = emojify(account.note, emojiMap);
+
+  if (account.fields) {
+    account.fields = account.fields.map(pair => ({
+      ...pair,
+      name_emojified: emojify(escapeTextContentForBrowser(pair.name)),
+      value_emojified: emojify(pair.value, emojiMap),
+      value_plain: unescapeHTML(pair.value),
+    }));
+  }
+
+  if (account.moved) {
+    state = normalizeAccount(state, account.moved);
+    account.moved = account.moved.id;
+  }
+
+  return state.set(account.id, 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 = 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:
+    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:
+  case PINNED_ACCOUNTS_FETCH_SUCCESS:
+  case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY:
+    return action.accounts ? normalizeAccounts(state, action.accounts) : state;
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
+    return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
+  case TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+  case BOOKMARKED_STATUSES_FETCH_SUCCESS:
+  case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
+    return normalizeAccountsFromStatuses(state, action.statuses);
+  case REBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+  case BOOKMARK_SUCCESS:
+  case UNBOOKMARK_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/flavours/glitch/reducers/accounts_counters.js b/app/javascript/flavours/glitch/reducers/accounts_counters.js
new file mode 100644
index 000000000..64dff9b55
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/accounts_counters.js
@@ -0,0 +1,150 @@
+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 'flavours/glitch/actions/accounts';
+import {
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/blocks';
+import {
+  MUTES_FETCH_SUCCESS,
+  MUTES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/mutes';
+import { COMPOSE_SUGGESTIONS_READY } from 'flavours/glitch/actions/compose';
+import {
+  REBLOG_SUCCESS,
+  UNREBLOG_SUCCESS,
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS,
+  BOOKMARK_SUCCESS,
+  UNBOOKMARK_SUCCESS,
+  REBLOGS_FETCH_SUCCESS,
+  FAVOURITES_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/interactions';
+import {
+  TIMELINE_UPDATE,
+  TIMELINE_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/timelines';
+import {
+  STATUS_FETCH_SUCCESS,
+  CONTEXT_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/statuses';
+import { SEARCH_FETCH_SUCCESS } from 'flavours/glitch/actions/search';
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/favourites';
+import {
+  BOOKMARKED_STATUSES_FETCH_SUCCESS,
+  BOOKMARKED_STATUSES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/bookmarks';
+import {
+  LIST_ACCOUNTS_FETCH_SUCCESS,
+  LIST_EDITOR_SUGGESTIONS_READY,
+} from 'flavours/glitch/actions/lists';
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const normalizeAccount = (state, account) => state.set(account.id, 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 = 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:
+    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_EXPAND_SUCCESS:
+  case SEARCH_FETCH_SUCCESS:
+    return normalizeAccountsFromStatuses(normalizeAccounts(state, action.accounts), action.statuses);
+  case TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+  case BOOKMARKED_STATUSES_FETCH_SUCCESS:
+  case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
+    return normalizeAccountsFromStatuses(state, action.statuses);
+  case REBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+  case BOOKMARK_SUCCESS:
+  case UNBOOKMARK_SUCCESS:
+    return normalizeAccountFromStatus(state, action.response);
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+    return normalizeAccountFromStatus(state, action.status);
+  case ACCOUNT_FOLLOW_SUCCESS:
+    if (action.alreadyFollowing) {
+      return state;
+    }
+    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/flavours/glitch/reducers/alerts.js b/app/javascript/flavours/glitch/reducers/alerts.js
new file mode 100644
index 000000000..50f8d30f7
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/alerts.js
@@ -0,0 +1,25 @@
+import {
+  ALERT_SHOW,
+  ALERT_DISMISS,
+  ALERT_CLEAR,
+} from 'flavours/glitch/actions/alerts';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableList([]);
+
+export default function alerts(state = initialState, action) {
+  switch(action.type) {
+  case ALERT_SHOW:
+    return state.push(ImmutableMap({
+      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/flavours/glitch/reducers/cards.js b/app/javascript/flavours/glitch/reducers/cards.js
new file mode 100644
index 000000000..92ecfd086
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/cards.js
@@ -0,0 +1,14 @@
+import { STATUS_CARD_FETCH_SUCCESS } from 'flavours/glitch/actions/cards';
+
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const initialState = ImmutableMap();
+
+export default function cards(state = initialState, action) {
+  switch(action.type) {
+  case STATUS_CARD_FETCH_SUCCESS:
+    return state.set(action.id, fromJS(action.card));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
new file mode 100644
index 000000000..0ddff707e
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -0,0 +1,427 @@
+import {
+  COMPOSE_MOUNT,
+  COMPOSE_UNMOUNT,
+  COMPOSE_CHANGE,
+  COMPOSE_CYCLE_ELEFRIEND,
+  COMPOSE_REPLY,
+  COMPOSE_REPLY_CANCEL,
+  COMPOSE_DIRECT,
+  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_SUGGESTION_TAGS_UPDATE,
+  COMPOSE_TAG_HISTORY_UPDATE,
+  COMPOSE_ADVANCED_OPTIONS_CHANGE,
+  COMPOSE_SENSITIVITY_CHANGE,
+  COMPOSE_SPOILERNESS_CHANGE,
+  COMPOSE_SPOILER_TEXT_CHANGE,
+  COMPOSE_VISIBILITY_CHANGE,
+  COMPOSE_EMOJI_INSERT,
+  COMPOSE_UPLOAD_CHANGE_REQUEST,
+  COMPOSE_UPLOAD_CHANGE_SUCCESS,
+  COMPOSE_UPLOAD_CHANGE_FAIL,
+  COMPOSE_DOODLE_SET,
+  COMPOSE_RESET,
+} from 'flavours/glitch/actions/compose';
+import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { REDRAFT } from 'flavours/glitch/actions/statuses';
+import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
+import uuid from 'flavours/glitch/util/uuid';
+import { privacyPreference } from 'flavours/glitch/util/privacy_preference';
+import { me } from 'flavours/glitch/util/initial_state';
+import { overwrite } from 'flavours/glitch/util/js_helpers';
+import { unescapeHTML } from 'flavours/glitch/util/html';
+import { recoverHashtags } from 'flavours/glitch/util/hashtag';
+
+const totalElefriends = 3;
+
+// ~4% chance you'll end up with an unexpected friend
+// glitch-soc/mastodon repo created_at date: 2017-04-20T21:55:28Z
+const glitchProbability = 1 - 0.0420215528;
+
+const initialState = ImmutableMap({
+  mounted: false,
+  advanced_options: ImmutableMap({
+    do_not_federate: false,
+    threaded_mode: false,
+  }),
+  sensitive: false,
+  elefriend: Math.random() < glitchProbability ? Math.floor(Math.random() * totalElefriends) : totalElefriends,
+  spoiler: false,
+  spoiler_text: '',
+  privacy: null,
+  text: '',
+  focusDate: null,
+  caretPosition: null,
+  preselectDate: null,
+  in_reply_to: null,
+  is_submitting: false,
+  is_uploading: false,
+  progress: 0,
+  media_attachments: ImmutableList(),
+  suggestion_token: null,
+  suggestions: ImmutableList(),
+  default_advanced_options: ImmutableMap({
+    do_not_federate: false,
+    threaded_mode: null,  //  Do not reset
+  }),
+  default_privacy: 'public',
+  default_sensitive: false,
+  resetFileKey: Math.floor((Math.random() * 0x10000)),
+  idempotencyKey: null,
+  tagHistory: ImmutableList(),
+  doodle: ImmutableMap({
+    fg: 'rgb(  0,    0,    0)',
+    bg: 'rgb(255,  255,  255)',
+    swapped: false,
+    mode: 'draw',
+    size: 'normal',
+    weight: 2,
+    opacity: 1,
+    adaptiveStroke: true,
+    smoothing: false,
+  }),
+});
+
+function statusToTextMentions(state, status) {
+  let set = ImmutableOrderedSet([]);
+
+  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 apiStatusToTextMentions (state, status) {
+  let set = ImmutableOrderedSet([]);
+
+  if (status.account.id !== me) {
+    set = set.add(`@${status.account.acct} `);
+  }
+
+  return set.union(status.mentions.filter(
+    mention => mention.id !== me
+  ).map(
+    mention => `@${mention.acct} `
+  )).join('');
+}
+
+function apiStatusToTextHashtags (state, status) {
+  const text = unescapeHTML(status.content);
+  return ImmutableOrderedSet([]).union(recoverHashtags(status.tags, text).map(
+    (name) => `#${name} `
+  )).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.update(
+      'advanced_options',
+      map => map.mergeWith(overwrite, state.get('default_advanced_options'))
+    );
+    map.set('privacy', state.get('default_privacy'));
+    map.set('sensitive', false);
+    map.update('media_attachments', list => list.clear());
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+function continueThread (state, status) {
+  return state.withMutations(function (map) {
+    let text = apiStatusToTextMentions(state, status);
+    text = text + apiStatusToTextHashtags(state, status);
+    map.set('text', text);
+    if (status.spoiler_text) {
+      map.set('spoiler', true);
+      map.set('spoiler_text', status.spoiler_text);
+    } else {
+      map.set('spoiler', false);
+      map.set('spoiler_text', '');
+    }
+    map.set('is_submitting', false);
+    map.set('in_reply_to', status.id);
+    map.update(
+      'advanced_options',
+      map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(status.content) }))
+    );
+    map.set('privacy', status.visibility);
+    map.set('sensitive', false);
+    map.update('media_attachments', list => list.clear());
+    map.set('idempotencyKey', uuid());
+    map.set('focusDate', new Date());
+    map.set('caretPosition', null);
+    map.set('preselectDate', new Date());
+  });
+}
+
+function appendMedia(state, media) {
+  const prevSize = state.get('media_attachments').size;
+
+  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.set('idempotencyKey', uuid());
+
+    if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
+      map.set('sensitive', true);
+    }
+  });
+};
+
+function removeMedia(state, 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.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}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`);
+    map.set('suggestion_token', null);
+    map.update('suggestions', ImmutableList(), list => list.clear());
+    map.set('focusDate', new Date());
+    map.set('caretPosition', position + completion.length + 1);
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+const updateSuggestionTags = (state, token) => {
+  const prefix = token.slice(1);
+
+  return state.merge({
+    suggestions: state.get('tagHistory')
+      .filter(tag => tag.toLowerCase().startsWith(prefix.toLowerCase()))
+      .slice(0, 4)
+      .map(tag => '#' + tag),
+    suggestion_token: token,
+  });
+};
+
+const insertEmoji = (state, position, emojiData) => {
+  const emoji = emojiData.native;
+
+  return state.withMutations(map => {
+    map.update('text', oldText => `${oldText.slice(0, position)}${emoji}\u200B${oldText.slice(position)}`);
+    map.set('focusDate', new Date());
+    map.set('caretPosition', position + emoji.length + 1);
+    map.set('idempotencyKey', uuid());
+  });
+};
+
+const hydrate = (state, hydratedState) => {
+  state = clearAll(state.merge(hydratedState));
+
+  if (hydratedState.has('text')) {
+    state = state.set('text', hydratedState.get('text'));
+  }
+
+  return state;
+};
+
+const domParser = new DOMParser();
+
+const expandMentions = status => {
+  const fragment = domParser.parseFromString(status.get('content'), 'text/html').documentElement;
+
+  status.get('mentions').forEach(mention => {
+    fragment.querySelector(`a[href="${mention.get('url')}"]`).textContent = `@${mention.get('acct')}`;
+  });
+
+  return fragment.innerHTML;
+};
+
+export default function compose(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return hydrate(state, action.state.get('compose'));
+  case COMPOSE_MOUNT:
+    return state.set('mounted', true);
+  case COMPOSE_UNMOUNT:
+    return state.set('mounted', false);
+  case COMPOSE_ADVANCED_OPTIONS_CHANGE:
+    return state
+      .set('advanced_options', state.get('advanced_options').set(action.option, !!overwrite(!state.getIn(['advanced_options', action.option]), action.value)))
+      .set('idempotencyKey', uuid());
+  case COMPOSE_SENSITIVITY_CHANGE:
+    return state.withMutations(map => {
+      if (!state.get('spoiler')) {
+        map.set('sensitive', !state.get('sensitive'));
+      }
+
+      map.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());
+
+      if (!state.get('sensitive') && state.get('media_attachments').size >= 1) {
+        map.set('sensitive', true);
+      }
+    });
+  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_CYCLE_ELEFRIEND:
+    return state
+      .set('elefriend', (state.get('elefriend') + 1) % totalElefriends);
+  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.update(
+        'advanced_options',
+        map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(action.status.get('content')) }))
+      );
+      map.set('focusDate', new Date());
+      map.set('caretPosition', null);
+      map.set('preselectDate', new Date());
+      map.set('idempotencyKey', uuid());
+
+      if (action.status.get('spoiler_text').length > 0) {
+        let spoiler_text = action.status.get('spoiler_text');
+        if (!spoiler_text.match(/^re[: ]/i)) {
+          spoiler_text = 're: '.concat(spoiler_text);
+        }
+        map.set('spoiler', true);
+        map.set('spoiler_text', spoiler_text);
+      } else {
+        map.set('spoiler', false);
+        map.set('spoiler_text', '');
+      }
+    });
+  case COMPOSE_REPLY_CANCEL:
+    state = state.setIn(['advanced_options', 'threaded_mode'], false);
+  case COMPOSE_RESET:
+    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.update(
+        'advanced_options',
+        map => map.mergeWith(overwrite, state.get('default_advanced_options'))
+      );
+      map.set('idempotencyKey', uuid());
+    });
+  case COMPOSE_SUBMIT_REQUEST:
+  case COMPOSE_UPLOAD_CHANGE_REQUEST:
+    return state.set('is_submitting', true);
+  case COMPOSE_SUBMIT_SUCCESS:
+    return action.status && state.getIn(['advanced_options', 'threaded_mode']) ? continueThread(state, action.status) : clearAll(state);
+  case COMPOSE_SUBMIT_FAIL:
+  case COMPOSE_UPLOAD_CHANGE_FAIL:
+    return state.set('is_submitting', false);
+  case COMPOSE_UPLOAD_REQUEST:
+    return state.set('is_uploading', true);
+  case COMPOSE_UPLOAD_SUCCESS:
+    return appendMedia(state, 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.withMutations(map => {
+      map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
+      map.set('focusDate', new Date());
+      map.set('caretPosition', null);
+      map.set('idempotencyKey', uuid());
+    });
+  case COMPOSE_DIRECT:
+    return state.withMutations(map => {
+      map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
+      map.set('privacy', 'direct');
+      map.set('focusDate', new Date());
+      map.set('caretPosition', null);
+      map.set('idempotencyKey', uuid());
+    });
+  case COMPOSE_SUGGESTIONS_CLEAR:
+    return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
+  case COMPOSE_SUGGESTIONS_READY:
+    return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token);
+  case COMPOSE_SUGGESTION_SELECT:
+    return insertSuggestion(state, action.position, action.token, action.completion);
+  case COMPOSE_SUGGESTION_TAGS_UPDATE:
+    return updateSuggestionTags(state, action.token);
+  case COMPOSE_TAG_HISTORY_UPDATE:
+    return state.set('tagHistory', fromJS(action.tags));
+  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);
+  case COMPOSE_UPLOAD_CHANGE_SUCCESS:
+    return state
+      .set('is_submitting', false)
+      .update('media_attachments', list => list.map(item => {
+        if (item.get('id') === action.media.id) {
+          return fromJS(action.media);
+        }
+
+        return item;
+      }));
+  case COMPOSE_DOODLE_SET:
+    return state.mergeIn(['doodle'], action.options);
+  case REDRAFT:
+    return state.withMutations(map => {
+      map.set('text', unescapeHTML(expandMentions(action.status)));
+      map.set('in_reply_to', action.status.get('in_reply_to_id'));
+      map.set('privacy', action.status.get('visibility'));
+      map.set('media_attachments', action.status.get('media_attachments'));
+      map.set('focusDate', new Date());
+      map.set('caretPosition', null);
+      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', '');
+      }
+    });
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/contexts.js b/app/javascript/flavours/glitch/reducers/contexts.js
new file mode 100644
index 000000000..effd70756
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/contexts.js
@@ -0,0 +1,80 @@
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import { CONTEXT_FETCH_SUCCESS } from 'flavours/glitch/actions/statuses';
+import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from 'flavours/glitch/actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableMap({
+  ancestors: ImmutableMap(),
+  descendants: ImmutableMap(),
+});
+
+const normalizeContext = (state, id, ancestors, descendants) => {
+  const ancestorsIds   = ImmutableList(ancestors.map(ancestor => ancestor.id));
+  const descendantsIds = ImmutableList(descendants.map(descendant => descendant.id));
+
+  return state.withMutations(map => {
+    map.setIn(['ancestors', id], ancestorsIds);
+    map.setIn(['descendants', id], descendantsIds);
+  });
+};
+
+const deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => {
+  state.update('ancestors', immutableAncestors => immutableAncestors.withMutations(ancestors => {
+    state.update('descendants', immutableDescendants => immutableDescendants.withMutations(descendants => {
+      ids.forEach(id => {
+        descendants.get(id, ImmutableList()).forEach(descendantId => {
+          ancestors.update(descendantId, ImmutableList(), list => list.filterNot(itemId => itemId === id));
+        });
+
+        ancestors.get(id, ImmutableList()).forEach(ancestorId => {
+          descendants.update(ancestorId, ImmutableList(), list => list.filterNot(itemId => itemId === id));
+        });
+
+        descendants.delete(id);
+        ancestors.delete(id);
+      });
+    }));
+  }));
+});
+
+const filterContexts = (state, relationship, statuses) => {
+  const ownedStatusIds = statuses.filter(status => status.get('account') === relationship.id)
+                                 .map(status => status.get('id'));
+
+  return deleteFromContexts(state, ownedStatusIds);
+};
+
+const updateContext = (state, status, references) => {
+  return state.update('descendants', map => {
+    references.forEach(parentId => {
+      map = map.update(parentId, ImmutableList(), list => {
+        if (list.includes(status.id)) {
+          return list;
+        }
+
+        return list.push(status.id);
+      });
+    });
+
+    return map;
+  });
+};
+
+export default function contexts(state = initialState, action) {
+  switch(action.type) {
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return filterContexts(state, action.relationship, action.statuses);
+  case CONTEXT_FETCH_SUCCESS:
+    return normalizeContext(state, action.id, action.ancestors, action.descendants);
+  case TIMELINE_DELETE:
+    return deleteFromContexts(state, [action.id]);
+  case TIMELINE_CONTEXT_UPDATE:
+    return updateContext(state, action.status, action.references);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/custom_emojis.js b/app/javascript/flavours/glitch/reducers/custom_emojis.js
new file mode 100644
index 000000000..90e3040a4
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/custom_emojis.js
@@ -0,0 +1,15 @@
+import { List as ImmutableList, fromJS as ConvertToImmutable } from 'immutable';
+import { CUSTOM_EMOJIS_FETCH_SUCCESS } from 'flavours/glitch/actions/custom_emojis';
+import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light';
+import { buildCustomEmojis } from 'flavours/glitch/util/emoji';
+
+const initialState = ImmutableList([]);
+
+export default function custom_emojis(state = initialState, action) {
+  if(action.type === CUSTOM_EMOJIS_FETCH_SUCCESS) {
+    state = ConvertToImmutable(action.custom_emojis);
+    emojiSearch('', { custom: buildCustomEmojis(state) });
+  }
+
+  return state;
+};
diff --git a/app/javascript/flavours/glitch/reducers/domain_lists.js b/app/javascript/flavours/glitch/reducers/domain_lists.js
new file mode 100644
index 000000000..eff97fbd6
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/domain_lists.js
@@ -0,0 +1,25 @@
+import {
+  DOMAIN_BLOCKS_FETCH_SUCCESS,
+  DOMAIN_BLOCKS_EXPAND_SUCCESS,
+  DOMAIN_UNBLOCK_SUCCESS,
+} from '../actions/domain_blocks';
+import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
+
+const initialState = ImmutableMap({
+  blocks: ImmutableMap({
+    items: ImmutableOrderedSet(),
+  }),
+});
+
+export default function domainLists(state = initialState, action) {
+  switch(action.type) {
+  case DOMAIN_BLOCKS_FETCH_SUCCESS:
+    return state.setIn(['blocks', 'items'], ImmutableOrderedSet(action.domains)).setIn(['blocks', 'next'], action.next);
+  case DOMAIN_BLOCKS_EXPAND_SUCCESS:
+    return state.updateIn(['blocks', 'items'], set => set.union(action.domains)).setIn(['blocks', 'next'], action.next);
+  case DOMAIN_UNBLOCK_SUCCESS:
+    return state.updateIn(['blocks', 'items'], set => set.delete(action.domain));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/dropdown_menu.js b/app/javascript/flavours/glitch/reducers/dropdown_menu.js
new file mode 100644
index 000000000..36fd4f132
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/dropdown_menu.js
@@ -0,0 +1,18 @@
+import Immutable from 'immutable';
+import {
+  DROPDOWN_MENU_OPEN,
+  DROPDOWN_MENU_CLOSE,
+} from '../actions/dropdown_menu';
+
+const initialState = Immutable.Map({ openId: null, placement: null, keyboard: false });
+
+export default function dropdownMenu(state = initialState, action) {
+  switch (action.type) {
+  case DROPDOWN_MENU_OPEN:
+    return state.merge({ openId: action.id, placement: action.placement, keyboard: action.keyboard });
+  case DROPDOWN_MENU_CLOSE:
+    return state.get('openId') === action.id ? state.set('openId', null) : state;
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/filters.js b/app/javascript/flavours/glitch/reducers/filters.js
new file mode 100644
index 000000000..33f0c6732
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/filters.js
@@ -0,0 +1,11 @@
+import { FILTERS_FETCH_SUCCESS } from '../actions/filters';
+import { List as ImmutableList, fromJS } from 'immutable';
+
+export default function filters(state = ImmutableList(), action) {
+  switch(action.type) {
+  case FILTERS_FETCH_SUCCESS:
+    return fromJS(action.filters);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/height_cache.js b/app/javascript/flavours/glitch/reducers/height_cache.js
new file mode 100644
index 000000000..8b05e0b19
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/height_cache.js
@@ -0,0 +1,23 @@
+import { Map as ImmutableMap } from 'immutable';
+import { HEIGHT_CACHE_SET, HEIGHT_CACHE_CLEAR } from 'flavours/glitch/actions/height_cache';
+
+const initialState = ImmutableMap();
+
+const setHeight = (state, key, id, height) => {
+  return state.update(key, ImmutableMap(), map => map.set(id, height));
+};
+
+const clearHeights = () => {
+  return ImmutableMap();
+};
+
+export default function statuses(state = initialState, action) {
+  switch(action.type) {
+  case HEIGHT_CACHE_SET:
+    return setHeight(state, action.key, action.id, action.height);
+  case HEIGHT_CACHE_CLEAR:
+    return clearHeights();
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
new file mode 100644
index 000000000..218a5ac8f
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -0,0 +1,66 @@
+import { combineReducers } from 'redux-immutable';
+import dropdown_menu from './dropdown_menu';
+import timelines from './timelines';
+import meta from './meta';
+import alerts from './alerts';
+import { loadingBarReducer } from 'react-redux-loading-bar';
+import modal from './modal';
+import user_lists from './user_lists';
+import domain_lists from './domain_lists';
+import accounts from './accounts';
+import accounts_counters from './accounts_counters';
+import statuses from './statuses';
+import relationships from './relationships';
+import settings from './settings';
+import local_settings from './local_settings';
+import push_notifications from './push_notifications';
+import status_lists from './status_lists';
+import cards from './cards';
+import mutes from './mutes';
+import reports from './reports';
+import contexts from './contexts';
+import compose from './compose';
+import search from './search';
+import media_attachments from './media_attachments';
+import notifications from './notifications';
+import height_cache from './height_cache';
+import custom_emojis from './custom_emojis';
+import lists from './lists';
+import listEditor from './list_editor';
+import filters from './filters';
+import pinnedAccountsEditor from './pinned_accounts_editor';
+
+const reducers = {
+  dropdown_menu,
+  timelines,
+  meta,
+  alerts,
+  loadingBar: loadingBarReducer,
+  modal,
+  user_lists,
+  domain_lists,
+  status_lists,
+  accounts,
+  accounts_counters,
+  statuses,
+  relationships,
+  settings,
+  local_settings,
+  push_notifications,
+  cards,
+  mutes,
+  reports,
+  contexts,
+  compose,
+  search,
+  media_attachments,
+  notifications,
+  height_cache,
+  custom_emojis,
+  lists,
+  listEditor,
+  filters,
+  pinnedAccountsEditor,
+};
+
+export default combineReducers(reducers);
diff --git a/app/javascript/flavours/glitch/reducers/list_editor.js b/app/javascript/flavours/glitch/reducers/list_editor.js
new file mode 100644
index 000000000..02a0dabb1
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/list_editor.js
@@ -0,0 +1,89 @@
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+  LIST_CREATE_REQUEST,
+  LIST_CREATE_FAIL,
+  LIST_CREATE_SUCCESS,
+  LIST_UPDATE_REQUEST,
+  LIST_UPDATE_FAIL,
+  LIST_UPDATE_SUCCESS,
+  LIST_EDITOR_RESET,
+  LIST_EDITOR_SETUP,
+  LIST_EDITOR_TITLE_CHANGE,
+  LIST_ACCOUNTS_FETCH_REQUEST,
+  LIST_ACCOUNTS_FETCH_SUCCESS,
+  LIST_ACCOUNTS_FETCH_FAIL,
+  LIST_EDITOR_SUGGESTIONS_READY,
+  LIST_EDITOR_SUGGESTIONS_CLEAR,
+  LIST_EDITOR_SUGGESTIONS_CHANGE,
+  LIST_EDITOR_ADD_SUCCESS,
+  LIST_EDITOR_REMOVE_SUCCESS,
+} from '../actions/lists';
+
+const initialState = ImmutableMap({
+  listId: null,
+  isSubmitting: false,
+  title: '',
+
+  accounts: ImmutableMap({
+    items: ImmutableList(),
+    loaded: false,
+    isLoading: false,
+  }),
+
+  suggestions: ImmutableMap({
+    value: '',
+    items: ImmutableList(),
+  }),
+});
+
+export default function listEditorReducer(state = initialState, action) {
+  switch(action.type) {
+  case LIST_EDITOR_RESET:
+    return initialState;
+  case LIST_EDITOR_SETUP:
+    return state.withMutations(map => {
+      map.set('listId', action.list.get('id'));
+      map.set('title', action.list.get('title'));
+      map.set('isSubmitting', false);
+    });
+  case LIST_EDITOR_TITLE_CHANGE:
+    return state.set('title', action.value);
+  case LIST_CREATE_REQUEST:
+  case LIST_UPDATE_REQUEST:
+    return state.set('isSubmitting', true);
+  case LIST_CREATE_FAIL:
+  case LIST_UPDATE_FAIL:
+    return state.set('isSubmitting', false);
+  case LIST_CREATE_SUCCESS:
+  case LIST_UPDATE_SUCCESS:
+    return state.withMutations(map => {
+      map.set('isSubmitting', false);
+      map.set('listId', action.list.id);
+    });
+  case LIST_ACCOUNTS_FETCH_REQUEST:
+    return state.setIn(['accounts', 'isLoading'], true);
+  case LIST_ACCOUNTS_FETCH_FAIL:
+    return state.setIn(['accounts', 'isLoading'], false);
+  case LIST_ACCOUNTS_FETCH_SUCCESS:
+    return state.update('accounts', accounts => accounts.withMutations(map => {
+      map.set('isLoading', false);
+      map.set('loaded', true);
+      map.set('items', ImmutableList(action.accounts.map(item => item.id)));
+    }));
+  case LIST_EDITOR_SUGGESTIONS_CHANGE:
+    return state.setIn(['suggestions', 'value'], action.value);
+  case LIST_EDITOR_SUGGESTIONS_READY:
+    return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id)));
+  case LIST_EDITOR_SUGGESTIONS_CLEAR:
+    return state.update('suggestions', suggestions => suggestions.withMutations(map => {
+      map.set('items', ImmutableList());
+      map.set('value', '');
+    }));
+  case LIST_EDITOR_ADD_SUCCESS:
+    return state.updateIn(['accounts', 'items'], list => list.unshift(action.accountId));
+  case LIST_EDITOR_REMOVE_SUCCESS:
+    return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.accountId));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/lists.js b/app/javascript/flavours/glitch/reducers/lists.js
new file mode 100644
index 000000000..f30ffbcbd
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/lists.js
@@ -0,0 +1,37 @@
+import {
+  LIST_FETCH_SUCCESS,
+  LIST_FETCH_FAIL,
+  LISTS_FETCH_SUCCESS,
+  LIST_CREATE_SUCCESS,
+  LIST_UPDATE_SUCCESS,
+  LIST_DELETE_SUCCESS,
+} from '../actions/lists';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const initialState = ImmutableMap();
+
+const normalizeList = (state, list) => state.set(list.id, fromJS(list));
+
+const normalizeLists = (state, lists) => {
+  lists.forEach(list => {
+    state = normalizeList(state, list);
+  });
+
+  return state;
+};
+
+export default function lists(state = initialState, action) {
+  switch(action.type) {
+  case LIST_FETCH_SUCCESS:
+  case LIST_CREATE_SUCCESS:
+  case LIST_UPDATE_SUCCESS:
+    return normalizeList(state, action.list);
+  case LISTS_FETCH_SUCCESS:
+    return normalizeLists(state, action.lists);
+  case LIST_DELETE_SUCCESS:
+  case LIST_FETCH_FAIL:
+    return state.set(action.id, false);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js
new file mode 100644
index 000000000..19233a963
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -0,0 +1,59 @@
+//  Package imports.
+import { Map as ImmutableMap } from 'immutable';
+
+//  Our imports.
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { LOCAL_SETTING_CHANGE } from 'flavours/glitch/actions/local_settings';
+
+const initialState = ImmutableMap({
+  layout    : 'auto',
+  stretch   : true,
+  navbar_under : false,
+  side_arm  : 'none',
+  side_arm_reply_mode : 'keep',
+  show_reply_count : false,
+  always_show_spoilers_field: false,
+  confirm_missing_media_description: false,
+  preselect_on_reply: true,
+  content_warnings : ImmutableMap({
+    auto_unfold : false,
+    filter      : null,
+  }),
+  collapsed : ImmutableMap({
+    enabled     : true,
+    auto        : ImmutableMap({
+      all              : false,
+      notifications    : true,
+      lengthy          : true,
+      reblogs          : false,
+      replies          : false,
+      media            : false,
+    }),
+    backgrounds : ImmutableMap({
+      user_backgrounds : false,
+      preview_images   : false,
+    }),
+    show_action_bar : true,
+  }),
+  media     : ImmutableMap({
+    letterbox   : true,
+    fullwidth   : true,
+  }),
+  notifications : ImmutableMap({
+    favicon_badge : false,
+    tab_badge     : true,
+  }),
+});
+
+const hydrate = (state, localSettings) => state.mergeDeep(localSettings);
+
+export default function localSettings(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return hydrate(state, action.state.get('local_settings'));
+  case LOCAL_SETTING_CHANGE:
+    return state.setIn(action.key, action.value);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/media_attachments.js b/app/javascript/flavours/glitch/reducers/media_attachments.js
new file mode 100644
index 000000000..6e6058576
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/media_attachments.js
@@ -0,0 +1,15 @@
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { Map as ImmutableMap } from 'immutable';
+
+const initialState = ImmutableMap({
+  accept_content_types: [],
+});
+
+export default function meta(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return state.merge(action.state.get('media_attachments'));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/meta.js b/app/javascript/flavours/glitch/reducers/meta.js
new file mode 100644
index 000000000..a98dc436a
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/meta.js
@@ -0,0 +1,16 @@
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { Map as ImmutableMap } from 'immutable';
+
+const initialState = ImmutableMap({
+  streaming_api_base_url: null,
+  access_token: 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/flavours/glitch/reducers/modal.js b/app/javascript/flavours/glitch/reducers/modal.js
new file mode 100644
index 000000000..80bc11dda
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/modal.js
@@ -0,0 +1,17 @@
+import { MODAL_OPEN, MODAL_CLOSE } from 'flavours/glitch/actions/modal';
+
+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/flavours/glitch/reducers/mutes.js b/app/javascript/flavours/glitch/reducers/mutes.js
new file mode 100644
index 000000000..8f52a7704
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/mutes.js
@@ -0,0 +1,29 @@
+import Immutable from 'immutable';
+
+import {
+  MUTES_INIT_MODAL,
+  MUTES_TOGGLE_HIDE_NOTIFICATIONS,
+} from 'flavours/glitch/actions/mutes';
+
+const initialState = Immutable.Map({
+  new: Immutable.Map({
+    isSubmitting: false,
+    account: null,
+    notifications: true,
+  }),
+});
+
+export default function mutes(state = initialState, action) {
+  switch (action.type) {
+  case MUTES_INIT_MODAL:
+    return state.withMutations((state) => {
+      state.setIn(['new', 'isSubmitting'], false);
+      state.setIn(['new', 'account'], action.account);
+      state.setIn(['new', 'notifications'], true);
+    });
+  case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
+    return state.updateIn(['new', 'notifications'], (old) => !old);
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js
new file mode 100644
index 000000000..0b816e85e
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -0,0 +1,245 @@
+import {
+  NOTIFICATIONS_MOUNT,
+  NOTIFICATIONS_UNMOUNT,
+  NOTIFICATIONS_SET_VISIBILITY,
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_EXPAND_SUCCESS,
+  NOTIFICATIONS_EXPAND_REQUEST,
+  NOTIFICATIONS_EXPAND_FAIL,
+  NOTIFICATIONS_CLEAR,
+  NOTIFICATIONS_SCROLL_TOP,
+  NOTIFICATIONS_DELETE_MARKED_REQUEST,
+  NOTIFICATIONS_DELETE_MARKED_SUCCESS,
+  NOTIFICATION_MARK_FOR_DELETE,
+  NOTIFICATIONS_DELETE_MARKED_FAIL,
+  NOTIFICATIONS_ENTER_CLEARING_MODE,
+  NOTIFICATIONS_MARK_ALL_FOR_DELETE,
+} from 'flavours/glitch/actions/notifications';
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from 'flavours/glitch/actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import compareId from 'flavours/glitch/util/compare_id';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  hasMore: true,
+  top: true,
+  mounted: 0,
+  unread: 0,
+  lastReadId: '0',
+  isLoading: false,
+  cleaningMode: false,
+  isTabVisible: true,
+  // notification removal mark of new notifs loaded whilst cleaningMode is true.
+  markNewForDelete: false,
+});
+
+const notificationToMap = (state, notification) => ImmutableMap({
+  id: notification.id,
+  type: notification.type,
+  account: notification.account.id,
+  markedForDelete: state.get('markNewForDelete'),
+  status: notification.status ? notification.status.id : null,
+});
+
+const normalizeNotification = (state, notification) => {
+  const top = !shouldCountUnreadNotifications(state);
+
+  if (top) {
+    state = state.set('lastReadId', notification.id);
+  } else {
+    state = state.update('unread', unread => unread + 1);
+  }
+
+  return state.update('items', list => {
+    if (top && list.size > 40) {
+      list = list.take(20);
+    }
+
+    return list.unshift(notificationToMap(state, notification));
+  });
+};
+
+const expandNormalizedNotifications = (state, notifications, next) => {
+  const top = !(shouldCountUnreadNotifications(state));
+  const lastReadId = state.get('lastReadId');
+  let items = ImmutableList();
+
+  notifications.forEach((n, i) => {
+    items = items.set(i, notificationToMap(state, n));
+  });
+
+  return state.withMutations(mutable => {
+    if (!items.isEmpty()) {
+      mutable.update('items', list => {
+        const lastIndex = 1 + list.findLastIndex(
+          item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id'))
+        );
+
+        const firstIndex = 1 + list.take(lastIndex).findLastIndex(
+          item => item !== null && compareId(item.get('id'), items.first().get('id')) > 0
+        );
+
+        return list.take(firstIndex).concat(items, list.skip(lastIndex));
+      });
+    }
+
+    if (top) {
+      if (!items.isEmpty()) {
+        mutable.update('lastReadId', id => compareId(id, items.first().get('id')) > 0 ? id : items.first().get('id'));
+      }
+    } else {
+      mutable.update('unread', unread => unread + items.filter(item => compareId(item.get('id'), lastReadId) > 0).size);
+    }
+
+    if (!next) {
+      mutable.set('hasMore', true);
+    }
+
+    mutable.set('isLoading', false);
+  });
+};
+
+const filterNotifications = (state, relationship) => {
+  return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id));
+};
+
+const clearUnread = (state) => {
+  state = state.set('unread', 0);
+  const lastNotification = state.get('items').find(item => item !== null);
+  return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0');
+}
+
+const updateTop = (state, top) => {
+  state = state.set('top', top);
+
+  if (!shouldCountUnreadNotifications(state)) {
+    state = clearUnread(state);
+  }
+
+  return state.set('top', top);
+};
+
+const deleteByStatus = (state, statusId) => {
+  const top = !(shouldCountUnreadNotifications(state));
+  if (!top) {
+    const lastReadId = state.get('lastReadId');
+    const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
+    state = state.update('unread', unread => unread - deletedUnread.size);
+  }
+  return state.update('items', list => list.filterNot(item => item !== null && item.get('status') === statusId));
+};
+
+const markForDelete = (state, notificationId, yes) => {
+  return state.update('items', list => list.map(item => {
+    if(item.get('id') === notificationId) {
+      return item.set('markedForDelete', yes);
+    } else {
+      return item;
+    }
+  }));
+};
+
+const markAllForDelete = (state, yes) => {
+  return state.update('items', list => list.map(item => {
+    if(yes !== null) {
+      return item.set('markedForDelete', yes);
+    } else {
+      return item.set('markedForDelete', !item.get('markedForDelete'));
+    }
+  }));
+};
+
+const unmarkAllForDelete = (state) => {
+  return state.update('items', list => list.map(item => item.set('markedForDelete', false)));
+};
+
+const deleteMarkedNotifs = (state) => {
+  return state.update('items', list => list.filterNot(item => item.get('markedForDelete')));
+};
+
+const updateMounted = (state) => {
+  state = state.update('mounted', count => count + 1);
+  if (!shouldCountUnreadNotifications(state)) {
+    state = clearUnread(state);
+  }
+  return state;
+};
+
+const updateVisibility = (state, visibility) => {
+  state = state.set('isTabVisible', visibility);
+  if (!shouldCountUnreadNotifications(state)) {
+    state = clearUnread(state);
+  }
+  return state;
+};
+
+const shouldCountUnreadNotifications = (state) => {
+  return !(state.get('isTabVisible') && state.get('top') && state.get('mounted') > 0);
+};
+
+export default function notifications(state = initialState, action) {
+  let st;
+
+  switch(action.type) {
+  case NOTIFICATIONS_MOUNT:
+    return updateMounted(state);
+  case NOTIFICATIONS_UNMOUNT:
+    return state.update('mounted', count => count - 1);
+  case NOTIFICATIONS_SET_VISIBILITY:
+    return updateVisibility(state, action.visibility);
+  case NOTIFICATIONS_EXPAND_REQUEST:
+  case NOTIFICATIONS_DELETE_MARKED_REQUEST:
+    return state.set('isLoading', true);
+  case NOTIFICATIONS_DELETE_MARKED_FAIL:
+  case NOTIFICATIONS_EXPAND_FAIL:
+    return state.set('isLoading', false);
+  case NOTIFICATIONS_SCROLL_TOP:
+    return updateTop(state, action.top);
+  case NOTIFICATIONS_UPDATE:
+    return normalizeNotification(state, action.notification);
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+    return expandNormalizedNotifications(state, action.notifications, action.next);
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return filterNotifications(state, action.relationship);
+  case NOTIFICATIONS_CLEAR:
+    return state.set('items', ImmutableList()).set('hasMore', false);
+  case TIMELINE_DELETE:
+    return deleteByStatus(state, action.id);
+  case TIMELINE_DISCONNECT:
+    return action.timeline === 'home' ?
+      state.update('items', items => items.first() ? items.unshift(null) : items) :
+      state;
+
+  case NOTIFICATION_MARK_FOR_DELETE:
+    return markForDelete(state, action.id, action.yes);
+
+  case NOTIFICATIONS_DELETE_MARKED_SUCCESS:
+    return deleteMarkedNotifs(state).set('isLoading', false);
+
+  case NOTIFICATIONS_ENTER_CLEARING_MODE:
+    st = state.set('cleaningMode', action.yes);
+    if (!action.yes) {
+      return unmarkAllForDelete(st).set('markNewForDelete', false);
+    } else {
+      return st;
+    }
+
+  case NOTIFICATIONS_MARK_ALL_FOR_DELETE:
+    st = state;
+    if (action.yes === null) {
+      // Toggle - this is a bit confusing, as it toggles the all-none mode
+      //st = st.set('markNewForDelete', !st.get('markNewForDelete'));
+    } else {
+      st = st.set('markNewForDelete', action.yes);
+    }
+    return markAllForDelete(st, action.yes);
+
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js b/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js
new file mode 100644
index 000000000..267521bb8
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js
@@ -0,0 +1,57 @@
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+  PINNED_ACCOUNTS_EDITOR_RESET,
+  PINNED_ACCOUNTS_FETCH_REQUEST,
+  PINNED_ACCOUNTS_FETCH_SUCCESS,
+  PINNED_ACCOUNTS_FETCH_FAIL,
+  PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY,
+  PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR,
+  PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE,
+  ACCOUNT_PIN_SUCCESS,
+  ACCOUNT_UNPIN_SUCCESS,
+} from '../actions/accounts';
+
+const initialState = ImmutableMap({
+  accounts: ImmutableMap({
+    items: ImmutableList(),
+    loaded: false,
+    isLoading: false,
+  }),
+
+  suggestions: ImmutableMap({
+    value: '',
+    items: ImmutableList(),
+  }),
+});
+
+export default function listEditorReducer(state = initialState, action) {
+  switch(action.type) {
+  case PINNED_ACCOUNTS_EDITOR_RESET:
+    return initialState;
+  case PINNED_ACCOUNTS_FETCH_REQUEST:
+    return state.setIn(['accounts', 'isLoading'], true);
+  case PINNED_ACCOUNTS_FETCH_FAIL:
+    return state.setIn(['accounts', 'isLoading'], false);
+  case PINNED_ACCOUNTS_FETCH_SUCCESS:
+    return state.update('accounts', accounts => accounts.withMutations(map => {
+      map.set('isLoading', false);
+      map.set('loaded', true);
+      map.set('items', ImmutableList(action.accounts.map(item => item.id)));
+    }));
+  case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE:
+    return state.setIn(['suggestions', 'value'], action.value);
+  case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY:
+    return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id)));
+  case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR:
+    return state.update('suggestions', suggestions => suggestions.withMutations(map => {
+      map.set('items', ImmutableList());
+      map.set('value', '');
+    }));
+  case ACCOUNT_PIN_SUCCESS:
+    return state.updateIn(['accounts', 'items'], list => list.unshift(action.relationship.id));
+  case ACCOUNT_UNPIN_SUCCESS:
+    return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.relationship.id));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/push_notifications.js b/app/javascript/flavours/glitch/reducers/push_notifications.js
new file mode 100644
index 000000000..1b47ca962
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/push_notifications.js
@@ -0,0 +1,51 @@
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, SET_ALERTS } from 'flavours/glitch/actions/push_notifications';
+import Immutable from 'immutable';
+
+const initialState = Immutable.Map({
+  subscription: null,
+  alerts: new Immutable.Map({
+    follow: false,
+    favourite: false,
+    reblog: false,
+    mention: false,
+  }),
+  isSubscribed: false,
+  browserSupport: false,
+});
+
+export default function push_subscriptions(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE: {
+    const push_subscription = action.state.get('push_subscription');
+
+    if (push_subscription) {
+      return state
+        .set('subscription', new Immutable.Map({
+          id: push_subscription.get('id'),
+          endpoint: push_subscription.get('endpoint'),
+        }))
+        .set('alerts', push_subscription.get('alerts') || initialState.get('alerts'))
+        .set('isSubscribed', true);
+    }
+
+    return state;
+  }
+  case SET_SUBSCRIPTION:
+    return state
+      .set('subscription', new Immutable.Map({
+        id: action.subscription.id,
+        endpoint: action.subscription.endpoint,
+      }))
+      .set('alerts', new Immutable.Map(action.subscription.alerts))
+      .set('isSubscribed', true);
+  case SET_BROWSER_SUPPORT:
+    return state.set('browserSupport', action.value);
+  case CLEAR_SUBSCRIPTION:
+    return initialState;
+  case SET_ALERTS:
+    return state.setIn(action.path, action.value);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/relationships.js b/app/javascript/flavours/glitch/reducers/relationships.js
new file mode 100644
index 000000000..4652bbc14
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/relationships.js
@@ -0,0 +1,58 @@
+import {
+  ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_SUCCESS,
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_UNBLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+  ACCOUNT_UNMUTE_SUCCESS,
+  ACCOUNT_PIN_SUCCESS,
+  ACCOUNT_UNPIN_SUCCESS,
+  RELATIONSHIPS_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import {
+  DOMAIN_BLOCK_SUCCESS,
+  DOMAIN_UNBLOCK_SUCCESS,
+} from 'flavours/glitch/actions/domain_blocks';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
+
+const normalizeRelationships = (state, relationships) => {
+  relationships.forEach(relationship => {
+    state = normalizeRelationship(state, relationship);
+  });
+
+  return state;
+};
+
+const setDomainBlocking = (state, accounts, blocking) => {
+  return state.withMutations(map => {
+    accounts.forEach(id => {
+      map.setIn([id, 'domain_blocking'], blocking);
+    });
+  });
+};
+
+const initialState = ImmutableMap();
+
+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:
+  case ACCOUNT_PIN_SUCCESS:
+  case ACCOUNT_UNPIN_SUCCESS:
+    return normalizeRelationship(state, action.relationship);
+  case RELATIONSHIPS_FETCH_SUCCESS:
+    return normalizeRelationships(state, action.relationships);
+  case DOMAIN_BLOCK_SUCCESS:
+    return setDomainBlocking(state, action.accounts, true);
+  case DOMAIN_UNBLOCK_SUCCESS:
+    return setDomainBlocking(state, action.accounts, false);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/reports.js b/app/javascript/flavours/glitch/reducers/reports.js
new file mode 100644
index 000000000..fdcfb14a0
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/reports.js
@@ -0,0 +1,64 @@
+import {
+  REPORT_INIT,
+  REPORT_SUBMIT_REQUEST,
+  REPORT_SUBMIT_SUCCESS,
+  REPORT_SUBMIT_FAIL,
+  REPORT_CANCEL,
+  REPORT_STATUS_TOGGLE,
+  REPORT_COMMENT_CHANGE,
+  REPORT_FORWARD_CHANGE,
+} from 'flavours/glitch/actions/reports';
+import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable';
+
+const initialState = ImmutableMap({
+  new: ImmutableMap({
+    isSubmitting: false,
+    account_id: null,
+    status_ids: ImmutableSet(),
+    comment: '',
+    forward: false,
+  }),
+});
+
+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 ? ImmutableSet([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : ImmutableSet());
+        map.setIn(['new', 'comment'], '');
+      } else if (action.status) {
+        map.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id'))));
+      }
+    });
+  case REPORT_STATUS_TOGGLE:
+    return state.updateIn(['new', 'status_ids'], ImmutableSet(), 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_FORWARD_CHANGE:
+    return state.setIn(['new', 'forward'], action.forward);
+  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'], ImmutableSet());
+      map.setIn(['new', 'comment'], '');
+      map.setIn(['new', 'isSubmitting'], false);
+    });
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/search.js b/app/javascript/flavours/glitch/reducers/search.js
new file mode 100644
index 000000000..9a525bf47
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/search.js
@@ -0,0 +1,47 @@
+import {
+  SEARCH_CHANGE,
+  SEARCH_CLEAR,
+  SEARCH_FETCH_SUCCESS,
+  SEARCH_SHOW,
+} from 'flavours/glitch/actions/search';
+import {
+  COMPOSE_MENTION,
+  COMPOSE_REPLY,
+  COMPOSE_DIRECT,
+} from 'flavours/glitch/actions/compose';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  value: '',
+  submitted: false,
+  hidden: false,
+  results: ImmutableMap(),
+});
+
+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', ImmutableMap());
+      map.set('submitted', false);
+      map.set('hidden', false);
+    });
+  case SEARCH_SHOW:
+    return state.set('hidden', false);
+  case COMPOSE_REPLY:
+  case COMPOSE_MENTION:
+  case COMPOSE_DIRECT:
+    return state.set('hidden', true);
+  case SEARCH_FETCH_SUCCESS:
+    return state.set('results', ImmutableMap({
+      accounts: ImmutableList(action.results.accounts.map(item => item.id)),
+      statuses: ImmutableList(action.results.statuses.map(item => item.id)),
+      hashtags: fromJS(action.results.hashtags),
+    })).set('submitted', true);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js
new file mode 100644
index 000000000..c04f262da
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -0,0 +1,127 @@
+import { SETTING_CHANGE, SETTING_SAVE } from 'flavours/glitch/actions/settings';
+import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from 'flavours/glitch/actions/columns';
+import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
+import { EMOJI_USE } from 'flavours/glitch/actions/emojis';
+import { LIST_DELETE_SUCCESS, LIST_FETCH_FAIL } from '../actions/lists';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+import uuid from 'flavours/glitch/util/uuid';
+
+const initialState = ImmutableMap({
+  saved: true,
+
+  onboarded: false,
+  layout: 'auto',
+
+  skinTone: 1,
+
+  home: ImmutableMap({
+    shows: ImmutableMap({
+      reblog: true,
+      reply: true,
+      direct: true,
+    }),
+
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
+
+  notifications: ImmutableMap({
+    alerts: ImmutableMap({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true,
+    }),
+
+    shows: ImmutableMap({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true,
+    }),
+
+    sounds: ImmutableMap({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true,
+    }),
+  }),
+
+  community: ImmutableMap({
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
+
+  public: ImmutableMap({
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
+
+  direct: ImmutableMap({
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
+});
+
+const defaultColumns = fromJS([
+  { id: 'COMPOSE', uuid: uuid(), params: {} },
+  { id: 'HOME', uuid: uuid(), params: {} },
+  { id: 'NOTIFICATIONS', uuid: uuid(), params: {} },
+]);
+
+const hydrate = (state, settings) => state.mergeDeep(settings).update('columns', (val = defaultColumns) => val);
+
+const moveColumn = (state, uuid, direction) => {
+  const columns  = state.get('columns');
+  const index    = columns.findIndex(item => item.get('uuid') === uuid);
+  const newIndex = index + direction;
+
+  let newColumns;
+
+  newColumns = columns.splice(index, 1);
+  newColumns = newColumns.splice(newIndex, 0, columns.get(index));
+
+  return state
+    .set('columns', newColumns)
+    .set('saved', false);
+};
+
+const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false);
+
+const filterDeadListColumns = (state, listId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'LIST' && column.get('params').get('id') === listId));
+
+export default function settings(state = initialState, action) {
+  switch(action.type) {
+  case STORE_HYDRATE:
+    return hydrate(state, action.state.get('settings'));
+  case SETTING_CHANGE:
+    return state
+      .setIn(action.path, action.value)
+      .set('saved', false);
+  case COLUMN_ADD:
+    return state
+      .update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params })))
+      .set('saved', false);
+  case COLUMN_REMOVE:
+    return state
+      .update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid))
+      .set('saved', false);
+  case COLUMN_MOVE:
+    return moveColumn(state, action.uuid, action.direction);
+  case EMOJI_USE:
+    return updateFrequentEmojis(state, action.emoji);
+  case SETTING_SAVE:
+    return state.set('saved', true);
+  case LIST_FETCH_FAIL:
+    return action.error.response.status === 404 ? filterDeadListColumns(state, action.id) : state;
+  case LIST_DELETE_SUCCESS:
+    return filterDeadListColumns(state, action.id);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/status_lists.js b/app/javascript/flavours/glitch/reducers/status_lists.js
new file mode 100644
index 000000000..241833bfe
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/status_lists.js
@@ -0,0 +1,116 @@
+import {
+  FAVOURITED_STATUSES_FETCH_REQUEST,
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_FETCH_FAIL,
+  FAVOURITED_STATUSES_EXPAND_REQUEST,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_FAIL,
+} from 'flavours/glitch/actions/favourites';
+import {
+  BOOKMARKED_STATUSES_FETCH_REQUEST,
+  BOOKMARKED_STATUSES_FETCH_SUCCESS,
+  BOOKMARKED_STATUSES_FETCH_FAIL,
+  BOOKMARKED_STATUSES_EXPAND_REQUEST,
+  BOOKMARKED_STATUSES_EXPAND_SUCCESS,
+  BOOKMARKED_STATUSES_EXPAND_FAIL,
+} from 'flavours/glitch/actions/bookmarks';
+import {
+  PINNED_STATUSES_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/pin_statuses';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+  FAVOURITE_SUCCESS,
+  UNFAVOURITE_SUCCESS,
+  BOOKMARK_SUCCESS,
+  UNBOOKMARK_SUCCESS,
+  PIN_SUCCESS,
+  UNPIN_SUCCESS,
+} from 'flavours/glitch/actions/interactions';
+
+const initialState = ImmutableMap({
+  favourites: ImmutableMap({
+    next: null,
+    loaded: false,
+    items: ImmutableList(),
+  }),
+  bookmarks: ImmutableMap({
+    next: null,
+    loaded: false,
+    items: ImmutableList(),
+  }),
+  pins: ImmutableMap({
+    next: null,
+    loaded: false,
+    items: ImmutableList(),
+  }),
+});
+
+const normalizeList = (state, listType, statuses, next) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('next', next);
+    map.set('loaded', true);
+    map.set('isLoading', false);
+    map.set('items', ImmutableList(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('isLoading', false);
+    map.set('items', map.get('items').concat(statuses.map(item => item.id)));
+  }));
+};
+
+const prependOneToList = (state, listType, status) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('items', map.get('items').unshift(status.get('id')));
+  }));
+};
+
+const removeOneFromList = (state, listType, status) => {
+  return state.update(listType, listMap => listMap.withMutations(map => {
+    map.set('items', map.get('items').filter(item => item !== status.get('id')));
+  }));
+};
+
+export default function statusLists(state = initialState, action) {
+  switch(action.type) {
+  case FAVOURITED_STATUSES_FETCH_REQUEST:
+  case FAVOURITED_STATUSES_EXPAND_REQUEST:
+    return state.setIn(['favourites', 'isLoading'], true);
+  case FAVOURITED_STATUSES_FETCH_FAIL:
+  case FAVOURITED_STATUSES_EXPAND_FAIL:
+    return state.setIn(['favourites', 'isLoading'], false);
+  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);
+  case BOOKMARKED_STATUSES_FETCH_REQUEST:
+  case BOOKMARKED_STATUSES_EXPAND_REQUEST:
+    return state.setIn(['bookmarks', 'isLoading'], true);
+  case BOOKMARKED_STATUSES_FETCH_FAIL:
+  case BOOKMARKED_STATUSES_EXPAND_FAIL:
+    return state.setIn(['bookmarks', 'isLoading'], false);
+  case BOOKMARKED_STATUSES_FETCH_SUCCESS:
+    return normalizeList(state, 'bookmarks', action.statuses, action.next);
+  case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
+    return appendToList(state, 'bookmarks', action.statuses, action.next);
+  case FAVOURITE_SUCCESS:
+    return prependOneToList(state, 'favourites', action.status);
+  case UNFAVOURITE_SUCCESS:
+    return removeOneFromList(state, 'favourites', action.status);
+  case BOOKMARK_SUCCESS:
+    return prependOneToList(state, 'bookmarks', action.status);
+  case UNBOOKMARK_SUCCESS:
+    return removeOneFromList(state, 'bookmarks', action.status);
+  case PINNED_STATUSES_FETCH_SUCCESS:
+    return normalizeList(state, 'pins', action.statuses, action.next);
+  case PIN_SUCCESS:
+    return prependOneToList(state, 'pins', action.status);
+  case UNPIN_SUCCESS:
+    return removeOneFromList(state, 'pins', action.status);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js
new file mode 100644
index 000000000..617d96e5d
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/statuses.js
@@ -0,0 +1,149 @@
+import {
+  REBLOG_REQUEST,
+  REBLOG_SUCCESS,
+  REBLOG_FAIL,
+  UNREBLOG_SUCCESS,
+  FAVOURITE_REQUEST,
+  FAVOURITE_SUCCESS,
+  FAVOURITE_FAIL,
+  UNFAVOURITE_SUCCESS,
+  BOOKMARK_REQUEST,
+  BOOKMARK_SUCCESS,
+  BOOKMARK_FAIL,
+  UNBOOKMARK_SUCCESS,
+  PIN_SUCCESS,
+  UNPIN_SUCCESS,
+} from 'flavours/glitch/actions/interactions';
+import {
+  COMPOSE_SUBMIT_SUCCESS,
+} from 'flavours/glitch/actions/compose';
+import {
+  STATUS_FETCH_SUCCESS,
+  CONTEXT_FETCH_SUCCESS,
+  STATUS_MUTE_SUCCESS,
+  STATUS_UNMUTE_SUCCESS,
+} from 'flavours/glitch/actions/statuses';
+import {
+  TIMELINE_UPDATE,
+  TIMELINE_DELETE,
+  TIMELINE_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/timelines';
+import {
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/notifications';
+import {
+  FAVOURITED_STATUSES_FETCH_SUCCESS,
+  FAVOURITED_STATUSES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/favourites';
+import {
+  BOOKMARKED_STATUSES_FETCH_SUCCESS,
+  BOOKMARKED_STATUSES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/bookmarks';
+import {
+  PINNED_STATUSES_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/pin_statuses';
+import { SEARCH_FETCH_SUCCESS } from 'flavours/glitch/actions/search';
+import emojify from 'flavours/glitch/util/emoji';
+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 \/>/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);
+  }
+
+  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 deleteStatus = (state, id, references) => {
+  references.forEach(ref => {
+    state = deleteStatus(state, ref[0], []);
+  });
+
+  return state.delete(id);
+};
+
+const initialState = ImmutableMap();
+
+export default function statuses(state = initialState, action) {
+  switch(action.type) {
+  case TIMELINE_UPDATE:
+  case STATUS_FETCH_SUCCESS:
+  case NOTIFICATIONS_UPDATE:
+  case COMPOSE_SUBMIT_SUCCESS:
+    return normalizeStatus(state, action.status);
+  case REBLOG_SUCCESS:
+  case UNREBLOG_SUCCESS:
+  case FAVOURITE_SUCCESS:
+  case UNFAVOURITE_SUCCESS:
+  case BOOKMARK_SUCCESS:
+  case UNBOOKMARK_SUCCESS:
+  case PIN_SUCCESS:
+  case UNPIN_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 BOOKMARK_REQUEST:
+    return state.setIn([action.status.get('id'), 'bookmarked'], true);
+  case BOOKMARK_FAIL:
+    return state.setIn([action.status.get('id'), 'bookmarked'], 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 STATUS_MUTE_SUCCESS:
+    return state.setIn([action.id, 'muted'], true);
+  case STATUS_UNMUTE_SUCCESS:
+    return state.setIn([action.id, 'muted'], false);
+  case TIMELINE_EXPAND_SUCCESS:
+  case CONTEXT_FETCH_SUCCESS:
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+  case FAVOURITED_STATUSES_FETCH_SUCCESS:
+  case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+  case BOOKMARKED_STATUSES_FETCH_SUCCESS:
+  case BOOKMARKED_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:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js
new file mode 100644
index 000000000..19e400b19
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/timelines.js
@@ -0,0 +1,144 @@
+import {
+  TIMELINE_UPDATE,
+  TIMELINE_DELETE,
+  TIMELINE_EXPAND_SUCCESS,
+  TIMELINE_EXPAND_REQUEST,
+  TIMELINE_EXPAND_FAIL,
+  TIMELINE_SCROLL_TOP,
+  TIMELINE_DISCONNECT,
+} from 'flavours/glitch/actions/timelines';
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+  ACCOUNT_UNFOLLOW_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+import compareId from 'flavours/glitch/util/compare_id';
+
+const initialState = ImmutableMap();
+
+const initialTimeline = ImmutableMap({
+  unread: 0,
+  top: true,
+  isLoading: false,
+  hasMore: true,
+  items: ImmutableList(),
+});
+
+const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial) => {
+  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+    mMap.set('isLoading', false);
+    if (!next) mMap.set('hasMore', false);
+
+    if (!statuses.isEmpty()) {
+      mMap.update('items', ImmutableList(), oldIds => {
+        const newIds = statuses.map(status => status.get('id'));
+        const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1;
+        const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0);
+
+        if (firstIndex < 0) {
+          return (isPartial ? newIds.unshift(null) : newIds).concat(oldIds.skip(lastIndex));
+        }
+
+        return oldIds.take(firstIndex + 1).concat(
+          isPartial && oldIds.get(firstIndex) !== null ? newIds.unshift(null) : newIds,
+          oldIds.skip(lastIndex)
+        );
+      });
+    }
+  }));
+};
+
+const updateTimeline = (state, timeline, status, references) => {
+  const top        = state.getIn([timeline, 'top']);
+  const ids        = state.getIn([timeline, 'items'], ImmutableList());
+  const includesId = ids.includes(status.get('id'));
+  const unread     = state.getIn([timeline, 'unread'], 0);
+
+  if (includesId) {
+    return state;
+  }
+
+  let newIds = ids;
+
+  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+    if (!top) mMap.set('unread', unread + 1);
+    if (top && ids.size > 40) newIds = newIds.take(20);
+    if (status.getIn(['reblog', 'id'], null) !== null) newIds = newIds.filterNot(item => references.includes(item));
+    mMap.set('items', newIds.unshift(status.get('id')));
+  }));
+};
+
+const deleteStatus = (state, id, accountId, references) => {
+  state.keySeq().forEach(timeline => {
+    state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === 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 filterTimeline = (timeline, state, relationship, statuses) =>
+  state.updateIn([timeline, 'items'], ImmutableList(), list =>
+    list.filterNot(statusId =>
+      statuses.getIn([statusId, 'account']) === relationship.id
+    ));
+
+const updateTop = (state, timeline, top) => {
+  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+    if (top) mMap.set('unread', 0);
+    mMap.set('top', top);
+  }));
+};
+
+export default function timelines(state = initialState, action) {
+  switch(action.type) {
+  case TIMELINE_EXPAND_REQUEST:
+    return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true));
+  case TIMELINE_EXPAND_FAIL:
+    return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false));
+  case TIMELINE_EXPAND_SUCCESS:
+    return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial);
+  case TIMELINE_UPDATE:
+    return updateTimeline(state, action.timeline, fromJS(action.status), action.references);
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return filterTimelines(state, action.relationship, action.statuses);
+  case ACCOUNT_UNFOLLOW_SUCCESS:
+    return filterTimeline('home', state, action.relationship, action.statuses);
+  case TIMELINE_SCROLL_TOP:
+    return updateTop(state, action.timeline, action.top);
+  case TIMELINE_DISCONNECT:
+    return state.update(
+      action.timeline,
+      initialTimeline,
+      map => map.update(
+        'items',
+        items => items.first() ? items.unshift(null) : items
+      )
+    );
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js
new file mode 100644
index 000000000..a4df9ec8d
--- /dev/null
+++ b/app/javascript/flavours/glitch/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 'flavours/glitch/actions/accounts';
+import {
+  REBLOGS_FETCH_SUCCESS,
+  FAVOURITES_FETCH_SUCCESS,
+} from 'flavours/glitch/actions/interactions';
+import {
+  BLOCKS_FETCH_SUCCESS,
+  BLOCKS_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/blocks';
+import {
+  MUTES_FETCH_SUCCESS,
+  MUTES_EXPAND_SUCCESS,
+} from 'flavours/glitch/actions/mutes';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+const initialState = ImmutableMap({
+  followers: ImmutableMap(),
+  following: ImmutableMap(),
+  reblogged_by: ImmutableMap(),
+  favourited_by: ImmutableMap(),
+  follow_requests: ImmutableMap(),
+  blocks: ImmutableMap(),
+  mutes: ImmutableMap(),
+});
+
+const normalizeList = (state, type, id, accounts, next) => {
+  return state.setIn([type, id], ImmutableMap({
+    next,
+    items: ImmutableList(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], ImmutableList(action.accounts.map(item => item.id)));
+  case FAVOURITES_FETCH_SUCCESS:
+    return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
+  case FOLLOW_REQUESTS_FETCH_SUCCESS:
+    return state.setIn(['follow_requests', 'items'], ImmutableList(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'], ImmutableList(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'], ImmutableList(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;
+  }
+};