[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 {getRoutingInstrumentation} from 'lib/sentry'
|
||||||
import {bskyTitle} from 'lib/strings/headings'
|
import {bskyTitle} from 'lib/strings/headings'
|
||||||
import {JSX} from 'react/jsx-runtime'
|
import {JSX} from 'react/jsx-runtime'
|
||||||
|
import {timeout} from 'lib/async/timeout'
|
||||||
|
|
||||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
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()) {
|
if (navigationRef.isReady()) {
|
||||||
navigationRef.dispatch(
|
navigationRef.dispatch(
|
||||||
CommonActions.reset({
|
CommonActions.reset({
|
||||||
|
@ -486,6 +488,18 @@ function reset() {
|
||||||
routes: [{name: isNative ? 'HomeTab' : 'Home'}],
|
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': {}
|
'MultiFeed:onRefresh': {}
|
||||||
// MODERATION events
|
// MODERATION events
|
||||||
'Moderation:ContentfilteringButtonClicked': {}
|
'Moderation:ContentfilteringButtonClicked': {}
|
||||||
|
// ONBOARDING events
|
||||||
|
'Onboarding:Begin': {}
|
||||||
|
'Onboarding:Complete': {}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScreenPropertiesMap {
|
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()
|
/* dont await */ this.preferences.sync()
|
||||||
await this.me.load()
|
await this.me.load()
|
||||||
if (!hadSession) {
|
if (!hadSession) {
|
||||||
resetNavigation()
|
await resetNavigation()
|
||||||
}
|
}
|
||||||
|
this.emitSessionReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -195,6 +196,14 @@ export class RootStoreModel {
|
||||||
DeviceEventEmitter.emit('session-loaded')
|
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
|
// the session was dropped due to bad/expired refresh tokens
|
||||||
onSessionDropped(handler: () => void): EmitterSubscription {
|
onSessionDropped(handler: () => void): EmitterSubscription {
|
||||||
return DeviceEventEmitter.addListener('session-dropped', handler)
|
return DeviceEventEmitter.addListener('session-dropped', handler)
|
||||||
|
|
|
@ -108,6 +108,13 @@ export class CreateAccountModel {
|
||||||
}
|
}
|
||||||
this.setError('')
|
this.setError('')
|
||||||
this.setIsProcessing(true)
|
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 {
|
try {
|
||||||
await this.rootStore.session.createAccount({
|
await this.rootStore.session.createAccount({
|
||||||
service: this.serviceUrl,
|
service: this.serviceUrl,
|
||||||
|
@ -116,7 +123,9 @@ export class CreateAccountModel {
|
||||||
password: this.password,
|
password: this.password,
|
||||||
inviteCode: this.inviteCode,
|
inviteCode: this.inviteCode,
|
||||||
})
|
})
|
||||||
|
track('Create Account')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
sessionReadySub.remove()
|
||||||
let errMsg = e.toString()
|
let errMsg = e.toString()
|
||||||
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
|
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
|
||||||
errMsg =
|
errMsg =
|
||||||
|
@ -126,8 +135,6 @@ export class CreateAccountModel {
|
||||||
this.setIsProcessing(false)
|
this.setIsProcessing(false)
|
||||||
this.setError(cleanError(errMsg))
|
this.setError(cleanError(errMsg))
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
|
||||||
track('Create Account')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -127,6 +127,10 @@ export interface PreferencesHomeFeed {
|
||||||
name: 'preferences-home-feed'
|
name: 'preferences-home-feed'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OnboardingModal {
|
||||||
|
name: 'onboarding'
|
||||||
|
}
|
||||||
|
|
||||||
export type Modal =
|
export type Modal =
|
||||||
// Account
|
// Account
|
||||||
| AddAppPasswordModal
|
| AddAppPasswordModal
|
||||||
|
@ -158,6 +162,9 @@ export type Modal =
|
||||||
| WaitlistModal
|
| WaitlistModal
|
||||||
| InviteCodesModal
|
| InviteCodesModal
|
||||||
|
|
||||||
|
// Onboarding
|
||||||
|
| OnboardingModal
|
||||||
|
|
||||||
// Generic
|
// Generic
|
||||||
| ConfirmModal
|
| 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 {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index'
|
||||||
import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
|
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}) => {
|
export const Step1 = observer(({model}: {model: CreateAccountModel}) => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)
|
const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)
|
||||||
|
|
|
@ -12,6 +12,15 @@ import {Policies} from './Policies'
|
||||||
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||||
import {useStores} from 'state/index'
|
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}) => {
|
export const Step2 = observer(({model}: {model: CreateAccountModel}) => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
|
|
@ -10,6 +10,9 @@ import {createFullHandle} from 'lib/strings/handles'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||||
|
|
||||||
|
/** STEP 3: Your user handle
|
||||||
|
* @field User handle
|
||||||
|
*/
|
||||||
export const Step3 = observer(({model}: {model: CreateAccountModel}) => {
|
export const Step3 = observer(({model}: {model: CreateAccountModel}) => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
return (
|
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 ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||||
import * as PreferencesHomeFeed from './PreferencesHomeFeed'
|
import * as PreferencesHomeFeed from './PreferencesHomeFeed'
|
||||||
|
import * as OnboardingModal from './OnboardingModal'
|
||||||
|
|
||||||
const DEFAULT_SNAPPOINTS = ['90%']
|
const DEFAULT_SNAPPOINTS = ['90%']
|
||||||
|
|
||||||
|
@ -117,6 +118,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
|
||||||
} else if (activeModal?.name === 'preferences-home-feed') {
|
} else if (activeModal?.name === 'preferences-home-feed') {
|
||||||
snapPoints = PreferencesHomeFeed.snapPoints
|
snapPoints = PreferencesHomeFeed.snapPoints
|
||||||
element = <PreferencesHomeFeed.Component />
|
element = <PreferencesHomeFeed.Component />
|
||||||
|
} else if (activeModal?.name === 'onboarding') {
|
||||||
|
snapPoints = OnboardingModal.snapPoints
|
||||||
|
element = <OnboardingModal.Component />
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ import * as AddAppPassword from './AddAppPasswords'
|
||||||
import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
|
import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
|
||||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||||
|
import * as OnboardingModal from './OnboardingModal'
|
||||||
|
|
||||||
import * as PreferencesHomeFeed from './PreferencesHomeFeed'
|
import * as PreferencesHomeFeed from './PreferencesHomeFeed'
|
||||||
|
|
||||||
|
@ -107,6 +108,8 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||||
element = <EditImageModal.Component {...modal} />
|
element = <EditImageModal.Component {...modal} />
|
||||||
} else if (modal.name === 'preferences-home-feed') {
|
} else if (modal.name === 'preferences-home-feed') {
|
||||||
element = <PreferencesHomeFeed.Component />
|
element = <PreferencesHomeFeed.Component />
|
||||||
|
} else if (modal.name === 'onboarding') {
|
||||||
|
element = <OnboardingModal.Component />
|
||||||
} else {
|
} else {
|
||||||
return null
|
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