diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx index 0f23ca7fc..5aab993c1 100644 --- a/app/assets/javascripts/components/actions/timelines.jsx +++ b/app/assets/javascripts/components/actions/timelines.jsx @@ -12,12 +12,13 @@ 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 function refreshTimelineSuccess(timeline, statuses, replace) { +export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; + +export function refreshTimelineSuccess(timeline, statuses) { return { type: TIMELINE_REFRESH_SUCCESS, timeline: timeline, - statuses: statuses, - replace: replace + statuses: statuses }; }; @@ -48,24 +49,25 @@ export function deleteFromTimelines(id) { }; }; -export function refreshTimelineRequest(timeline) { +export function refreshTimelineRequest(timeline, id) { return { type: TIMELINE_REFRESH_REQUEST, - timeline: timeline + timeline, + id }; }; -export function refreshTimeline(timeline, replace = false, id = null) { +export function refreshTimeline(timeline, id = null) { return function (dispatch, getState) { - dispatch(refreshTimelineRequest(timeline)); + dispatch(refreshTimelineRequest(timeline, id)); - const ids = getState().getIn(['timelines', timeline], Immutable.List()); + const ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List()); const newestId = ids.size > 0 ? ids.first() : null; let params = ''; let path = timeline; - if (newestId !== null && !replace) { + if (newestId !== null) { params = `?since_id=${newestId}`; } @@ -74,7 +76,7 @@ export function refreshTimeline(timeline, replace = false, id = null) { } api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) { - dispatch(refreshTimelineSuccess(timeline, response.data, replace)); + dispatch(refreshTimelineSuccess(timeline, response.data)); }).catch(function (error) { dispatch(refreshTimelineFail(timeline, error)); }); @@ -84,14 +86,14 @@ export function refreshTimeline(timeline, replace = false, id = null) { export function refreshTimelineFail(timeline, error) { return { type: TIMELINE_REFRESH_FAIL, - timeline: timeline, - error: error + timeline, + error }; }; export function expandTimeline(timeline, id = null) { return (dispatch, getState) => { - const lastId = getState().getIn(['timelines', timeline], Immutable.List()).last(); + const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last(); dispatch(expandTimelineRequest(timeline)); @@ -112,22 +114,30 @@ export function expandTimeline(timeline, id = null) { export function expandTimelineRequest(timeline) { return { type: TIMELINE_EXPAND_REQUEST, - timeline: timeline + timeline }; }; export function expandTimelineSuccess(timeline, statuses) { return { type: TIMELINE_EXPAND_SUCCESS, - timeline: timeline, - statuses: statuses + timeline, + statuses }; }; export function expandTimelineFail(timeline, error) { return { type: TIMELINE_EXPAND_FAIL, - timeline: timeline, - error: error + timeline, + error + }; +}; + +export function scrollTopTimeline(timeline, top) { + return { + type: TIMELINE_SCROLL_TOP, + timeline, + top }; }; diff --git a/app/assets/javascripts/components/components/status_list.jsx b/app/assets/javascripts/components/components/status_list.jsx index f989ef895..b48d94405 100644 --- a/app/assets/javascripts/components/components/status_list.jsx +++ b/app/assets/javascripts/components/components/status_list.jsx @@ -1,14 +1,16 @@ -import Status from './status'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; +import Status from './status'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; import { ScrollContainer } from 'react-router-scroll'; -import StatusContainer from '../containers/status_container'; +import StatusContainer from '../containers/status_container'; const StatusList = React.createClass({ propTypes: { statusIds: ImmutablePropTypes.list.isRequired, onScrollToBottom: React.PropTypes.func, + onScrollToTop: React.PropTypes.func, + onScroll: React.PropTypes.func, trackScroll: React.PropTypes.bool }, @@ -27,6 +29,10 @@ const StatusList = React.createClass({ if (scrollTop === scrollHeight - clientHeight) { this.props.onScrollToBottom(); + } else if (scrollTop < 100) { + this.props.onScrollToTop(); + } else { + this.props.onScroll(); } }, diff --git a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx index bea0a2759..cf53a7729 100644 --- a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx +++ b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx @@ -47,13 +47,13 @@ const HashtagTimeline = React.createClass({ const { dispatch } = this.props; const { id } = this.props.params; - dispatch(refreshTimeline('tag', true, id)); + dispatch(refreshTimeline('tag', id)); this._subscribe(dispatch, id); }, componentWillReceiveProps (nextProps) { if (nextProps.params.id !== this.props.params.id) { - this.props.dispatch(refreshTimeline('tag', true, nextProps.params.id)); + this.props.dispatch(refreshTimeline('tag', nextProps.params.id)); this._unsubscribe(); this._subscribe(this.props.dispatch, nextProps.params.id); } diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx index 8004e3f04..1621cec7b 100644 --- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx @@ -1,16 +1,25 @@ import { connect } from 'react-redux'; import StatusList from '../../../components/status_list'; -import { expandTimeline } from '../../../actions/timelines'; +import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines'; import Immutable from 'immutable'; const mapStateToProps = (state, props) => ({ - statusIds: state.getIn(['timelines', props.type], Immutable.List()) + statusIds: state.getIn(['timelines', props.type, 'items'], Immutable.List()) }); const mapDispatchToProps = function (dispatch, props) { return { onScrollToBottom () { + dispatch(scrollTopTimeline(props.type, false)); dispatch(expandTimeline(props.type, props.id)); + }, + + onScrollToTop () { + dispatch(scrollTopTimeline(props.type, true)); + }, + + onScroll () { + dispatch(scrollTopTimeline(props.type, false)); } }; }; diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx index 358734eaf..de157eb25 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -1,8 +1,10 @@ import { + TIMELINE_REFRESH_REQUEST, TIMELINE_REFRESH_SUCCESS, TIMELINE_UPDATE, TIMELINE_DELETE, - TIMELINE_EXPAND_SUCCESS + TIMELINE_EXPAND_SUCCESS, + TIMELINE_SCROLL_TOP } from '../actions/timelines'; import { REBLOG_SUCCESS, @@ -23,10 +25,31 @@ import { import Immutable from 'immutable'; const initialState = Immutable.Map({ - home: Immutable.List(), - mentions: Immutable.List(), - public: Immutable.List(), - tag: Immutable.List(), + home: Immutable.Map({ + loaded: false, + top: true, + items: Immutable.List() + }), + + mentions: Immutable.Map({ + loaded: false, + top: true, + items: Immutable.List() + }), + + public: Immutable.Map({ + loaded: false, + top: true, + items: Immutable.List() + }), + + tag: Immutable.Map({ + id: null, + loaded: false, + top: true, + items: Immutable.List() + }), + accounts_timelines: Immutable.Map(), ancestors: Immutable.Map(), descendants: Immutable.Map() @@ -50,14 +73,17 @@ const normalizeStatus = (state, status) => { }; const normalizeTimeline = (state, timeline, statuses, replace = false) => { - let ids = Immutable.List(); + let ids = Immutable.List(); + const loaded = state.getIn([timeline, 'loaded']); statuses.forEach((status, i) => { state = normalizeStatus(state, status); ids = ids.set(i, status.get('id')); }); - return state.update(timeline, Immutable.List(), list => (replace ? ids : list.unshift(...ids))); + state = state.setIn([timeline, 'loaded'], true); + + return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? list.unshift(...ids) : list.push(...ids))); }; const appendNormalizedTimeline = (state, timeline, statuses) => { @@ -68,7 +94,7 @@ const appendNormalizedTimeline = (state, timeline, statuses) => { moreIds = moreIds.set(i, status.get('id')); }); - return state.update(timeline, Immutable.List(), list => list.push(...moreIds)); + return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds)); }; const normalizeAccountTimeline = (state, accountId, statuses, replace = false) => { @@ -94,9 +120,15 @@ const appendNormalizedAccountTimeline = (state, accountId, statuses) => { }; const updateTimeline = (state, timeline, status, references) => { + const top = state.getIn([timeline, 'top']); + state = normalizeStatus(state, status); - state = state.update(timeline, Immutable.List(), list => { + state = state.updateIn([timeline, 'items'], Immutable.List(), list => { + if (top && list.size > 40) { + list = list.take(20); + } + if (list.includes(status.get('id'))) { return list; } @@ -116,7 +148,7 @@ const updateTimeline = (state, timeline, status, references) => { const deleteStatus = (state, id, accountId, references) => { // Remove references from timelines ['home', 'mentions', 'public', 'tag'].forEach(function (timeline) { - state = state.update(timeline, list => list.filterNot(item => item === id)); + state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); }); // Remove references from account timelines @@ -166,10 +198,23 @@ const normalizeContext = (state, id, ancestors, descendants) => { }); }; +const resetTimeline = (state, timeline, id) => { + if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) { + state = state.update(timeline, map => map + .set('id', id) + .set('loaded', false) + .update('items', list => list.clear())); + } + + return state; +}; + export default function timelines(state = initialState, action) { switch(action.type) { + case TIMELINE_REFRESH_REQUEST: + return resetTimeline(state, action.timeline, action.id); case TIMELINE_REFRESH_SUCCESS: - return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.replace); + return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); case TIMELINE_EXPAND_SUCCESS: return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); case TIMELINE_UPDATE: @@ -184,6 +229,8 @@ export default function timelines(state = initialState, action) { return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); case ACCOUNT_BLOCK_SUCCESS: return filterTimelines(state, action.relationship, action.statuses); + case TIMELINE_SCROLL_TOP: + return state.setIn([action.timeline, 'top'], action.top); default: return state; } diff --git a/db/migrate/20161203164520_add_from_account_id_to_notifications.rb b/db/migrate/20161203164520_add_from_account_id_to_notifications.rb index 282676760..142adbe9c 100644 --- a/db/migrate/20161203164520_add_from_account_id_to_notifications.rb +++ b/db/migrate/20161203164520_add_from_account_id_to_notifications.rb @@ -2,10 +2,10 @@ class AddFromAccountIdToNotifications < ActiveRecord::Migration[5.0] def up add_column :notifications, :from_account_id, :integer - Notification.where(activity_type: 'Status').update_all('from_account_id = (SELECT statuses.account_id FROM notifications AS notifications1 INNER JOIN statuses ON notifications1.activity_id = statuses.id WHERE notifications1.activity_type = \'Status\' AND notifications1.id = notifications.id)') - Notification.where(activity_type: 'Mention').update_all('from_account_id = (SELECT statuses.account_id FROM notifications AS notifications1 INNER JOIN mentions ON notifications1.activity_id = mentions.id INNER JOIN statuses ON mentions.status_id = statuses.id WHERE notifications1.activity_type = \'Mention\' AND notifications1.id = notifications.id)') - Notification.where(activity_type: 'Favourite').update_all('from_account_id = (SELECT favourites.account_id FROM notifications AS notifications1 INNER JOIN favourites ON notifications1.activity_id = favourites.id WHERE notifications1.activity_type = \'Favourite\' AND notifications1.id = notifications.id)') - Notification.where(activity_type: 'Follow').update_all('from_account_id = (SELECT follows.account_id FROM notifications AS notifications1 INNER JOIN follows ON notifications1.activity_id = follows.id WHERE notifications1.activity_type = \'Follow\' AND notifications1.id = notifications.id)') + Notification.where(from_account_id: nil).where(activity_type: 'Status').update_all('from_account_id = (SELECT statuses.account_id FROM notifications AS notifications1 INNER JOIN statuses ON notifications1.activity_id = statuses.id WHERE notifications1.activity_type = \'Status\' AND notifications1.id = notifications.id)') + Notification.where(from_account_id: nil).where(activity_type: 'Mention').update_all('from_account_id = (SELECT statuses.account_id FROM notifications AS notifications1 INNER JOIN mentions ON notifications1.activity_id = mentions.id INNER JOIN statuses ON mentions.status_id = statuses.id WHERE notifications1.activity_type = \'Mention\' AND notifications1.id = notifications.id)') + Notification.where(from_account_id: nil).where(activity_type: 'Favourite').update_all('from_account_id = (SELECT favourites.account_id FROM notifications AS notifications1 INNER JOIN favourites ON notifications1.activity_id = favourites.id WHERE notifications1.activity_type = \'Favourite\' AND notifications1.id = notifications.id)') + Notification.where(from_account_id: nil).where(activity_type: 'Follow').update_all('from_account_id = (SELECT follows.account_id FROM notifications AS notifications1 INNER JOIN follows ON notifications1.activity_id = follows.id WHERE notifications1.activity_type = \'Follow\' AND notifications1.id = notifications.id)') end def down