diff --git a/app/assets/images/elephant-friend.png b/app/assets/images/elephant-friend.png new file mode 100644 index 000000000..3c5145ba9 Binary files /dev/null and b/app/assets/images/elephant-friend.png differ diff --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 }) => ( +
+
+
+
+ +
+

+

+

{acct}@{domain} }}/>

+
+
+); + +PageOne.propTypes = { + acct: React.PropTypes.string.isRequired, + domain: React.PropTypes.string.isRequired +}; + +const PageTwo = () => ( +
+
+ {}} + onSubmit={() => {}} + onPaste={() => {}} + onPickEmoji={() => {}} + onChangeSpoilerText={() => {}} + onClearSuggestions={() => {}} + onFetchSuggestions={() => {}} + onSuggestionSelected={() => {}} + /> +
+ +

+
+); + +const PageThree = ({ me, domain }) => ( +
+
+ {}} + onSubmit={() => {}} + onClear={() => {}} + onShow={() => {}} + /> + +
+ +
+
+ +

#illustration, introductions: #introductions }}/>

+

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

+
+ +
+
+

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

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

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

+ ); + } + + return ( +
+

+ {adminSection} +

GitHub }} />

+

}} />

+
+ ); +}; + +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 = [ + , + , + , + , + + ]; + + const { currentIndex } = this.state; + const hasMore = currentIndex < pages.length - 1; + + let nextOrDoneBtn; + + if(hasMore) { + nextOrDoneBtn = ; + } else { + nextOrDoneBtn = ; + } + + const styles = pages.map((page, i) => ({ + key: i, + style: { opacity: spring(i === currentIndex ? 1 : 0) } + })); + + return ( +
+ + {interpolatedStyles => +
+ {pages.map((page, i) => +
{page}
+ )} +
+ } +
+ +
+
+ +
+ +
+ {pages.map((_, i) =>
)} +
+ +
+ {nextOrDoneBtn} +
+
+
+ ); + } + +}); + +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 }