diff --git a/app/javascript/images/elephant_ui_greeting.svg b/app/javascript/images/elephant_ui_greeting.svg new file mode 100644 index 00000000000..f3eb4b142a1 --- /dev/null +++ b/app/javascript/images/elephant_ui_greeting.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/mastodon/actions/onboarding.js b/app/javascript/mastodon/actions/onboarding.js new file mode 100644 index 00000000000..a161c50efed --- /dev/null +++ b/app/javascript/mastodon/actions/onboarding.js @@ -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/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 8ae3b727af9..d1710445b53 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -2,6 +2,7 @@ import React from 'react'; import { Provider } from 'react-redux'; import PropTypes from 'prop-types'; import configureStore from '../store/configureStore'; +import { showOnboardingOnce } from '../actions/onboarding'; import { BrowserRouter, Route } from 'react-router-dom'; import { ScrollContext } from 'react-router-scroll-4'; import UI from '../features/ui'; @@ -39,6 +40,8 @@ export default class Mastodon extends React.PureComponent { const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s'; window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000); } + + store.dispatch(showOnboardingOnce()); } componentWillUnmount () { diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index dbfb46ee747..5839ba40a64 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -9,6 +9,7 @@ import VideoModal from './video_modal'; import BoostModal from './boost_modal'; import ConfirmationModal from './confirmation_modal'; import { + OnboardingModal, MuteModal, ReportModal, EmbedModal, @@ -17,6 +18,7 @@ import { const MODAL_COMPONENTS = { 'MEDIA': () => Promise.resolve({ default: MediaModal }), + 'ONBOARDING': OnboardingModal, 'VIDEO': () => Promise.resolve({ default: VideoModal }), 'BOOST': () => Promise.resolve({ default: BoostModal }), 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }), diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js new file mode 100644 index 00000000000..9b713cf9ee5 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js @@ -0,0 +1,324 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ReactSwipeableViews from 'react-swipeable-views'; +import classNames from 'classnames'; +import Permalink from '../../../components/permalink'; +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 { List as ImmutableList } from 'immutable'; +import { me } from '../../../initial_state'; + +const noop = () => { }; + +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 }) => ( +
+
+

+

+
+ +
+
+
+ +
+ +
+ @{acct}@{domain} +
+
+ +

+
+
+); + +PageOne.propTypes = { + acct: PropTypes.string.isRequired, + domain: PropTypes.string.isRequired, +}; + +const PageTwo = ({ myAccount }) => ( +
+
+
+ + + +
+
+ +

+
+); + +PageTwo.propTypes = { + myAccount: ImmutablePropTypes.map.isRequired, +}; + +const PageThree = ({ myAccount }) => ( +
+
+ + +
+ +
+
+ +

#illustration, introductions: #introductions }} />

+

+
+); + +PageThree.propTypes = { + myAccount: ImmutablePropTypes.map.isRequired, +}; + +const PageFour = ({ domain, intl }) => ( +
+
+
+
+
+

+
+ +
+
+

+
+
+ +
+
+
+
+ +
+
+
+
+ +

+
+
+); + +PageFour.propTypes = { + domain: PropTypes.string.isRequired, + intl: PropTypes.object.isRequired, +}; + +const PageSix = ({ admin, domain }) => { + let adminSection = ''; + + if (admin) { + adminSection = ( +

+ @{admin.get('acct')} }} /> +
+ }} /> +

+ ); + } + + return ( +
+

+ {adminSection} +

GitHub }} />

+

}} />

+

