about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/lib/hooks/useFollowDid.ts46
-rw-r--r--src/view/com/modals/ProfilePreview.tsx7
-rw-r--r--src/view/com/profile/FollowButton.tsx47
-rw-r--r--src/view/com/profile/ProfileHeader.tsx52
-rw-r--r--src/view/com/profile/ProfileHeaderSuggestedFollows.tsx288
5 files changed, 406 insertions, 34 deletions
diff --git a/src/lib/hooks/useFollowDid.ts b/src/lib/hooks/useFollowDid.ts
new file mode 100644
index 000000000..223adb047
--- /dev/null
+++ b/src/lib/hooks/useFollowDid.ts
@@ -0,0 +1,46 @@
+import React from 'react'
+
+import {useStores} from 'state/index'
+import {FollowState} from 'state/models/cache/my-follows'
+
+export function useFollowDid({did}: {did: string}) {
+  const store = useStores()
+  const state = store.me.follows.getFollowState(did)
+
+  return {
+    state,
+    following: state === FollowState.Following,
+    toggle: React.useCallback(async () => {
+      if (state === FollowState.Following) {
+        try {
+          await store.agent.deleteFollow(store.me.follows.getFollowUri(did))
+          store.me.follows.removeFollow(did)
+          return {
+            state: FollowState.NotFollowing,
+            following: false,
+          }
+        } catch (e: any) {
+          store.log.error('Failed to delete follow', e)
+          throw e
+        }
+      } else if (state === FollowState.NotFollowing) {
+        try {
+          const res = await store.agent.follow(did)
+          store.me.follows.addFollow(did, res.uri)
+          return {
+            state: FollowState.Following,
+            following: true,
+          }
+        } catch (e: any) {
+          store.log.error('Failed to create follow', e)
+          throw e
+        }
+      }
+
+      return {
+        state: FollowState.Unknown,
+        following: false,
+      }
+    }, [store, did, state]),
+  }
+}
diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx
index 6f189cf1a..e0b3ec072 100644
--- a/src/view/com/modals/ProfilePreview.tsx
+++ b/src/view/com/modals/ProfilePreview.tsx
@@ -41,7 +41,12 @@ export const Component = observer(function ProfilePreviewImpl({
           styles.headerWrapper,
           isLoading && isIOS && styles.headerPositionAdjust,
         ]}>
-        <ProfileHeader view={model} hideBackButton onRefreshAll={() => {}} />
+        <ProfileHeader
+          view={model}
+          hideBackButton
+          onRefreshAll={() => {}}
+          isProfilePreview
+        />
       </View>
       <View style={[styles.hintWrapper, pal.view]}>
         <View style={styles.hint}>
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
index 4b2b944f7..217d326e8 100644
--- a/src/view/com/profile/FollowButton.tsx
+++ b/src/view/com/profile/FollowButton.tsx
@@ -2,9 +2,9 @@ import React from 'react'
 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'
 import * as Toast from '../util/Toast'
 import {FollowState} from 'state/models/cache/my-follows'
+import {useFollowDid} from 'lib/hooks/useFollowDid'
 
 export const FollowButton = observer(function FollowButtonImpl({
   unfollowedType = 'inverted',
@@ -19,44 +19,27 @@ export const FollowButton = observer(function FollowButtonImpl({
   onToggleFollow?: (v: boolean) => void
   labelStyle?: StyleProp<TextStyle>
 }) {
-  const store = useStores()
-  const followState = store.me.follows.getFollowState(did)
+  const {state, following, toggle} = useFollowDid({did})
 
-  if (followState === FollowState.Unknown) {
-    return <View />
-  }
-
-  const onToggleFollowInner = async () => {
-    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)
-      } 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)
-      } catch (e: any) {
-        store.log.error('Failed to create follow', e)
-        Toast.show('An issue occurred, please try again.')
-      }
+  const onPress = React.useCallback(async () => {
+    try {
+      const {following} = await toggle()
+      onToggleFollow?.(following)
+    } catch (e: any) {
+      Toast.show('An issue occurred, please try again.')
     }
+  }, [toggle, onToggleFollow])
+
+  if (state === FollowState.Unknown) {
+    return <View />
   }
 
   return (
     <Button
-      type={
-        followState === FollowState.Following ? followedType : unfollowedType
-      }
+      type={following ? followedType : unfollowedType}
       labelStyle={labelStyle}
-      onPress={onToggleFollowInner}
-      label={followState === FollowState.Following ? 'Unfollow' : 'Follow'}
+      onPress={onPress}
+      label={following ? 'Unfollow' : 'Follow'}
       withLoading={true}
     />
   )
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index cafb37743..7f3e52d96 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -38,17 +38,20 @@ import {BACK_HITSLOP} from 'lib/constants'
 import {isInvalidHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
 import {Link} from '../util/Link'
+import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
 
 interface Props {
   view: ProfileModel
   onRefreshAll: () => void
   hideBackButton?: boolean
+  isProfilePreview?: boolean
 }
 
 export const ProfileHeader = observer(function ProfileHeaderImpl({
   view,
   onRefreshAll,
   hideBackButton = false,
+  isProfilePreview,
 }: Props) {
   const pal = usePalette('default')
 
@@ -95,6 +98,7 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({
       view={view}
       onRefreshAll={onRefreshAll}
       hideBackButton={hideBackButton}
+      isProfilePreview={isProfilePreview}
     />
   )
 })
@@ -103,6 +107,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
   view,
   onRefreshAll,
   hideBackButton = false,
+  isProfilePreview,
 }: Props) {
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
@@ -111,6 +116,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
   const {track} = useAnalytics()
   const invalidHandle = isInvalidHandle(view.handle)
   const {isDesktop} = useWebMediaQueries()
+  const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
 
   const onPressBack = React.useCallback(() => {
     navigation.goBack()
@@ -133,6 +139,8 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
     )
     view?.toggleFollowing().then(
       () => {
+        setShowSuggestedFollows(Boolean(view.viewer.following))
+
         Toast.show(
           `${
             view.viewer.following ? 'Following' : 'No longer following'
@@ -141,7 +149,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
       },
       err => store.log.error('Failed to toggle follow', err),
     )
-  }, [track, view, store.log])
+  }, [track, view, store.log, setShowSuggestedFollows])
 
   const onPressEditProfile = React.useCallback(() => {
     track('ProfileHeader:EditProfileButtonClicked')
@@ -373,6 +381,39 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
             </TouchableOpacity>
           ) : !view.viewer.blockedBy ? (
             <>
+              {!isProfilePreview && (
+                <TouchableOpacity
+                  testID="suggestedFollowsBtn"
+                  onPress={() => setShowSuggestedFollows(!showSuggestedFollows)}
+                  style={[
+                    styles.btn,
+                    styles.mainBtn,
+                    pal.btn,
+                    {
+                      paddingHorizontal: 10,
+                      backgroundColor: showSuggestedFollows
+                        ? colors.blue3
+                        : pal.viewLight.backgroundColor,
+                    },
+                  ]}
+                  accessibilityRole="button"
+                  accessibilityLabel={`Show follows similar to ${view.handle}`}
+                  accessibilityHint={`Shows a list of users similar to this user.`}>
+                  <FontAwesomeIcon
+                    icon="user-plus"
+                    style={[
+                      pal.text,
+                      {
+                        color: showSuggestedFollows
+                          ? colors.white
+                          : pal.text.color,
+                      },
+                    ]}
+                    size={14}
+                  />
+                </TouchableOpacity>
+              )}
+
               {store.me.follows.getFollowState(view.did) ===
               FollowState.Following ? (
                 <TouchableOpacity
@@ -504,6 +545,15 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
         )}
         <ProfileHeaderAlerts moderation={view.moderation} />
       </View>
+
+      {!isProfilePreview && (
+        <ProfileHeaderSuggestedFollows
+          actorDid={view.did}
+          active={showSuggestedFollows}
+          requestDismiss={() => setShowSuggestedFollows(!showSuggestedFollows)}
+        />
+      )}
+
       {!isDesktop && !hideBackButton && (
         <TouchableWithoutFeedback
           onPress={onPressBack}
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
new file mode 100644
index 000000000..0199c9b39
--- /dev/null
+++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
@@ -0,0 +1,288 @@
+import React from 'react'
+import {View, StyleSheet, ScrollView, Pressable} from 'react-native'
+import Animated, {
+  useSharedValue,
+  withTiming,
+  useAnimatedStyle,
+  Easing,
+} from 'react-native-reanimated'
+import {useQuery} from '@tanstack/react-query'
+import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
+import {observer} from 'mobx-react-lite'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+
+import * as Toast from '../util/Toast'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {Text} from 'view/com/util/text/Text'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {useFollowDid} from 'lib/hooks/useFollowDid'
+import {Button} from 'view/com/util/forms/Button'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {makeProfileLink} from 'lib/routes/links'
+import {Link} from 'view/com/util/Link'
+
+const OUTER_PADDING = 10
+const INNER_PADDING = 14
+const TOTAL_HEIGHT = 250
+
+export function ProfileHeaderSuggestedFollows({
+  actorDid,
+  active,
+  requestDismiss,
+}: {
+  actorDid: string
+  active: boolean
+  requestDismiss: () => void
+}) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const animatedHeight = useSharedValue(0)
+  const animatedStyles = useAnimatedStyle(() => ({
+    opacity: animatedHeight.value / TOTAL_HEIGHT,
+    height: animatedHeight.value,
+  }))
+
+  React.useEffect(() => {
+    if (active) {
+      animatedHeight.value = withTiming(TOTAL_HEIGHT, {
+        duration: 500,
+        easing: Easing.inOut(Easing.exp),
+      })
+    } else {
+      animatedHeight.value = withTiming(0, {
+        duration: 500,
+        easing: Easing.inOut(Easing.exp),
+      })
+    }
+  }, [active, animatedHeight])
+
+  const {isLoading, data: suggestedFollows} = useQuery({
+    enabled: active,
+    cacheTime: 0,
+    staleTime: 0,
+    queryKey: ['suggested_follows_by_actor', actorDid],
+    async queryFn() {
+      try {
+        const {
+          data: {suggestions},
+          success,
+        } = await store.agent.app.bsky.graph.getSuggestedFollowsByActor({
+          actor: actorDid,
+        })
+
+        if (!success) {
+          return []
+        }
+
+        store.me.follows.hydrateProfiles(suggestions)
+
+        return suggestions
+      } catch (e) {
+        return []
+      }
+    },
+  })
+
+  return (
+    <Animated.View style={[{overflow: 'hidden', opacity: 0}, animatedStyles]}>
+      <View style={{paddingVertical: OUTER_PADDING}}>
+        <View
+          style={{
+            backgroundColor: pal.viewLight.backgroundColor,
+            height: '100%',
+            paddingTop: INNER_PADDING / 2,
+            paddingBottom: INNER_PADDING,
+          }}>
+          <View
+            style={{
+              flexDirection: 'row',
+              justifyContent: 'space-between',
+              alignItems: 'center',
+              paddingTop: 4,
+              paddingBottom: INNER_PADDING / 2,
+              paddingLeft: INNER_PADDING,
+              paddingRight: INNER_PADDING / 2,
+            }}>
+            <Text type="sm-bold" style={[pal.textLight]}>
+              Suggested for you
+            </Text>
+
+            <Pressable
+              accessibilityRole="button"
+              onPress={requestDismiss}
+              hitSlop={10}
+              style={{padding: INNER_PADDING / 2}}>
+              <FontAwesomeIcon
+                icon="x"
+                size={12}
+                style={pal.textLight as FontAwesomeIconStyle}
+              />
+            </Pressable>
+          </View>
+
+          <ScrollView
+            horizontal
+            showsHorizontalScrollIndicator={false}
+            contentContainerStyle={{
+              alignItems: 'flex-start',
+              paddingLeft: INNER_PADDING / 2,
+            }}>
+            {isLoading ? (
+              <>
+                <SuggestedFollowSkeleton />
+                <SuggestedFollowSkeleton />
+                <SuggestedFollowSkeleton />
+                <SuggestedFollowSkeleton />
+                <SuggestedFollowSkeleton />
+                <SuggestedFollowSkeleton />
+              </>
+            ) : suggestedFollows ? (
+              suggestedFollows.map(profile => (
+                <SuggestedFollow key={profile.did} profile={profile} />
+              ))
+            ) : (
+              <View />
+            )}
+          </ScrollView>
+        </View>
+      </View>
+    </Animated.View>
+  )
+}
+
+function SuggestedFollowSkeleton() {
+  const pal = usePalette('default')
+  return (
+    <View
+      style={[
+        styles.suggestedFollowCardOuter,
+        {
+          backgroundColor: pal.view.backgroundColor,
+        },
+      ]}>
+      <View
+        style={{
+          height: 60,
+          width: 60,
+          borderRadius: 60,
+          backgroundColor: pal.viewLight.backgroundColor,
+          opacity: 0.6,
+        }}
+      />
+      <View
+        style={{
+          height: 17,
+          width: 70,
+          borderRadius: 4,
+          backgroundColor: pal.viewLight.backgroundColor,
+          marginTop: 12,
+          marginBottom: 4,
+        }}
+      />
+      <View
+        style={{
+          height: 12,
+          width: 70,
+          borderRadius: 4,
+          backgroundColor: pal.viewLight.backgroundColor,
+          marginBottom: 12,
+          opacity: 0.6,
+        }}
+      />
+      <View
+        style={{
+          height: 32,
+          borderRadius: 32,
+          width: '100%',
+          backgroundColor: pal.viewLight.backgroundColor,
+        }}
+      />
+    </View>
+  )
+}
+
+const SuggestedFollow = observer(function SuggestedFollowImpl({
+  profile,
+}: {
+  profile: AppBskyActorDefs.ProfileView
+}) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const {following, toggle} = useFollowDid({did: profile.did})
+  const moderation = moderateProfile(profile, store.preferences.moderationOpts)
+
+  const onPress = React.useCallback(async () => {
+    try {
+      await toggle()
+    } catch (e: any) {
+      Toast.show('An issue occurred, please try again.')
+    }
+  }, [toggle])
+
+  return (
+    <Link
+      href={makeProfileLink(profile)}
+      title={profile.handle}
+      asAnchor
+      anchorNoUnderline>
+      <View
+        style={[
+          styles.suggestedFollowCardOuter,
+          {
+            backgroundColor: pal.view.backgroundColor,
+          },
+        ]}>
+        <UserAvatar
+          size={60}
+          avatar={profile.avatar}
+          moderation={moderation.avatar}
+        />
+
+        <View style={{width: '100%', paddingVertical: 12}}>
+          <Text
+            type="xs-medium"
+            style={[pal.text, {textAlign: 'center'}]}
+            numberOfLines={1}>
+            {sanitizeDisplayName(
+              profile.displayName || sanitizeHandle(profile.handle),
+              moderation.profile,
+            )}
+          </Text>
+          <Text
+            type="xs-medium"
+            style={[pal.textLight, {textAlign: 'center'}]}
+            numberOfLines={1}>
+            {sanitizeHandle(profile.handle, '@')}
+          </Text>
+        </View>
+
+        <Button
+          label={following ? 'Unfollow' : 'Follow'}
+          type="inverted"
+          labelStyle={{textAlign: 'center'}}
+          onPress={onPress}
+          withLoading
+        />
+      </View>
+    </Link>
+  )
+})
+
+const styles = StyleSheet.create({
+  suggestedFollowCardOuter: {
+    marginHorizontal: INNER_PADDING / 2,
+    paddingTop: 10,
+    paddingBottom: 12,
+    paddingHorizontal: 10,
+    borderRadius: 8,
+    width: 130,
+    alignItems: 'center',
+    overflow: 'hidden',
+    flexShrink: 1,
+  },
+})