diff options
author | Paul Frazee <pfrazee@gmail.com> | 2022-11-07 15:35:51 -0600 |
---|---|---|
committer | Paul Frazee <pfrazee@gmail.com> | 2022-11-07 15:35:51 -0600 |
commit | d228a5f4f5d118f30129f3bafd676bfe0e80bf38 (patch) | |
tree | e292e9d3b1f7a027298308ef583a7d3b90b59157 /src | |
parent | b4097e25d67739c0ab6bc5b5f6ce8ee062796458 (diff) | |
download | voidsky-d228a5f4f5d118f30129f3bafd676bfe0e80bf38.tar.zst |
Add onboarding (WIP)
Diffstat (limited to 'src')
-rw-r--r-- | src/state/index.ts | 3 | ||||
-rw-r--r-- | src/state/lib/type-guards.ts | 4 | ||||
-rw-r--r-- | src/state/models/onboard.ts | 62 | ||||
-rw-r--r-- | src/state/models/root-store.ts | 6 | ||||
-rw-r--r-- | src/state/models/session.ts | 32 | ||||
-rw-r--r-- | src/state/models/suggested-actors-view.ts | 121 | ||||
-rw-r--r-- | src/view/com/onboard/FeatureExplainer.tsx | 147 | ||||
-rw-r--r-- | src/view/com/onboard/Follows.tsx | 202 | ||||
-rw-r--r-- | src/view/com/profile/ProfileHeader.tsx | 1 | ||||
-rw-r--r-- | src/view/screens/Login.tsx | 1 | ||||
-rw-r--r-- | src/view/screens/Onboard.tsx | 33 | ||||
-rw-r--r-- | src/view/shell/mobile/index.tsx | 10 |
12 files changed, 588 insertions, 34 deletions
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<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') +} 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 = () => ( + <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}}>°</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, + }, +}) 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<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, + }, +}) 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>( 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 ( + <View style={{flex: 1}}> + <Com /> + </View> + ) +}) 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(() => { </LinearGradient> ) } + if (store.onboard.isOnboarding) { + return ( + <View style={styles.outerContainer}> + <View style={styles.innerContainer}> + <Onboard /> + </View> + </View> + ) + } return ( <View style={styles.outerContainer}> |