diff options
author | Eugen <eugen@zeonfederated.com> | 2017-04-16 20:32:00 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-04-16 20:32:00 +0200 |
commit | 6d70a8026309c2d41e2402ec84875ced8e181105 (patch) | |
tree | d20d6de17504b8545fa40ba22435104aef870976 /app | |
parent | 0cbcc5e297acc51688fa618d3d4a5882629cdcb3 (diff) |
Onboarding modal (#1883)
* Basic onboarding modal that's shown to users once * Lay out pages 2 through 5, add images, style modals (#1509) * Lay out pages 2 through 5 Added images and laid out pages 2 through 5 in the jsx file. SCSS will come, still working on just seeing if this works at all. * Fix jsx errors, add images to modal pages, style modal pages * Add animations to onboarding pager changes, improve wording and styling * Finishing touches on the onboarding * Add missing propTypes * Update wording
Diffstat (limited to 'app')
-rw-r--r-- | app/assets/images/elephant-friend.png | bin | 0 -> 24466 bytes | |||
-rw-r--r-- | app/assets/javascripts/components/actions/onboarding.jsx | 14 | ||||
-rw-r--r-- | app/assets/javascripts/components/containers/mastodon.jsx | 3 | ||||
-rw-r--r-- | app/assets/javascripts/components/features/ui/components/modal_root.jsx | 2 | ||||
-rw-r--r-- | app/assets/javascripts/components/features/ui/components/onboarding_modal.jsx | 251 | ||||
-rw-r--r-- | app/assets/javascripts/components/reducers/settings.jsx | 2 | ||||
-rw-r--r-- | app/assets/stylesheets/components.scss | 238 | ||||
-rw-r--r-- | app/controllers/home_controller.rb | 1 | ||||
-rw-r--r-- | app/views/home/initial_state.json.rabl | 9 |
9 files changed, 517 insertions, 3 deletions
diff --git a/app/assets/images/elephant-friend.png b/app/assets/images/elephant-friend.png new file mode 100644 index 000000000..3c5145ba9 --- /dev/null +++ b/app/assets/images/elephant-friend.png Binary files differdiff --git a/app/assets/javascripts/components/actions/onboarding.jsx b/app/assets/javascripts/components/actions/onboarding.jsx new file mode 100644 index 000000000..a161c50ef --- /dev/null +++ b/app/assets/javascripts/components/actions/onboarding.jsx @@ -0,0 +1,14 @@ +import { openModal } from './modal'; +import { changeSetting, saveSettings } from './settings'; + +export function showOnboardingOnce() { + return (dispatch, getState) => { + const alreadySeen = getState().getIn(['settings', 'onboarded']); + + if (!alreadySeen) { + dispatch(openModal('ONBOARDING')); + dispatch(changeSetting(['onboarded'], true)); + dispatch(saveSettings()); + } + }; +}; diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index a771d1269..08576913e 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -8,6 +8,7 @@ import { connectTimeline, disconnectTimeline } from '../actions/timelines'; +import { showOnboardingOnce } from '../actions/onboarding'; import { updateNotifications, refreshNotifications } from '../actions/notifications'; import createBrowserHistory from 'history/lib/createBrowserHistory'; import { @@ -134,6 +135,8 @@ const Mastodon = React.createClass({ if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { Notification.requestPermission(); } + + store.dispatch(showOnboardingOnce()); }, componentWillUnmount () { diff --git a/app/assets/javascripts/components/features/ui/components/modal_root.jsx b/app/assets/javascripts/components/features/ui/components/modal_root.jsx index a1ed8fd88..ace3e085f 100644 --- a/app/assets/javascripts/components/features/ui/components/modal_root.jsx +++ b/app/assets/javascripts/components/features/ui/components/modal_root.jsx @@ -1,11 +1,13 @@ import PureRenderMixin from 'react-addons-pure-render-mixin'; import MediaModal from './media_modal'; +import OnboardingModal from './onboarding_modal'; import VideoModal from './video_modal'; import BoostModal from './boost_modal'; import { TransitionMotion, spring } from 'react-motion'; const MODAL_COMPONENTS = { 'MEDIA': MediaModal, + 'ONBOARDING': OnboardingModal, 'VIDEO': VideoModal, 'BOOST': BoostModal }; diff --git a/app/assets/javascripts/components/features/ui/components/onboarding_modal.jsx b/app/assets/javascripts/components/features/ui/components/onboarding_modal.jsx new file mode 100644 index 000000000..8d5132ea2 --- /dev/null +++ b/app/assets/javascripts/components/features/ui/components/onboarding_modal.jsx @@ -0,0 +1,251 @@ +import { connect } from 'react-redux'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Permalink from '../../../components/permalink'; +import { TransitionMotion, spring } from 'react-motion'; +import ComposeForm from '../../compose/components/compose_form'; +import Search from '../../compose/components/search'; +import NavigationBar from '../../compose/components/navigation_bar'; +import ColumnHeader from './column_header'; +import Immutable from 'immutable'; + +const messages = defineMessages({ + home_title: { id: 'column.home', defaultMessage: 'Home' }, + notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + local_title: { id: 'column.community', defaultMessage: 'Local timeline' }, + federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' } +}); + +const PageOne = ({ acct, domain }) => ( + <div className='onboarding-modal__page onboarding-modal__page-one'> + <div style={{ flex: '0 0 auto' }}> + <div className='onboarding-modal__page-one__elephant-friend' /> + </div> + + <div> + <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1> + <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a social network that belongs to everyone.' /></p> + <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, one of many independent Mastodon instances. Your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }}/></p> + </div> + </div> +); + +PageOne.propTypes = { + acct: React.PropTypes.string.isRequired, + domain: React.PropTypes.string.isRequired +}; + +const PageTwo = () => ( + <div className='onboarding-modal__page onboarding-modal__page-two'> + <div className='figure non-interactive'> + <ComposeForm + text='Awoo! #introductions' + suggestions={Immutable.List()} + mentionedDomains={[]} + onChange={() => {}} + onSubmit={() => {}} + onPaste={() => {}} + onPickEmoji={() => {}} + onChangeSpoilerText={() => {}} + onClearSuggestions={() => {}} + onFetchSuggestions={() => {}} + onSuggestionSelected={() => {}} + /> + </div> + + <p><FormattedMessage id='onboarding.page_two.compose' defaultMessage='Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.' /></p> + </div> +); + +const PageThree = ({ me, domain }) => ( + <div className='onboarding-modal__page onboarding-modal__page-three'> + <div className='figure non-interactive'> + <Search + value='' + onChange={() => {}} + onSubmit={() => {}} + onClear={() => {}} + onShow={() => {}} + /> + + <div className='pseudo-drawer'> + <NavigationBar account={me} /> + </div> + </div> + + <p><FormattedMessage id='onboarding.page_three.search' defaultMessage='Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.' values={{ illustration: <Permalink to='/timelines/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/timelines/tag/introductions' href='/tags/introductions'>#introductions</Permalink> }}/></p> + <p><FormattedMessage id='onboarding.page_three.profile' defaultMessage='Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.' /></p> + </div> +); + +PageThree.propTypes = { + me: ImmutablePropTypes.map.isRequired, + domain: React.PropTypes.string.isRequired +}; + +const PageFour = ({ domain, intl }) => ( + <div className='onboarding-modal__page onboarding-modal__page-four'> + <div className='onboarding-modal__page-four__columns'> + <div className='row'> + <div> + <div className='figure non-interactive'><ColumnHeader icon='home' type={intl.formatMessage(messages.home_title)} /></div> + <p><FormattedMessage id='onboarding.page_four.home' defaultMessage='Home timeline shows posts from people you follow'/></p> + </div> + + <div> + <div className='figure non-interactive'><ColumnHeader icon='bell' type={intl.formatMessage(messages.notifications_title)} /></div> + <p><FormattedMessage id='onboarding.page_four.notifications' defaultMessage='Notifications show when someone interacts with you' /></p> + </div> + </div> + + <div className='row'> + <div> + <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='globe' type={intl.formatMessage(messages.federated_title)} /></div> + </div> + + <div> + <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='users' type={intl.formatMessage(messages.local_title)} /></div> + </div> + </div> + + <p><FormattedMessage id='onboarding.page_five.public_timelines' defaultMessage='Federated timeline lists public posts from everyone who people on {domain} follow. Local timeline is the same, but limited to people on {domain}.' values={{ domain }} /></p> + </div> + </div> +); + +PageFour.propTypes = { + domain: React.PropTypes.string.isRequired, + intl: React.PropTypes.object.isRequired +}; + +const PageSix = ({ admin }) => { + let adminSection = ''; + + if (admin) { + adminSection = ( + <p> + <FormattedMessage id='onboarding.page_six.admin' defaultMessage="Your instance's admin is {admin}." values={{ admin: <Permalink href={admin.get('url')} to={`/accounts/${admin.get('id')}`}>@{admin.get('acct')}</Permalink> }} /> + <br /> + <FormattedMessage id='onboarding.page_six.read_guidelines' defaultMessage='Please, do not forget to read the {guidelines}!' values={{ guidelines: <a href='/about/more' target='_blank'><FormattedMessage id='onboarding.page_six.guidelines' defaultMessage='community guidelines' /></a> }}/> + </p> + ); + } + + return ( + <div className='onboarding-modal__page onboarding-modal__page-six'> + <h1><FormattedMessage id='onboarding.page_six.almost_done' defaultMessage='Almost done...' /></h1> + {adminSection} + <p><FormattedMessage id='onboarding.page_six.github' defaultMessage='Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ github: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p> + <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms. And now... Bon Appetoot!' values={{ apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='various mobile apps' /></a> }} /></p> + </div> + ); +}; + +PageSix.propTypes = { + admin: ImmutablePropTypes.map +}; + +const mapStateToProps = state => ({ + me: state.getIn(['accounts', state.getIn(['meta', 'me'])]), + admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]), + domain: state.getIn(['meta', 'domain']) +}); + +const OnboardingModal = React.createClass({ + + propTypes: { + onClose: React.PropTypes.func.isRequired, + intl: React.PropTypes.object.isRequired, + me: ImmutablePropTypes.map.isRequired, + domain: React.PropTypes.string.isRequired, + admin: ImmutablePropTypes.map + }, + + getInitialState () { + return { + currentIndex: 0 + }; + }, + + mixins: [PureRenderMixin], + + handleSkip (e) { + e.preventDefault(); + this.props.onClose(); + }, + + handleDot (i, e) { + e.preventDefault(); + this.setState({ currentIndex: i }); + }, + + handleNext (maxNum, e) { + e.preventDefault(); + + if (this.state.currentIndex < maxNum - 1) { + this.setState({ currentIndex: this.state.currentIndex + 1 }); + } else { + this.props.onClose(); + } + }, + + render () { + const { me, admin, domain, intl } = this.props; + + const pages = [ + <PageOne acct={me.get('acct')} domain={domain} />, + <PageTwo />, + <PageThree me={me} domain={domain} />, + <PageFour domain={domain} intl={intl} />, + <PageSix admin={admin} /> + ]; + + const { currentIndex } = this.state; + const hasMore = currentIndex < pages.length - 1; + + let nextOrDoneBtn; + + if(hasMore) { + nextOrDoneBtn = <a href='#' onClick={this.handleNext.bind(null, pages.length)} className='onboarding-modal__nav onboarding-modal__next'><FormattedMessage id='onboarding.next' defaultMessage='Next' /></a>; + } else { + nextOrDoneBtn = <a href='#' onClick={this.handleNext.bind(null, pages.length)} className='onboarding-modal__nav onboarding-modal__done'><FormattedMessage id='onboarding.next' defaultMessage='Done' /></a>; + } + + const styles = pages.map((page, i) => ({ + key: i, + style: { opacity: spring(i === currentIndex ? 1 : 0) } + })); + + return ( + <div className='modal-root__modal onboarding-modal'> + <TransitionMotion styles={styles}> + {interpolatedStyles => + <div className='onboarding-modal__pager'> + {pages.map((page, i) => + <div key={i} style={{ opacity: interpolatedStyles[i].style.opacity, pointerEvents: i === currentIndex ? 'auto' : 'none' }}>{page}</div> + )} + </div> + } + </TransitionMotion> + + <div className='onboarding-modal__paginator'> + <div> + <a href='#' className='onboarding-modal__skip' onClick={this.handleSkip}><FormattedMessage id='onboarding.skip' defaultMessage='Skip' /></a> + </div> + + <div className='onboarding-modal__dots'> + {pages.map((_, i) => <div key={i} onClick={this.handleDot.bind(null, i)} className={`onboarding-modal__dot ${i === currentIndex ? 'active' : ''}`} />)} + </div> + + <div> + {nextOrDoneBtn} + </div> + </div> + </div> + ); + } + +}); + +export default connect(mapStateToProps)(injectIntl(OnboardingModal)); diff --git a/app/assets/javascripts/components/reducers/settings.jsx b/app/assets/javascripts/components/reducers/settings.jsx index 8acc3faca..820af99ed 100644 --- a/app/assets/javascripts/components/reducers/settings.jsx +++ b/app/assets/javascripts/components/reducers/settings.jsx @@ -3,6 +3,8 @@ import { STORE_HYDRATE } from '../actions/store'; import Immutable from 'immutable'; const initialState = Immutable.Map({ + onboarded: false, + home: Immutable.Map({ shows: Immutable.Map({ reblog: true, diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index 8bd35819a..84344e94d 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -932,6 +932,12 @@ a.status__content__spoiler-link { } } +.pseudo-drawer { + background: lighten($color1, 13%); + font-size: 13px; + text-align: left; +} + .drawer__header { flex: 0 0 auto; font-size: 16px; @@ -2018,6 +2024,7 @@ button.icon-button.active i.fa-retweet { .modal-root__modal { pointer-events: auto; display: flex; + z-index: 9999; } .media-modal { @@ -2031,6 +2038,237 @@ button.icon-button.active i.fa-retweet { } } +.onboarding-modal { + background: $color2; + color: $color1; + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.onboarding-modal__pager { + height: 80vh; + width: 80vw; + max-width: 500px; + max-height: 350px; + position: relative; + + & > div { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 25px; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + display: flex; + opacity: 0; + user-select: text; + } +} + +@media screen and (max-width: 550px) { + .onboarding-modal { + width: 100%; + height: 100%; + border-radius: 0; + } + + .onboarding-modal__pager { + width: 100%; + height: auto; + max-width: none; + max-height: none; + flex: 1 1 auto; + } +} + +.onboarding-modal__paginator { + flex: 0 0 auto; + background: darken($color2, 8%); + display: flex; + padding: 25px; + + & > div { + min-width: 33px; + } + + a { + color: darken($color2, 34%); + text-decoration: none; + font-size: 14px; + font-weight: 500; + + &:hover, &:focus, &:active { + color: darken($color2, 38%); + } + + &.onboarding-modal__done, &.onboarding-modal__next { + color: $color4; + } + } +} + +.onboarding-modal__dots { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.onboarding-modal__dot { + width: 14px; + height: 14px; + border-radius: 14px; + background: darken($color2, 16%); + margin: 0 3px; + cursor: pointer; + + &:hover { + background: darken($color2, 18%); + } + + &.active { + cursor: default; + background: darken($color2, 24%); + } +} + +.onboarding-modal__page { + cursor: default; + line-height: 21px; + + h1 { + font-size: 18px; + font-weight: 500; + color: $color1; + margin-bottom: 20px; + } + + a { + color: $color4; + + &:hover, &:focus, &:active { + color: lighten($color4, 4%); + } + } + + p { + font-size: 16px; + color: lighten($color1, 8%); + margin-top: 10px; + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + + strong { + font-weight: 500; + background: $color1; + color: $color2; + border-radius: 4px; + font-size: 14px; + padding: 3px 6px; + } + } +} + +.onboarding-modal__page-one { + display: flex; +} + +.onboarding-modal__page-one__elephant-friend { + background: image-url('elephant-friend.png') no-repeat 0 0; + width: 147px; + height: 160px; + margin-right: 10px; +} + +.onboarding-modal__page-two, +.onboarding-modal__page-three, +.onboarding-modal__page-four, +.onboarding-modal__page-five { + p { + text-align: left; + } + + .figure { + background: darken($color1, 8%); + color: $color2; + margin-bottom: 20px; + border-radius: 4px; + padding: 10px; + text-align: center; + font-size: 14px; + box-shadow: 1px 2px 6px rgba($color8, 0.3); + + .onboarding-modal__image { + border-radius: 4px; + margin-bottom: 10px; + } + + &.non-interactive { + pointer-events: none; + text-align: left; + } + } +} + +.onboarding-modal__page-four__columns { + .row { + display: flex; + margin-bottom: 20px; + + & > div { + flex: 1 1 0; + margin: 0 10px; + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } + + p { + text-align: center; + } + } + + &:last-child { + margin-bottom: 0; + } + } + + .column-header { + color: $color5; + } +} + +.onboarding-modal__image { + border-radius: 8px; + width: 70vw; + max-width: 450px; + max-height: auto; + display: block; + margin: auto; + margin-bottom: 20px; +} + +.onboard-sliders { + display: inline-block; + max-width: 30px; + max-height: auto; + margin-left: 10px; +} + .boost-modal { background: lighten($color2, 8%); color: $color1; diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 2d1cf74f0..0a25b52aa 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -7,6 +7,7 @@ class HomeController < ApplicationController @body_classes = 'app-body' @token = find_or_create_access_token.token @web_settings = Web::Setting.find_by(user: current_user)&.data || {} + @admin = Account.find_local(Setting.site_contact_username) @streaming_api_base_url = Rails.configuration.x.streaming_api_base_url end diff --git a/app/views/home/initial_state.json.rabl b/app/views/home/initial_state.json.rabl index 9f94e6141..ce7bfbd44 100644 --- a/app/views/home/initial_state.json.rabl +++ b/app/views/home/initial_state.json.rabl @@ -5,7 +5,9 @@ node(:meta) do streaming_api_base_url: @streaming_api_base_url, access_token: @token, locale: I18n.locale, + domain: Rails.configuration.x.local_domain, me: current_account.id, + admin: @admin.try(:id), boost_modal: current_account.user.setting_boost_modal, } end @@ -18,9 +20,10 @@ node(:compose) do end node(:accounts) do - { - current_account.id => partial('api/v1/accounts/show', object: current_account), - } + store = {} + store[current_account.id] = partial('api/v1/accounts/show', object: current_account) + store[@admin.id] = partial('api/v1/accounts/show', object: @admin) unless @admin.nil? + store end node(:settings) { @web_settings } |