diff --git a/app/javascript/images/elephant_ui_conversation.svg b/app/javascript/images/elephant_ui_conversation.svg new file mode 100644 index 0000000000..f849b59592 --- /dev/null +++ b/app/javascript/images/elephant_ui_conversation.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 9615032875..b7c9e63575 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -74,6 +74,7 @@ export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTIO export const COMPOSE_CHANGE_MEDIA_FOCUS = 'COMPOSE_CHANGE_MEDIA_FOCUS'; export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS'; +export const COMPOSE_FOCUS = 'COMPOSE_FOCUS'; const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, @@ -125,6 +126,14 @@ export function resetCompose() { }; } +export const focusCompose = routerHistory => dispatch => { + dispatch({ + type: COMPOSE_FOCUS, + }); + + ensureComposeIsVisible(routerHistory); +}; + export function mentionCompose(account, routerHistory) { return (dispatch, getState) => { dispatch({ diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx index a8a47ecacb..0e2295e3a9 100644 --- a/app/javascript/mastodon/components/account.jsx +++ b/app/javascript/mastodon/components/account.jsx @@ -12,8 +12,8 @@ import Skeleton from 'mastodon/components/skeleton'; import { Link } from 'react-router-dom'; import { counterRenderer } from 'mastodon/components/common_counter'; import ShortNumber from 'mastodon/components/short_number'; -import Icon from 'mastodon/components/icon'; import classNames from 'classnames'; +import VerifiedBadge from 'mastodon/components/verified_badge'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -27,26 +27,6 @@ const messages = defineMessages({ block: { id: 'account.block', defaultMessage: 'Block @{name}' }, }); -class VerifiedBadge extends React.PureComponent { - - static propTypes = { - link: PropTypes.string.isRequired, - verifiedAt: PropTypes.string.isRequired, - }; - - render () { - const { link } = this.props; - - return ( - - - - - ); - } - -} - class Account extends ImmutablePureComponent { static propTypes = { diff --git a/app/javascript/mastodon/components/check.jsx b/app/javascript/mastodon/components/check.jsx index ee2ef1595a..2fd0af7401 100644 --- a/app/javascript/mastodon/components/check.jsx +++ b/app/javascript/mastodon/components/check.jsx @@ -1,8 +1,8 @@ import React from 'react'; const Check = () => ( - - + + ); diff --git a/app/javascript/mastodon/components/column_back_button.jsx b/app/javascript/mastodon/components/column_back_button.jsx index 5c5226b7ea..12926bb253 100644 --- a/app/javascript/mastodon/components/column_back_button.jsx +++ b/app/javascript/mastodon/components/column_back_button.jsx @@ -12,13 +12,19 @@ export default class ColumnBackButton extends React.PureComponent { static propTypes = { multiColumn: PropTypes.bool, + onClick: PropTypes.func, }; handleClick = () => { - if (window.history && window.history.state) { - this.context.router.history.goBack(); + const { router } = this.context; + const { onClick } = this.props; + + if (onClick) { + onClick(); + } else if (window.history && window.history.state) { + router.history.goBack(); } else { - this.context.router.history.push('/'); + router.history.push('/'); } }; diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 60a77a39c3..f1dba566be 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -520,6 +520,7 @@ class Status extends ImmutablePureComponent { {prepend}
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
diff --git a/app/javascript/mastodon/components/verified_badge.jsx b/app/javascript/mastodon/components/verified_badge.jsx new file mode 100644 index 0000000000..3d878d5dd1 --- /dev/null +++ b/app/javascript/mastodon/components/verified_badge.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'mastodon/components/icon'; + +class VerifiedBadge extends React.PureComponent { + + static propTypes = { + link: PropTypes.string.isRequired, + verifiedAt: PropTypes.string.isRequired, + }; + + render () { + const { link } = this.props; + + return ( + + + + + ); + } + +} + +export default VerifiedBadge; \ No newline at end of file diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index 4a8f403016..ffcef83c48 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -20,6 +20,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { length } from 'stringz'; import { countableText } from '../util/counter'; import Icon from 'mastodon/components/icon'; +import classNames from 'classnames'; const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; @@ -70,6 +71,10 @@ class ComposeForm extends ImmutablePureComponent { autoFocus: false, }; + state = { + highlighted: false, + }; + handleChange = (e) => { this.props.onChange(e.target.value); }; @@ -143,6 +148,10 @@ class ComposeForm extends ImmutablePureComponent { this._updateFocusAndSelection({ }); } + componentWillUnmount () { + if (this.timeout) clearTimeout(this.timeout); + } + componentDidUpdate (prevProps) { this._updateFocusAndSelection(prevProps); } @@ -173,6 +182,8 @@ class ComposeForm extends ImmutablePureComponent { Promise.resolve().then(() => { this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); this.autosuggestTextarea.textarea.focus(); + this.setState({ highlighted: true }); + this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700); }).catch(console.error); } else if(prevProps.isSubmitting && !this.props.isSubmitting) { this.autosuggestTextarea.textarea.focus(); @@ -207,6 +218,7 @@ class ComposeForm extends ImmutablePureComponent { render () { const { intl, onPaste, autoFocus } = this.props; + const { highlighted } = this.state; const disabled = this.props.isSubmitting; let publishText = ''; @@ -245,41 +257,43 @@ class ComposeForm extends ImmutablePureComponent { />
- - - -
- - -
-
- -
-
- - - - - -
- -
- +
+ + + +
+ + +
+
+ +
+
+ + + + + +
+ +
+ +
diff --git a/app/javascript/mastodon/features/follow_recommendations/components/account.jsx b/app/javascript/mastodon/features/follow_recommendations/components/account.jsx deleted file mode 100644 index 9cb26fe645..0000000000 --- a/app/javascript/mastodon/features/follow_recommendations/components/account.jsx +++ /dev/null @@ -1,85 +0,0 @@ -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 { Link } from 'react-router-dom'; -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)|[.。?!\n•])/); - - return arr[0]; -}; - -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} -
-
-
- ); - } - -} - -export default connect(makeMapStateToProps)(injectIntl(Account)); diff --git a/app/javascript/mastodon/features/follow_recommendations/index.jsx b/app/javascript/mastodon/features/follow_recommendations/index.jsx deleted file mode 100644 index 7ba34b51f4..0000000000 --- a/app/javascript/mastodon/features/follow_recommendations/index.jsx +++ /dev/null @@ -1,117 +0,0 @@ -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 { markAsPartial } from 'mastodon/actions/timelines'; -import Column from 'mastodon/features/ui/components/column'; -import Account from './components/account'; -import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg'; -import Button from 'mastodon/components/button'; -import { Helmet } from 'react-helmet'; - -const mapStateToProps = state => ({ - suggestions: state.getIn(['suggestions', 'items']), - isLoading: state.getIn(['suggestions', 'isLoading']), -}); - -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)); - } - } - - componentWillUnmount () { - const { dispatch } = this.props; - - // Force the home timeline to be reloaded when the user navigates - // to it; if the user is new, it would've been empty before - - dispatch(markAsPartial('home')); - } - - 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('/home'); - }; - - render () { - const { suggestions, isLoading } = this.props; - - return ( - -
-
- - - - -

-

-
- - {!isLoading && ( - -
- {suggestions.size > 0 ? suggestions.map(suggestion => ( - - )) : ( -
- -
- )} -
- -
- - -
-
- )} -
- - - - -
- ); - } - -} - -export default connect(mapStateToProps)(FollowRecommendations); diff --git a/app/javascript/mastodon/features/onboarding/components/arrow_small_right.jsx b/app/javascript/mastodon/features/onboarding/components/arrow_small_right.jsx new file mode 100644 index 0000000000..40e166f6dc --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/components/arrow_small_right.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +const ArrowSmallRight = () => ( + + + +); + +export default ArrowSmallRight; \ No newline at end of file diff --git a/app/javascript/mastodon/features/onboarding/components/progress_indicator.jsx b/app/javascript/mastodon/features/onboarding/components/progress_indicator.jsx new file mode 100644 index 0000000000..97134c0c9c --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/components/progress_indicator.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Check from 'mastodon/components/check'; +import classNames from 'classnames'; + +const ProgressIndicator = ({ steps, completed }) => ( +
+ {(new Array(steps)).fill().map((_, i) => ( + + {i > 0 &&
i })} />} + +
i })}> + {completed > i && } +
+ + ))} +
+); + +ProgressIndicator.propTypes = { + steps: PropTypes.number.isRequired, + completed: PropTypes.number, +}; + +export default ProgressIndicator; \ No newline at end of file diff --git a/app/javascript/mastodon/features/onboarding/components/step.jsx b/app/javascript/mastodon/features/onboarding/components/step.jsx new file mode 100644 index 0000000000..6f376e5d55 --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/components/step.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'mastodon/components/icon'; +import Check from 'mastodon/components/check'; + +const Step = ({ label, description, icon, completed, onClick, href }) => { + const content = ( + <> +
+ +
+ +
+
{label}
+

{description}

+
+ + {completed && ( +
+ +
+ )} + + ); + + if (href) { + return ( +
+ {content} + + ); + } + + return ( + + ); +}; + +Step.propTypes = { + label: PropTypes.node, + description: PropTypes.node, + icon: PropTypes.string, + completed: PropTypes.bool, + href: PropTypes.string, + onClick: PropTypes.func, +}; + +export default Step; \ No newline at end of file diff --git a/app/javascript/mastodon/features/onboarding/follows.jsx b/app/javascript/mastodon/features/onboarding/follows.jsx new file mode 100644 index 0000000000..c42daf2ff1 --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/follows.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import Column from 'mastodon/components/column'; +import ColumnBackButton from 'mastodon/components/column_back_button'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { fetchSuggestions } from 'mastodon/actions/suggestions'; +import { markAsPartial } from 'mastodon/actions/timelines'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Account from 'mastodon/containers/account_container'; +import EmptyAccount from 'mastodon/components/account'; +import { FormattedMessage, FormattedHTMLMessage } from 'react-intl'; +import { makeGetAccount } from 'mastodon/selectors'; +import { me } from 'mastodon/initial_state'; +import ProgressIndicator from './components/progress_indicator'; + +const mapStateToProps = () => { + const getAccount = makeGetAccount(); + + return state => ({ + account: getAccount(state, me), + suggestions: state.getIn(['suggestions', 'items']), + isLoading: state.getIn(['suggestions', 'isLoading']), + }); +}; + +class Follows extends React.PureComponent { + + static propTypes = { + onBack: PropTypes.func, + dispatch: PropTypes.func.isRequired, + suggestions: ImmutablePropTypes.list, + account: ImmutablePropTypes.map, + isLoading: PropTypes.bool, + }; + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchSuggestions(true)); + } + + componentWillUnmount () { + const { dispatch } = this.props; + dispatch(markAsPartial('home')); + } + + render () { + const { onBack, isLoading, suggestions, account } = this.props; + + return ( + + + +
+
+

+

+
+ + + +
+ {isLoading ? (new Array(8)).fill().map((_, i) => ) : suggestions.map(suggestion => ( + + ))} +
+ +

{text} }} />

