diff --git a/app/soapbox/actions/__tests__/onboarding.test.ts b/app/soapbox/actions/__tests__/onboarding.test.ts new file mode 100644 index 000000000..08ca76284 --- /dev/null +++ b/app/soapbox/actions/__tests__/onboarding.test.ts @@ -0,0 +1,24 @@ +import { getSettings } from 'soapbox/actions/settings'; +import { createTestStore, rootState } from 'soapbox/jest/test-helpers'; + +import { ONBOARDING_VERSION, endOnboarding } from '../onboarding'; + +describe('endOnboarding()', () => { + it('updates the onboardingVersion setting', async() => { + const store = createTestStore(rootState); + + // Sanity check: + // `onboardingVersion` should be `0` by default + const initialVersion = getSettings(store.getState()).get('onboardingVersion'); + expect(initialVersion).toBe(0); + + await store.dispatch(endOnboarding()); + + // After dispatching, `onboardingVersion` is updated + const updatedVersion = getSettings(store.getState()).get('onboardingVersion'); + expect(updatedVersion).toBe(ONBOARDING_VERSION); + + // Sanity check: `updatedVersion` is greater than `initialVersion` + expect(updatedVersion > initialVersion).toBe(true); + }); +}); diff --git a/app/soapbox/actions/onboarding.js b/app/soapbox/actions/onboarding.js deleted file mode 100644 index a1dd3a731..000000000 --- a/app/soapbox/actions/onboarding.js +++ /dev/null @@ -1,8 +0,0 @@ -import { changeSetting, saveSettings } from './settings'; - -export const INTRODUCTION_VERSION = 20181216044202; - -export const closeOnboarding = () => dispatch => { - dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); - dispatch(saveSettings()); -}; diff --git a/app/soapbox/actions/onboarding.ts b/app/soapbox/actions/onboarding.ts new file mode 100644 index 000000000..13bcf0f73 --- /dev/null +++ b/app/soapbox/actions/onboarding.ts @@ -0,0 +1,13 @@ +import { changeSettingImmediate } from 'soapbox/actions/settings'; + +/** Repeat the onboading process when we bump the version */ +export const ONBOARDING_VERSION = 1; + +/** Finish onboarding and store the setting */ +const endOnboarding = () => (dispatch: React.Dispatch) => { + dispatch(changeSettingImmediate(['onboardingVersion'], ONBOARDING_VERSION)); +}; + +export { + endOnboarding, +}; diff --git a/app/soapbox/actions/settings.js b/app/soapbox/actions/settings.js index 098cdfdb1..8a76a2c6c 100644 --- a/app/soapbox/actions/settings.js +++ b/app/soapbox/actions/settings.js @@ -20,7 +20,7 @@ const messages = defineMessages({ }); export const defaultSettings = ImmutableMap({ - onboarded: false, + onboardingVersion: 0, skinTone: 1, reduceMotion: false, underlineLinks: false, diff --git a/app/soapbox/components/ui/button/useButtonStyles.ts b/app/soapbox/components/ui/button/useButtonStyles.ts index 15a6f87cc..f670d028e 100644 --- a/app/soapbox/components/ui/button/useButtonStyles.ts +++ b/app/soapbox/components/ui/button/useButtonStyles.ts @@ -1,6 +1,6 @@ import classNames from 'classnames'; -type ButtonThemes = 'primary' | 'secondary' | 'ghost' | 'accent' | 'danger' | 'transparent' +type ButtonThemes = 'primary' | 'secondary' | 'ghost' | 'accent' | 'danger' | 'transparent' | 'link' type ButtonSizes = 'sm' | 'md' | 'lg' type IButtonStyles = { @@ -25,6 +25,7 @@ const useButtonStyles = ({ accent: 'border-transparent text-white bg-accent-500 hover:bg-accent-300 focus:ring-pink-500 focus:ring-2 focus:ring-offset-2', danger: 'border-transparent text-danger-700 bg-danger-100 hover:bg-danger-200 focus:ring-danger-500 focus:ring-2 focus:ring-offset-2', transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80', + link: 'border-transparent text-primary-600 hover:bg-gray-100 hover:text-primary-700', }; const sizes = { diff --git a/app/soapbox/components/ui/icon/icon.tsx b/app/soapbox/components/ui/icon/icon.tsx index e8d8162a9..224d15a9b 100644 --- a/app/soapbox/components/ui/icon/icon.tsx +++ b/app/soapbox/components/ui/icon/icon.tsx @@ -2,7 +2,7 @@ import React from 'react'; import SvgIcon from './svg-icon'; -interface IIcon { +interface IIcon extends Pick, 'strokeWidth'> { className?: string, count?: number, alt?: string, diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index f30ae5d09..e8361223e 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -11,7 +11,7 @@ const messages = defineMessages({ hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' }, }); -interface IInput extends Pick, 'onChange' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required'> { +interface IInput extends Pick, 'maxLength' | 'onChange' | 'type' | 'autoComplete' | 'autoCorrect' | 'autoCapitalize' | 'required'> { autoFocus?: boolean, defaultValue?: string, className?: string, diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 1441ae17e..7e55ff5a2 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import React from 'react'; -type SIZES = 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 +type SIZES = 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 10 const spaces = { '0.5': 'space-y-0.5', @@ -11,6 +11,7 @@ const spaces = { 3: 'space-y-3', 4: 'space-y-4', 5: 'space-y-5', + 10: 'space-y-10', }; const justifyContentOptions = { diff --git a/app/soapbox/components/ui/text/text.tsx b/app/soapbox/components/ui/text/text.tsx index c1b8f4c25..b2db90b2c 100644 --- a/app/soapbox/components/ui/text/text.tsx +++ b/app/soapbox/components/ui/text/text.tsx @@ -6,6 +6,7 @@ type Weights = 'normal' | 'medium' | 'semibold' | 'bold' type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' type Alignments = 'left' | 'center' | 'right' type TrackingSizes = 'normal' | 'wide' +type TransformProperties = 'uppercase' | 'normal' type Families = 'sans' | 'mono' type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' @@ -48,6 +49,11 @@ const trackingSizes = { wide: 'tracking-wide', }; +const transformProperties = { + normal: 'normal-case', + uppercase: 'uppercase', +}; + const families = { sans: 'font-sans', mono: 'font-mono', @@ -62,6 +68,7 @@ interface IText extends Pick, 'danger tag?: Tags, theme?: Themes, tracking?: TrackingSizes, + transform?: TransformProperties, truncate?: boolean, weight?: Weights } @@ -76,6 +83,7 @@ const Text: React.FC = React.forwardRef( tag = 'p', theme = 'default', tracking = 'normal', + transform = 'normal', truncate = false, weight = 'normal', ...filteredProps @@ -99,6 +107,7 @@ const Text: React.FC = React.forwardRef( [trackingSizes[tracking]]: true, [families[family]]: true, [alignmentClass]: typeof align !== 'undefined', + [transformProperties[transform]]: typeof transform !== 'undefined', }, className)} /> ); diff --git a/app/soapbox/components/ui/textarea/textarea.tsx b/app/soapbox/components/ui/textarea/textarea.tsx index d091f5165..81a8488b8 100644 --- a/app/soapbox/components/ui/textarea/textarea.tsx +++ b/app/soapbox/components/ui/textarea/textarea.tsx @@ -1,14 +1,13 @@ import classNames from 'classnames'; import React from 'react'; -interface ITextarea { +interface ITextarea extends Pick, 'maxLength' | 'onChange' | 'required'> { autoFocus?: boolean, defaultValue?: string, name?: string, isCodeEditor?: boolean, placeholder?: string, value?: string, - onChange?: (event: React.ChangeEvent) => void } const Textarea = React.forwardRef( diff --git a/app/soapbox/containers/soapbox.js b/app/soapbox/containers/soapbox.js index b3f318c01..e5f0de6fd 100644 --- a/app/soapbox/containers/soapbox.js +++ b/app/soapbox/containers/soapbox.js @@ -19,7 +19,9 @@ import { FE_SUBDIRECTORY } from 'soapbox/build_config'; import { NODE_ENV } from 'soapbox/build_config'; import Helmet from 'soapbox/components/helmet'; import AuthLayout from 'soapbox/features/auth_layout'; +import OnboardingWizard from 'soapbox/features/onboarding/onboarding-wizard'; import PublicLayout from 'soapbox/features/public_layout'; +import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container'; import WaitlistPage from 'soapbox/features/verification/waitlist_page'; import { createGlobals } from 'soapbox/globals'; import messages from 'soapbox/locales/messages'; @@ -28,10 +30,9 @@ import { getFeatures } from 'soapbox/utils/features'; import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; import { generateThemeCss } from 'soapbox/utils/theme'; -import { INTRODUCTION_VERSION } from '../actions/onboarding'; +import { ONBOARDING_VERSION } from '../actions/onboarding'; import { preload } from '../actions/preload'; import ErrorBoundary from '../components/error_boundary'; -// import Introduction from '../features/introduction'; import UI from '../features/ui'; import { store } from '../store'; @@ -71,15 +72,14 @@ const makeAccount = makeGetAccount(); const mapStateToProps = (state) => { const me = state.get('me'); const account = makeAccount(state, me); - const showIntroduction = account ? state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION : false; const settings = getSettings(state); + const needsOnboarding = settings.get('onboardingVersion') < ONBOARDING_VERSION; const soapboxConfig = getSoapboxConfig(state); const locale = settings.get('locale'); const singleUserMode = soapboxConfig.get('singleUserMode') && soapboxConfig.get('singleUserModeProfile'); return { - showIntroduction, me, account, reduceMotion: settings.get('reduceMotion'), @@ -93,6 +93,7 @@ const mapStateToProps = (state) => { appleAppId: soapboxConfig.get('appleAppId'), themeMode: settings.get('themeMode'), singleUserMode, + needsOnboarding, }; }; @@ -100,12 +101,12 @@ const mapStateToProps = (state) => { class SoapboxMount extends React.PureComponent { static propTypes = { - showIntroduction: PropTypes.bool, me: SoapboxPropTypes.me, account: ImmutablePropTypes.record, reduceMotion: PropTypes.bool, underlineLinks: PropTypes.bool, systemFont: PropTypes.bool, + needsOnboarding: PropTypes.bool, dyslexicFont: PropTypes.bool, demetricator: PropTypes.bool, locale: PropTypes.string.isRequired, @@ -154,7 +155,7 @@ class SoapboxMount extends React.PureComponent { } render() { - const { me, account, themeCss, locale, singleUserMode } = this.props; + const { me, account, themeCss, locale, needsOnboarding, singleUserMode } = this.props; if (me === null) return null; if (me && !account) return null; if (!this.state.isLoaded) return null; @@ -162,6 +163,26 @@ class SoapboxMount extends React.PureComponent { const waitlisted = account && !account.getIn(['source', 'approved'], true); + if (account && !waitlisted && needsOnboarding) { + return ( + + + + + {themeCss && } + + + + + + + + + + + ); + } + const bodyClass = classNames('bg-white dark:bg-slate-900 text-base', { 'no-reduce-motion': !this.props.reduceMotion, 'underline-links': this.props.underlineLinks, diff --git a/app/soapbox/features/onboarding/onboarding-wizard.tsx b/app/soapbox/features/onboarding/onboarding-wizard.tsx new file mode 100644 index 000000000..7ef3cb804 --- /dev/null +++ b/app/soapbox/features/onboarding/onboarding-wizard.tsx @@ -0,0 +1,111 @@ +import classNames from 'classnames'; +import * as React from 'react'; +import { useDispatch } from 'react-redux'; +import ReactSwipeableViews from 'react-swipeable-views'; + +import { endOnboarding } from 'soapbox/actions/onboarding'; +import { HStack } from 'soapbox/components/ui'; + +import AvatarSelectionStep from './steps/avatar-selection-step'; +import BioStep from './steps/bio-step'; +import CompletedStep from './steps/completed-step'; +import CoverPhotoSelectionStep from './steps/cover-photo-selection-step'; +import DisplayNameStep from './steps/display-name-step'; +import SuggestedAccountsStep from './steps/suggested-accounts-step'; + +const OnboardingWizard = () => { + const dispatch = useDispatch(); + + const [currentStep, setCurrentStep] = React.useState(0); + + const handleSwipe = (nextStep: number) => { + setCurrentStep(nextStep); + }; + + const handlePreviousStep = () => { + setCurrentStep((prevStep) => Math.max(0, prevStep - 1)); + }; + + const handleNextStep = () => { + setCurrentStep((prevStep) => Math.min(prevStep + 1, steps.length - 1)); + }; + + const handleComplete = () => { + dispatch(endOnboarding()); + }; + + const steps = [ + , + , + , + , + , + , + ]; + + const handleKeyUp = ({ key }: KeyboardEvent): void => { + switch (key) { + case 'ArrowLeft': + handlePreviousStep(); + break; + case 'ArrowRight': + handleNextStep(); + break; + } + }; + + const handleDotClick = (nextStep: number) => { + setCurrentStep(nextStep); + }; + + React.useEffect(() => { + document.addEventListener('keyup', handleKeyUp); + + return () => { + document.removeEventListener('keyup', handleKeyUp); + }; + }, []); + + return ( +
+
+ +
+
+ + {steps.map((step, i) => ( +
+
+ {step} +
+
+ ))} +
+ + + {steps.map((_, i) => ( +
+
+
+ ); +}; + +export default OnboardingWizard; diff --git a/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx new file mode 100644 index 000000000..b4216ee6b --- /dev/null +++ b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx @@ -0,0 +1,137 @@ +import { AxiosError } from 'axios'; +import classNames from 'classnames'; +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { patchMe } from 'soapbox/actions/me'; +import snackbar from 'soapbox/actions/snackbar'; +import { Avatar, Button, Card, CardBody, Icon, Spinner, Stack, Text } from 'soapbox/components/ui'; +import { useOwnAccount } from 'soapbox/hooks'; +import resizeImage from 'soapbox/utils/resize_image'; + +/** Default avatar filenames from various backends */ +const DEFAULT_AVATARS = [ + '/avatars/original/missing.png', // Mastodon + '/images/avi.png', // Pleroma +]; + +/** Check if the avatar is a default avatar */ +const isDefaultAvatar = (url: string) => { + return DEFAULT_AVATARS.every(avatar => url.endsWith(avatar)); +}; + +const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => { + const dispatch = useDispatch(); + const account = useOwnAccount(); + + const fileInput = React.useRef(null); + const [selectedFile, setSelectedFile] = React.useState(); + const [isSubmitting, setSubmitting] = React.useState(false); + const [isDisabled, setDisabled] = React.useState(true); + const isDefault = account ? isDefaultAvatar(account.avatar) : false; + + const openFilePicker = () => { + fileInput.current?.click(); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const maxPixels = 400 * 400; + const [rawFile] = event.target.files || [] as any; + + resizeImage(rawFile, maxPixels).then((file) => { + const url = file ? URL.createObjectURL(file) : account?.avatar as string; + + setSelectedFile(url); + setSubmitting(true); + + const formData = new FormData(); + formData.append('avatar', rawFile); + const credentials = dispatch(patchMe(formData)); + + Promise.all([credentials]).then(() => { + setDisabled(false); + setSubmitting(false); + onNext(); + }).catch((error: AxiosError) => { + setSubmitting(false); + setDisabled(false); + setSelectedFile(null); + + if (error.response?.status === 422) { + dispatch(snackbar.error(error.response.data.error.replace('Validation failed: ', ''))); + } else { + dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.')); + } + }); + }).catch(console.error); + }; + + return ( + + +
+
+ + + + + + + + + +
+ +
+ +
+ {account && ( + + )} + + {isSubmitting && ( +
+ +
+ )} + + + + +
+ + + + + {isDisabled && ( + + )} + +
+
+
+
+
+ ); +}; + +export default AvatarSelectionStep; diff --git a/app/soapbox/features/onboarding/steps/bio-step.tsx b/app/soapbox/features/onboarding/steps/bio-step.tsx new file mode 100644 index 000000000..a67b84c6f --- /dev/null +++ b/app/soapbox/features/onboarding/steps/bio-step.tsx @@ -0,0 +1,103 @@ +import { AxiosError } from 'axios'; +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { patchMe } from 'soapbox/actions/me'; +import snackbar from 'soapbox/actions/snackbar'; +import { Button, Card, CardBody, FormGroup, Stack, Text, Textarea } from 'soapbox/components/ui'; +import { useOwnAccount } from 'soapbox/hooks'; + +const BioStep = ({ onNext }: { onNext: () => void }) => { + const dispatch = useDispatch(); + + const account = useOwnAccount(); + const [value, setValue] = React.useState(account?.source.get('note') || ''); + const [isSubmitting, setSubmitting] = React.useState(false); + const [errors, setErrors] = React.useState([]); + + const trimmedValue = value.trim(); + const isValid = trimmedValue.length > 0; + const isDisabled = !isValid; + + const handleSubmit = () => { + setSubmitting(true); + + const credentials = dispatch(patchMe({ note: value })); + + Promise.all([credentials]) + .then(() => { + setSubmitting(false); + onNext(); + }).catch((error: AxiosError) => { + setSubmitting(false); + + if (error.response?.status === 422) { + setErrors([error.response.data.error.replace('Validation failed: ', '')]); + } else { + dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.')); + } + }); + }; + + return ( + + +
+
+ + + + + + + + + +
+ + +
+ +