From 6aa69f57b8ace3252b644f67699ed35ce73388d0 Mon Sep 17 00:00:00 2001 From: Justin Date: Thu, 7 Apr 2022 13:39:22 -0400 Subject: [PATCH 01/16] Convert Registration component to TSX --- app/soapbox/components/ui/input/input.tsx | 4 ++-- .../{registration.js => registration.tsx} | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) rename app/soapbox/features/verification/{registration.js => registration.tsx} (87%) diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 343a9d0dd..4b81ed440 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'> { +interface IInput extends Pick, 'onChange' | 'required' | 'type'> { autoFocus?: boolean, defaultValue?: string, className?: string, @@ -19,7 +19,7 @@ interface IInput extends Pick, 'onCh name?: string, placeholder?: string, value?: string, - onChange?: () => void, + onChange?: (event: React.ChangeEvent) => void, type: 'text' | 'email' | 'tel' | 'password' } diff --git a/app/soapbox/features/verification/registration.js b/app/soapbox/features/verification/registration.tsx similarity index 87% rename from app/soapbox/features/verification/registration.js rename to app/soapbox/features/verification/registration.tsx index b00be7ad0..9d87ae410 100644 --- a/app/soapbox/features/verification/registration.js +++ b/app/soapbox/features/verification/registration.tsx @@ -1,6 +1,7 @@ +import { AxiosError } from 'axios'; import * as React from 'react'; import { useIntl } from 'react-intl'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { Redirect } from 'react-router-dom'; import { logIn, verifyCredentials } from 'soapbox/actions/auth'; @@ -8,6 +9,7 @@ import { fetchInstance } from 'soapbox/actions/instance'; import snackbar from 'soapbox/actions/snackbar'; import { createAccount } from 'soapbox/actions/verification'; import { removeStoredVerification } from 'soapbox/actions/verification'; +import { useAppSelector } from 'soapbox/hooks'; import { Button, Form, FormGroup, Input } from '../../components/ui'; @@ -20,11 +22,11 @@ const Registration = () => { const dispatch = useDispatch(); const intl = useIntl(); - const isLoading = useSelector((state) => state.verification.get('isLoading')); - const siteTitle = useSelector((state) => state.instance.title); + const isLoading = useAppSelector((state) => state.verification.get('isLoading') as boolean); + const siteTitle = useAppSelector((state) => state.instance.title); const [state, setState] = React.useState(initialState); - const [shouldRedirect, setShouldRedirect] = React.useState(false); + const [shouldRedirect, setShouldRedirect] = React.useState(false); const { username, password } = state; const handleSubmit = React.useCallback((event) => { @@ -33,7 +35,7 @@ const Registration = () => { // TODO: handle validation errors from Pepe dispatch(createAccount(username, password)) .then(() => dispatch(logIn(intl, username, password))) - .then(({ access_token }) => dispatch(verifyCredentials(access_token))) + .then(({ access_token }: any) => dispatch(verifyCredentials(access_token))) .then(() => dispatch(fetchInstance())) .then(() => { setShouldRedirect(true); @@ -47,7 +49,7 @@ const Registration = () => { ), ); }) - .catch((error) => { + .catch((error: AxiosError) => { if (error?.response?.status === 422) { dispatch( snackbar.error( From 5e626995dfed14391d831c2bf2e332e34add9e1f Mon Sep 17 00:00:00 2001 From: Justin Date: Thu, 7 Apr 2022 13:39:53 -0400 Subject: [PATCH 02/16] Convert Registration component test to TSX --- .../__tests__/{registration.test.js => registration.test.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/soapbox/features/verification/__tests__/{registration.test.js => registration.test.tsx} (100%) diff --git a/app/soapbox/features/verification/__tests__/registration.test.js b/app/soapbox/features/verification/__tests__/registration.test.tsx similarity index 100% rename from app/soapbox/features/verification/__tests__/registration.test.js rename to app/soapbox/features/verification/__tests__/registration.test.tsx From f625e13a251d6d63d036745d736a969a0251353d Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:49:41 -0400 Subject: [PATCH 03/16] Update Textarea type --- app/soapbox/components/ui/textarea/textarea.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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( From b7e9015dfd3fd32cc7c9d00d30399c2949b1affa Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:50:04 -0400 Subject: [PATCH 04/16] Add 'transform' prop to Text --- app/soapbox/components/ui/text/text.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) 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)} /> ); From 3e0d7de2cd8ec759d13206f025a352fe4396bb9e Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:50:17 -0400 Subject: [PATCH 05/16] Add more sizes to Stack --- app/soapbox/components/ui/stack/stack.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 = { From fd6ae83fe7ae398e276cbfde384b1bc0402e1a27 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:50:29 -0400 Subject: [PATCH 06/16] Add 'maxLength' prop to Input --- app/soapbox/components/ui/input/input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 4b81ed440..6441cd37b 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' | 'required' | 'type'> { +interface IInput extends Pick, 'maxLength' | 'onChange' | 'required' | 'type'> { autoFocus?: boolean, defaultValue?: string, className?: string, From afec2ad9eae37fdd3592d9282454816124e15f47 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:50:39 -0400 Subject: [PATCH 07/16] Add 'strokeWidth' prop to Icon --- app/soapbox/components/ui/icon/icon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 3bd8ef13ef01e349cdb1b42e9204e05bb2d5ebd0 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:50:54 -0400 Subject: [PATCH 08/16] Add 'link' theme to Button --- app/soapbox/components/ui/button/useButtonStyles.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 = { From c8c715ee4b13e790d9bd7ab09020f7acb73beedd Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:51:28 -0400 Subject: [PATCH 09/16] Add Onboarding controls to Redux --- .../actions/__tests__/onboarding.test.ts | 101 ++++++++++++++++++ app/soapbox/actions/onboarding.js | 8 -- app/soapbox/actions/onboarding.ts | 40 +++++++ app/soapbox/jest/test-helpers.tsx | 18 ++++ .../reducers/__tests__/onboarding.test.ts | 27 +++++ app/soapbox/reducers/index.ts | 2 + app/soapbox/reducers/onboarding.ts | 22 ++++ 7 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 app/soapbox/actions/__tests__/onboarding.test.ts delete mode 100644 app/soapbox/actions/onboarding.js create mode 100644 app/soapbox/actions/onboarding.ts create mode 100644 app/soapbox/reducers/__tests__/onboarding.test.ts create mode 100644 app/soapbox/reducers/onboarding.ts diff --git a/app/soapbox/actions/__tests__/onboarding.test.ts b/app/soapbox/actions/__tests__/onboarding.test.ts new file mode 100644 index 000000000..cdd268ed5 --- /dev/null +++ b/app/soapbox/actions/__tests__/onboarding.test.ts @@ -0,0 +1,101 @@ +import { mockStore, mockWindowProperty } from 'soapbox/jest/test-helpers'; +import rootReducer from 'soapbox/reducers'; + +import { checkOnboardingStatus, startOnboarding, endOnboarding } from '../onboarding'; + +describe('checkOnboarding()', () => { + let mockGetItem: any; + + mockWindowProperty('localStorage', { + getItem: (key: string) => mockGetItem(key), + }); + + beforeEach(() => { + mockGetItem = jest.fn().mockReturnValue(null); + }); + + it('does nothing if localStorage item is not set', async() => { + mockGetItem = jest.fn().mockReturnValue(null); + + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); + + it('does nothing if localStorage item is invalid', async() => { + mockGetItem = jest.fn().mockReturnValue('invalid'); + + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); + + it('dispatches the correct action', async() => { + mockGetItem = jest.fn().mockReturnValue('1'); + + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_START' }]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); +}); + +describe('startOnboarding()', () => { + let mockSetItem: any; + + mockWindowProperty('localStorage', { + setItem: (key: string, value: string) => mockSetItem(key, value), + }); + + beforeEach(() => { + mockSetItem = jest.fn(); + }); + + it('dispatches the correct action', async() => { + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(startOnboarding()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_START' }]); + expect(mockSetItem.mock.calls.length).toBe(1); + }); +}); + +describe('endOnboarding()', () => { + let mockRemoveItem: any; + + mockWindowProperty('localStorage', { + removeItem: (key: string) => mockRemoveItem(key), + }); + + beforeEach(() => { + mockRemoveItem = jest.fn(); + }); + + it('dispatches the correct action', async() => { + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(endOnboarding()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_END' }]); + expect(mockRemoveItem.mock.calls.length).toBe(1); + }); +}); 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..ff12bd074 --- /dev/null +++ b/app/soapbox/actions/onboarding.ts @@ -0,0 +1,40 @@ +const ONBOARDING_START = 'ONBOARDING_START'; +const ONBOARDING_END = 'ONBOARDING_END'; + +const ONBOARDING_LOCAL_STORAGE_KEY = 'soapbox:onboarding'; + +type OnboardingStartAction = { + type: typeof ONBOARDING_START +} + +type OnboardingEndAction = { + type: typeof ONBOARDING_END +} + +export type OnboardingActions = OnboardingStartAction | OnboardingEndAction + +const checkOnboardingStatus = () => (dispatch: React.Dispatch) => { + const needsOnboarding = localStorage.getItem(ONBOARDING_LOCAL_STORAGE_KEY) === '1'; + + if (needsOnboarding) { + dispatch({ type: ONBOARDING_START }); + } +}; + +const startOnboarding = () => (dispatch: React.Dispatch) => { + localStorage.setItem(ONBOARDING_LOCAL_STORAGE_KEY, '1'); + dispatch({ type: ONBOARDING_START }); +}; + +const endOnboarding = () => (dispatch: React.Dispatch) => { + localStorage.removeItem(ONBOARDING_LOCAL_STORAGE_KEY); + dispatch({ type: ONBOARDING_END }); +}; + +export { + ONBOARDING_END, + ONBOARDING_START, + checkOnboardingStatus, + endOnboarding, + startOnboarding, +}; diff --git a/app/soapbox/jest/test-helpers.tsx b/app/soapbox/jest/test-helpers.tsx index 7a4f8f53b..0b195e404 100644 --- a/app/soapbox/jest/test-helpers.tsx +++ b/app/soapbox/jest/test-helpers.tsx @@ -63,6 +63,23 @@ const customRender = ( ...options, }); +const mockWindowProperty = (property: any, value: any) => { + const { [property]: originalProperty } = window; + delete window[property]; + + beforeAll(() => { + Object.defineProperty(window, property, { + configurable: true, + writable: true, + value, + }); + }); + + afterAll(() => { + window[property] = originalProperty; + }); +}; + export * from '@testing-library/react'; export { customRender as render, @@ -70,4 +87,5 @@ export { applyActions, rootState, rootReducer, + mockWindowProperty, }; diff --git a/app/soapbox/reducers/__tests__/onboarding.test.ts b/app/soapbox/reducers/__tests__/onboarding.test.ts new file mode 100644 index 000000000..95ecdf755 --- /dev/null +++ b/app/soapbox/reducers/__tests__/onboarding.test.ts @@ -0,0 +1,27 @@ +import { ONBOARDING_START, ONBOARDING_END } from 'soapbox/actions/onboarding'; + +import reducer from '../onboarding'; + +describe('onboarding reducer', () => { + it('should return the initial state', () => { + expect(reducer(undefined, {})).toEqual({ + needsOnboarding: false, + }); + }); + + describe('ONBOARDING_START', () => { + it('sets "needsOnboarding" to "true"', () => { + const initialState = { needsOnboarding: false }; + const action = { type: ONBOARDING_START }; + expect(reducer(initialState, action).needsOnboarding).toEqual(true); + }); + }); + + describe('ONBOARDING_END', () => { + it('sets "needsOnboarding" to "false"', () => { + const initialState = { needsOnboarding: true }; + const action = { type: ONBOARDING_END }; + expect(reducer(initialState, action).needsOnboarding).toEqual(false); + }); + }); +}); diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index 4e54ec19f..61234e2ed 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -39,6 +39,7 @@ import meta from './meta'; import modals from './modals'; import mutes from './mutes'; import notifications from './notifications'; +import onboarding from './onboarding'; import patron from './patron'; import pending_statuses from './pending_statuses'; import polls from './polls'; @@ -118,6 +119,7 @@ const reducers = { accounts_meta, trending_statuses, verification, + onboarding, }; // Build a default state from all reducers: it has the key and `undefined` diff --git a/app/soapbox/reducers/onboarding.ts b/app/soapbox/reducers/onboarding.ts new file mode 100644 index 000000000..844d6b353 --- /dev/null +++ b/app/soapbox/reducers/onboarding.ts @@ -0,0 +1,22 @@ +import { ONBOARDING_START, ONBOARDING_END } from 'soapbox/actions/onboarding'; + +import type { OnboardingActions } from 'soapbox/actions/onboarding'; + +type OnboardingState = { + needsOnboarding: boolean, +} + +const initialState: OnboardingState = { + needsOnboarding: false, +}; + +export default function onboarding(state: OnboardingState = initialState, action: OnboardingActions): OnboardingState { + switch(action.type) { + case ONBOARDING_START: + return { ...state, needsOnboarding: true }; + case ONBOARDING_END: + return { ...state, needsOnboarding: false }; + default: + return state; + } +} From 98c77006ce80236b5e1698cda2eba88a5a930e22 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:52:04 -0400 Subject: [PATCH 10/16] Add Onboarding components --- app/soapbox/containers/soapbox.js | 33 ++-- .../features/onboarding/onboarding-wizard.tsx | 111 ++++++++++++++ .../steps/avatar-selection-step.tsx | 118 +++++++++++++++ .../features/onboarding/steps/bio-step.tsx | 94 ++++++++++++ .../onboarding/steps/completed-step.tsx | 37 +++++ .../steps/cover-photo-selection-step.tsx | 142 ++++++++++++++++++ .../onboarding/steps/display-name-step.tsx | 100 ++++++++++++ .../steps/suggested-accounts-step.tsx | 76 ++++++++++ .../features/verification/registration.tsx | 2 + package.json | 1 + yarn.lock | 7 + 11 files changed, 711 insertions(+), 10 deletions(-) create mode 100644 app/soapbox/features/onboarding/onboarding-wizard.tsx create mode 100644 app/soapbox/features/onboarding/steps/avatar-selection-step.tsx create mode 100644 app/soapbox/features/onboarding/steps/bio-step.tsx create mode 100644 app/soapbox/features/onboarding/steps/completed-step.tsx create mode 100644 app/soapbox/features/onboarding/steps/cover-photo-selection-step.tsx create mode 100644 app/soapbox/features/onboarding/steps/display-name-step.tsx create mode 100644 app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx diff --git a/app/soapbox/containers/soapbox.js b/app/soapbox/containers/soapbox.js index c1a7db84e..4c5a8914d 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'; @@ -27,10 +29,9 @@ import { makeGetAccount } from 'soapbox/selectors'; import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; import { generateThemeCss } from 'soapbox/utils/theme'; -import { INTRODUCTION_VERSION } from '../actions/onboarding'; +import { checkOnboardingStatus } 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'; @@ -54,6 +55,7 @@ store.dispatch(fetchMe()) // Postpone for authenticated fetch store.dispatch(loadInstance()); store.dispatch(loadSoapboxConfig()); + store.dispatch(checkOnboardingStatus()); if (!account) { store.dispatch(fetchVerificationConfig()); @@ -66,7 +68,6 @@ 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 soapboxConfig = getSoapboxConfig(state); const locale = settings.get('locale'); @@ -74,7 +75,6 @@ const mapStateToProps = (state) => { const singleUserMode = soapboxConfig.get('singleUserMode') && soapboxConfig.get('singleUserModeProfile'); return { - showIntroduction, me, account, instanceLoaded: isInstanceLoaded(state), @@ -88,6 +88,7 @@ const mapStateToProps = (state) => { brandColor: soapboxConfig.get('brandColor'), themeMode: settings.get('themeMode'), singleUserMode, + needsOnboarding: state.onboarding.needsOnboarding, }; }; @@ -95,13 +96,13 @@ const mapStateToProps = (state) => { class SoapboxMount extends React.PureComponent { static propTypes = { - showIntroduction: PropTypes.bool, me: SoapboxPropTypes.me, account: ImmutablePropTypes.record, instanceLoaded: PropTypes.bool, reduceMotion: PropTypes.bool, underlineLinks: PropTypes.bool, systemFont: PropTypes.bool, + needsOnboarding: PropTypes.bool, dyslexicFont: PropTypes.bool, demetricator: PropTypes.bool, locale: PropTypes.string.isRequired, @@ -151,11 +152,23 @@ class SoapboxMount extends React.PureComponent { const waitlisted = account && !account.getIn(['source', 'approved'], true); // Disabling introduction for launch - // const { showIntroduction } = this.props; - // - // if (showIntroduction) { - // return ; - // } + const { needsOnboarding } = this.props; + + if (needsOnboarding) { + return ( + + + + + {themeCss && } + + + + + + + ); + } const bodyClass = classNames('bg-white dark:bg-slate-900 text-base', { 'no-reduce-motion': !this.props.reduceMotion, 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..3f2fec197 --- /dev/null +++ b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx @@ -0,0 +1,118 @@ +import { AxiosError } from 'axios'; +import classNames from 'classnames'; +import * as React from 'react'; +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'; + +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 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 ( + + +
+
+ + + Choose a profile picture + + + + Just have fun with it. + + +
+ +
+ +
+ {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..384adc311 --- /dev/null +++ b/app/soapbox/features/onboarding/steps/bio-step.tsx @@ -0,0 +1,94 @@ +import { AxiosError } from 'axios'; +import * as React from 'react'; +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'; + +const BioStep = ({ onNext }: { onNext: () => void }) => { + const dispatch = useDispatch(); + + const [value, setValue] = React.useState(''); + 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 ( + + +
+
+ + + Write a short bio + + + + You can always edit this later. + + +
+ + +
+ +