+ +
+ +
+
+
+ ); + } + +} + +export default connect(mapStateToProps)(Follows); \ No newline at end of file diff --git a/app/javascript/mastodon/features/onboarding/index.jsx b/app/javascript/mastodon/features/onboarding/index.jsx new file mode 100644 index 0000000000..5980ba0d08 --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/index.jsx @@ -0,0 +1,141 @@ +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 { focusCompose } from 'mastodon/actions/compose'; +import Column from 'mastodon/features/ui/components/column'; +import { Helmet } from 'react-helmet'; +import illustration from 'mastodon/../images/elephant_ui_conversation.svg'; +import { Link } from 'react-router-dom'; +import { me } from 'mastodon/initial_state'; +import { makeGetAccount } from 'mastodon/selectors'; +import { closeOnboarding } from 'mastodon/actions/onboarding'; +import { fetchAccount } from 'mastodon/actions/accounts'; +import Follows from './follows'; +import Share from './share'; +import Step from './components/step'; +import ArrowSmallRight from './components/arrow_small_right'; +import { FormattedMessage } from 'react-intl'; +import { debounce } from 'lodash'; + +const mapStateToProps = () => { + const getAccount = makeGetAccount(); + + return state => ({ + account: getAccount(state, me), + }); +}; + +class Onboarding extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object.isRequired, + }; + + static propTypes = { + dispatch: PropTypes.func.isRequired, + account: ImmutablePropTypes.map, + }; + + state = { + step: null, + profileClicked: false, + shareClicked: false, + }; + + handleClose = () => { + const { dispatch } = this.props; + const { router } = this.context; + + dispatch(closeOnboarding()); + router.history.push('/home'); + }; + + handleProfileClick = () => { + this.setState({ profileClicked: true }); + }; + + handleFollowClick = () => { + this.setState({ step: 'follows' }); + }; + + handleComposeClick = () => { + const { dispatch } = this.props; + const { router } = this.context; + + dispatch(focusCompose(router.history)); + }; + + handleShareClick = () => { + this.setState({ step: 'share', shareClicked: true }); + }; + + handleBackClick = () => { + this.setState({ step: null }); + }; + + handleWindowFocus = debounce(() => { + const { dispatch, account } = this.props; + dispatch(fetchAccount(account.get('id'))); + }, 1000, { trailing: true }); + + componentDidMount () { + window.addEventListener('focus', this.handleWindowFocus, false); + } + + componentWillUnmount () { + window.removeEventListener('focus', this.handleWindowFocus); + } + + render () { + const { account } = this.props; + const { step, shareClicked } = this.state; + + switch(step) { + case 'follows': + return ; + case 'share': + return ; + } + + return ( + +
+
+ +

+

+
+ +
+ 0 && account.get('note').length > 0)} icon='address-book-o' label={} description={} /> + = 7} icon='user-plus' label={} description={} /> + = 1} icon='pencil-square-o' label={} description={} /> + } description={} /> +
+ +

