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/alerts.js12
-rw-r--r--app/javascript/flavours/glitch/actions/blocks.js14
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js58
-rw-r--r--app/javascript/flavours/glitch/actions/conversations.js28
-rw-r--r--app/javascript/flavours/glitch/actions/directory.js61
-rw-r--r--app/javascript/flavours/glitch/actions/domain_blocks.js1
-rw-r--r--app/javascript/flavours/glitch/actions/importer/normalizer.js13
-rw-r--r--app/javascript/flavours/glitch/actions/markers.js30
-rw-r--r--app/javascript/flavours/glitch/actions/modal.js3
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js49
-rw-r--r--app/javascript/flavours/glitch/actions/polls.js2
-rw-r--r--app/javascript/flavours/glitch/actions/search.js61
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js57
-rw-r--r--app/javascript/flavours/glitch/actions/trends.js32
14 files changed, 367 insertions, 54 deletions
diff --git a/app/javascript/flavours/glitch/actions/alerts.js b/app/javascript/flavours/glitch/actions/alerts.js
index ef2500e7b..cd36d8007 100644
--- a/app/javascript/flavours/glitch/actions/alerts.js
+++ b/app/javascript/flavours/glitch/actions/alerts.js
@@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl';
 const messages = defineMessages({
   unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
   unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
+  rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' },
+  rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' },
 });
 
 export const ALERT_SHOW    = 'ALERT_SHOW';
@@ -23,23 +25,29 @@ export function clearAlert() {
   };
 };
 
-export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) {
+export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
   return {
     type: ALERT_SHOW,
     title,
     message,
+    message_values,
   };
 };
 
 export function showAlertForError(error) {
   if (error.response) {
-    const { data, status, statusText } = error.response;
+    const { data, status, statusText, headers } = error.response;
 
     if (status === 404 || status === 410) {
       // Skip these errors as they are reflected in the UI
       return { type: ALERT_NOOP };
     }
 
+    if (status === 429 && headers['x-ratelimit-reset']) {
+      const reset_date = new Date(headers['x-ratelimit-reset']);
+      return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date });
+    }
+
     let message = statusText;
     let title   = `${status}`;
 
diff --git a/app/javascript/flavours/glitch/actions/blocks.js b/app/javascript/flavours/glitch/actions/blocks.js
index 498ce519f..adae9d83c 100644
--- a/app/javascript/flavours/glitch/actions/blocks.js
+++ b/app/javascript/flavours/glitch/actions/blocks.js
@@ -1,6 +1,7 @@
 import api, { getLinks } from 'flavours/glitch/util/api';
 import { fetchRelationships } from './accounts';
 import { importFetchedAccounts } from './importer';
+import { openModal } from './modal';
 
 export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST';
 export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS';
