Move onboarding state to new persistence + reducer context (#1835)
This commit is contained in:
parent
3a211017d3
commit
4afed4be28
14 changed files with 199 additions and 167 deletions
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
<AltTextRequiredProvider>{children}</AltTextRequiredProvider>
|
||||
<OnboardingProvider>
|
||||
<AltTextRequiredProvider>{children}</AltTextRequiredProvider>
|
||||
</OnboardingProvider>
|
||||
</ColorModeProvider>
|
||||
</MinimalModeProvider>
|
||||
</DrawerSwipableProvider>
|
||||
|
|
119
src/state/shell/onboarding.tsx
Normal file
119
src/state/shell/onboarding.tsx
Normal 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',
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue