[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 <pfrazee@gmail.com>
zio/stable
Ansh 2023-07-19 23:50:42 -07:00 committed by GitHub
parent 3517d9fa28
commit 30ac9259c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 231 additions and 4 deletions

View File

@ -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<AllNavigatorParams>()
@ -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<void> {
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<void>(resolve => {
const handler = () => {
resolve()
navigationRef.removeListener('state', handler)
}
navigationRef.addListener('state', handler)
}),
])
} else {
return Promise.resolve()
}
}

View File

@ -117,6 +117,9 @@ interface TrackPropertiesMap {
'MultiFeed:onRefresh': {}
// MODERATION events
'Moderation:ContentfilteringButtonClicked': {}
// ONBOARDING events
'Onboarding:Begin': {}
'Onboarding:Complete': {}
}
interface ScreenPropertiesMap {

View File

@ -0,0 +1,3 @@
export function timeout(ms: number): Promise<void> {
return new Promise(r => setTimeout(r, ms))
}

View File

@ -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)

View File

@ -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')
}
}

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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 (

View File

@ -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 (
<View style={[pal.view, styles.container]}>
{state.currentStep === OnboardingStep.WELCOME && <Welcome next={next} />}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 20,
},
})

View File

@ -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 (
<View style={[styles.container]}>
<View>
<Text style={[pal.text, styles.title]}>Welcome to </Text>
<Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text>
<View style={styles.spacer} />
<View style={[styles.row]}>
<FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="lg-bold" style={[pal.text]}>
Bluesky is public.
</Text>
<Text type="lg-thin" style={[pal.text, s.pt2]}>
Your posts, likes, and blocks are public. Mutes are private.
</Text>
</View>
</View>
<View style={[styles.row]}>
<FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="lg-bold" style={[pal.text]}>
Bluesky is open.
</Text>
<Text type="lg-thin" style={[pal.text, s.pt2]}>
Never lose access to your followers and data.
</Text>
</View>
</View>
<View style={[styles.row]}>
<FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="lg-bold" style={[pal.text]}>
Bluesky is flexible.
</Text>
<Text type="lg-thin" style={[pal.text, s.pt2]}>
Choose the algorithms that power your experience with custom
feeds.
</Text>
</View>
</View>
</View>
<Button onPress={next} label="Continue" labelStyle={styles.buttonText} />
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
marginVertical: 60,
justifyContent: 'space-between',
},
title: {
fontSize: 48,
fontWeight: '800',
},
row: {
flexDirection: 'row',
columnGap: 20,
alignItems: 'center',
marginVertical: 20,
},
rowText: {
flex: 1,
},
spacer: {
height: 20,
},
buttonText: {
textAlign: 'center',
fontSize: 18,
marginVertical: 4,
},
})

View File

@ -27,6 +27,7 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as PreferencesHomeFeed from './PreferencesHomeFeed'
import * as OnboardingModal from './OnboardingModal'
const DEFAULT_SNAPPOINTS = ['90%']
@ -117,6 +118,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'preferences-home-feed') {
snapPoints = PreferencesHomeFeed.snapPoints
element = <PreferencesHomeFeed.Component />
} else if (activeModal?.name === 'onboarding') {
snapPoints = OnboardingModal.snapPoints
element = <OnboardingModal.Component />
} else {
return null
}

View File

@ -26,6 +26,7 @@ import * as AddAppPassword from './AddAppPasswords'
import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as OnboardingModal from './OnboardingModal'
import * as PreferencesHomeFeed from './PreferencesHomeFeed'
@ -107,6 +108,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <EditImageModal.Component {...modal} />
} else if (modal.name === 'preferences-home-feed') {
element = <PreferencesHomeFeed.Component />
} else if (modal.name === 'onboarding') {
element = <OnboardingModal.Component />
} else {
return null
}

View File

@ -0,0 +1,8 @@
import React from 'react'
import {Onboarding} from '../auth/onboarding/Onboarding'
export const snapPoints = ['90%']
export function Component() {
return <Onboarding />
}