about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/state/queries/suggested-follows.ts75
-rw-r--r--src/view/com/auth/onboarding/RecommendedFollows.tsx108
-rw-r--r--src/view/com/auth/onboarding/RecommendedFollowsItem.tsx107
3 files changed, 227 insertions, 63 deletions
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
new file mode 100644
index 000000000..805668bcb
--- /dev/null
+++ b/src/state/queries/suggested-follows.ts
@@ -0,0 +1,75 @@
+import {AppBskyActorGetSuggestions, moderateProfile} from '@atproto/api'
+import {
+  useInfiniteQuery,
+  useMutation,
+  InfiniteData,
+  QueryKey,
+} from '@tanstack/react-query'
+
+import {useSession} from '#/state/session'
+import {useModerationOpts} from '#/state/queries/preferences'
+
+export const suggestedFollowsQueryKey = ['suggested-follows']
+
+export function useSuggestedFollowsQuery() {
+  const {agent, currentAccount} = useSession()
+  const moderationOpts = useModerationOpts()
+
+  return useInfiniteQuery<
+    AppBskyActorGetSuggestions.OutputSchema,
+    Error,
+    InfiniteData<AppBskyActorGetSuggestions.OutputSchema>,
+    QueryKey,
+    string | undefined
+  >({
+    enabled: !!moderationOpts,
+    queryKey: suggestedFollowsQueryKey,
+    queryFn: async ({pageParam}) => {
+      const res = await agent.app.bsky.actor.getSuggestions({
+        limit: 25,
+        cursor: pageParam,
+      })
+
+      res.data.actors = res.data.actors
+        .filter(
+          actor => !moderateProfile(actor, moderationOpts!).account.filter,
+        )
+        .filter(actor => {
+          const viewer = actor.viewer
+          if (viewer) {
+            if (
+              viewer.following ||
+              viewer.muted ||
+              viewer.mutedByList ||
+              viewer.blockedBy ||
+              viewer.blocking
+            ) {
+              return false
+            }
+          }
+          if (actor.did === currentAccount?.did) {
+            return false
+          }
+          return true
+        })
+
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}
+
+export function useGetSuggestedFollowersByActor() {
+  const {agent} = useSession()
+
+  return useMutation({
+    mutationFn: async (actor: string) => {
+      const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
+        actor: actor,
+      })
+
+      return res.data
+    },
+  })
+}
diff --git a/src/view/com/auth/onboarding/RecommendedFollows.tsx b/src/view/com/auth/onboarding/RecommendedFollows.tsx
index 9eef14e0b..efe41562e 100644
--- a/src/view/com/auth/onboarding/RecommendedFollows.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFollows.tsx
@@ -2,6 +2,7 @@ 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 {AppBskyActorDefs, moderateProfile} from '@atproto/api'
 import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints'
 import {Text} from 'view/com/util/text/Text'
 import {ViewHeader} from 'view/com/util/ViewHeader'
@@ -9,9 +10,11 @@ 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'
-import {SuggestedActorsModel} from '#/state/models/discovery/suggested-actors'
+import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
+import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {logger} from '#/logger'
 
 type Props = {
   next: () => void
@@ -19,14 +22,16 @@ type Props = {
 export const RecommendedFollows = observer(function RecommendedFollowsImpl({
   next,
 }: Props) {
-  const store = useStores()
   const pal = usePalette('default')
   const {isTabletOrMobile} = useWebMediaQueries()
-  const suggestedActors = React.useMemo(() => {
-    const model = new SuggestedActorsModel(store)
-    model.refresh()
-    return model
-  }, [store])
+  const {data: suggestedFollows, dataUpdatedAt} = useSuggestedFollowsQuery()
+  const {mutateAsync: getSuggestedFollowsByActor} =
+    useGetSuggestedFollowersByActor()
+  const [additionalSuggestions, setAdditionalSuggestions] = React.useState<{
+    [did: string]: AppBskyActorDefs.ProfileView[]
+  }>({})
+  const existingDids = React.useRef<string[]>([])
+  const moderationOpts = useModerationOpts()
 
   const title = (
     <>
@@ -84,6 +89,59 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
     </>
   )
 
+  const suggestions = React.useMemo(() => {
+    if (!suggestedFollows) return []
+
+    const additional = Object.entries(additionalSuggestions)
+    const items = suggestedFollows.pages.flatMap(page => page.actors)
+
+    outer: while (additional.length) {
+      const additionalAccount = additional.shift()
+
+      if (!additionalAccount) break
+
+      const [followedUser, relatedAccounts] = additionalAccount
+
+      for (let i = 0; i < items.length; i++) {
+        if (items[i].did === followedUser) {
+          items.splice(i + 1, 0, ...relatedAccounts)
+          continue outer
+        }
+      }
+    }
+
+    existingDids.current = items.map(i => i.did)
+
+    return items
+  }, [suggestedFollows, additionalSuggestions])
+
+  const onFollowStateChange = React.useCallback(
+    async ({following, did}: {following: boolean; did: string}) => {
+      if (following) {
+        try {
+          const {suggestions: results} = await getSuggestedFollowsByActor(did)
+
+          if (results.length) {
+            const deduped = results.filter(
+              r => !existingDids.current.find(did => did === r.did),
+            )
+            setAdditionalSuggestions(s => ({
+              ...s,
+              [did]: deduped.slice(0, 3),
+            }))
+          }
+        } catch (e) {
+          logger.error('RecommendedFollows: failed to get suggestions', {
+            error: e,
+          })
+        }
+      }
+
+      // not handling the unfollow case
+    },
+    [existingDids, getSuggestedFollowsByActor, setAdditionalSuggestions],
+  )
+
   return (
     <>
       <TabletOrDesktop>
@@ -93,21 +151,20 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
           horizontal
           titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}}
           contentStyle={{paddingHorizontal: 0}}>
-          {suggestedActors.isLoading ? (
+          {!suggestedFollows || !moderationOpts ? (
             <ActivityIndicator size="large" />
           ) : (
             <FlatList
-              data={suggestedActors.suggestions}
-              renderItem={({item, index}) => (
+              data={suggestions}
+              renderItem={({item}) => (
                 <RecommendedFollowsItem
-                  item={item}
-                  index={index}
-                  insertSuggestionsByActor={suggestedActors.insertSuggestionsByActor.bind(
-                    suggestedActors,
-                  )}
+                  profile={item}
+                  dataUpdatedAt={dataUpdatedAt}
+                  onFollowStateChange={onFollowStateChange}
+                  moderation={moderateProfile(item, moderationOpts)}
                 />
               )}
-              keyExtractor={(item, index) => item.did + index.toString()}
+              keyExtractor={item => item.did}
               style={{flex: 1}}
             />
           )}
@@ -127,21 +184,20 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({
               users.
             </Text>
           </View>
-          {suggestedActors.isLoading ? (
+          {!suggestedFollows || !moderationOpts ? (
             <ActivityIndicator size="large" />
           ) : (
             <FlatList
-              data={suggestedActors.suggestions}
-              renderItem={({item, index}) => (
+              data={suggestions}
+              renderItem={({item}) => (
                 <RecommendedFollowsItem
-                  item={item}
-                  index={index}
-                  insertSuggestionsByActor={suggestedActors.insertSuggestionsByActor.bind(
-                    suggestedActors,
-                  )}
+                  profile={item}
+                  dataUpdatedAt={dataUpdatedAt}
+                  onFollowStateChange={onFollowStateChange}
+                  moderation={moderateProfile(item, moderationOpts)}
                 />
               )}
-              keyExtractor={(item, index) => item.did + index.toString()}
+              keyExtractor={item => item.did}
               style={{flex: 1}}
             />
           )}
diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
index 7ec78bd7f..f52b31213 100644
--- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
@@ -1,9 +1,7 @@
 import React 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 {ProfileModeration} from '@atproto/api'
+import {Button} from '#/view/com/util/forms/Button'
 import {usePalette} from 'lib/hooks/usePalette'
 import {SuggestedActor} from 'state/models/discovery/suggested-actors'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
@@ -15,19 +13,32 @@ import Animated, {FadeInRight} from 'react-native-reanimated'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {Trans} from '@lingui/macro'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {
+  useProfileFollowMutation,
+  useProfileUnfollowMutation,
+} from '#/state/queries/profile'
+import {logger} from '#/logger'
 
 type Props = {
-  item: SuggestedActor
-  index: number
-  insertSuggestionsByActor: (did: string, index: number) => Promise<void>
+  profile: SuggestedActor
+  dataUpdatedAt: number
+  moderation: ProfileModeration
+  onFollowStateChange: (props: {
+    did: string
+    following: boolean
+  }) => Promise<void>
 }
-export const RecommendedFollowsItem: React.FC<Props> = ({
-  item,
-  index,
-  insertSuggestionsByActor,
-}) => {
+
+export function RecommendedFollowsItem({
+  profile,
+  dataUpdatedAt,
+  moderation,
+  onFollowStateChange,
+}: React.PropsWithChildren<Props>) {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
+  const shadowedProfile = useProfileShadow(profile, dataUpdatedAt)
 
   return (
     <Animated.View
@@ -42,30 +53,57 @@ export const RecommendedFollowsItem: React.FC<Props> = ({
         },
       ]}>
       <ProfileCard
-        key={item.did}
-        profile={item}
-        index={index}
-        insertSuggestionsByActor={insertSuggestionsByActor}
+        key={profile.did}
+        profile={shadowedProfile}
+        onFollowStateChange={onFollowStateChange}
+        moderation={moderation}
       />
     </Animated.View>
   )
 }
 
-export const ProfileCard = observer(function ProfileCardImpl({
+export function ProfileCard({
   profile,
-  index,
-  insertSuggestionsByActor,
-}: {
-  profile: AppBskyActorDefs.ProfileViewBasic
-  index: number
-  insertSuggestionsByActor: (did: string, index: number) => Promise<void>
-}) {
+  onFollowStateChange,
+  moderation,
+}: Omit<Props, 'dataUpdatedAt'>) {
   const {track} = useAnalytics()
-  const store = useStores()
   const pal = usePalette('default')
-  const moderation = moderateProfile(profile, store.preferences.moderationOpts)
   const [addingMoreSuggestions, setAddingMoreSuggestions] =
     React.useState(false)
+  const {mutateAsync: follow} = useProfileFollowMutation()
+  const {mutateAsync: unfollow} = useProfileUnfollowMutation()
+
+  const onToggleFollow = React.useCallback(async () => {
+    try {
+      if (
+        profile.viewer?.following &&
+        profile.viewer?.following !== 'pending'
+      ) {
+        await unfollow({did: profile.did, followUri: profile.viewer.following})
+      } else if (
+        !profile.viewer?.following &&
+        profile.viewer?.following !== 'pending'
+      ) {
+        setAddingMoreSuggestions(true)
+        await follow({did: profile.did})
+        await onFollowStateChange({did: profile.did, following: true})
+        setAddingMoreSuggestions(false)
+        track('Onboarding:SuggestedFollowFollowed')
+      }
+    } catch (e) {
+      logger.error('RecommendedFollows: failed to toggle following', {error: e})
+    } finally {
+      setAddingMoreSuggestions(false)
+    }
+  }, [
+    profile,
+    follow,
+    unfollow,
+    setAddingMoreSuggestions,
+    track,
+    onFollowStateChange,
+  ])
 
   return (
     <View style={styles.card}>
@@ -93,17 +131,12 @@ export const ProfileCard = observer(function ProfileCardImpl({
           </Text>
         </View>
 
-        <FollowButton
-          profile={profile}
+        <Button
+          type={profile.viewer?.following ? 'default' : 'inverted'}
           labelStyle={styles.followButton}
-          onToggleFollow={async isFollow => {
-            if (isFollow) {
-              setAddingMoreSuggestions(true)
-              await insertSuggestionsByActor(profile.did, index)
-              setAddingMoreSuggestions(false)
-              track('Onboarding:SuggestedFollowFollowed')
-            }
-          }}
+          onPress={onToggleFollow}
+          label={profile.viewer?.following ? 'Unfollow' : 'Follow'}
+          withLoading={true}
         />
       </View>
       {profile.description ? (
@@ -123,7 +156,7 @@ export const ProfileCard = observer(function ProfileCardImpl({
       ) : null}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   cardContainer: {