Move onboarding state to new persistence + reducer context (#1835)

zio/stable
Paul Frazee 2023-11-08 09:04:06 -08:00 committed by GitHub
parent 3a211017d3
commit 4afed4be28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 199 additions and 167 deletions

View File

@ -1,106 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from '../root-store'
import {hasProp} from 'lib/type-guards'
import {track} from 'lib/analytics/analytics'
import {SuggestedActorsModel} from './suggested-actors'
export const OnboardingScreenSteps = {
Welcome: 'Welcome',
RecommendedFeeds: 'RecommendedFeeds',
RecommendedFollows: 'RecommendedFollows',
Home: 'Home',
} as const
type OnboardingStep =
(typeof OnboardingScreenSteps)[keyof typeof OnboardingScreenSteps]
const OnboardingStepsArray = Object.values(OnboardingScreenSteps)
export class OnboardingModel {
// state
step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start()
// data
suggestedActors: SuggestedActorsModel
constructor(public rootStore: RootStoreModel) {
this.suggestedActors = new SuggestedActorsModel(this.rootStore)
makeAutoObservable(this, {
rootStore: false,
hydrate: false,
serialize: false,
})
}
serialize(): unknown {
return {
step: this.step,
}
}
hydrate(v: unknown) {
if (typeof v === 'object' && v !== null) {
if (
hasProp(v, 'step') &&
typeof v.step === 'string' &&
OnboardingStepsArray.includes(v.step as OnboardingStep)
) {
this.step = v.step as OnboardingStep
}
} else {
// if there is no valid state, we'll just reset
this.reset()
}
}
/**
* Returns the name of the next screen in the onboarding process based on the current step or screen name provided.
* @param {OnboardingStep} [currentScreenName]
* @returns name of next screen in the onboarding process
*/
next(currentScreenName?: OnboardingStep) {
currentScreenName = currentScreenName || this.step
if (currentScreenName === 'Welcome') {
this.step = 'RecommendedFeeds'
return this.step
} else if (this.step === 'RecommendedFeeds') {
this.step = 'RecommendedFollows'
// prefetch recommended follows
this.suggestedActors.loadMore(true)
return this.step
} else if (this.step === 'RecommendedFollows') {
this.finish()
return this.step
} else {
// if we get here, we're in an invalid state, let's just go Home
return 'Home'
}
}
start() {
this.step = 'Welcome'
track('Onboarding:Begin')
}
finish() {
this.rootStore.me.mainFeed.refresh() // load the selected content
this.step = 'Home'
track('Onboarding:Complete')
}
reset() {
this.step = 'Welcome'
track('Onboarding:Reset')
}
skip() {
this.step = 'Home'
track('Onboarding:Skipped')
}
get isComplete() {
return this.step === 'Home'
}
get isActive() {
return !this.isComplete
}
}

View File

@ -27,7 +27,6 @@ import {logger} from '#/logger'
// remove after backend testing finishes
// -prf
import {applyDebugHeader} from 'lib/api/debug-appview-proxy-header'
import {OnboardingModel} from './discovery/onboarding'
export const appInfo = z.object({
build: z.string(),
@ -44,7 +43,6 @@ export class RootStoreModel {
shell = new ShellUiModel(this)
preferences = new PreferencesModel(this)
me = new MeModel(this)
onboarding = new OnboardingModel(this)
invitedUsers = new InvitedUsers(this)
handleResolutions = new HandleResolutionsCache()
profiles = new ProfilesCache(this)
@ -71,7 +69,6 @@ export class RootStoreModel {
appInfo: this.appInfo,
session: this.session.serialize(),
me: this.me.serialize(),
onboarding: this.onboarding.serialize(),
preferences: this.preferences.serialize(),
invitedUsers: this.invitedUsers.serialize(),
mutedThreads: this.mutedThreads.serialize(),
@ -89,9 +86,6 @@ export class RootStoreModel {
if (hasProp(v, 'me')) {
this.me.hydrate(v.me)
}
if (hasProp(v, 'onboarding')) {
this.onboarding.hydrate(v.onboarding)
}
if (hasProp(v, 'session')) {
this.session.hydrate(v.session)
}

View File

@ -9,6 +9,7 @@ import {cleanError} from 'lib/strings/errors'
import {getAge} from 'lib/strings/time'
import {track} from 'lib/analytics/analytics'
import {logger} from '#/logger'
import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
@ -90,7 +91,7 @@ export class CreateAccountModel {
}
}
async submit() {
async submit(onboardingDispatch: OnboardingDispatchContext) {
if (!this.email) {
this.setStep(2)
return this.setError('Please enter your email.')
@ -111,7 +112,7 @@ export class CreateAccountModel {
this.setIsProcessing(true)
try {
this.rootStore.onboarding.start() // start now to avoid flashing the wrong view
onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
await this.rootStore.session.createAccount({
service: this.serviceUrl,
email: this.email,
@ -122,7 +123,7 @@ export class CreateAccountModel {
/* dont await */ this.rootStore.preferences.setBirthDate(this.birthDate)
track('Create Account')
} catch (e: any) {
this.rootStore.onboarding.skip() // undo starting the onboard
onboardingDispatch({type: 'skip'}) // undo starting the onboard
let errMsg = e.toString()
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
errMsg =

View File

@ -7,9 +7,9 @@ const accountSchema = z.object({
did: z.string(),
refreshJwt: z.string().optional(),
accessJwt: z.string().optional(),
handle: z.string(),
displayName: z.string(),
aviUrl: z.string(),
handle: z.string().optional(),
displayName: z.string().optional(),
aviUrl: z.string().optional(),
})
export const schema = z.object({

View File

@ -27,7 +27,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
setState(persisted.get('colorMode'))
updateDocument(persisted.get('colorMode'))
})
}, [setStateWrapped])
}, [setState])
return (
<stateContext.Provider value={state}>

View File

@ -4,6 +4,7 @@ import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled'
import {Provider as MinimalModeProvider} from './minimal-mode'
import {Provider as ColorModeProvider} from './color-mode'
import {Provider as AltTextRequiredProvider} from './alt-text-required'
import {Provider as OnboardingProvider} from './onboarding'
export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open'
export {
@ -16,6 +17,7 @@ export {
useRequireAltTextEnabled,
useSetRequireAltTextEnabled,
} from './alt-text-required'
export {useOnboardingState, useOnboardingDispatch} from './onboarding'
export function Provider({children}: React.PropsWithChildren<{}>) {
return (
@ -23,7 +25,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
<DrawerSwipableProvider>
<MinimalModeProvider>
<ColorModeProvider>
<OnboardingProvider>
<AltTextRequiredProvider>{children}</AltTextRequiredProvider>
</OnboardingProvider>
</ColorModeProvider>
</MinimalModeProvider>
</DrawerSwipableProvider>

View File

@ -0,0 +1,119 @@
import React from 'react'
import * as persisted from '#/state/persisted'
import {track} from '#/lib/analytics/analytics'
export const OnboardingScreenSteps = {
Welcome: 'Welcome',
RecommendedFeeds: 'RecommendedFeeds',
RecommendedFollows: 'RecommendedFollows',
Home: 'Home',
} as const
type OnboardingStep =
(typeof OnboardingScreenSteps)[keyof typeof OnboardingScreenSteps]
const OnboardingStepsArray = Object.values(OnboardingScreenSteps)
type Action =
| {type: 'set'; step: OnboardingStep}
| {type: 'next'; currentStep?: OnboardingStep}
| {type: 'start'}
| {type: 'finish'}
| {type: 'skip'}
export type StateContext = persisted.Schema['onboarding'] & {
isComplete: boolean
isActive: boolean
}
export type DispatchContext = (action: Action) => void
const stateContext = React.createContext<StateContext>(
compute(persisted.defaults.onboarding),
)
const dispatchContext = React.createContext<DispatchContext>((_: Action) => {})
function reducer(state: StateContext, action: Action): StateContext {
switch (action.type) {
case 'set': {
if (OnboardingStepsArray.includes(action.step)) {
persisted.write('onboarding', {step: action.step})
return compute({...state, step: action.step})
}
return state
}
case 'next': {
const currentStep = action.currentStep || state.step
let nextStep = 'Home'
if (currentStep === 'Welcome') {
nextStep = 'RecommendedFeeds'
} else if (currentStep === 'RecommendedFeeds') {
nextStep = 'RecommendedFollows'
} else if (currentStep === 'RecommendedFollows') {
nextStep = 'Home'
}
persisted.write('onboarding', {step: nextStep})
return compute({...state, step: nextStep})
}
case 'start': {
track('Onboarding:Begin')
persisted.write('onboarding', {step: 'Welcome'})
return compute({...state, step: 'Welcome'})
}
case 'finish': {
track('Onboarding:Complete')
persisted.write('onboarding', {step: 'Home'})
return compute({...state, step: 'Home'})
}
case 'skip': {
track('Onboarding:Skipped')
persisted.write('onboarding', {step: 'Home'})
return compute({...state, step: 'Home'})
}
default: {
throw new Error('Invalid action')
}
}
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, dispatch] = React.useReducer(
reducer,
compute(persisted.get('onboarding')),
)
React.useEffect(() => {
return persisted.onUpdate(() => {
dispatch({
type: 'set',
step: persisted.get('onboarding').step as OnboardingStep,
})
})
}, [dispatch])
return (
<stateContext.Provider value={state}>
<dispatchContext.Provider value={dispatch}>
{children}
</dispatchContext.Provider>
</stateContext.Provider>
)
}
export function useOnboardingState() {
return React.useContext(stateContext)
}
export function useOnboardingDispatch() {
return React.useContext(dispatchContext)
}
export function isOnboardingActive() {
return compute(persisted.get('onboarding')).isActive
}
function compute(state: persisted.Schema['onboarding']): StateContext {
return {
...state,
isActive: state.step !== 'Home',
isComplete: state.step === 'Home',
}
}

View File

@ -4,34 +4,35 @@ import {observer} from 'mobx-react-lite'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {Welcome} from './onboarding/Welcome'
import {RecommendedFeeds} from './onboarding/RecommendedFeeds'
import {RecommendedFollows} from './onboarding/RecommendedFollows'
import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
import {useOnboardingState, useOnboardingDispatch} from '#/state/shell'
export const Onboarding = observer(function OnboardingImpl() {
const pal = usePalette('default')
const store = useStores()
const setMinimalShellMode = useSetMinimalShellMode()
const onboardingState = useOnboardingState()
const onboardingDispatch = useOnboardingDispatch()
React.useEffect(() => {
setMinimalShellMode(true)
}, [setMinimalShellMode])
const next = () => store.onboarding.next()
const skip = () => store.onboarding.skip()
const next = () => onboardingDispatch({type: 'next'})
const skip = () => onboardingDispatch({type: 'skip'})
return (
<SafeAreaView testID="onboardingView" style={[s.hContentRegion, pal.view]}>
<ErrorBoundary>
{store.onboarding.step === 'Welcome' && (
{onboardingState.step === 'Welcome' && (
<Welcome skip={skip} next={next} />
)}
{store.onboarding.step === 'RecommendedFeeds' && (
{onboardingState.step === 'RecommendedFeeds' && (
<RecommendedFeeds next={next} />
)}
{store.onboarding.step === 'RecommendedFollows' && (
{onboardingState.step === 'RecommendedFollows' && (
<RecommendedFollows next={next} />
)}
</ErrorBoundary>

View File

@ -15,6 +15,7 @@ import {s} from 'lib/styles'
import {useStores} from 'state/index'
import {CreateAccountModel} from 'state/models/ui/create-account'
import {usePalette} from 'lib/hooks/usePalette'
import {useOnboardingDispatch} from '#/state/shell'
import {Step1} from './Step1'
import {Step2} from './Step2'
@ -29,6 +30,7 @@ export const CreateAccount = observer(function CreateAccountImpl({
const pal = usePalette('default')
const store = useStores()
const model = React.useMemo(() => new CreateAccountModel(store), [store])
const onboardingDispatch = useOnboardingDispatch()
React.useEffect(() => {
screen('CreateAccount')
@ -59,14 +61,14 @@ export const CreateAccount = observer(function CreateAccountImpl({
model.next()
} else {
try {
await model.submit()
await model.submit(onboardingDispatch)
} catch {
// dont need to handle here
} finally {
track('Try Create Account')
}
}
}, [model, track])
}, [model, track, onboardingDispatch])
return (
<LoggedOutLayout

View File

@ -11,6 +11,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {RecommendedFollowsItem} from './RecommendedFollowsItem'
import {SuggestedActorsModel} from '#/state/models/discovery/suggested-actors'
type Props = {
next: () => void
@ -21,16 +22,10 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
const store = useStores()
const pal = usePalette('default')
const {isTabletOrMobile} = useWebMediaQueries()
React.useEffect(() => {
// Load suggested actors if not already loaded
// prefetch should happen in the onboarding model
if (
!store.onboarding.suggestedActors.hasLoaded ||
store.onboarding.suggestedActors.isEmpty
) {
store.onboarding.suggestedActors.loadMore(true)
}
const suggestedActors = React.useMemo(() => {
const model = new SuggestedActorsModel(store)
model.refresh()
return model
}, [store])
const title = (
@ -98,13 +93,19 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
horizontal
titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}}
contentStyle={{paddingHorizontal: 0}}>
{store.onboarding.suggestedActors.isLoading ? (
{suggestedActors.isLoading ? (
<ActivityIndicator size="large" />
) : (
<FlatList
data={store.onboarding.suggestedActors.suggestions}
data={suggestedActors.suggestions}
renderItem={({item, index}) => (
<RecommendedFollowsItem item={item} index={index} />
<RecommendedFollowsItem
item={item}
index={index}
insertSuggestionsByActor={suggestedActors.insertSuggestionsByActor.bind(
suggestedActors,
)}
/>
)}
keyExtractor={(item, index) => item.did + index.toString()}
style={{flex: 1}}
@ -126,13 +127,19 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
users.
</Text>
</View>
{store.onboarding.suggestedActors.isLoading ? (
{suggestedActors.isLoading ? (
<ActivityIndicator size="large" />
) : (
<FlatList
data={store.onboarding.suggestedActors.suggestions}
data={suggestedActors.suggestions}
renderItem={({item, index}) => (
<RecommendedFollowsItem item={item} index={index} />
<RecommendedFollowsItem
item={item}
index={index}
insertSuggestionsByActor={suggestedActors.insertSuggestionsByActor.bind(
suggestedActors,
)}
/>
)}
keyExtractor={(item, index) => item.did + index.toString()}
style={{flex: 1}}

View File

@ -1,4 +1,4 @@
import React, {useMemo} from 'react'
import React from 'react'
import {View, StyleSheet, ActivityIndicator} from 'react-native'
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {observer} from 'mobx-react-lite'
@ -18,22 +18,19 @@ import {useAnalytics} from 'lib/analytics/analytics'
type Props = {
item: SuggestedActor
index: number
insertSuggestionsByActor: (did: string, index: number) => Promise<void>
}
export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => {
export const RecommendedFollowsItem: React.FC<Props> = ({
item,
index,
insertSuggestionsByActor,
}) => {
const pal = usePalette('default')
const store = useStores()
const {isMobile} = useWebMediaQueries()
const delay = useMemo(() => {
return (
50 *
(Math.abs(store.onboarding.suggestedActors.lastInsertedAtIndex - index) %
5)
)
}, [index, store.onboarding.suggestedActors.lastInsertedAtIndex])
return (
<Animated.View
entering={FadeInRight.delay(delay).springify()}
entering={FadeInRight}
style={[
styles.cardContainer,
pal.view,
@ -43,7 +40,12 @@ export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => {
borderRightWidth: isMobile ? undefined : 1,
},
]}>
<ProfileCard key={item.did} profile={item} index={index} />
<ProfileCard
key={item.did}
profile={item}
index={index}
insertSuggestionsByActor={insertSuggestionsByActor}
/>
</Animated.View>
)
}
@ -51,9 +53,11 @@ export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => {
export const ProfileCard = observer(function ProfileCardImpl({
profile,
index,
insertSuggestionsByActor,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
index: number
insertSuggestionsByActor: (did: string, index: number) => Promise<void>
}) {
const {track} = useAnalytics()
const store = useStores()
@ -94,10 +98,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
onToggleFollow={async isFollow => {
if (isFollow) {
setAddingMoreSuggestions(true)
await store.onboarding.suggestedActors.insertSuggestionsByActor(
profile.did,
index,
)
await insertSuggestionsByActor(profile.did, index)
setAddingMoreSuggestions(false)
track('Onboarding:SuggestedFollowFollowed')
}

View File

@ -13,19 +13,21 @@ import {Onboarding} from './Onboarding'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {STATUS_PAGE_URL} from 'lib/constants'
import {useOnboardingState} from '#/state/shell'
export const withAuthRequired = <P extends object>(
Component: React.ComponentType<P>,
): React.FC<P> =>
observer(function AuthRequired(props: P) {
const store = useStores()
const onboardingState = useOnboardingState()
if (store.session.isResumingSession) {
return <Loading />
}
if (!store.session.hasSession) {
return <LoggedOut />
}
if (store.onboarding.isActive) {
if (onboardingState.isActive) {
return <Onboarding />
}
return <Component {...props} />

View File

@ -52,6 +52,7 @@ import {
useSetColorMode,
useRequireAltTextEnabled,
useSetRequireAltTextEnabled,
useOnboardingDispatch,
} from '#/state/shell'
// TEMPORARY (APP-700)
@ -70,6 +71,7 @@ export const SettingsScreen = withAuthRequired(
const setMinimalShellMode = useSetMinimalShellMode()
const requireAltTextEnabled = useRequireAltTextEnabled()
const setRequireAltTextEnabled = useSetRequireAltTextEnabled()
const onboardingDispatch = useOnboardingDispatch()
const navigation = useNavigation<NavigationProp>()
const {isMobile} = useWebMediaQueries()
const {screen, track} = useAnalytics()
@ -157,9 +159,9 @@ export const SettingsScreen = withAuthRequired(
}, [store])
const onPressResetOnboarding = React.useCallback(async () => {
store.onboarding.reset()
onboardingDispatch({type: 'start'})
Toast.show('Onboarding reset')
}, [store])
}, [onboardingDispatch])
const onPressBuildInfo = React.useCallback(() => {
Clipboard.setString(

View File

@ -17,12 +17,17 @@ import {BottomBarWeb} from './bottom-bar/BottomBarWeb'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {useAuxClick} from 'lib/hooks/useAuxClick'
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
import {
useIsDrawerOpen,
useSetDrawerOpen,
useOnboardingState,
} from '#/state/shell'
const ShellInner = observer(function ShellInnerImpl() {
const store = useStores()
const isDrawerOpen = useIsDrawerOpen()
const setDrawerOpen = useSetDrawerOpen()
const onboardingState = useOnboardingState()
const {isDesktop, isMobile} = useWebMediaQueries()
const navigator = useNavigation<NavigationProp>()
useAuxClick()
@ -34,9 +39,9 @@ const ShellInner = observer(function ShellInnerImpl() {
})
}, [navigator, store.shell, setDrawerOpen])
const showBottomBar = isMobile && !store.onboarding.isActive
const showBottomBar = isMobile && !onboardingState.isActive
const showSideNavs =
!isMobile && store.session.hasSession && !store.onboarding.isActive
!isMobile && store.session.hasSession && !onboardingState.isActive
return (
<View style={[s.hContentRegion, {overflow: 'hidden'}]}>
<View style={s.hContentRegion}>