diff --git a/app/soapbox/components/account.js b/app/soapbox/components/account.js index 189e90686..e1f057b59 100644 --- a/app/soapbox/components/account.js +++ b/app/soapbox/components/account.js @@ -11,6 +11,7 @@ import IconButton from './icon_button'; import RelativeTimestamp from './relative_timestamp'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import classNames from 'classnames'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -44,8 +45,14 @@ class Account extends ImmutablePureComponent { actionTitle: PropTypes.string, onActionClick: PropTypes.func, withDate: PropTypes.bool, + withRelationship: PropTypes.bool, }; + static defaultProps = { + withDate: false, + withRelationship: true, + } + handleFollow = () => { this.props.onFollow(this.props.account); } @@ -71,7 +78,7 @@ class Account extends ImmutablePureComponent { } render() { - const { account, intl, hidden, onActionClick, actionIcon, actionTitle, me, withDate } = this.props; + const { account, intl, hidden, onActionClick, actionIcon, actionTitle, me, withDate, withRelationship } = this.props; if (!account) { return
; @@ -87,7 +94,7 @@ class Account extends ImmutablePureComponent { } let buttons; - let followed_by; + let followedBy; if (onActionClick && actionIcon) { buttons = ; @@ -97,7 +104,7 @@ class Account extends ImmutablePureComponent { const blocking = account.getIn(['relationship', 'blocking']); const muting = account.getIn(['relationship', 'muting']); - followed_by = account.getIn(['relationship', 'followed_by']); + followedBy = account.getIn(['relationship', 'followed_by']); if (requested) { buttons = ; @@ -121,29 +128,36 @@ class Account extends ImmutablePureComponent { } } + const createdAt = account.get('created_at'); + + const joinedAt = createdAt ? ( +
+ + +
+ ) : null; + return ( -
+
- { followed_by ? - - - - : '' } + {withRelationship ? (<> + {followedBy && + + + } -
- {buttons} -
+
+ {buttons} +
+ ) : withDate && joinedAt}
- {withDate && (
- - -
)} + {(withDate && withRelationship) && joinedAt}
); } diff --git a/app/soapbox/features/admin/components/latest_accounts_panel.js b/app/soapbox/features/admin/components/latest_accounts_panel.js new file mode 100644 index 000000000..b2b8614f5 --- /dev/null +++ b/app/soapbox/features/admin/components/latest_accounts_panel.js @@ -0,0 +1,86 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { injectIntl, defineMessages } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import AccountListPanel from 'soapbox/features/ui/components/account_list_panel'; +import { fetchUsers } from 'soapbox/actions/admin'; +import { is } from 'immutable'; +import compareId from 'soapbox/compare_id'; + +const messages = defineMessages({ + title: { id: 'admin.latest_accounts_panel.title', defaultMessage: 'Latest Accounts' }, + expand: { id: 'admin.latest_accounts_panel.expand_message', defaultMessage: 'Click to see {count} more {count, plural, one {account} other {accounts}}' }, +}); + +const mapStateToProps = state => { + const accountIds = state.getIn(['admin', 'latestUsers']); + + // HACK: AdminAPI only recently started sorting new users at the top. + // Try a dirty check to see if the users are sorted properly, or don't show the panel. + // Probably works most of the time. + const sortedIds = accountIds.sort(compareId).reverse(); + const hasDates = accountIds.every(id => state.getIn(['accounts', id, 'created_at'])); + const isSorted = hasDates && is(accountIds, sortedIds); + + return { + isSorted, + accountIds, + }; +}; + +export default @connect(mapStateToProps) +@injectIntl +class LatestAccountsPanel extends ImmutablePureComponent { + + static propTypes = { + accountIds: ImmutablePropTypes.orderedSet.isRequired, + limit: PropTypes.number, + }; + + static defaultProps = { + limit: 5, + } + + state = { + total: 0, + } + + componentDidMount() { + const { dispatch, limit } = this.props; + + dispatch(fetchUsers(['local', 'active'], 1, null, limit)) + .then(({ count }) => { + this.setState({ total: count }); + }) + .catch(() => {}); + } + + render() { + const { intl, accountIds, limit, isSorted, ...props } = this.props; + const { total } = this.state; + + if (!isSorted || !accountIds || accountIds.isEmpty()) { + return null; + } + + const expandCount = total - accountIds.size; + + return ( + + ); + }; + +}; diff --git a/app/soapbox/features/ui/components/account_list_panel.js b/app/soapbox/features/ui/components/account_list_panel.js new file mode 100644 index 000000000..2159b657b --- /dev/null +++ b/app/soapbox/features/ui/components/account_list_panel.js @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Icon from 'soapbox/components/icon'; +import AccountContainer from '../../../containers/account_container'; +import { Link } from 'react-router-dom'; + +export default class AccountListPanel extends ImmutablePureComponent { + + static propTypes = { + title: PropTypes.node.isRequired, + accountIds: ImmutablePropTypes.orderedSet.isRequired, + icon: PropTypes.string.isRequired, + limit: PropTypes.number, + total: PropTypes.number, + expandMessage: PropTypes.string, + expandRoute: PropTypes.string, + }; + + static defaultProps = { + limit: Infinity, + } + + render() { + const { title, icon, accountIds, limit, total, expandMessage, expandRoute, ...props } = this.props; + + if (!accountIds || accountIds.isEmpty()) { + return null; + } + + const canExpand = expandMessage && expandRoute && (accountIds.size < total); + + return ( +
+
+ + + {title} + +
+
+
+ {accountIds.take(limit).map(accountId => ( + + ))} +
+
+ {canExpand && + {expandMessage} + } +
+ ); + }; + +}; diff --git a/app/soapbox/pages/admin_page.js b/app/soapbox/pages/admin_page.js index 28dd2d25f..a010581cc 100644 --- a/app/soapbox/pages/admin_page.js +++ b/app/soapbox/pages/admin_page.js @@ -2,6 +2,7 @@ import React from 'react'; import ImmutablePureComponent from 'react-immutable-pure-component'; import LinkFooter from '../features/ui/components/link_footer'; import AdminNav from 'soapbox/features/admin/components/admin_nav'; +import LatestAccountsPanel from 'soapbox/features/admin/components/latest_accounts_panel'; export default class AdminPage extends ImmutablePureComponent { @@ -28,6 +29,7 @@ class AdminPage extends ImmutablePureComponent {
+
diff --git a/app/soapbox/reducers/__tests__/admin-test.js b/app/soapbox/reducers/__tests__/admin-test.js index 588abe7aa..e56eff6e9 100644 --- a/app/soapbox/reducers/__tests__/admin-test.js +++ b/app/soapbox/reducers/__tests__/admin-test.js @@ -11,6 +11,7 @@ describe('admin reducer', () => { reports: ImmutableMap(), openReports: ImmutableOrderedSet(), users: ImmutableMap(), + latestUsers: ImmutableOrderedSet(), awaitingApproval: ImmutableOrderedSet(), configs: ImmutableList(), needsReboot: false, diff --git a/app/soapbox/reducers/admin.js b/app/soapbox/reducers/admin.js index f7cb33fb6..b4978c218 100644 --- a/app/soapbox/reducers/admin.js +++ b/app/soapbox/reducers/admin.js @@ -12,31 +12,68 @@ import { import { Map as ImmutableMap, List as ImmutableList, + Set as ImmutableSet, OrderedSet as ImmutableOrderedSet, fromJS, + is, } from 'immutable'; -import { normalizePleromaUserFields } from 'soapbox/utils/pleroma'; const initialState = ImmutableMap({ reports: ImmutableMap(), openReports: ImmutableOrderedSet(), users: ImmutableMap(), + latestUsers: ImmutableOrderedSet(), awaitingApproval: ImmutableOrderedSet(), configs: ImmutableList(), needsReboot: false, }); -function importUsers(state, users) { +const FILTER_UNAPPROVED = ['local', 'need_approval']; +const FILTER_LATEST = ['local', 'active']; + +const filtersMatch = (f1, f2) => is(ImmutableSet(f1), ImmutableSet(f2)); +const toIds = items => items.map(item => item.id); + +const mergeSet = (state, key, users) => { + const newIds = toIds(users); + return state.update(key, ImmutableOrderedSet(), ids => ids.union(newIds)); +}; + +const replaceSet = (state, key, users) => { + const newIds = toIds(users); + return state.set(key, ImmutableOrderedSet(newIds)); +}; + +const maybeImportUnapproved = (state, users, filters) => { + if (filtersMatch(FILTER_UNAPPROVED, filters)) { + return mergeSet(state, 'awaitingApproval', users); + } else { + return state; + } +}; + +const maybeImportLatest = (state, users, filters, page) => { + if (page === 1 && filtersMatch(FILTER_LATEST, filters)) { + return replaceSet(state, 'latestUsers', users); + } else { + return state; + } +}; + +const importUser = (state, user) => ( + state.setIn(['users', user.id], ImmutableMap({ + email: user.email, + registration_reason: user.registration_reason, + })) +); + +function importUsers(state, users, filters, page) { return state.withMutations(state => { + maybeImportUnapproved(state, users, filters); + maybeImportLatest(state, users, filters, page); + users.forEach(user => { - user = normalizePleromaUserFields(user); - if (!user.is_approved) { - state.update('awaitingApproval', orderedSet => orderedSet.add(user.id)); - } - state.setIn(['users', user.id], ImmutableMap({ - email: user.email, - registration_reason: user.registration_reason, - })); + importUser(state, user); }); }); } @@ -97,7 +134,7 @@ export default function admin(state = initialState, action) { case ADMIN_REPORTS_PATCH_SUCCESS: return handleReportDiffs(state, action.reports); case ADMIN_USERS_FETCH_SUCCESS: - return importUsers(state, action.users); + return importUsers(state, action.users, action.filters, action.page); case ADMIN_USERS_DELETE_REQUEST: case ADMIN_USERS_DELETE_SUCCESS: return deleteUsers(state, action.accountIds); diff --git a/app/styles/accounts.scss b/app/styles/accounts.scss index 35bc1aa3d..7e7e2680d 100644 --- a/app/styles/accounts.scss +++ b/app/styles/accounts.scss @@ -518,10 +518,18 @@ a .account__avatar { } .account__joined-at { - padding: 3px 2px 0 48px; + padding: 3px 2px 0 5px; font-size: 14px; + display: flex; + white-space: nowrap; i.fa-calendar { padding-right: 5px; } } + +.account--with-date.account--with-relationship { + .account__joined-at { + padding-left: 48px; + } +}