~cytrogen/masto-fe

e91bf82083ac390a0cf229d8e94fa412fdec57ff — Eugen Rochko 6 years ago c8a4759
[Glitch] Add option to disable real-time updates in web UI

Port 729723f857d11434c0f78d63fe16537d77f1c77c to glitch-soc

Signed-off-by: Thibaut Girka <thib@sitedethib.com>
M app/javascript/flavours/glitch/actions/notifications.js => app/javascript/flavours/glitch/actions/notifications.js +24 -8
@@ 12,6 12,8 @@ import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from 'flavours/glitch/util/html';
import { getFiltersRegex } from 'flavours/glitch/selectors';
import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';
import compareId from 'flavours/glitch/util/compare_id';

export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';



@@ 32,8 34,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,6 55,10 @@ 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);


@@ 83,6 90,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
      dispatch({
        type: NOTIFICATIONS_UPDATE,
        notification,
        usePendingItems: preferPendingItems,
        meta: (playSound && !filtered) ? { sound: 'boop' } : undefined,
      });



@@ 136,10 144,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 => {


@@ 148,7 165,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 && preferPendingItems));
      fetchRelatedRelationships(dispatch, response.data);
      done();
    }).catch(error => {


@@ 165,13 182,12 @@ export function expandNotificationsRequest(isLoadingMore) {
  };
};

export function expandNotificationsSuccess(notifications, next, isLoadingMore) {
export function expandNotificationsSuccess(notifications, next, isLoadingMore, usePendingItems) {
  return {
    type: NOTIFICATIONS_EXPAND_SUCCESS,
    notifications,
    accounts: notifications.map(item => item.account),
    statuses: notifications.map(item => item.status).filter(status => !!status),
    next,
    usePendingItems,
    skipLoading: !isLoadingMore,
  };
};

M app/javascript/flavours/glitch/actions/timelines.js => app/javascript/flavours/glitch/actions/timelines.js +28 -13
@@ 1,6 1,8 @@
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 { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';

export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE';
export const TIMELINE_DELETE  = 'TIMELINE_DELETE';


@@ 10,10 12,15 @@ 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 => {


@@ 27,6 34,7 @@ export function updateTimeline(timeline, status, accept) {
      type: TIMELINE_UPDATE,
      timeline,
      status,
      usePendingItems: preferPendingItems,
    });
  };
};


@@ 71,8 79,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;


@@ 82,7 97,7 @@ 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.code === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
      done();
    }).catch(error => {
      dispatch(expandTimelineFail(timelineId, error, isLoadingMore));


@@ 117,7 132,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 140,7 @@ export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadi
    next,
    partial,
    isLoadingRecent,
    usePendingItems,
    skipLoading: !isLoadingMore,
  };
};


@@ 153,9 169,8 @@ export function connectTimeline(timeline) {
  };
};

export function disconnectTimeline(timeline) {
  return {
    type: TIMELINE_DISCONNECT,
    timeline,
  };
};
export const disconnectTimeline = timeline => ({
  type: TIMELINE_DISCONNECT,
  timeline,
  usePendingItems: preferPendingItems,
});

A app/javascript/flavours/glitch/components/load_pending.js => app/javascript/flavours/glitch/components/load_pending.js +22 -0
@@ 0,0 1,22 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';

export default class LoadPending extends React.PureComponent {

  static propTypes = {
    onClick: PropTypes.func,
    count: PropTypes.number,
  }

  render() {
    const { count } = this.props;

    return (
      <button className='load-more load-gap' onClick={this.props.onClick}>
        <FormattedMessage id='load_pending' defaultMessage='{count, plural, one {# new item} other {# new items}}' values={{ count }} />
      </button>
    );
  }

}

