about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorAnsh <anshnanda10@gmail.com>2023-09-20 01:18:50 +0530
committerGitHub <noreply@github.com>2023-09-20 01:18:50 +0530
commit859588c3f63949182acf3ca800b0229dd5e1d88e (patch)
tree909b6c8ae8c6ca47b6db080d9f891dac676fd685 /src
parentda8499c8810eccbb448516adedcbb19a1964c081 (diff)
downloadvoidsky-859588c3f63949182acf3ca800b0229dd5e1d88e.tar.zst
Onboarding recommended follows (#1457)
* upgrade api package

* add RecommendedFollows as a step in onboarding

* add list of recommended follows from suggested actor model

* remove dead code

* hoist suggestedActors into onboarding model

* add comments

* load more suggested follows on follow

* styling changes

* add animation

* tweak animations

* adjust styling slightly

* adjust styles on mobile

* styling improvements for web

* fix text alignment in RecommendedFollows

* dedupe inserted suggestions

* fix animation duration

* Minor spacing tweak

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com> and Eric Bailey <git@esb.lol>
Diffstat (limited to 'src')
-rw-r--r--src/state/models/discovery/onboarding.ts11
-rw-r--r--src/state/models/discovery/suggested-actors.ts19
-rw-r--r--src/view/com/auth/Onboarding.tsx4
-rw-r--r--src/view/com/auth/onboarding/RecommendedFeeds.tsx3
-rw-r--r--src/view/com/auth/onboarding/RecommendedFollows.tsx204
-rw-r--r--src/view/com/auth/onboarding/RecommendedFollowsItem.tsx160
-rw-r--r--src/view/com/auth/onboarding/WelcomeMobile.tsx4
-rw-r--r--src/view/com/profile/FollowButton.tsx10
-rw-r--r--src/view/com/util/forms/Button.tsx45
9 files changed, 445 insertions, 15 deletions
diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts
index 09c9eac04..8ad321ed9 100644
--- a/src/state/models/discovery/onboarding.ts
+++ b/src/state/models/discovery/onboarding.ts
@@ -2,10 +2,12 @@ import {makeAutoObservable} from 'mobx'
 import {RootStoreModel} from '../root-store'
 import {hasProp} from 'lib/type-guards'
 import {track} from 'lib/analytics/analytics'
+import {SuggestedActorsModel} from './suggested-actors'
 
 export const OnboardingScreenSteps = {
   Welcome: 'Welcome',
   RecommendedFeeds: 'RecommendedFeeds',
+  RecommendedFollows: 'RecommendedFollows',
   Home: 'Home',
 } as const
 
@@ -16,7 +18,11 @@ export class OnboardingModel {
   // state
   step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start()
 
+  // data
+  suggestedActors: SuggestedActorsModel
+
   constructor(public rootStore: RootStoreModel) {
+    this.suggestedActors = new SuggestedActorsModel(this.rootStore)
     makeAutoObservable(this, {
       rootStore: false,
       hydrate: false,
@@ -56,6 +62,11 @@ export class OnboardingModel {
       this.step = 'RecommendedFeeds'
       return this.step
     } else if (this.step === 'RecommendedFeeds') {
+      this.step = 'RecommendedFollows'
+      // prefetch recommended follows
+      this.suggestedActors.loadMore(true)
+      return this.step
+    } else if (this.step === 'RecommendedFollows') {
       this.finish()
       return this.step
     } else {
diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts
index 0b3d36952..afa5e74e3 100644
--- a/src/state/models/discovery/suggested-actors.ts
+++ b/src/state/models/discovery/suggested-actors.ts
@@ -19,6 +19,7 @@ export class SuggestedActorsModel {
   loadMoreCursor: string | undefined = undefined
   error = ''
   hasMore = false
+  lastInsertedAtIndex = -1
 
   // data
   suggestions: SuggestedActor[] = []
@@ -110,6 +111,24 @@ export class SuggestedActorsModel {
     }
   })
 
+  async insertSuggestionsByActor(actor: string, indexToInsertAt: number) {
+    // fetch suggestions
+    const res =
+      await this.rootStore.agent.app.bsky.graph.getSuggestedFollowsByActor({
+        actor: actor,
+      })
+    const {suggestions: moreSuggestions} = res.data
+    this.rootStore.me.follows.hydrateProfiles(moreSuggestions)
+    // dedupe
+    const toInsert = moreSuggestions.filter(
+      s => !this.suggestions.find(s2 => s2.did === s.did),
+    )
+    //  insert
+    this.suggestions.splice(indexToInsertAt + 1, 0, ...toInsert)
+    // update index
+    this.lastInsertedAtIndex = indexToInsertAt
+  }
+
   // state transitions
   // =
 
diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx
index 6ea8cd79e..a36544a03 100644
--- a/src/view/com/auth/Onboarding.tsx
+++ b/src/view/com/auth/Onboarding.tsx
@@ -7,6 +7,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useStores} from 'state/index'
 import {Welcome} from './onboarding/Welcome'
 import {RecommendedFeeds} from './onboarding/RecommendedFeeds'
+import {RecommendedFollows} from './onboarding/RecommendedFollows'
 
 export const Onboarding = observer(function OnboardingImpl() {
   const pal = usePalette('default')
@@ -28,6 +29,9 @@ export const Onboarding = observer(function OnboardingImpl() {
         {store.onboarding.step === 'RecommendedFeeds' && (
           <RecommendedFeeds next={next} />
         )}
+        {store.onboarding.step === 'RecommendedFollows' && (
+          <RecommendedFollows next={next} />
+        )}
       </ErrorBoundary>
     </SafeAreaView>
   )
diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
index b39714ef2..24fc9eef1 100644
--- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx
@@ -96,7 +96,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({
             <Text
               type="2xl-medium"
               style={{color: '#fff', position: 'relative', top: -1}}>
-              Done
+              Next
             </Text>
             <FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
           </View>
@@ -215,6 +215,7 @@ const mStyles = StyleSheet.create({
     marginBottom: 16,
     marginHorizontal: 16,
     marginTop: 16,
+    alignItems: 'center',
   },
   buttonText: {
     textAlign: 'center',
diff --git a/src/view/com/auth/onboarding/RecommendedFollows.tsx b/src/view/com/auth/onboarding/RecommendedFollows.tsx
new file mode 100644
index 000000000..f2710d2ac
--- /dev/null
+++ b/src/view/com/auth/onboarding/RecommendedFollows.tsx
@@ -0,0 +1,204 @@
+import React from 'react'
+import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints'
+import {Text} from 'view/com/util/text/Text'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
+import {Button} from 'view/com/util/forms/Button'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {RecommendedFollowsItem} from './RecommendedFollowsItem'
+
+type Props = {
+  next: () => void
+}
+export const RecommendedFollows = observer(function RecommendedFollowsImpl({
+  next,
+}: Props) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const {isTabletOrMobile} = useWebMediaQueries()
+
+  React.useEffect(() => {
+    // Load suggested actors if not already loaded
+    // prefetch should happen in the onboarding model
+    if (
+      !store.onboarding.suggestedActors.hasLoaded ||
+      store.onboarding.suggestedActors.isEmpty
+    ) {
+      store.onboarding.suggestedActors.loadMore(true)
+    }
+  }, [store])
+
+  const title = (
+    <>
+      <Text
+        style={[
+          pal.textLight,
+          tdStyles.title1,
+          isTabletOrMobile && tdStyles.title1Small,
+        ]}>
+        Follow some
+      </Text>
+      <Text
+        style={[
+          pal.link,
+          tdStyles.title2,
+          isTabletOrMobile && tdStyles.title2Small,
+        ]}>
+        Recommended
+      </Text>
+      <Text
+        style={[
+          pal.link,
+          tdStyles.title2,
+          isTabletOrMobile && tdStyles.title2Small,
+        ]}>
+        Users
+      </Text>
+      <Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}>
+        Follow some users to get started. We can recommend you more users based
+        on who you find interesting.
+      </Text>
+      <View
+        style={{
+          flexDirection: 'row',
+          justifyContent: 'flex-end',
+          marginTop: 20,
+        }}>
+        <Button onPress={next} testID="continueBtn">
+          <View
+            style={{
+              flexDirection: 'row',
+              alignItems: 'center',
+              paddingLeft: 2,
+              gap: 6,
+            }}>
+            <Text
+              type="2xl-medium"
+              style={{color: '#fff', position: 'relative', top: -1}}>
+              Done
+            </Text>
+            <FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
+          </View>
+        </Button>
+      </View>
+    </>
+  )
+
+  return (
+    <>
+      <TabletOrDesktop>
+        <TitleColumnLayout
+          testID="recommendedFollowsOnboarding"
+          title={title}
+          horizontal
+          titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}}
+          contentStyle={{paddingHorizontal: 0}}>
+          {store.onboarding.suggestedActors.isLoading ? (
+            <ActivityIndicator size="large" />
+          ) : (
+            <FlatList
+              data={store.onboarding.suggestedActors.suggestions}
+              renderItem={({item, index}) => (
+                <RecommendedFollowsItem item={item} index={index} />
+              )}
+              keyExtractor={(item, index) => item.did + index.toString()}
+              style={{flex: 1}}
+            />
+          )}
+        </TitleColumnLayout>
+      </TabletOrDesktop>
+
+      <Mobile>
+        <View style={[mStyles.container]} testID="recommendedFollowsOnboarding">
+          <View>
+            <ViewHeader
+              title="Recommended Follows"
+              showBackButton={false}
+              showOnDesktop
+            />
+            <Text type="lg-medium" style={[pal.text, mStyles.header]}>
+              Check out some recommended users. Follow them to see similar
+              users.
+            </Text>
+          </View>
+          {store.onboarding.suggestedActors.isLoading ? (
+            <ActivityIndicator size="large" />
+          ) : (
+            <FlatList
+              data={store.onboarding.suggestedActors.suggestions}
+              renderItem={({item, index}) => (
+                <RecommendedFollowsItem item={item} index={index} />
+              )}
+              keyExtractor={(item, index) => item.did + index.toString()}
+              style={{flex: 1}}
+            />
+          )}
+          <Button
+            onPress={next}
+            label="Continue"
+            testID="continueBtn"
+            style={mStyles.button}
+            labelStyle={mStyles.buttonText}
+          />
+        </View>
+      </Mobile>
+    </>
+  )
+})
+
+const tdStyles = StyleSheet.create({
+  container: {
+    flex: 1,
+    marginHorizontal: 16,
+    justifyContent: 'space-between',
+  },
+  title1: {
+    fontSize: 36,
+    fontWeight: '800',
+    textAlign: 'right',
+  },
+  title1Small: {
+    fontSize: 24,
+  },
+  title2: {
+    fontSize: 58,
+    fontWeight: '800',
+    textAlign: 'right',
+  },
+  title2Small: {
+    fontSize: 36,
+  },
+  description: {
+    maxWidth: 400,
+    marginTop: 10,
+    marginLeft: 'auto',
+    textAlign: 'right',
+  },
+})
+
+const mStyles = StyleSheet.create({
+  container: {
+    flex: 1,
+    justifyContent: 'space-between',
+  },
+  header: {
+    marginBottom: 16,
+    marginHorizontal: 16,
+  },
+  button: {
+    marginBottom: 16,
+    marginHorizontal: 16,
+    marginTop: 16,
+    alignItems: 'center',
+  },
+  buttonText: {
+    textAlign: 'center',
+    fontSize: 18,
+    paddingVertical: 4,
+  },
+})
diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
new file mode 100644
index 000000000..144fdc2e9
--- /dev/null
+++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
@@ -0,0 +1,160 @@
+import React, {useMemo} from 'react'
+import {View, StyleSheet, ActivityIndicator} from 'react-native'
+import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
+import {observer} from 'mobx-react-lite'
+import {useStores} from 'state/index'
+import {FollowButton} from 'view/com/profile/FollowButton'
+import {usePalette} from 'lib/hooks/usePalette'
+import {SuggestedActor} from 'state/models/discovery/suggested-actors'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {s} from 'lib/styles'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {Text} from 'view/com/util/text/Text'
+import Animated, {FadeInRight} from 'react-native-reanimated'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+
+type Props = {
+  item: SuggestedActor
+  index: number
+}
+export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => {
+  const pal = usePalette('default')
+  const store = useStores()
+  const {isMobile} = useWebMediaQueries()
+  const delay = useMemo(() => {
+    return (
+      50 *
+      (Math.abs(store.onboarding.suggestedActors.lastInsertedAtIndex - index) %
+        5)
+    )
+  }, [index, store.onboarding.suggestedActors.lastInsertedAtIndex])
+
+  return (
+    <Animated.View
+      entering={FadeInRight.delay(delay).springify()}
+      style={[
+        styles.cardContainer,
+        pal.view,
+        pal.border,
+        {
+          maxWidth: isMobile ? undefined : 670,
+          borderRightWidth: isMobile ? undefined : 1,
+        },
+      ]}>
+      <ProfileCard key={item.did} profile={item} index={index} />
+    </Animated.View>
+  )
+}
+
+export const ProfileCard = observer(function ProfileCardImpl({
+  profile,
+  index,
+}: {
+  profile: AppBskyActorDefs.ProfileViewBasic
+  index: number
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const moderation = moderateProfile(profile, store.preferences.moderationOpts)
+  const [addingMoreSuggestions, setAddingMoreSuggestions] =
+    React.useState(false)
+
+  return (
+    <View style={styles.card}>
+      <View style={styles.layout}>
+        <View style={styles.layoutAvi}>
+          <UserAvatar
+            size={40}
+            avatar={profile.avatar}
+            moderation={moderation.avatar}
+          />
+        </View>
+        <View style={styles.layoutContent}>
+          <Text
+            type="2xl-bold"
+            style={[s.bold, pal.text]}
+            numberOfLines={1}
+            lineHeight={1.2}>
+            {sanitizeDisplayName(
+              profile.displayName || sanitizeHandle(profile.handle),
+              moderation.profile,
+            )}
+          </Text>
+          <Text type="xl" style={[pal.textLight]} numberOfLines={1}>
+            {sanitizeHandle(profile.handle, '@')}
+          </Text>
+        </View>
+
+        <FollowButton
+          did={profile.did}
+          labelStyle={styles.followButton}
+          onToggleFollow={async isFollow => {
+            if (isFollow) {
+              setAddingMoreSuggestions(true)
+              await store.onboarding.suggestedActors.insertSuggestionsByActor(
+                profile.did,
+                index,
+              )
+              setAddingMoreSuggestions(false)
+            }
+          }}
+        />
+      </View>
+      {profile.description ? (
+        <View style={styles.details}>
+          <Text type="lg" style={pal.text} numberOfLines={4}>
+            {profile.description as string}
+          </Text>
+        </View>
+      ) : undefined}
+      {addingMoreSuggestions ? (
+        <View style={styles.addingMoreContainer}>
+          <ActivityIndicator size="small" color={pal.colors.text} />
+          <Text style={[pal.text]}>Finding similar accounts...</Text>
+        </View>
+      ) : null}
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  cardContainer: {
+    borderTopWidth: 1,
+  },
+  card: {
+    paddingHorizontal: 10,
+  },
+  layout: {
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  layoutAvi: {
+    width: 54,
+    paddingLeft: 4,
+    paddingTop: 8,
+    paddingBottom: 10,
+  },
+  layoutContent: {
+    flex: 1,
+    paddingRight: 10,
+    paddingTop: 10,
+    paddingBottom: 10,
+  },
+  details: {
+    paddingLeft: 54,
+    paddingRight: 10,
+    paddingBottom: 10,
+  },
+  addingMoreContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingLeft: 54,
+    paddingTop: 4,
+    paddingBottom: 12,
+    gap: 4,
+  },
+  followButton: {
+    fontSize: 16,
+  },
+})
diff --git a/src/view/com/auth/onboarding/WelcomeMobile.tsx b/src/view/com/auth/onboarding/WelcomeMobile.tsx
index 19c8d52d0..1f0a64370 100644
--- a/src/view/com/auth/onboarding/WelcomeMobile.tsx
+++ b/src/view/com/auth/onboarding/WelcomeMobile.tsx
@@ -88,6 +88,7 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({
         onPress={next}
         label="Continue"
         testID="continueBtn"
+        style={[styles.buttonContainer]}
         labelStyle={styles.buttonText}
       />
     </View>
@@ -117,6 +118,9 @@ const styles = StyleSheet.create({
   spacer: {
     height: 20,
   },
+  buttonContainer: {
+    alignItems: 'center',
+  },
   buttonText: {
     textAlign: 'center',
     fontSize: 18,
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
index 6f6286e69..4b2b944f7 100644
--- a/src/view/com/profile/FollowButton.tsx
+++ b/src/view/com/profile/FollowButton.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {View} from 'react-native'
+import {StyleProp, TextStyle, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {Button, ButtonType} from '../util/forms/Button'
 import {useStores} from 'state/index'
@@ -11,11 +11,13 @@ export const FollowButton = observer(function FollowButtonImpl({
   followedType = 'default',
   did,
   onToggleFollow,
+  labelStyle,
 }: {
   unfollowedType?: ButtonType
   followedType?: ButtonType
   did: string
   onToggleFollow?: (v: boolean) => void
+  labelStyle?: StyleProp<TextStyle>
 }) {
   const store = useStores()
   const followState = store.me.follows.getFollowState(did)
@@ -28,18 +30,18 @@ export const FollowButton = observer(function FollowButtonImpl({
     const updatedFollowState = await store.me.follows.fetchFollowState(did)
     if (updatedFollowState === FollowState.Following) {
       try {
+        onToggleFollow?.(false)
         await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
         store.me.follows.removeFollow(did)
-        onToggleFollow?.(false)
       } catch (e: any) {
         store.log.error('Failed to delete follow', e)
         Toast.show('An issue occurred, please try again.')
       }
     } else if (updatedFollowState === FollowState.NotFollowing) {
       try {
+        onToggleFollow?.(true)
         const res = await store.agent.follow(did)
         store.me.follows.addFollow(did, res.uri)
-        onToggleFollow?.(true)
       } catch (e: any) {
         store.log.error('Failed to create follow', e)
         Toast.show('An issue occurred, please try again.')
@@ -52,8 +54,10 @@ export const FollowButton = observer(function FollowButtonImpl({
       type={
         followState === FollowState.Following ? followedType : unfollowedType
       }
+      labelStyle={labelStyle}
       onPress={onToggleFollowInner}
       label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
+      withLoading={true}
     />
   )
 })
diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx
index 8049d2243..076fa1baa 100644
--- a/src/view/com/util/forms/Button.tsx
+++ b/src/view/com/util/forms/Button.tsx
@@ -7,6 +7,8 @@ import {
   Pressable,
   ViewStyle,
   PressableStateCallbackType,
+  ActivityIndicator,
+  View,
 } from 'react-native'
 import {Text} from '../text/Text'
 import {useTheme} from 'lib/ThemeContext'
@@ -48,17 +50,19 @@ export function Button({
   accessibilityHint,
   accessibilityLabelledBy,
   onAccessibilityEscape,
+  withLoading = false,
 }: React.PropsWithChildren<{
   type?: ButtonType
   label?: string
   style?: StyleProp<ViewStyle>
   labelStyle?: StyleProp<TextStyle>
-  onPress?: () => void
+  onPress?: () => void | Promise<void>
   testID?: string
   accessibilityLabel?: string
   accessibilityHint?: string
   accessibilityLabelledBy?: string
   onAccessibilityEscape?: () => void
+  withLoading?: boolean
 }>) {
   const theme = useTheme()
   const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
@@ -138,13 +142,16 @@ export function Button({
     },
   )
 
+  const [isLoading, setIsLoading] = React.useState(false)
   const onPressWrapped = React.useCallback(
-    (event: Event) => {
+    async (event: Event) => {
       event.stopPropagation()
       event.preventDefault()
-      onPress?.()
+      withLoading && setIsLoading(true)
+      await onPress?.()
+      withLoading && setIsLoading(false)
     },
-    [onPress],
+    [onPress, withLoading],
   )
 
   const getStyle = React.useCallback(
@@ -160,23 +167,35 @@ export function Button({
     [typeOuterStyle, style],
   )
 
+  const renderChildern = React.useCallback(() => {
+    if (!label) {
+      return children
+    }
+
+    return (
+      <View style={styles.labelContainer}>
+        {label && withLoading && isLoading ? (
+          <ActivityIndicator size={12} color={typeLabelStyle.color} />
+        ) : null}
+        <Text type="button" style={[typeLabelStyle, labelStyle]}>
+          {label}
+        </Text>
+      </View>
+    )
+  }, [children, label, withLoading, isLoading, typeLabelStyle, labelStyle])
+
   return (
     <Pressable
       style={getStyle}
       onPress={onPressWrapped}
+      disabled={isLoading}
       testID={testID}
       accessibilityRole="button"
       accessibilityLabel={accessibilityLabel}
       accessibilityHint={accessibilityHint}
       accessibilityLabelledBy={accessibilityLabelledBy}
       onAccessibilityEscape={onAccessibilityEscape}>
-      {label ? (
-        <Text type="button" style={[typeLabelStyle, labelStyle]}>
-          {label}
-        </Text>
-      ) : (
-        children
-      )}
+      {renderChildern}
     </Pressable>
   )
 }
@@ -187,4 +206,8 @@ const styles = StyleSheet.create({
     paddingVertical: 8,
     borderRadius: 24,
   },
+  labelContainer: {
+    flexDirection: 'row',
+    gap: 8,
+  },
 })