diff --git a/app/soapbox/actions/__tests__/onboarding.test.ts b/app/soapbox/actions/__tests__/onboarding.test.ts index cdd268ed5..08ca76284 100644 --- a/app/soapbox/actions/__tests__/onboarding.test.ts +++ b/app/soapbox/actions/__tests__/onboarding.test.ts @@ -1,101 +1,24 @@ -import { mockStore, mockWindowProperty } from 'soapbox/jest/test-helpers'; -import rootReducer from 'soapbox/reducers'; +import { getSettings } from 'soapbox/actions/settings'; +import { createTestStore, rootState } from 'soapbox/jest/test-helpers'; -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); - }); -}); +import { ONBOARDING_VERSION, endOnboarding } from '../onboarding'; describe('endOnboarding()', () => { - let mockRemoveItem: any; + it('updates the onboardingVersion setting', async() => { + const store = createTestStore(rootState); - 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); + // Sanity check: + // `onboardingVersion` should be `0` by default + const initialVersion = getSettings(store.getState()).get('onboardingVersion'); + expect(initialVersion).toBe(0); await store.dispatch(endOnboarding()); - const actions = store.getActions(); - expect(actions).toEqual([{ type: 'ONBOARDING_END' }]); - expect(mockRemoveItem.mock.calls.length).toBe(1); + // 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.ts b/app/soapbox/actions/onboarding.ts index ff12bd074..13bcf0f73 100644 --- a/app/soapbox/actions/onboarding.ts +++ b/app/soapbox/actions/onboarding.ts @@ -1,40 +1,13 @@ -const ONBOARDING_START = 'ONBOARDING_START'; -const ONBOARDING_END = 'ONBOARDING_END'; +import { changeSettingImmediate } from 'soapbox/actions/settings'; -const ONBOARDING_LOCAL_STORAGE_KEY = 'soapbox:onboarding'; +/** Repeat the onboading process when we bump the version */ +export const ONBOARDING_VERSION = 1; -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 }); +/** Finish onboarding and store the setting */ +const endOnboarding = () => (dispatch: React.Dispatch) => { + dispatch(changeSettingImmediate(['onboardingVersion'], ONBOARDING_VERSION)); }; export { - ONBOARDING_END, - ONBOARDING_START, - checkOnboardingStatus, endOnboarding, - startOnboarding, }; 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/containers/soapbox.js b/app/soapbox/containers/soapbox.js index 8be6cc7c2..e5f0de6fd 100644 --- a/app/soapbox/containers/soapbox.js +++ b/app/soapbox/containers/soapbox.js @@ -30,7 +30,7 @@ import { getFeatures } from 'soapbox/utils/features'; import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; import { generateThemeCss } from 'soapbox/utils/theme'; -import { checkOnboardingStatus } from '../actions/onboarding'; +import { ONBOARDING_VERSION } from '../actions/onboarding'; import { preload } from '../actions/preload'; import ErrorBoundary from '../components/error_boundary'; import UI from '../features/ui'; @@ -44,9 +44,6 @@ createGlobals(store); // Preload happens synchronously store.dispatch(preload()); -// This happens synchronously -store.dispatch(checkOnboardingStatus()); - /** Load initial data from the backend */ const loadInitial = () => { return async(dispatch, getState) => { @@ -76,6 +73,7 @@ const mapStateToProps = (state) => { const me = state.get('me'); const account = makeAccount(state, me); const settings = getSettings(state); + const needsOnboarding = settings.get('onboardingVersion') < ONBOARDING_VERSION; const soapboxConfig = getSoapboxConfig(state); const locale = settings.get('locale'); @@ -95,7 +93,7 @@ const mapStateToProps = (state) => { appleAppId: soapboxConfig.get('appleAppId'), themeMode: settings.get('themeMode'), singleUserMode, - needsOnboarding: state.onboarding.needsOnboarding, + needsOnboarding, }; }; @@ -157,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; @@ -165,8 +163,7 @@ class SoapboxMount extends React.PureComponent { const waitlisted = account && !account.getIn(['source', 'approved'], true); - const { needsOnboarding } = this.props; - if (!waitlisted && needsOnboarding) { + if (account && !waitlisted && needsOnboarding) { return ( @@ -176,8 +173,12 @@ class SoapboxMount extends React.PureComponent { - - + + + + + + ); } diff --git a/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx index 7df4f8628..b4216ee6b 100644 --- a/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx +++ b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx @@ -10,6 +10,17 @@ import { Avatar, Button, Card, CardBody, Icon, Spinner, Stack, Text } from 'soap 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(); @@ -18,6 +29,7 @@ const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => { 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(); @@ -100,7 +112,7 @@ const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => { - diff --git a/app/soapbox/features/onboarding/steps/display-name-step.tsx b/app/soapbox/features/onboarding/steps/display-name-step.tsx index cea1c4af2..5a2f0d7e2 100644 --- a/app/soapbox/features/onboarding/steps/display-name-step.tsx +++ b/app/soapbox/features/onboarding/steps/display-name-step.tsx @@ -6,11 +6,13 @@ import { useDispatch } from 'react-redux'; import { patchMe } from 'soapbox/actions/me'; import snackbar from 'soapbox/actions/snackbar'; import { Button, Card, CardBody, FormGroup, Input, Stack, Text } from 'soapbox/components/ui'; +import { useOwnAccount } from 'soapbox/hooks'; const DisplayNameStep = ({ onNext }: { onNext: () => void }) => { const dispatch = useDispatch(); - const [value, setValue] = React.useState(''); + const account = useOwnAccount(); + const [value, setValue] = React.useState(account?.display_name || ''); const [isSubmitting, setSubmitting] = React.useState(false); const [errors, setErrors] = React.useState([]); diff --git a/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx b/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx index e739bc744..027882d21 100644 --- a/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx +++ b/app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx @@ -61,7 +61,7 @@ const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => { theme='primary' onClick={onNext} > - Done +