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.js33
-rw-r--r--app/javascript/flavours/glitch/reducers/accounts_counters.js38
-rw-r--r--app/javascript/flavours/glitch/reducers/alerts.js26
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js485
-rw-r--r--app/javascript/flavours/glitch/reducers/contexts.js105
-rw-r--r--app/javascript/flavours/glitch/reducers/conversations.js113
-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/identity_proofs.js25
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js74
-rw-r--r--app/javascript/flavours/glitch/reducers/list_adder.js47
-rw-r--r--app/javascript/flavours/glitch/reducers/list_editor.js96
-rw-r--r--app/javascript/flavours/glitch/reducers/lists.js37
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js69
-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.js262
-rw-r--r--app/javascript/flavours/glitch/reducers/pinned_accounts_editor.js57
-rw-r--r--app/javascript/flavours/glitch/reducers/polls.js15
-rw-r--r--app/javascript/flavours/glitch/reducers/push_notifications.js52
-rw-r--r--app/javascript/flavours/glitch/reducers/relationships.js58
-rw-r--r--app/javascript/flavours/glitch/reducers/reports.js77
-rw-r--r--app/javascript/flavours/glitch/reducers/search.js52
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js152
-rw-r--r--app/javascript/flavours/glitch/reducers/status_lists.js116
-rw-r--r--app/javascript/flavours/glitch/reducers/statuses.js61
-rw-r--r--app/javascript/flavours/glitch/reducers/suggestions.js37
-rw-r--r--app/javascript/flavours/glitch/reducers/timelines.js170
-rw-r--r--app/javascript/flavours/glitch/reducers/user_lists.js80
34 files changed, 2506 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..530ed8e60
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/accounts.js
@@ -0,0 +1,33 @@
+import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const initialState = ImmutableMap();
+
+const normalizeAccount = (state, account) => {
+  account = { ...account };
+
+  delete account.followers_count;
+  delete account.following_count;
+  delete account.statuses_count;
+
+  return state.set(account.id, fromJS(account));
+};
+
+const normalizeAccounts = (state, accounts) => {
+  accounts.forEach(account => {
+    state = normalizeAccount(state, account);
+  });
+
+  return state;
+};
+
+export default function accounts(state = initialState, action) {
+  switch(action.type) {
+  case ACCOUNT_IMPORT:
+    return normalizeAccount(state, action.account);
+  case ACCOUNTS_IMPORT:
+    return normalizeAccounts(state, action.accounts);
+  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..9ebf72af9
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/accounts_counters.js
@@ -0,0 +1,38 @@
+import {
+  ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_SUCCESS,
+} from '../actions/accounts';
+import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
+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 initialState = ImmutableMap();
+
+export default function accountsCounters(state = initialState, action) {
+  switch(action.type) {
+  case ACCOUNT_IMPORT:
+    return normalizeAccount(state, action.account);
+  case ACCOUNTS_IMPORT:
+    return normalizeAccounts(state, action.accounts);
+  case ACCOUNT_FOLLOW_SUCCESS:
+    return action.alreadyFollowing ? state :
+      state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
+  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..ee3d54ab0
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/alerts.js
@@ -0,0 +1,26 @@
+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,
+      message_values: action.message_values,
+    }));
+  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/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
new file mode 100644
index 000000000..5f176b832
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -0,0 +1,485 @@
+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_CONTENT_TYPE_CHANGE,
+  COMPOSE_EMOJI_INSERT,
+  COMPOSE_UPLOAD_CHANGE_REQUEST,
+  COMPOSE_UPLOAD_CHANGE_SUCCESS,
+  COMPOSE_UPLOAD_CHANGE_FAIL,
+  COMPOSE_DOODLE_SET,
+  COMPOSE_RESET,
+  COMPOSE_POLL_ADD,
+  COMPOSE_POLL_REMOVE,
+  COMPOSE_POLL_OPTION_ADD,
+  COMPOSE_POLL_OPTION_CHANGE,
+  COMPOSE_POLL_OPTION_REMOVE,
+  COMPOSE_POLL_SETTINGS_CHANGE,
+} 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, defaultContentType } 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: 0,
+  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,
+  content_type: defaultContentType || 'text/plain',
+  text: '',
+  focusDate: null,
+  caretPosition: null,
+  preselectDate: null,
+  in_reply_to: null,
+  is_submitting: false,
+  is_uploading: false,
+  is_changing_upload: false,
+  progress: 0,
+  media_attachments: ImmutableList(),
+  poll: null,
+  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,
+  }),
+});
+
+const initialPoll = ImmutableMap({
+  options: ImmutableList(['', '']),
+  expires_in: 24 * 3600,
+  multiple: 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', '');
+    if (defaultContentType) map.set('content_type', defaultContentType);
+    map.set('spoiler', false);
+    map.set('spoiler_text', '');
+    map.set('is_submitting', false);
+    map.set('is_changing_upload', 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('poll', null);
+    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('poll', null);
+    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, path) => {
+  return state.withMutations(map => {
+    map.updateIn(path, oldText => `${oldText.slice(0, position)}${completion}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`);
+    map.set('suggestion_token', null);
+    map.set('suggestions', ImmutableList());
+    if (path.length === 1 && path[0] === 'text') {
+      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;
+};
+
+const expiresInFromExpiresAt = expires_at => {
+  if (!expires_at) return 24 * 3600;
+  const delta = (new Date(expires_at).getTime() - Date.now()) / 1000;
+  return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
+};
+
+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', state.get('mounted') + 1);
+  case COMPOSE_UNMOUNT:
+    return state.set('mounted', Math.max(state.get('mounted') - 1, 0));
+  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_CONTENT_TYPE_CHANGE:
+    return state
+      .set('content_type', 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);
+      if (defaultContentType) map.set('content_type', defaultContentType);
+      map.set('text', '');
+      map.set('spoiler', false);
+      map.set('spoiler_text', '');
+      map.set('privacy', state.get('default_privacy'));
+      map.set('poll', null);
+      map.update(
+        'advanced_options',
+        map => map.mergeWith(overwrite, state.get('default_advanced_options'))
+      );
+      map.set('idempotencyKey', uuid());
+    });
+  case COMPOSE_SUBMIT_REQUEST:
+    return state.set('is_submitting', true);
+  case COMPOSE_UPLOAD_CHANGE_REQUEST:
+    return state.set('is_changing_upload', true);
+  case COMPOSE_SUBMIT_SUCCESS:
+    return action.status && state.getIn(['advanced_options', 'threaded_mode']) ? continueThread(state, action.status) : clearAll(state);
+  case COMPOSE_SUBMIT_FAIL:
+    return state.set('is_submitting', false);
+  case COMPOSE_UPLOAD_CHANGE_FAIL:
+    return state.set('is_changing_upload', 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, action.path);
+  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_changing_upload', 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', action.raw_text || unescapeHTML(expandMentions(action.status)));
+      map.set('content_type', action.content_type || 'text/plain');
+      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());
+      map.set('sensitive', action.status.get('sensitive'));
+
+      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', '');
+      }
+
+      if (action.status.get('poll')) {
+        map.set('poll', ImmutableMap({
+          options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
+          multiple: action.status.getIn(['poll', 'multiple']),
+          expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])),
+        }));
+      }
+    });
+  case COMPOSE_POLL_ADD:
+    return state.set('poll', initialPoll);
+  case COMPOSE_POLL_REMOVE:
+    return state.set('poll', null);
+  case COMPOSE_POLL_OPTION_ADD:
+    return state.updateIn(['poll', 'options'], options => options.push(action.title));
+  case COMPOSE_POLL_OPTION_CHANGE:
+    return state.setIn(['poll', 'options', action.index], action.title);
+  case COMPOSE_POLL_OPTION_REMOVE:
+    return state.updateIn(['poll', 'options'], options => options.delete(action.index));
+  case COMPOSE_POLL_SETTINGS_CHANGE:
+    return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
+  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..73b25fe3f
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/contexts.js
@@ -0,0 +1,105 @@
+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_UPDATE } 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({
+  inReplyTos: ImmutableMap(),
+  replies: ImmutableMap(),
+});
+
+const normalizeContext = (immutableState, id, ancestors, descendants) => immutableState.withMutations(state => {
+  state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
+    state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
+      function addReply({ id, in_reply_to_id }) {
+        if (in_reply_to_id && !inReplyTos.has(id)) {
+
+          replies.update(in_reply_to_id, ImmutableList(), siblings => {
+            const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0);
+            return siblings.insert(index + 1, id);
+          });
+
+          inReplyTos.set(id, in_reply_to_id);
+        }
+      }
+
+      // We know in_reply_to_id of statuses but `id` itself.
+      // So we assume that the status of the id replies to last ancestors.
+
+      ancestors.forEach(addReply);
+
+      if (ancestors[0]) {
+        addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id });
+      }
+
+      descendants.forEach(addReply);
+    }));
+  }));
+});
+
+const deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => {
+  state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
+    state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
+      ids.forEach(id => {
+        const inReplyToIdOfId = inReplyTos.get(id);
+        const repliesOfId = replies.get(id);
+        const siblings = replies.get(inReplyToIdOfId);
+
+        if (siblings) {
+          replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id));
+        }
+
+
+        if (repliesOfId) {
+          repliesOfId.forEach(reply => inReplyTos.delete(reply));
+        }
+
+        inReplyTos.delete(id);
+        replies.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) => {
+  if (status.in_reply_to_id) {
+    return state.withMutations(mutable => {
+      const replies = mutable.getIn(['replies', status.in_reply_to_id], ImmutableList());
+
+      mutable.setIn(['inReplyTos', status.id], status.in_reply_to_id);
+
+      if (!replies.includes(status.id)) {
+        mutable.setIn(['replies', status.in_reply_to_id], replies.push(status.id));
+      }
+    });
+  }
+
+  return state;
+};
+
+export default function replies(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_UPDATE:
+    return updateContext(state, action.status);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/conversations.js b/app/javascript/flavours/glitch/reducers/conversations.js
new file mode 100644
index 000000000..8fcc2cc79
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/conversations.js
@@ -0,0 +1,113 @@
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+  CONVERSATIONS_MOUNT,
+  CONVERSATIONS_UNMOUNT,
+  CONVERSATIONS_FETCH_REQUEST,
+  CONVERSATIONS_FETCH_SUCCESS,
+  CONVERSATIONS_FETCH_FAIL,
+  CONVERSATIONS_UPDATE,
+  CONVERSATIONS_READ,
+} from '../actions/conversations';
+import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'flavours/glitch/actions/accounts';
+import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks';
+import compareId from 'flavours/glitch/util/compare_id';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+  hasMore: true,
+  mounted: 0,
+});
+
+const conversationToMap = item => ImmutableMap({
+  id: item.id,
+  unread: item.unread,
+  accounts: ImmutableList(item.accounts.map(a => a.id)),
+  last_status: item.last_status ? item.last_status.id : null,
+});
+
+const updateConversation = (state, item) => state.update('items', list => {
+  const index   = list.findIndex(x => x.get('id') === item.id);
+  const newItem = conversationToMap(item);
+
+  if (index === -1) {
+    return list.unshift(newItem);
+  } else {
+    return list.set(index, newItem);
+  }
+});
+
+const expandNormalizedConversations = (state, conversations, next, isLoadingRecent) => {
+  let items = ImmutableList(conversations.map(conversationToMap));
+
+  return state.withMutations(mutable => {
+    if (!items.isEmpty()) {
+      mutable.update('items', list => {
+        list = list.map(oldItem => {
+          const newItemIndex = items.findIndex(x => x.get('id') === oldItem.get('id'));
+
+          if (newItemIndex === -1) {
+            return oldItem;
+          }
+
+          const newItem = items.get(newItemIndex);
+          items = items.delete(newItemIndex);
+
+          return newItem;
+        });
+
+        list = list.concat(items);
+
+        return list.sortBy(x => x.get('last_status'), (a, b) => {
+          if(a === null || b === null) {
+            return -1;
+          }
+
+          return compareId(a, b) * -1;
+        });
+      });
+    }
+
+    if (!next && !isLoadingRecent) {
+      mutable.set('hasMore', false);
+    }
+
+    mutable.set('isLoading', false);
+  });
+};
+
+const filterConversations = (state, accountIds) => {
+  return state.update('items', list => list.filterNot(item => item.get('accounts').some(accountId => accountIds.includes(accountId))));
+};
+
+export default function conversations(state = initialState, action) {
+  switch (action.type) {
+  case CONVERSATIONS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case CONVERSATIONS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case CONVERSATIONS_FETCH_SUCCESS:
+    return expandNormalizedConversations(state, action.conversations, action.next, action.isLoadingRecent);
+  case CONVERSATIONS_UPDATE:
+    return updateConversation(state, action.conversation);
+  case CONVERSATIONS_MOUNT:
+    return state.update('mounted', count => count + 1);
+  case CONVERSATIONS_UNMOUNT:
+    return state.update('mounted', count => count - 1);
+  case CONVERSATIONS_READ:
+    return state.update('items', list => list.map(item => {
+      if (item.get('id') === action.id) {
+        return item.set('unread', false);
+      }
+
+      return item;
+    }));
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return filterConversations(state, [action.relationship.id]);
+  case DOMAIN_BLOCK_SUCCESS:
+    return filterConversations(state, action.accounts);
+  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/identity_proofs.js b/app/javascript/flavours/glitch/reducers/identity_proofs.js
new file mode 100644
index 000000000..58af0a5fa
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/identity_proofs.js
@@ -0,0 +1,25 @@
+import { Map as ImmutableMap, fromJS } from 'immutable';
+import {
+  IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
+  IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS,
+  IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
+} from '../actions/identity_proofs';
+
+const initialState = ImmutableMap();
+
+export default function identityProofsReducer(state = initialState, action) {
+  switch(action.type) {
+  case IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS:
+    return state.update(identity_proofs => identity_proofs.withMutations(map => {
+      map.set('isLoading', false);
+      map.set('loaded', true);
+      map.set(action.accountId, fromJS(action.identity_proofs));
+    }));
+  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..266d87dc1
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -0,0 +1,74 @@
+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 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 listAdder from './list_adder';
+import filters from './filters';
+import conversations from './conversations';
+import suggestions from './suggestions';
+import pinnedAccountsEditor from './pinned_accounts_editor';
+import polls from './polls';
+import identity_proofs from './identity_proofs';
+
+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,
+  mutes,
+  reports,
+  contexts,
+  compose,
+  search,
+  media_attachments,
+  notifications,
+  height_cache,
+  custom_emojis,
+  identity_proofs,
+  lists,
+  listEditor,
+  listAdder,
+  filters,
+  conversations,
+  suggestions,
+  pinnedAccountsEditor,
+  polls,
+};
+
+export default combineReducers(reducers);
diff --git a/app/javascript/flavours/glitch/reducers/list_adder.js b/app/javascript/flavours/glitch/reducers/list_adder.js
new file mode 100644
index 000000000..b8c1b0e26
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/list_adder.js
@@ -0,0 +1,47 @@
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+  LIST_ADDER_RESET,
+  LIST_ADDER_SETUP,
+  LIST_ADDER_LISTS_FETCH_REQUEST,
+  LIST_ADDER_LISTS_FETCH_SUCCESS,
+  LIST_ADDER_LISTS_FETCH_FAIL,
+  LIST_EDITOR_ADD_SUCCESS,
+  LIST_EDITOR_REMOVE_SUCCESS,
+} from '../actions/lists';
+
+const initialState = ImmutableMap({
+  accountId: null,
+
+  lists: ImmutableMap({
+    items: ImmutableList(),
+    loaded: false,
+    isLoading: false,
+  }),
+});
+
+export default function listAdderReducer(state = initialState, action) {
+  switch(action.type) {
+  case LIST_ADDER_RESET:
+    return initialState;
+  case LIST_ADDER_SETUP:
+    return state.withMutations(map => {
+      map.set('accountId', action.account.get('id'));
+    });
+  case LIST_ADDER_LISTS_FETCH_REQUEST:
+    return state.setIn(['lists', 'isLoading'], true);
+  case LIST_ADDER_LISTS_FETCH_FAIL:
+    return state.setIn(['lists', 'isLoading'], false);
+  case LIST_ADDER_LISTS_FETCH_SUCCESS:
+    return state.update('lists', lists => lists.withMutations(map => {
+      map.set('isLoading', false);
+      map.set('loaded', true);
+      map.set('items', ImmutableList(action.lists.map(item => item.id)));
+    }));
+  case LIST_EDITOR_ADD_SUCCESS:
+    return state.updateIn(['lists', 'items'], list => list.unshift(action.listId));
+  case LIST_EDITOR_REMOVE_SUCCESS:
+    return state.updateIn(['lists', 'items'], list => list.filterNot(item => item === action.listId));
+  default:
+    return state;
+  }
+};
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..5427ac098
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/list_editor.js
@@ -0,0 +1,96 @@
+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,
+  isChanged: 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.withMutations(map => {
+      map.set('title', action.value);
+      map.set('isChanged', true);
+    });
+  case LIST_CREATE_REQUEST:
+  case LIST_UPDATE_REQUEST:
+      return state.withMutations(map => {
+        map.set('isSubmitting', true);
+        map.set('isChanged', false);
+      });
+  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..ad94ea243
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -0,0 +1,69 @@
+//  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,
+  swipe_to_change_columns: true,
+  side_arm  : 'none',
+  side_arm_reply_mode : 'keep',
+  show_reply_count : false,
+  always_show_spoilers_field: false,
+  confirm_missing_media_description: false,
+  confirm_boost_missing_media_description: false,
+  confirm_before_clearing_draft: true,
+  preselect_on_reply: true,
+  inline_preview_cards: true,
+  hicolor_privacy_icons: false,
+  show_content_type_choice: false,
+  filtering_behavior: 'hide',
+  tag_misleading_links: true,
+  rewrite_mentions: 'no',
+  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,
+    reveal_behind_cw : false,
+  }),
+  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..7bd9d4b32
--- /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 (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state;
+  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..135995da6
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -0,0 +1,262 @@
+import {
+  NOTIFICATIONS_MOUNT,
+  NOTIFICATIONS_UNMOUNT,
+  NOTIFICATIONS_SET_VISIBILITY,
+  NOTIFICATIONS_UPDATE,
+  NOTIFICATIONS_EXPAND_SUCCESS,
+  NOTIFICATIONS_EXPAND_REQUEST,
+  NOTIFICATIONS_EXPAND_FAIL,
+  NOTIFICATIONS_FILTER_SET,
+  NOTIFICATIONS_CLEAR,
+  NOTIFICATIONS_SCROLL_TOP,
+  NOTIFICATIONS_LOAD_PENDING,
+  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 { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks';
+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({
+  pendingItems: ImmutableList(),
+  items: ImmutableList(),
+  hasMore: true,
+  top: false,
+  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, usePendingItems) => {
+  if (usePendingItems) {
+    return state.update('pendingItems', list => list.unshift(notificationToMap(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, usePendingItems) => {
+  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(usePendingItems ? 'pendingItems' : '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', false);
+    }
+
+    mutable.set('isLoading', false);
+  });
+};
+
+const filterNotifications = (state, accountIds) => {
+  const helper = list => list.filterNot(item => item !== null && accountIds.includes(item.get('account')));
+  return state.update('items', helper).update('pendingItems', helper);
+};
+
+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);
+  }
+  const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId);
+  return state.update('items', helper).update('pendingItems', helper);
+};
+
+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_LOAD_PENDING:
+    return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0);
+  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_FILTER_SET:
+    return state.set('items', ImmutableList()).set('hasMore', true);
+  case NOTIFICATIONS_SCROLL_TOP:
+    return updateTop(state, action.top);
+  case NOTIFICATIONS_UPDATE:
+    return normalizeNotification(state, action.notification, action.usePendingItems);
+  case NOTIFICATIONS_EXPAND_SUCCESS:
+    return expandNormalizedNotifications(state, action.notifications, action.next, action.usePendingItems);
+  case ACCOUNT_BLOCK_SUCCESS:
+    return filterNotifications(state, [action.relationship.id]);
+  case ACCOUNT_MUTE_SUCCESS:
+    return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state;
+  case DOMAIN_BLOCK_SUCCESS:
+    return filterNotifications(state, action.accounts);
+  case NOTIFICATIONS_CLEAR:
+    return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
+  case TIMELINE_DELETE:
+    return deleteByStatus(state, action.id);
+  case TIMELINE_DISCONNECT:
+    return action.timeline === 'home' ?
+      state.update(action.usePendingItems ? 'pendingItems' : '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/polls.js b/app/javascript/flavours/glitch/reducers/polls.js
new file mode 100644
index 000000000..9956cf83f
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/polls.js
@@ -0,0 +1,15 @@
+import { POLLS_IMPORT } from 'mastodon/actions/importer';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll))));
+
+const initialState = ImmutableMap();
+
+export default function polls(state = initialState, action) {
+  switch(action.type) {
+  case POLLS_IMPORT:
+    return importPolls(state, action.polls);
+  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..e87e8fc1a
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/push_notifications.js
@@ -0,0 +1,52 @@
+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,
+    poll: 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..1f7f3f273
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/reports.js
@@ -0,0 +1,77 @@
+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 {
+  TIMELINE_DELETE,
+} from 'flavours/glitch/actions/timelines';
+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,
+  }),
+});
+
+const deleteStatus = (state, id, references) => {
+  references.forEach(ref => {
+    state = deleteStatus(state, ref[0], []);
+  });
+
+  return state.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.remove(id));
+};
+
+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);
+    });
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.references);
+  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..c346e958b
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/search.js
@@ -0,0 +1,52 @@
+import {
+  SEARCH_CHANGE,
+  SEARCH_CLEAR,
+  SEARCH_FETCH_SUCCESS,
+  SEARCH_SHOW,
+  SEARCH_EXPAND_SUCCESS,
+} 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(),
+  searchTerm: '',
+});
+
+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).set('searchTerm', action.searchTerm);
+  case SEARCH_EXPAND_SUCCESS:
+    const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id);
+    return state.updateIn(['results', action.searchType], list => list.concat(results));
+  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..a37863a69
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -0,0 +1,152 @@
+import { SETTING_CHANGE, SETTING_SAVE } from 'flavours/glitch/actions/settings';
+import { NOTIFICATIONS_FILTER_SET } from 'flavours/glitch/actions/notifications';
+import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE, COLUMN_PARAMS_CHANGE } 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,
+      poll: true,
+    }),
+
+    quickFilter: ImmutableMap({
+      active: 'all',
+      show: true,
+      advanced: false,
+    }),
+
+    shows: ImmutableMap({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true,
+      poll: true,
+    }),
+
+    sounds: ImmutableMap({
+      follow: true,
+      favourite: true,
+      reblog: true,
+      mention: true,
+      poll: true,
+    }),
+  }),
+
+  community: ImmutableMap({
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
+
+  public: ImmutableMap({
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
+
+  direct: ImmutableMap({
+    conversations: true,
+    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 changeColumnParams = (state, uuid, path, value) => {
+  const columns = state.get('columns');
+  const index   = columns.findIndex(item => item.get('uuid') === uuid);
+
+  const newColumns = columns.update(index, column => column.updateIn(['params', ...path], () => value));
+
+  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 NOTIFICATIONS_FILTER_SET:
+  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 COLUMN_PARAMS_CHANGE:
+    return changeColumnParams(state, action.uuid, action.path, action.value);
+  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..96c9c6d04
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/statuses.js
@@ -0,0 +1,61 @@
+import {
+  REBLOG_REQUEST,
+  REBLOG_FAIL,
+  FAVOURITE_REQUEST,
+  FAVOURITE_FAIL,
+  BOOKMARK_REQUEST,
+  BOOKMARK_FAIL,
+} from 'flavours/glitch/actions/interactions';
+import {
+  STATUS_MUTE_SUCCESS,
+  STATUS_UNMUTE_SUCCESS,
+} from 'flavours/glitch/actions/statuses';
+import {
+  TIMELINE_DELETE,
+} from 'flavours/glitch/actions/timelines';
+import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const importStatus = (state, status) => state.set(status.id, fromJS(status));
+
+const importStatuses = (state, statuses) =>
+  state.withMutations(mutable => statuses.forEach(status => importStatus(mutable, status)));
+
+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 STATUS_IMPORT:
+    return importStatus(state, action.status);
+  case STATUSES_IMPORT:
+    return importStatuses(state, action.statuses);
+  case FAVOURITE_REQUEST:
+    return state.setIn([action.status.get('id'), 'favourited'], true);
+  case FAVOURITE_FAIL:
+    return state.get(action.status.get('id')) === undefined ? state : 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.get(action.status.get('id')) === undefined ? state : 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.get(action.status.get('id')) === undefined ? state : 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_DELETE:
+    return deleteStatus(state, action.id, action.references);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/suggestions.js b/app/javascript/flavours/glitch/reducers/suggestions.js
new file mode 100644
index 000000000..834be728f
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/suggestions.js
@@ -0,0 +1,37 @@
+import {
+  SUGGESTIONS_FETCH_REQUEST,
+  SUGGESTIONS_FETCH_SUCCESS,
+  SUGGESTIONS_FETCH_FAIL,
+  SUGGESTIONS_DISMISS,
+} from '../actions/suggestions';
+import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts';
+import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+});
+
+export default function suggestionsReducer(state = initialState, action) {
+  switch(action.type) {
+  case SUGGESTIONS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case SUGGESTIONS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.set('items', fromJS(action.accounts.map(x => x.id)));
+      map.set('isLoading', false);
+    });
+  case SUGGESTIONS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case SUGGESTIONS_DISMISS:
+    return state.update('items', list => list.filterNot(id => id === action.id));
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return state.update('items', list => list.filterNot(id => id === action.relationship.id));
+  case DOMAIN_BLOCK_SUCCESS:
+    return state.update('items', list => list.filterNot(id => action.accounts.includes(id)));
+  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..9b016a4c6
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/timelines.js
@@ -0,0 +1,170 @@
+import {
+  TIMELINE_UPDATE,
+  TIMELINE_DELETE,
+  TIMELINE_CLEAR,
+  TIMELINE_EXPAND_SUCCESS,
+  TIMELINE_EXPAND_REQUEST,
+  TIMELINE_EXPAND_FAIL,
+  TIMELINE_SCROLL_TOP,
+  TIMELINE_CONNECT,
+  TIMELINE_DISCONNECT,
+  TIMELINE_LOAD_PENDING,
+} 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,
+  online: false,
+  top: true,
+  isLoading: false,
+  hasMore: true,
+  pendingItems: ImmutableList(),
+  items: ImmutableList(),
+});
+
+const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => {
+  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
+    mMap.set('isLoading', false);
+    mMap.set('isPartial', isPartial);
+
+    if (!next && !isLoadingRecent) mMap.set('hasMore', false);
+
+    if (timeline.endsWith(':pinned')) {
+      mMap.set('items', statuses.map(status => status.get('id')));
+    } else if (!statuses.isEmpty()) {
+      mMap.update(usePendingItems ? 'pendingItems' : '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, usePendingItems) => {
+  if (usePendingItems) {
+    if (state.getIn([timeline, 'pendingItems'], ImmutableList()).includes(status.get('id')) || state.getIn([timeline, 'items'], ImmutableList()).includes(status.get('id'))) {
+      return state;
+    }
+
+    return state.update(timeline, initialTimeline, map => map.update('pendingItems', list => list.unshift(status.get('id'))));
+  }
+
+  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);
+    mMap.set('items', newIds.unshift(status.get('id')));
+  }));
+};
+
+const deleteStatus = (state, id, accountId, references, exclude_account = null) => {
+  state.keySeq().forEach(timeline => {
+    if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) {
+      const helper = list => list.filterNot(item => item === id);
+      state = state.updateIn([timeline, 'items'], helper).updateIn([timeline, 'pendingItems'], helper);
+    }
+  });
+
+  // Remove reblogs of deleted status
+  references.forEach(ref => {
+    state = deleteStatus(state, ref[0], ref[1], [], exclude_account);
+  });
+
+  return state;
+};
+
+const clearTimeline = (state, timeline) => {
+  return state.set(timeline, initialTimeline);
+};
+
+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, relationship.id);
+  });
+
+  return state;
+};
+
+const filterTimeline = (timeline, state, relationship, statuses) => {
+  const helper = list => list.filterNot(statusId => statuses.getIn([statusId, 'account']) === relationship.id);
+  return state.updateIn([timeline, 'items'], ImmutableList(), helper).updateIn([timeline, 'pendingItems'], ImmutableList(), helper);
+};
+
+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_LOAD_PENDING:
+    return state.update(action.timeline, initialTimeline, map =>
+      map.update('items', list => map.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0));
+  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, action.isLoadingRecent, action.usePendingItems);
+  case TIMELINE_UPDATE:
+    return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems);
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
+  case TIMELINE_CLEAR:
+    return clearTimeline(state, action.timeline);
+  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_CONNECT:
+    return state.update(action.timeline, initialTimeline, map => map.set('online', true));
+  case TIMELINE_DISCONNECT:
+    return state.update(
+      action.timeline,
+      initialTimeline,
+      map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : '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;
+  }
+};