Add onboarding (WIP)

zio/stable
Paul Frazee 2022-11-07 15:35:51 -06:00
parent b4097e25d6
commit d228a5f4f5
15 changed files with 613 additions and 34 deletions

View File

@ -220,6 +220,8 @@ PODS:
- React-jsinspector (0.68.2)
- React-logger (0.68.2):
- glog
- react-native-pager-view (6.0.2):
- React-Core
- react-native-safe-area-context (4.3.4):
- RCT-Folly
- RCTRequired
@ -357,6 +359,7 @@ DEPENDENCIES:
- React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`)
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- react-native-pager-view (from `../node_modules/react-native-pager-view`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-splash-screen (from `../node_modules/react-native-splash-screen`)
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
@ -423,6 +426,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/jsinspector"
React-logger:
:path: "../node_modules/react-native/ReactCommon/logger"
react-native-pager-view:
:path: "../node_modules/react-native-pager-view"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
react-native-splash-screen:
@ -489,6 +494,7 @@ SPEC CHECKSUMS:
React-jsiexecutor: b7b553412f2ec768fe6c8f27cd6bafdb9d8719e6
React-jsinspector: c5989c77cb89ae6a69561095a61cce56a44ae8e8
React-logger: a0833912d93b36b791b7a521672d8ee89107aff1
react-native-pager-view: 592421df0259bf7a7a4fe85b74c24f3f39905605
react-native-safe-area-context: dfe5aa13bee37a0c7e8059d14f72ffc076d120e9
react-native-splash-screen: 4312f786b13a81b5169ef346d76d33bc0c6dc457
React-perflogger: a18b4f0bd933b8b24ecf9f3c54f9bf65180f3fe6

View File

@ -32,6 +32,7 @@
"react-native-gesture-handler": "^2.5.0",
"react-native-inappbrowser-reborn": "^3.6.3",
"react-native-linear-gradient": "^2.6.2",
"react-native-pager-view": "^6.0.2",
"react-native-progress": "^5.0.0",
"react-native-reanimated": "^2.9.1",
"react-native-root-siblings": "^4.1.1",
@ -40,6 +41,7 @@
"react-native-screens": "^3.13.1",
"react-native-splash-screen": "^3.3.0",
"react-native-svg": "^12.4.0",
"react-native-tab-view": "^3.3.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-web": "^0.17.7"
},

View File

@ -23,13 +23,14 @@ export async function setupState() {
console.error('Failed to load state from storage', e)
}
await rootStore.session.setup()
// track changes & save to storage
autorun(() => {
const snapshot = rootStore.serialize()
storage.save(ROOT_STATE_STORAGE_KEY, snapshot)
})
await rootStore.session.setup()
await rootStore.fetchStateUpdate()
console.log(rootStore.me)

View File

@ -8,3 +8,7 @@ export function hasProp<K extends PropertyKey>(
): data is Record<K, unknown> {
return prop in data
}
export function isStrArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every(item => typeof item === 'string')
}

View File

@ -0,0 +1,62 @@
import {makeAutoObservable} from 'mobx'
import {isObj, hasProp} from '../lib/type-guards'
export const OnboardStage = {
Explainers: 'explainers',
Follows: 'follows',
}
export const OnboardStageOrder = [OnboardStage.Explainers, OnboardStage.Follows]
export class OnboardModel {
isOnboarding: boolean = true
stage: string = OnboardStageOrder[0]
constructor() {
makeAutoObservable(this, {
serialize: false,
hydrate: false,
})
}
serialize(): unknown {
return {
isOnboarding: this.isOnboarding,
stage: this.stage,
}
}
hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'isOnboarding') && typeof v.isOnboarding === 'boolean') {
this.isOnboarding = v.isOnboarding
}
if (
hasProp(v, 'stage') &&
typeof v.stage === 'string' &&
OnboardStageOrder.includes(v.stage)
) {
this.stage = v.stage
}
}
}
start() {
this.isOnboarding = true
}
stop() {
this.isOnboarding = false
}
next() {
if (!this.isOnboarding) return
let i = OnboardStageOrder.indexOf(this.stage)
i++
if (i >= OnboardStageOrder.length) {
this.isOnboarding = false
} else {
this.stage = OnboardStageOrder[i]
}
}
}

View File

@ -11,12 +11,14 @@ import {SessionModel} from './session'
import {NavigationModel} from './navigation'
import {ShellModel} from './shell'
import {MeModel} from './me'
import {OnboardModel} from './onboard'
export class RootStoreModel {
session = new SessionModel(this)
nav = new NavigationModel()
shell = new ShellModel()
me = new MeModel(this)
onboard = new OnboardModel()
constructor(public api: SessionServiceClient) {
makeAutoObservable(this, {
@ -53,6 +55,7 @@ export class RootStoreModel {
return {
session: this.session.serialize(),
nav: this.nav.serialize(),
onboard: this.onboard.serialize(),
}
}
@ -64,6 +67,9 @@ export class RootStoreModel {
if (hasProp(v, 'nav')) {
this.nav.hydrate(v.nav)
}
if (hasProp(v, 'onboard')) {
this.onboard.hydrate(v.onboard)
}
}
}

View File

@ -15,17 +15,8 @@ interface SessionData {
did: string
}
export enum OnboardingStage {
Init = 'init',
}
interface OnboardingState {
stage: OnboardingStage
}
export class SessionModel {
data: SessionData | null = null
onboardingState: OnboardingState | null = null
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
@ -42,7 +33,6 @@ export class SessionModel {
serialize(): unknown {
return {
data: this.data,
onboardingState: this.onboardingState,
}
}
@ -87,18 +77,6 @@ export class SessionModel {
this.data = data
}
}
if (
this.data &&
hasProp(v, 'onboardingState') &&
isObj(v.onboardingState)
) {
if (
hasProp(v.onboardingState, 'stage') &&
typeof v.onboardingState === 'string'
) {
this.onboardingState = v.onboardingState
}
}
}
}
@ -212,7 +190,7 @@ export class SessionModel {
handle: res.data.handle,
did: res.data.did,
})
this.setOnboardingStage(OnboardingStage.Init)
this.rootStore.onboard.start()
this.configureApi()
this.rootStore.me.load().catch(e => {
console.error('Failed to fetch local user information', e)
@ -228,12 +206,4 @@ export class SessionModel {
}
this.rootStore.clearAll()
}
setOnboardingStage(stage: OnboardingStage | null) {
if (stage === null) {
this.onboardingState = null
} else {
this.onboardingState = {stage}
}
}
}

View File

@ -0,0 +1,121 @@
import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from './root-store'
interface Response {
data: {
suggestions: ResponseSuggestedActor[]
}
}
export type ResponseSuggestedActor = {
did: string
handle: string
displayName?: string
description?: string
createdAt?: string
indexedAt: string
}
export type SuggestedActor = ResponseSuggestedActor & {
_reactKey: string
}
export class SuggestedActorsViewModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
// data
suggestions: SuggestedActor[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.suggestions.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async setup() {
await this._fetch()
}
async refresh() {
await this._fetch(true)
}
async loadMore() {
// TODO
}
// state transitions
// =
private _xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
private _xIdle(err: string = '') {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
}
// loader functions
// =
private async _fetch(isRefreshing = false) {
this._xLoading(isRefreshing)
try {
const debugRes = await this.rootStore.api.app.bsky.graph.getFollowers({
user: 'alice.test',
})
const res = {
data: {
suggestions: debugRes.data.followers,
},
}
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(e.toString())
}
}
private _replaceAll(res: Response) {
this.suggestions.length = 0
let counter = 0
for (const item of res.data.suggestions) {
this._append({
_reactKey: `item-${counter++}`,
description: 'Just another cool person using Bluesky',
...item,
})
}
}
private _append(item: SuggestedActor) {
this.suggestions.push(item)
}
}

View File

@ -0,0 +1,147 @@
import React, {useState} from 'react'
import {
Animated,
SafeAreaView,
StyleSheet,
Text,
TouchableOpacity,
useWindowDimensions,
View,
} from 'react-native'
import {TabView, SceneMap, Route, TabBarProps} from 'react-native-tab-view'
import {UserGroupIcon} from '../../lib/icons'
import {useStores} from '../../../state'
import {s} from '../../lib/styles'
const Scenes = () => (
<View style={styles.explainer}>
<View style={styles.explainerIcon}>
<View style={s.flex1} />
<UserGroupIcon style={s.black} size="48" />
<View style={s.flex1} />
</View>
<Text style={styles.explainerHeading}>Scenes</Text>
<Text style={styles.explainerDesc}>
Scenes are invite-only groups of users. Follow them to see what's trending
with the scene's members.
</Text>
<Text style={styles.explainerDesc}>[ TODO screenshot ]</Text>
</View>
)
const SCENE_MAP = {
scenes: Scenes,
}
const renderScene = SceneMap(SCENE_MAP)
export const FeatureExplainer = () => {
const layout = useWindowDimensions()
const store = useStores()
const [index, setIndex] = useState(0)
const routes = [{key: 'scenes', title: 'Scenes'}]
const onPressSkip = () => store.onboard.next()
const onPressNext = () => {
if (index >= routes.length - 1) {
store.onboard.next()
} else {
setIndex(index + 1)
}
}
const renderTabBar = (props: TabBarProps<Route>) => {
const inputRange = props.navigationState.routes.map((x, i) => i)
return (
<View style={styles.tabBar}>
<View style={s.flex1} />
{props.navigationState.routes.map((route, i) => {
const opacity = props.position.interpolate({
inputRange,
outputRange: inputRange.map(inputIndex =>
inputIndex === i ? 1 : 0.5,
),
})
return (
<TouchableOpacity
key={i}
style={styles.tabItem}
onPress={() => setIndex(i)}>
<Animated.Text style={{opacity}}>&deg;</Animated.Text>
</TouchableOpacity>
)
})}
<View style={s.flex1} />
</View>
)
}
const FirstExplainer = SCENE_MAP[routes[0]?.key as keyof typeof SCENE_MAP]
return (
<SafeAreaView style={styles.container}>
{routes.length > 1 ? (
<TabView
navigationState={{index, routes}}
renderScene={renderScene}
renderTabBar={renderTabBar}
onIndexChange={setIndex}
initialLayout={{width: layout.width}}
tabBarPosition="bottom"
/>
) : FirstExplainer ? (
<FirstExplainer />
) : (
<View />
)}
<View style={styles.footer}>
<TouchableOpacity onPress={onPressSkip}>
<Text style={[s.blue3, s.f18]}>Skip</Text>
</TouchableOpacity>
<View style={s.flex1} />
<TouchableOpacity onPress={onPressNext}>
<Text style={[s.blue3, s.f18]}>Next</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
tabBar: {
flexDirection: 'row',
},
tabItem: {
alignItems: 'center',
padding: 16,
},
explainer: {
flex: 1,
paddingHorizontal: 16,
paddingVertical: 16,
},
explainerIcon: {
flexDirection: 'row',
},
explainerHeading: {
fontSize: 42,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 16,
},
explainerDesc: {
fontSize: 18,
textAlign: 'center',
marginBottom: 16,
},
footer: {
flexDirection: 'row',
paddingHorizontal: 32,
paddingBottom: 24,
},
})

View File

@ -0,0 +1,202 @@
import React, {useMemo, useEffect} from 'react'
import {
ActivityIndicator,
FlatList,
SafeAreaView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {observer} from 'mobx-react-lite'
import {ErrorScreen} from '../util/ErrorScreen'
import {UserAvatar} from '../util/UserAvatar'
import {useStores} from '../../../state'
import {
SuggestedActorsViewModel,
SuggestedActor,
} from '../../../state/models/suggested-actors-view'
import {s, colors, gradients} from '../../lib/styles'
export const Follows = observer(() => {
const store = useStores()
const view = useMemo<SuggestedActorsViewModel>(
() => new SuggestedActorsViewModel(store),
[],
)
useEffect(() => {
console.log('Fetching suggested actors')
view
.setup()
.catch((err: any) => console.error('Failed to fetch suggestions', err))
}, [view])
useEffect(() => {
if (!view.isLoading && !view.hasError && !view.hasContent) {
// no suggestions, bounce from this view
store.onboard.next()
}
}, [view, view.isLoading, view.hasError, view.hasContent])
const onPressTryAgain = () =>
view
.setup()
.catch((err: any) => console.error('Failed to fetch suggestions', err))
const onPressNext = () => store.onboard.next()
const renderItem = ({item}: {item: SuggestedActor}) => <User item={item} />
return (
<SafeAreaView style={styles.container}>
<Text style={styles.title}>Suggested follows</Text>
{view.isLoading ? (
<View>
<ActivityIndicator />
</View>
) : view.hasError ? (
<ErrorScreen
title="Failed to load suggestions"
message="There was an error while trying to load suggested follows."
details={view.error}
onPressTryAgain={onPressTryAgain}
/>
) : (
<View style={styles.suggestionsContainer}>
<FlatList
data={view.suggestions}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
style={s.flex1}
/>
</View>
)}
<View style={styles.footer}>
<TouchableOpacity onPress={onPressNext}>
<Text style={[s.blue3, s.f18]}>Skip</Text>
</TouchableOpacity>
<View style={s.flex1} />
<TouchableOpacity onPress={onPressNext}>
<Text style={[s.blue3, s.f18]}>Next</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
)
})
const User = ({item}: {item: SuggestedActor}) => {
return (
<View style={styles.actor}>
<View style={styles.actorMeta}>
<View style={styles.actorAvi}>
<UserAvatar
size={40}
displayName={item.displayName}
handle={item.handle}
/>
</View>
<View style={styles.actorContent}>
<Text style={[s.f17, s.bold]} numberOfLines={1}>
{item.displayName}
</Text>
<Text style={[s.f14, s.gray5]} numberOfLines={1}>
@{item.handle}
</Text>
</View>
<View style={styles.actorBtn}>
<TouchableOpacity>
<LinearGradient
colors={[gradients.primary.start, gradients.primary.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn, styles.gradientBtn]}>
<FontAwesomeIcon icon="plus" style={[s.white, s.mr5]} size={15} />
<Text style={[s.white, s.fw600, s.f15]}>Follow</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
{item.description ? (
<View style={styles.actorDetails}>
<Text style={[s.f15]} numberOfLines={4}>
{item.description}
</Text>
</View>
) : undefined}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
title: {
fontSize: 24,
fontWeight: 'bold',
paddingHorizontal: 16,
paddingBottom: 12,
},
suggestionsContainer: {
flex: 1,
backgroundColor: colors.gray1,
},
actor: {
backgroundColor: colors.white,
borderRadius: 6,
margin: 2,
marginBottom: 0,
},
actorMeta: {
flexDirection: 'row',
},
actorAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingBottom: 10,
},
actorContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
},
actorBtn: {
paddingRight: 10,
paddingTop: 10,
},
actorDetails: {
paddingLeft: 60,
paddingRight: 10,
paddingBottom: 10,
},
gradientBtn: {
paddingHorizontal: 24,
paddingVertical: 6,
},
secondaryBtn: {
paddingHorizontal: 14,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
borderRadius: 50,
backgroundColor: colors.gray1,
marginLeft: 6,
},
footer: {
flexDirection: 'row',
paddingHorizontal: 32,
paddingBottom: 24,
paddingTop: 16,
},
})

View File

@ -2,7 +2,6 @@ import React from 'react'
import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
Image,
StyleSheet,
Text,
TouchableOpacity,

View File

@ -441,7 +441,6 @@ function cleanUsername(v: string): string {
export const Login = observer(
(/*{navigation}: RootTabsScreenProps<'Login'>*/) => {
// const store = useStores()
const [screenState, setScreenState] = useState<ScreenState>(
ScreenState.SigninOrCreateAccount,
)

View File

@ -0,0 +1,33 @@
import React, {useEffect} from 'react'
import {View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FeatureExplainer} from '../com/onboard/FeatureExplainer'
import {Follows} from '../com/onboard/Follows'
import {OnboardStage, OnboardStageOrder} from '../../state/models/onboard'
import {useStores} from '../../state'
export const Onboard = observer(() => {
const store = useStores()
useEffect(() => {
// sanity check - bounce out of onboarding if the stage is wrong somehow
if (!OnboardStageOrder.includes(store.onboard.stage)) {
store.onboard.stop()
}
}, [store.onboard.stage])
let Com
if (store.onboard.stage === OnboardStage.Explainers) {
Com = FeatureExplainer
} else if (store.onboard.stage === OnboardStage.Follows) {
Com = Follows
} else {
Com = View
}
return (
<View style={{flex: 1}}>
<Com />
</View>
)
})

View File

@ -27,6 +27,7 @@ import {useStores} from '../../../state'
import {NavigationModel} from '../../../state/models/navigation'
import {match, MatchResult} from '../../routes'
import {Login} from '../../screens/Login'
import {Onboard} from '../../screens/Onboard'
import {Modal} from '../../com/modals/Modal'
import {MainMenu} from './MainMenu'
import {TabsSelector} from './TabsSelector'
@ -161,6 +162,15 @@ export const MobileShell: React.FC = observer(() => {
</LinearGradient>
)
}
if (store.onboard.isOnboarding) {
return (
<View style={styles.outerContainer}>
<View style={styles.innerContainer}>
<Onboard />
</View>
</View>
)
}
return (
<View style={styles.outerContainer}>

View File

@ -10177,6 +10177,11 @@ react-native-linear-gradient@^2.6.2:
resolved "https://registry.yarnpkg.com/react-native-linear-gradient/-/react-native-linear-gradient-2.6.2.tgz#56598a76832724b2afa7889747635b5c80948f38"
integrity sha512-Z8Xxvupsex+9BBFoSYS87bilNPWcRfRsGC0cpJk72Nxb5p2nEkGSBv73xZbEHnW2mUFvP+huYxrVvjZkr/gRjQ==
react-native-pager-view@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.0.2.tgz#447b85fcb9f35225c4d6885c18689a7d30c181d9"
integrity sha512-XL3Qc9k7o0BykclGHtuRUz97FpF6rcKbP8LqszLeS2hKnINYcbUPYqg46EhbwVhFOUJE+XhT3idrSO1e/D6jtQ==
react-native-progress@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/react-native-progress/-/react-native-progress-5.0.0.tgz#f5ac6ceaeee27f184c660b00f29419e82a9d0ab0"
@ -10237,6 +10242,13 @@ react-native-svg@^12.4.0:
css-select "^5.1.0"
css-tree "^1.1.3"
react-native-tab-view@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/react-native-tab-view/-/react-native-tab-view-3.3.0.tgz#3d24ae4f4c55cfd54cd1d2d1f8915b0b2c33a5da"
integrity sha512-xjAQe657Gp/de2QHb7ptksTg8Jcb+j3fLAdcYryzfavt/pe+HtKLpkCtQsxyIJpRrAO7YPxFsymi2N4MnNfePA==
dependencies:
use-latest-callback "^0.1.5"
react-native-url-polyfill@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-1.3.0.tgz#c1763de0f2a8c22cc3e959b654c8790622b6ef6a"
@ -11980,6 +11992,11 @@ url-parse@^1.5.3:
querystringify "^2.1.1"
requires-port "^1.0.0"
use-latest-callback@^0.1.5:
version "0.1.5"
resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.1.5.tgz#a4a836c08fa72f6608730b5b8f4bbd9c57c04f51"
integrity sha512-HtHatS2U4/h32NlkhupDsPlrbiD27gSH5swBdtXbCAlc6pfOFzaj0FehW/FO12rx8j2Vy4/lJScCiJyM01E+bQ==
"use-subscription@>=1.0.0 <1.6.0":
version "1.5.1"
resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.5.1.tgz#73501107f02fad84c6dd57965beb0b75c68c42d1"