+ +
+ + + + +
+ +
+ +
+
+ + + + +
+ ); + } + +} + +export default connect(mapStateToProps)(Onboarding); diff --git a/app/javascript/mastodon/features/onboarding/share.jsx b/app/javascript/mastodon/features/onboarding/share.jsx new file mode 100644 index 0000000000..897b2e74d6 --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/share.jsx @@ -0,0 +1,132 @@ +import React from 'react'; +import Column from 'mastodon/components/column'; +import ColumnBackButton from 'mastodon/components/column_back_button'; +import PropTypes from 'prop-types'; +import { me, domain } from 'mastodon/initial_state'; +import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import Icon from 'mastodon/components/icon'; +import ArrowSmallRight from './components/arrow_small_right'; +import { Link } from 'react-router-dom'; + +const messages = defineMessages({ + shareableMessage: { id: 'onboarding.share.message', defaultMessage: 'I\'m {username} on Mastodon! Come follow me at {url}' }, +}); + +const mapStateToProps = state => ({ + account: state.getIn(['accounts', me]), +}); + +class CopyPasteText extends React.PureComponent { + + static propTypes = { + value: PropTypes.string, + }; + + state = { + copied: false, + focused: false, + }; + + setRef = c => { + this.input = c; + }; + + handleInputClick = () => { + this.setState({ copied: false }); + this.input.focus(); + this.input.select(); + this.input.setSelectionRange(0, this.props.value.length); + }; + + handleButtonClick = e => { + e.stopPropagation(); + + const { value } = this.props; + navigator.clipboard.writeText(value); + this.input.blur(); + this.setState({ copied: true }); + this.timeout = setTimeout(() => this.setState({ copied: false }), 700); + }; + + handleFocus = () => { + this.setState({ focused: true }); + }; + + handleBlur = () => { + this.setState({ focused: false }); + }; + + componentWillUnmount () { + if (this.timeout) clearTimeout(this.timeout); + } + + render () { + const { value } = this.props; + const { copied, focused } = this.state; + + return ( +
+