+
+ ); +}; + +PageSix.propTypes = { + admin: ImmutablePropTypes.map, + domain: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + myAccount: state.getIn(['accounts', me]), + admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]), + domain: state.getIn(['meta', 'domain']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class OnboardingModal extends React.PureComponent { + + static propTypes = { + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + myAccount: ImmutablePropTypes.map.isRequired, + domain: PropTypes.string.isRequired, + admin: ImmutablePropTypes.map, + }; + + state = { + currentIndex: 0, + }; + + componentWillMount() { + const { myAccount, admin, domain, intl } = this.props; + this.pages = [ + , + , + , + , + , + ]; + }; + + componentDidMount() { + window.addEventListener('keyup', this.handleKeyUp); + } + + componentWillUnmount() { + window.addEventListener('keyup', this.handleKeyUp); + } + + handleSkip = (e) => { + e.preventDefault(); + this.props.onClose(); + } + + handleDot = (e) => { + const i = Number(e.currentTarget.getAttribute('data-index')); + e.preventDefault(); + this.setState({ currentIndex: i }); + } + + handlePrev = () => { + this.setState(({ currentIndex }) => ({ + currentIndex: Math.max(0, currentIndex - 1), + })); + } + + handleNext = () => { + const { pages } = this; + this.setState(({ currentIndex }) => ({ + currentIndex: Math.min(currentIndex + 1, pages.length - 1), + })); + } + + handleSwipe = (index) => { + this.setState({ currentIndex: index }); + } + + handleKeyUp = ({ key }) => { + switch (key) { + case 'ArrowLeft': + this.handlePrev(); + break; + case 'ArrowRight': + this.handleNext(); + break; + } + } + + handleClose = () => { + this.props.onClose(); + } + + render () { + const { pages } = this; + const { currentIndex } = this.state; + const hasMore = currentIndex < pages.length - 1; + + const nextOrDoneBtn = hasMore ? ( + + ) : ( + + ); + + return ( +
+ + {pages.map((page, i) => { + const className = classNames('onboarding-modal__page__wrapper', `onboarding-modal__page__wrapper-${i}`, { + 'onboarding-modal__page__wrapper--active': i === currentIndex, + }); + + return ( +
{page}
+ ); + })} +
+ +
+
+ +
+ +
+ {pages.map((_, i) => { + const className = classNames('onboarding-modal__dot', { + active: i === currentIndex, + }); + + return ( +
+ ); + })} +
+ +
+ {nextOrDoneBtn} +
+
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index a03c4cefd8e..d6586680b8f 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -94,6 +94,10 @@ export function Mutes () { return import(/* webpackChunkName: "features/mutes" */'../../mutes'); } +export function OnboardingModal () { + return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal'); +} + export function MuteModal () { return import(/* webpackChunkName: "modals/mute_modal" */'../components/mute_modal'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index d214fe85fe5..8315763bfbf 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -174,7 +174,7 @@ "onboarding.page_four.home": "The home timeline shows posts from people you follow.", "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.", "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", - "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}", + "onboarding.page_one.handle": "Your full handle is {handle}. This is what you would tell your friends to search for.", "onboarding.page_one.welcome": "Welcome to Mastodon!", "onboarding.page_six.admin": "Your instance's admin is {admin}.", "onboarding.page_six.almost_done": "Almost done...", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index c0a32ed053e..4a9a379a8e2 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1419,6 +1419,10 @@ color: $primary-text-color; } + a { + color: inherit; + } + .permalink { text-decoration: none; } @@ -2760,6 +2764,7 @@ flex: 1 1 auto; align-items: center; justify-content: center; + @supports(display: grid) { // hack to fix Chrome <57 contain: strict; } @@ -2805,11 +2810,48 @@ } } -.pulse-loading { +.no-reduce-motion .pulse-loading { transform-origin: center center; animation: heartbeat 1.5s ease-in-out infinite both; } +@keyframes shake-bottom { + 0%, + 100% { + transform: rotate(0deg); + transform-origin: 50% 100%; + } + + 10% { + transform: rotate(2deg); + } + + 20%, + 40%, + 60% { + transform: rotate(-4deg); + } + + 30%, + 50%, + 70% { + transform: rotate(4deg); + } + + 80% { + transform: rotate(-2deg); + } + + 90% { + transform: rotate(2deg); + } +} + +.no-reduce-motion .shake-bottom { + transform-origin: 50% 100%; + animation: shake-bottom 0.8s cubic-bezier(0.455, 0.030, 0.515, 0.955) 2s 2 both; +} + .emoji-picker-dropdown__menu { background: $simple-background-color; position: absolute; @@ -3300,6 +3342,7 @@ z-index: 100; } +.onboarding-modal, .error-modal, .embed-modal { background: $ui-secondary-color; @@ -3310,6 +3353,25 @@ flex-direction: column; } +.onboarding-modal__pager { + height: 80vh; + width: 80vw; + max-width: 520px; + max-height: 470px; + + .react-swipeable-view-container > div { + width: 100%; + height: 100%; + box-sizing: border-box; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + display: flex; + user-select: text; + } +} + .error-modal__body { height: 80vh; width: 80vw; @@ -3343,6 +3405,23 @@ text-align: center; } +@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, .error-modal__footer { flex: 0 0 auto; background: darken($ui-secondary-color, 8%); @@ -3353,20 +3432,35 @@ min-width: 33px; } + .onboarding-modal__nav, .error-modal__nav { color: darken($ui-secondary-color, 34%); - background-color: transparent; border: 0; font-size: 14px; font-weight: 500; - padding: 0; + padding: 10px 25px; line-height: inherit; height: auto; + margin: -10px; + border-radius: 4px; + background-color: transparent; &:hover, &:focus, &:active { color: darken($ui-secondary-color, 38%); + background-color: darken($ui-secondary-color, 16%); + } + + &.onboarding-modal__done, + &.onboarding-modal__next { + color: $ui-base-color; + + &:hover, + &:focus, + &:active { + color: darken($ui-base-color, 4%); + } } } } @@ -3375,6 +3469,239 @@ justify-content: center; } +.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($ui-secondary-color, 16%); + margin: 0 3px; + cursor: pointer; + + &:hover { + background: darken($ui-secondary-color, 18%); + } + + &.active { + cursor: default; + background: darken($ui-secondary-color, 24%); + } +} + +.onboarding-modal__page__wrapper { + pointer-events: none; + padding: 25px; + padding-bottom: 0; + + &.onboarding-modal__page__wrapper--active { + pointer-events: auto; + } +} + +.onboarding-modal__page { + cursor: default; + line-height: 21px; + + h1 { + font-size: 18px; + font-weight: 500; + color: $ui-base-color; + margin-bottom: 20px; + } + + a { + color: $ui-highlight-color; + + &:hover, + &:focus, + &:active { + color: lighten($ui-highlight-color, 4%); + } + } + + .navigation-bar a { + color: inherit; + } + + p { + font-size: 16px; + color: lighten($ui-base-color, 8%); + margin-top: 10px; + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + + strong { + font-weight: 500; + background: $ui-base-color; + color: $ui-secondary-color; + border-radius: 4px; + font-size: 14px; + padding: 3px 6px; + + @each $lang in $cjk-langs { + &:lang(#{$lang}) { + font-weight: 700; + } + } + } + } +} + +.onboarding-modal__page__wrapper-0 { + background: url('../images/elephant_ui_greeting.svg') no-repeat left bottom / auto 250px; + height: 100%; + padding: 0; +} + +.onboarding-modal__page-one { + &__lead { + padding: 65px; + padding-top: 45px; + padding-bottom: 0; + margin-bottom: 10px; + + h1 { + font-size: 26px; + line-height: 36px; + margin-bottom: 8px; + } + + p { + margin-bottom: 0; + } + } + + &__extra { + padding-right: 65px; + padding-left: 185px; + text-align: center; + } +} + +.display-case { + text-align: center; + font-size: 15px; + margin-bottom: 15px; + + &__label { + font-weight: 500; + color: $ui-base-color; + margin-bottom: 5px; + text-transform: uppercase; + font-size: 12px; + } + + &__case { + background: $ui-base-color; + color: $ui-secondary-color; + font-weight: 500; + padding: 10px; + border-radius: 4px; + } +} + +.onboarding-modal__page-two, +.onboarding-modal__page-three, +.onboarding-modal__page-four, +.onboarding-modal__page-five { + p { + text-align: left; + } + + .figure { + background: darken($ui-base-color, 8%); + color: $ui-secondary-color; + margin-bottom: 20px; + border-radius: 4px; + padding: 10px; + text-align: center; + font-size: 14px; + box-shadow: 1px 2px 6px rgba($base-shadow-color, 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: $primary-text-color; + } +} + +@media screen and (max-width: 320px) and (max-height: 600px) { + .onboarding-modal__page p { + font-size: 14px; + line-height: 20px; + } + + .onboarding-modal__page-two .figure, + .onboarding-modal__page-three .figure, + .onboarding-modal__page-four .figure, + .onboarding-modal__page-five .figure { + font-size: 12px; + margin-bottom: 10px; + } + + .onboarding-modal__page-four__columns .row { + margin-bottom: 10px; + } + + .onboarding-modal__page-four__columns .column-header { + padding: 5px; + font-size: 12px; + } +} + +.onboard-sliders { + display: inline-block; + max-width: 30px; + max-height: auto; + margin-left: 10px; +} + .boost-modal, .confirmation-modal, .report-modal,