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/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 new file mode 100644 index 000000000..4e04aa276 --- /dev/null +++ b/app/soapbox/features/directory/components/account_card.js @@ -0,0 +1,72 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from '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 { FormattedMessage, injectIntl } from 'react-intl'; +import { getSettings } from 'soapbox/actions/settings'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; +import ActionButton from 'soapbox/features/ui/components/action_button'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { id }) => ({ + account: getAccount(state, id), + autoPlayGif: getSettings(state).get('autoPlayGif'), + }); + + return mapStateToProps; +}; + +export default @injectIntl +@connect(makeMapStateToProps) +class AccountCard extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + autoPlayGif: PropTypes.bool, + }; + + render() { + const { account, autoPlayGif } = this.props; + + return ( +
+
+ +
+
+ +
+ +
+ + + + +
+ +
+

') && 'empty')} + dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} + /> +
+ +
+
{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..52209e9f1 --- /dev/null +++ b/app/soapbox/features/directory/index.js @@ -0,0 +1,114 @@ +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/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 { 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), + title: state.getIn(['instance', 'title']), + features: getFeatures(state.get('instance')), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Directory extends React.PureComponent { + + static propTypes = { + isLoading: PropTypes.bool, + accountIds: ImmutablePropTypes.list.isRequired, + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + title: 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, + }); + + 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)); + } + } + + 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, title, features } = this.props; + const { order, local } = this.getParams(this.props, this.state); + + return ( + +
+
+ + +
+ + {features.federating && ( +
+ + +
+ )} +
+ +
+ {accountIds.map(accountId => )} +
+ + +
+ ); + } + +} \ No newline at end of file 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/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/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 e8f054202..69308cd47 100644 --- a/app/styles/application.scss +++ b/app/styles/application.scss @@ -91,6 +91,8 @@ @import 'components/profile-stats'; @import 'components/progress-circle'; @import 'components/register-invite'; +@import 'components/radio-button'; +@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..4749eef99 --- /dev/null +++ b/app/styles/components/directory.scss @@ -0,0 +1,168 @@ +.directory { + &__filter-form { + display: flex; + background: var(--foreground-color); + + &__column { + padding: 10px 15px; + } + + .radio-button { + display: block; + } + } + + &__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; + position: relative; + + &__action-button { + z-index: 1; + position: absolute; + top: 78px; + right: 12px; + } + + &__img { + height: 125px; + position: relative; + background: var(--brand-color--med); + + img { + display: block; + width: 100%; + height: 100%; + margin: 0; + object-fit: cover; + } + } + + &__bar { + display: flex; + align-items: center; + background: var(--brand-color--med); + padding: 10px; + + &__name { + flex: 1 1 auto; + display: flex; + align-items: center; + text-decoration: none; + overflow: hidden; + } + + .account__avatar { + flex: 0 0 auto; + width: 48px; + min-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); + display: flex; + align-items: center; + justify-content: center; + + .accounts-table__count { + padding: 15px 0; + 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; + } + } + + .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 3735051e1..321cadc95 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; }