about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/actions
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch/actions')
-rw-r--r--app/javascript/flavours/glitch/actions/accounts.js661
-rw-r--r--app/javascript/flavours/glitch/actions/alerts.js24
-rw-r--r--app/javascript/flavours/glitch/actions/blocks.js82
-rw-r--r--app/javascript/flavours/glitch/actions/bundles.js25
-rw-r--r--app/javascript/flavours/glitch/actions/cards.js52
-rw-r--r--app/javascript/flavours/glitch/actions/columns.js40
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js409
-rw-r--r--app/javascript/flavours/glitch/actions/domain_blocks.js165
-rw-r--r--app/javascript/flavours/glitch/actions/dropdown_menu.js10
-rw-r--r--app/javascript/flavours/glitch/actions/emojis.js14
-rw-r--r--app/javascript/flavours/glitch/actions/favourites.js87
-rw-r--r--app/javascript/flavours/glitch/actions/height_cache.js17
-rw-r--r--app/javascript/flavours/glitch/actions/interactions.js313
-rw-r--r--app/javascript/flavours/glitch/actions/lists.js313
-rw-r--r--app/javascript/flavours/glitch/actions/local_settings.js24
-rw-r--r--app/javascript/flavours/glitch/actions/modal.js16
-rw-r--r--app/javascript/flavours/glitch/actions/mutes.js103
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js265
-rw-r--r--app/javascript/flavours/glitch/actions/onboarding.js14
-rw-r--r--app/javascript/flavours/glitch/actions/pin_statuses.js40
-rw-r--r--app/javascript/flavours/glitch/actions/push_notifications/index.js23
-rw-r--r--app/javascript/flavours/glitch/actions/push_notifications/registerer.js149
-rw-r--r--app/javascript/flavours/glitch/actions/push_notifications/setter.js34
-rw-r--r--app/javascript/flavours/glitch/actions/reports.js89
-rw-r--r--app/javascript/flavours/glitch/actions/search.js73
-rw-r--r--app/javascript/flavours/glitch/actions/settings.js31
-rw-r--r--app/javascript/flavours/glitch/actions/statuses.js217
-rw-r--r--app/javascript/flavours/glitch/actions/store.js17
-rw-r--r--app/javascript/flavours/glitch/actions/streaming.js55
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js216
30 files changed, 3578 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js
new file mode 100644
index 000000000..8ab92f9e7
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/accounts.js
@@ -0,0 +1,661 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+
+export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
+export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS';
+export const ACCOUNT_FETCH_FAIL    = 'ACCOUNT_FETCH_FAIL';
+
+export const ACCOUNT_FOLLOW_REQUEST = 'ACCOUNT_FOLLOW_REQUEST';
+export const ACCOUNT_FOLLOW_SUCCESS = 'ACCOUNT_FOLLOW_SUCCESS';
+export const ACCOUNT_FOLLOW_FAIL    = 'ACCOUNT_FOLLOW_FAIL';
+
+export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST';
+export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS';
+export const ACCOUNT_UNFOLLOW_FAIL    = 'ACCOUNT_UNFOLLOW_FAIL';
+
+export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST';
+export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS';
+export const ACCOUNT_BLOCK_FAIL    = 'ACCOUNT_BLOCK_FAIL';
+
+export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
+export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
+export const ACCOUNT_UNBLOCK_FAIL    = 'ACCOUNT_UNBLOCK_FAIL';
+
+export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
+export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
+export const ACCOUNT_MUTE_FAIL    = 'ACCOUNT_MUTE_FAIL';
+
+export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
+export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
+export const ACCOUNT_UNMUTE_FAIL    = 'ACCOUNT_UNMUTE_FAIL';
+
+export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
+export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
+export const FOLLOWERS_FETCH_FAIL    = 'FOLLOWERS_FETCH_FAIL';
+
+export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST';
+export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS';
+export const FOLLOWERS_EXPAND_FAIL    = 'FOLLOWERS_EXPAND_FAIL';
+
+export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST';
+export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS';
+export const FOLLOWING_FETCH_FAIL    = 'FOLLOWING_FETCH_FAIL';
+
+export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST';
+export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS';
+export const FOLLOWING_EXPAND_FAIL    = 'FOLLOWING_EXPAND_FAIL';
+
+export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
+export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
+export const RELATIONSHIPS_FETCH_FAIL    = 'RELATIONSHIPS_FETCH_FAIL';
+
+export const FOLLOW_REQUESTS_FETCH_REQUEST = 'FOLLOW_REQUESTS_FETCH_REQUEST';
+export const FOLLOW_REQUESTS_FETCH_SUCCESS = 'FOLLOW_REQUESTS_FETCH_SUCCESS';
+export const FOLLOW_REQUESTS_FETCH_FAIL    = 'FOLLOW_REQUESTS_FETCH_FAIL';
+
+export const FOLLOW_REQUESTS_EXPAND_REQUEST = 'FOLLOW_REQUESTS_EXPAND_REQUEST';
+export const FOLLOW_REQUESTS_EXPAND_SUCCESS = 'FOLLOW_REQUESTS_EXPAND_SUCCESS';
+export const FOLLOW_REQUESTS_EXPAND_FAIL    = 'FOLLOW_REQUESTS_EXPAND_FAIL';
+
+export const FOLLOW_REQUEST_AUTHORIZE_REQUEST = 'FOLLOW_REQUEST_AUTHORIZE_REQUEST';
+export const FOLLOW_REQUEST_AUTHORIZE_SUCCESS = 'FOLLOW_REQUEST_AUTHORIZE_SUCCESS';
+export const FOLLOW_REQUEST_AUTHORIZE_FAIL    = 'FOLLOW_REQUEST_AUTHORIZE_FAIL';
+
+export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
+export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
+export const FOLLOW_REQUEST_REJECT_FAIL    = 'FOLLOW_REQUEST_REJECT_FAIL';
+
+export function fetchAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchRelationships([id]));
+
+    if (getState().getIn(['accounts', id], null) !== null) {
+      return;
+    }
+
+    dispatch(fetchAccountRequest(id));
+
+    api(getState).get(`/api/v1/accounts/${id}`).then(response => {
+      dispatch(fetchAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(fetchAccountFail(id, error));
+    });
+  };
+};
+
+export function fetchAccountRequest(id) {
+  return {
+    type: ACCOUNT_FETCH_REQUEST,
+    id,
+  };
+};
+
+export function fetchAccountSuccess(account) {
+  return {
+    type: ACCOUNT_FETCH_SUCCESS,
+    account,
+  };
+};
+
+export function fetchAccountFail(id, error) {
+  return {
+    type: ACCOUNT_FETCH_FAIL,
+    id,
+    error,
+    skipAlert: true,
+  };
+};
+
+export function followAccount(id, reblogs = true) {
+  return (dispatch, getState) => {
+    const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
+    dispatch(followAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => {
+      dispatch(followAccountSuccess(response.data, alreadyFollowing));
+    }).catch(error => {
+      dispatch(followAccountFail(error));
+    });
+  };
+};
+
+export function unfollowAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unfollowAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unfollow`).then(response => {
+      dispatch(unfollowAccountSuccess(response.data, getState().get('statuses')));
+    }).catch(error => {
+      dispatch(unfollowAccountFail(error));
+    });
+  };
+};
+
+export function followAccountRequest(id) {
+  return {
+    type: ACCOUNT_FOLLOW_REQUEST,
+    id,
+  };
+};
+
+export function followAccountSuccess(relationship, alreadyFollowing) {
+  return {
+    type: ACCOUNT_FOLLOW_SUCCESS,
+    relationship,
+    alreadyFollowing,
+  };
+};
+
+export function followAccountFail(error) {
+  return {
+    type: ACCOUNT_FOLLOW_FAIL,
+    error,
+  };
+};
+
+export function unfollowAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNFOLLOW_REQUEST,
+    id,
+  };
+};
+
+export function unfollowAccountSuccess(relationship, statuses) {
+  return {
+    type: ACCOUNT_UNFOLLOW_SUCCESS,
+    relationship,
+    statuses,
+  };
+};
+
+export function unfollowAccountFail(error) {
+  return {
+    type: ACCOUNT_UNFOLLOW_FAIL,
+    error,
+  };
+};
+
+export function blockAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(blockAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/block`).then(response => {
+      // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+      dispatch(blockAccountSuccess(response.data, getState().get('statuses')));
+    }).catch(error => {
+      dispatch(blockAccountFail(id, error));
+    });
+  };
+};
+
+export function unblockAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unblockAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unblock`).then(response => {
+      dispatch(unblockAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(unblockAccountFail(id, error));
+    });
+  };
+};
+
+export function blockAccountRequest(id) {
+  return {
+    type: ACCOUNT_BLOCK_REQUEST,
+    id,
+  };
+};
+
+export function blockAccountSuccess(relationship, statuses) {
+  return {
+    type: ACCOUNT_BLOCK_SUCCESS,
+    relationship,
+    statuses,
+  };
+};
+
+export function blockAccountFail(error) {
+  return {
+    type: ACCOUNT_BLOCK_FAIL,
+    error,
+  };
+};
+
+export function unblockAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNBLOCK_REQUEST,
+    id,
+  };
+};
+
+export function unblockAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_UNBLOCK_SUCCESS,
+    relationship,
+  };
+};
+
+export function unblockAccountFail(error) {
+  return {
+    type: ACCOUNT_UNBLOCK_FAIL,
+    error,
+  };
+};
+
+
+export function muteAccount(id, notifications) {
+  return (dispatch, getState) => {
+    dispatch(muteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
+      // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+      dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
+    }).catch(error => {
+      dispatch(muteAccountFail(id, error));
+    });
+  };
+};
+
+export function unmuteAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unmuteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
+      dispatch(unmuteAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(unmuteAccountFail(id, error));
+    });
+  };
+};
+
+export function muteAccountRequest(id) {
+  return {
+    type: ACCOUNT_MUTE_REQUEST,
+    id,
+  };
+};
+
+export function muteAccountSuccess(relationship, statuses) {
+  return {
+    type: ACCOUNT_MUTE_SUCCESS,
+    relationship,
+    statuses,
+  };
+};
+
+export function muteAccountFail(error) {
+  return {
+    type: ACCOUNT_MUTE_FAIL,
+    error,
+  };
+};
+
+export function unmuteAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNMUTE_REQUEST,
+    id,
+  };
+};
+
+export function unmuteAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_UNMUTE_SUCCESS,
+    relationship,
+  };
+};
+
+export function unmuteAccountFail(error) {
+  return {
+    type: ACCOUNT_UNMUTE_FAIL,
+    error,
+  };
+};
+
+
+export function fetchFollowers(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchFollowersRequest(id));
+
+    api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(fetchFollowersSuccess(id, response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => {
+      dispatch(fetchFollowersFail(id, error));
+    });
+  };
+};
+
+export function fetchFollowersRequest(id) {
+  return {
+    type: FOLLOWERS_FETCH_REQUEST,
+    id,
+  };
+};
+
+export function fetchFollowersSuccess(id, accounts, next) {
+  return {
+    type: FOLLOWERS_FETCH_SUCCESS,
+    id,
+    accounts,
+    next,
+  };
+};
+
+export function fetchFollowersFail(id, error) {
+  return {
+    type: FOLLOWERS_FETCH_FAIL,
+    id,
+    error,
+  };
+};
+
+export function expandFollowers(id) {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'followers', id, 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFollowersRequest(id));
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(expandFollowersSuccess(id, response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => {
+      dispatch(expandFollowersFail(id, error));
+    });
+  };
+};
+
+export function expandFollowersRequest(id) {
+  return {
+    type: FOLLOWERS_EXPAND_REQUEST,
+    id,
+  };
+};
+
+export function expandFollowersSuccess(id, accounts, next) {
+  return {
+    type: FOLLOWERS_EXPAND_SUCCESS,
+    id,
+    accounts,
+    next,
+  };
+};
+
+export function expandFollowersFail(id, error) {
+  return {
+    type: FOLLOWERS_EXPAND_FAIL,
+    id,
+    error,
+  };
+};
+
+export function fetchFollowing(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchFollowingRequest(id));
+
+    api(getState).get(`/api/v1/accounts/${id}/following`).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(fetchFollowingSuccess(id, response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => {
+      dispatch(fetchFollowingFail(id, error));
+    });
+  };
+};
+
+export function fetchFollowingRequest(id) {
+  return {
+    type: FOLLOWING_FETCH_REQUEST,
+    id,
+  };
+};
+
+export function fetchFollowingSuccess(id, accounts, next) {
+  return {
+    type: FOLLOWING_FETCH_SUCCESS,
+    id,
+    accounts,
+    next,
+  };
+};
+
+export function fetchFollowingFail(id, error) {
+  return {
+    type: FOLLOWING_FETCH_FAIL,
+    id,
+    error,
+  };
+};
+
+export function expandFollowing(id) {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'following', id, 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFollowingRequest(id));
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(expandFollowingSuccess(id, response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => {
+      dispatch(expandFollowingFail(id, error));
+    });
+  };
+};
+
+export function expandFollowingRequest(id) {
+  return {
+    type: FOLLOWING_EXPAND_REQUEST,
+    id,
+  };
+};
+
+export function expandFollowingSuccess(id, accounts, next) {
+  return {
+    type: FOLLOWING_EXPAND_SUCCESS,
+    id,
+    accounts,
+    next,
+  };
+};
+
+export function expandFollowingFail(id, error) {
+  return {
+    type: FOLLOWING_EXPAND_FAIL,
+    id,
+    error,
+  };
+};
+
+export function fetchRelationships(accountIds) {
+  return (dispatch, getState) => {
+    const loadedRelationships = getState().get('relationships');
+    const newAccountIds = accountIds.filter(id => loadedRelationships.get(id, null) === null);
+
+    if (newAccountIds.length === 0) {
+      return;
+    }
+
+    dispatch(fetchRelationshipsRequest(newAccountIds));
+
+    api(getState).get(`/api/v1/accounts/relationships?${newAccountIds.map(id => `id[]=${id}`).join('&')}`).then(response => {
+      dispatch(fetchRelationshipsSuccess(response.data));
+    }).catch(error => {
+      dispatch(fetchRelationshipsFail(error));
+    });
+  };
+};
+
+export function fetchRelationshipsRequest(ids) {
+  return {
+    type: RELATIONSHIPS_FETCH_REQUEST,
+    ids,
+    skipLoading: true,
+  };
+};
+
+export function fetchRelationshipsSuccess(relationships) {
+  return {
+    type: RELATIONSHIPS_FETCH_SUCCESS,
+    relationships,
+    skipLoading: true,
+  };
+};
+
+export function fetchRelationshipsFail(error) {
+  return {
+    type: RELATIONSHIPS_FETCH_FAIL,
+    error,
+    skipLoading: true,
+  };
+};
+
+export function fetchFollowRequests() {
+  return (dispatch, getState) => {
+    dispatch(fetchFollowRequestsRequest());
+
+    api(getState).get('/api/v1/follow_requests').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchFollowRequestsSuccess(response.data, next ? next.uri : null));
+    }).catch(error => dispatch(fetchFollowRequestsFail(error)));
+  };
+};
+
+export function fetchFollowRequestsRequest() {
+  return {
+    type: FOLLOW_REQUESTS_FETCH_REQUEST,
+  };
+};
+
+export function fetchFollowRequestsSuccess(accounts, next) {
+  return {
+    type: FOLLOW_REQUESTS_FETCH_SUCCESS,
+    accounts,
+    next,
+  };
+};
+
+export function fetchFollowRequestsFail(error) {
+  return {
+    type: FOLLOW_REQUESTS_FETCH_FAIL,
+    error,
+  };
+};
+
+export function expandFollowRequests() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'follow_requests', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFollowRequestsRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandFollowRequestsSuccess(response.data, next ? next.uri : null));
+    }).catch(error => dispatch(expandFollowRequestsFail(error)));
+  };
+};
+
+export function expandFollowRequestsRequest() {
+  return {
+    type: FOLLOW_REQUESTS_EXPAND_REQUEST,
+  };
+};
+
+export function expandFollowRequestsSuccess(accounts, next) {
+  return {
+    type: FOLLOW_REQUESTS_EXPAND_SUCCESS,
+    accounts,
+    next,
+  };
+};
+
+export function expandFollowRequestsFail(error) {
+  return {
+    type: FOLLOW_REQUESTS_EXPAND_FAIL,
+    error,
+  };
+};
+
+export function authorizeFollowRequest(id) {
+  return (dispatch, getState) => {
+    dispatch(authorizeFollowRequestRequest(id));
+
+    api(getState)
+      .post(`/api/v1/follow_requests/${id}/authorize`)
+      .then(() => dispatch(authorizeFollowRequestSuccess(id)))
+      .catch(error => dispatch(authorizeFollowRequestFail(id, error)));
+  };
+};
+
+export function authorizeFollowRequestRequest(id) {
+  return {
+    type: FOLLOW_REQUEST_AUTHORIZE_REQUEST,
+    id,
+  };
+};
+
+export function authorizeFollowRequestSuccess(id) {
+  return {
+    type: FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+    id,
+  };
+};
+
+export function authorizeFollowRequestFail(id, error) {
+  return {
+    type: FOLLOW_REQUEST_AUTHORIZE_FAIL,
+    id,
+    error,
+  };
+};
+
+
+export function rejectFollowRequest(id) {
+  return (dispatch, getState) => {
+    dispatch(rejectFollowRequestRequest(id));
+
+    api(getState)
+      .post(`/api/v1/follow_requests/${id}/reject`)
+      .then(() => dispatch(rejectFollowRequestSuccess(id)))
+      .catch(error => dispatch(rejectFollowRequestFail(id, error)));
+  };
+};
+
+export function rejectFollowRequestRequest(id) {
+  return {
+    type: FOLLOW_REQUEST_REJECT_REQUEST,
+    id,
+  };
+};
+
+export function rejectFollowRequestSuccess(id) {
+  return {
+    type: FOLLOW_REQUEST_REJECT_SUCCESS,
+    id,
+  };
+};
+
+export function rejectFollowRequestFail(id, error) {
+  return {
+    type: FOLLOW_REQUEST_REJECT_FAIL,
+    id,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/alerts.js b/app/javascript/flavours/glitch/actions/alerts.js
new file mode 100644
index 000000000..f37fdeeb6
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/alerts.js
@@ -0,0 +1,24 @@
+export const ALERT_SHOW    = 'ALERT_SHOW';
+export const ALERT_DISMISS = 'ALERT_DISMISS';
+export const ALERT_CLEAR   = 'ALERT_CLEAR';
+
+export function dismissAlert(alert) {
+  return {
+    type: ALERT_DISMISS,
+    alert,
+  };
+};
+
+export function clearAlert() {
+  return {
+    type: ALERT_CLEAR,
+  };
+};
+
+export function showAlert(title, message) {
+  return {
+    type: ALERT_SHOW,
+    title,
+    message,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/blocks.js b/app/javascript/flavours/glitch/actions/blocks.js
new file mode 100644
index 000000000..fe44ca19a
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/blocks.js
@@ -0,0 +1,82 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+import { fetchRelationships } from './accounts';
+
+export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
+export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
+export const BLOCKS_FETCH_FAIL    = 'BLOCKS_FETCH_FAIL';
+
+export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST';
+export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS';
+export const BLOCKS_EXPAND_FAIL    = 'BLOCKS_EXPAND_FAIL';
+
+export function fetchBlocks() {
+  return (dispatch, getState) => {
+    dispatch(fetchBlocksRequest());
+
+    api(getState).get('/api/v1/blocks').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchBlocksSuccess(response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => dispatch(fetchBlocksFail(error)));
+  };
+};
+
+export function fetchBlocksRequest() {
+  return {
+    type: BLOCKS_FETCH_REQUEST,
+  };
+};
+
+export function fetchBlocksSuccess(accounts, next) {
+  return {
+    type: BLOCKS_FETCH_SUCCESS,
+    accounts,
+    next,
+  };
+};
+
+export function fetchBlocksFail(error) {
+  return {
+    type: BLOCKS_FETCH_FAIL,
+    error,
+  };
+};
+
+export function expandBlocks() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'blocks', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandBlocksRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandBlocksSuccess(response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => dispatch(expandBlocksFail(error)));
+  };
+};
+
+export function expandBlocksRequest() {
+  return {
+    type: BLOCKS_EXPAND_REQUEST,
+  };
+};
+
+export function expandBlocksSuccess(accounts, next) {
+  return {
+    type: BLOCKS_EXPAND_SUCCESS,
+    accounts,
+    next,
+  };
+};
+
+export function expandBlocksFail(error) {
+  return {
+    type: BLOCKS_EXPAND_FAIL,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/bundles.js b/app/javascript/flavours/glitch/actions/bundles.js
new file mode 100644
index 000000000..ecc9c8f7d
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/bundles.js
@@ -0,0 +1,25 @@
+export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST';
+export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS';
+export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL';
+
+export function fetchBundleRequest(skipLoading) {
+  return {
+    type: BUNDLE_FETCH_REQUEST,
+    skipLoading,
+  };
+}
+
+export function fetchBundleSuccess(skipLoading) {
+  return {
+    type: BUNDLE_FETCH_SUCCESS,
+    skipLoading,
+  };
+}
+
+export function fetchBundleFail(error, skipLoading) {
+  return {
+    type: BUNDLE_FETCH_FAIL,
+    error,
+    skipLoading,
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/cards.js b/app/javascript/flavours/glitch/actions/cards.js
new file mode 100644
index 000000000..c897daf58
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/cards.js
@@ -0,0 +1,52 @@
+import api from 'flavours/glitch/util/api';
+
+export const STATUS_CARD_FETCH_REQUEST = 'STATUS_CARD_FETCH_REQUEST';
+export const STATUS_CARD_FETCH_SUCCESS = 'STATUS_CARD_FETCH_SUCCESS';
+export const STATUS_CARD_FETCH_FAIL    = 'STATUS_CARD_FETCH_FAIL';
+
+export function fetchStatusCard(id) {
+  return (dispatch, getState) => {
+    if (getState().getIn(['cards', id], null) !== null) {
+      return;
+    }
+
+    dispatch(fetchStatusCardRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/card`).then(response => {
+      if (!response.data.url) {
+        return;
+      }
+
+      dispatch(fetchStatusCardSuccess(id, response.data));
+    }).catch(error => {
+      dispatch(fetchStatusCardFail(id, error));
+    });
+  };
+};
+
+export function fetchStatusCardRequest(id) {
+  return {
+    type: STATUS_CARD_FETCH_REQUEST,
+    id,
+    skipLoading: true,
+  };
+};
+
+export function fetchStatusCardSuccess(id, card) {
+  return {
+    type: STATUS_CARD_FETCH_SUCCESS,
+    id,
+    card,
+    skipLoading: true,
+  };
+};
+
+export function fetchStatusCardFail(id, error) {
+  return {
+    type: STATUS_CARD_FETCH_FAIL,
+    id,
+    error,
+    skipLoading: true,
+    skipAlert: true,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/columns.js b/app/javascript/flavours/glitch/actions/columns.js
new file mode 100644
index 000000000..bcb0cdf98
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/columns.js
@@ -0,0 +1,40 @@
+import { saveSettings } from './settings';
+
+export const COLUMN_ADD    = 'COLUMN_ADD';
+export const COLUMN_REMOVE = 'COLUMN_REMOVE';
+export const COLUMN_MOVE   = 'COLUMN_MOVE';
+
+export function addColumn(id, params) {
+  return dispatch => {
+    dispatch({
+      type: COLUMN_ADD,
+      id,
+      params,
+    });
+
+    dispatch(saveSettings());
+  };
+};
+
+export function removeColumn(uuid) {
+  return dispatch => {
+    dispatch({
+      type: COLUMN_REMOVE,
+      uuid,
+    });
+
+    dispatch(saveSettings());
+  };
+};
+
+export function moveColumn(uuid, direction) {
+  return dispatch => {
+    dispatch({
+      type: COLUMN_MOVE,
+      uuid,
+      direction,
+    });
+
+    dispatch(saveSettings());
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
new file mode 100644
index 000000000..8a6ca3699
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -0,0 +1,409 @@
+import api from 'flavours/glitch/util/api';
+import { CancelToken } from 'axios';
+import { throttle } from 'lodash';
+import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light';
+import { useEmoji } from './emojis';
+
+import {
+  updateTimeline,
+  refreshHomeTimeline,
+  refreshCommunityTimeline,
+  refreshPublicTimeline,
+  refreshDirectTimeline,
+} from './timelines';
+
+let cancelFetchComposeSuggestionsAccounts;
+
+export const COMPOSE_CHANGE          = 'COMPOSE_CHANGE';
+export const COMPOSE_CYCLE_ELEFRIEND = 'COMPOSE_CYCLE_ELEFRIEND';
+export const COMPOSE_SUBMIT_REQUEST  = 'COMPOSE_SUBMIT_REQUEST';
+export const COMPOSE_SUBMIT_SUCCESS  = 'COMPOSE_SUBMIT_SUCCESS';
+export const COMPOSE_SUBMIT_FAIL     = 'COMPOSE_SUBMIT_FAIL';
+export const COMPOSE_REPLY           = 'COMPOSE_REPLY';
+export const COMPOSE_REPLY_CANCEL    = 'COMPOSE_REPLY_CANCEL';
+export const COMPOSE_MENTION         = 'COMPOSE_MENTION';
+export const COMPOSE_RESET           = 'COMPOSE_RESET';
+export const COMPOSE_UPLOAD_REQUEST  = 'COMPOSE_UPLOAD_REQUEST';
+export const COMPOSE_UPLOAD_SUCCESS  = 'COMPOSE_UPLOAD_SUCCESS';
+export const COMPOSE_UPLOAD_FAIL     = 'COMPOSE_UPLOAD_FAIL';
+export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
+export const COMPOSE_UPLOAD_UNDO     = 'COMPOSE_UPLOAD_UNDO';
+
+export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
+export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
+export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
+
+export const COMPOSE_MOUNT   = 'COMPOSE_MOUNT';
+export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
+
+export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE';
+export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
+export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
+export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
+export const COMPOSE_VISIBILITY_CHANGE  = 'COMPOSE_VISIBILITY_CHANGE';
+export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
+
+export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
+
+export const COMPOSE_UPLOAD_CHANGE_REQUEST     = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
+export const COMPOSE_UPLOAD_CHANGE_SUCCESS     = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
+export const COMPOSE_UPLOAD_CHANGE_FAIL        = 'COMPOSE_UPLOAD_UPDATE_FAIL';
+
+export const COMPOSE_DOODLE_SET        = 'COMPOSE_DOODLE_SET';
+
+export function changeCompose(text) {
+  return {
+    type: COMPOSE_CHANGE,
+    text: text,
+  };
+};
+
+export function cycleElefriendCompose() {
+  return {
+    type: COMPOSE_CYCLE_ELEFRIEND,
+  };
+};
+
+export function replyCompose(status, router) {
+  return (dispatch, getState) => {
+    dispatch({
+      type: COMPOSE_REPLY,
+      status: status,
+    });
+
+    if (router && !getState().getIn(['compose', 'mounted'])) {
+      router.push('/statuses/new');
+    }
+  };
+};
+
+export function cancelReplyCompose() {
+  return {
+    type: COMPOSE_REPLY_CANCEL,
+  };
+};
+
+export function resetCompose() {
+  return {
+    type: COMPOSE_RESET,
+  };
+};
+
+export function mentionCompose(account, router) {
+  return (dispatch, getState) => {
+    dispatch({
+      type: COMPOSE_MENTION,
+      account: account,
+    });
+
+    if (!getState().getIn(['compose', 'mounted'])) {
+      router.push('/statuses/new');
+    }
+  };
+};
+
+export function submitCompose() {
+  return function (dispatch, getState) {
+    let status = getState().getIn(['compose', 'text'], '');
+    let media  = getState().getIn(['compose', 'media_attachments']);
+
+    if ((!status || !status.length) && media.size === 0) {
+      return;
+    }
+
+    dispatch(submitComposeRequest());
+    if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) {
+      status = status + ' 👁️';
+    }
+    api(getState).post('/api/v1/statuses', {
+      status,
+      in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
+      media_ids: media.map(item => item.get('id')),
+      sensitive: getState().getIn(['compose', 'sensitive']),
+      spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
+      visibility: getState().getIn(['compose', 'privacy']),
+    }, {
+      headers: {
+        'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
+      },
+    }).then(function (response) {
+      dispatch(submitComposeSuccess({ ...response.data }));
+
+      //  If the response has no data then we can't do anything else.
+      if (!response.data) {
+        return;
+      }
+
+      // To make the app more responsive, immediately get the status into the columns
+
+      const insertOrRefresh = (timelineId, refreshAction) => {
+        if (getState().getIn(['timelines', timelineId, 'online'])) {
+          dispatch(updateTimeline(timelineId, { ...response.data }));
+        } else if (getState().getIn(['timelines', timelineId, 'loaded'])) {
+          dispatch(refreshAction());
+        }
+      };
+
+      insertOrRefresh('home', refreshHomeTimeline);
+
+      if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
+        insertOrRefresh('community', refreshCommunityTimeline);
+        insertOrRefresh('public', refreshPublicTimeline);
+      } else if (response.data.visibility === 'direct') {
+        insertOrRefresh('direct', refreshDirectTimeline);
+      }
+    }).catch(function (error) {
+      dispatch(submitComposeFail(error));
+    });
+  };
+};
+
+export function submitComposeRequest() {
+  return {
+    type: COMPOSE_SUBMIT_REQUEST,
+  };
+};
+
+export function submitComposeSuccess(status) {
+  return {
+    type: COMPOSE_SUBMIT_SUCCESS,
+    status: status,
+  };
+};
+
+export function submitComposeFail(error) {
+  return {
+    type: COMPOSE_SUBMIT_FAIL,
+    error: error,
+  };
+};
+
+export function doodleSet(options) {
+  return {
+    type: COMPOSE_DOODLE_SET,
+    options: options,
+  };
+};
+
+export function uploadCompose(files) {
+  return function (dispatch, getState) {
+    if (getState().getIn(['compose', 'media_attachments']).size > 3) {
+      return;
+    }
+
+    dispatch(uploadComposeRequest());
+
+    let data = new FormData();
+    data.append('file', files[0]);
+
+    api(getState).post('/api/v1/media', data, {
+      onUploadProgress: function (e) {
+        dispatch(uploadComposeProgress(e.loaded, e.total));
+      },
+    }).then(function (response) {
+      dispatch(uploadComposeSuccess(response.data));
+    }).catch(function (error) {
+      dispatch(uploadComposeFail(error));
+    });
+  };
+};
+
+export function changeUploadCompose(id, description) {
+  return (dispatch, getState) => {
+    dispatch(changeUploadComposeRequest());
+
+    api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
+      dispatch(changeUploadComposeSuccess(response.data));
+    }).catch(error => {
+      dispatch(changeUploadComposeFail(id, error));
+    });
+  };
+};
+
+export function changeUploadComposeRequest() {
+  return {
+    type: COMPOSE_UPLOAD_CHANGE_REQUEST,
+    skipLoading: true,
+  };
+};
+export function changeUploadComposeSuccess(media) {
+  return {
+    type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
+    media: media,
+    skipLoading: true,
+  };
+};
+
+export function changeUploadComposeFail(error) {
+  return {
+    type: COMPOSE_UPLOAD_CHANGE_FAIL,
+    error: error,
+    skipLoading: true,
+  };
+};
+
+export function uploadComposeRequest() {
+  return {
+    type: COMPOSE_UPLOAD_REQUEST,
+    skipLoading: true,
+  };
+};
+
+export function uploadComposeProgress(loaded, total) {
+  return {
+    type: COMPOSE_UPLOAD_PROGRESS,
+    loaded: loaded,
+    total: total,
+  };
+};
+
+export function uploadComposeSuccess(media) {
+  return {
+    type: COMPOSE_UPLOAD_SUCCESS,
+    media: media,
+    skipLoading: true,
+  };
+};
+
+export function uploadComposeFail(error) {
+  return {
+    type: COMPOSE_UPLOAD_FAIL,
+    error: error,
+    skipLoading: true,
+  };
+};
+
+export function undoUploadCompose(media_id) {
+  return {
+    type: COMPOSE_UPLOAD_UNDO,
+    media_id: media_id,
+  };
+};
+
+export function clearComposeSuggestions() {
+  if (cancelFetchComposeSuggestionsAccounts) {
+    cancelFetchComposeSuggestionsAccounts();
+  }
+  return {
+    type: COMPOSE_SUGGESTIONS_CLEAR,
+  };
+};
+
+const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
+  if (cancelFetchComposeSuggestionsAccounts) {
+    cancelFetchComposeSuggestionsAccounts();
+  }
+  api(getState).get('/api/v1/accounts/search', {
+    cancelToken: new CancelToken(cancel => {
+      cancelFetchComposeSuggestionsAccounts = cancel;
+    }),
+    params: {
+      q: token.slice(1),
+      resolve: false,
+      limit: 4,
+    },
+  }).then(response => {
+    dispatch(readyComposeSuggestionsAccounts(token, response.data));
+  });
+}, 200, { leading: true, trailing: true });
+
+const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
+  const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
+  dispatch(readyComposeSuggestionsEmojis(token, results));
+};
+
+export function fetchComposeSuggestions(token) {
+  return (dispatch, getState) => {
+    if (token[0] === ':') {
+      fetchComposeSuggestionsEmojis(dispatch, getState, token);
+    } else {
+      fetchComposeSuggestionsAccounts(dispatch, getState, token);
+    }
+  };
+};
+
+export function readyComposeSuggestionsEmojis(token, emojis) {
+  return {
+    type: COMPOSE_SUGGESTIONS_READY,
+    token,
+    emojis,
+  };
+};
+
+export function readyComposeSuggestionsAccounts(token, accounts) {
+  return {
+    type: COMPOSE_SUGGESTIONS_READY,
+    token,
+    accounts,
+  };
+};
+
+export function selectComposeSuggestion(position, token, suggestion) {
+  return (dispatch, getState) => {
+    const completion = typeof suggestion === 'object' && suggestion.id ? (
+      dispatch(useEmoji(suggestion)),
+      suggestion.native || suggestion.colons
+    ) : '@' + getState().getIn(['accounts', suggestion, 'acct']);
+
+    dispatch({
+      type: COMPOSE_SUGGESTION_SELECT,
+      position,
+      token,
+      completion,
+    });
+  };
+};
+
+export function mountCompose() {
+  return {
+    type: COMPOSE_MOUNT,
+  };
+};
+
+export function unmountCompose() {
+  return {
+    type: COMPOSE_UNMOUNT,
+  };
+};
+
+export function changeComposeAdvancedOption(option, value) {
+  return {
+    option,
+    type: COMPOSE_ADVANCED_OPTIONS_CHANGE,
+    value,
+  };
+}
+
+export function changeComposeSensitivity() {
+  return {
+    type: COMPOSE_SENSITIVITY_CHANGE,
+  };
+};
+
+export function changeComposeSpoilerness() {
+  return {
+    type: COMPOSE_SPOILERNESS_CHANGE,
+  };
+};
+
+export function changeComposeSpoilerText(text) {
+  return {
+    type: COMPOSE_SPOILER_TEXT_CHANGE,
+    text,
+  };
+};
+
+export function changeComposeVisibility(value) {
+  return {
+    type: COMPOSE_VISIBILITY_CHANGE,
+    value,
+  };
+};
+
+export function insertEmojiCompose(position, emoji) {
+  return {
+    type: COMPOSE_EMOJI_INSERT,
+    position,
+    emoji,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/domain_blocks.js b/app/javascript/flavours/glitch/actions/domain_blocks.js
new file mode 100644
index 000000000..46f76710c
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/domain_blocks.js
@@ -0,0 +1,165 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+
+export const DOMAIN_BLOCK_REQUEST = 'DOMAIN_BLOCK_REQUEST';
+export const DOMAIN_BLOCK_SUCCESS = 'DOMAIN_BLOCK_SUCCESS';
+export const DOMAIN_BLOCK_FAIL    = 'DOMAIN_BLOCK_FAIL';
+
+export const DOMAIN_UNBLOCK_REQUEST = 'DOMAIN_UNBLOCK_REQUEST';
+export const DOMAIN_UNBLOCK_SUCCESS = 'DOMAIN_UNBLOCK_SUCCESS';
+export const DOMAIN_UNBLOCK_FAIL    = 'DOMAIN_UNBLOCK_FAIL';
+
+export const DOMAIN_BLOCKS_FETCH_REQUEST = 'DOMAIN_BLOCKS_FETCH_REQUEST';
+export const DOMAIN_BLOCKS_FETCH_SUCCESS = 'DOMAIN_BLOCKS_FETCH_SUCCESS';
+export const DOMAIN_BLOCKS_FETCH_FAIL    = 'DOMAIN_BLOCKS_FETCH_FAIL';
+
+export const DOMAIN_BLOCKS_EXPAND_REQUEST = 'DOMAIN_BLOCKS_EXPAND_REQUEST';
+export const DOMAIN_BLOCKS_EXPAND_SUCCESS = 'DOMAIN_BLOCKS_EXPAND_SUCCESS';
+export const DOMAIN_BLOCKS_EXPAND_FAIL    = 'DOMAIN_BLOCKS_EXPAND_FAIL';
+
+export function blockDomain(domain) {
+  return (dispatch, getState) => {
+    dispatch(blockDomainRequest(domain));
+
+    api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
+      const at_domain = '@' + domain;
+      const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
+      dispatch(blockDomainSuccess(domain, accounts));
+    }).catch(err => {
+      dispatch(blockDomainFail(domain, err));
+    });
+  };
+};
+
+export function blockDomainRequest(domain) {
+  return {
+    type: DOMAIN_BLOCK_REQUEST,
+    domain,
+  };
+};
+
+export function blockDomainSuccess(domain, accounts) {
+  return {
+    type: DOMAIN_BLOCK_SUCCESS,
+    domain,
+    accounts,
+  };
+};
+
+export function blockDomainFail(domain, error) {
+  return {
+    type: DOMAIN_BLOCK_FAIL,
+    domain,
+    error,
+  };
+};
+
+export function unblockDomain(domain) {
+  return (dispatch, getState) => {
+    dispatch(unblockDomainRequest(domain));
+
+    api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => {
+      const at_domain = '@' + domain;
+      const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
+      dispatch(unblockDomainSuccess(domain, accounts));
+    }).catch(err => {
+      dispatch(unblockDomainFail(domain, err));
+    });
+  };
+};
+
+export function unblockDomainRequest(domain) {
+  return {
+    type: DOMAIN_UNBLOCK_REQUEST,
+    domain,
+  };
+};
+
+export function unblockDomainSuccess(domain, accounts) {
+  return {
+    type: DOMAIN_UNBLOCK_SUCCESS,
+    domain,
+    accounts,
+  };
+};
+
+export function unblockDomainFail(domain, error) {
+  return {
+    type: DOMAIN_UNBLOCK_FAIL,
+    domain,
+    error,
+  };
+};
+
+export function fetchDomainBlocks() {
+  return (dispatch, getState) => {
+    dispatch(fetchDomainBlocksRequest());
+
+    api(getState).get('/api/v1/domain_blocks').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchDomainBlocksSuccess(response.data, next ? next.uri : null));
+    }).catch(err => {
+      dispatch(fetchDomainBlocksFail(err));
+    });
+  };
+};
+
+export function fetchDomainBlocksRequest() {
+  return {
+    type: DOMAIN_BLOCKS_FETCH_REQUEST,
+  };
+};
+
+export function fetchDomainBlocksSuccess(domains, next) {
+  return {
+    type: DOMAIN_BLOCKS_FETCH_SUCCESS,
+    domains,
+    next,
+  };
+};
+
+export function fetchDomainBlocksFail(error) {
+  return {
+    type: DOMAIN_BLOCKS_FETCH_FAIL,
+    error,
+  };
+};
+
+export function expandDomainBlocks() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['domain_lists', 'blocks', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandDomainBlocksRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandDomainBlocksSuccess(response.data, next ? next.uri : null));
+    }).catch(err => {
+      dispatch(expandDomainBlocksFail(err));
+    });
+  };
+};
+
+export function expandDomainBlocksRequest() {
+  return {
+    type: DOMAIN_BLOCKS_EXPAND_REQUEST,
+  };
+};
+
+export function expandDomainBlocksSuccess(domains, next) {
+  return {
+    type: DOMAIN_BLOCKS_EXPAND_SUCCESS,
+    domains,
+    next,
+  };
+};
+
+export function expandDomainBlocksFail(error) {
+  return {
+    type: DOMAIN_BLOCKS_EXPAND_FAIL,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/dropdown_menu.js b/app/javascript/flavours/glitch/actions/dropdown_menu.js
new file mode 100644
index 000000000..217ba4e74
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/dropdown_menu.js
@@ -0,0 +1,10 @@
+export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN';
+export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE';
+
+export function openDropdownMenu(id, placement) {
+  return { type: DROPDOWN_MENU_OPEN, id, placement };
+}
+
+export function closeDropdownMenu(id) {
+  return { type: DROPDOWN_MENU_CLOSE, id };
+}
diff --git a/app/javascript/flavours/glitch/actions/emojis.js b/app/javascript/flavours/glitch/actions/emojis.js
new file mode 100644
index 000000000..7cd9d4b7b
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/emojis.js
@@ -0,0 +1,14 @@
+import { saveSettings } from './settings';
+
+export const EMOJI_USE = 'EMOJI_USE';
+
+export function useEmoji(emoji) {
+  return dispatch => {
+    dispatch({
+      type: EMOJI_USE,
+      emoji,
+    });
+
+    dispatch(saveSettings());
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/favourites.js b/app/javascript/flavours/glitch/actions/favourites.js
new file mode 100644
index 000000000..0c0f3af44
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/favourites.js
@@ -0,0 +1,87 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+
+export const FAVOURITED_STATUSES_FETCH_REQUEST = 'FAVOURITED_STATUSES_FETCH_REQUEST';
+export const FAVOURITED_STATUSES_FETCH_SUCCESS = 'FAVOURITED_STATUSES_FETCH_SUCCESS';
+export const FAVOURITED_STATUSES_FETCH_FAIL    = 'FAVOURITED_STATUSES_FETCH_FAIL';
+
+export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_REQUEST';
+export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS';
+export const FAVOURITED_STATUSES_EXPAND_FAIL    = 'FAVOURITED_STATUSES_EXPAND_FAIL';
+
+export function fetchFavouritedStatuses() {
+  return (dispatch, getState) => {
+    if (getState().getIn(['status_lists', 'favourites', 'isLoading'])) {
+      return;
+    }
+
+    dispatch(fetchFavouritedStatusesRequest());
+
+    api(getState).get('/api/v1/favourites').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(fetchFavouritedStatusesFail(error));
+    });
+  };
+};
+
+export function fetchFavouritedStatusesRequest() {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_REQUEST,
+  };
+};
+
+export function fetchFavouritedStatusesSuccess(statuses, next) {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_SUCCESS,
+    statuses,
+    next,
+  };
+};
+
+export function fetchFavouritedStatusesFail(error) {
+  return {
+    type: FAVOURITED_STATUSES_FETCH_FAIL,
+    error,
+  };
+};
+
+export function expandFavouritedStatuses() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['status_lists', 'favourites', 'next'], null);
+
+    if (url === null || getState().getIn(['status_lists', 'favourites', 'isLoading'])) {
+      return;
+    }
+
+    dispatch(expandFavouritedStatusesRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandFavouritedStatusesSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandFavouritedStatusesFail(error));
+    });
+  };
+};
+
+export function expandFavouritedStatusesRequest() {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_REQUEST,
+  };
+};
+
+export function expandFavouritedStatusesSuccess(statuses, next) {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_SUCCESS,
+    statuses,
+    next,
+  };
+};
+
+export function expandFavouritedStatusesFail(error) {
+  return {
+    type: FAVOURITED_STATUSES_EXPAND_FAIL,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/height_cache.js b/app/javascript/flavours/glitch/actions/height_cache.js
new file mode 100644
index 000000000..4c752993f
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/height_cache.js
@@ -0,0 +1,17 @@
+export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET';
+export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR';
+
+export function setHeight (key, id, height) {
+  return {
+    type: HEIGHT_CACHE_SET,
+    key,
+    id,
+    height,
+  };
+};
+
+export function clearHeight () {
+  return {
+    type: HEIGHT_CACHE_CLEAR,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js
new file mode 100644
index 000000000..ceeb2773b
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/interactions.js
@@ -0,0 +1,313 @@
+import api from 'flavours/glitch/util/api';
+
+export const REBLOG_REQUEST = 'REBLOG_REQUEST';
+export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
+export const REBLOG_FAIL    = 'REBLOG_FAIL';
+
+export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
+export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
+export const FAVOURITE_FAIL    = 'FAVOURITE_FAIL';
+
+export const UNREBLOG_REQUEST = 'UNREBLOG_REQUEST';
+export const UNREBLOG_SUCCESS = 'UNREBLOG_SUCCESS';
+export const UNREBLOG_FAIL    = 'UNREBLOG_FAIL';
+
+export const UNFAVOURITE_REQUEST = 'UNFAVOURITE_REQUEST';
+export const UNFAVOURITE_SUCCESS = 'UNFAVOURITE_SUCCESS';
+export const UNFAVOURITE_FAIL    = 'UNFAVOURITE_FAIL';
+
+export const REBLOGS_FETCH_REQUEST = 'REBLOGS_FETCH_REQUEST';
+export const REBLOGS_FETCH_SUCCESS = 'REBLOGS_FETCH_SUCCESS';
+export const REBLOGS_FETCH_FAIL    = 'REBLOGS_FETCH_FAIL';
+
+export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
+export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
+export const FAVOURITES_FETCH_FAIL    = 'FAVOURITES_FETCH_FAIL';
+
+export const PIN_REQUEST = 'PIN_REQUEST';
+export const PIN_SUCCESS = 'PIN_SUCCESS';
+export const PIN_FAIL    = 'PIN_FAIL';
+
+export const UNPIN_REQUEST = 'UNPIN_REQUEST';
+export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
+export const UNPIN_FAIL    = 'UNPIN_FAIL';
+
+export function reblog(status) {
+  return function (dispatch, getState) {
+    dispatch(reblogRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) {
+      // The reblog API method returns a new status wrapped around the original. In this case we are only
+      // interested in how the original is modified, hence passing it skipping the wrapper
+      dispatch(reblogSuccess(status, response.data.reblog));
+    }).catch(function (error) {
+      dispatch(reblogFail(status, error));
+    });
+  };
+};
+
+export function unreblog(status) {
+  return (dispatch, getState) => {
+    dispatch(unreblogRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/unreblog`).then(response => {
+      dispatch(unreblogSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(unreblogFail(status, error));
+    });
+  };
+};
+
+export function reblogRequest(status) {
+  return {
+    type: REBLOG_REQUEST,
+    status: status,
+  };
+};
+
+export function reblogSuccess(status, response) {
+  return {
+    type: REBLOG_SUCCESS,
+    status: status,
+    response: response,
+  };
+};
+
+export function reblogFail(status, error) {
+  return {
+    type: REBLOG_FAIL,
+    status: status,
+    error: error,
+  };
+};
+
+export function unreblogRequest(status) {
+  return {
+    type: UNREBLOG_REQUEST,
+    status: status,
+  };
+};
+
+export function unreblogSuccess(status, response) {
+  return {
+    type: UNREBLOG_SUCCESS,
+    status: status,
+    response: response,
+  };
+};
+
+export function unreblogFail(status, error) {
+  return {
+    type: UNREBLOG_FAIL,
+    status: status,
+    error: error,
+  };
+};
+
+export function favourite(status) {
+  return function (dispatch, getState) {
+    dispatch(favouriteRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/favourite`).then(function (response) {
+      dispatch(favouriteSuccess(status, response.data));
+    }).catch(function (error) {
+      dispatch(favouriteFail(status, error));
+    });
+  };
+};
+
+export function unfavourite(status) {
+  return (dispatch, getState) => {
+    dispatch(unfavouriteRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/unfavourite`).then(response => {
+      dispatch(unfavouriteSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(unfavouriteFail(status, error));
+    });
+  };
+};
+
+export function favouriteRequest(status) {
+  return {
+    type: FAVOURITE_REQUEST,
+    status: status,
+  };
+};
+
+export function favouriteSuccess(status, response) {
+  return {
+    type: FAVOURITE_SUCCESS,
+    status: status,
+    response: response,
+  };
+};
+
+export function favouriteFail(status, error) {
+  return {
+    type: FAVOURITE_FAIL,
+    status: status,
+    error: error,
+  };
+};
+
+export function unfavouriteRequest(status) {
+  return {
+    type: UNFAVOURITE_REQUEST,
+    status: status,
+  };
+};
+
+export function unfavouriteSuccess(status, response) {
+  return {
+    type: UNFAVOURITE_SUCCESS,
+    status: status,
+    response: response,
+  };
+};
+
+export function unfavouriteFail(status, error) {
+  return {
+    type: UNFAVOURITE_FAIL,
+    status: status,
+    error: error,
+  };
+};
+
+export function fetchReblogs(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchReblogsRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
+      dispatch(fetchReblogsSuccess(id, response.data));
+    }).catch(error => {
+      dispatch(fetchReblogsFail(id, error));
+    });
+  };
+};
+
+export function fetchReblogsRequest(id) {
+  return {
+    type: REBLOGS_FETCH_REQUEST,
+    id,
+  };
+};
+
+export function fetchReblogsSuccess(id, accounts) {
+  return {
+    type: REBLOGS_FETCH_SUCCESS,
+    id,
+    accounts,
+  };
+};
+
+export function fetchReblogsFail(id, error) {
+  return {
+    type: REBLOGS_FETCH_FAIL,
+    error,
+  };
+};
+
+export function fetchFavourites(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchFavouritesRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
+      dispatch(fetchFavouritesSuccess(id, response.data));
+    }).catch(error => {
+      dispatch(fetchFavouritesFail(id, error));
+    });
+  };
+};
+
+export function fetchFavouritesRequest(id) {
+  return {
+    type: FAVOURITES_FETCH_REQUEST,
+    id,
+  };
+};
+
+export function fetchFavouritesSuccess(id, accounts) {
+  return {
+    type: FAVOURITES_FETCH_SUCCESS,
+    id,
+    accounts,
+  };
+};
+
+export function fetchFavouritesFail(id, error) {
+  return {
+    type: FAVOURITES_FETCH_FAIL,
+    error,
+  };
+};
+
+export function pin(status) {
+  return (dispatch, getState) => {
+    dispatch(pinRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
+      dispatch(pinSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(pinFail(status, error));
+    });
+  };
+};
+
+export function pinRequest(status) {
+  return {
+    type: PIN_REQUEST,
+    status,
+  };
+};
+
+export function pinSuccess(status, response) {
+  return {
+    type: PIN_SUCCESS,
+    status,
+    response,
+  };
+};
+
+export function pinFail(status, error) {
+  return {
+    type: PIN_FAIL,
+    status,
+    error,
+  };
+};
+
+export function unpin (status) {
+  return (dispatch, getState) => {
+    dispatch(unpinRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
+      dispatch(unpinSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(unpinFail(status, error));
+    });
+  };
+};
+
+export function unpinRequest(status) {
+  return {
+    type: UNPIN_REQUEST,
+    status,
+  };
+};
+
+export function unpinSuccess(status, response) {
+  return {
+    type: UNPIN_SUCCESS,
+    status,
+    response,
+  };
+};
+
+export function unpinFail(status, error) {
+  return {
+    type: UNPIN_FAIL,
+    status,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/lists.js b/app/javascript/flavours/glitch/actions/lists.js
new file mode 100644
index 000000000..3c3af5fee
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/lists.js
@@ -0,0 +1,313 @@
+import api from 'flavours/glitch/util/api';
+
+export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
+export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
+export const LIST_FETCH_FAIL    = 'LIST_FETCH_FAIL';
+
+export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST';
+export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS';
+export const LISTS_FETCH_FAIL    = 'LISTS_FETCH_FAIL';
+
+export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE';
+export const LIST_EDITOR_RESET        = 'LIST_EDITOR_RESET';
+export const LIST_EDITOR_SETUP        = 'LIST_EDITOR_SETUP';
+
+export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST';
+export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS';
+export const LIST_CREATE_FAIL    = 'LIST_CREATE_FAIL';
+
+export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST';
+export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS';
+export const LIST_UPDATE_FAIL    = 'LIST_UPDATE_FAIL';
+
+export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST';
+export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS';
+export const LIST_DELETE_FAIL    = 'LIST_DELETE_FAIL';
+
+export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST';
+export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS';
+export const LIST_ACCOUNTS_FETCH_FAIL    = 'LIST_ACCOUNTS_FETCH_FAIL';
+
+export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE';
+export const LIST_EDITOR_SUGGESTIONS_READY  = 'LIST_EDITOR_SUGGESTIONS_READY';
+export const LIST_EDITOR_SUGGESTIONS_CLEAR  = 'LIST_EDITOR_SUGGESTIONS_CLEAR';
+
+export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST';
+export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS';
+export const LIST_EDITOR_ADD_FAIL    = 'LIST_EDITOR_ADD_FAIL';
+
+export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST';
+export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS';
+export const LIST_EDITOR_REMOVE_FAIL    = 'LIST_EDITOR_REMOVE_FAIL';
+
+export const fetchList = id => (dispatch, getState) => {
+  if (getState().getIn(['lists', id])) {
+    return;
+  }
+
+  dispatch(fetchListRequest(id));
+
+  api(getState).get(`/api/v1/lists/${id}`)
+    .then(({ data }) => dispatch(fetchListSuccess(data)))
+    .catch(err => dispatch(fetchListFail(id, err)));
+};
+
+export const fetchListRequest = id => ({
+  type: LIST_FETCH_REQUEST,
+  id,
+});
+
+export const fetchListSuccess = list => ({
+  type: LIST_FETCH_SUCCESS,
+  list,
+});
+
+export const fetchListFail = (id, error) => ({
+  type: LIST_FETCH_FAIL,
+  id,
+  error,
+});
+
+export const fetchLists = () => (dispatch, getState) => {
+  dispatch(fetchListsRequest());
+
+  api(getState).get('/api/v1/lists')
+    .then(({ data }) => dispatch(fetchListsSuccess(data)))
+    .catch(err => dispatch(fetchListsFail(err)));
+};
+
+export const fetchListsRequest = () => ({
+  type: LISTS_FETCH_REQUEST,
+});
+
+export const fetchListsSuccess = lists => ({
+  type: LISTS_FETCH_SUCCESS,
+  lists,
+});
+
+export const fetchListsFail = error => ({
+  type: LISTS_FETCH_FAIL,
+  error,
+});
+
+export const submitListEditor = shouldReset => (dispatch, getState) => {
+  const listId = getState().getIn(['listEditor', 'listId']);
+  const title  = getState().getIn(['listEditor', 'title']);
+
+  if (listId === null) {
+    dispatch(createList(title, shouldReset));
+  } else {
+    dispatch(updateList(listId, title, shouldReset));
+  }
+};
+
+export const setupListEditor = listId => (dispatch, getState) => {
+  dispatch({
+    type: LIST_EDITOR_SETUP,
+    list: getState().getIn(['lists', listId]),
+  });
+
+  dispatch(fetchListAccounts(listId));
+};
+
+export const changeListEditorTitle = value => ({
+  type: LIST_EDITOR_TITLE_CHANGE,
+  value,
+});
+
+export const createList = (title, shouldReset) => (dispatch, getState) => {
+  dispatch(createListRequest());
+
+  api(getState).post('/api/v1/lists', { title }).then(({ data }) => {
+    dispatch(createListSuccess(data));
+
+    if (shouldReset) {
+      dispatch(resetListEditor());
+    }
+  }).catch(err => dispatch(createListFail(err)));
+};
+
+export const createListRequest = () => ({
+  type: LIST_CREATE_REQUEST,
+});
+
+export const createListSuccess = list => ({
+  type: LIST_CREATE_SUCCESS,
+  list,
+});
+
+export const createListFail = error => ({
+  type: LIST_CREATE_FAIL,
+  error,
+});
+
+export const updateList = (id, title, shouldReset) => (dispatch, getState) => {
+  dispatch(updateListRequest(id));
+
+  api(getState).put(`/api/v1/lists/${id}`, { title }).then(({ data }) => {
+    dispatch(updateListSuccess(data));
+
+    if (shouldReset) {
+      dispatch(resetListEditor());
+    }
+  }).catch(err => dispatch(updateListFail(id, err)));
+};
+
+export const updateListRequest = id => ({
+  type: LIST_UPDATE_REQUEST,
+  id,
+});
+
+export const updateListSuccess = list => ({
+  type: LIST_UPDATE_SUCCESS,
+  list,
+});
+
+export const updateListFail = (id, error) => ({
+  type: LIST_UPDATE_FAIL,
+  id,
+  error,
+});
+
+export const resetListEditor = () => ({
+  type: LIST_EDITOR_RESET,
+});
+
+export const deleteList = id => (dispatch, getState) => {
+  dispatch(deleteListRequest(id));
+
+  api(getState).delete(`/api/v1/lists/${id}`)
+    .then(() => dispatch(deleteListSuccess(id)))
+    .catch(err => dispatch(deleteListFail(id, err)));
+};
+
+export const deleteListRequest = id => ({
+  type: LIST_DELETE_REQUEST,
+  id,
+});
+
+export const deleteListSuccess = id => ({
+  type: LIST_DELETE_SUCCESS,
+  id,
+});
+
+export const deleteListFail = (id, error) => ({
+  type: LIST_DELETE_FAIL,
+  id,
+  error,
+});
+
+export const fetchListAccounts = listId => (dispatch, getState) => {
+  dispatch(fetchListAccountsRequest(listId));
+
+  api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } })
+    .then(({ data }) => dispatch(fetchListAccountsSuccess(listId, data)))
+    .catch(err => dispatch(fetchListAccountsFail(listId, err)));
+};
+
+export const fetchListAccountsRequest = id => ({
+  type: LIST_ACCOUNTS_FETCH_REQUEST,
+  id,
+});
+
+export const fetchListAccountsSuccess = (id, accounts, next) => ({
+  type: LIST_ACCOUNTS_FETCH_SUCCESS,
+  id,
+  accounts,
+  next,
+});
+
+export const fetchListAccountsFail = (id, error) => ({
+  type: LIST_ACCOUNTS_FETCH_FAIL,
+  id,
+  error,
+});
+
+export const fetchListSuggestions = q => (dispatch, getState) => {
+  const params = {
+    q,
+    resolve: false,
+    limit: 4,
+    following: true,
+  };
+
+  api(getState).get('/api/v1/accounts/search', { params })
+    .then(({ data }) => dispatch(fetchListSuggestionsReady(q, data)));
+};
+
+export const fetchListSuggestionsReady = (query, accounts) => ({
+  type: LIST_EDITOR_SUGGESTIONS_READY,
+  query,
+  accounts,
+});
+
+export const clearListSuggestions = () => ({
+  type: LIST_EDITOR_SUGGESTIONS_CLEAR,
+});
+
+export const changeListSuggestions = value => ({
+  type: LIST_EDITOR_SUGGESTIONS_CHANGE,
+  value,
+});
+
+export const addToListEditor = accountId => (dispatch, getState) => {
+  dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId));
+};
+
+export const addToList = (listId, accountId) => (dispatch, getState) => {
+  dispatch(addToListRequest(listId, accountId));
+
+  api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] })
+    .then(() => dispatch(addToListSuccess(listId, accountId)))
+    .catch(err => dispatch(addToListFail(listId, accountId, err)));
+};
+
+export const addToListRequest = (listId, accountId) => ({
+  type: LIST_EDITOR_ADD_REQUEST,
+  listId,
+  accountId,
+});
+
+export const addToListSuccess = (listId, accountId) => ({
+  type: LIST_EDITOR_ADD_SUCCESS,
+  listId,
+  accountId,
+});
+
+export const addToListFail = (listId, accountId, error) => ({
+  type: LIST_EDITOR_ADD_FAIL,
+  listId,
+  accountId,
+  error,
+});
+
+export const removeFromListEditor = accountId => (dispatch, getState) => {
+  dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId));
+};
+
+export const removeFromList = (listId, accountId) => (dispatch, getState) => {
+  dispatch(removeFromListRequest(listId, accountId));
+
+  api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } })
+    .then(() => dispatch(removeFromListSuccess(listId, accountId)))
+    .catch(err => dispatch(removeFromListFail(listId, accountId, err)));
+};
+
+export const removeFromListRequest = (listId, accountId) => ({
+  type: LIST_EDITOR_REMOVE_REQUEST,
+  listId,
+  accountId,
+});
+
+export const removeFromListSuccess = (listId, accountId) => ({
+  type: LIST_EDITOR_REMOVE_SUCCESS,
+  listId,
+  accountId,
+});
+
+export const removeFromListFail = (listId, accountId, error) => ({
+  type: LIST_EDITOR_REMOVE_FAIL,
+  listId,
+  accountId,
+  error,
+});
diff --git a/app/javascript/flavours/glitch/actions/local_settings.js b/app/javascript/flavours/glitch/actions/local_settings.js
new file mode 100644
index 000000000..28660a4e8
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/local_settings.js
@@ -0,0 +1,24 @@
+export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE';
+
+export function changeLocalSetting(key, value) {
+  return dispatch => {
+    dispatch({
+      type: LOCAL_SETTING_CHANGE,
+      key,
+      value,
+    });
+
+    dispatch(saveLocalSettings());
+  };
+};
+
+//  __TODO :__
+//  Right now `saveLocalSettings()` doesn't keep track of which user
+//  is currently signed in, but it might be better to give each user
+//  their *own* local settings.
+export function saveLocalSettings() {
+  return (_, getState) => {
+    const localSettings = getState().get('local_settings').toJS();
+    localStorage.setItem('mastodon-settings', JSON.stringify(localSettings));
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/modal.js b/app/javascript/flavours/glitch/actions/modal.js
new file mode 100644
index 000000000..80e15c28e
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/modal.js
@@ -0,0 +1,16 @@
+export const MODAL_OPEN  = 'MODAL_OPEN';
+export const MODAL_CLOSE = 'MODAL_CLOSE';
+
+export function openModal(type, props) {
+  return {
+    type: MODAL_OPEN,
+    modalType: type,
+    modalProps: props,
+  };
+};
+
+export function closeModal() {
+  return {
+    type: MODAL_CLOSE,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js
new file mode 100644
index 000000000..e06130533
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/mutes.js
@@ -0,0 +1,103 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+import { fetchRelationships } from './accounts';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
+export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
+export const MUTES_FETCH_FAIL    = 'MUTES_FETCH_FAIL';
+
+export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST';
+export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS';
+export const MUTES_EXPAND_FAIL    = 'MUTES_EXPAND_FAIL';
+
+export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
+export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
+
+export function fetchMutes() {
+  return (dispatch, getState) => {
+    dispatch(fetchMutesRequest());
+
+    api(getState).get('/api/v1/mutes').then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(fetchMutesSuccess(response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => dispatch(fetchMutesFail(error)));
+  };
+};
+
+export function fetchMutesRequest() {
+  return {
+    type: MUTES_FETCH_REQUEST,
+  };
+};
+
+export function fetchMutesSuccess(accounts, next) {
+  return {
+    type: MUTES_FETCH_SUCCESS,
+    accounts,
+    next,
+  };
+};
+
+export function fetchMutesFail(error) {
+  return {
+    type: MUTES_FETCH_FAIL,
+    error,
+  };
+};
+
+export function expandMutes() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['user_lists', 'mutes', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandMutesRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandMutesSuccess(response.data, next ? next.uri : null));
+      dispatch(fetchRelationships(response.data.map(item => item.id)));
+    }).catch(error => dispatch(expandMutesFail(error)));
+  };
+};
+
+export function expandMutesRequest() {
+  return {
+    type: MUTES_EXPAND_REQUEST,
+  };
+};
+
+export function expandMutesSuccess(accounts, next) {
+  return {
+    type: MUTES_EXPAND_SUCCESS,
+    accounts,
+    next,
+  };
+};
+
+export function expandMutesFail(error) {
+  return {
+    type: MUTES_EXPAND_FAIL,
+    error,
+  };
+};
+
+export function initMuteModal(account) {
+  return dispatch => {
+    dispatch({
+      type: MUTES_INIT_MODAL,
+      account,
+    });
+
+    dispatch(openModal('MUTE'));
+  };
+}
+
+export function toggleHideNotifications() {
+  return dispatch => {
+    dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
new file mode 100644
index 000000000..cf27eff90
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -0,0 +1,265 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+import { List as ImmutableList } from 'immutable';
+import IntlMessageFormat from 'intl-messageformat';
+import { fetchRelationships } from './accounts';
+import { defineMessages } from 'react-intl';
+
+export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
+
+// tracking the notif cleaning request
+export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST';
+export const NOTIFICATIONS_DELETE_MARKED_SUCCESS = 'NOTIFICATIONS_DELETE_MARKED_SUCCESS';
+export const NOTIFICATIONS_DELETE_MARKED_FAIL = 'NOTIFICATIONS_DELETE_MARKED_FAIL';
+export const NOTIFICATIONS_MARK_ALL_FOR_DELETE = 'NOTIFICATIONS_MARK_ALL_FOR_DELETE';
+export const NOTIFICATIONS_ENTER_CLEARING_MODE = 'NOTIFICATIONS_ENTER_CLEARING_MODE'; // arg: yes
+// Unmark notifications (when the cleaning mode is left)
+export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR_DELETE';
+// Mark one for delete
+export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE';
+
+export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
+export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
+export const NOTIFICATIONS_REFRESH_FAIL    = 'NOTIFICATIONS_REFRESH_FAIL';
+
+export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
+export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
+export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL';
+
+export const NOTIFICATIONS_CLEAR      = 'NOTIFICATIONS_CLEAR';
+export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
+
+defineMessages({
+  mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
+});
+
+const fetchRelatedRelationships = (dispatch, notifications) => {
+  const accountIds = notifications.filter(item => item.type === 'follow').map(item => item.account.id);
+
+  if (accountIds > 0) {
+    dispatch(fetchRelationships(accountIds));
+  }
+};
+
+const unescapeHTML = (html) => {
+  const wrapper = document.createElement('div');
+  html = html.replace(/<br \/>|<br>|\n/g, ' ');
+  wrapper.innerHTML = html;
+  return wrapper.textContent;
+};
+
+export function updateNotifications(notification, intlMessages, intlLocale) {
+  return (dispatch, getState) => {
+    const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
+    const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
+
+    dispatch({
+      type: NOTIFICATIONS_UPDATE,
+      notification,
+      account: notification.account,
+      status: notification.status,
+      meta: playSound ? { sound: 'boop' } : undefined,
+    });
+
+    fetchRelatedRelationships(dispatch, [notification]);
+
+    // Desktop notifications
+    if (typeof window.Notification !== 'undefined' && showAlert) {
+      const title = new IntlMessageFormat(intlMessages[`notification.${notification.type}`], intlLocale).format({ name: notification.account.display_name.length > 0 ? notification.account.display_name : notification.account.username });
+      const body  = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : '');
+
+      const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
+      notify.addEventListener('click', () => {
+        window.focus();
+        notify.close();
+      });
+    }
+  };
+};
+
+const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
+
+export function refreshNotifications() {
+  return (dispatch, getState) => {
+    const params = {};
+    const ids    = getState().getIn(['notifications', 'items']);
+
+    let skipLoading = false;
+
+    if (ids.size > 0) {
+      params.since_id = ids.first().get('id');
+    }
+
+    if (getState().getIn(['notifications', 'loaded'])) {
+      skipLoading = true;
+    }
+
+    params.exclude_types = excludeTypesFromSettings(getState());
+
+    dispatch(refreshNotificationsRequest(skipLoading));
+
+    api(getState).get('/api/v1/notifications', { params }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null));
+      fetchRelatedRelationships(dispatch, response.data);
+    }).catch(error => {
+      dispatch(refreshNotificationsFail(error, skipLoading));
+    });
+  };
+};
+
+export function refreshNotificationsRequest(skipLoading) {
+  return {
+    type: NOTIFICATIONS_REFRESH_REQUEST,
+    skipLoading,
+  };
+};
+
+export function refreshNotificationsSuccess(notifications, skipLoading, next) {
+  return {
+    type: NOTIFICATIONS_REFRESH_SUCCESS,
+    notifications,
+    accounts: notifications.map(item => item.account),
+    statuses: notifications.map(item => item.status).filter(status => !!status),
+    skipLoading,
+    next,
+  };
+};
+
+export function refreshNotificationsFail(error, skipLoading) {
+  return {
+    type: NOTIFICATIONS_REFRESH_FAIL,
+    error,
+    skipLoading,
+  };
+};
+
+export function expandNotifications() {
+  return (dispatch, getState) => {
+    const items  = getState().getIn(['notifications', 'items'], ImmutableList());
+
+    if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) {
+      return;
+    }
+
+    const params = {
+      max_id: items.last().get('id'),
+      limit: 20,
+      exclude_types: excludeTypesFromSettings(getState()),
+    };
+
+    dispatch(expandNotificationsRequest());
+
+    api(getState).get('/api/v1/notifications', { params }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null));
+      fetchRelatedRelationships(dispatch, response.data);
+    }).catch(error => {
+      dispatch(expandNotificationsFail(error));
+    });
+  };
+};
+
+export function expandNotificationsRequest() {
+  return {
+    type: NOTIFICATIONS_EXPAND_REQUEST,
+  };
+};
+
+export function expandNotificationsSuccess(notifications, next) {
+  return {
+    type: NOTIFICATIONS_EXPAND_SUCCESS,
+    notifications,
+    accounts: notifications.map(item => item.account),
+    statuses: notifications.map(item => item.status).filter(status => !!status),
+    next,
+  };
+};
+
+export function expandNotificationsFail(error) {
+  return {
+    type: NOTIFICATIONS_EXPAND_FAIL,
+    error,
+  };
+};
+
+export function clearNotifications() {
+  return (dispatch, getState) => {
+    dispatch({
+      type: NOTIFICATIONS_CLEAR,
+    });
+
+    api(getState).post('/api/v1/notifications/clear');
+  };
+};
+
+export function scrollTopNotifications(top) {
+  return {
+    type: NOTIFICATIONS_SCROLL_TOP,
+    top,
+  };
+};
+
+export function deleteMarkedNotifications() {
+  return (dispatch, getState) => {
+    dispatch(deleteMarkedNotificationsRequest());
+
+    let ids = [];
+    getState().getIn(['notifications', 'items']).forEach((n) => {
+      if (n.get('markedForDelete')) {
+        ids.push(n.get('id'));
+      }
+    });
+
+    if (ids.length === 0) {
+      return;
+    }
+
+    api(getState).delete(`/api/v1/notifications/destroy_multiple?ids[]=${ids.join('&ids[]=')}`).then(() => {
+      dispatch(deleteMarkedNotificationsSuccess());
+    }).catch(error => {
+      console.error(error);
+      dispatch(deleteMarkedNotificationsFail(error));
+    });
+  };
+};
+
+export function enterNotificationClearingMode(yes) {
+  return {
+    type: NOTIFICATIONS_ENTER_CLEARING_MODE,
+    yes: yes,
+  };
+};
+
+export function markAllNotifications(yes) {
+  return {
+    type: NOTIFICATIONS_MARK_ALL_FOR_DELETE,
+    yes: yes, // true, false or null. null = invert
+  };
+};
+
+export function deleteMarkedNotificationsRequest() {
+  return {
+    type: NOTIFICATIONS_DELETE_MARKED_REQUEST,
+  };
+};
+
+export function deleteMarkedNotificationsFail() {
+  return {
+    type: NOTIFICATIONS_DELETE_MARKED_FAIL,
+  };
+};
+
+export function markNotificationForDelete(id, yes) {
+  return {
+    type: NOTIFICATION_MARK_FOR_DELETE,
+    id: id,
+    yes: yes,
+  };
+};
+
+export function deleteMarkedNotificationsSuccess() {
+  return {
+    type: NOTIFICATIONS_DELETE_MARKED_SUCCESS,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/onboarding.js b/app/javascript/flavours/glitch/actions/onboarding.js
new file mode 100644
index 000000000..a161c50ef
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/onboarding.js
@@ -0,0 +1,14 @@
+import { openModal } from './modal';
+import { changeSetting, saveSettings } from './settings';
+
+export function showOnboardingOnce() {
+  return (dispatch, getState) => {
+    const alreadySeen = getState().getIn(['settings', 'onboarded']);
+
+    if (!alreadySeen) {
+      dispatch(openModal('ONBOARDING'));
+      dispatch(changeSetting(['onboarded'], true));
+      dispatch(saveSettings());
+    }
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/pin_statuses.js b/app/javascript/flavours/glitch/actions/pin_statuses.js
new file mode 100644
index 000000000..d3d1a154f
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/pin_statuses.js
@@ -0,0 +1,40 @@
+import api from 'flavours/glitch/util/api';
+
+export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
+export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
+export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';
+
+import { me } from 'flavours/glitch/util/initial_state';
+
+export function fetchPinnedStatuses() {
+  return (dispatch, getState) => {
+    dispatch(fetchPinnedStatusesRequest());
+
+    api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => {
+      dispatch(fetchPinnedStatusesSuccess(response.data, null));
+    }).catch(error => {
+      dispatch(fetchPinnedStatusesFail(error));
+    });
+  };
+};
+
+export function fetchPinnedStatusesRequest() {
+  return {
+    type: PINNED_STATUSES_FETCH_REQUEST,
+  };
+};
+
+export function fetchPinnedStatusesSuccess(statuses, next) {
+  return {
+    type: PINNED_STATUSES_FETCH_SUCCESS,
+    statuses,
+    next,
+  };
+};
+
+export function fetchPinnedStatusesFail(error) {
+  return {
+    type: PINNED_STATUSES_FETCH_FAIL,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/push_notifications/index.js b/app/javascript/flavours/glitch/actions/push_notifications/index.js
new file mode 100644
index 000000000..2ffec500a
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/push_notifications/index.js
@@ -0,0 +1,23 @@
+import {
+  SET_BROWSER_SUPPORT,
+  SET_SUBSCRIPTION,
+  CLEAR_SUBSCRIPTION,
+  SET_ALERTS,
+  setAlerts,
+} from './setter';
+import { register, saveSettings } from './registerer';
+
+export {
+  SET_BROWSER_SUPPORT,
+  SET_SUBSCRIPTION,
+  CLEAR_SUBSCRIPTION,
+  SET_ALERTS,
+  register,
+};
+
+export function changeAlerts(path, value) {
+  return dispatch => {
+    dispatch(setAlerts(path, value));
+    dispatch(saveSettings());
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js
new file mode 100644
index 000000000..5ad11f73f
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js
@@ -0,0 +1,149 @@
+import api from 'flavours/glitch/util/api';
+import { pushNotificationsSetting } from 'flavours/glitch/util/settings';
+import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
+
+// Taken from https://www.npmjs.com/package/web-push
+const urlBase64ToUint8Array = (base64String) => {
+  const padding = '='.repeat((4 - base64String.length % 4) % 4);
+  const base64 = (base64String + padding)
+    .replace(/\-/g, '+')
+    .replace(/_/g, '/');
+
+  const rawData = window.atob(base64);
+  const outputArray = new Uint8Array(rawData.length);
+
+  for (let i = 0; i < rawData.length; ++i) {
+    outputArray[i] = rawData.charCodeAt(i);
+  }
+  return outputArray;
+};
+
+const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
+
+const getRegistration = () => navigator.serviceWorker.ready;
+
+const getPushSubscription = (registration) =>
+  registration.pushManager.getSubscription()
+    .then(subscription => ({ registration, subscription }));
+
+const subscribe = (registration) =>
+  registration.pushManager.subscribe({
+    userVisibleOnly: true,
+    applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()),
+  });
+
+const unsubscribe = ({ registration, subscription }) =>
+  subscription ? subscription.unsubscribe().then(() => registration) : registration;
+
+const sendSubscriptionToBackend = (getState, subscription, me) => {
+  const params = { subscription };
+
+  if (me) {
+    const data = pushNotificationsSetting.get(me);
+    if (data) {
+      params.data = data;
+    }
+  }
+
+  return api(getState).post('/api/web/push_subscriptions', params).then(response => response.data);
+};
+
+// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
+const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
+
+export function register () {
+  return (dispatch, getState) => {
+    dispatch(setBrowserSupport(supportsPushNotifications));
+    const me = getState().getIn(['meta', 'me']);
+
+    if (me && !pushNotificationsSetting.get(me)) {
+      const alerts = getState().getIn(['push_notifications', 'alerts']);
+      if (alerts) {
+        pushNotificationsSetting.set(me, { alerts: alerts });
+      }
+    }
+
+    if (supportsPushNotifications) {
+      if (!getApplicationServerKey()) {
+        console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
+        return;
+      }
+
+      getRegistration()
+        .then(getPushSubscription)
+        .then(({ registration, subscription }) => {
+          if (subscription !== null) {
+            // We have a subscription, check if it is still valid
+            const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString();
+            const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString();
+            const serverEndpoint = getState().getIn(['push_notifications', 'subscription', 'endpoint']);
+
+            // If the VAPID public key did not change and the endpoint corresponds
+            // to the endpoint saved in the backend, the subscription is valid
+            if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) {
+              return subscription;
+            } else {
+              // Something went wrong, try to subscribe again
+              return unsubscribe({ registration, subscription }).then(subscribe).then(
+                subscription => sendSubscriptionToBackend(getState, subscription, me));
+            }
+          }
+
+          // No subscription, try to subscribe
+          return subscribe(registration).then(
+            subscription => sendSubscriptionToBackend(getState, subscription, me));
+        })
+        .then(subscription => {
+          // If we got a PushSubscription (and not a subscription object from the backend)
+          // it means that the backend subscription is valid (and was set during hydration)
+          if (!(subscription instanceof PushSubscription)) {
+            dispatch(setSubscription(subscription));
+            if (me) {
+              pushNotificationsSetting.set(me, { alerts: subscription.alerts });
+            }
+          }
+        })
+        .catch(error => {
+          if (error.code === 20 && error.name === 'AbortError') {
+            console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
+          } else if (error.code === 5 && error.name === 'InvalidCharacterError') {
+            console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
+          }
+
+          // Clear alerts and hide UI settings
+          dispatch(clearSubscription());
+          if (me) {
+            pushNotificationsSetting.remove(me);
+          }
+
+          try {
+            getRegistration()
+              .then(getPushSubscription)
+              .then(unsubscribe);
+          } catch (e) {
+
+          }
+        });
+    } else {
+      console.warn('Your browser does not support Web Push Notifications.');
+    }
+  };
+}
+
+export function saveSettings() {
+  return (_, getState) => {
+    const state = getState().get('push_notifications');
+    const subscription = state.get('subscription');
+    const alerts = state.get('alerts');
+    const data = { alerts };
+
+    api(getState).put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
+      data,
+    }).then(() => {
+      const me = getState().getIn(['meta', 'me']);
+      if (me) {
+        pushNotificationsSetting.set(me, data);
+      }
+    });
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/push_notifications/setter.js b/app/javascript/flavours/glitch/actions/push_notifications/setter.js
new file mode 100644
index 000000000..5561766bf
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/push_notifications/setter.js
@@ -0,0 +1,34 @@
+export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
+export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
+export const SET_ALERTS = 'PUSH_NOTIFICATIONS_SET_ALERTS';
+
+export function setBrowserSupport (value) {
+  return {
+    type: SET_BROWSER_SUPPORT,
+    value,
+  };
+}
+
+export function setSubscription (subscription) {
+  return {
+    type: SET_SUBSCRIPTION,
+    subscription,
+  };
+}
+
+export function clearSubscription () {
+  return {
+    type: CLEAR_SUBSCRIPTION,
+  };
+}
+
+export function setAlerts (path, value) {
+  return dispatch => {
+    dispatch({
+      type: SET_ALERTS,
+      path,
+      value,
+    });
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/reports.js b/app/javascript/flavours/glitch/actions/reports.js
new file mode 100644
index 000000000..80c3b3280
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/reports.js
@@ -0,0 +1,89 @@
+import api from 'flavours/glitch/util/api';
+import { openModal, closeModal } from './modal';
+
+export const REPORT_INIT   = 'REPORT_INIT';
+export const REPORT_CANCEL = 'REPORT_CANCEL';
+
+export const REPORT_SUBMIT_REQUEST = 'REPORT_SUBMIT_REQUEST';
+export const REPORT_SUBMIT_SUCCESS = 'REPORT_SUBMIT_SUCCESS';
+export const REPORT_SUBMIT_FAIL    = 'REPORT_SUBMIT_FAIL';
+
+export const REPORT_STATUS_TOGGLE  = 'REPORT_STATUS_TOGGLE';
+export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
+export const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE';
+
+export function initReport(account, status) {
+  return dispatch => {
+    dispatch({
+      type: REPORT_INIT,
+      account,
+      status,
+    });
+
+    dispatch(openModal('REPORT'));
+  };
+};
+
+export function cancelReport() {
+  return {
+    type: REPORT_CANCEL,
+  };
+};
+
+export function toggleStatusReport(statusId, checked) {
+  return {
+    type: REPORT_STATUS_TOGGLE,
+    statusId,
+    checked,
+  };
+};
+
+export function submitReport() {
+  return (dispatch, getState) => {
+    dispatch(submitReportRequest());
+
+    api(getState).post('/api/v1/reports', {
+      account_id: getState().getIn(['reports', 'new', 'account_id']),
+      status_ids: getState().getIn(['reports', 'new', 'status_ids']),
+      comment: getState().getIn(['reports', 'new', 'comment']),
+      forward: getState().getIn(['reports', 'new', 'forward']),
+    }).then(response => {
+      dispatch(closeModal());
+      dispatch(submitReportSuccess(response.data));
+    }).catch(error => dispatch(submitReportFail(error)));
+  };
+};
+
+export function submitReportRequest() {
+  return {
+    type: REPORT_SUBMIT_REQUEST,
+  };
+};
+
+export function submitReportSuccess(report) {
+  return {
+    type: REPORT_SUBMIT_SUCCESS,
+    report,
+  };
+};
+
+export function submitReportFail(error) {
+  return {
+    type: REPORT_SUBMIT_FAIL,
+    error,
+  };
+};
+
+export function changeReportComment(comment) {
+  return {
+    type: REPORT_COMMENT_CHANGE,
+    comment,
+  };
+};
+
+export function changeReportForward(forward) {
+  return {
+    type: REPORT_FORWARD_CHANGE,
+    forward,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js
new file mode 100644
index 000000000..e86bd848e
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/search.js
@@ -0,0 +1,73 @@
+import api from 'flavours/glitch/util/api';
+
+export const SEARCH_CHANGE = 'SEARCH_CHANGE';
+export const SEARCH_CLEAR  = 'SEARCH_CLEAR';
+export const SEARCH_SHOW   = 'SEARCH_SHOW';
+
+export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
+export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
+export const SEARCH_FETCH_FAIL    = 'SEARCH_FETCH_FAIL';
+
+export function changeSearch(value) {
+  return {
+    type: SEARCH_CHANGE,
+    value,
+  };
+};
+
+export function clearSearch() {
+  return {
+    type: SEARCH_CLEAR,
+  };
+};
+
+export function submitSearch() {
+  return (dispatch, getState) => {
+    const value = getState().getIn(['search', 'value']);
+
+    if (value.length === 0) {
+      return;
+    }
+
+    dispatch(fetchSearchRequest());
+
+    api(getState).get('/api/v1/search', {
+      params: {
+        q: value,
+        resolve: true,
+      },
+    }).then(response => {
+      dispatch(fetchSearchSuccess(response.data));
+    }).catch(error => {
+      dispatch(fetchSearchFail(error));
+    });
+  };
+};
+
+export function fetchSearchRequest() {
+  return {
+    type: SEARCH_FETCH_REQUEST,
+  };
+};
+
+export function fetchSearchSuccess(results) {
+  return {
+    type: SEARCH_FETCH_SUCCESS,
+    results,
+    accounts: results.accounts,
+    statuses: results.statuses,
+  };
+};
+
+export function fetchSearchFail(error) {
+  return {
+    type: SEARCH_FETCH_FAIL,
+    error,
+  };
+};
+
+export function showSearch() {
+  return {
+    type: SEARCH_SHOW,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/settings.js b/app/javascript/flavours/glitch/actions/settings.js
new file mode 100644
index 000000000..87b2ae76d
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/settings.js
@@ -0,0 +1,31 @@
+import api from 'flavours/glitch/util/api';
+import { debounce } from 'lodash';
+
+export const SETTING_CHANGE = 'SETTING_CHANGE';
+export const SETTING_SAVE   = 'SETTING_SAVE';
+
+export function changeSetting(path, value) {
+  return dispatch => {
+    dispatch({
+      type: SETTING_CHANGE,
+      path,
+      value,
+    });
+
+    dispatch(saveSettings());
+  };
+};
+
+const debouncedSave = debounce((dispatch, getState) => {
+  if (getState().getIn(['settings', 'saved'])) {
+    return;
+  }
+
+  const data = getState().get('settings').filter((_, path) => path !== 'saved').toJS();
+
+  api(getState).put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
+}, 5000, { trailing: true });
+
+export function saveSettings() {
+  return (dispatch, getState) => debouncedSave(dispatch, getState);
+};
diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js
new file mode 100644
index 000000000..8b49083ac
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/statuses.js
@@ -0,0 +1,217 @@
+import api from 'flavours/glitch/util/api';
+
+import { deleteFromTimelines } from './timelines';
+import { fetchStatusCard } from './cards';
+
+export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
+export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
+export const STATUS_FETCH_FAIL    = 'STATUS_FETCH_FAIL';
+
+export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
+export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
+export const STATUS_DELETE_FAIL    = 'STATUS_DELETE_FAIL';
+
+export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
+export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
+export const CONTEXT_FETCH_FAIL    = 'CONTEXT_FETCH_FAIL';
+
+export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
+export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
+export const STATUS_MUTE_FAIL    = 'STATUS_MUTE_FAIL';
+
+export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
+export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
+export const STATUS_UNMUTE_FAIL    = 'STATUS_UNMUTE_FAIL';
+
+export function fetchStatusRequest(id, skipLoading) {
+  return {
+    type: STATUS_FETCH_REQUEST,
+    id,
+    skipLoading,
+  };
+};
+
+export function fetchStatus(id) {
+  return (dispatch, getState) => {
+    const skipLoading = getState().getIn(['statuses', id], null) !== null;
+
+    dispatch(fetchContext(id));
+    dispatch(fetchStatusCard(id));
+
+    if (skipLoading) {
+      return;
+    }
+
+    dispatch(fetchStatusRequest(id, skipLoading));
+
+    api(getState).get(`/api/v1/statuses/${id}`).then(response => {
+      dispatch(fetchStatusSuccess(response.data, skipLoading));
+    }).catch(error => {
+      dispatch(fetchStatusFail(id, error, skipLoading));
+    });
+  };
+};
+
+export function fetchStatusSuccess(status, skipLoading) {
+  return {
+    type: STATUS_FETCH_SUCCESS,
+    status,
+    skipLoading,
+  };
+};
+
+export function fetchStatusFail(id, error, skipLoading) {
+  return {
+    type: STATUS_FETCH_FAIL,
+    id,
+    error,
+    skipLoading,
+    skipAlert: true,
+  };
+};
+
+export function deleteStatus(id) {
+  return (dispatch, getState) => {
+    dispatch(deleteStatusRequest(id));
+
+    api(getState).delete(`/api/v1/statuses/${id}`).then(() => {
+      dispatch(deleteStatusSuccess(id));
+      dispatch(deleteFromTimelines(id));
+    }).catch(error => {
+      dispatch(deleteStatusFail(id, error));
+    });
+  };
+};
+
+export function deleteStatusRequest(id) {
+  return {
+    type: STATUS_DELETE_REQUEST,
+    id: id,
+  };
+};
+
+export function deleteStatusSuccess(id) {
+  return {
+    type: STATUS_DELETE_SUCCESS,
+    id: id,
+  };
+};
+
+export function deleteStatusFail(id, error) {
+  return {
+    type: STATUS_DELETE_FAIL,
+    id: id,
+    error: error,
+  };
+};
+
+export function fetchContext(id) {
+  return (dispatch, getState) => {
+    dispatch(fetchContextRequest(id));
+
+    api(getState).get(`/api/v1/statuses/${id}/context`).then(response => {
+      dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
+
+    }).catch(error => {
+      if (error.response && error.response.status === 404) {
+        dispatch(deleteFromTimelines(id));
+      }
+
+      dispatch(fetchContextFail(id, error));
+    });
+  };
+};
+
+export function fetchContextRequest(id) {
+  return {
+    type: CONTEXT_FETCH_REQUEST,
+    id,
+  };
+};
+
+export function fetchContextSuccess(id, ancestors, descendants) {
+  return {
+    type: CONTEXT_FETCH_SUCCESS,
+    id,
+    ancestors,
+    descendants,
+    statuses: ancestors.concat(descendants),
+  };
+};
+
+export function fetchContextFail(id, error) {
+  return {
+    type: CONTEXT_FETCH_FAIL,
+    id,
+    error,
+    skipAlert: true,
+  };
+};
+
+export function muteStatus(id) {
+  return (dispatch, getState) => {
+    dispatch(muteStatusRequest(id));
+
+    api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => {
+      dispatch(muteStatusSuccess(id));
+    }).catch(error => {
+      dispatch(muteStatusFail(id, error));
+    });
+  };
+};
+
+export function muteStatusRequest(id) {
+  return {
+    type: STATUS_MUTE_REQUEST,
+    id,
+  };
+};
+
+export function muteStatusSuccess(id) {
+  return {
+    type: STATUS_MUTE_SUCCESS,
+    id,
+  };
+};
+
+export function muteStatusFail(id, error) {
+  return {
+    type: STATUS_MUTE_FAIL,
+    id,
+    error,
+  };
+};
+
+export function unmuteStatus(id) {
+  return (dispatch, getState) => {
+    dispatch(unmuteStatusRequest(id));
+
+    api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => {
+      dispatch(unmuteStatusSuccess(id));
+    }).catch(error => {
+      dispatch(unmuteStatusFail(id, error));
+    });
+  };
+};
+
+export function unmuteStatusRequest(id) {
+  return {
+    type: STATUS_UNMUTE_REQUEST,
+    id,
+  };
+};
+
+export function unmuteStatusSuccess(id) {
+  return {
+    type: STATUS_UNMUTE_SUCCESS,
+    id,
+  };
+};
+
+export function unmuteStatusFail(id, error) {
+  return {
+    type: STATUS_UNMUTE_FAIL,
+    id,
+    error,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/store.js b/app/javascript/flavours/glitch/actions/store.js
new file mode 100644
index 000000000..a1db0fdd5
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/store.js
@@ -0,0 +1,17 @@
+import { Iterable, fromJS } from 'immutable';
+
+export const STORE_HYDRATE = 'STORE_HYDRATE';
+export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
+
+const convertState = rawState =>
+  fromJS(rawState, (k, v) =>
+    Iterable.isIndexed(v) ? v.toList() : v.toMap());
+
+export function hydrateStore(rawState) {
+  const state = convertState(rawState);
+
+  return {
+    type: STORE_HYDRATE,
+    state,
+  };
+};
diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js
new file mode 100644
index 000000000..ae51e8349
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -0,0 +1,55 @@
+import { connectStream } from 'flavours/glitch/util/stream';
+import {
+  updateTimeline,
+  deleteFromTimelines,
+  refreshHomeTimeline,
+  connectTimeline,
+  disconnectTimeline,
+} from './timelines';
+import { updateNotifications, refreshNotifications } from './notifications';
+import { getLocale } from 'mastodon/locales';
+
+const { messages } = getLocale();
+
+export function connectTimelineStream (timelineId, path, pollingRefresh = null) {
+
+  return connectStream (path, pollingRefresh, (dispatch, getState) => {
+    const locale = getState().getIn(['meta', 'locale']);
+    return {
+      onConnect() {
+        dispatch(connectTimeline(timelineId));
+      },
+
+      onDisconnect() {
+        dispatch(disconnectTimeline(timelineId));
+      },
+
+      onReceive (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline(timelineId, JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
+        case 'notification':
+          dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
+          break;
+        }
+      },
+    };
+  });
+}
+
+function refreshHomeTimelineAndNotification (dispatch) {
+  dispatch(refreshHomeTimeline());
+  dispatch(refreshNotifications());
+}
+
+export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
+export const connectCommunityStream = () => connectTimelineStream('community', 'public:local');
+export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
+export const connectPublicStream = () => connectTimelineStream('public', 'public');
+export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
+export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
+export const connectListStream = (id) => connectTimelineStream(`list:${id}`, `list&list=${id}`);
diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js
new file mode 100644
index 000000000..d99c6d98b
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -0,0 +1,216 @@
+import api, { getLinks } from 'flavours/glitch/util/api';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+
+export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE';
+export const TIMELINE_DELETE  = 'TIMELINE_DELETE';
+
+export const TIMELINE_REFRESH_REQUEST = 'TIMELINE_REFRESH_REQUEST';
+export const TIMELINE_REFRESH_SUCCESS = 'TIMELINE_REFRESH_SUCCESS';
+export const TIMELINE_REFRESH_FAIL    = 'TIMELINE_REFRESH_FAIL';
+
+export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
+export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS';
+export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL';
+
+export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
+
+export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT';
+export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
+
+export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
+
+export function refreshTimelineSuccess(timeline, statuses, skipLoading, next, partial) {
+  return {
+    type: TIMELINE_REFRESH_SUCCESS,
+    timeline,
+    statuses,
+    skipLoading,
+    next,
+    partial,
+  };
+};
+
+export function updateTimeline(timeline, status) {
+  return (dispatch, getState) => {
+    const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
+    const parents = [];
+
+    if (status.in_reply_to_id) {
+      let parent = getState().getIn(['statuses', status.in_reply_to_id]);
+
+      while (parent && parent.get('in_reply_to_id')) {
+        parents.push(parent.get('id'));
+        parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]);
+      }
+    }
+
+    dispatch({
+      type: TIMELINE_UPDATE,
+      timeline,
+      status,
+      references,
+    });
+
+    if (parents.length > 0) {
+      dispatch({
+        type: TIMELINE_CONTEXT_UPDATE,
+        status,
+        references: parents,
+      });
+    }
+  };
+};
+
+export function deleteFromTimelines(id) {
+  return (dispatch, getState) => {
+    const accountId  = getState().getIn(['statuses', id, 'account']);
+    const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]);
+    const reblogOf   = getState().getIn(['statuses', id, 'reblog'], null);
+
+    dispatch({
+      type: TIMELINE_DELETE,
+      id,
+      accountId,
+      references,
+      reblogOf,
+    });
+  };
+};
+
+export function refreshTimelineRequest(timeline, skipLoading) {
+  return {
+    type: TIMELINE_REFRESH_REQUEST,
+    timeline,
+    skipLoading,
+  };
+};
+
+export function refreshTimeline(timelineId, path, params = {}) {
+  return function (dispatch, getState) {
+    const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
+
+    if (timeline.get('isLoading') || (timeline.get('online') && !timeline.get('isPartial'))) {
+      return;
+    }
+
+    const ids      = timeline.get('items', ImmutableList());
+    const newestId = ids.size > 0 ? ids.first() : null;
+
+    let skipLoading = timeline.get('loaded');
+
+    if (newestId !== null) {
+      params.since_id = newestId;
+    }
+
+    dispatch(refreshTimelineRequest(timelineId, skipLoading));
+
+    api(getState).get(path, { params }).then(response => {
+      if (response.status === 206) {
+        dispatch(refreshTimelineSuccess(timelineId, [], skipLoading, null, true));
+      } else {
+        const next = getLinks(response).refs.find(link => link.rel === 'next');
+        dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null, false));
+      }
+    }).catch(error => {
+      dispatch(refreshTimelineFail(timelineId, error, skipLoading));
+    });
+  };
+};
+
+export const refreshHomeTimeline            = () => refreshTimeline('home', '/api/v1/timelines/home');
+export const refreshPublicTimeline          = () => refreshTimeline('public', '/api/v1/timelines/public');
+export const refreshCommunityTimeline       = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true });
+export const refreshDirectTimeline          = () => refreshTimeline('direct', '/api/v1/timelines/direct');
+export const refreshAccountTimeline         = (accountId, withReplies) => refreshTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies });
+export const refreshAccountFeaturedTimeline = accountId => refreshTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
+export const refreshAccountMediaTimeline    = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
+export const refreshHashtagTimeline         = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
+export const refreshListTimeline            = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`);
+
+export function refreshTimelineFail(timeline, error, skipLoading) {
+  return {
+    type: TIMELINE_REFRESH_FAIL,
+    timeline,
+    error,
+    skipLoading,
+    skipAlert: error.response && error.response.status === 404,
+  };
+};
+
+export function expandTimeline(timelineId, path, params = {}) {
+  return (dispatch, getState) => {
+    const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
+    const ids      = timeline.get('items', ImmutableList());
+
+    if (timeline.get('isLoading') || ids.size === 0) {
+      return;
+    }
+
+    params.max_id = ids.last();
+    params.limit  = 10;
+
+    dispatch(expandTimelineRequest(timelineId));
+
+    api(getState).get(path, { params }).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandTimelineFail(timelineId, error));
+    });
+  };
+};
+
+export const expandHomeTimeline         = () => expandTimeline('home', '/api/v1/timelines/home');
+export const expandPublicTimeline       = () => expandTimeline('public', '/api/v1/timelines/public');
+export const expandCommunityTimeline    = () => expandTimeline('community', '/api/v1/timelines/public', { local: true });
+export const expandDirectTimeline       = () => expandTimeline('direct', '/api/v1/timelines/direct');
+export const expandAccountTimeline      = (accountId, withReplies) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies })
+export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
+export const expandHashtagTimeline      = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
+export const expandListTimeline         = id => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`);
+
+export function expandTimelineRequest(timeline) {
+  return {
+    type: TIMELINE_EXPAND_REQUEST,
+    timeline,
+  };
+};
+
+export function expandTimelineSuccess(timeline, statuses, next) {
+  return {
+    type: TIMELINE_EXPAND_SUCCESS,
+    timeline,
+    statuses,
+    next,
+  };
+};
+
+export function expandTimelineFail(timeline, error) {
+  return {
+    type: TIMELINE_EXPAND_FAIL,
+    timeline,
+    error,
+  };
+};
+
+export function scrollTopTimeline(timeline, top) {
+  return {
+    type: TIMELINE_SCROLL_TOP,
+    timeline,
+    top,
+  };
+};
+
+export function connectTimeline(timeline) {
+  return {
+    type: TIMELINE_CONNECT,
+    timeline,
+  };
+};
+
+export function disconnectTimeline(timeline) {
+  return {
+    type: TIMELINE_DISCONNECT,
+    timeline,
+  };
+};