From 4537b4b9613e29d1951e57796c5f654830ab61fb Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 23 Apr 2023 22:24:53 +0200 Subject: [PATCH 01/19] [Glitch] Add new onboarding flow to web UI Port 0461f83320378fb8cee679da896ce35cec5bcbf3 to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/actions/compose.js | 9 + .../flavours/glitch/actions/onboarding.js | 18 +- .../flavours/glitch/components/check.jsx | 4 +- .../glitch/components/column_back_button.jsx | 7 +- .../compose/components/compose_form.jsx | 60 ++-- .../components/account.jsx | 87 ----- .../features/follow_recommendations/index.jsx | 119 ------- .../components/arrow_small_right.jsx | 7 + .../components/progress_indicator.jsx | 25 ++ .../features/onboarding/components/step.jsx | 49 +++ .../glitch/features/onboarding/follows.jsx | 79 +++++ .../glitch/features/onboarding/index.jsx | 149 +++++++++ .../glitch/features/onboarding/share.jsx | 132 ++++++++ .../flavours/glitch/features/ui/index.jsx | 8 +- .../features/ui/util/async-components.js | 4 +- .../glitch/reducers/accounts_counters.js | 14 +- .../flavours/glitch/reducers/compose.js | 3 + .../glitch/styles/components/accounts.scss | 10 - .../glitch/styles/components/columns.scss | 305 +++++++++++++++--- .../glitch/styles/components/misc.scss | 5 + 20 files changed, 789 insertions(+), 305 deletions(-) delete mode 100644 app/javascript/flavours/glitch/features/follow_recommendations/components/account.jsx delete mode 100644 app/javascript/flavours/glitch/features/follow_recommendations/index.jsx create mode 100644 app/javascript/flavours/glitch/features/onboarding/components/arrow_small_right.jsx create mode 100644 app/javascript/flavours/glitch/features/onboarding/components/progress_indicator.jsx create mode 100644 app/javascript/flavours/glitch/features/onboarding/components/step.jsx create mode 100644 app/javascript/flavours/glitch/features/onboarding/follows.jsx create mode 100644 app/javascript/flavours/glitch/features/onboarding/index.jsx create mode 100644 app/javascript/flavours/glitch/features/onboarding/share.jsx diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 9e0b123704..d196c340d1 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -84,6 +84,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.' }, @@ -144,6 +145,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/flavours/glitch/actions/onboarding.js b/app/javascript/flavours/glitch/actions/onboarding.js index a4a525c427..a1dd3a731e 100644 --- a/app/javascript/flavours/glitch/actions/onboarding.js +++ b/app/javascript/flavours/glitch/actions/onboarding.js @@ -1,16 +1,8 @@ -import { openModal } from './modal'; import { changeSetting, saveSettings } from './settings'; -export function showOnboardingOnce() { - return (dispatch, getState) => { - const alreadySeen = getState().getIn(['settings', 'onboarded']); +export const INTRODUCTION_VERSION = 20181216044202; - if (!alreadySeen) { - dispatch(openModal({ - modalType: 'ONBOARDING', - })); - dispatch(changeSetting(['onboarded'], true)); - dispatch(saveSettings()); - } - }; -} +export const closeOnboarding = () => dispatch => { + dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); + dispatch(saveSettings()); +}; diff --git a/app/javascript/flavours/glitch/components/check.jsx b/app/javascript/flavours/glitch/components/check.jsx index d818480b7b..ae313f1f3b 100644 --- a/app/javascript/flavours/glitch/components/check.jsx +++ b/app/javascript/flavours/glitch/components/check.jsx @@ -1,6 +1,6 @@ const Check = () => ( - - + + ); diff --git a/app/javascript/flavours/glitch/components/column_back_button.jsx b/app/javascript/flavours/glitch/components/column_back_button.jsx index df623ab233..5e705e05d7 100644 --- a/app/javascript/flavours/glitch/components/column_back_button.jsx +++ b/app/javascript/flavours/glitch/components/column_back_button.jsx @@ -13,13 +13,16 @@ export class ColumnBackButton extends PureComponent { static propTypes = { multiColumn: PropTypes.bool, + onClick: PropTypes.func, ...WithRouterPropTypes, }; handleClick = () => { - const { history } = this.props; + const { onClick, history } = this.props; - if (history.location?.state?.fromMastodon) { + if (onClick) { + onClick(); + } else if (history.location?.state?.fromMastodon) { history.goBack(); } else { history.push('/'); diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx index 72fc4c4ab8..0da21a1aee 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx @@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { length } from 'stringz'; +import classNames from 'classnames'; import { maxChars } from 'flavours/glitch/initial_state'; import { isMobile } from 'flavours/glitch/is_mobile'; @@ -84,6 +85,10 @@ class ComposeForm extends ImmutablePureComponent { showSearch: false, }; + state = { + highlighted: false, + }; + handleChange = (e) => { this.props.onChange(e.target.value); }; @@ -209,6 +214,10 @@ class ComposeForm extends ImmutablePureComponent { this._updateFocusAndSelection({ }); } + componentWillUnmount () { + if (this.timeout) clearTimeout(this.timeout); + } + componentDidUpdate (prevProps) { this._updateFocusAndSelection(prevProps); } @@ -257,6 +266,8 @@ class ComposeForm extends ImmutablePureComponent { textarea.setSelectionRange(selectionStart, selectionEnd); textarea.focus(); if (!singleColumn) textarea.scrollIntoView(); + this.setState({ highlighted: true }); + this.timeout = setTimeout(() => this.setState({ highlighted: false }), 700); }).catch(console.error); } @@ -302,6 +313,7 @@ class ComposeForm extends ImmutablePureComponent { spoilersAlwaysOn, isEditing, } = this.props; + const { highlighted } = this.state; const countText = this.getFulltextForCharacterCounting(); @@ -332,29 +344,31 @@ class ComposeForm extends ImmutablePureComponent { /> - - - -
- - -
- +
+ + + +
+ + +
+
+
{ - 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/flavours/glitch/features/follow_recommendations/index.jsx b/app/javascript/flavours/glitch/features/follow_recommendations/index.jsx deleted file mode 100644 index 04fc2b06bc..0000000000 --- a/app/javascript/flavours/glitch/features/follow_recommendations/index.jsx +++ /dev/null @@ -1,119 +0,0 @@ -import PropTypes from 'prop-types'; - -import { FormattedMessage } from 'react-intl'; - -import { Helmet } from 'react-helmet'; -import { withRouter } from 'react-router-dom'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { requestBrowserPermission } from 'flavours/glitch/actions/notifications'; -import { changeSetting, saveSettings } from 'flavours/glitch/actions/settings'; -import { fetchSuggestions } from 'flavours/glitch/actions/suggestions'; -import { markAsPartial } from 'flavours/glitch/actions/timelines'; -import { Button } from 'flavours/glitch/components/button'; -import Column from 'flavours/glitch/features/ui/components/column'; -import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; -import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg'; - -import Account from './components/account'; - -const mapStateToProps = state => ({ - suggestions: state.getIn(['suggestions', 'items']), - isLoading: state.getIn(['suggestions', 'isLoading']), -}); - -class FollowRecommendations extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - suggestions: ImmutablePropTypes.list, - isLoading: PropTypes.bool, - ...WithRouterPropTypes, - }; - - 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 { history, dispatch } = this.props; - - 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()); - } - })); - - history.push('/home'); - }; - - render () { - const { suggestions, isLoading } = this.props; - - return ( - -
-
- - - - -

-

-
- - {!isLoading && ( - <> -
- {suggestions.size > 0 ? suggestions.map(suggestion => ( - - )) : ( -
- -
- )} -
- -
- - -
- - )} -
- - - - -
- ); - } - -} - -export default withRouter(connect(mapStateToProps)(FollowRecommendations)); diff --git a/app/javascript/flavours/glitch/features/onboarding/components/arrow_small_right.jsx b/app/javascript/flavours/glitch/features/onboarding/components/arrow_small_right.jsx new file mode 100644 index 0000000000..79b9db383f --- /dev/null +++ b/app/javascript/flavours/glitch/features/onboarding/components/arrow_small_right.jsx @@ -0,0 +1,7 @@ +const ArrowSmallRight = () => ( + + + +); + +export default ArrowSmallRight; \ No newline at end of file diff --git a/app/javascript/flavours/glitch/features/onboarding/components/progress_indicator.jsx b/app/javascript/flavours/glitch/features/onboarding/components/progress_indicator.jsx new file mode 100644 index 0000000000..4437070500 --- /dev/null +++ b/app/javascript/flavours/glitch/features/onboarding/components/progress_indicator.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Check from 'flavours/glitch/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/flavours/glitch/features/onboarding/components/step.jsx b/app/javascript/flavours/glitch/features/onboarding/components/step.jsx new file mode 100644 index 0000000000..09bc37d9e3 --- /dev/null +++ b/app/javascript/flavours/glitch/features/onboarding/components/step.jsx @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; +import Check from 'flavours/glitch/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/flavours/glitch/features/onboarding/follows.jsx b/app/javascript/flavours/glitch/features/onboarding/follows.jsx new file mode 100644 index 0000000000..3deb1e14d5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/onboarding/follows.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import Column from 'flavours/glitch/components/column'; +import ColumnBackButton from 'flavours/glitch/components/column_back_button'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { fetchSuggestions } from 'flavours/glitch/actions/suggestions'; +import { markAsPartial } from 'flavours/glitch/actions/timelines'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Account from 'flavours/glitch/containers/account_container'; +import EmptyAccount from 'flavours/glitch/components/account'; +import { FormattedMessage, FormattedHTMLMessage } from 'react-intl'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import { me } from 'flavours/glitch/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/flavours/glitch/features/onboarding/index.jsx b/app/javascript/flavours/glitch/features/onboarding/index.jsx new file mode 100644 index 0000000000..5536db4292 --- /dev/null +++ b/app/javascript/flavours/glitch/features/onboarding/index.jsx @@ -0,0 +1,149 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { fetchAccount } from 'flavours/glitch/actions/accounts'; +import { focusCompose } from 'flavours/glitch/actions/compose'; +import { closeOnboarding } from 'flavours/glitch/actions/onboarding'; +import Column from 'flavours/glitch/features/ui/components/column'; +import { me } from 'flavours/glitch/initial_state'; +import { makeGetAccount } from 'flavours/glitch/selectors'; +import illustration from 'mastodon/../images/elephant_ui_conversation.svg'; + +import ArrowSmallRight from './components/arrow_small_right'; +import Step from './components/step'; +import Follows from './follows'; +import Share from './share'; + + + +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/flavours/glitch/features/onboarding/share.jsx b/app/javascript/flavours/glitch/features/onboarding/share.jsx new file mode 100644 index 0000000000..10526f515d --- /dev/null +++ b/app/javascript/flavours/glitch/features/onboarding/share.jsx @@ -0,0 +1,132 @@ +import React from 'react'; +import Column from 'flavours/glitch/components/column'; +import ColumnBackButton from 'flavours/glitch/components/column_back_button'; +import PropTypes from 'prop-types'; +import { me, domain } from 'flavours/glitch/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 'flavours/glitch/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 ( +
+