@@ -10,6 +11,8 @@ 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 const BLOCKS_INIT_MODAL = 'BLOCKS_INIT_MODAL';
+
 export function fetchBlocks() {
   return (dispatch, getState) => {
     dispatch(fetchBlocksRequest());
@@ -83,3 +86,14 @@ export function expandBlocksFail(error) {
     error,
   };
 };
+
+export function initBlockModal(account) {
+  return dispatch => {
+    dispatch({
+      type: BLOCKS_INIT_MODAL,
+      account,
+    });
+
+    dispatch(openModal('BLOCK'));
+  };
+}
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 69cc6827f..f80642bd8 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -12,7 +12,7 @@ import { showAlertForError } from './alerts';
 import { showAlert } from './alerts';
 import { defineMessages } from 'react-intl';
 
-let cancelFetchComposeSuggestionsAccounts;
+let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags;
 
 export const COMPOSE_CHANGE          = 'COMPOSE_CHANGE';
 export const COMPOSE_CYCLE_ELEFRIEND = 'COMPOSE_CYCLE_ELEFRIEND';
@@ -138,7 +138,8 @@ export function submitCompose(routerHistory) {
   return function (dispatch, getState) {
     let status = getState().getIn(['compose', 'text'], '');
     let media  = getState().getIn(['compose', 'media_attachments']);
-    let spoilerText = getState().getIn(['compose', 'spoiler_text'], '');
+    const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']);
+    let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
 
     if ((!status || !status.length) && media.size === 0) {
       return;
@@ -231,10 +232,11 @@ export function uploadCompose(files) {
   return function (dispatch, getState) {
     const uploadLimit = 4;
     const media  = getState().getIn(['compose', 'media_attachments']);
+    const pending  = getState().getIn(['compose', 'pending_media_attachments']);
     const progress = new Array(files.length).fill(0);
     let total = Array.from(files).reduce((a, v) => a + v.size, 0);
 
-    if (files.length + media.size > uploadLimit) {
+    if (files.length + media.size + pending > uploadLimit) {
       dispatch(showAlert(undefined, messages.uploadErrorLimit));
       return;
     }
@@ -260,7 +262,7 @@ export function uploadCompose(files) {
             progress[i] = loaded;
             dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
           },
-        }).then(({ data }) => dispatch(uploadComposeSuccess(data)));
+        }).then(({ data }) => dispatch(uploadComposeSuccess(data, f)));
       }).catch(error => dispatch(uploadComposeFail(error)));
     };
   };
@@ -315,10 +317,11 @@ export function uploadComposeProgress(loaded, total) {
   };
 };
 
-export function uploadComposeSuccess(media) {
+export function uploadComposeSuccess(media, file) {
   return {
     type: COMPOSE_UPLOAD_SUCCESS,
     media: media,
+    file: file,
     skipLoading: true,
   };
 };
@@ -351,10 +354,12 @@ 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,
@@ -375,9 +380,32 @@ const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
   dispatch(readyComposeSuggestionsEmojis(token, results));
 };
 
-const fetchComposeSuggestionsTags = (dispatch, getState, token) => {
+const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
+  if (cancelFetchComposeSuggestionsTags) {
+    cancelFetchComposeSuggestionsTags();
+  }
+
   dispatch(updateSuggestionTags(token));
-};
+
+  api(getState).get('/api/v2/search', {
+    cancelToken: new CancelToken(cancel => {
+      cancelFetchComposeSuggestionsTags = cancel;
+    }),
+
+    params: {
+      type: 'hashtags',
+      q: token.slice(1),
+      resolve: false,
+      limit: 4,
+    },
+  }).then(({ data }) => {
+    dispatch(readyComposeSuggestionsTags(token, data.hashtags));
+  }).catch(error => {
+    if (!isCancel(error)) {
+      dispatch(showAlertForError(error));
+    }
+  });
+}, 200, { leading: true, trailing: true });
 
 export function fetchComposeSuggestions(token) {
   return (dispatch, getState) => {
@@ -411,16 +439,22 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
   };
 };
 
+export const readyComposeSuggestionsTags = (token, tags) => ({
+  type: COMPOSE_SUGGESTIONS_READY,
+  token,
+  tags,
+});
+
 export function selectComposeSuggestion(position, token, suggestion, path) {
   return (dispatch, getState) => {
     let completion;
-    if (typeof suggestion === 'object' && suggestion.id) {
+    if (suggestion.type === 'emoji') {
       dispatch(useEmoji(suggestion));
       completion = suggestion.native || suggestion.colons;
-    } else if (suggestion[0] === '#') {
-      completion = suggestion;
-    } else {
-      completion = '@' + getState().getIn(['accounts', suggestion, 'acct']);
+    } else if (suggestion.type === 'hashtag') {
+      completion = `#${suggestion.name}`;
+    } else if (suggestion.type === 'account') {
+      completion = '@' + getState().getIn(['accounts', suggestion.id, 'acct']);
     }
 
     dispatch({
diff --git a/app/javascript/flavours/glitch/actions/conversations.js b/app/javascript/flavours/glitch/actions/conversations.js
index 856f8f10f..e5c85c65d 100644
--- a/app/javascript/flavours/glitch/actions/conversations.js
+++ b/app/javascript/flavours/glitch/actions/conversations.js
@@ -15,6 +15,10 @@ export const CONVERSATIONS_UPDATE        = 'CONVERSATIONS_UPDATE';
 
 export const CONVERSATIONS_READ = 'CONVERSATIONS_READ';
 
+export const CONVERSATIONS_DELETE_REQUEST = 'CONVERSATIONS_DELETE_REQUEST';
+export const CONVERSATIONS_DELETE_SUCCESS = 'CONVERSATIONS_DELETE_SUCCESS';
+export const CONVERSATIONS_DELETE_FAIL    = 'CONVERSATIONS_DELETE_FAIL';
+
 export const mountConversations = () => ({
   type: CONVERSATIONS_MOUNT,
 });
@@ -82,3 +86,27 @@ export const updateConversations = conversation => dispatch => {
     conversation,
   });
 };
+
+export const deleteConversation = conversationId => (dispatch, getState) => {
+  dispatch(deleteConversationRequest(conversationId));
+
+  api(getState).delete(`/api/v1/conversations/${conversationId}`)
+    .then(() => dispatch(deleteConversationSuccess(conversationId)))
+    .catch(error => dispatch(deleteConversationFail(conversationId, error)));
+};
+
+export const deleteConversationRequest = id => ({
+  type: CONVERSATIONS_DELETE_REQUEST,
+  id,
+});
+
+export const deleteConversationSuccess = id => ({
+  type: CONVERSATIONS_DELETE_SUCCESS,
+  id,
+});
+
+export const deleteConversationFail = (id, error) => ({
+  type: CONVERSATIONS_DELETE_FAIL,
+  id,
+  error,
+});
diff --git a/app/javascript/flavours/glitch/actions/directory.js b/app/javascript/flavours/glitch/actions/directory.js
new file mode 100644
index 000000000..9fbfb7f5b
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/directory.js
@@ -0,0 +1,61 @@
+import api from 'flavours/glitch/util/api';
+import { importFetchedAccounts } from './importer';
+import { fetchRelationships } from './accounts';
+
+export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
+export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';
+export const DIRECTORY_FETCH_FAIL    = 'DIRECTORY_FETCH_FAIL';
+
+export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST';
+export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS';
+export const DIRECTORY_EXPAND_FAIL    = 'DIRECTORY_EXPAND_FAIL';
+
+export const fetchDirectory = params => (dispatch, getState) => {
+  dispatch(fetchDirectoryRequest());
+
+  api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => {
+    dispatch(importFetchedAccounts(data));
+    dispatch(fetchDirectorySuccess(data));
+    dispatch(fetchRelationships(data.map(x => x.id)));
+  }).catch(error => dispatch(fetchDirectoryFail(error)));
+};
+
+export const fetchDirectoryRequest = () => ({
+  type: DIRECTORY_FETCH_REQUEST,
+});
+
+export const fetchDirectorySuccess = accounts => ({
+  type: DIRECTORY_FETCH_SUCCESS,
+  accounts,
+});
+
+export const fetchDirectoryFail = error => ({
+  type: DIRECTORY_FETCH_FAIL,
+  error,
+});
+
+export const expandDirectory = params => (dispatch, getState) => {
+  dispatch(expandDirectoryRequest());
+
+  const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size;
+
+  api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => {
+    dispatch(importFetchedAccounts(data));
+    dispatch(expandDirectorySuccess(data));
+    dispatch(fetchRelationships(data.map(x => x.id)));
+  }).catch(error => dispatch(expandDirectoryFail(error)));
+};
+
+export const expandDirectoryRequest = () => ({
+  type: DIRECTORY_EXPAND_REQUEST,
+});
+
+export const expandDirectorySuccess = accounts => ({
+  type: DIRECTORY_EXPAND_SUCCESS,
+  accounts,
+});
+
+export const expandDirectoryFail = error => ({
+  type: DIRECTORY_EXPAND_FAIL,
+  error,
+});
diff --git a/app/javascript/flavours/glitch/actions/domain_blocks.js b/app/javascript/flavours/glitch/actions/domain_blocks.js
index 7397f561b..6d3f471fa 100644
--- a/app/javascript/flavours/glitch/actions/domain_blocks.js
+++ b/app/javascript/flavours/glitch/actions/domain_blocks.js
@@ -23,6 +23,7 @@ export function blockDomain(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));
diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js
index a8c3fe16a..2bc603930 100644
--- a/app/javascript/flavours/glitch/actions/importer/normalizer.js
+++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js
@@ -10,6 +10,12 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
   return obj;
 }, {});
 
