Merge branch 'suggestions-v2' into 'develop'
SuggestionsV2: pull in Mastodon's upstream code See merge request soapbox-pub/soapbox-fe!765
This commit is contained in:
commit
34dce143e5
11 changed files with 278 additions and 0 deletions
|
@ -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 => ({
|
||||
|
|
18
app/soapbox/actions/suggestions_v2.js
Normal file
18
app/soapbox/actions/suggestions_v2.js
Normal file
|
@ -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 });
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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 = <IconButton icon='check' title={intl.formatMessage(messages.unfollow)} active onClick={this.handleFollow} />;
|
||||
} else {
|
||||
button = <IconButton icon='plus' title={intl.formatMessage(messages.follow)} onClick={this.handleFollow} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account follow-recommendations-account'>
|
||||
<div className='account__wrapper'>
|
||||
<Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
|
||||
<DisplayName account={account} />
|
||||
|
||||
<div className='account__note'>{getFirstSentence(account.get('note_plain'))}</div>
|
||||
</Permalink>
|
||||
|
||||
<div className='account__relationship'>
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
79
app/soapbox/features/follow_recommendations/index.js
Normal file
79
app/soapbox/features/follow_recommendations/index.js
Normal file
|
@ -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 (
|
||||
<Column>
|
||||
<div className='scrollable follow-recommendations-container'>
|
||||
<div className='column-title'>
|
||||
<h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3>
|
||||
<p><FormattedMessage id='follow_recommendations.lead' defaultMessage="Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!" /></p>
|
||||
</div>
|
||||
|
||||
{!isLoading && (
|
||||
<>
|
||||
<div className='column-list'>
|
||||
{suggestions.size > 0 ? suggestions.map(suggestion => (
|
||||
<Account key={suggestion.get('account')} id={suggestion.get('account')} />
|
||||
)) : (
|
||||
<div className='column-list__empty-message'>
|
||||
<FormattedMessage id='empty_column.follow_recommendations' defaultMessage='Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='column-actions'>
|
||||
<Button onClick={this.handleDone}><FormattedMessage id='follow_recommendations.done' defaultMessage='Done' /></Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -106,6 +106,7 @@ import {
|
|||
UserIndex,
|
||||
FederationRestrictions,
|
||||
Aliases,
|
||||
FollowRecommendations,
|
||||
} from './util/async-components';
|
||||
|
||||
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
||||
|
@ -250,6 +251,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||
<WrappedRoute path='/notifications' page={DefaultPage} component={Notifications} content={children} />
|
||||
|
||||
<WrappedRoute path='/search' publicRoute page={DefaultPage} component={Search} content={children} />
|
||||
<WrappedRoute path='/suggestions' publicRoute page={DefaultPage} component={FollowRecommendations} content={children} />
|
||||
|
||||
<WrappedRoute path='/chats' exact page={DefaultPage} component={ChatIndex} content={children} />
|
||||
<WrappedRoute path='/chats/:chatId' page={DefaultPage} component={ChatRoom} content={children} />
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
37
app/soapbox/reducers/suggestions_v2.js
Normal file
37
app/soapbox/reducers/suggestions_v2.js
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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'),
|
||||
|
|
|
@ -206,6 +206,13 @@
|
|||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
&__note {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--primary-text-color--faint);
|
||||
}
|
||||
}
|
||||
|
||||
.account__wrapper {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue