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 eacbeef06f..803911c6c7 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 c70a4d121e..6b3aa69dd5 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 8e1becbda3..3a04ebb094 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 195b143afb..e0532dca13 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 76d69f7511..548f7fc1f6 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 d7eeee729f..aebe362306 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 0000000000..1aa3ce511b --- /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 0000000000..ee6b6dcfdf --- /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 0000000000..0274ac2fcf --- /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 0000000000..2ceca3d625 --- /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 62d507b486..df912321e2 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 e9256b8ec8..62d6839d71 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 0000000000..9d2b7d96a4 --- /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 331cbf59c6..59a1fbaa7b 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 0000000000..ee4b842968 --- /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 b571e43d56..21ee969061 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));