From 1c84d505c8cb926710d059725c5a2d966dd4736b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 27 Oct 2016 21:59:56 +0200 Subject: [PATCH] Adding following/followers lists to the UI --- .../components/actions/accounts.jsx | 78 +++++++++++++++++++ .../components/actions/suggestions.jsx | 4 +- .../components/containers/mastodon.jsx | 4 + .../account/components/action_bar.jsx | 39 +++++++--- .../components/features/account/index.jsx | 18 +++-- .../compose/components/suggestions_box.jsx | 1 - .../features/followers/components/account.jsx | 66 ++++++++++++++++ .../containers/account_container.jsx | 20 +++++ .../components/features/followers/index.jsx | 51 ++++++++++++ .../components/features/following/index.jsx | 51 ++++++++++++ .../features/getting_started/index.jsx | 1 - .../javascripts/components/reducers/index.jsx | 4 + .../components/reducers/suggestions.jsx | 13 ++++ .../components/reducers/timelines.jsx | 12 ++- .../components/reducers/user_lists.jsx | 21 +++++ .../components/selectors/index.jsx | 16 ++-- 16 files changed, 369 insertions(+), 30 deletions(-) create mode 100644 app/assets/javascripts/components/features/followers/components/account.jsx create mode 100644 app/assets/javascripts/components/features/followers/containers/account_container.jsx create mode 100644 app/assets/javascripts/components/features/followers/index.jsx create mode 100644 app/assets/javascripts/components/features/following/index.jsx create mode 100644 app/assets/javascripts/components/reducers/suggestions.jsx create mode 100644 app/assets/javascripts/components/reducers/user_lists.jsx diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx index eacbeef06..803911c6c 100644 --- a/app/assets/javascripts/components/actions/accounts.jsx +++ b/app/assets/javascripts/components/actions/accounts.jsx @@ -32,6 +32,14 @@ export const ACCOUNT_TIMELINE_EXPAND_REQUEST = 'ACCOUNT_TIMELINE_EXPAND_REQUEST' export const ACCOUNT_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_TIMELINE_EXPAND_SUCCESS'; export const ACCOUNT_TIMELINE_EXPAND_FAIL = 'ACCOUNT_TIMELINE_EXPAND_FAIL'; +export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; +export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS'; +export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL'; + +export const FOLLOWING_FETCH_REQUEST = 'FOLLOWING_FETCH_REQUEST'; +export const FOLLOWING_FETCH_SUCCESS = 'FOLLOWING_FETCH_SUCCESS'; +export const FOLLOWING_FETCH_FAIL = 'FOLLOWING_FETCH_FAIL'; + export function setAccountSelf(account) { return { type: ACCOUNT_SET_SELF, @@ -289,3 +297,73 @@ export function unblockAccountFail(error) { error: error }; }; + +export function fetchFollowers(id) { + return (dispatch, getState) => { + dispatch(fetchFollowersRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}/followers`).then(response => { + dispatch(fetchFollowersSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchFollowersFail(id, error)); + }); + }; +}; + +export function fetchFollowersRequest(id) { + return { + type: FOLLOWERS_FETCH_REQUEST, + id: id + }; +}; + +export function fetchFollowersSuccess(id, accounts) { + return { + type: FOLLOWERS_FETCH_SUCCESS, + id: id, + accounts: accounts + }; +}; + +export function fetchFollowersFail(id, error) { + return { + type: FOLLOWERS_FETCH_FAIL, + id: id, + error: error + }; +}; + +export function fetchFollowing(id) { + return (dispatch, getState) => { + dispatch(fetchFollowingRequest(id)); + + api(getState).get(`/api/v1/accounts/${id}/following`).then(response => { + dispatch(fetchFollowingSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchFollowingFail(id, error)); + }); + }; +}; + +export function fetchFollowingRequest(id) { + return { + type: FOLLOWING_FETCH_REQUEST, + id: id + }; +}; + +export function fetchFollowingSuccess(id, accounts) { + return { + type: FOLLOWING_FETCH_SUCCESS, + id: id, + accounts: accounts + }; +}; + +export function fetchFollowingFail(id, error) { + return { + type: FOLLOWING_FETCH_FAIL, + id: id, + error: error + }; +}; diff --git a/app/assets/javascripts/components/actions/suggestions.jsx b/app/assets/javascripts/components/actions/suggestions.jsx index c70a4d121..6b3aa69dd 100644 --- a/app/assets/javascripts/components/actions/suggestions.jsx +++ b/app/assets/javascripts/components/actions/suggestions.jsx @@ -22,10 +22,10 @@ export function fetchSuggestionsRequest() { }; }; -export function fetchSuggestionsSuccess(suggestions) { +export function fetchSuggestionsSuccess(accounts) { return { type: SUGGESTIONS_FETCH_SUCCESS, - suggestions: suggestions + accounts: accounts }; }; diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index 8e1becbda..3a04ebb09 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -26,6 +26,8 @@ import AccountTimeline from '../features/account_timeline'; import HomeTimeline from '../features/home_timeline'; import MentionsTimeline from '../features/mentions_timeline'; import Compose from '../features/compose'; +import Followers from '../features/followers'; +import Following from '../features/following'; const store = configureStore(); @@ -83,6 +85,8 @@ const Mastodon = React.createClass({ + + diff --git a/app/assets/javascripts/components/features/account/components/action_bar.jsx b/app/assets/javascripts/components/features/account/components/action_bar.jsx index 195b143af..e0532dca1 100644 --- a/app/assets/javascripts/components/features/account/components/action_bar.jsx +++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx @@ -1,6 +1,27 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; import DropdownMenu from '../../../components/dropdown_menu'; +import { Link } from 'react-router'; + +const outerStyle = { + borderTop: '1px solid #363c4b', + borderBottom: '1px solid #363c4b', + lineHeight: '36px', + overflow: 'hidden', + flex: '0 0 auto', + display: 'flex' +}; + +const outerDropdownStyle = { + padding: '10px', + flex: '1 1 auto' +}; + +const outerLinksStyle = { + flex: '1 1 auto', + display: 'flex', + lineHeight: '18px' +}; const ActionBar = React.createClass({ @@ -34,26 +55,26 @@ const ActionBar = React.createClass({ } return ( -
-
+
+
-
-
+
+ Posts {account.get('statuses_count')} -
+ -
+ Follows {account.get('following_count')} -
+ -
+ Followers {account.get('followers_count')} -
+
); diff --git a/app/assets/javascripts/components/features/account/index.jsx b/app/assets/javascripts/components/features/account/index.jsx index 76d69f751..548f7fc1f 100644 --- a/app/assets/javascripts/components/features/account/index.jsx +++ b/app/assets/javascripts/components/features/account/index.jsx @@ -14,17 +14,23 @@ import { mentionCompose } from '../../actions/compose'; import Header from './components/header'; import { getAccountTimeline, - getAccount + makeGetAccount } from '../../selectors'; import LoadingIndicator from '../../components/loading_indicator'; import ActionBar from './components/action_bar'; import Column from '../ui/components/column'; import ColumnBackButton from '../../components/column_back_button'; -const mapStateToProps = (state, props) => ({ - account: getAccount(state, Number(props.params.accountId)), - me: state.getIn(['timelines', 'me']) -}); +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, props) => ({ + account: getAccount(state, Number(props.params.accountId)), + me: state.getIn(['timelines', 'me']) + }); + + return mapStateToProps; +}; const Account = React.createClass({ @@ -92,4 +98,4 @@ const Account = React.createClass({ }); -export default connect(mapStateToProps)(Account); +export default connect(makeMapStateToProps)(Account); diff --git a/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx b/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx index d7eeee729..aebe36230 100644 --- a/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx +++ b/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx @@ -1,7 +1,6 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Avatar from '../../../components/avatar'; -import DisplayName from '../../../components/display_name'; import { Link } from 'react-router'; const outerStyle = { diff --git a/app/assets/javascripts/components/features/followers/components/account.jsx b/app/assets/javascripts/components/features/followers/components/account.jsx new file mode 100644 index 000000000..1aa3ce511 --- /dev/null +++ b/app/assets/javascripts/components/features/followers/components/account.jsx @@ -0,0 +1,66 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from '../../../components/avatar'; +import { Link } from 'react-router'; + +const outerStyle = { + padding: '10px' +}; + +const displayNameStyle = { + display: 'block', + fontWeight: '500', + overflow: 'hidden', + textOverflow: 'ellipsis', + color: '#fff' +}; + +const acctStyle = { + display: 'block', + overflow: 'hidden', + textOverflow: 'ellipsis' +}; + +const itemStyle = { + display: 'block', + color: '#9baec8', + overflow: 'hidden', + textDecoration: 'none' +}; + +const Account = React.createClass({ + + propTypes: { + account: ImmutablePropTypes.map.isRequired, + me: React.PropTypes.number.isRequired + }, + + mixins: [PureRenderMixin], + + render () { + const { account } = this.props; + + if (!account) { + return
; + } + + let displayName = account.get('display_name'); + + if (displayName.length === 0) { + displayName = account.get('username'); + } + + return ( +
+ +
+ {displayName} + {account.get('acct')} + +
+ ); + } + +}); + +export default Account; diff --git a/app/assets/javascripts/components/features/followers/containers/account_container.jsx b/app/assets/javascripts/components/features/followers/containers/account_container.jsx new file mode 100644 index 000000000..ee6b6dcfd --- /dev/null +++ b/app/assets/javascripts/components/features/followers/containers/account_container.jsx @@ -0,0 +1,20 @@ +import { connect } from 'react-redux'; +import { makeGetAccount } from '../../../selectors'; +import Account from '../components/account'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, props) => ({ + account: getAccount(state, props.id), + me: state.getIn(['timelines', 'me']) + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch) => ({ + // +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(Account); diff --git a/app/assets/javascripts/components/features/followers/index.jsx b/app/assets/javascripts/components/features/followers/index.jsx new file mode 100644 index 000000000..0274ac2fc --- /dev/null +++ b/app/assets/javascripts/components/features/followers/index.jsx @@ -0,0 +1,51 @@ +import { connect } from 'react-redux'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { fetchFollowers } from '../../actions/accounts'; +import { ScrollContainer } from 'react-router-scroll'; +import AccountContainer from './containers/account_container'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId)]) +}); + +const Followers = React.createClass({ + + propTypes: { + params: React.PropTypes.object.isRequired, + dispatch: React.PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list + }, + + mixins: [PureRenderMixin], + + componentWillMount () { + this.props.dispatch(fetchFollowers(Number(this.props.params.accountId))); + }, + + componentWillReceiveProps(nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId))); + } + }, + + render () { + const { accountIds } = this.props; + + if (!accountIds) { + return ; + } + + return ( + +
+ {accountIds.map(id => )} +
+
+ ); + } + +}); + +export default connect(mapStateToProps)(Followers); diff --git a/app/assets/javascripts/components/features/following/index.jsx b/app/assets/javascripts/components/features/following/index.jsx new file mode 100644 index 000000000..2ceca3d62 --- /dev/null +++ b/app/assets/javascripts/components/features/following/index.jsx @@ -0,0 +1,51 @@ +import { connect } from 'react-redux'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { fetchFollowing } from '../../actions/accounts'; +import { ScrollContainer } from 'react-router-scroll'; +import AccountContainer from '../followers/containers/account_container'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId)]) +}); + +const Following = React.createClass({ + + propTypes: { + params: React.PropTypes.object.isRequired, + dispatch: React.PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list + }, + + mixins: [PureRenderMixin], + + componentWillMount () { + this.props.dispatch(fetchFollowing(Number(this.props.params.accountId))); + }, + + componentWillReceiveProps(nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId))); + } + }, + + render () { + const { accountIds } = this.props; + + if (!accountIds) { + return ; + } + + return ( + +
+ {accountIds.map(id => )} +
+
+ ); + } + +}); + +export default connect(mapStateToProps)(Following); diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx index 62d507b48..df912321e 100644 --- a/app/assets/javascripts/components/features/getting_started/index.jsx +++ b/app/assets/javascripts/components/features/getting_started/index.jsx @@ -6,7 +6,6 @@ const GettingStarted = () => {

Getting started

-

Mastodon is still in development and one of the lacking areas at the moment is user discovery.

You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form in the bottom of the sidebar.

If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.

The developer of this project can be followed as Gargron@mastodon.social

diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx index e9256b8ec..62d6839d7 100644 --- a/app/assets/javascripts/components/reducers/index.jsx +++ b/app/assets/javascripts/components/reducers/index.jsx @@ -6,6 +6,8 @@ import follow from './follow'; import notifications from './notifications'; import { loadingBarReducer } from 'react-redux-loading-bar'; import modal from './modal'; +import user_lists from './user_lists'; +import suggestions from './suggestions'; export default combineReducers({ timelines, @@ -15,4 +17,6 @@ export default combineReducers({ notifications, loadingBar: loadingBarReducer, modal, + user_lists, + suggestions }); diff --git a/app/assets/javascripts/components/reducers/suggestions.jsx b/app/assets/javascripts/components/reducers/suggestions.jsx new file mode 100644 index 000000000..9d2b7d96a --- /dev/null +++ b/app/assets/javascripts/components/reducers/suggestions.jsx @@ -0,0 +1,13 @@ +import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions'; +import Immutable from 'immutable'; + +const initialState = Immutable.List(); + +export default function suggestions(state = initialState, action) { + switch(action.type) { + case SUGGESTIONS_FETCH_SUCCESS: + return Immutable.List(action.accounts.map(item => item.id)); + default: + return state; + } +} diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx index 331cbf59c..59a1fbaa7 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -18,7 +18,9 @@ import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_UNBLOCK_SUCCESS, ACCOUNT_TIMELINE_FETCH_SUCCESS, - ACCOUNT_TIMELINE_EXPAND_SUCCESS + ACCOUNT_TIMELINE_EXPAND_SUCCESS, + FOLLOWERS_FETCH_SUCCESS, + FOLLOWING_FETCH_SUCCESS } from '../actions/accounts'; import { STATUS_FETCH_SUCCESS, @@ -206,12 +208,12 @@ function normalizeContext(state, status, ancestors, descendants) { }); }; -function normalizeSuggestions(state, accounts) { +function normalizeAccounts(state, accounts) { accounts.forEach(account => { state = state.setIn(['accounts', account.get('id')], account); }); - return state.set('suggestions', accounts.map(account => account.get('id'))); + return state; }; export default function timelines(state = initialState, action) { @@ -247,7 +249,9 @@ export default function timelines(state = initialState, action) { case ACCOUNT_TIMELINE_EXPAND_SUCCESS: return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); case SUGGESTIONS_FETCH_SUCCESS: - return normalizeSuggestions(state, Immutable.fromJS(action.suggestions)); + case FOLLOWERS_FETCH_SUCCESS: + case FOLLOWING_FETCH_SUCCESS: + return normalizeAccounts(state, Immutable.fromJS(action.accounts)); default: return state; } diff --git a/app/assets/javascripts/components/reducers/user_lists.jsx b/app/assets/javascripts/components/reducers/user_lists.jsx new file mode 100644 index 000000000..ee4b84296 --- /dev/null +++ b/app/assets/javascripts/components/reducers/user_lists.jsx @@ -0,0 +1,21 @@ +import { + FOLLOWERS_FETCH_SUCCESS, + FOLLOWING_FETCH_SUCCESS +} from '../actions/accounts'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + followers: Immutable.Map(), + following: Immutable.Map() +}); + +export default function userLists(state = initialState, action) { + switch(action.type) { + case FOLLOWERS_FETCH_SUCCESS: + return state.setIn(['followers', action.id], Immutable.List(action.accounts.map(item => item.id))); + case FOLLOWING_FETCH_SUCCESS: + return state.setIn(['following', action.id], Immutable.List(action.accounts.map(item => item.id))); + default: + return state; + } +}; diff --git a/app/assets/javascripts/components/selectors/index.jsx b/app/assets/javascripts/components/selectors/index.jsx index b571e43d5..21ee96906 100644 --- a/app/assets/javascripts/components/selectors/index.jsx +++ b/app/assets/javascripts/components/selectors/index.jsx @@ -7,13 +7,15 @@ const getAccounts = state => state.getIn(['timelines', 'accounts']); const getAccountBase = (state, id) => state.getIn(['timelines', 'accounts', id], null); const getAccountRelationship = (state, id) => state.getIn(['timelines', 'relationships', id]); -export const getAccount = createSelector([getAccountBase, getAccountRelationship], (base, relationship) => { - if (base === null) { - return null; - } +export const makeGetAccount = () => { + return createSelector([getAccountBase, getAccountRelationship], (base, relationship) => { + if (base === null) { + return null; + } - return base.set('relationship', relationship); -}); + return base.set('relationship', relationship); + }); +}; const getStatusBase = (state, id) => state.getIn(['timelines', 'statuses', id], null); @@ -65,7 +67,7 @@ export const getNotifications = createSelector([getNotificationsBase], (base) => return arr; }); -const getSuggestionsBase = (state) => state.getIn(['timelines', 'suggestions']); +const getSuggestionsBase = (state) => state.get('suggestions'); export const getSuggestions = createSelector([getSuggestionsBase, getAccounts], (base, accounts) => { return base.map(accountId => accounts.get(accountId));