+export function searchTextFromRawStatus (status) {
+  const spoilerText   = status.spoiler_text || '';
+  const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+  return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+}
+
 export function normalizeAccount(account) {
   account = { ...account };
 
@@ -22,7 +28,7 @@ export function normalizeAccount(account) {
   if (account.fields) {
     account.fields = account.fields.map(pair => ({
       ...pair,
-      name_emojified: emojify(escapeTextContentForBrowser(pair.name)),
+      name_emojified: emojify(escapeTextContentForBrowser(pair.name), emojiMap),
       value_emojified: emojify(pair.value, emojiMap),
       value_plain: unescapeHTML(pair.value),
     }));
@@ -55,7 +61,7 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
   } else {
     const spoilerText   = normalStatus.spoiler_text || '';
-    const searchContent = [spoilerText, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+    const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
     const emojiMap      = makeEmojiMap(normalStatus);
 
     normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
@@ -71,8 +77,9 @@ export function normalizePoll(poll) {
 
   const emojiMap = makeEmojiMap(normalPoll);
 
-  normalPoll.options = poll.options.map(option => ({
+  normalPoll.options = poll.options.map((option, index) => ({
     ...option,
+    voted: poll.own_votes && poll.own_votes.includes(index),
     title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap),
   }));
 
diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js
new file mode 100644
index 000000000..c3a5fe86f
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/markers.js
@@ -0,0 +1,30 @@
+export const submitMarkers = () => (dispatch, getState) => {
+  const accessToken = getState().getIn(['meta', 'access_token'], '');
+  const params      = {};
+
+  const lastHomeId         = getState().getIn(['timelines', 'home', 'items', 0]);
+  const lastNotificationId = getState().getIn(['notifications', 'items', 0, 'id']);
+
+  if (lastHomeId) {
+    params.home = {
+      last_read_id: lastHomeId,
+    };
+  }
+
+  if (lastNotificationId) {
+    params.notifications = {
+      last_read_id: lastNotificationId,
+    };
+  }
+
+  if (Object.keys(params).length === 0) {
+    return;
+  }
+
+  const client = new XMLHttpRequest();
+
+  client.open('POST', '/api/v1/markers', false);
+  client.setRequestHeader('Content-Type', 'application/json');
+  client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
+  client.send(JSON.stringify(params));
+};
diff --git a/app/javascript/flavours/glitch/actions/modal.js b/app/javascript/flavours/glitch/actions/modal.js
index 80e15c28e..3d0299db5 100644
--- a/app/javascript/flavours/glitch/actions/modal.js
+++ b/app/javascript/flavours/glitch/actions/modal.js
@@ -9,8 +9,9 @@ export function openModal(type, props) {
   };
 };
 
-export function closeModal() {
+export function closeModal(type) {
   return {
     type: MODAL_CLOSE,
+    modalType: type,
   };
 };
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index 57fecf63d..940f3c3d4 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -11,7 +11,10 @@ import { saveSettings } from './settings';
 import { defineMessages } from 'react-intl';
 import { List as ImmutableList } from 'immutable';
 import { unescapeHTML } from 'flavours/glitch/util/html';
-import { getFilters, regexFromFilters } from 'flavours/glitch/selectors';
+import { getFiltersRegex } from 'flavours/glitch/selectors';
+import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';
+import compareId from 'flavours/glitch/util/compare_id';
+import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer';
 
 export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
 
@@ -32,8 +35,9 @@ export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL';
 
 export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
 
-export const NOTIFICATIONS_CLEAR      = 'NOTIFICATIONS_CLEAR';
-export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
+export const NOTIFICATIONS_CLEAR        = 'NOTIFICATIONS_CLEAR';
+export const NOTIFICATIONS_SCROLL_TOP   = 'NOTIFICATIONS_SCROLL_TOP';
+export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
 
 export const NOTIFICATIONS_MOUNT   = 'NOTIFICATIONS_MOUNT';
 export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
@@ -52,18 +56,27 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
   }
 };
 
+export const loadPending = () => ({
+  type: NOTIFICATIONS_LOAD_PENDING,
+});
+
 export function updateNotifications(notification, intlMessages, intlLocale) {
   return (dispatch, getState) => {
     const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
     const showAlert    = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
     const playSound    = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
-    const filters      = getFilters(getState(), { contextType: 'notifications' });
+    const filters      = getFiltersRegex(getState(), { contextType: 'notifications' });
 
     let filtered = false;
 
     if (notification.type === 'mention') {
-      const regex       = regexFromFilters(filters);
-      const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
+      const dropRegex   = filters[0];
+      const regex       = filters[1];
+      const searchIndex = searchTextFromRawStatus(notification.status);
+
+      if (dropRegex && dropRegex.test(searchIndex)) {
+        return;
+      }
 
       filtered = regex && regex.test(searchIndex);
     }
@@ -78,6 +91,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
       dispatch({
         type: NOTIFICATIONS_UPDATE,
         notification,
+        usePendingItems: preferPendingItems,
         meta: (playSound && !filtered) ? { sound: 'boop' } : undefined,
       });
 
@@ -107,7 +121,7 @@ const excludeTypesFromSettings = state => state.getIn(['settings', 'notification
 
 
 const excludeTypesFromFilter = filter => {
-  const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']);
+  const allTypes = ImmutableList(['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll']);
   return allTypes.filterNot(item => item === filter).toJS();
 };
 
@@ -131,10 +145,19 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
         : excludeTypesFromFilter(activeFilter),
     };
 
-    if (!maxId && notifications.get('items').size > 0) {
-      params.since_id = notifications.getIn(['items', 0, 'id']);
+    if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) {
+      const a = notifications.getIn(['pendingItems', 0, 'id']);
+      const b = notifications.getIn(['items', 0, 'id']);
+
+      if (a && b && compareId(a, b) > 0) {
+        params.since_id = a;
+      } else {
+        params.since_id = b || a;
+      }
     }
 
+    const isLoadingRecent = !!params.since_id;
+
     dispatch(expandNotificationsRequest(isLoadingMore));
 
     api(getState).get('/api/v1/notifications', { params }).then(response => {
@@ -143,7 +166,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
       dispatch(importFetchedAccounts(response.data.map(item => item.account)));
       dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
 
-      dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore));
+      dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
       fetchRelatedRelationships(dispatch, response.data);
       done();
     }).catch(error => {
@@ -160,13 +183,13 @@ export function expandNotificationsRequest(isLoadingMore) {
   };
 };
 
-export function expandNotificationsSuccess(notifications, next, isLoadingMore) {
+export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) {
   return {
     type: NOTIFICATIONS_EXPAND_SUCCESS,
     notifications,
-    accounts: notifications.map(item => item.account),
-    statuses: notifications.map(item => item.status).filter(status => !!status),
     next,
+    isLoadingRecent: isLoadingRecent,
+    usePendingItems,
     skipLoading: !isLoadingMore,
   };
 };
diff --git a/app/javascript/flavours/glitch/actions/polls.js b/app/javascript/flavours/glitch/actions/polls.js
index 8e8b82df5..ca94a095f 100644
--- a/app/javascript/flavours/glitch/actions/polls.js
+++ b/app/javascript/flavours/glitch/actions/polls.js
@@ -1,4 +1,4 @@
-import api from '../api';
+import api from 'flavours/glitch/util/api';
 import { importFetchedPoll } from './importer';
 
 export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST';
diff --git a/app/javascript/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js
index b2d24e10b..a025f352a 100644
--- a/app/javascript/flavours/glitch/actions/search.js
+++ b/app/javascript/flavours/glitch/actions/search.js
@@ -10,6 +10,10 @@ 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 const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
+export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
+export const SEARCH_EXPAND_FAIL    = 'SEARCH_EXPAND_FAIL';
+
 export function changeSearch(value) {
   return {
     type: SEARCH_CHANGE,
@@ -48,7 +52,7 @@ export function submitSearch() {
         dispatch(importFetchedStatuses(response.data.statuses));
       }
 
-      dispatch(fetchSearchSuccess(response.data));
+      dispatch(fetchSearchSuccess(response.data, value));
       dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
     }).catch(error => {
       dispatch(fetchSearchFail(error));
@@ -62,12 +66,11 @@ export function fetchSearchRequest() {
   };
 };
 
-export function fetchSearchSuccess(results) {
+export function fetchSearchSuccess(results, searchTerm) {
   return {
     type: SEARCH_FETCH_SUCCESS,
     results,
-    accounts: results.accounts,
-    statuses: results.statuses,
+    searchTerm,
   };
 };
 
@@ -78,8 +81,50 @@ export function fetchSearchFail(error) {
   };
 };
 
-export function showSearch() {
-  return {
-    type: SEARCH_SHOW,
-  };
+export const expandSearch = type => (dispatch, getState) => {
+  const value  = getState().getIn(['search', 'value']);
+  const offset = getState().getIn(['search', 'results', type]).size;
+
+  dispatch(expandSearchRequest());
+
+  api(getState).get('/api/v2/search', {
+    params: {
+      q: value,
+      type,
+      offset,
+    },
+  }).then(({ data }) => {
+    if (data.accounts) {
+      dispatch(importFetchedAccounts(data.accounts));
+    }
+
+    if (data.statuses) {
+      dispatch(importFetchedStatuses(data.statuses));
+    }
+
+    dispatch(expandSearchSuccess(data, value, type));
+    dispatch(fetchRelationships(data.accounts.map(item => item.id)));
+  }).catch(error => {
+    dispatch(expandSearchFail(error));
+  });
 };
+
+export const expandSearchRequest = () => ({
+  type: SEARCH_EXPAND_REQUEST,
+});
+
+export const expandSearchSuccess = (results, searchTerm, searchType) => ({
+  type: SEARCH_EXPAND_SUCCESS,
+  results,
+  searchTerm,
+  searchType,
+});
+
+export const expandSearchFail = error => ({
+  type: SEARCH_EXPAND_FAIL,
+  error,
+});
+
+export const showSearch = () => ({
+  type: SEARCH_SHOW,
+});
diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js
index cca571583..097878c3b 100644
--- a/app/javascript/flavours/glitch/actions/timelines.js
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -1,6 +1,10 @@
 import { importFetchedStatus, importFetchedStatuses } from './importer';
 import api, { getLinks } from 'flavours/glitch/util/api';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import compareId from 'flavours/glitch/util/compare_id';
+import { me, usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';
+import { getFiltersRegex } from 'flavours/glitch/selectors';
+import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer';
 
 export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE';
 export const TIMELINE_DELETE  = 'TIMELINE_DELETE';
@@ -10,23 +14,40 @@ 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_SCROLL_TOP   = 'TIMELINE_SCROLL_TOP';
+export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING';
+export const TIMELINE_DISCONNECT   = 'TIMELINE_DISCONNECT';
+export const TIMELINE_CONNECT      = 'TIMELINE_CONNECT';
 
-export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT';
-export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
+export const loadPending = timeline => ({
+  type: TIMELINE_LOAD_PENDING,
+  timeline,
+});
 
 export function updateTimeline(timeline, status, accept) {
-  return dispatch => {
+  return (dispatch, getState) => {
     if (typeof accept === 'function' && !accept(status)) {
       return;
     }
 
+    const filters   = getFiltersRegex(getState(), { contextType: timeline });
+    const dropRegex = filters[0];
+    const regex     = filters[1];
+    const text      = searchTextFromRawStatus(status);
+    let filtered    = false;
+
+    if (status.account.id !== me) {
+      filtered = (dropRegex && dropRegex.test(text)) || (regex && regex.test(text));
+    }
+
     dispatch(importFetchedStatus(status));
 
     dispatch({
       type: TIMELINE_UPDATE,
       timeline,
       status,
+      usePendingItems: preferPendingItems,
+      filtered
     });
   };
 };
@@ -71,8 +92,15 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
       return;
     }
 
-    if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) {
-      params.since_id = timeline.getIn(['items', 0]);
+    if (!params.max_id && !params.pinned && (timeline.get('items', ImmutableList()).size + timeline.get('pendingItems', ImmutableList()).size) > 0) {
+      const a = timeline.getIn(['pendingItems', 0]);
+      const b = timeline.getIn(['items', 0]);
+
+      if (a && b && compareId(a, b) > 0) {
+        params.since_id = a;
+      } else {
+        params.since_id = b || a;
+      }
     }
 
     const isLoadingRecent = !!params.since_id;
@@ -81,8 +109,9 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
 
     api(getState).get(path, { params }).then(response => {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
+
       dispatch(importFetchedStatuses(response.data));
-      dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore));
+      dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
       done();
     }).catch(error => {
       dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
@@ -117,7 +146,7 @@ export function expandTimelineRequest(timeline, isLoadingMore) {
   };
 };
 
-export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore) {
+export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore, usePendingItems) {
   return {
     type: TIMELINE_EXPAND_SUCCESS,
     timeline,
@@ -125,6 +154,7 @@ export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadi
     next,
     partial,
     isLoadingRecent,
+    usePendingItems,
     skipLoading: !isLoadingMore,
   };
 };
@@ -153,9 +183,8 @@ export function connectTimeline(timeline) {
   };
 };
 
-export function disconnectTimeline(timeline) {
-  return {
-    type: TIMELINE_DISCONNECT,
-    timeline,
-  };
-};
+export const disconnectTimeline = timeline => ({
+  type: TIMELINE_DISCONNECT,
+  timeline,
+  usePendingItems: preferPendingItems,
+});
diff --git a/app/javascript/flavours/glitch/actions/trends.js b/app/javascript/flavours/glitch/actions/trends.js
new file mode 100644
index 000000000..1b0ce2b5b
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/trends.js
@@ -0,0 +1,32 @@
+import api from 'flavours/glitch/util/api';
+
+export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST';
+export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS';
+export const TRENDS_FETCH_FAIL    = 'TRENDS_FETCH_FAIL';
+
+export const fetchTrends = () => (dispatch, getState) => {
+  dispatch(fetchTrendsRequest());
+
+  api(getState)
+    .get('/api/v1/trends')
+    .then(({ data }) => dispatch(fetchTrendsSuccess(data)))
+    .catch(err => dispatch(fetchTrendsFail(err)));
+};
+
+export const fetchTrendsRequest = () => ({
+  type: TRENDS_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchTrendsSuccess = trends => ({
+  type: TRENDS_FETCH_SUCCESS,
+  trends,
+  skipLoading: true,
+});
+
+export const fetchTrendsFail = error => ({
+  type: TRENDS_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});