diff --git a/packages/mobile/src/screens/sign-on-screen/SignOnStack.tsx b/packages/mobile/src/screens/sign-on-screen/SignOnStack.tsx index 399990a1aa4..98a7fb9dec6 100644 --- a/packages/mobile/src/screens/sign-on-screen/SignOnStack.tsx +++ b/packages/mobile/src/screens/sign-on-screen/SignOnStack.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useCurrentAccountUser, @@ -9,10 +9,11 @@ import type { NativeStackNavigationOptions } from '@react-navigation/native-stac import { createNativeStackNavigator } from '@react-navigation/native-stack' import { getFinishedPhase1, + getHandleField, getPage, getStartedAndFinishedSignup } from 'common/store/pages/signon/selectors' -import { Pages } from 'common/store/pages/signon/types' +import type { Pages } from 'common/store/pages/signon/types' import { Platform } from 'react-native' import { useSelector } from 'react-redux' @@ -29,6 +30,7 @@ import { SelectArtistsScreen } from './screens/SelectArtistScreen' import { SelectGenresScreen } from './screens/SelectGenresScreen' import { SignOnScreen } from './screens/SignOnScreen' import type { SignOnScreenParamList } from './types' +import { getSignOnScreen } from './utils/getSignOnScreen' const Stack = createNativeStackNavigator() const screenOptionsOverrides = { animationTypeForReplace: 'pop' as const } @@ -69,15 +71,23 @@ export const SignOnStack = (props: SignOnStackProps) => { ) const page = useSelector(getPage) + const handle = useSelector(getHandleField) const navigation = useNavigation() + const lastHandledPage = useRef(null) // Respond to signon saga page changes useEffect(() => { - // This occurs when a guest account confirms email and is ready to complete their account - if (page === Pages.PASSWORD) { - navigation.navigate('CreatePassword') - } - }, [navigation, page]) + // Process each page transition once. Handle changes on PickHandle should not + // advance the user to FinishProfile before they submit the handle. + if (lastHandledPage.current === page) return + lastHandledPage.current = page + + const screen = getSignOnScreen({ + page, + hasHandle: Boolean(handle.value) + }) + if (screen) navigation.navigate(screen) + }, [handle.value, navigation, page]) return ( ({ + finishSignUp: () => mockFinishSignUpAction, + signUp: () => mockSignUpAction +})) + +jest.mock('react-redux', () => ({ + useDispatch: () => mockDispatch, + useSelector: (selector: (state: unknown) => unknown) => + selector({ signOn: mockSignOnState }) +})) + +jest.mock('app/hooks/useNavigation', () => ({ + useNavigation: () => ({ navigate: mockNavigate }) +})) + +jest.mock('../hooks/useFastReferral', () => ({ + useFastReferral: () => mockIsFastReferral +})) + +jest.mock('app/components/loading-spinner', () => { + const React = require('react') + const { View } = require('react-native') + return (props: Record) => + React.createElement(View, { ...props, testID: 'loading-spinner' }) +}) + +jest.mock('../components/layout', () => { + const React = require('react') + const { Text, View } = require('react-native') + return { + Page: ({ children }: { children: React.ReactNode }) => + React.createElement(View, null, children), + Heading: ({ + heading, + description + }: { + heading: string + description: string + }) => + React.createElement( + View, + null, + React.createElement(Text, null, heading), + React.createElement(Text, null, description) + ) + } +}) + +jest.mock( + '@audius/harmony-native', + () => { + const React = require('react') + const { Pressable, Text, View } = require('react-native') + return { + Button: ({ + children, + onPress + }: { + children: React.ReactNode + onPress: () => void + }) => + React.createElement( + Pressable, + { accessibilityRole: 'button', onPress }, + React.createElement(Text, null, children) + ), + Flex: ({ children }: { children: React.ReactNode }) => + React.createElement(View, null, children) + } + }, + { virtual: true } +) + +describe('AccountLoadingScreen', () => { + beforeEach(() => { + jest.clearAllMocks() + mockIsFastReferral = false + mockSignOnState = { + accountReady: false, + status: 'loading' + } + }) + + it('shows a retry action when account creation fails', () => { + mockSignOnState.status = 'failure' + + render() + + expect( + screen.getByText("We Couldn't Finish Creating Your Account") + ).toBeOnTheScreen() + expect(screen.queryByTestId('loading-spinner')).not.toBeOnTheScreen() + + fireEvent.press(screen.getByRole('button', { name: 'Try Again' })) + + expect(mockDispatch).toHaveBeenCalledWith(mockSignUpAction) + }) + + it('continues showing progress while account creation is pending', () => { + render() + + expect(screen.getByTestId('loading-spinner')).toBeOnTheScreen() + expect(screen.queryByText('Try Again')).not.toBeOnTheScreen() + }) + + it('finishes signup and navigates home when the account is ready', () => { + mockSignOnState.status = 'success' + + render() + + expect(mockDispatch).toHaveBeenCalledWith(mockFinishSignUpAction) + expect(mockNavigate).toHaveBeenCalledWith('HomeStack', { + screen: 'Trending' + }) + }) +}) diff --git a/packages/mobile/src/screens/sign-on-screen/screens/AccountLoadingScreen.tsx b/packages/mobile/src/screens/sign-on-screen/screens/AccountLoadingScreen.tsx index bdbee0aad30..e5db6ab5bcc 100644 --- a/packages/mobile/src/screens/sign-on-screen/screens/AccountLoadingScreen.tsx +++ b/packages/mobile/src/screens/sign-on-screen/screens/AccountLoadingScreen.tsx @@ -1,8 +1,11 @@ // This loading page shows up when the users account is still being created either due to slow creation or a fast user -import { useEffect } from 'react' +import { useCallback, useEffect } from 'react' -import { finishSignUp } from '@audius/web/src/common/store/pages/signon/actions' +import { + finishSignUp, + signUp +} from '@audius/web/src/common/store/pages/signon/actions' import { getAccountReady, getStatus @@ -10,7 +13,7 @@ import { import { EditingStatus } from '@audius/web/src/common/store/pages/signon/types' import { useDispatch, useSelector } from 'react-redux' -import { Flex } from '@audius/harmony-native' +import { Button, Flex } from '@audius/harmony-native' import LoadingSpinner from 'app/components/loading-spinner' import { useNavigation } from 'app/hooks/useNavigation' @@ -21,7 +24,10 @@ import type { SignOnScreenParamList } from '../types' const messages = { heading: 'Your Account is Almost Ready to Rock 🤘', description: "We're just finishing up a few things...", - continueButton: 'Continue to Audius' + failureHeading: "We Couldn't Finish Creating Your Account", + failureDescription: + 'Your login is saved. Try again to finish creating your account.', + retry: 'Try Again' } // The user just waits here until the account is created and before being shown the welcome modal on the trending page @@ -36,6 +42,11 @@ export const AccountLoadingScreen = () => { const isAccountReady = isFastReferral ? accountReady : accountReady || accountCreationStatus === EditingStatus.SUCCESS + const didAccountCreationFail = accountCreationStatus === EditingStatus.FAILURE + + const handleRetry = useCallback(() => { + dispatch(signUp()) + }, [dispatch]) useEffect(() => { if (isAccountReady) { @@ -47,14 +58,27 @@ export const AccountLoadingScreen = () => { return ( - + {didAccountCreationFail ? null : ( + + )} + {didAccountCreationFail ? ( + + ) : null} ) } diff --git a/packages/mobile/src/screens/sign-on-screen/utils/getSignOnScreen.test.ts b/packages/mobile/src/screens/sign-on-screen/utils/getSignOnScreen.test.ts new file mode 100644 index 00000000000..0fbfa55eda6 --- /dev/null +++ b/packages/mobile/src/screens/sign-on-screen/utils/getSignOnScreen.test.ts @@ -0,0 +1,27 @@ +import { Pages } from '@audius/web/src/common/store/pages/signon/types' + +import { getSignOnScreen } from './getSignOnScreen' + +describe('getSignOnScreen', () => { + it('routes Identity-only accounts to handle selection', () => { + expect(getSignOnScreen({ page: Pages.PROFILE, hasHandle: false })).toBe( + 'PickHandle' + ) + }) + + it('routes indexed incomplete accounts to profile completion', () => { + expect(getSignOnScreen({ page: Pages.PROFILE, hasHandle: true })).toBe( + 'FinishProfile' + ) + }) + + it('preserves the guest password resume screen', () => { + expect(getSignOnScreen({ page: Pages.PASSWORD, hasHandle: true })).toBe( + 'CreatePassword' + ) + }) + + it('ignores sign-on states without a native transition', () => { + expect(getSignOnScreen({ page: Pages.EMAIL, hasHandle: false })).toBeNull() + }) +}) diff --git a/packages/mobile/src/screens/sign-on-screen/utils/getSignOnScreen.ts b/packages/mobile/src/screens/sign-on-screen/utils/getSignOnScreen.ts new file mode 100644 index 00000000000..91fe86631f4 --- /dev/null +++ b/packages/mobile/src/screens/sign-on-screen/utils/getSignOnScreen.ts @@ -0,0 +1,26 @@ +import { Pages } from '@audius/web/src/common/store/pages/signon/types' + +type GetSignOnScreenParams = { + page: Pages + hasHandle: boolean +} + +export type ResumableSignOnScreen = + | 'CreatePassword' + | 'PickHandle' + | 'FinishProfile' + +/** Maps shared sign-on state to the equivalent native navigation screen. */ +export const getSignOnScreen = ({ + page, + hasHandle +}: GetSignOnScreenParams): ResumableSignOnScreen | null => { + switch (page) { + case Pages.PASSWORD: + return 'CreatePassword' + case Pages.PROFILE: + return hasHandle ? 'FinishProfile' : 'PickHandle' + default: + return null + } +} diff --git a/packages/web/src/common/store/pages/signon/getSignOnRoute.test.ts b/packages/web/src/common/store/pages/signon/getSignOnRoute.test.ts index d67f35ca6f2..49a8d6e68b9 100644 --- a/packages/web/src/common/store/pages/signon/getSignOnRoute.test.ts +++ b/packages/web/src/common/store/pages/signon/getSignOnRoute.test.ts @@ -1,14 +1,16 @@ -import { - SIGN_IN_PAGE, - SIGN_UP_FINISH_PROFILE_PAGE, - SIGN_UP_HANDLE_PAGE, - SIGN_UP_PASSWORD_PAGE -} from '@audius/common/src/utils/route' +import { route } from '@audius/common/utils' import { describe, expect, it } from 'vitest' import { getSignOnRoute } from './getSignOnRoute' import { Pages } from './types' +const { + SIGN_IN_PAGE, + SIGN_UP_FINISH_PROFILE_PAGE, + SIGN_UP_HANDLE_PAGE, + SIGN_UP_PASSWORD_PAGE +} = route + describe('getSignOnRoute', () => { it('routes Identity-only accounts to handle selection', () => { expect( diff --git a/packages/web/src/common/store/pages/signon/getSignOnRoute.ts b/packages/web/src/common/store/pages/signon/getSignOnRoute.ts index 9259bafbb67..31c7f798502 100644 --- a/packages/web/src/common/store/pages/signon/getSignOnRoute.ts +++ b/packages/web/src/common/store/pages/signon/getSignOnRoute.ts @@ -1,12 +1,14 @@ -import { +import { route } from '@audius/common/utils' + +import { Pages } from './types' + +const { SIGN_IN_PAGE, SIGN_UP_FINISH_PROFILE_PAGE, SIGN_UP_HANDLE_PAGE, SIGN_UP_PAGE, SIGN_UP_PASSWORD_PAGE -} from '@audius/common/src/utils/route' - -import { Pages } from './types' +} = route type GetSignOnRouteParams = { signIn: boolean