From 10f7339e5c796c4f7254e09db169fbfbb0568c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 22 Dec 2021 21:22:29 +0100 Subject: [PATCH] Profile directories, adapted from Mastodon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/directory.js | 61 +++++++ app/soapbox/components/radio_button.js | 35 ++++ .../directory/components/account_card.js | 160 ++++++++++++++++ app/soapbox/features/directory/index.js | 148 +++++++++++++++ app/soapbox/features/ui/index.js | 2 + .../features/ui/util/async-components.js | 4 + app/soapbox/reducers/user_lists.js | 18 ++ app/styles/application.scss | 1 + app/styles/components/directory.scss | 172 ++++++++++++++++++ 9 files changed, 601 insertions(+) create mode 100644 app/soapbox/actions/directory.js create mode 100644 app/soapbox/components/radio_button.js create mode 100644 app/soapbox/features/directory/components/account_card.js create mode 100644 app/soapbox/features/directory/index.js create mode 100644 app/styles/components/directory.scss diff --git a/app/soapbox/actions/directory.js b/app/soapbox/actions/directory.js new file mode 100644 index 000000000..35e699703 --- /dev/null +++ b/app/soapbox/actions/directory.js @@ -0,0 +1,61 @@ +import api from '../api'; +import { importFetchedAccounts } from './importer'; +import { fetchRelationships } from './accounts'; + +export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST'; +export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS'; +export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL'; + +export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST'; +export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS'; +export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL'; + +export const fetchDirectory = params => (dispatch, getState) => { + dispatch(fetchDirectoryRequest()); + + api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(fetchDirectoryFail(error))); +}; + +export const fetchDirectoryRequest = () => ({ + type: DIRECTORY_FETCH_REQUEST, +}); + +export const fetchDirectorySuccess = accounts => ({ + type: DIRECTORY_FETCH_SUCCESS, + accounts, +}); + +export const fetchDirectoryFail = error => ({ + type: DIRECTORY_FETCH_FAIL, + error, +}); + +export const expandDirectory = params => (dispatch, getState) => { + dispatch(expandDirectoryRequest()); + + const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size; + + api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(expandDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(expandDirectoryFail(error))); +}; + +export const expandDirectoryRequest = () => ({ + type: DIRECTORY_EXPAND_REQUEST, +}); + +export const expandDirectorySuccess = accounts => ({ + type: DIRECTORY_EXPAND_SUCCESS, + accounts, +}); + +export const expandDirectoryFail = error => ({ + type: DIRECTORY_EXPAND_FAIL, + error, +}); \ No newline at end of file diff --git a/app/soapbox/components/radio_button.js b/app/soapbox/components/radio_button.js new file mode 100644 index 000000000..7500578da --- /dev/null +++ b/app/soapbox/components/radio_button.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class RadioButton extends React.PureComponent { + + static propTypes = { + value: PropTypes.string.isRequired, + checked: PropTypes.bool, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + label: PropTypes.node.isRequired, + }; + + render() { + const { name, value, checked, onChange, label } = this.props; + + return ( + + ); + } + +} \ No newline at end of file diff --git a/app/soapbox/features/directory/components/account_card.js b/app/soapbox/features/directory/components/account_card.js new file mode 100644 index 000000000..95a8264dc --- /dev/null +++ b/app/soapbox/features/directory/components/account_card.js @@ -0,0 +1,160 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; +import { connect } from 'react-redux'; +import { makeGetAccount } from 'soapbox/selectors'; +import Avatar from 'soapbox/components/avatar'; +import DisplayName from 'soapbox/components/display_name'; +import Permalink from 'soapbox/components/permalink'; +import RelativeTimestamp from 'soapbox/components/relative_timestamp'; +import IconButton from 'soapbox/components/icon_button'; +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; +import { getSettings } from 'soapbox/actions/settings'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; +import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'soapbox/actions/accounts'; +import { openModal } from 'soapbox/actions/modal'; +import { initMuteModal } from 'soapbox/actions/mutes'; + + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { id }) => ({ + account: getAccount(state, id), + autoPlayGif: getSettings(state).get('autoPlayGif'), + me: state.get('me'), + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onFollow(account) { + dispatch((_, getState) => { + const unfollowModal = getSettings(getState()).get('unfollowModal'); + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { + if (unfollowModal) { + dispatch(openModal('CONFIRM', { + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.unfollowConfirm), + onConfirm: () => dispatch(unfollowAccount(account.get('id'))), + })); + } else { + dispatch(unfollowAccount(account.get('id'))); + } + } else { + dispatch(followAccount(account.get('id'))); + } + }); + }, + + onBlock(account) { + if (account.getIn(['relationship', 'blocking'])) { + dispatch(unblockAccount(account.get('id'))); + } else { + dispatch(blockAccount(account.get('id'))); + } + }, + + onMute(account) { + if (account.getIn(['relationship', 'muting'])) { + dispatch(unmuteAccount(account.get('id'))); + } else { + dispatch(initMuteModal(account)); + } + }, + +}); + +export default @injectIntl +@connect(makeMapStateToProps, mapDispatchToProps) +class AccountCard extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onFollow: PropTypes.func.isRequired, + onBlock: PropTypes.func.isRequired, + onMute: PropTypes.func.isRequired, + autoPlayGif: PropTypes.bool, + me: SoapboxPropTypes.me, + }; + + handleFollow = () => { + this.props.onFollow(this.props.account); + } + + handleBlock = () => { + this.props.onBlock(this.props.account); + } + + handleMute = () => { + this.props.onMute(this.props.account); + } + + render() { + const { account, intl, me, autoPlayGif } = this.props; + + let buttons; + + if (account.get('id') !== me && account.get('relationship', null) !== null) { + const following = account.getIn(['relationship', 'following']); + const requested = account.getIn(['relationship', 'requested']); + const blocking = account.getIn(['relationship', 'blocking']); + const muting = account.getIn(['relationship', 'muting']); + + if (requested) { + buttons = ; + } else if (blocking) { + buttons = ; + } else if (muting) { + buttons = ; + } else if (!account.get('moved') || following) { + buttons = ; + } + } + + return ( +
+
+ +
+ +
+ + + + + +
+ {buttons} +
+
+ + {account.get('note').length > 0 && account.get('note') !== '

' && ( +
+
+
+ )} + +
+
{shortNumberFormat(account.get('statuses_count'))}
+
{shortNumberFormat(account.get('followers_count'))}
+
{account.get('last_status_at') === null ? : }
+
+
+ ); + } + +} \ No newline at end of file diff --git a/app/soapbox/features/directory/index.js b/app/soapbox/features/directory/index.js new file mode 100644 index 000000000..408c85371 --- /dev/null +++ b/app/soapbox/features/directory/index.js @@ -0,0 +1,148 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from 'soapbox/components/column'; +import ColumnHeader from 'soapbox/components/column_header'; +import { fetchDirectory, expandDirectory } from 'soapbox/actions/directory'; +import { List as ImmutableList } from 'immutable'; +import AccountCard from './components/account_card'; +import RadioButton from 'soapbox/components/radio_button'; +import classNames from 'classnames'; +import LoadMore from 'soapbox/components/load_more'; +import { ScrollContainer } from 'react-router-scroll-4'; +import { getFeatures } from 'soapbox/utils/features'; + +const messages = defineMessages({ + title: { id: 'column.directory', defaultMessage: 'Browse profiles' }, + recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' }, + newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' }, + local: { id: 'directory.local', defaultMessage: 'From {domain} only' }, + federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' }, +}); + +const mapStateToProps = state => ({ + accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()), + isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true), + domain: state.getIn(['meta', 'domain']), + features: getFeatures(state.get('instance')), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Directory extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + isLoading: PropTypes.bool, + accountIds: ImmutablePropTypes.list.isRequired, + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + domain: PropTypes.string.isRequired, + params: PropTypes.shape({ + order: PropTypes.string, + local: PropTypes.bool, + }), + features: PropTypes.object.isRequired, + }; + + state = { + order: null, + local: null, + }; + + getParams = (props, state) => ({ + order: state.order === null ? (props.params.order || 'active') : state.order, + local: state.local === null ? (props.params.local || false) : state.local, + }); + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount() { + const { dispatch } = this.props; + dispatch(fetchDirectory(this.getParams(this.props, this.state))); + } + + componentDidUpdate(prevProps, prevState) { + const { dispatch } = this.props; + const paramsOld = this.getParams(prevProps, prevState); + const paramsNew = this.getParams(this.props, this.state); + + if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) { + dispatch(fetchDirectory(paramsNew)); + } + } + + setRef = c => { + this.column = c; + } + + handleChangeOrder = e => { + this.setState({ order: e.target.value }); + } + + handleChangeLocal = e => { + this.setState({ local: e.target.value === '1' }); + } + + handleLoadMore = () => { + const { dispatch } = this.props; + dispatch(expandDirectory(this.getParams(this.props, this.state))); + } + + render() { + const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll, features } = this.props; + const { order, local } = this.getParams(this.props, this.state); + const pinned = !!columnId; + + const scrollableArea = ( +
+
+
+ + +
+ + {features.federating && ( +
+ + +
+ )} +
+ +
+ {accountIds.map(accountId => )} +
+ + +
+ ); + + return ( + + + + {multiColumn && !pinned ? {scrollableArea} : scrollableArea} + + ); + } + +} \ No newline at end of file diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index 9c1bbdd43..49e4c26d7 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -107,6 +107,7 @@ import { FederationRestrictions, Aliases, FollowRecommendations, + Directory, SidebarMenu, UploadArea, NotificationsContainer, @@ -277,6 +278,7 @@ class SwitchingColumnsArea extends React.PureComponent { + diff --git a/app/soapbox/features/ui/util/async-components.js b/app/soapbox/features/ui/util/async-components.js index b01582ac1..81b29c3a7 100644 --- a/app/soapbox/features/ui/util/async-components.js +++ b/app/soapbox/features/ui/util/async-components.js @@ -410,6 +410,10 @@ export function FollowRecommendations() { return import(/* webpackChunkName: "features/follow_recommendations" */'../../follow_recommendations'); } +export function Directory() { + return import(/* webpackChunkName: "features/directory" */'../../directory'); +} + export function RegisterInvite() { return import(/* webpackChunkName: "features/register_invite" */'../../register_invite'); } diff --git a/app/soapbox/reducers/user_lists.js b/app/soapbox/reducers/user_lists.js index f0d80de77..aa63c917c 100644 --- a/app/soapbox/reducers/user_lists.js +++ b/app/soapbox/reducers/user_lists.js @@ -24,6 +24,14 @@ import { MUTES_FETCH_SUCCESS, MUTES_EXPAND_SUCCESS, } from '../actions/mutes'; +import { + DIRECTORY_FETCH_REQUEST, + DIRECTORY_FETCH_SUCCESS, + DIRECTORY_FETCH_FAIL, + DIRECTORY_EXPAND_REQUEST, + DIRECTORY_EXPAND_SUCCESS, + DIRECTORY_EXPAND_FAIL, +} from '../actions/directory'; import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; import { GROUP_MEMBERS_FETCH_SUCCESS, @@ -98,6 +106,16 @@ export default function userLists(state = initialState, action) { return state.setIn(['mutes', 'items'], ImmutableOrderedSet(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); case MUTES_EXPAND_SUCCESS: return state.updateIn(['mutes', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['mutes', 'next'], action.next); + case DIRECTORY_FETCH_SUCCESS: + return state.setIn(['directory', 'items'], ImmutableOrderedSet(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false); + case DIRECTORY_EXPAND_SUCCESS: + return state.updateIn(['directory', 'items'], list => list.concat(action.accounts.map(item => item.id))).setIn(['directory', 'isLoading'], false); + case DIRECTORY_FETCH_REQUEST: + case DIRECTORY_EXPAND_REQUEST: + return state.setIn(['directory', 'isLoading'], true); + case DIRECTORY_FETCH_FAIL: + case DIRECTORY_EXPAND_FAIL: + return state.setIn(['directory', 'isLoading'], false); case GROUP_MEMBERS_FETCH_SUCCESS: return normalizeList(state, 'groups', action.id, action.accounts, action.next); case GROUP_MEMBERS_EXPAND_SUCCESS: diff --git a/app/styles/application.scss b/app/styles/application.scss index e8f054202..50347b078 100644 --- a/app/styles/application.scss +++ b/app/styles/application.scss @@ -91,6 +91,7 @@ @import 'components/profile-stats'; @import 'components/progress-circle'; @import 'components/register-invite'; +@import 'components/directory'; // Holiday @import 'holiday/halloween'; diff --git a/app/styles/components/directory.scss b/app/styles/components/directory.scss new file mode 100644 index 000000000..6c5d5cd89 --- /dev/null +++ b/app/styles/components/directory.scss @@ -0,0 +1,172 @@ +.directory { + &__list { + display: grid; + grid-gap: 10px; + grid-template-columns: minmax(0, 50%) minmax(0, 50%); + width: 100%; + padding: 10px; + transition: opacity 100ms ease-in; + box-sizing: border-box; + + &.loading { + opacity: 0.7; + } + + @media screen and (max-width: 630px) { + grid-template-columns: minmax(0, 100%); + } + } + + &__card { + box-sizing: border-box; + margin-bottom: 0; + box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.1); + border-radius: 10px; + background: var(--foreground-color); + overflow: hidden; + + &__img { + height: 125px; + position: relative; + background: var(--foreground-color); + + img { + display: block; + width: 100%; + height: 100%; + margin: 0; + object-fit: cover; + } + } + + &__bar { + display: flex; + align-items: center; + background: var(--foreground-color); + padding: 10px; + + &__name { + flex: 1 1 auto; + display: flex; + align-items: center; + text-decoration: none; + } + + &__relationship { + width: 23px; + min-height: 1px; + flex: 0 0 auto; + } + + .avatar { + flex: 0 0 auto; + width: 48px; + height: 48px; + padding-top: 2px; + + img { + width: 100%; + height: 100%; + display: block; + margin: 0; + border-radius: 4px; + background: var(--brand-color--faint); + object-fit: cover; + } + } + + .display-name { + margin-left: 15px; + text-align: left; + + strong { + font-size: 15px; + color: var(--primary-text-color); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + } + + span { + display: block; + font-size: 14px; + color: var(--primary-text-color--faint); + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + &__extra { + background: var(--foreground-color); + padding: 15px 0; + display: flex; + align-items: center; + + .accounts-table__count { + text-align: center; + font-size: 15px; + font-weight: 500; + width: 33.33%; + flex: 0 0 auto; + + small { + display: block; + color: var(--primary-text-color--faint); + font-weight: 400; + font-size: 14px; + } + } + } + } +} + +.filter-form { + display: flex; + background: var(--foreground-color); + + &__column { + padding: 10px 15px; + } + + .radio-button { + display: block; + } +} + +.radio-button { + font-size: 14px; + position: relative; + display: inline-block; + padding: 6px 0; + line-height: 18px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + + input[type=radio], + input[type=checkbox] { + display: none; + } + + &__input { + display: inline-block; + position: relative; + border: 1px solid var(--primary-text-color--faint); + box-sizing: border-box; + width: 18px; + height: 18px; + flex: 0 0 auto; + margin-right: 10px; + top: -1px; + border-radius: 50%; + vertical-align: middle; + + &.checked { + border-color: var(--brand-color); + background: var(--brand-color); + } + } +}