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 1/3] 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); + } + } +} From a829f429f751daacfc934d14ea5c6b771e8538d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 25 Dec 2021 01:17:14 +0100 Subject: [PATCH 2/3] use instance title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/directory/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/soapbox/features/directory/index.js b/app/soapbox/features/directory/index.js index 408c85371..a28c62c9e 100644 --- a/app/soapbox/features/directory/index.js +++ b/app/soapbox/features/directory/index.js @@ -25,7 +25,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()), isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true), - domain: state.getIn(['meta', 'domain']), + title: state.getIn(['instance', 'title']), features: getFeatures(state.get('instance')), }); @@ -45,7 +45,7 @@ class Directory extends React.PureComponent { columnId: PropTypes.string, intl: PropTypes.object.isRequired, multiColumn: PropTypes.bool, - domain: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, params: PropTypes.shape({ order: PropTypes.string, local: PropTypes.bool, @@ -100,7 +100,7 @@ class Directory extends React.PureComponent { } render() { - const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll, features } = this.props; + const { isLoading, accountIds, intl, columnId, multiColumn, title, shouldUpdateScroll, features } = this.props; const { order, local } = this.getParams(this.props, this.state); const pinned = !!columnId; @@ -114,7 +114,7 @@ class Directory extends React.PureComponent { {features.federating && (
- +
)} From e3d2b44bdc4921e3bdf9267773cb90b083d2bb68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 25 Dec 2021 18:21:20 +0100 Subject: [PATCH 3/3] Profile directory: styles, cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/sidebar_menu.js | 5 + .../directory/components/account_card.js | 118 +++--------------- app/soapbox/features/directory/index.js | 48 ++----- .../features/ui/components/link_footer.js | 5 +- app/soapbox/utils/features.js | 4 + app/styles/application.scss | 1 + app/styles/components/directory.scss | 114 ++++++++--------- app/styles/components/radio-button.scss | 35 ++++++ app/styles/components/sidebar-menu.scss | 2 +- 9 files changed, 127 insertions(+), 205 deletions(-) create mode 100644 app/styles/components/radio-button.scss diff --git a/app/soapbox/components/sidebar_menu.js b/app/soapbox/components/sidebar_menu.js index e16c62405..ea4e2ef94 100644 --- a/app/soapbox/components/sidebar_menu.js +++ b/app/soapbox/components/sidebar_menu.js @@ -41,6 +41,7 @@ const messages = defineMessages({ logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, lists: { id: 'column.lists', defaultMessage: 'Lists' }, bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, + profileDirectory: { id: 'column.profile_directory', defaultMessage: 'Profile directory' }, header: { id: 'tabs_bar.header', defaultMessage: 'Account Info' }, apps: { id: 'tabs_bar.apps', defaultMessage: 'Apps' }, news: { id: 'tabs_bar.news', defaultMessage: 'News' }, @@ -253,6 +254,10 @@ class SidebarMenu extends ImmutablePureComponent { {intl.formatMessage(messages.bookmarks)} } + {features.profileDirectory && + + {intl.formatMessage(messages.profileDirectory)} + }
diff --git a/app/soapbox/features/directory/components/account_card.js b/app/soapbox/features/directory/components/account_card.js index 95a8264dc..4e04aa276 100644 --- a/app/soapbox/features/directory/components/account_card.js +++ b/app/soapbox/features/directory/components/account_card.js @@ -2,29 +2,17 @@ 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 classNames from 'classnames'; 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 { FormattedMessage, injectIntl } 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}' }, -}); +import ActionButton from 'soapbox/features/ui/components/action_button'; const makeMapStateToProps = () => { const getAccount = makeGetAccount(); @@ -32,121 +20,45 @@ const makeMapStateToProps = () => { 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) +@connect(makeMapStateToProps) 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 = ; - } - } + const { account, autoPlayGif } = this.props; return (
+
+ +
- + - -
- {buttons} -
- {account.get('note').length > 0 && account.get('note') !== '

' && ( -
-
-
- )} +
+

') && 'empty')} + dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} + /> +
{shortNumberFormat(account.get('statuses_count'))}
diff --git a/app/soapbox/features/directory/index.js b/app/soapbox/features/directory/index.js index a28c62c9e..52209e9f1 100644 --- a/app/soapbox/features/directory/index.js +++ b/app/soapbox/features/directory/index.js @@ -3,15 +3,13 @@ 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 Column from 'soapbox/features/ui/components/column'; 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({ @@ -33,18 +31,11 @@ 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, title: PropTypes.string.isRequired, params: PropTypes.shape({ order: PropTypes.string, @@ -63,10 +54,6 @@ class Directory extends React.PureComponent { 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))); @@ -82,10 +69,6 @@ class Directory extends React.PureComponent { } } - setRef = c => { - this.column = c; - } - handleChangeOrder = e => { this.setState({ order: e.target.value }); } @@ -100,20 +83,19 @@ class Directory extends React.PureComponent { } render() { - const { isLoading, accountIds, intl, columnId, multiColumn, title, shouldUpdateScroll, features } = this.props; + const { isLoading, accountIds, intl, title, features } = this.props; const { order, local } = this.getParams(this.props, this.state); - const pinned = !!columnId; - const scrollableArea = ( -
-
-
+ return ( + +
+
{features.federating && ( -
+
@@ -125,22 +107,6 @@ class Directory extends React.PureComponent {
-
- ); - - return ( - - - - {multiColumn && !pinned ? {scrollableArea} : scrollableArea} ); } diff --git a/app/soapbox/features/ui/components/link_footer.js b/app/soapbox/features/ui/components/link_footer.js index ed89dc8dd..f85caf09a 100644 --- a/app/soapbox/features/ui/components/link_footer.js +++ b/app/soapbox/features/ui/components/link_footer.js @@ -18,6 +18,7 @@ const mapStateToProps = state => { return { account, + profileDirectory: features.profileDirectory, federating: features.federating, showAliases: features.accountAliasesAPI, importAPI: features.importAPI, @@ -35,10 +36,11 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ }, }); -const LinkFooter = ({ onOpenHotkeys, account, federating, showAliases, importAPI, onClickLogOut, baseURL }) => ( +const LinkFooter = ({ onOpenHotkeys, account, profileDirectory, federating, showAliases, importAPI, onClickLogOut, baseURL }) => (
    {account && <> + {profileDirectory &&
  • }
  • @@ -75,6 +77,7 @@ const LinkFooter = ({ onOpenHotkeys, account, federating, showAliases, importAPI LinkFooter.propTypes = { account: ImmutablePropTypes.map, + profileDirectory: PropTypes.bool, federating: PropTypes.bool, showAliases: PropTypes.bool, importAPI: PropTypes.bool, diff --git a/app/soapbox/utils/features.js b/app/soapbox/utils/features.js index c544e7db5..8e7a695b6 100644 --- a/app/soapbox/utils/features.js +++ b/app/soapbox/utils/features.js @@ -66,6 +66,10 @@ export const getFeatures = createSelector([ accountSubscriptions: v.software === PLEROMA && gte(v.version, '1.0.0'), unrestrictedLists: v.software === PLEROMA, accountByUsername: v.software === PLEROMA, + profileDirectory: any([ + v.software === MASTODON && gte(v.compatVersion, '3.0.0'), + features.includes('profile_directory'), + ]), }; }); diff --git a/app/styles/application.scss b/app/styles/application.scss index 50347b078..69308cd47 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/radio-button'; @import 'components/directory'; // Holiday diff --git a/app/styles/components/directory.scss b/app/styles/components/directory.scss index 6c5d5cd89..4749eef99 100644 --- a/app/styles/components/directory.scss +++ b/app/styles/components/directory.scss @@ -1,4 +1,17 @@ .directory { + &__filter-form { + display: flex; + background: var(--foreground-color); + + &__column { + padding: 10px 15px; + } + + .radio-button { + display: block; + } + } + &__list { display: grid; grid-gap: 10px; @@ -24,11 +37,19 @@ border-radius: 10px; background: var(--foreground-color); overflow: hidden; + position: relative; + + &__action-button { + z-index: 1; + position: absolute; + top: 78px; + right: 12px; + } &__img { height: 125px; position: relative; - background: var(--foreground-color); + background: var(--brand-color--med); img { display: block; @@ -42,7 +63,7 @@ &__bar { display: flex; align-items: center; - background: var(--foreground-color); + background: var(--brand-color--med); padding: 10px; &__name { @@ -50,17 +71,13 @@ display: flex; align-items: center; text-decoration: none; + overflow: hidden; } - &__relationship { - width: 23px; - min-height: 1px; - flex: 0 0 auto; - } - - .avatar { + .account__avatar { flex: 0 0 auto; width: 48px; + min-width: 48px; height: 48px; padding-top: 2px; @@ -100,11 +117,12 @@ &__extra { background: var(--foreground-color); - padding: 15px 0; display: flex; align-items: center; + justify-content: center; .accounts-table__count { + padding: 15px 0; text-align: center; font-size: 15px; font-weight: 500; @@ -118,55 +136,33 @@ 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); + + .account__header__content { + box-sizing: border-box; + padding: 15px 10px; + border-bottom: 1px solid var(--brand-color--med); + width: 100%; + min-height: 50px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.empty { + border-color: transparent; + } + + p { + display: none; + + &:first-child { + display: inline; + } + } + + br { + display: none; + } + } } } } diff --git a/app/styles/components/radio-button.scss b/app/styles/components/radio-button.scss new file mode 100644 index 000000000..d1a303f3f --- /dev/null +++ b/app/styles/components/radio-button.scss @@ -0,0 +1,35 @@ +.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); + } + } +} diff --git a/app/styles/components/sidebar-menu.scss b/app/styles/components/sidebar-menu.scss index b69e22db6..10486f6bf 100644 --- a/app/styles/components/sidebar-menu.scss +++ b/app/styles/components/sidebar-menu.scss @@ -155,7 +155,7 @@ > .fa { width: 24px; - font-size: 20px; + font-size: 28px; margin-right: 15px; text-align: center; }