diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 9e0b123704..8242ec6c84 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,15 @@ export function resetCompose() { }; } +export const focusCompose = (routerHistory, defaultText) => dispatch => { + dispatch({ + type: COMPOSE_FOCUS, + defaultText, + }); + + 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 deleted file mode 100644 index d818480b7b..0000000000 --- a/app/javascript/flavours/glitch/components/check.jsx +++ /dev/null @@ -1,7 +0,0 @@ -const Check = () => ( - - - -); - -export default Check; diff --git a/app/javascript/flavours/glitch/components/check.tsx b/app/javascript/flavours/glitch/components/check.tsx new file mode 100644 index 0000000000..901f89fc5b --- /dev/null +++ b/app/javascript/flavours/glitch/components/check.tsx @@ -0,0 +1,13 @@ +export const Check: React.FC = () => ( + + + +); 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..47cf124932 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx @@ -2,6 +2,8 @@ import PropTypes from 'prop-types'; import { defineMessages, injectIntl } from 'react-intl'; +import classNames from 'classnames'; + import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -84,6 +86,10 @@ class ComposeForm extends ImmutablePureComponent { showSearch: false, }; + state = { + highlighted: false, + }; + handleChange = (e) => { this.props.onChange(e.target.value); }; @@ -209,6 +215,10 @@ class ComposeForm extends ImmutablePureComponent { this._updateFocusAndSelection({ }); } + componentWillUnmount () { + if (this.timeout) clearTimeout(this.timeout); + } + componentDidUpdate (prevProps) { this._updateFocusAndSelection(prevProps); } @@ -257,6 +267,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 +314,7 @@ class ComposeForm extends ImmutablePureComponent { spoilersAlwaysOn, isEditing, } = this.props; + const { highlighted } = this.state; const countText = this.getFulltextForCharacterCounting(); @@ -332,42 +345,44 @@ class ComposeForm extends ImmutablePureComponent { /> - - - -
- - -
-
- -
- + 0)} - spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler} - /> -
- + value={this.props.text} + onChange={this.handleChange} + onKeyDown={this.handleKeyDown} + suggestions={suggestions} + onFocus={this.handleFocus} + onSuggestionsFetchRequested={onFetchSuggestions} + onSuggestionsClearRequested={onClearSuggestions} + onSuggestionSelected={this.handleSuggestionSelected} + onPaste={onPaste} + autoFocus={!showSearch && !isMobile(window.innerWidth, layout)} + lang={this.props.lang} + > + + +
+ + +
+ + +
+ 0)} + spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler} + /> +
+ +
diff --git a/app/javascript/flavours/glitch/features/follow_recommendations/components/account.jsx b/app/javascript/flavours/glitch/features/follow_recommendations/components/account.jsx deleted file mode 100644 index 89a270d092..0000000000 --- a/app/javascript/flavours/glitch/features/follow_recommendations/components/account.jsx +++ /dev/null @@ -1,87 +0,0 @@ -import PropTypes from 'prop-types'; - -import { injectIntl, defineMessages } from 'react-intl'; - -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { followAccount, unfollowAccount } from 'flavours/glitch/actions/accounts'; -import { Avatar } from 'flavours/glitch/components/avatar'; -import { DisplayName } from 'flavours/glitch/components/display_name'; -import { IconButton } from 'flavours/glitch/components/icon_button'; -import Permalink from 'flavours/glitch/components/permalink'; -import { makeGetAccount } from 'flavours/glitch/selectors'; - -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/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/getting_started_misc/index.jsx b/app/javascript/flavours/glitch/features/getting_started_misc/index.jsx index 2c2fbc05c0..60b710afca 100644 --- a/app/javascript/flavours/glitch/features/getting_started_misc/index.jsx +++ b/app/javascript/flavours/glitch/features/getting_started_misc/index.jsx @@ -19,7 +19,6 @@ const messages = defineMessages({ blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, - show_me_around: { id: 'getting_started.onboarding', defaultMessage: 'Show me around' }, pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' }, keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' }, featured_users: { id: 'navigation_bar.featured_users', defaultMessage: 'Featured users' }, @@ -36,12 +35,6 @@ class GettingStartedMisc extends ImmutablePureComponent { dispatch: PropTypes.func.isRequired, }; - openOnboardingModal = () => { - this.props.dispatch(openModal({ - modalType: 'ONBOARDING', - })); - }; - openFeaturedAccountsModal = () => { this.props.dispatch(openModal({ modalType: 'PINNED_ACCOUNTS_EDITOR', @@ -65,7 +58,6 @@ class GettingStartedMisc extends ImmutablePureComponent { {signedIn && ()} {signedIn && ()} - {signedIn && ()}
); 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..f7d54ae37d --- /dev/null +++ b/app/javascript/flavours/glitch/features/onboarding/components/progress_indicator.jsx @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import { Fragment } from 'react'; + +import classNames from 'classnames'; + +import { Check } from 'flavours/glitch/components/check'; + + +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; 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..b5bb8e727f --- /dev/null +++ b/app/javascript/flavours/glitch/features/onboarding/components/step.jsx @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; + +import { Check } from 'flavours/glitch/components/check'; +import { Icon } from 'flavours/glitch/components/icon'; + +import ArrowSmallRight from './arrow_small_right'; + +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; 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..76673cdb41 --- /dev/null +++ b/app/javascript/flavours/glitch/features/onboarding/follows.jsx @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import { fetchSuggestions } from 'flavours/glitch/actions/suggestions'; +import { markAsPartial } from 'flavours/glitch/actions/timelines'; +import Column from 'flavours/glitch/components/column'; +import ColumnBackButton from 'flavours/glitch/components/column_back_button'; +import { EmptyAccount } from 'flavours/glitch/components/empty_account'; +import Account from 'flavours/glitch/containers/account_container'; + +const mapStateToProps = state => ({ + suggestions: state.getIn(['suggestions', 'items']), + isLoading: state.getIn(['suggestions', 'isLoading']), +}); + +class Follows extends PureComponent { + + static propTypes = { + onBack: PropTypes.func, + dispatch: PropTypes.func.isRequired, + suggestions: ImmutablePropTypes.list, + isLoading: PropTypes.bool, + multiColumn: PropTypes.bool, + }; + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchSuggestions(true)); + } + + componentWillUnmount () { + const { dispatch } = this.props; + dispatch(markAsPartial('home')); + } + + render () { + const { onBack, isLoading, suggestions, multiColumn } = this.props; + + let loadedContent; + + if (isLoading) { + loadedContent = (new Array(8)).fill().map((_, i) => ); + } else if (suggestions.isEmpty()) { + loadedContent =
; + } else { + loadedContent = suggestions.map(suggestion => ); + } + + return ( + + + +
+
+

+

+
+ +
+ {loadedContent} +
+ +

{chunks} }} />

+ +
+ +
+
+
+ ); + } + +} + +export default connect(mapStateToProps)(Follows); 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..64cf42efac --- /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, injectIntl, defineMessages } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { Link, withRouter } 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 { assetHost } from 'flavours/glitch/utils/config'; +import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; +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 messages = defineMessages({ + template: { id: 'onboarding.compose.template', defaultMessage: 'Hello #Mastodon!' }, +}); + +const mapStateToProps = () => { + const getAccount = makeGetAccount(); + + return state => ({ + account: getAccount(state, me), + }); +}; + +class Onboarding extends ImmutablePureComponent { + static propTypes = { + dispatch: PropTypes.func.isRequired, + account: ImmutablePropTypes.map, + multiColumn: PropTypes.bool, + ...WithRouterPropTypes, + }; + + state = { + step: null, + profileClicked: false, + shareClicked: false, + }; + + handleClose = () => { + const { dispatch, history } = this.props; + + dispatch(closeOnboarding()); + history.push('/home'); + }; + + handleProfileClick = () => { + this.setState({ profileClicked: true }); + }; + + handleFollowClick = () => { + this.setState({ step: 'follows' }); + }; + + handleComposeClick = () => { + const { dispatch, intl, history } = this.props; + + dispatch(focusCompose(history, intl.formatMessage(messages.template))); + }; + + 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, multiColumn } = 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 withRouter(connect(mapStateToProps)(injectIntl(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..a313ee2e8d --- /dev/null +++ b/app/javascript/flavours/glitch/features/onboarding/share.jsx @@ -0,0 +1,200 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; + +import SwipeableViews from 'react-swipeable-views'; + +import Column from 'flavours/glitch/components/column'; +import ColumnBackButton from 'flavours/glitch/components/column_back_button'; +import { Icon } from 'flavours/glitch/components/icon'; +import { me, domain } from 'flavours/glitch/initial_state'; + +import ArrowSmallRight from './components/arrow_small_right'; + +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 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 ( +
+