about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-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
7 files changed, 415 insertions, 15 deletions
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,
+  },
 })