From 9a1a55ce526c956ac6b35897d483c316b7ad4394 Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Sat, 24 Mar 2018 23:25:15 +0900 Subject: [PATCH] Allow clients to fetch statuses made while they were offline (#6876) --- app/javascript/mastodon/actions/compose.js | 20 +--- app/javascript/mastodon/actions/streaming.js | 9 +- app/javascript/mastodon/actions/timelines.js | 111 ++---------------- .../mastodon/components/load_more.js | 5 +- .../mastodon/components/scrollable_list.js | 6 +- .../mastodon/components/status_list.js | 37 +++++- .../features/account_gallery/index.js | 51 ++++++-- .../features/account_timeline/index.js | 18 ++- .../features/community_timeline/index.js | 13 +- .../features/hashtag_timeline/index.js | 15 +-- .../mastodon/features/home_timeline/index.js | 12 +- .../mastodon/features/list_timeline/index.js | 10 +- .../features/public_timeline/index.js | 13 +- .../standalone/community_timeline/index.js | 13 +- .../standalone/hashtag_timeline/index.js | 13 +- .../standalone/public_timeline/index.js | 13 +- .../features/ui/components/report_modal.js | 6 +- .../ui/containers/status_list_container.js | 6 +- app/javascript/mastodon/features/ui/index.js | 4 +- app/javascript/mastodon/reducers/statuses.js | 4 +- app/javascript/mastodon/reducers/timelines.js | 65 +++++----- app/javascript/mastodon/stream.js | 6 +- 22 files changed, 191 insertions(+), 259 deletions(-) diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 8e13209b83..5e7cdd2706 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -5,13 +5,7 @@ import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light import { tagHistory } from '../settings'; import { useEmoji } from './emojis'; import { importFetchedAccounts } from './importer'; - -import { - updateTimeline, - refreshHomeTimeline, - refreshCommunityTimeline, - refreshPublicTimeline, -} from './timelines'; +import { updateTimeline } from './timelines'; let cancelFetchComposeSuggestionsAccounts; @@ -125,19 +119,17 @@ 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'); } }).catch(function (error) { dispatch(submitComposeFail(error)); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index c22152edde..3ac6b8a09a 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -2,8 +2,7 @@ import { connectStream } from '../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/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index e5748b4e76..5be07126d5 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -1,36 +1,20 @@ import { importFetchedStatus, importFetchedStatuses } from './importer'; import api, { getLinks } from '../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) : []; @@ -80,97 +64,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(importFetchedStatuses(response.data)); - 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 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(importFetchedStatuses(response.data)); - 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 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 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 { @@ -179,12 +100,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, }; }; @@ -204,13 +126,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/mastodon/components/load_more.js b/app/javascript/mastodon/components/load_more.js index c4c8c94a2a..389c3e1e11 100644 --- a/app/javascript/mastodon/components/load_more.js +++ b/app/javascript/mastodon/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 ( - ); diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js index ac3e404df4..ee07106f7f 100644 --- a/app/javascript/mastodon/components/scrollable_list.js +++ b/app/javascript/mastodon/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, @@ -148,11 +148,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) ? : null; + const loadMore = (hasMore && childrenCount > 0 && onLoadMore) ? : null; let scrollableArea = null; if (isLoading || childrenCount > 0 || !emptyMessage) { diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 3bebf702cf..8c2673f301 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/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 '../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 ; + } + +} + export default class StatusList extends ImmutablePureComponent { static propTypes = { @@ -38,6 +58,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`); @@ -51,7 +75,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) { @@ -70,7 +94,14 @@ export default class StatusList extends ImmutablePureComponent { } let scrollableContent = (isLoading || statusIds.size > 0) ? ( - statusIds.map(statusId => ( + statusIds.map((statusId, index) => statusId === null ? ( + 0 ? statusIds.get(index - 1) : null} + onClick={onLoadMore} + /> + ) : ( + {scrollableContent} ); diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index 4b408256a1..9a40d139cb 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/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 '../../actions/accounts'; -import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from '../../actions/timelines'; +import { expandAccountMediaTimeline } from '../../actions/timelines'; import LoadingIndicator from '../../components/loading_indicator'; import Column from '../ui/components/column'; import ColumnBackButton from '../../components/column_back_button'; @@ -17,9 +17,31 @@ import LoadMore from '../../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 ( + + ); + } + +} + @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 = ; + loadOlder = ; } return ( @@ -89,13 +115,18 @@ export default class AccountGallery extends ImmutablePureComponent {
- {medias.map(media => ( + {medias.map((media, index) => media === null ? ( + 0 ? medias.getIn(index - 1, 'id') : null} + /> + ) : ( ))} - {loadMore} + {loadOlder}
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index 5e21cf7c6f..d329bac5c1 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/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 '../../actions/accounts'; -import { refreshAccountTimeline, refreshAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines'; +import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../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/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index 596a89412d..870474ed58 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -4,10 +4,7 @@ import PropTypes from 'prop-types'; import StatusListContainer from '../ui/containers/status_list_container'; import Column from '../../components/column'; import ColumnHeader from '../../components/column_header'; -import { - refreshCommunityTimeline, - expandCommunityTimeline, -} from '../../actions/timelines'; +import { expandCommunityTimeline } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../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={} /> diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index 5fe21ce90e..374615ac7f 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -4,10 +4,7 @@ import PropTypes from 'prop-types'; import StatusListContainer from '../ui/containers/status_list_container'; import Column from '../../components/column'; import ColumnHeader from '../../components/column_header'; -import { - refreshHashtagTimeline, - expandHashtagTimeline, -} from '../../actions/timelines'; +import { expandHashtagTimeline } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { FormattedMessage } from 'react-intl'; import { connectHashtagStream } from '../../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={} /> diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js index 31f5a3c8b2..db6bbdec13 100644 --- a/app/javascript/mastodon/features/home_timeline/index.js +++ b/app/javascript/mastodon/features/home_timeline/index.js @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { expandHomeTimeline, refreshHomeTimeline } from '../../actions/timelines'; +import { expandHomeTimeline } from '../../actions/timelines'; import PropTypes from 'prop-types'; import StatusListContainer from '../ui/containers/status_list_container'; import Column from '../../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 { }} />} /> diff --git a/app/javascript/mastodon/features/list_timeline/index.js b/app/javascript/mastodon/features/list_timeline/index.js index 3b97ac62a2..9a1e3c6d69 100644 --- a/app/javascript/mastodon/features/list_timeline/index.js +++ b/app/javascript/mastodon/features/list_timeline/index.js @@ -8,7 +8,7 @@ import ColumnHeader from '../../components/column_header'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { connectListStream } from '../../actions/streaming'; -import { refreshListTimeline, expandListTimeline } from '../../actions/timelines'; +import { expandListTimeline } from '../../actions/timelines'; import { fetchList, deleteList } from '../../actions/lists'; import { openModal } from '../../actions/modal'; import MissingIndicator from '../../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={} /> diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js index 193489c630..5a88f76017 100644 --- a/app/javascript/mastodon/features/public_timeline/index.js +++ b/app/javascript/mastodon/features/public_timeline/index.js @@ -4,10 +4,7 @@ import PropTypes from 'prop-types'; import StatusListContainer from '../ui/containers/status_list_container'; import Column from '../../components/column'; import ColumnHeader from '../../components/column_header'; -import { - refreshPublicTimeline, - expandPublicTimeline, -} from '../../actions/timelines'; +import { expandPublicTimeline } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../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 { } diff --git a/app/javascript/mastodon/features/standalone/community_timeline/index.js b/app/javascript/mastodon/features/standalone/community_timeline/index.js index 51e50e1f50..629d058a26 100644 --- a/app/javascript/mastodon/features/standalone/community_timeline/index.js +++ b/app/javascript/mastodon/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 '../../ui/containers/status_list_container'; -import { - refreshCommunityTimeline, - expandCommunityTimeline, -} from '../../../actions/timelines'; +import { expandCommunityTimeline } from '../../../actions/timelines'; import Column from '../../../components/column'; import ColumnHeader from '../../../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 { diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js index f14be2aafb..931ca2a32e 100644 --- a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js +++ b/app/javascript/mastodon/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 '../../ui/containers/status_list_container'; -import { - refreshHashtagTimeline, - expandHashtagTimeline, -} from '../../../actions/timelines'; +import { expandHashtagTimeline } from '../../../actions/timelines'; import Column from '../../../components/column'; import ColumnHeader from '../../../components/column_header'; import { connectHashtagStream } from '../../../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} /> ); diff --git a/app/javascript/mastodon/features/standalone/public_timeline/index.js b/app/javascript/mastodon/features/standalone/public_timeline/index.js index 5805d1a105..1236cb9273 100644 --- a/app/javascript/mastodon/features/standalone/public_timeline/index.js +++ b/app/javascript/mastodon/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 '../../ui/containers/status_list_container'; -import { - refreshPublicTimeline, - expandPublicTimeline, -} from '../../../actions/timelines'; +import { expandPublicTimeline } from '../../../actions/timelines'; import Column from '../../../components/column'; import ColumnHeader from '../../../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 { diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js index 3ae97646fc..8a55c553c1 100644 --- a/app/javascript/mastodon/features/ui/components/report_modal.js +++ b/app/javascript/mastodon/features/ui/components/report_modal.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { changeReportComment, changeReportForward, submitReport } from '../../../actions/reports'; -import { refreshAccountTimeline } from '../../../actions/timelines'; +import { expandAccountTimeline } from '../../../actions/timelines'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { makeGetAccount } from '../../../selectors'; @@ -64,12 +64,12 @@ export default class ReportModal extends ImmutablePureComponent { } componentDidMount () { - this.props.dispatch(refreshAccountTimeline(this.props.account.get('id'))); + this.props.dispatch(expandAccountTimeline(this.props.account.get('id'))); } componentWillReceiveProps (nextProps) { if (this.props.account !== nextProps.account && nextProps.account) { - this.props.dispatch(refreshAccountTimeline(nextProps.account.get('id'))); + this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'))); } } diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js index fc2867cf05..4efacda65e 100644 --- a/app/javascript/mastodon/features/ui/containers/status_list_container.js +++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js @@ -48,15 +48,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/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 6cf00222a5..59412908af 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -10,7 +10,7 @@ import { Redirect, withRouter } from 'react-router-dom'; import { isMobile } from '../../is_mobile'; import { debounce } from 'lodash'; import { uploadCompose, resetCompose } from '../../actions/compose'; -import { refreshHomeTimeline } from '../../actions/timelines'; +import { expandHomeTimeline } from '../../actions/timelines'; import { refreshNotifications } from '../../actions/notifications'; import { clearHeight } from '../../actions/height_cache'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; @@ -284,7 +284,7 @@ export default class UI extends React.PureComponent { navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); } - this.props.dispatch(refreshHomeTimeline()); + this.props.dispatch(expandHomeTimeline()); this.props.dispatch(refreshNotifications()); } diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index fc4b4900e5..3abe69bcea 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -10,9 +10,7 @@ import { STATUS_REVEAL, STATUS_HIDE, } from '../actions/statuses'; -import { - TIMELINE_DELETE, -} from '../actions/timelines'; +import { TIMELINE_DELETE } from '../actions/timelines'; import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer'; import { Map as ImmutableMap, fromJS } from 'immutable'; diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js index 9a10bcc593..f795e7e089 100644 --- a/app/javascript/mastodon/reducers/timelines.js +++ b/app/javascript/mastodon/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 '../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) + ); + }); + } })); }; @@ -118,16 +110,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)); case TIMELINE_DELETE: @@ -139,10 +127,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/mastodon/stream.js b/app/javascript/mastodon/stream.js index 9a6f4f26d1..6c67ba2755 100644 --- a/app/javascript/mastodon/stream.js +++ b/app/javascript/mastodon/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(); }, });