diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index dca44917a5..087f264910 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -24,6 +24,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/javascript/mastodon/actions/onboarding.js b/app/javascript/mastodon/actions/onboarding.js index 42d8ea33fd..a1dd3a731e 100644 --- a/app/javascript/mastodon/actions/onboarding.js +++ b/app/javascript/mastodon/actions/onboarding.js @@ -1,21 +1,8 @@ import { changeSetting, saveSettings } from './settings'; -import { requestBrowserPermission } from './notifications'; export const INTRODUCTION_VERSION = 20181216044202; export const closeOnboarding = () => dispatch => { dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); dispatch(saveSettings()); - - dispatch(requestBrowserPermission((permission) => { - if (permission === 'granted') { - dispatch(changeSetting(['notifications', 'alerts', 'follow'], true)); - dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true)); - dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true)); - dispatch(changeSetting(['notifications', 'alerts', 'mention'], true)); - dispatch(changeSetting(['notifications', 'alerts', 'poll'], true)); - dispatch(changeSetting(['notifications', 'alerts', 'status'], true)); - dispatch(saveSettings()); - } - })); }; diff --git a/app/javascript/mastodon/actions/suggestions.js b/app/javascript/mastodon/actions/suggestions.js index 336c0c8f65..e3a5497597 100644 --- a/app/javascript/mastodon/actions/suggestions.js +++ b/app/javascript/mastodon/actions/suggestions.js @@ -1,5 +1,6 @@ import api from '../api'; import { importFetchedAccounts } from './importer'; +import { fetchRelationships } from './accounts'; export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST'; export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS'; @@ -7,13 +8,17 @@ export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL'; export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS'; -export function fetchSuggestions() { +export function fetchSuggestions(withRelationships = false) { return (dispatch, getState) => { dispatch(fetchSuggestionsRequest()); api(getState).get('/api/v2/suggestions').then(response => { dispatch(importFetchedAccounts(response.data.map(x => x.account))); dispatch(fetchSuggestionsSuccess(response.data)); + + if (withRelationships) { + dispatch(fetchRelationships(response.data.map(item => item.account.id))); + } }).catch(error => dispatch(fetchSuggestionsFail(error))); }; }; diff --git a/app/javascript/mastodon/components/logo.js b/app/javascript/mastodon/components/logo.js new file mode 100644 index 0000000000..d1c7f08a91 --- /dev/null +++ b/app/javascript/mastodon/components/logo.js @@ -0,0 +1,9 @@ +import React from 'react'; + +const Logo = () => ( + + + +); + +export default Logo; diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 3ac58cf7c5..513b599087 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -1,12 +1,10 @@ import React from 'react'; -import { Provider, connect } from 'react-redux'; +import { Provider } from 'react-redux'; import PropTypes from 'prop-types'; import configureStore from '../store/configureStore'; -import { INTRODUCTION_VERSION } from '../actions/onboarding'; import { BrowserRouter, Route } from 'react-router-dom'; import { ScrollContext } from 'react-router-scroll-4'; import UI from '../features/ui'; -import Introduction from '../features/introduction'; import { fetchCustomEmojis } from '../actions/custom_emojis'; import { hydrateStore } from '../actions/store'; import { connectUserStream } from '../actions/streaming'; @@ -26,39 +24,6 @@ const hydrateAction = hydrateStore(initialState); store.dispatch(hydrateAction); store.dispatch(fetchCustomEmojis()); -const mapStateToProps = state => ({ - showIntroduction: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION, -}); - -@connect(mapStateToProps) -class MastodonMount extends React.PureComponent { - - static propTypes = { - showIntroduction: PropTypes.bool, - }; - - shouldUpdateScroll (_, { location }) { - return location.state !== previewMediaState && location.state !== previewVideoState; - } - - render () { - const { showIntroduction } = this.props; - - if (showIntroduction) { - return ; - } - - return ( - - - - - - ); - } - -} - export default class Mastodon extends React.PureComponent { static propTypes = { @@ -76,6 +41,10 @@ export default class Mastodon extends React.PureComponent { } } + shouldUpdateScroll (_, { location }) { + return location.state !== previewMediaState && location.state !== previewVideoState; + } + render () { const { locale } = this.props; @@ -83,7 +52,11 @@ export default class Mastodon extends React.PureComponent { - + + + + + diff --git a/app/javascript/mastodon/features/follow_recommendations/components/account.js b/app/javascript/mastodon/features/follow_recommendations/components/account.js new file mode 100644 index 0000000000..23c03d1ff6 --- /dev/null +++ b/app/javascript/mastodon/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 'mastodon/selectors'; +import Avatar from 'mastodon/components/avatar'; +import DisplayName from 'mastodon/components/display_name'; +import Permalink from 'mastodon/components/permalink'; +import IconButton from 'mastodon/components/icon_button'; +import { injectIntl, defineMessages } from 'react-intl'; +import { followAccount, unfollowAccount } from 'mastodon/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)|[.。?!])/); + + 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/javascript/mastodon/features/follow_recommendations/index.js b/app/javascript/mastodon/features/follow_recommendations/index.js new file mode 100644 index 0000000000..1231a27eab --- /dev/null +++ b/app/javascript/mastodon/features/follow_recommendations/index.js @@ -0,0 +1,95 @@ +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 'mastodon/actions/suggestions'; +import { changeSetting, saveSettings } from 'mastodon/actions/settings'; +import { requestBrowserPermission } from 'mastodon/actions/notifications'; +import Column from 'mastodon/features/ui/components/column'; +import Account from './components/account'; +import Logo from 'mastodon/components/logo'; +import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg'; +import Button from 'mastodon/components/button'; + +const mapStateToProps = state => ({ + suggestions: state.getIn(['suggestions', 'items']), + isLoading: state.getIn(['suggestions', '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 { dispatch } = this.props; + const { router } = this.context; + + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changeSetting(['notifications', 'alerts', 'follow'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'mention'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'poll'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'status'], true)); + dispatch(saveSettings()); + } + })); + + router.history.push('/timelines/home'); + } + + render () { + const { suggestions, isLoading } = this.props; + + return ( + +
+
+ +

+

+
+ + {!isLoading && ( + +
+ {suggestions.map(suggestion => ( + + ))} +
+ +
+ + +
+
+ )} +
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 507ac1df14..078a69f0f3 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -51,10 +51,12 @@ import { Lists, Search, Directory, + FollowRecommendations, } from './util/async-components'; import { me } from '../../initial_state'; import { previewState as previewMediaState } from './components/media_modal'; import { previewState as previewVideoState } from './components/video_modal'; +import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding'; // Dummy import, to make sure that ends up in the application bundle. // Without this it ends up in ~8 very commonly used bundles. @@ -71,6 +73,7 @@ const mapStateToProps = state => ({ hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0, canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4, dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null, + firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION, }); const keyMap = { @@ -167,6 +170,7 @@ class SwitchingColumnsArea extends React.PureComponent { + @@ -215,6 +219,7 @@ class UI extends React.PureComponent { intl: PropTypes.object.isRequired, dropdownMenuIsOpen: PropTypes.bool, layout: PropTypes.string.isRequired, + firstLaunch: PropTypes.bool, }; state = { @@ -350,6 +355,12 @@ class UI extends React.PureComponent { navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); } + // On first launch, redirect to the follow recommendations page + if (this.props.firstLaunch) { + this.context.router.history.replace('/start'); + this.props.dispatch(closeOnboarding()); + } + this.props.dispatch(fetchMarkers()); this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandNotifications()); diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 986efda1ec..aa90b226ad 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -153,3 +153,7 @@ export function Audio () { export function Directory () { return import(/* webpackChunkName: "features/directory" */'../../directory'); } + +export function FollowRecommendations () { + return import(/* webpackChunkName: "features/follow_recommendations" */'../../follow_recommendations'); +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 49432b8644..f151224ae9 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1307,6 +1307,29 @@ overflow: hidden; text-decoration: none; font-size: 14px; + + &--with-note { + strong { + display: inline; + } + } + } + + &__note { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: $ui-secondary-color; + } +} + +.follow-recommendations-account { + .icon-button { + color: $ui-primary-color; + + &.active { + color: $valid-value-color; + } } } @@ -2459,6 +2482,49 @@ a.account__display-name { border-color: darken($ui-base-color, 8%); } +.column-title { + text-align: center; + padding: 40px; + + .logo { + fill: $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: $darker-text-color; + } +} + +.column-actions { + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + padding-top: 40px; + padding-bottom: 200px; + + &__background { + position: absolute; + left: 0; + bottom: 0; + height: 220px; + width: auto; + } +} + .compose-panel { width: 285px; margin-top: 10px;