about summary refs log tree commit diff
diff options
context:
space:
mode:
authorThibaut Girka <thib@sitedethib.com>2018-05-27 19:10:37 +0200
committerThibG <thib@sitedethib.com>2018-05-29 21:25:28 +0200
commit0ad3eedd4cf6d1d7f7ceacc2c4412dbd8ee79cef (patch)
tree9ce0301ec860e4ca5c3cf3ef2a03bcc471aa7e94
parent532fb8e2150c70c627d57f9f72f8232606976a4a (diff)
[Glitch] Allow clients to fetch statuses made while they were offline
Port 9a1a55ce526c956ac6b35897d483c316b7ad4394 to glitch-soc
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js22
-rw-r--r--app/javascript/flavours/glitch/actions/streaming.js9
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js113
-rw-r--r--app/javascript/flavours/glitch/components/load_more.js5
-rw-r--r--app/javascript/flavours/glitch/components/scrollable_list.js6
-rw-r--r--app/javascript/flavours/glitch/components/status_list.js37
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/index.js57
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/index.js18
-rw-r--r--app/javascript/flavours/glitch/features/community_timeline/index.js13
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/index.js15
-rw-r--r--app/javascript/flavours/glitch/features/home_timeline/index.js12
-rw-r--r--app/javascript/flavours/glitch/features/list_timeline/index.js10
-rw-r--r--app/javascript/flavours/glitch/features/public_timeline/index.js13
-rw-r--r--app/javascript/flavours/glitch/features/standalone/community_timeline/index.js13
-rw-r--r--app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js13
-rw-r--r--app/javascript/flavours/glitch/features/standalone/public_timeline/index.js13
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/report_modal.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/status_list_container.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/timelines.js65
-rw-r--r--app/javascript/flavours/glitch/util/stream.js6
21 files changed, 195 insertions, 261 deletions
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 199a79e7f..b7f706a83 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -5,13 +5,7 @@ import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_sea
 import { useEmoji } from './emojis';
 import resizeImage from 'flavours/glitch/util/resize_image';
 
-import {
-  updateTimeline,
-  refreshHomeTimeline,
-  refreshCommunityTimeline,
-  refreshPublicTimeline,
-  refreshDirectTimeline,
-} from './timelines';
+import { updateTimeline } from './timelines';
 
 let cancelFetchComposeSuggestionsAccounts;
 
@@ -151,21 +145,19 @@ export function submitCompose() {
 
       // To make the app more responsive, immediately get the status into the columns
 
-      const insertOrRefresh = (timelineId, refreshAction) => {
-        if (getState().getIn(['timelines', timelineId, 'online'])) {
+      const insertIfOnline = (timelineId) => {
+        if (getState().getIn(['timelines', timelineId, 'items', 0]) !== null) {
           dispatch(updateTimeline(timelineId, { ...response.data }));
-        } else if (getState().getIn(['timelines', timelineId, 'loaded'])) {
-          dispatch(refreshAction());
         }
       };
 
-      insertOrRefresh('home', refreshHomeTimeline);
+      insertIfOnline('home');
 
       if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
-        insertOrRefresh('community', refreshCommunityTimeline);
-        insertOrRefresh('public', refreshPublicTimeline);
+        insertIfOnline('community');
+        insertIfOnline('public');
       } else if (response.data.visibility === 'direct') {
-        insertOrRefresh('direct', refreshDirectTimeline);
+        insertIfOnline('direct');
       }
     }).catch(function (error) {
       dispatch(submitComposeFail(error));
diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js
index ae51e8349..8d623af75 100644
--- a/app/javascript/flavours/glitch/actions/streaming.js
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -2,8 +2,7 @@ import { connectStream } from 'flavours/glitch/util/stream';
 import {
   updateTimeline,
   deleteFromTimelines,
-  refreshHomeTimeline,
-  connectTimeline,
+  expandHomeTimeline,
   disconnectTimeline,
 } from './timelines';
 import { updateNotifications, refreshNotifications } from './notifications';
@@ -16,10 +15,6 @@ 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));
       },
@@ -42,7 +37,7 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
 }
 
 function refreshHomeTimelineAndNotification (dispatch) {
-  dispatch(refreshHomeTimeline());
+  dispatch(expandHomeTimeline());
   dispatch(refreshNotifications());
 }
 
diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js
index d99c6d98b..06f422be1 100644
--- a/app/javascript/flavours/glitch/actions/timelines.js
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -1,35 +1,19 @@
 import api, { getLinks } from 'flavours/glitch/util/api';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import { Map as ImmutableMap } 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) : [];
@@ -77,97 +61,34 @@ export function deleteFromTimelines(id) {
   };
 };
 
-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) {
+    if (timeline.get('isLoading')) {
       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));
+      dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206));
     }).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 const expandHomeTimeline         = ({ maxId } = {}) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId });
+export const expandPublicTimeline       = ({ maxId } = {}) => expandTimeline('public', '/api/v1/timelines/public', { max_id: maxId });
+export const expandCommunityTimeline    = ({ maxId } = {}) => expandTimeline('community', '/api/v1/timelines/public', { local: true, max_id: maxId });
+export const expandDirectTimeline       = ({ maxId } = {}) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId });
+export const expandAccountTimeline      = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
+export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
+export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
+export const expandHashtagTimeline      = (hashtag, { maxId } = {}) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId });
+export const expandListTimeline         = (id, { maxId } = {}) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId });
 
 export function expandTimelineRequest(timeline) {
   return {
@@ -176,12 +97,13 @@ export function expandTimelineRequest(timeline) {
   };
 };
 
-export function expandTimelineSuccess(timeline, statuses, next) {
+export function expandTimelineSuccess(timeline, statuses, next, partial) {
   return {
     type: TIMELINE_EXPAND_SUCCESS,
     timeline,
     statuses,
     next,
+    partial,
   };
 };
 
@@ -201,13 +123,6 @@ export function scrollTopTimeline(timeline, top) {
   };
 };
 
-export function connectTimeline(timeline) {
-  return {
-    type: TIMELINE_CONNECT,
-    timeline,
-  };
-};
-
 export function disconnectTimeline(timeline) {
   return {
     type: TIMELINE_DISCONNECT,
diff --git a/app/javascript/flavours/glitch/components/load_more.js b/app/javascript/flavours/glitch/components/load_more.js
index c4c8c94a2..389c3e1e1 100644
--- a/app/javascript/flavours/glitch/components/load_more.js
+++ b/app/javascript/flavours/glitch/components/load_more.js
@@ -6,6 +6,7 @@ export default class LoadMore extends React.PureComponent {
 
   static propTypes = {
     onClick: PropTypes.func,
+    disabled: PropTypes.bool,
     visible: PropTypes.bool,
   }
 
@@ -14,10 +15,10 @@ export default class LoadMore extends React.PureComponent {
   }
 
   render() {
-    const { visible } = this.props;
+    const { disabled, visible } = this.props;
 
     return (
-      <button className='load-more' disabled={!visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}>
+      <button className='load-more' disabled={disabled || !visible} style={{ visibility: visible ? 'visible' : 'hidden' }} onClick={this.props.onClick}>
         <FormattedMessage id='status.load_more' defaultMessage='Load more' />
       </button>
     );
diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js
index f74f5617a..b96b4dd98 100644
--- a/app/javascript/flavours/glitch/components/scrollable_list.js
+++ b/app/javascript/flavours/glitch/components/scrollable_list.js
@@ -17,7 +17,7 @@ export default class ScrollableList extends PureComponent {
 
   static propTypes = {
     scrollKey: PropTypes.string.isRequired,
-    onLoadMore: PropTypes.func.isRequired,
+    onLoadMore: PropTypes.func,
     onScrollToTop: PropTypes.func,
     onScroll: PropTypes.func,
     trackScroll: PropTypes.bool,
@@ -150,11 +150,11 @@ export default class ScrollableList extends PureComponent {
   }
 
   render () {
-    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
+    const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
-    const loadMore     = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
+    const loadMore     = (hasMore && childrenCount > 0 && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
     let scrollableArea = null;
 
     if (isLoading || childrenCount > 0 || !emptyMessage) {
diff --git a/app/javascript/flavours/glitch/components/status_list.js b/app/javascript/flavours/glitch/components/status_list.js
index 5df75db3c..fac745986 100644
--- a/app/javascript/flavours/glitch/components/status_list.js
+++ b/app/javascript/flavours/glitch/components/status_list.js
@@ -1,11 +1,31 @@
+import { debounce } from 'lodash';
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import StatusContainer from 'flavours/glitch/containers/status_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import LoadMore from './load_more';
 import ScrollableList from './scrollable_list';
 import { FormattedMessage } from 'react-intl';
 
+class LoadGap extends ImmutablePureComponent {
+
+  static propTypes = {
+    disabled: PropTypes.bool,
+    maxId: PropTypes.string,
+    onClick: PropTypes.func.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.onClick(this.props.maxId);
+  }
+
+  render () {
+    return <LoadMore onClick={this.handleClick} disabled={this.props.disabled} />;
+  }
+
+}
+
 export default class StatusList extends ImmutablePureComponent {
 
   static propTypes = {
@@ -50,6 +70,10 @@ export default class StatusList extends ImmutablePureComponent {
     this._selectChild(elementIndex);
   }
 
+  handleLoadOlder = debounce(() => {
+    this.props.onLoadMore(this.props.statusIds.last());
+  }, 300, { leading: true })
+
   _selectChild (index) {
     const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
 
@@ -63,7 +87,7 @@ export default class StatusList extends ImmutablePureComponent {
   }
 
   render () {
-    const { statusIds, featuredStatusIds, ...other }  = this.props;
+    const { statusIds, featuredStatusIds, onLoadMore, ...other }  = this.props;
     const { isLoading, isPartial } = other;
 
     if (isPartial) {
@@ -82,7 +106,14 @@ export default class StatusList extends ImmutablePureComponent {
     }
 
     let scrollableContent = (isLoading || statusIds.size > 0) ? (
-      statusIds.map(statusId => (
+      statusIds.map((statusId, index) => statusId === null ? (
+        <LoadGap
+          key={'gap:' + statusIds.get(index + 1)}
+          disabled={isLoading}
+          maxId={index > 0 ? statusIds.get(index - 1) : null}
+          onClick={onLoadMore}
+        />
+      ) : (
         <StatusContainer
           key={statusId}
           id={statusId}
@@ -105,7 +136,7 @@ export default class StatusList extends ImmutablePureComponent {
     }
 
     return (
-      <ScrollableList {...other} ref={this.setRef}>
+      <ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
         {scrollableContent}
       </ScrollableList>
     );
diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js
index 63ff98deb..8f770eb9a 100644
--- a/app/javascript/flavours/glitch/features/account_gallery/index.js
+++ b/app/javascript/flavours/glitch/features/account_gallery/index.js
@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import { fetchAccount } from 'flavours/glitch/actions/accounts';
-import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines';
+import { expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines';
 import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
 import Column from 'flavours/glitch/features/ui/components/column';
 import ColumnBackButton from 'flavours/glitch/components/column_back_button';
@@ -17,9 +17,31 @@ import LoadMore from 'flavours/glitch/components/load_more';
 const mapStateToProps = (state, props) => ({
   medias: getAccountGallery(state, props.params.accountId),
   isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
-  hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
+  hasMore:   state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
 });
 
+class LoadMoreMedia extends ImmutablePureComponent {
+
+  static propTypes = {
+    maxId: PropTypes.string,
+    onLoadMore: PropTypes.func.isRequired,
+  };
+
+  handleLoadMore = () => {
+    this.props.onLoadMore(this.props.maxId);
+  }
+
+  render () {
+    return (
+      <LoadMore
+        disabled={this.props.disabled}
+        onLoadMore={this.handleLoadMore}
+      />
+    );
+  }
+
+}
+
 @connect(mapStateToProps)
 export default class AccountGallery extends ImmutablePureComponent {
 
@@ -33,19 +55,19 @@ export default class AccountGallery extends ImmutablePureComponent {
 
   componentDidMount () {
     this.props.dispatch(fetchAccount(this.props.params.accountId));
-    this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
+    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
       this.props.dispatch(fetchAccount(nextProps.params.accountId));
-      this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
+      this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
     }
   }
 
   handleScrollToBottom = () => {
     if (this.props.hasMore) {
-      this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
+      this.handleLoadMore(this.props.medias.last().get('id'));
     }
   }
 
@@ -58,7 +80,11 @@ export default class AccountGallery extends ImmutablePureComponent {
     }
   }
 
-  handleLoadMore = (e) => {
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
+  };
+
+  handleLoadOlder = (e) => {
     e.preventDefault();
     this.handleScrollToBottom();
   }
@@ -66,7 +92,7 @@ export default class AccountGallery extends ImmutablePureComponent {
   render () {
     const { medias, isLoading, hasMore } = this.props;
 
-    let loadMore = null;
+    let loadOlder = null;
 
     if (!medias && isLoading) {
       return (
@@ -77,7 +103,7 @@ export default class AccountGallery extends ImmutablePureComponent {
     }
 
     if (!isLoading && medias.size > 0 && hasMore) {
-      loadMore = <LoadMore onClick={this.handleLoadMore} />;
+      loadOlder = <LoadMore onClick={this.handleLoadOlder} />;
     }
 
     return (
@@ -89,13 +115,18 @@ export default class AccountGallery extends ImmutablePureComponent {
             <HeaderContainer accountId={this.props.params.accountId} />
 
             <div className='account-gallery__container'>
-              {medias.map(media =>
-                (<MediaItem
+              {medias.map((media, index) => media === null ? (
+                <LoadMoreMedia
+                  key={'more:' + medias.getIn(index + 1, 'id')}
+                  maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
+                />
+              ) : (
+                <MediaItem
                   key={media.get('id')}
                   media={media}
-                />)
-              )}
-              {loadMore}
+                />
+              ))}
+              {loadOlder}
             </div>
           </div>
         </ScrollContainer>
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js
index dd70d1ee2..2216f9153 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.js
@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import { fetchAccount } from 'flavours/glitch/actions/accounts';
-import { refreshAccountTimeline, refreshAccountFeaturedTimeline, expandAccountTimeline } from 'flavours/glitch/actions/timelines';
+import { expandAccountFeaturedTimeline, expandAccountTimeline } from 'flavours/glitch/actions/timelines';
 import StatusList from '../../components/status_list';
 import LoadingIndicator from '../../components/loading_indicator';
 import Column from '../ui/components/column';
@@ -19,7 +19,7 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
     statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()),
     featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()),
     isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
-    hasMore: !!state.getIn(['timelines', `account:${path}`, 'next']),
+    hasMore:   state.getIn(['timelines', `account:${path}`, 'hasMore']),
   };
 };
 
@@ -41,25 +41,23 @@ export default class AccountTimeline extends ImmutablePureComponent {
 
     this.props.dispatch(fetchAccount(accountId));
     if (!withReplies) {
-      this.props.dispatch(refreshAccountFeaturedTimeline(accountId));
+      this.props.dispatch(expandAccountFeaturedTimeline(accountId));
     }
-    this.props.dispatch(refreshAccountTimeline(accountId, withReplies));
+    this.props.dispatch(expandAccountTimeline(accountId, { withReplies }));
   }
 
   componentWillReceiveProps (nextProps) {
     if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {
       this.props.dispatch(fetchAccount(nextProps.params.accountId));
       if (!nextProps.withReplies) {
-        this.props.dispatch(refreshAccountFeaturedTimeline(nextProps.params.accountId));
+        this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
       }
-      this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId, nextProps.params.withReplies));
+      this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
     }
   }
 
-  handleLoadMore = () => {
-    if (!this.props.isLoading && this.props.hasMore) {
-      this.props.dispatch(expandAccountTimeline(this.props.params.accountId, this.props.withReplies));
-    }
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies }));
   }
 
   render () {
diff --git a/app/javascript/flavours/glitch/features/community_timeline/index.js b/app/javascript/flavours/glitch/features/community_timeline/index.js
index 55355414f..b5843ca16 100644
--- a/app/javascript/flavours/glitch/features/community_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/community_timeline/index.js
@@ -4,10 +4,7 @@ import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
-import {
-  refreshCommunityTimeline,
-  expandCommunityTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
@@ -55,7 +52,7 @@ export default class CommunityTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(refreshCommunityTimeline());
+    dispatch(expandCommunityTimeline());
     this.disconnect = dispatch(connectCommunityStream());
   }
 
@@ -70,8 +67,8 @@ export default class CommunityTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandCommunityTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandCommunityTimeline({ maxId }));
   }
 
   render () {
@@ -97,7 +94,7 @@ export default class CommunityTimeline extends React.PureComponent {
           trackScroll={!pinned}
           scrollKey={`community_timeline-${columnId}`}
           timelineId='community'
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
         />
       </Column>
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
index 9f3c9bec7..8f77ed42b 100644
--- a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
@@ -4,10 +4,7 @@ import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
-import {
-  refreshHashtagTimeline,
-  expandHashtagTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandHashtagTimeline } from 'flavours/glitch/actions/timelines';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { FormattedMessage } from 'react-intl';
 import { connectHashtagStream } from 'flavours/glitch/actions/streaming';
@@ -61,13 +58,13 @@ export default class HashtagTimeline extends React.PureComponent {
     const { dispatch } = this.props;
     const { id } = this.props.params;
 
-    dispatch(refreshHashtagTimeline(id));
+    dispatch(expandHashtagTimeline(id));
     this._subscribe(dispatch, id);
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.id !== this.props.params.id) {
-      this.props.dispatch(refreshHashtagTimeline(nextProps.params.id));
+      this.props.dispatch(expandHashtagTimeline(nextProps.params.id));
       this._unsubscribe();
       this._subscribe(this.props.dispatch, nextProps.params.id);
     }
@@ -81,8 +78,8 @@ export default class HashtagTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandHashtagTimeline(this.props.params.id));
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandHashtagTimeline(this.props.params.id, { maxId }));
   }
 
   render () {
@@ -108,7 +105,7 @@ export default class HashtagTimeline extends React.PureComponent {
           trackScroll={!pinned}
           scrollKey={`hashtag_timeline-${columnId}`}
           timelineId={`hashtag:${id}`}
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
         />
       </Column>
diff --git a/app/javascript/flavours/glitch/features/home_timeline/index.js b/app/javascript/flavours/glitch/features/home_timeline/index.js
index c20c0244a..3650ffc6d 100644
--- a/app/javascript/flavours/glitch/features/home_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/home_timeline/index.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import { connect } from 'react-redux';
-import { expandHomeTimeline, refreshHomeTimeline } from 'flavours/glitch/actions/timelines';
+import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
 import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
 import Column from 'flavours/glitch/components/column';
@@ -16,7 +16,7 @@ const messages = defineMessages({
 
 const mapStateToProps = state => ({
   hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
-  isPartial: state.getIn(['timelines', 'home', 'isPartial'], false),
+  isPartial: state.getIn(['timelines', 'home', 'items', 0], null) === null,
 });
 
 @connect(mapStateToProps)
@@ -55,8 +55,8 @@ export default class HomeTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandHomeTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandHomeTimeline({ maxId }));
   }
 
   componentDidMount () {
@@ -78,7 +78,7 @@ export default class HomeTimeline extends React.PureComponent {
       return;
     } else if (!wasPartial && isPartial) {
       this.polling = setInterval(() => {
-        dispatch(refreshHomeTimeline());
+        dispatch(expandHomeTimeline());
       }, 3000);
     } else if (wasPartial && !isPartial) {
       this._stopPolling();
@@ -114,7 +114,7 @@ export default class HomeTimeline extends React.PureComponent {
         <StatusListContainer
           trackScroll={!pinned}
           scrollKey={`home_timeline-${columnId}`}
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           timelineId='home'
           emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Visit {public} or use search to get started and meet other users.' values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />}
         />
diff --git a/app/javascript/flavours/glitch/features/list_timeline/index.js b/app/javascript/flavours/glitch/features/list_timeline/index.js
index f9476d92d..07edf45aa 100644
--- a/app/javascript/flavours/glitch/features/list_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/list_timeline/index.js
@@ -8,7 +8,7 @@ import ColumnHeader from 'flavours/glitch/components/column_header';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import { connectListStream } from 'flavours/glitch/actions/streaming';
-import { refreshListTimeline, expandListTimeline } from 'flavours/glitch/actions/timelines';
+import { expandListTimeline } from 'flavours/glitch/actions/timelines';
 import { fetchList, deleteList } from 'flavours/glitch/actions/lists';
 import { openModal } from 'flavours/glitch/actions/modal';
 import MissingIndicator from 'flavours/glitch/components/missing_indicator';
@@ -67,7 +67,7 @@ export default class ListTimeline extends React.PureComponent {
     const { id } = this.props.params;
 
     dispatch(fetchList(id));
-    dispatch(refreshListTimeline(id));
+    dispatch(expandListTimeline(id));
 
     this.disconnect = dispatch(connectListStream(id));
   }
@@ -83,9 +83,9 @@ export default class ListTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
+  handleLoadMore = maxId => {
     const { id } = this.props.params;
-    this.props.dispatch(expandListTimeline(id));
+    this.props.dispatch(expandListTimeline(id, { maxId }));
   }
 
   handleEditClick = () => {
@@ -164,7 +164,7 @@ export default class ListTimeline extends React.PureComponent {
           trackScroll={!pinned}
           scrollKey={`list_timeline-${columnId}`}
           timelineId={`list:${id}`}
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet.' />}
         />
       </Column>
diff --git a/app/javascript/flavours/glitch/features/public_timeline/index.js b/app/javascript/flavours/glitch/features/public_timeline/index.js
index bbdd4612e..a6c0b1688 100644
--- a/app/javascript/flavours/glitch/features/public_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/public_timeline/index.js
@@ -4,10 +4,7 @@ import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
-import {
-  refreshPublicTimeline,
-  expandPublicTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandPublicTimeline } from 'flavours/glitch/actions/timelines';
 import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
@@ -55,7 +52,7 @@ export default class PublicTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(refreshPublicTimeline());
+    dispatch(expandPublicTimeline());
     this.disconnect = dispatch(connectPublicStream());
   }
 
@@ -70,8 +67,8 @@ export default class PublicTimeline extends React.PureComponent {
     this.column = c;
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandPublicTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandPublicTimeline({ maxId }));
   }
 
   render () {
@@ -95,7 +92,7 @@ export default class PublicTimeline extends React.PureComponent {
 
         <StatusListContainer
           timelineId='public'
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           trackScroll={!pinned}
           scrollKey={`public_timeline-${columnId}`}
           emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />}
diff --git a/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js
index ff464b390..c488f9541 100644
--- a/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js
@@ -2,10 +2,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
-import {
-  refreshCommunityTimeline,
-  expandCommunityTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
 import { defineMessages, injectIntl } from 'react-intl';
@@ -35,7 +32,7 @@ export default class CommunityTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(refreshCommunityTimeline());
+    dispatch(expandCommunityTimeline());
     this.disconnect = dispatch(connectCommunityStream());
   }
 
@@ -46,8 +43,8 @@ export default class CommunityTimeline extends React.PureComponent {
     }
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandCommunityTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandCommunityTimeline({ maxId }));
   }
 
   render () {
@@ -63,7 +60,7 @@ export default class CommunityTimeline extends React.PureComponent {
 
         <StatusListContainer
           timelineId='community'
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           scrollKey='standalone_public_timeline'
           trackScroll={false}
         />
diff --git a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
index 39053bcc9..dc02f1c91 100644
--- a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
@@ -2,10 +2,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
-import {
-  refreshHashtagTimeline,
-  expandHashtagTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandHashtagTimeline } from 'flavours/glitch/actions/timelines';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
 import { connectHashtagStream } from 'flavours/glitch/actions/streaming';
@@ -29,7 +26,7 @@ export default class HashtagTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch, hashtag } = this.props;
 
-    dispatch(refreshHashtagTimeline(hashtag));
+    dispatch(expandHashtagTimeline(hashtag));
     this.disconnect = dispatch(connectHashtagStream(hashtag));
   }
 
@@ -40,8 +37,8 @@ export default class HashtagTimeline extends React.PureComponent {
     }
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandHashtagTimeline(this.props.hashtag));
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
   }
 
   render () {
@@ -59,7 +56,7 @@ export default class HashtagTimeline extends React.PureComponent {
           trackScroll={false}
           scrollKey='standalone_hashtag_timeline'
           timelineId={`hashtag:${hashtag}`}
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
         />
       </Column>
     );
diff --git a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
index 8e1ad4e52..0b4238485 100644
--- a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
@@ -2,10 +2,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
-import {
-  refreshPublicTimeline,
-  expandPublicTimeline,
-} from 'flavours/glitch/actions/timelines';
+import { expandPublicTimeline } from 'flavours/glitch/actions/timelines';
 import Column from 'flavours/glitch/components/column';
 import ColumnHeader from 'flavours/glitch/components/column_header';
 import { defineMessages, injectIntl } from 'react-intl';
@@ -35,7 +32,7 @@ export default class PublicTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(refreshPublicTimeline());
+    dispatch(expandPublicTimeline());
     this.disconnect = dispatch(connectPublicStream());
   }
 
@@ -46,8 +43,8 @@ export default class PublicTimeline extends React.PureComponent {
     }
   }
 
-  handleLoadMore = () => {
-    this.props.dispatch(expandPublicTimeline());
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandPublicTimeline({ maxId }));
   }
 
   render () {
@@ -63,7 +60,7 @@ export default class PublicTimeline extends React.PureComponent {
 
         <StatusListContainer
           timelineId='public'
-          loadMore={this.handleLoadMore}
+          onLoadMore={this.handleLoadMore}
           scrollKey='standalone_public_timeline'
           trackScroll={false}
         />
diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.js b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
index 3b7a5ff20..ff81522a8 100644
--- a/app/javascript/flavours/glitch/features/ui/components/report_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import { changeReportComment, changeReportForward, submitReport } from 'flavours/glitch/actions/reports';
-import { refreshAccountTimeline } from 'flavours/glitch/actions/timelines';
+import { expandAccountTimeline } from 'flavours/glitch/actions/timelines';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { makeGetAccount } from 'flavours/glitch/selectors';
@@ -64,12 +64,12 @@ export default class ReportModal extends ImmutablePureComponent {
   }
 
   componentDidMount () {
-    this.props.dispatch(refreshAccountTimeline(this.props.account.get('id'), true));
+    this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { withReplies: true }));
   }
 
   componentWillReceiveProps (nextProps) {
     if (this.props.account !== nextProps.account && nextProps.account) {
-      this.props.dispatch(refreshAccountTimeline(nextProps.account.get('id'), true));
+      this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { withReplies: true }));
     }
   }
 
diff --git a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
index 33932e95d..9047752f5 100644
--- a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
+++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
@@ -52,15 +52,13 @@ const makeMapStateToProps = () => {
     statusIds: getStatusIds(state, { type: timelineId }),
     isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
     isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
-    hasMore: !!state.getIn(['timelines', timelineId, 'next']),
+    hasMore:   state.getIn(['timelines', timelineId, 'hasMore']),
   });
 
   return mapStateToProps;
 };
 
-const mapDispatchToProps = (dispatch, { timelineId, loadMore }) => ({
-
-  onLoadMore: debounce(loadMore, 300, { leading: true }),
+const mapDispatchToProps = (dispatch, { timelineId }) => ({
 
   onScrollToTop: debounce(() => {
     dispatch(scrollTopTimeline(timelineId, true));
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index e4b69cb3b..ef3c3ffe4 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -9,7 +9,7 @@ import { Redirect, withRouter } from 'react-router-dom';
 import { isMobile } from 'flavours/glitch/util/is_mobile';
 import { debounce } from 'lodash';
 import { uploadCompose, resetCompose } from 'flavours/glitch/actions/compose';
-import { refreshHomeTimeline } from 'flavours/glitch/actions/timelines';
+import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
 import { refreshNotifications } from 'flavours/glitch/actions/notifications';
 import { clearHeight } from 'flavours/glitch/actions/height_cache';
 import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers';
@@ -219,7 +219,7 @@ export default class UI extends React.Component {
       navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
     }
 
-    this.props.dispatch(refreshHomeTimeline());
+    this.props.dispatch(expandHomeTimeline());
     this.props.dispatch(refreshNotifications());
   }
 
diff --git a/app/javascript/flavours/glitch/reducers/timelines.js b/app/javascript/flavours/glitch/reducers/timelines.js
index c4ae2bc97..532e396b4 100644
--- a/app/javascript/flavours/glitch/reducers/timelines.js
+++ b/app/javascript/flavours/glitch/reducers/timelines.js
@@ -1,14 +1,10 @@
 import {
-  TIMELINE_REFRESH_REQUEST,
-  TIMELINE_REFRESH_SUCCESS,
-  TIMELINE_REFRESH_FAIL,
   TIMELINE_UPDATE,
   TIMELINE_DELETE,
   TIMELINE_EXPAND_SUCCESS,
   TIMELINE_EXPAND_REQUEST,
   TIMELINE_EXPAND_FAIL,
   TIMELINE_SCROLL_TOP,
-  TIMELINE_CONNECT,
   TIMELINE_DISCONNECT,
 } from 'flavours/glitch/actions/timelines';
 import {
@@ -22,37 +18,33 @@ const initialState = ImmutableMap();
 
 const initialTimeline = ImmutableMap({
   unread: 0,
-  online: false,
   top: true,
-  loaded: false,
   isLoading: false,
-  next: false,
+  hasMore: true,
   items: ImmutableList(),
 });
 
-const normalizeTimeline = (state, timeline, statuses, next, isPartial) => {
-  const oldIds    = state.getIn([timeline, 'items'], ImmutableList());
-  const ids       = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
-  const wasLoaded = state.getIn([timeline, 'loaded']);
-  const hadNext   = state.getIn([timeline, 'next']);
-
-  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
-    mMap.set('loaded', true);
-    mMap.set('isLoading', false);
-    if (!hadNext) mMap.set('next', next);
-    mMap.set('items', wasLoaded ? ids.concat(oldIds) : oldIds.concat(ids));
-    mMap.set('isPartial', isPartial);
-  }));
-};
-
-const appendNormalizedTimeline = (state, timeline, statuses, next) => {
-  const oldIds = state.getIn([timeline, 'items'], ImmutableList());
-  const ids    = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
-
+const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial) => {
   return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
     mMap.set('isLoading', false);
-    mMap.set('next', next);
-    mMap.set('items', oldIds.concat(ids));
+    if (!next) mMap.set('hasMore', false);
+
+    if (!statuses.isEmpty()) {
+      mMap.update('items', ImmutableList(), oldIds => {
+        const newIds = statuses.map(status => status.get('id'));
+        const lastIndex = oldIds.findLastIndex(id => id !== null && id >= newIds.last()) + 1;
+        const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && id > newIds.first());
+
+        if (firstIndex < 0) {
+          return (isPartial ? newIds.unshift(null) : newIds).concat(oldIds.skip(lastIndex));
+        }
+
+        return oldIds.take(firstIndex + 1).concat(
+          isPartial && oldIds.get(firstIndex) !== null ? newIds.unshift(null) : newIds,
+          oldIds.skip(lastIndex)
+        );
+      });
+    }
   }));
 };
 
@@ -119,16 +111,12 @@ const updateTop = (state, timeline, top) => {
 
 export default function timelines(state = initialState, action) {
   switch(action.type) {
-  case TIMELINE_REFRESH_REQUEST:
   case TIMELINE_EXPAND_REQUEST:
     return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true));
-  case TIMELINE_REFRESH_FAIL:
   case TIMELINE_EXPAND_FAIL:
     return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false));
-  case TIMELINE_REFRESH_SUCCESS:
-    return normalizeTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial);
   case TIMELINE_EXPAND_SUCCESS:
-    return appendNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next);
+    return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial);
   case TIMELINE_UPDATE:
     return updateTimeline(state, action.timeline, fromJS(action.status), action.references);
   case TIMELINE_DELETE:
@@ -140,10 +128,15 @@ export default function timelines(state = initialState, action) {
     return filterTimeline('home', state, action.relationship, action.statuses);
   case TIMELINE_SCROLL_TOP:
     return updateTop(state, action.timeline, action.top);
-  case TIMELINE_CONNECT:
-    return state.update(action.timeline, initialTimeline, map => map.set('online', true));
   case TIMELINE_DISCONNECT:
-    return state.update(action.timeline, initialTimeline, map => map.set('online', false));
+    return state.update(
+      action.timeline,
+      initialTimeline,
+      map => map.update(
+        'items',
+        items => items.first() ? items : items.unshift(null)
+      )
+    );
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/util/stream.js b/app/javascript/flavours/glitch/util/stream.js
index 9a6f4f26d..6c67ba275 100644
--- a/app/javascript/flavours/glitch/util/stream.js
+++ b/app/javascript/flavours/glitch/util/stream.js
@@ -1,10 +1,10 @@
 import WebSocketClient from 'websocket.js';
 
-export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) {
+export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onDisconnect() {}, onReceive() {} })) {
   return (dispatch, getState) => {
     const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
     const accessToken = getState().getIn(['meta', 'access_token']);
-    const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState);
+    const { onDisconnect, onReceive } = callbacks(dispatch, getState);
     let polling = null;
 
     const setupPolling = () => {
@@ -25,7 +25,6 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
         if (pollingRefresh) {
           clearPolling();
         }
-        onConnect();
       },
 
       disconnected () {
@@ -44,7 +43,6 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
           clearPolling();
           pollingRefresh(dispatch);
         }
-        onConnect();
       },
 
     });