From 4afed4be281b6319c328938e4ed757624a78b13c Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 8 Nov 2023 09:04:06 -0800 Subject: [PATCH] Move onboarding state to new persistence + reducer context (#1835) --- src/state/models/discovery/onboarding.ts | 106 ---------------- src/state/models/root-store.ts | 6 - src/state/models/ui/create-account.ts | 7 +- src/state/persisted/schema.ts | 6 +- src/state/shell/color-mode.tsx | 2 +- src/state/shell/index.tsx | 6 +- src/state/shell/onboarding.tsx | 119 ++++++++++++++++++ src/view/com/auth/Onboarding.tsx | 15 +-- src/view/com/auth/create/CreateAccount.tsx | 6 +- .../auth/onboarding/RecommendedFollows.tsx | 39 +++--- .../onboarding/RecommendedFollowsItem.tsx | 33 ++--- src/view/com/auth/withAuthRequired.tsx | 4 +- src/view/screens/Settings.tsx | 6 +- src/view/shell/index.web.tsx | 11 +- 14 files changed, 199 insertions(+), 167 deletions(-) delete mode 100644 src/state/models/discovery/onboarding.ts create mode 100644 src/state/shell/onboarding.tsx diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts deleted file mode 100644 index 3638e7f0d2..0000000000 --- a/src/state/models/discovery/onboarding.ts +++ /dev/null @@ -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 - } -} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 6ba78e7115..f04a9922d8 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -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) } diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts index 1711b530fb..39c881db63 100644 --- a/src/state/models/ui/create-account.ts +++ b/src/state/models/ui/create-account.ts @@ -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 = diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index c00ee500ab..708930610e 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -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({ diff --git a/src/state/shell/color-mode.tsx b/src/state/shell/color-mode.tsx index 74379da371..c6a4b8a186 100644 --- a/src/state/shell/color-mode.tsx +++ b/src/state/shell/color-mode.tsx @@ -27,7 +27,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { setState(persisted.get('colorMode')) updateDocument(persisted.get('colorMode')) }) - }, [setStateWrapped]) + }, [setState]) return ( diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx index 807ee79ab8..0bb8988a62 100644 --- a/src/state/shell/index.tsx +++ b/src/state/shell/index.tsx @@ -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<{}>) { - {children} + + {children} + diff --git a/src/state/shell/onboarding.tsx b/src/state/shell/onboarding.tsx new file mode 100644 index 0000000000..5963cc50ec --- /dev/null +++ b/src/state/shell/onboarding.tsx @@ -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( + compute(persisted.defaults.onboarding), +) +const dispatchContext = React.createContext((_: 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 ( + + + {children} + + + ) +} + +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', + } +} diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx index bec1dc236a..994f4c1492 100644 --- a/src/view/com/auth/Onboarding.tsx +++ b/src/view/com/auth/Onboarding.tsx @@ -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 ( - {store.onboarding.step === 'Welcome' && ( + {onboardingState.step === 'Welcome' && ( )} - {store.onboarding.step === 'RecommendedFeeds' && ( + {onboardingState.step === 'RecommendedFeeds' && ( )} - {store.onboarding.step === 'RecommendedFollows' && ( + {onboardingState.step === 'RecommendedFollows' && ( )} diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 1d64cc0670..c3cfb3ad39 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -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 ( 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 ? ( ) : ( ( - + )} keyExtractor={(item, index) => item.did + index.toString()} style={{flex: 1}} @@ -126,13 +127,19 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ users. - {store.onboarding.suggestedActors.isLoading ? ( + {suggestedActors.isLoading ? ( ) : ( ( - + )} keyExtractor={(item, index) => item.did + index.toString()} style={{flex: 1}} diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx index 2b26918d02..f672372b8e 100644 --- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx @@ -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 } -export const RecommendedFollowsItem: React.FC = ({item, index}) => { +export const RecommendedFollowsItem: React.FC = ({ + 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 ( = ({item, index}) => { borderRightWidth: isMobile ? undefined : 1, }, ]}> - + ) } @@ -51,9 +53,11 @@ export const RecommendedFollowsItem: React.FC = ({item, index}) => { export const ProfileCard = observer(function ProfileCardImpl({ profile, index, + insertSuggestionsByActor, }: { profile: AppBskyActorDefs.ProfileViewBasic index: number + insertSuggestionsByActor: (did: string, index: number) => Promise }) { 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') } diff --git a/src/view/com/auth/withAuthRequired.tsx b/src/view/com/auth/withAuthRequired.tsx index 25d12165fc..898f81051f 100644 --- a/src/view/com/auth/withAuthRequired.tsx +++ b/src/view/com/auth/withAuthRequired.tsx @@ -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 =

( Component: React.ComponentType

, ): React.FC

=> observer(function AuthRequired(props: P) { const store = useStores() + const onboardingState = useOnboardingState() if (store.session.isResumingSession) { return } if (!store.session.hasSession) { return } - if (store.onboarding.isActive) { + if (onboardingState.isActive) { return } return diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 09a99e6d22..37c2961b47 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -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() 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( diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 843d0b284b..1731ea2477 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -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() 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 (