M app/javascript/flavours/glitch/components/scrollable_list.js => app/javascript/flavours/glitch/components/scrollable_list.js +12 -1
@@ 3,6 3,7 @@ import { ScrollContainer } from 'react-router-scroll-4';
import PropTypes from 'prop-types';
import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container';
import LoadMore from './load_more';
import LoadPending from './load_pending';
import IntersectionObserverWrapper from 'flavours/glitch/util/intersection_observer_wrapper';
import { throttle } from 'lodash';
import { List as ImmutableList } from 'immutable';


@@ 21,6 22,7 @@ export default class ScrollableList extends PureComponent {
  static propTypes = {
    scrollKey: PropTypes.string.isRequired,
    onLoadMore: PropTypes.func,
    onLoadPending: PropTypes.func,
    onScrollToTop: PropTypes.func,
    onScroll: PropTypes.func,
    trackScroll: PropTypes.bool,


@@ 28,6 30,7 @@ export default class ScrollableList extends PureComponent {
    isLoading: PropTypes.bool,
    showLoading: PropTypes.bool,
    hasMore: PropTypes.bool,
    numPending: PropTypes.number,
    prepend: PropTypes.node,
    alwaysPrepend: PropTypes.bool,
    emptyMessage: PropTypes.node,


@@ 222,12 225,18 @@ export default class ScrollableList extends PureComponent {
    return !(location.state && location.state.mastodonModalOpen);
  }

  handleLoadPending = e => {
    e.preventDefault();
    this.props.onLoadPending();
  }

  render () {
    const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
    const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
    const { fullscreen } = this.state;
    const childrenCount = React.Children.count(children);

    const loadMore     = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
    const loadPending  = (numPending > 0) ? <LoadPending count={numPending} onClick={this.handleLoadPending} /> : null;
    let scrollableArea = null;

    if (showLoading) {


@@ 248,6 257,8 @@ export default class ScrollableList extends PureComponent {
          <div role='feed' className='item-list'>
            {prepend}

            {loadPending}

            {React.Children.map(this.props.children, (child, index) => (
              <IntersectionObserverArticleContainer
                key={child.key}

M app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js => app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js +1 -1
@@ 26,7 26,7 @@ export default class ColumnSettings extends React.PureComponent {
    return (
      <div>
        <div className='column-settings__row'>
          <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} />
          <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
        </div>

        <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>

M app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js => app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js +3 -2
@@ 12,6 12,7 @@ export default class SettingToggle extends React.PureComponent {
    label: PropTypes.node.isRequired,
    meta: PropTypes.node,
    onChange: PropTypes.func.isRequired,
    defaultValue: PropTypes.bool,
  }

  onChange = ({ target }) => {


@@ 19,12 20,12 @@ export default class SettingToggle extends React.PureComponent {
  }

  render () {
    const { prefix, settings, settingPath, label, meta } = this.props;
    const { prefix, settings, settingPath, label, meta, defaultValue } = this.props;
    const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-');

    return (
      <div className='setting-toggle'>
        <Toggle id={id} checked={settings.getIn(settingPath)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
        <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
        <label htmlFor={id} className='setting-toggle__label'>{label}</label>
        {meta && <span className='setting-meta__label'>{meta}</span>}
      </div>

M app/javascript/flavours/glitch/features/notifications/index.js => app/javascript/flavours/glitch/features/notifications/index.js +10 -1
@@ 10,6 10,7 @@ import {
  scrollTopNotifications,
  mountNotifications,
  unmountNotifications,
  loadPending,
} from 'flavours/glitch/actions/notifications';
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
import NotificationContainer from './containers/notification_container';


@@ 48,6 49,7 @@ const mapStateToProps = state => ({
  isLoading: state.getIn(['notifications', 'isLoading'], true),
  isUnread: state.getIn(['notifications', 'unread']) > 0,
  hasMore: state.getIn(['notifications', 'hasMore']),
  numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
  notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
});



@@ 80,6 82,7 @@ export default class Notifications extends React.PureComponent {
    isUnread: PropTypes.bool,
    multiColumn: PropTypes.bool,
    hasMore: PropTypes.bool,
    numPending: PropTypes.number,
    localSettings: ImmutablePropTypes.map,
    notifCleaningActive: PropTypes.bool,
    onEnterCleaningMode: PropTypes.func,


@@ 100,6 103,10 @@ export default class Notifications extends React.PureComponent {
    this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
  }, 300, { leading: true });

  handleLoadPending = () => {
    this.props.dispatch(loadPending());
  };

  handleScrollToTop = debounce(() => {
    this.props.dispatch(scrollTopNotifications(true));
  }, 100);


@@ 170,7 177,7 @@ export default class Notifications extends React.PureComponent {
  }

  render () {
    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, showFilterBar } = this.props;
    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props;
    const pinned = !!columnId;
    const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;



@@ 212,8 219,10 @@ export default class Notifications extends React.PureComponent {
        isLoading={isLoading}
        showLoading={isLoading && notifications.size === 0}
        hasMore={hasMore}
        numPending={numPending}
        emptyMessage={emptyMessage}
        onLoadMore={this.handleLoadOlder}
        onLoadPending={this.handleLoadPending}
        onScrollToTop={this.handleScrollToTop}
        onScroll={this.handleScroll}
        shouldUpdateScroll={shouldUpdateScroll}

M app/javascript/flavours/glitch/features/ui/containers/status_list_container.js => app/javascript/flavours/glitch/features/ui/containers/status_list_container.js +4 -1
@@ 1,6 1,6 @@
import { connect } from 'react-redux';
import StatusList from 'flavours/glitch/components/status_list';
import { scrollTopTimeline } from 'flavours/glitch/actions/timelines';
import { scrollTopTimeline, loadPending } from 'flavours/glitch/actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { createSelector } from 'reselect';
import { debounce } from 'lodash';


@@ 62,6 62,7 @@ const makeMapStateToProps = () => {
    isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
    isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
    hasMore:   state.getIn(['timelines', timelineId, 'hasMore']),
    numPending: state.getIn(['timelines', timelineId, 'pendingItems'], ImmutableList()).size,
  });

  return mapStateToProps;


@@ 77,6 78,8 @@ const mapDispatchToProps = (dispatch, { timelineId }) => ({
    dispatch(scrollTopTimeline(timelineId, false));
  }, 100),

  onLoadPending: () => dispatch(loadPending(timelineId)),

});

export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);

M app/javascript/flavours/glitch/reducers/notifications.js => app/javascript/flavours/glitch/reducers/notifications.js +19 -9
@@ 9,6 9,7 @@ import {
  NOTIFICATIONS_FILTER_SET,
  NOTIFICATIONS_CLEAR,
  NOTIFICATIONS_SCROLL_TOP,
  NOTIFICATIONS_LOAD_PENDING,
  NOTIFICATIONS_DELETE_MARKED_REQUEST,
  NOTIFICATIONS_DELETE_MARKED_SUCCESS,
  NOTIFICATION_MARK_FOR_DELETE,


@@ 25,6 26,7 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from 'flavours/glitch/util/compare_id';

const initialState = ImmutableMap({
  pendingItems: ImmutableList(),
  items: ImmutableList(),
  hasMore: true,
  top: false,


@@ 46,7 48,11 @@ const notificationToMap = (state, notification) => ImmutableMap({
  status: notification.status ? notification.status.id : null,
});

const normalizeNotification = (state, notification) => {
const normalizeNotification = (state, notification, usePendingItems) => {
  if (usePendingItems) {
    return state.update('pendingItems', list => list.unshift(notificationToMap(state, notification)));
  }

  const top = !shouldCountUnreadNotifications(state);

  if (top) {


@@ 64,7 70,7 @@ const normalizeNotification = (state, notification) => {
  });
};

const expandNormalizedNotifications = (state, notifications, next) => {
const expandNormalizedNotifications = (state, notifications, next, usePendingItems) => {
  const top = !(shouldCountUnreadNotifications(state));
  const lastReadId = state.get('lastReadId');
  let items = ImmutableList();


@@ 75,7 81,7 @@ const expandNormalizedNotifications = (state, notifications, next) => {

  return state.withMutations(mutable => {
    if (!items.isEmpty()) {
      mutable.update('items', list => {
      mutable.update(usePendingItems ? 'pendingItems' : 'items', list => {
        const lastIndex = 1 + list.findLastIndex(
          item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id'))
        );


@@ 105,7 111,8 @@ const expandNormalizedNotifications = (state, notifications, next) => {
};

const filterNotifications = (state, relationship) => {
  return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id));
  const helper = list => list.filterNot(item => item !== null && item.get('account') === relationship.id);
  return state.update('items', helper).update('pendingItems', helper);
};

const clearUnread = (state) => {


@@ 131,7 138,8 @@ const deleteByStatus = (state, statusId) => {
    const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
    state = state.update('unread', unread => unread - deletedUnread.size);
  }
  return state.update('items', list => list.filterNot(item => item !== null && item.get('status') === statusId));
  const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId);
  return state.update('items', helper).update('pendingItems', helper);
};

const markForDelete = (state, notificationId, yes) => {


@@ 192,6 200,8 @@ export default function notifications(state = initialState, action) {
    return state.update('mounted', count => count - 1);
  case NOTIFICATIONS_SET_VISIBILITY:
    return updateVisibility(state, action.visibility);
  case NOTIFICATIONS_LOAD_PENDING:
    return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0);
  case NOTIFICATIONS_EXPAND_REQUEST:
  case NOTIFICATIONS_DELETE_MARKED_REQUEST:
    return state.set('isLoading', true);


@@ 203,20 213,20 @@ export default function notifications(state = initialState, action) {
  case NOTIFICATIONS_SCROLL_TOP:
    return updateTop(state, action.top);
  case NOTIFICATIONS_UPDATE:
    return normalizeNotification(state, action.notification);
    return normalizeNotification(state, action.notification, action.usePendingItems);
  case NOTIFICATIONS_EXPAND_SUCCESS:
    return expandNormalizedNotifications(state, action.notifications, action.next);
    return expandNormalizedNotifications(state, action.notifications, action.next, action.usePendingItems);
  case ACCOUNT_BLOCK_SUCCESS:
    return filterNotifications(state, action.relationship);
  case ACCOUNT_MUTE_SUCCESS:
    return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state;
  case NOTIFICATIONS_CLEAR:
    return state.set('items', ImmutableList()).set('hasMore', false);
    return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
  case TIMELINE_DELETE:
    return deleteByStatus(state, action.id);
  case TIMELINE_DISCONNECT:
    return action.timeline === 'home' ?
      state.update('items', items => items.first() ? items.unshift(null) : items) :
      state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) :
      state;

  case NOTIFICATION_MARK_FOR_DELETE:

M app/javascript/flavours/glitch/reducers/timelines.js => app/javascript/flavours/glitch/reducers/timelines.js +27 -13
@@ 8,6 8,7 @@ import {
  TIMELINE_SCROLL_TOP,
  TIMELINE_CONNECT,
  TIMELINE_DISCONNECT,
  TIMELINE_LOAD_PENDING,
} from 'flavours/glitch/actions/timelines';
import {
  ACCOUNT_BLOCK_SUCCESS,


@@ 25,10 26,11 @@ const initialTimeline = ImmutableMap({
  top: true,
  isLoading: false,
  hasMore: true,
  pendingItems: ImmutableList(),
  items: ImmutableList(),
});

const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent) => {
const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => {
  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
    mMap.set('isLoading', false);
    mMap.set('isPartial', isPartial);


@@ 38,7 40,7 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
    if (timeline.endsWith(':pinned')) {
      mMap.set('items', statuses.map(status => status.get('id')));
    } else if (!statuses.isEmpty()) {
      mMap.update('items', ImmutableList(), oldIds => {
      mMap.update(usePendingItems ? 'pendingItems' : 'items', ImmutableList(), oldIds => {
        const newIds = statuses.map(status => status.get('id'));
        const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1;
        const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0);


@@ 56,7 58,15 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
  }));
};

const updateTimeline = (state, timeline, status) => {
const updateTimeline = (state, timeline, status, usePendingItems) => {
  if (usePendingItems) {
    if (state.getIn([timeline, 'pendingItems'], ImmutableList()).includes(status.get('id')) || state.getIn([timeline, 'items'], ImmutableList()).includes(status.get('id'))) {
      return state;
    }

    return state.update(timeline, initialTimeline, map => map.update('pendingItems', list => list.unshift(status.get('id'))));
  }

  const top        = state.getIn([timeline, 'top']);
  const ids        = state.getIn([timeline, 'items'], ImmutableList());
  const includesId = ids.includes(status.get('id'));


@@ 77,8 87,10 @@ const updateTimeline = (state, timeline, status) => {

const deleteStatus = (state, id, accountId, references, exclude_account = null) => {
  state.keySeq().forEach(timeline => {
    if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`)))
      state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id));
    if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) {
      const helper = list => list.filterNot(item => item === id);
      state = state.updateIn([timeline, 'items'], helper).updateIn([timeline, 'pendingItems'], helper);
    }
  });

  // Remove reblogs of deleted status


@@ 108,11 120,10 @@ const filterTimelines = (state, relationship, statuses) => {
  return state;
};

const filterTimeline = (timeline, state, relationship, statuses) =>
  state.updateIn([timeline, 'items'], ImmutableList(), list =>
    list.filterNot(statusId =>
      statuses.getIn([statusId, 'account']) === relationship.id
    ));
const filterTimeline = (timeline, state, relationship, statuses) => {
  const helper = list => list.filterNot(statusId => statuses.getIn([statusId, 'account']) === relationship.id);
  return state.updateIn([timeline, 'items'], ImmutableList(), helper).updateIn([timeline, 'pendingItems'], ImmutableList(), helper);
};

const updateTop = (state, timeline, top) => {
  return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {


@@ 123,14 134,17 @@ const updateTop = (state, timeline, top) => {

export default function timelines(state = initialState, action) {
  switch(action.type) {
  case TIMELINE_LOAD_PENDING:
    return state.update(action.timeline, initialTimeline, map =>
      map.update('items', list => map.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0));
  case TIMELINE_EXPAND_REQUEST:
    return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true));
  case TIMELINE_EXPAND_FAIL:
    return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false));
  case TIMELINE_EXPAND_SUCCESS:
    return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent);
    return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent, action.usePendingItems);
  case TIMELINE_UPDATE:
    return updateTimeline(state, action.timeline, fromJS(action.status));
    return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems);
  case TIMELINE_DELETE:
    return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
  case TIMELINE_CLEAR:


@@ 148,7 162,7 @@ export default function timelines(state = initialState, action) {
    return state.update(
      action.timeline,
      initialTimeline,
      map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items)
      map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items)
    );
  default:
    return state;

M app/javascript/flavours/glitch/util/compare_id.js => app/javascript/flavours/glitch/util/compare_id.js +3 -2
@@ 1,10 1,11 @@
export default function compareId(id1, id2) {
export default function compareId (id1, id2) {
  if (id1 === id2) {
    return 0;
  }

  if (id1.length === id2.length) {
    return id1 > id2 ? 1 : -1;
  } else {
    return id1.length > id2.length ? 1 : -1;
  }
}
};

M app/javascript/flavours/glitch/util/initial_state.js => app/javascript/flavours/glitch/util/initial_state.js +1 -0
@@ 30,5 30,6 @@ export const isStaff = getMeta('is_staff');
export const defaultContentType = getMeta('default_content_type');
export const forceSingleColumn = getMeta('advanced_layout') === false;
export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');

export default initialState;