From e9a8cf5c26984bf66da8624de7d85c65b640b6eb Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 5 Dec 2025 16:21:40 +0200 Subject: [PATCH] restructure onboarding to better support dynamic screens --- src/lib/statsig/gates.ts | 1 + src/screens/Onboarding/Layout.tsx | 10 +- src/screens/Onboarding/StepFinished/index.tsx | 114 +------------- .../Onboarding/StepInterests/index.tsx | 4 +- src/screens/Onboarding/StepProfile/index.tsx | 4 +- .../StepSuggestedAccounts/index.tsx | 13 +- .../StepSuggestedStarterpacks/index.tsx | 5 +- src/screens/Onboarding/index.tsx | 25 +-- src/screens/Onboarding/state.ts | 148 +++++++++++------- 9 files changed, 133 insertions(+), 191 deletions(-) diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index ea263e22e5..8c757a0160 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -3,6 +3,7 @@ export type Gate = | 'alt_share_icon' | 'debug_show_feedcontext' | 'debug_subscriptions' + | 'disable_onboarding_find_contacts' | 'explore_show_suggested_feeds' | 'feed_reply_button_open_thread' | 'old_postonboarding' diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx index 84448d97d4..95607214f6 100644 --- a/src/screens/Onboarding/Layout.tsx +++ b/src/screens/Onboarding/Layout.tsx @@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react' import {isAndroid, isWeb} from '#/platform/detection' import {useOnboardingDispatch} from '#/state/shell' -import {Context} from '#/screens/Onboarding/state' +import {useOnboardingInternalState} from '#/screens/Onboarding/state' import { atoms as a, native, @@ -34,7 +34,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) { const insets = useSafeAreaInsets() const {gtMobile} = useBreakpoints() const onboardDispatch = useOnboardingDispatch() - const {state, dispatch} = React.useContext(Context) + const {state, dispatch} = useOnboardingInternalState() const scrollview = React.useRef(null) const prevActiveStep = React.useRef(state.activeStep) @@ -104,7 +104,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) { a.justify_between, {maxWidth: ONBOARDING_COL_WIDTH}, ]}> - {state.hasPrev ? ( + {state.canGoBack ? ( - - - ) -} diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx index 3e51409757..ee28601e38 100644 --- a/src/screens/Onboarding/StepInterests/index.tsx +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -12,7 +12,7 @@ import { OnboardingControls, TitleText, } from '#/screens/Onboarding/Layout' -import {Context} from '#/screens/Onboarding/state' +import {useOnboardingInternalState} from '#/screens/Onboarding/state' import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton' import {atoms as a} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -26,7 +26,7 @@ export function StepInterests() { const {_} = useLingui() const interestsDisplayNames = useInterestsDisplayNames() - const {state, dispatch} = React.useContext(Context) + const {state, dispatch} = useOnboardingInternalState() const [saving, setSaving] = React.useState(false) const [selectedInterests, setSelectedInterests] = React.useState( state.interestsStepResults.selectedInterests.map(i => i), diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 453184639a..18ffced8a7 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -23,7 +23,7 @@ import { OnboardingControls, TitleText, } from '#/screens/Onboarding/Layout' -import {Context} from '#/screens/Onboarding/state' +import {useOnboardingInternalState} from '#/screens/Onboarding/state' import {AvatarCircle} from '#/screens/Onboarding/StepProfile/AvatarCircle' import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle' import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems' @@ -78,7 +78,7 @@ export function StepProfile() { const creatorControl = Dialog.useDialogControl() const [error, setError] = React.useState('') - const {state, dispatch} = React.useContext(Context) + const {state, dispatch} = useOnboardingInternalState() const [avatar, setAvatar] = React.useState({ image: state.profileStepResults?.image, placeholder: state.profileStepResults.creatorState?.emoji || emojiItems.at, diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx index 200ccb71e6..1219d3e98b 100644 --- a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -1,11 +1,4 @@ -import { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {View} from 'react-native' import {type ModerationOpts} from '@atproto/api' import {msg, Trans} from '@lingui/macro' @@ -23,7 +16,7 @@ import {useLanguagePrefs} from '#/state/preferences' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useAgent, useSession} from '#/state/session' import {OnboardingControls} from '#/screens/Onboarding/Layout' -import {Context} from '#/screens/Onboarding/state' +import {useOnboardingInternalState} from '#/screens/Onboarding/state' import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers' import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' import {Admonition} from '#/components/Admonition' @@ -47,7 +40,7 @@ export function StepSuggestedAccounts() { const {currentAccount} = useSession() const queryClient = useQueryClient() - const {state, dispatch} = useContext(Context) + const {state, dispatch} = useOnboardingInternalState() const [selectedInterest, setSelectedInterest] = useState(null) // keeping track of who was followed via the follow all button diff --git a/src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx b/src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx index 5ba00ef7d1..fcd37a329c 100644 --- a/src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx +++ b/src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx @@ -1,4 +1,3 @@ -import {useContext} from 'react' import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -6,7 +5,7 @@ import {useLingui} from '@lingui/react' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useOnboardingSuggestedStarterPacksQuery} from '#/state/queries/useOnboardingSuggestedStarterPacksQuery' import {OnboardingControls} from '#/screens/Onboarding/Layout' -import {Context} from '#/screens/Onboarding/state' +import {useOnboardingInternalState} from '#/screens/Onboarding/state' import {atoms as a, useBreakpoints} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -20,7 +19,7 @@ export function StepSuggestedStarterpacks() { const {gtMobile} = useBreakpoints() const moderationOpts = useModerationOpts() - const {state, dispatch} = useContext(Context) + const {state, dispatch} = useOnboardingInternalState() const { data: suggestedStarterPacks, diff --git a/src/screens/Onboarding/index.tsx b/src/screens/Onboarding/index.tsx index adf6a3f618..2e91ac2ad0 100644 --- a/src/screens/Onboarding/index.tsx +++ b/src/screens/Onboarding/index.tsx @@ -4,13 +4,18 @@ import {useLingui} from '@lingui/react' import * as bcp47Match from 'bcp-47-match' import {useGate} from '#/lib/statsig/statsig' +import {isNative} from '#/platform/detection' import {useLanguagePrefs} from '#/state/preferences' import { Layout, OnboardingControls, OnboardingHeaderSlot, } from '#/screens/Onboarding/Layout' -import {Context, initialState, reducer} from '#/screens/Onboarding/state' +import { + Context, + createInitialOnboardingState, + reducer, +} from '#/screens/Onboarding/state' import {StepFinished} from '#/screens/Onboarding/StepFinished' import {StepInterests} from '#/screens/Onboarding/StepInterests' import {StepProfile} from '#/screens/Onboarding/StepProfile' @@ -36,15 +41,16 @@ export function Onboarding() { probablySpeaksEnglish && gate('onboarding_suggested_starterpacks') - const [state, dispatch] = useReducer(reducer, { - ...initialState, - totalSteps: 4 + (showSuggestedStarterpacks ? 1 : 0), - experiments: { - onboarding_suggested_accounts: true, - onboarding_value_prop: true, - onboarding_suggested_starterpacks: showSuggestedStarterpacks, + const showFindContacts = isNative && !gate('disable_onboarding_find_contacts') + + const [state, dispatch] = useReducer( + reducer, + { + starterPacksStepEnabled: showSuggestedStarterpacks, + findContactsStepEnabled: showFindContacts, }, - }) + createInitialOnboardingState, + ) const interestsDisplayNames = useMemo(() => { return { @@ -94,6 +100,7 @@ export function Onboarding() { {state.activeStep === 'suggested-starterpacks' && ( )} + {state.activeStep === 'find-contacts' && null} {state.activeStep === 'finished' && } diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts index e9877a3d6b..9166952fc0 100644 --- a/src/screens/Onboarding/state.ts +++ b/src/screens/Onboarding/state.ts @@ -1,4 +1,4 @@ -import React from 'react' +import {createContext, useContext, useMemo} from 'react' import {logger} from '#/logger' import { @@ -6,16 +6,17 @@ import { type Emoji, } from '#/screens/Onboarding/StepProfile/types' +type OnboardingScreen = + | 'profile' + | 'interests' + | 'suggested-accounts' + | 'suggested-starterpacks' + | 'find-contacts' + | 'finished' + export type OnboardingState = { - hasPrev: boolean - totalSteps: number - activeStep: - | 'profile' - | 'interests' - | 'suggested-accounts' - | 'suggested-starterpacks' - | 'finished' - activeStepIndex: number + screens: Record + activeStep: OnboardingScreen stepTransitionDirection: 'Forward' | 'Backward' interestsStepResults: { @@ -37,12 +38,6 @@ export type OnboardingState = { backgroundColor: AvatarColor } } - - experiments?: { - onboarding_suggested_accounts?: boolean - onboarding_value_prop?: boolean - onboarding_suggested_starterpacks?: boolean - } } export type OnboardingAction = @@ -73,31 +68,44 @@ export type OnboardingAction = | undefined } -export const initialState: OnboardingState = { - hasPrev: false, - totalSteps: 3, - activeStep: 'profile', - activeStepIndex: 1, - stepTransitionDirection: 'Forward', +export function createInitialOnboardingState( + { + starterPacksStepEnabled, + findContactsStepEnabled, + }: { + starterPacksStepEnabled: boolean + findContactsStepEnabled: boolean + } = {starterPacksStepEnabled: true, findContactsStepEnabled: false}, +): OnboardingState { + const screens: OnboardingState['screens'] = { + profile: true, + interests: true, + 'suggested-accounts': true, + 'suggested-starterpacks': starterPacksStepEnabled, + 'find-contacts': findContactsStepEnabled, + finished: true, + } - interestsStepResults: { - selectedInterests: [], - }, - profileStepResults: { - isCreatedAvatar: false, - image: undefined, - imageUri: '', - imageMime: '', - }, + return { + screens, + activeStep: 'profile', + stepTransitionDirection: 'Forward', + interestsStepResults: { + selectedInterests: [], + }, + profileStepResults: { + isCreatedAvatar: false, + image: undefined, + imageUri: '', + imageMime: '', + }, + } } -export const Context = React.createContext<{ +export const Context = createContext<{ state: OnboardingState dispatch: React.Dispatch -}>({ - state: {...initialState}, - dispatch: () => {}, -}) +} | null>(null) Context.displayName = 'OnboardingContext' export function reducer( @@ -106,42 +114,32 @@ export function reducer( ): OnboardingState { let next = {...s} - const stepOrder: OnboardingState['activeStep'][] = [ - 'profile', - 'interests', - ...(s.experiments?.onboarding_suggested_accounts - ? (['suggested-accounts'] as const) - : []), - ...(s.experiments?.onboarding_suggested_starterpacks - ? (['suggested-starterpacks'] as const) - : []), - 'finished', - ] + const stepOrder = getStepOrder(s) switch (a.type) { case 'next': { - // 1-indexed for some reason - const nextIndex = s.activeStepIndex + const nextIndex = stepOrder.indexOf(next.activeStep) + 1 const nextStep = stepOrder[nextIndex] if (nextStep) { next.activeStep = nextStep - next.activeStepIndex = nextIndex + 1 } next.stepTransitionDirection = 'Forward' break } case 'prev': { - const prevIndex = s.activeStepIndex - 2 + const prevIndex = stepOrder.indexOf(next.activeStep) - 1 const prevStep = stepOrder[prevIndex] if (prevStep) { next.activeStep = prevStep - next.activeStepIndex = prevIndex + 1 } next.stepTransitionDirection = 'Backward' break } case 'finish': { - next = initialState + next = createInitialOnboardingState({ + starterPacksStepEnabled: s.screens['suggested-starterpacks'], + findContactsStepEnabled: s.screens['find-contacts'], + }) break } case 'setInterestsStepResults': { @@ -170,7 +168,6 @@ export function reducer( logger.debug(`onboarding`, { hasPrev: state.hasPrev, activeStep: state.activeStep, - activeStepIndex: state.activeStepIndex, interestsStepResults: { selectedInterests: state.interestsStepResults.selectedInterests, }, @@ -183,3 +180,46 @@ export function reducer( return state } + +function getStepOrder(s: OnboardingState): OnboardingScreen[] { + return [ + s.screens.profile && ('profile' as const), + s.screens.interests && ('interests' as const), + s.screens['suggested-accounts'] && ('suggested-accounts' as const), + s.screens['suggested-starterpacks'] && ('suggested-starterpacks' as const), + s.screens['find-contacts'] && ('find-contacts' as const), + s.screens.finished && ('finished' as const), + ].filter(x => !!x) +} + +/** + * Note: not to be confused with `useOnboardingState`, which just determines if onboarding is active. + * This hook is for internal state of the onboarding flow (i.e. active step etc). + * + * This adds additional derived state to the onboarding context reducer. + */ +export function useOnboardingInternalState() { + const ctx = useContext(Context) + + if (!ctx) { + throw new Error( + 'useOnboardingInternalState must be used within OnboardingContext', + ) + } + + const {state, dispatch} = ctx + + return { + state: useMemo(() => { + const stepOrder = getStepOrder(state) + const canGoBack = state.activeStep !== stepOrder[0] + return { + ...state, + canGoBack, + totalSteps: stepOrder.length, + activeStepIndex: stepOrder.indexOf(state.activeStep), + } + }, [state]), + dispatch, + } +}