Infinite scroll for followers/following lists

This commit is contained in:
Eugen Rochko 2016-11-13 20:42:54 +01:00
parent 49b7896953
commit dbfe1e4be6
9 changed files with 219 additions and 68 deletions

View file

@ -1,4 +1,4 @@
import api from '../api' import api, { getLinks } from '../api'
import Immutable from 'immutable'; import Immutable from 'immutable';
export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF'; export const ACCOUNT_SET_SELF = 'ACCOUNT_SET_SELF';
@ -35,10 +35,18 @@ export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS'; export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL'; export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL';
export const FOLLOWERS_EXPAND_REQUEST = 'FOLLOWERS_EXPAND_REQUEST';
export const FOLLOWERS_EXPAND_SUCCESS = 'FOLLOWERS_EXPAND_SUCCESS';
export const FOLLOWERS_EXPAND_FAIL = 'FOLLOWERS_EXPAND_FAIL';
export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST'; export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST';
export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS'; export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS';
export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL'; export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL';
export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST';
export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS';
export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL';
export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST';
export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS'; export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS';
export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
@ -46,7 +54,7 @@ export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL';
export function setAccountSelf(account) { export function setAccountSelf(account) {
return { return {
type: ACCOUNT_SET_SELF, type: ACCOUNT_SET_SELF,
account: account account
}; };
}; };
@ -101,22 +109,22 @@ export function expandAccountTimeline(id) {
export function fetchAccountRequest(id) { export function fetchAccountRequest(id) {
return { return {
type: ACCOUNT_FETCH_REQUEST, type: ACCOUNT_FETCH_REQUEST,
id: id id
}; };
}; };
export function fetchAccountSuccess(account) { export function fetchAccountSuccess(account) {
return { return {
type: ACCOUNT_FETCH_SUCCESS, type: ACCOUNT_FETCH_SUCCESS,
account: account account
}; };
}; };
export function fetchAccountFail(id, error) { export function fetchAccountFail(id, error) {
return { return {
type: ACCOUNT_FETCH_FAIL, type: ACCOUNT_FETCH_FAIL,
id: id, id,
error: error error
}; };
}; };
@ -147,89 +155,89 @@ export function unfollowAccount(id) {
export function followAccountRequest(id) { export function followAccountRequest(id) {
return { return {
type: ACCOUNT_FOLLOW_REQUEST, type: ACCOUNT_FOLLOW_REQUEST,
id: id id
}; };
}; };
export function followAccountSuccess(relationship) { export function followAccountSuccess(relationship) {
return { return {
type: ACCOUNT_FOLLOW_SUCCESS, type: ACCOUNT_FOLLOW_SUCCESS,
relationship: relationship relationship
}; };
}; };
export function followAccountFail(error) { export function followAccountFail(error) {
return { return {
type: ACCOUNT_FOLLOW_FAIL, type: ACCOUNT_FOLLOW_FAIL,
error: error error
}; };
}; };
export function unfollowAccountRequest(id) { export function unfollowAccountRequest(id) {
return { return {
type: ACCOUNT_UNFOLLOW_REQUEST, type: ACCOUNT_UNFOLLOW_REQUEST,
id: id id
}; };
}; };
export function unfollowAccountSuccess(relationship) { export function unfollowAccountSuccess(relationship) {
return { return {
type: ACCOUNT_UNFOLLOW_SUCCESS, type: ACCOUNT_UNFOLLOW_SUCCESS,
relationship: relationship relationship
}; };
}; };
export function unfollowAccountFail(error) { export function unfollowAccountFail(error) {
return { return {
type: ACCOUNT_UNFOLLOW_FAIL, type: ACCOUNT_UNFOLLOW_FAIL,
error: error error
}; };
}; };
export function fetchAccountTimelineRequest(id) { export function fetchAccountTimelineRequest(id) {
return { return {
type: ACCOUNT_TIMELINE_FETCH_REQUEST, type: ACCOUNT_TIMELINE_FETCH_REQUEST,
id: id id
}; };
}; };
export function fetchAccountTimelineSuccess(id, statuses, replace) { export function fetchAccountTimelineSuccess(id, statuses, replace) {
return { return {
type: ACCOUNT_TIMELINE_FETCH_SUCCESS, type: ACCOUNT_TIMELINE_FETCH_SUCCESS,
id: id, id,
statuses: statuses, statuses,
replace: replace replace
}; };
}; };
export function fetchAccountTimelineFail(id, error) { export function fetchAccountTimelineFail(id, error) {
return { return {
type: ACCOUNT_TIMELINE_FETCH_FAIL, type: ACCOUNT_TIMELINE_FETCH_FAIL,
id: id, id,
error: error error
}; };
}; };
export function expandAccountTimelineRequest(id) { export function expandAccountTimelineRequest(id) {
return { return {
type: ACCOUNT_TIMELINE_EXPAND_REQUEST, type: ACCOUNT_TIMELINE_EXPAND_REQUEST,
id: id id
}; };
}; };
export function expandAccountTimelineSuccess(id, statuses) { export function expandAccountTimelineSuccess(id, statuses) {
return { return {
type: ACCOUNT_TIMELINE_EXPAND_SUCCESS, type: ACCOUNT_TIMELINE_EXPAND_SUCCESS,
id: id, id,
statuses: statuses statuses
}; };
}; };
export function expandAccountTimelineFail(id, error) { export function expandAccountTimelineFail(id, error) {
return { return {
type: ACCOUNT_TIMELINE_EXPAND_FAIL, type: ACCOUNT_TIMELINE_EXPAND_FAIL,
id: id, id,
error: error error
}; };
}; };
@ -260,42 +268,42 @@ export function unblockAccount(id) {
export function blockAccountRequest(id) { export function blockAccountRequest(id) {
return { return {
type: ACCOUNT_BLOCK_REQUEST, type: ACCOUNT_BLOCK_REQUEST,
id: id id
}; };
}; };
export function blockAccountSuccess(relationship) { export function blockAccountSuccess(relationship) {
return { return {
type: ACCOUNT_BLOCK_SUCCESS, type: ACCOUNT_BLOCK_SUCCESS,
relationship: relationship relationship
}; };
}; };
export function blockAccountFail(error) { export function blockAccountFail(error) {
return { return {
type: ACCOUNT_BLOCK_FAIL, type: ACCOUNT_BLOCK_FAIL,
error: error error
}; };
}; };
export function unblockAccountRequest(id) { export function unblockAccountRequest(id) {
return { return {
type: ACCOUNT_UNBLOCK_REQUEST, type: ACCOUNT_UNBLOCK_REQUEST,
id: id id
}; };
}; };
export function unblockAccountSuccess(relationship) { export function unblockAccountSuccess(relationship) {
return { return {
type: ACCOUNT_UNBLOCK_SUCCESS, type: ACCOUNT_UNBLOCK_SUCCESS,
relationship: relationship relationship
}; };
}; };
export function unblockAccountFail(error) { export function unblockAccountFail(error) {
return { return {
type: ACCOUNT_UNBLOCK_FAIL, type: ACCOUNT_UNBLOCK_FAIL,
error: error error
}; };
}; };
@ -304,7 +312,9 @@ export function fetchFollowers(id) {
dispatch(fetchFollowersRequest(id)); dispatch(fetchFollowersRequest(id));
api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => {
dispatch(fetchFollowersSuccess(id, response.data)); const prev = getLinks(response).refs.find(link => link.rel === 'prev').uri;
dispatch(fetchFollowersSuccess(id, response.data, prev));
dispatch(fetchRelationships(response.data.map(item => item.id))); dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => { }).catch(error => {
dispatch(fetchFollowersFail(id, error)); dispatch(fetchFollowersFail(id, error));
@ -315,23 +325,65 @@ export function fetchFollowers(id) {
export function fetchFollowersRequest(id) { export function fetchFollowersRequest(id) {
return { return {
type: FOLLOWERS_FETCH_REQUEST, type: FOLLOWERS_FETCH_REQUEST,
id: id id
}; };
}; };
export function fetchFollowersSuccess(id, accounts) { export function fetchFollowersSuccess(id, accounts, prev) {
return { return {
type: FOLLOWERS_FETCH_SUCCESS, type: FOLLOWERS_FETCH_SUCCESS,
id: id, id,
accounts: accounts accounts,
prev
}; };
}; };
export function fetchFollowersFail(id, error) { export function fetchFollowersFail(id, error) {
return { return {
type: FOLLOWERS_FETCH_FAIL, type: FOLLOWERS_FETCH_FAIL,
id: id, id,
error: error error
};
};
export function expandFollowers(id) {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'followers', id, 'prev']);
dispatch(expandFollowersRequest(id));
api(getState).get(url).then(response => {
const prev = getLinks(response).refs.find(link => link.rel === 'prev').uri;
dispatch(expandFollowersSuccess(id, response.data, prev));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(expandFollowersFail(id, error));
});
};
};
export function expandFollowersRequest(id) {
return {
type: FOLLOWERS_EXPAND_REQUEST,
id
};
};
export function expandFollowersSuccess(id, accounts, prev) {
return {
type: FOLLOWERS_EXPAND_SUCCESS,
id,
accounts,
prev
};
};
export function expandFollowersFail(id, error) {
return {
type: FOLLOWERS_EXPAND_FAIL,
id,
error
}; };
}; };
@ -351,23 +403,64 @@ export function fetchFollowing(id) {
export function fetchFollowingRequest(id) { export function fetchFollowingRequest(id) {
return { return {
type: FOLLOWING_FETCH_REQUEST, type: FOLLOWING_FETCH_REQUEST,
id: id id
}; };
}; };
export function fetchFollowingSuccess(id, accounts) { export function fetchFollowingSuccess(id, accounts) {
return { return {
type: FOLLOWING_FETCH_SUCCESS, type: FOLLOWING_FETCH_SUCCESS,
id: id, id,
accounts: accounts accounts
}; };
}; };
export function fetchFollowingFail(id, error) { export function fetchFollowingFail(id, error) {
return { return {
type: FOLLOWING_FETCH_FAIL, type: FOLLOWING_FETCH_FAIL,
id: id, id,
error: error error
};
};
export function expandFollowing(id) {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'following', id, 'prev']);
dispatch(expandFollowingRequest(id));
api(getState).get(url).then(response => {
const prev = getLinks(response).refs.find(link => link.rel === 'prev').uri;
dispatch(expandFollowingSuccess(id, response.data, prev));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(expandFollowingFail(id, error));
});
};
};
export function expandFollowingRequest(id) {
return {
type: FOLLOWING_EXPAND_REQUEST,
id
};
};
export function expandFollowingSuccess(id, accounts, prev) {
return {
type: FOLLOWING_EXPAND_SUCCESS,
id,
accounts,
prev
};
};
export function expandFollowingFail(id, error) {
return {
type: FOLLOWING_EXPAND_FAIL,
id,
error
}; };
}; };
@ -386,20 +479,20 @@ export function fetchRelationships(account_ids) {
export function fetchRelationshipsRequest(ids) { export function fetchRelationshipsRequest(ids) {
return { return {
type: RELATIONSHIPS_FETCH_REQUEST, type: RELATIONSHIPS_FETCH_REQUEST,
ids: ids ids
}; };
}; };
export function fetchRelationshipsSuccess(relationships) { export function fetchRelationshipsSuccess(relationships) {
return { return {
type: RELATIONSHIPS_FETCH_SUCCESS, type: RELATIONSHIPS_FETCH_SUCCESS,
relationships: relationships relationships
}; };
}; };
export function fetchRelationshipsFail(error) { export function fetchRelationshipsFail(error) {
return { return {
type: RELATIONSHIPS_FETCH_FAIL, type: RELATIONSHIPS_FETCH_FAIL,
error: error error
}; };
}; };

View file

@ -1,4 +1,9 @@
import axios from 'axios'; import axios from 'axios';
import LinkHeader from 'http-link-header';
export const getLinks = response => {
return LinkHeader.parse(response.headers.link);
};
export default getState => axios.create({ export default getState => axios.create({
headers: { headers: {

View file

@ -7,7 +7,6 @@ const UploadForm = React.createClass({
propTypes: { propTypes: {
media: ImmutablePropTypes.list.isRequired, media: ImmutablePropTypes.list.isRequired,
is_uploading: React.PropTypes.bool, is_uploading: React.PropTypes.bool,
onSelectFile: React.PropTypes.func.isRequired,
onRemoveFile: React.PropTypes.func.isRequired onRemoveFile: React.PropTypes.func.isRequired
}, },

View file

@ -2,12 +2,15 @@ import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator'; import LoadingIndicator from '../../components/loading_indicator';
import { fetchFollowers } from '../../actions/accounts'; import {
fetchFollowers,
expandFollowers
} from '../../actions/accounts';
import { ScrollContainer } from 'react-router-scroll'; import { ScrollContainer } from 'react-router-scroll';
import AccountContainer from './containers/account_container'; import AccountContainer from './containers/account_container';
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId)]) accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'items'])
}); });
const Followers = React.createClass({ const Followers = React.createClass({
@ -30,6 +33,14 @@ const Followers = React.createClass({
} }
}, },
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {
this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
}
},
render () { render () {
const { accountIds } = this.props; const { accountIds } = this.props;
@ -39,9 +50,11 @@ const Followers = React.createClass({
return ( return (
<ScrollContainer scrollKey='followers'> <ScrollContainer scrollKey='followers'>
<div className='scrollable'> <div className='scrollable' onScroll={this.handleScroll}>
<div>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</div> </div>
</div>
</ScrollContainer> </ScrollContainer>
); );
} }

View file

@ -2,12 +2,15 @@ import { connect } from 'react-redux';
import PureRenderMixin from 'react-addons-pure-render-mixin'; import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import LoadingIndicator from '../../components/loading_indicator'; import LoadingIndicator from '../../components/loading_indicator';
import { fetchFollowing } from '../../actions/accounts'; import {
fetchFollowing,
expandFollowing
} from '../../actions/accounts';
import { ScrollContainer } from 'react-router-scroll'; import { ScrollContainer } from 'react-router-scroll';
import AccountContainer from '../followers/containers/account_container'; import AccountContainer from '../followers/containers/account_container';
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId)]) accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId), 'items'])
}); });
const Following = React.createClass({ const Following = React.createClass({
@ -30,6 +33,14 @@ const Following = React.createClass({
} }
}, },
handleScroll (e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollTop === scrollHeight - clientHeight) {
this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
}
},
render () { render () {
const { accountIds } = this.props; const { accountIds } = this.props;
@ -39,9 +50,11 @@ const Following = React.createClass({
return ( return (
<ScrollContainer scrollKey='following'> <ScrollContainer scrollKey='following'>
<div className='scrollable'> <div className='scrollable' onScroll={this.handleScroll}>
<div>
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
</div> </div>
</div>
</ScrollContainer> </ScrollContainer>
); );
} }

View file

@ -2,7 +2,9 @@ import {
ACCOUNT_SET_SELF, ACCOUNT_SET_SELF,
ACCOUNT_FETCH_SUCCESS, ACCOUNT_FETCH_SUCCESS,
FOLLOWERS_FETCH_SUCCESS, FOLLOWERS_FETCH_SUCCESS,
FOLLOWERS_EXPAND_SUCCESS,
FOLLOWING_FETCH_SUCCESS, FOLLOWING_FETCH_SUCCESS,
FOLLOWING_EXPAND_SUCCESS,
ACCOUNT_TIMELINE_FETCH_SUCCESS, ACCOUNT_TIMELINE_FETCH_SUCCESS,
ACCOUNT_TIMELINE_EXPAND_SUCCESS ACCOUNT_TIMELINE_EXPAND_SUCCESS
} from '../actions/accounts'; } from '../actions/accounts';
@ -65,7 +67,9 @@ export default function accounts(state = initialState, action) {
return normalizeAccount(state, action.account); return normalizeAccount(state, action.account);
case SUGGESTIONS_FETCH_SUCCESS: case SUGGESTIONS_FETCH_SUCCESS:
case FOLLOWERS_FETCH_SUCCESS: case FOLLOWERS_FETCH_SUCCESS:
case FOLLOWERS_EXPAND_SUCCESS:
case FOLLOWING_FETCH_SUCCESS: case FOLLOWING_FETCH_SUCCESS:
case FOLLOWING_EXPAND_SUCCESS:
case REBLOGS_FETCH_SUCCESS: case REBLOGS_FETCH_SUCCESS:
case FAVOURITES_FETCH_SUCCESS: case FAVOURITES_FETCH_SUCCESS:
case COMPOSE_SUGGESTIONS_READY: case COMPOSE_SUGGESTIONS_READY:

View file

@ -1,6 +1,8 @@
import { import {
FOLLOWERS_FETCH_SUCCESS, FOLLOWERS_FETCH_SUCCESS,
FOLLOWING_FETCH_SUCCESS FOLLOWERS_EXPAND_SUCCESS,
FOLLOWING_FETCH_SUCCESS,
FOLLOWING_EXPAND_SUCCESS
} from '../actions/accounts'; } from '../actions/accounts';
import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions'; import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions';
import { import {
@ -17,12 +19,29 @@ const initialState = Immutable.Map({
favourited_by: Immutable.Map() favourited_by: Immutable.Map()
}); });
const normalizeList = (state, type, id, accounts, prev) => {
return state.setIn([type, id], Immutable.Map({
prev,
items: Immutable.List(accounts.map(item => item.id))
}));
};
const appendToList = (state, type, id, accounts, prev) => {
return state.updateIn([type, id], map => {
return map.set('prev', prev).update('items', list => list.push(...accounts.map(item => item.id)));
});
};
export default function userLists(state = initialState, action) { export default function userLists(state = initialState, action) {
switch(action.type) { switch(action.type) {
case FOLLOWERS_FETCH_SUCCESS: case FOLLOWERS_FETCH_SUCCESS:
return state.setIn(['followers', action.id], Immutable.List(action.accounts.map(item => item.id))); return normalizeList(state, 'followers', action.id, action.accounts, action.prev);
case FOLLOWERS_EXPAND_SUCCESS:
return appendToList(state, 'followers', action.id, action.accounts, action.prev);
case FOLLOWING_FETCH_SUCCESS: case FOLLOWING_FETCH_SUCCESS:
return state.setIn(['following', action.id], Immutable.List(action.accounts.map(item => item.id))); return normalizeList(state, 'following', action.id, action.accounts, action.prev);
case FOLLOWING_EXPAND_SUCCESS:
return appendToList(state, 'following', action.id, action.accounts, action.prev);
case SUGGESTIONS_FETCH_SUCCESS: case SUGGESTIONS_FETCH_SUCCESS:
return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))); return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id)));
case REBLOGS_FETCH_SUCCESS: case REBLOGS_FETCH_SUCCESS:

View file

@ -43,6 +43,7 @@
"dependencies": { "dependencies": {
"babel-plugin-transform-decorators-legacy": "^1.3.4", "babel-plugin-transform-decorators-legacy": "^1.3.4",
"emojione": "^2.2.6", "emojione": "^2.2.6",
"http-link-header": "^0.5.0",
"react-autosuggest": "^7.0.1", "react-autosuggest": "^7.0.1",
"react-decoration": "^1.4.0", "react-decoration": "^1.4.0",
"react-motion": "^0.4.5", "react-motion": "^0.4.5",

View file

@ -2399,6 +2399,10 @@ http-errors@~1.5.0:
setprototypeof "1.0.1" setprototypeof "1.0.1"
statuses ">= 1.3.0 < 2" statuses ">= 1.3.0 < 2"
http-link-header:
version "0.5.0"
resolved "https://registry.yarnpkg.com/http-link-header/-/http-link-header-0.5.0.tgz#68598d92c55d3dac7d3e6ae405142fecf7bd3303"
http-signature@~1.1.0: http-signature@~1.1.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf"