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;
}