[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
parent
3517d9fa28
commit
30ac9259c7
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -117,6 +117,9 @@ interface TrackPropertiesMap {
|
|||
'MultiFeed:onRefresh': {}
|
||||
// MODERATION events
|
||||
'Moderation:ContentfilteringButtonClicked': {}
|
||||
// ONBOARDING events
|
||||
'Onboarding:Begin': {}
|
||||
'Onboarding:Complete': {}
|
||||
}
|
||||
|
||||
interface ScreenPropertiesMap {
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export function timeout(ms: number): Promise<void> {
|
||||
return new Promise(r => setTimeout(r, ms))
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import React from 'react'
|
||||
import {Onboarding} from '../auth/onboarding/Onboarding'
|
||||
|
||||
export const snapPoints = ['90%']
|
||||
|
||||
export function Component() {
|
||||
return <Onboarding />
|
||||
}
|
Loading…
Reference in New Issue