From d228a5f4f5d118f30129f3bafd676bfe0e80bf38 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 7 Nov 2022 15:35:51 -0600 Subject: Add onboarding (WIP) --- src/state/index.ts | 3 +- src/state/lib/type-guards.ts | 4 + src/state/models/onboard.ts | 62 +++++++++ src/state/models/root-store.ts | 6 + src/state/models/session.ts | 32 +---- src/state/models/suggested-actors-view.ts | 121 ++++++++++++++++++ src/view/com/onboard/FeatureExplainer.tsx | 147 ++++++++++++++++++++++ src/view/com/onboard/Follows.tsx | 202 ++++++++++++++++++++++++++++++ src/view/com/profile/ProfileHeader.tsx | 1 - src/view/screens/Login.tsx | 1 - src/view/screens/Onboard.tsx | 33 +++++ src/view/shell/mobile/index.tsx | 10 ++ 12 files changed, 588 insertions(+), 34 deletions(-) create mode 100644 src/state/models/onboard.ts create mode 100644 src/state/models/suggested-actors-view.ts create mode 100644 src/view/com/onboard/FeatureExplainer.tsx create mode 100644 src/view/com/onboard/Follows.tsx create mode 100644 src/view/screens/Onboard.tsx (limited to 'src') diff --git a/src/state/index.ts b/src/state/index.ts index 0716ab592..2c3df7d07 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -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) diff --git a/src/state/lib/type-guards.ts b/src/state/lib/type-guards.ts index 4ae31f3ac..8fe651ffb 100644 --- a/src/state/lib/type-guards.ts +++ b/src/state/lib/type-guards.ts @@ -8,3 +8,7 @@ export function hasProp( ): data is Record { return prop in data } + +export function isStrArray(v: unknown): v is string[] { + return Array.isArray(v) && v.every(item => typeof item === 'string') +} diff --git a/src/state/models/onboard.ts b/src/state/models/onboard.ts new file mode 100644 index 000000000..77a066332 --- /dev/null +++ b/src/state/models/onboard.ts @@ -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] + } + } +} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 717caa4a9..da846a3b0 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -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) + } } } diff --git a/src/state/models/session.ts b/src/state/models/session.ts index e29960954..e10a08e86 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -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} - } - } } diff --git a/src/state/models/suggested-actors-view.ts b/src/state/models/suggested-actors-view.ts new file mode 100644 index 000000000..245dbf020 --- /dev/null +++ b/src/state/models/suggested-actors-view.ts @@ -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) + } +} diff --git a/src/view/com/onboard/FeatureExplainer.tsx b/src/view/com/onboard/FeatureExplainer.tsx new file mode 100644 index 000000000..227ad73dc --- /dev/null +++ b/src/view/com/onboard/FeatureExplainer.tsx @@ -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 = () => ( + + + + + + + Scenes + + Scenes are invite-only groups of users. Follow them to see what's trending + with the scene's members. + + [ TODO screenshot ] + +) + +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) => { + const inputRange = props.navigationState.routes.map((x, i) => i) + return ( + + + {props.navigationState.routes.map((route, i) => { + const opacity = props.position.interpolate({ + inputRange, + outputRange: inputRange.map(inputIndex => + inputIndex === i ? 1 : 0.5, + ), + }) + + return ( + setIndex(i)}> + ° + + ) + })} + + + ) + } + + const FirstExplainer = SCENE_MAP[routes[0]?.key as keyof typeof SCENE_MAP] + return ( + + {routes.length > 1 ? ( + + ) : FirstExplainer ? ( + + ) : ( + + )} + + + Skip + + + + Next + + + + ) +} + +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, + }, +}) diff --git a/src/view/com/onboard/Follows.tsx b/src/view/com/onboard/Follows.tsx new file mode 100644 index 000000000..c48531522 --- /dev/null +++ b/src/view/com/onboard/Follows.tsx @@ -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( + () => 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}) => + return ( + + Suggested follows + {view.isLoading ? ( + + + + ) : view.hasError ? ( + + ) : ( + + item._reactKey} + renderItem={renderItem} + style={s.flex1} + /> + + )} + + + Skip + + + + Next + + + + ) +}) + +const User = ({item}: {item: SuggestedActor}) => { + return ( + + + + + + + + {item.displayName} + + + @{item.handle} + + + + + + + Follow + + + + + {item.description ? ( + + + {item.description} + + + ) : undefined} + + ) +} + +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, + }, +}) diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 05ad5889f..984190283 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -2,7 +2,6 @@ import React from 'react' import {observer} from 'mobx-react-lite' import { ActivityIndicator, - Image, StyleSheet, Text, TouchableOpacity, diff --git a/src/view/screens/Login.tsx b/src/view/screens/Login.tsx index 7e5ab429d..db3555f71 100644 --- a/src/view/screens/Login.tsx +++ b/src/view/screens/Login.tsx @@ -441,7 +441,6 @@ function cleanUsername(v: string): string { export const Login = observer( (/*{navigation}: RootTabsScreenProps<'Login'>*/) => { - // const store = useStores() const [screenState, setScreenState] = useState( ScreenState.SigninOrCreateAccount, ) diff --git a/src/view/screens/Onboard.tsx b/src/view/screens/Onboard.tsx new file mode 100644 index 000000000..0f36494e6 --- /dev/null +++ b/src/view/screens/Onboard.tsx @@ -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 ( + + + + ) +}) diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx index f30827951..b359bdcb3 100644 --- a/src/view/shell/mobile/index.tsx +++ b/src/view/shell/mobile/index.tsx @@ -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(() => { ) } + if (store.onboard.isOnboarding) { + return ( + + + + + + ) + } return ( -- cgit 1.4.1