about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/state/index.ts3
-rw-r--r--src/state/lib/type-guards.ts4
-rw-r--r--src/state/models/onboard.ts62
-rw-r--r--src/state/models/root-store.ts6
-rw-r--r--src/state/models/session.ts32
-rw-r--r--src/state/models/suggested-actors-view.ts121
-rw-r--r--src/view/com/onboard/FeatureExplainer.tsx147
-rw-r--r--src/view/com/onboard/Follows.tsx202
-rw-r--r--src/view/com/profile/ProfileHeader.tsx1
-rw-r--r--src/view/screens/Login.tsx1
-rw-r--r--src/view/screens/Onboard.tsx33
-rw-r--r--src/view/shell/mobile/index.tsx10
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}}>&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,
+  },
+})
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}>