From 30ac9259c7b9dc367fca15cb6e7895feb0955bd4 Mon Sep 17 00:00:00 2001 From: Ansh Date: Wed, 19 Jul 2023 23:50:42 -0700 Subject: [APP-775] Add Welcome screen after account creation (#1038) * add comments to step 1-3 * add onboarding screen * add analytics for onboarding tracking * fix useEffect * change text * change icon size * put onboarding into bottom sheet modal instead of react navigation * wip * Simplify the type validation * Fix: only trigger onboarding modal when account creation succeeds * Add the 'session-ready' event which fires when the new session is stable * Use the 'session-ready' event to trigger the onboarding modal * update copy * update copy --------- Co-authored-by: Paul Frazee --- src/Navigation.tsx | 16 +++++- src/lib/analytics/types.ts | 3 + src/lib/async/timeout.ts | 3 + src/state/models/root-store.ts | 11 +++- src/state/models/ui/create-account.ts | 11 +++- src/state/models/ui/shell.ts | 7 +++ src/view/com/auth/create/Step1.tsx | 4 ++ src/view/com/auth/create/Step2.tsx | 9 +++ src/view/com/auth/create/Step3.tsx | 3 + src/view/com/auth/onboarding/Onboarding.tsx | 66 ++++++++++++++++++++++ src/view/com/auth/onboarding/Welcome.tsx | 87 +++++++++++++++++++++++++++++ src/view/com/modals/Modal.tsx | 4 ++ src/view/com/modals/Modal.web.tsx | 3 + src/view/com/modals/OnboardingModal.tsx | 8 +++ 14 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 src/lib/async/timeout.ts create mode 100644 src/view/com/auth/onboarding/Onboarding.tsx create mode 100644 src/view/com/auth/onboarding/Welcome.tsx create mode 100644 src/view/com/modals/OnboardingModal.tsx (limited to 'src') diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 640b771d4..06cce0f00 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -66,6 +66,7 @@ import {SavedFeeds} from 'view/screens/SavedFeeds' import {getRoutingInstrumentation} from 'lib/sentry' import {bskyTitle} from 'lib/strings/headings' import {JSX} from 'react/jsx-runtime' +import {timeout} from 'lib/async/timeout' const navigationRef = createNavigationContainerRef() @@ -478,7 +479,8 @@ function resetToTab(tabName: 'HomeTab' | 'SearchTab' | 'NotificationsTab') { } } -function reset() { +// returns a promise that resolves after the state reset is complete +function reset(): Promise { if (navigationRef.isReady()) { navigationRef.dispatch( CommonActions.reset({ @@ -486,6 +488,18 @@ function reset() { routes: [{name: isNative ? 'HomeTab' : 'Home'}], }), ) + return Promise.race([ + timeout(1e3), + new Promise(resolve => { + const handler = () => { + resolve() + navigationRef.removeListener('state', handler) + } + navigationRef.addListener('state', handler) + }), + ]) + } else { + return Promise.resolve() } } diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts index 585884632..2be37856e 100644 --- a/src/lib/analytics/types.ts +++ b/src/lib/analytics/types.ts @@ -117,6 +117,9 @@ interface TrackPropertiesMap { 'MultiFeed:onRefresh': {} // MODERATION events 'Moderation:ContentfilteringButtonClicked': {} + // ONBOARDING events + 'Onboarding:Begin': {} + 'Onboarding:Complete': {} } interface ScreenPropertiesMap { diff --git a/src/lib/async/timeout.ts b/src/lib/async/timeout.ts new file mode 100644 index 000000000..8be008d87 --- /dev/null +++ b/src/lib/async/timeout.ts @@ -0,0 +1,3 @@ +export function timeout(ms: number): Promise { + return new Promise(r => setTimeout(r, ms)) +} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 5a3d102aa..389ce86d8 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -135,8 +135,9 @@ export class RootStoreModel { /* dont await */ this.preferences.sync() await this.me.load() if (!hadSession) { - resetNavigation() + await resetNavigation() } + this.emitSessionReady() } /** @@ -195,6 +196,14 @@ export class RootStoreModel { DeviceEventEmitter.emit('session-loaded') } + // the session has completed all setup; good for post-initialization behaviors like triggering modals + onSessionReady(handler: () => void): EmitterSubscription { + return DeviceEventEmitter.addListener('session-ready', handler) + } + emitSessionReady() { + DeviceEventEmitter.emit('session-ready') + } + // the session was dropped due to bad/expired refresh tokens onSessionDropped(handler: () => void): EmitterSubscription { return DeviceEventEmitter.addListener('session-dropped', handler) diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts index d35b3557d..04e1554c6 100644 --- a/src/state/models/ui/create-account.ts +++ b/src/state/models/ui/create-account.ts @@ -108,6 +108,13 @@ export class CreateAccountModel { } this.setError('') this.setIsProcessing(true) + + // open the onboarding modal after the session is created + const sessionReadySub = this.rootStore.onSessionReady(() => { + sessionReadySub.remove() + this.rootStore.shell.openModal({name: 'onboarding'}) + }) + try { await this.rootStore.session.createAccount({ service: this.serviceUrl, @@ -116,7 +123,9 @@ export class CreateAccountModel { password: this.password, inviteCode: this.inviteCode, }) + track('Create Account') } catch (e: any) { + sessionReadySub.remove() let errMsg = e.toString() if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { errMsg = @@ -126,8 +135,6 @@ export class CreateAccountModel { this.setIsProcessing(false) this.setError(cleanError(errMsg)) throw e - } finally { - track('Create Account') } } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index b6ae12fda..17740a77f 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -127,6 +127,10 @@ export interface PreferencesHomeFeed { name: 'preferences-home-feed' } +export interface OnboardingModal { + name: 'onboarding' +} + export type Modal = // Account | AddAppPasswordModal @@ -158,6 +162,9 @@ export type Modal = | WaitlistModal | InviteCodesModal + // Onboarding + | OnboardingModal + // Generic | ConfirmModal diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index 57747f070..5038c8819 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -16,6 +16,10 @@ import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' +/** STEP 1: Your hosting provider + * @field Bluesky (default) + * @field Other (staging, local dev, your own PDS, etc.) + */ export const Step1 = observer(({model}: {model: CreateAccountModel}) => { const pal = usePalette('default') const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index 2865191c4..52a06f031 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -12,6 +12,15 @@ import {Policies} from './Policies' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {useStores} from 'state/index' +/** STEP 2: Your account + * @field Invite code or waitlist + * @field Email address + * @field Email address + * @field Email address + * @field Password + * @field Birth date + * @readonly Terms of service & privacy policy + */ export const Step2 = observer(({model}: {model: CreateAccountModel}) => { const pal = usePalette('default') const store = useStores() diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx index bf26231a0..f35777d27 100644 --- a/src/view/com/auth/create/Step3.tsx +++ b/src/view/com/auth/create/Step3.tsx @@ -10,6 +10,9 @@ import {createFullHandle} from 'lib/strings/handles' import {usePalette} from 'lib/hooks/usePalette' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' +/** STEP 3: Your user handle + * @field User handle + */ export const Step3 = observer(({model}: {model: CreateAccountModel}) => { const pal = usePalette('default') return ( diff --git a/src/view/com/auth/onboarding/Onboarding.tsx b/src/view/com/auth/onboarding/Onboarding.tsx new file mode 100644 index 000000000..28e4419d7 --- /dev/null +++ b/src/view/com/auth/onboarding/Onboarding.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {usePalette} from 'lib/hooks/usePalette' +import {Welcome} from './Welcome' +import {useStores} from 'state/index' +import {track} from 'lib/analytics/analytics' + +enum OnboardingStep { + WELCOME = 'WELCOME', + // SELECT_INTERESTS = 'SELECT_INTERESTS', + COMPLETE = 'COMPLETE', +} +type OnboardingState = { + currentStep: OnboardingStep +} +type Action = {type: 'NEXT_STEP'} +const initialState: OnboardingState = { + currentStep: OnboardingStep.WELCOME, +} +const reducer = (state: OnboardingState, action: Action): OnboardingState => { + switch (action.type) { + case 'NEXT_STEP': + switch (state.currentStep) { + case OnboardingStep.WELCOME: + track('Onboarding:Begin') + return {...state, currentStep: OnboardingStep.COMPLETE} + case OnboardingStep.COMPLETE: + track('Onboarding:Complete') + return state + default: + return state + } + default: + return state + } +} + +export const Onboarding = () => { + const pal = usePalette('default') + const rootStore = useStores() + const [state, dispatch] = React.useReducer(reducer, initialState) + const next = React.useCallback( + () => dispatch({type: 'NEXT_STEP'}), + [dispatch], + ) + + React.useEffect(() => { + if (state.currentStep === OnboardingStep.COMPLETE) { + // navigate to home + rootStore.shell.closeModal() + } + }, [state.currentStep, rootStore.shell]) + + return ( + + {state.currentStep === OnboardingStep.WELCOME && } + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 20, + }, +}) diff --git a/src/view/com/auth/onboarding/Welcome.tsx b/src/view/com/auth/onboarding/Welcome.tsx new file mode 100644 index 000000000..e7c068ea0 --- /dev/null +++ b/src/view/com/auth/onboarding/Welcome.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {Text} from 'view/com/util/text/Text' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {Button} from 'view/com/util/forms/Button' + +export const Welcome = ({next}: {next: () => void}) => { + const pal = usePalette('default') + return ( + + + Welcome to + Bluesky + + + + + + + + Bluesky is public. + + + Your posts, likes, and blocks are public. Mutes are private. + + + + + + + + Bluesky is open. + + + Never lose access to your followers and data. + + + + + + + + Bluesky is flexible. + + + Choose the algorithms that power your experience with custom + feeds. + + + + + +