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