diff --git a/app/soapbox/actions/importer/normalizer.js b/app/soapbox/actions/importer/normalizer.js
index a87f92e96..6d566fd92 100644
--- a/app/soapbox/actions/importer/normalizer.js
+++ b/app/soapbox/actions/importer/normalizer.js
@@ -17,6 +17,7 @@ export function normalizeAccount(account) {
account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
account.note_emojified = emojify(account.note, emojiMap);
+ account.note_plain = unescapeHTML(account.note);
if (account.fields) {
account.fields = account.fields.map(pair => ({
diff --git a/app/soapbox/actions/suggestions_v2.js b/app/soapbox/actions/suggestions_v2.js
new file mode 100644
index 000000000..0cb33fe39
--- /dev/null
+++ b/app/soapbox/actions/suggestions_v2.js
@@ -0,0 +1,18 @@
+import api from '../api';
+import { importFetchedAccount } from './importer';
+
+export const SUGGESTIONS_V2_FETCH_REQUEST = 'SUGGESTIONS_V2_FETCH_REQUEST';
+export const SUGGESTIONS_V2_FETCH_SUCCESS = 'SUGGESTIONS_V2_FETCH_SUCCESS';
+export const SUGGESTIONS_V2_FETCH_FAIL = 'SUGGESTIONS_V2_FETCH_FAIL';
+
+export function fetchSuggestions() {
+ return (dispatch, getState) => {
+ dispatch({ type: SUGGESTIONS_V2_FETCH_REQUEST, skipLoading: true });
+ api(getState).get('/api/v2/suggestions').then(({ data: suggestions }) => {
+ suggestions.forEach(({ account }) => dispatch(importFetchedAccount(account)));
+ dispatch({ type: SUGGESTIONS_V2_FETCH_SUCCESS, suggestions, skipLoading: true });
+ }).catch(error => {
+ dispatch({ type: SUGGESTIONS_V2_FETCH_FAIL, error, skipLoading: true, skipAlert: true });
+ });
+ };
+}
diff --git a/app/soapbox/features/follow_recommendations/components/account.js b/app/soapbox/features/follow_recommendations/components/account.js
new file mode 100644
index 000000000..97b143cc0
--- /dev/null
+++ b/app/soapbox/features/follow_recommendations/components/account.js
@@ -0,0 +1,85 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+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 IconButton from 'soapbox/components/icon_button';
+import { injectIntl, defineMessages } from 'react-intl';
+import { followAccount, unfollowAccount } from 'soapbox/actions/accounts';
+
+const messages = defineMessages({
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+});
+
+const makeMapStateToProps = () => {
+ const getAccount = makeGetAccount();
+
+ const mapStateToProps = (state, props) => ({
+ account: getAccount(state, props.id),
+ });
+
+ return mapStateToProps;
+};
+
+const getFirstSentence = str => {
+ const arr = str.split(/(([.?!]+\s)|[.。?!\n•])/);
+
+ return arr[0];
+};
+
+export default @connect(makeMapStateToProps)
+@injectIntl
+class Account extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ intl: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ handleFollow = () => {
+ const { account, dispatch } = this.props;
+
+ if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
+ dispatch(unfollowAccount(account.get('id')));
+ } else {
+ dispatch(followAccount(account.get('id')));
+ }
+ }
+
+ render() {
+ const { account, intl } = this.props;
+
+ let button;
+
+ if (account.getIn(['relationship', 'following'])) {
+ button = ;
+ } else {
+ button = ;
+ }
+
+ return (
+
+
+
+
+
+
+
+ {getFirstSentence(account.get('note_plain'))}
+
+
+
+ {button}
+
+
+
+ );
+ }
+
+}
diff --git a/app/soapbox/features/follow_recommendations/index.js b/app/soapbox/features/follow_recommendations/index.js
new file mode 100644
index 000000000..129076666
--- /dev/null
+++ b/app/soapbox/features/follow_recommendations/index.js
@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { FormattedMessage } from 'react-intl';
+import { fetchSuggestions } from 'soapbox/actions/suggestions_v2';
+import Column from 'soapbox/features/ui/components/column';
+import Account from './components/account';
+import Button from 'soapbox/components/button';
+
+const mapStateToProps = state => ({
+ suggestions: state.getIn(['suggestions_v2', 'items']),
+ isLoading: state.getIn(['suggestions_v2', 'isLoading']),
+});
+
+export default @connect(mapStateToProps)
+class FollowRecommendations extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object.isRequired,
+ };
+
+ static propTypes = {
+ dispatch: PropTypes.func.isRequired,
+ suggestions: ImmutablePropTypes.list,
+ isLoading: PropTypes.bool,
+ };
+
+ componentDidMount() {
+ const { dispatch, suggestions } = this.props;
+
+ // Don't re-fetch if we're e.g. navigating backwards to this page,
+ // since we don't want followed accounts to disappear from the list
+ if (suggestions.size === 0) {
+ dispatch(fetchSuggestions(true));
+ }
+ }
+
+ handleDone = () => {
+ const { router } = this.context;
+
+ router.history.push('/');
+ }
+
+ render() {
+ const { suggestions, isLoading } = this.props;
+
+ return (
+
+
+
+
+ {!isLoading && (
+ <>
+
+ {suggestions.size > 0 ? suggestions.map(suggestion => (
+
+ )) : (
+
+
+
+ )}
+
+
+
+
+
+ >
+ )}
+
+
+ );
+ }
+
+}
diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js
index af09692bb..80b3b314d 100644
--- a/app/soapbox/features/ui/index.js
+++ b/app/soapbox/features/ui/index.js
@@ -106,6 +106,7 @@ import {
UserIndex,
FederationRestrictions,
Aliases,
+ FollowRecommendations,
} from './util/async-components';
// Dummy import, to make sure that ends up in the application bundle.
@@ -250,6 +251,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 a0d1e0f36..d7543e842 100644
--- a/app/soapbox/features/ui/util/async-components.js
+++ b/app/soapbox/features/ui/util/async-components.js
@@ -281,3 +281,7 @@ export function Aliases() {
export function ScheduleForm() {
return import(/* webpackChunkName: "features/compose" */'../../compose/components/schedule_form');
}
+
+export function FollowRecommendations() {
+ return import(/* webpackChunkName: "features/follow_recommendations" */'../../follow_recommendations');
+}
diff --git a/app/soapbox/reducers/index.js b/app/soapbox/reducers/index.js
index 5c050b133..855370a31 100644
--- a/app/soapbox/reducers/index.js
+++ b/app/soapbox/reducers/index.js
@@ -31,6 +31,7 @@ import listAdder from './list_adder';
import filters from './filters';
import conversations from './conversations';
import suggestions from './suggestions';
+import suggestions_v2 from './suggestions_v2';
import polls from './polls';
import identity_proofs from './identity_proofs';
import trends from './trends';
@@ -88,6 +89,7 @@ const appReducer = combineReducers({
filters,
conversations,
suggestions,
+ suggestions_v2,
polls,
trends,
groups,
diff --git a/app/soapbox/reducers/suggestions_v2.js b/app/soapbox/reducers/suggestions_v2.js
new file mode 100644
index 000000000..da06d4e1b
--- /dev/null
+++ b/app/soapbox/reducers/suggestions_v2.js
@@ -0,0 +1,37 @@
+import {
+ SUGGESTIONS_V2_FETCH_REQUEST,
+ SUGGESTIONS_V2_FETCH_SUCCESS,
+ SUGGESTIONS_V2_FETCH_FAIL,
+} from '../actions/suggestions_v2';
+import { SUGGESTIONS_DISMISS } from '../actions/suggestions';
+import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'soapbox/actions/accounts';
+import { DOMAIN_BLOCK_SUCCESS } from 'soapbox/actions/domain_blocks';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+ items: ImmutableList(),
+ isLoading: false,
+});
+
+export default function suggestionsReducer(state = initialState, action) {
+ switch(action.type) {
+ case SUGGESTIONS_V2_FETCH_REQUEST:
+ return state.set('isLoading', true);
+ case SUGGESTIONS_V2_FETCH_SUCCESS:
+ return state.withMutations(map => {
+ map.set('items', fromJS(action.suggestions.map(x => ({ ...x, account: x.account.id }))));
+ map.set('isLoading', false);
+ });
+ case SUGGESTIONS_V2_FETCH_FAIL:
+ return state.set('isLoading', false);
+ case SUGGESTIONS_DISMISS:
+ return state.update('items', list => list.filterNot(x => x.account === action.id));
+ case ACCOUNT_BLOCK_SUCCESS:
+ case ACCOUNT_MUTE_SUCCESS:
+ return state.update('items', list => list.filterNot(x => x.account === action.relationship.id));
+ case DOMAIN_BLOCK_SUCCESS:
+ return state.update('items', list => list.filterNot(x => action.accounts.includes(x.account)));
+ default:
+ return state;
+ }
+}
diff --git a/app/soapbox/utils/features.js b/app/soapbox/utils/features.js
index 3d9d7260b..2ae45ccbb 100644
--- a/app/soapbox/utils/features.js
+++ b/app/soapbox/utils/features.js
@@ -10,6 +10,7 @@ export const getFeatures = createSelector([
], (v, features, federation) => {
return {
suggestions: v.software === 'Mastodon' && gte(v.compatVersion, '2.4.3'),
+ suggestionsV2: v.software === 'Mastodon' && gte(v.compatVersion, '3.4.0'),
trends: v.software === 'Mastodon' && gte(v.compatVersion, '3.0.0'),
emojiReacts: v.software === 'Pleroma' && gte(v.version, '2.0.0'),
emojiReactsRGI: v.software === 'Pleroma' && gte(v.version, '2.2.49'),
diff --git a/app/styles/accounts.scss b/app/styles/accounts.scss
index 2138a66f6..e31514b07 100644
--- a/app/styles/accounts.scss
+++ b/app/styles/accounts.scss
@@ -206,6 +206,13 @@
display: flex;
}
}
+
+ &__note {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--primary-text-color--faint);
+ }
}
.account__wrapper {
diff --git a/app/styles/components/columns.scss b/app/styles/components/columns.scss
index 205546845..ee909d686 100644
--- a/app/styles/components/columns.scss
+++ b/app/styles/components/columns.scss
@@ -768,3 +768,45 @@
}
}
}
+
+.column-title {
+ text-align: center;
+ padding: 40px;
+
+ .logo {
+ fill: var(--primary-text-color);
+ width: 50px;
+ margin: 0 auto;
+ margin-bottom: 40px;
+ }
+
+ h3 {
+ font-size: 24px;
+ line-height: 1.5;
+ font-weight: 700;
+ margin-bottom: 10px;
+ }
+
+ p {
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+ color: var(--primary-text-color--faint);
+ }
+}
+
+.column-actions {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 40px;
+ padding-top: 40px;
+
+ &__background {
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ height: 220px;
+ width: auto;
+ }
+}