about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/hooks/useFollowProfile.ts55
-rw-r--r--src/state/models/lists/user-followers.ts121
-rw-r--r--src/state/models/lists/user-follows.ts121
-rw-r--r--src/state/queries/profile-followers.ts32
-rw-r--r--src/state/queries/profile-follows.ts32
-rw-r--r--src/state/queries/suggested-follows.ts28
-rw-r--r--src/view/com/profile/FollowButton.tsx77
-rw-r--r--src/view/com/profile/ProfileCard.tsx63
-rw-r--r--src/view/com/profile/ProfileFollowers.tsx111
-rw-r--r--src/view/com/profile/ProfileFollows.tsx104
-rw-r--r--src/view/com/profile/ProfileHeaderSuggestedFollows.tsx97
11 files changed, 363 insertions, 478 deletions
diff --git a/src/lib/hooks/useFollowProfile.ts b/src/lib/hooks/useFollowProfile.ts
deleted file mode 100644
index 98dd63f5f..000000000
--- a/src/lib/hooks/useFollowProfile.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react'
-import {AppBskyActorDefs} from '@atproto/api'
-import {useStores} from 'state/index'
-import {FollowState} from 'state/models/cache/my-follows'
-import {logger} from '#/logger'
-
-export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
-  const store = useStores()
-  const state = store.me.follows.getFollowState(profile.did)
-
-  return {
-    state,
-    following: state === FollowState.Following,
-    toggle: React.useCallback(async () => {
-      if (state === FollowState.Following) {
-        try {
-          await store.agent.deleteFollow(
-            store.me.follows.getFollowUri(profile.did),
-          )
-          store.me.follows.removeFollow(profile.did)
-          return {
-            state: FollowState.NotFollowing,
-            following: false,
-          }
-        } catch (e: any) {
-          logger.error('Failed to delete follow', {error: e})
-          throw e
-        }
-      } else if (state === FollowState.NotFollowing) {
-        try {
-          const res = await store.agent.follow(profile.did)
-          store.me.follows.addFollow(profile.did, {
-            followRecordUri: res.uri,
-            did: profile.did,
-            handle: profile.handle,
-            displayName: profile.displayName,
-            avatar: profile.avatar,
-          })
-          return {
-            state: FollowState.Following,
-            following: true,
-          }
-        } catch (e: any) {
-          logger.error('Failed to create follow', {error: e})
-          throw e
-        }
-      }
-
-      return {
-        state: FollowState.Unknown,
-        following: false,
-      }
-    }, [store, profile, state]),
-  }
-}
diff --git a/src/state/models/lists/user-followers.ts b/src/state/models/lists/user-followers.ts
deleted file mode 100644
index 159308b9b..000000000
--- a/src/state/models/lists/user-followers.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {
-  AppBskyGraphGetFollowers as GetFollowers,
-  AppBskyActorDefs as ActorDefs,
-} from '@atproto/api'
-import {RootStoreModel} from '../root-store'
-import {cleanError} from 'lib/strings/errors'
-import {bundleAsync} from 'lib/async/bundle'
-import {logger} from '#/logger'
-
-const PAGE_SIZE = 30
-
-export type FollowerItem = ActorDefs.ProfileViewBasic
-
-export class UserFollowersModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  params: GetFollowers.QueryParams
-  hasMore = true
-  loadMoreCursor?: string
-
-  // data
-  subject: ActorDefs.ProfileViewBasic = {
-    did: '',
-    handle: '',
-  }
-  followers: FollowerItem[] = []
-
-  constructor(
-    public rootStore: RootStoreModel,
-    params: GetFollowers.QueryParams,
-  ) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-        params: false,
-      },
-      {autoBind: true},
-    )
-    this.params = params
-  }
-
-  get hasContent() {
-    return this.subject.did !== ''
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  // public api
-  // =
-
-  async refresh() {
-    return this.loadMore(true)
-  }
-
-  loadMore = bundleAsync(async (replace: boolean = false) => {
-    if (!replace && !this.hasMore) {
-      return
-    }
-    this._xLoading(replace)
-    try {
-      const params = Object.assign({}, this.params, {
-        limit: PAGE_SIZE,
-        cursor: replace ? undefined : this.loadMoreCursor,
-      })
-      const res = await this.rootStore.agent.getFollowers(params)
-      if (replace) {
-        this._replaceAll(res)
-      } else {
-        this._appendAll(res)
-      }
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  })
-
-  // state transitions
-  // =
-
-  _xLoading(isRefreshing = false) {
-    this.isLoading = true
-    this.isRefreshing = isRefreshing
-    this.error = ''
-  }
-
-  _xIdle(err?: any) {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = true
-    this.error = cleanError(err)
-    if (err) {
-      logger.error('Failed to fetch user followers', {error: err})
-    }
-  }
-
-  // helper functions
-  // =
-
-  _replaceAll(res: GetFollowers.Response) {
-    this.followers = []
-    this._appendAll(res)
-  }
-
-  _appendAll(res: GetFollowers.Response) {
-    this.loadMoreCursor = res.data.cursor
-    this.hasMore = !!this.loadMoreCursor
-    this.followers = this.followers.concat(res.data.followers)
-    this.rootStore.me.follows.hydrateMany(res.data.followers)
-  }
-}
diff --git a/src/state/models/lists/user-follows.ts b/src/state/models/lists/user-follows.ts
deleted file mode 100644
index 3abbbaf95..000000000
--- a/src/state/models/lists/user-follows.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {
-  AppBskyGraphGetFollows as GetFollows,
-  AppBskyActorDefs as ActorDefs,
-} from '@atproto/api'
-import {RootStoreModel} from '../root-store'
-import {cleanError} from 'lib/strings/errors'
-import {bundleAsync} from 'lib/async/bundle'
-import {logger} from '#/logger'
-
-const PAGE_SIZE = 30
-
-export type FollowItem = ActorDefs.ProfileViewBasic
-
-export class UserFollowsModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  params: GetFollows.QueryParams
-  hasMore = true
-  loadMoreCursor?: string
-
-  // data
-  subject: ActorDefs.ProfileViewBasic = {
-    did: '',
-    handle: '',
-  }
-  follows: FollowItem[] = []
-
-  constructor(
-    public rootStore: RootStoreModel,
-    params: GetFollows.QueryParams,
-  ) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-        params: false,
-      },
-      {autoBind: true},
-    )
-    this.params = params
-  }
-
-  get hasContent() {
-    return this.subject.did !== ''
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  // public api
-  // =
-
-  async refresh() {
-    return this.loadMore(true)
-  }
-
-  loadMore = bundleAsync(async (replace: boolean = false) => {
-    if (!replace && !this.hasMore) {
-      return
-    }
-    this._xLoading(replace)
-    try {
-      const params = Object.assign({}, this.params, {
-        limit: PAGE_SIZE,
-        cursor: replace ? undefined : this.loadMoreCursor,
-      })
-      const res = await this.rootStore.agent.getFollows(params)
-      if (replace) {
-        this._replaceAll(res)
-      } else {
-        this._appendAll(res)
-      }
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  })
-
-  // state transitions
-  // =
-
-  _xLoading(isRefreshing = false) {
-    this.isLoading = true
-    this.isRefreshing = isRefreshing
-    this.error = ''
-  }
-
-  _xIdle(err?: any) {
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasLoaded = true
-    this.error = cleanError(err)
-    if (err) {
-      logger.error('Failed to fetch user follows', err)
-    }
-  }
-
-  // helper functions
-  // =
-
-  _replaceAll(res: GetFollows.Response) {
-    this.follows = []
-    this._appendAll(res)
-  }
-
-  _appendAll(res: GetFollows.Response) {
-    this.loadMoreCursor = res.data.cursor
-    this.hasMore = !!this.loadMoreCursor
-    this.follows = this.follows.concat(res.data.follows)
-    this.rootStore.me.follows.hydrateMany(res.data.follows)
-  }
-}
diff --git a/src/state/queries/profile-followers.ts b/src/state/queries/profile-followers.ts
new file mode 100644
index 000000000..8e76a20a0
--- /dev/null
+++ b/src/state/queries/profile-followers.ts
@@ -0,0 +1,32 @@
+import {AppBskyGraphGetFollowers} from '@atproto/api'
+import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {useSession} from '../session'
+
+const PAGE_SIZE = 30
+type RQPageParam = string | undefined
+
+export const RQKEY = (did: string) => ['profile-followers', did]
+
+export function useProfileFollowersQuery(did: string | undefined) {
+  const {agent} = useSession()
+  return useInfiniteQuery<
+    AppBskyGraphGetFollowers.OutputSchema,
+    Error,
+    InfiniteData<AppBskyGraphGetFollowers.OutputSchema>,
+    QueryKey,
+    RQPageParam
+  >({
+    queryKey: RQKEY(did || ''),
+    async queryFn({pageParam}: {pageParam: RQPageParam}) {
+      const res = await agent.app.bsky.graph.getFollowers({
+        actor: did || '',
+        limit: PAGE_SIZE,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+    enabled: !!did,
+  })
+}
diff --git a/src/state/queries/profile-follows.ts b/src/state/queries/profile-follows.ts
new file mode 100644
index 000000000..f96cfc107
--- /dev/null
+++ b/src/state/queries/profile-follows.ts
@@ -0,0 +1,32 @@
+import {AppBskyGraphGetFollows} from '@atproto/api'
+import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {useSession} from '../session'
+
+const PAGE_SIZE = 30
+type RQPageParam = string | undefined
+
+export const RQKEY = (did: string) => ['profile-follows', did]
+
+export function useProfileFollowsQuery(did: string | undefined) {
+  const {agent} = useSession()
+  return useInfiniteQuery<
+    AppBskyGraphGetFollows.OutputSchema,
+    Error,
+    InfiniteData<AppBskyGraphGetFollows.OutputSchema>,
+    QueryKey,
+    RQPageParam
+  >({
+    queryKey: RQKEY(did || ''),
+    async queryFn({pageParam}: {pageParam: RQPageParam}) {
+      const res = await agent.app.bsky.graph.getFollows({
+        actor: did || '',
+        limit: PAGE_SIZE,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+    enabled: !!did,
+  })
+}
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index 805668bcb..5b5e142ca 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -1,7 +1,12 @@
-import {AppBskyActorGetSuggestions, moderateProfile} from '@atproto/api'
+import {
+  AppBskyActorGetSuggestions,
+  AppBskyGraphGetSuggestedFollowsByActor,
+  moderateProfile,
+} from '@atproto/api'
 import {
   useInfiniteQuery,
   useMutation,
+  useQuery,
   InfiniteData,
   QueryKey,
 } from '@tanstack/react-query'
@@ -9,7 +14,11 @@ import {
 import {useSession} from '#/state/session'
 import {useModerationOpts} from '#/state/queries/preferences'
 
-export const suggestedFollowsQueryKey = ['suggested-follows']
+const suggestedFollowsQueryKey = ['suggested-follows']
+const suggestedFollowsByActorQuery = (did: string) => [
+  'suggested-follows-by-actor',
+  did,
+]
 
 export function useSuggestedFollowsQuery() {
   const {agent, currentAccount} = useSession()
@@ -60,6 +69,21 @@ export function useSuggestedFollowsQuery() {
   })
 }
 
+export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
+  const {agent} = useSession()
+
+  return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
+    queryKey: suggestedFollowsByActorQuery(did),
+    queryFn: async () => {
+      const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
+        actor: did,
+      })
+      return res.data
+    },
+  })
+}
+
+// TODO: Delete and replace usages with the one above.
 export function useGetSuggestedFollowersByActor() {
   const {agent} = useSession()
 
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
index adb496f6d..032a910c7 100644
--- a/src/view/com/profile/FollowButton.tsx
+++ b/src/view/com/profile/FollowButton.tsx
@@ -1,47 +1,76 @@
 import React from 'react'
 import {StyleProp, TextStyle, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {AppBskyActorDefs} from '@atproto/api'
 import {Button, ButtonType} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
-import {FollowState} from 'state/models/cache/my-follows'
-import {useFollowProfile} from 'lib/hooks/useFollowProfile'
+import {
+  useProfileFollowMutation,
+  useProfileUnfollowMutation,
+} from '#/state/queries/profile'
+import {Shadow} from '#/state/cache/types'
 
-export const FollowButton = observer(function FollowButtonImpl({
+export function FollowButton({
   unfollowedType = 'inverted',
   followedType = 'default',
   profile,
-  onToggleFollow,
   labelStyle,
 }: {
   unfollowedType?: ButtonType
   followedType?: ButtonType
-  profile: AppBskyActorDefs.ProfileViewBasic
-  onToggleFollow?: (v: boolean) => void
+  profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
   labelStyle?: StyleProp<TextStyle>
 }) {
-  const {state, following, toggle} = useFollowProfile(profile)
+  const followMutation = useProfileFollowMutation()
+  const unfollowMutation = useProfileUnfollowMutation()
 
-  const onPress = React.useCallback(async () => {
+  const onPressFollow = async () => {
+    if (profile.viewer?.following) {
+      return
+    }
     try {
-      const {following} = await toggle()
-      onToggleFollow?.(following)
+      await followMutation.mutateAsync({did: profile.did})
     } catch (e: any) {
-      Toast.show('An issue occurred, please try again.')
+      Toast.show(`An issue occurred, please try again.`)
     }
-  }, [toggle, onToggleFollow])
+  }
 
-  if (state === FollowState.Unknown) {
+  const onPressUnfollow = async () => {
+    if (!profile.viewer?.following) {
+      return
+    }
+    try {
+      await unfollowMutation.mutateAsync({
+        did: profile.did,
+        followUri: profile.viewer?.following,
+      })
+    } catch (e: any) {
+      Toast.show(`An issue occurred, please try again.`)
+    }
+  }
+
+  if (!profile.viewer) {
     return <View />
   }
 
-  return (
-    <Button
-      type={following ? followedType : unfollowedType}
-      labelStyle={labelStyle}
-      onPress={onPress}
-      label={following ? 'Unfollow' : 'Follow'}
-      withLoading={true}
-    />
-  )
-})
+  if (profile.viewer.following) {
+    return (
+      <Button
+        type={followedType}
+        labelStyle={labelStyle}
+        onPress={onPressUnfollow}
+        label="Unfollow"
+        withLoading={true}
+      />
+    )
+  } else {
+    return (
+      <Button
+        type={unfollowedType}
+        labelStyle={labelStyle}
+        onPress={onPressFollow}
+        label="Follow"
+        withLoading={true}
+      />
+    )
+  }
+}
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 95f0ecd93..eeee17d4b 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -21,8 +21,10 @@ import {
   getProfileModerationCauses,
   getModerationCauseKey,
 } from 'lib/moderation'
+import {Shadow} from '#/state/cache/types'
 import {useModerationOpts} from '#/state/queries/preferences'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useSession} from '#/state/session'
 
 export function ProfileCard({
   testID,
@@ -40,7 +42,9 @@ export function ProfileCard({
   noBg?: boolean
   noBorder?: boolean
   followers?: AppBskyActorDefs.ProfileView[] | undefined
-  renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode
+  renderButton?: (
+    profile: Shadow<AppBskyActorDefs.ProfileViewBasic>,
+  ) => React.ReactNode
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
@@ -188,34 +192,37 @@ const FollowersList = observer(function FollowersListImpl({
   )
 })
 
-export const ProfileCardWithFollowBtn = observer(
-  function ProfileCardWithFollowBtnImpl({
-    profile,
-    noBg,
-    noBorder,
-    followers,
-  }: {
-    profile: AppBskyActorDefs.ProfileViewBasic
-    noBg?: boolean
-    noBorder?: boolean
-    followers?: AppBskyActorDefs.ProfileView[] | undefined
-  }) {
-    const store = useStores()
-    const isMe = store.me.did === profile.did
+export function ProfileCardWithFollowBtn({
+  profile,
+  noBg,
+  noBorder,
+  followers,
+  dataUpdatedAt,
+}: {
+  profile: AppBskyActorDefs.ProfileViewBasic
+  noBg?: boolean
+  noBorder?: boolean
+  followers?: AppBskyActorDefs.ProfileView[] | undefined
+  dataUpdatedAt: number
+}) {
+  const {currentAccount} = useSession()
+  const isMe = profile.did === currentAccount?.did
 
-    return (
-      <ProfileCard
-        profile={profile}
-        noBg={noBg}
-        noBorder={noBorder}
-        followers={followers}
-        renderButton={
-          isMe ? undefined : () => <FollowButton profile={profile} />
-        }
-      />
-    )
-  },
-)
+  return (
+    <ProfileCard
+      profile={profile}
+      noBg={noBg}
+      noBorder={noBorder}
+      followers={followers}
+      renderButton={
+        isMe
+          ? undefined
+          : profileShadow => <FollowButton profile={profileShadow} />
+      }
+      dataUpdatedAt={dataUpdatedAt}
+    />
+  )
+}
 
 const styles = StyleSheet.create({
   outer: {
diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx
index 00ea48ed6..b9e8c0c48 100644
--- a/src/view/com/profile/ProfileFollowers.tsx
+++ b/src/view/com/profile/ProfileFollowers.tsx
@@ -1,49 +1,73 @@
-import React, {useEffect} from 'react'
-import {observer} from 'mobx-react-lite'
+import React from 'react'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
-import {
-  UserFollowersModel,
-  FollowerItem,
-} from 'state/models/lists/user-followers'
+import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
 import {CenteredView, FlatList} from '../util/Views'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {ProfileCardWithFollowBtn} from './ProfileCard'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useProfileFollowersQuery} from '#/state/queries/profile-followers'
+import {useResolveDidQuery} from '#/state/queries/resolve-uri'
 import {logger} from '#/logger'
+import {cleanError} from '#/lib/strings/errors'
 
-export const ProfileFollowers = observer(function ProfileFollowers({
-  name,
-}: {
-  name: string
-}) {
+export function ProfileFollowers({name}: {name: string}) {
   const pal = usePalette('default')
-  const store = useStores()
-  const view = React.useMemo(
-    () => new UserFollowersModel(store, {actor: name}),
-    [store, name],
-  )
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const {
+    data: resolvedDid,
+    error: resolveError,
+    isFetching: isFetchingDid,
+  } = useResolveDidQuery(name)
+  const {
+    data,
+    dataUpdatedAt,
+    isFetching,
+    isFetched,
+    isFetchingNextPage,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error,
+    refetch,
+  } = useProfileFollowersQuery(resolvedDid?.did)
 
-  useEffect(() => {
-    view
-      .loadMore()
-      .catch(err =>
-        logger.error('Failed to fetch user followers', {error: err}),
-      )
-  }, [view])
+  const followers = React.useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.followers)
+    }
+  }, [data])
 
-  const onRefresh = () => {
-    view.refresh()
-  }
-  const onEndReached = () => {
-    view.loadMore().catch(err =>
-      logger.error('Failed to load more followers', {
-        error: err,
-      }),
-    )
+  const onRefresh = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh followers', {error: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
+
+  const onEndReached = async () => {
+    if (isFetching || !hasNextPage || isError) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more followers', {error: err})
+    }
   }
 
-  if (!view.hasLoaded) {
+  const renderItem = React.useCallback(
+    ({item}: {item: ActorDefs.ProfileViewBasic}) => (
+      <ProfileCardWithFollowBtn
+        key={item.did}
+        profile={item}
+        dataUpdatedAt={dataUpdatedAt}
+      />
+    ),
+    [dataUpdatedAt],
+  )
+
+  if (isFetchingDid || !isFetched) {
     return (
       <CenteredView>
         <ActivityIndicator />
@@ -53,26 +77,26 @@ export const ProfileFollowers = observer(function ProfileFollowers({
 
   // error
   // =
-  if (view.hasError) {
+  if (resolveError || isError) {
     return (
       <CenteredView>
-        <ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
+        <ErrorMessage
+          message={cleanError(resolveError || error)}
+          onPressTryAgain={onRefresh}
+        />
       </CenteredView>
     )
   }
 
   // loaded
   // =
-  const renderItem = ({item}: {item: FollowerItem}) => (
-    <ProfileCardWithFollowBtn key={item.did} profile={item} />
-  )
   return (
     <FlatList
-      data={view.followers}
+      data={followers}
       keyExtractor={item => item.did}
       refreshControl={
         <RefreshControl
-          refreshing={view.isRefreshing}
+          refreshing={isPTRing}
           onRefresh={onRefresh}
           tintColor={pal.colors.text}
           titleColor={pal.colors.text}
@@ -85,15 +109,14 @@ export const ProfileFollowers = observer(function ProfileFollowers({
       // eslint-disable-next-line react/no-unstable-nested-components
       ListFooterComponent={() => (
         <View style={styles.footer}>
-          {view.isLoading && <ActivityIndicator />}
+          {(isFetching || isFetchingNextPage) && <ActivityIndicator />}
         </View>
       )}
-      extraData={view.isLoading}
       // @ts-ignore our .web version only -prf
       desktopFixedHeight
     />
   )
-})
+}
 
 const styles = StyleSheet.create({
   footer: {
diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx
index abc35398a..77ae72da4 100644
--- a/src/view/com/profile/ProfileFollows.tsx
+++ b/src/view/com/profile/ProfileFollows.tsx
@@ -1,42 +1,73 @@
-import React, {useEffect} from 'react'
-import {observer} from 'mobx-react-lite'
+import React from 'react'
 import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
+import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
 import {CenteredView, FlatList} from '../util/Views'
-import {UserFollowsModel, FollowItem} from 'state/models/lists/user-follows'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {ProfileCardWithFollowBtn} from './ProfileCard'
-import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
+import {useResolveDidQuery} from '#/state/queries/resolve-uri'
 import {logger} from '#/logger'
+import {cleanError} from '#/lib/strings/errors'
 
-export const ProfileFollows = observer(function ProfileFollows({
-  name,
-}: {
-  name: string
-}) {
+export function ProfileFollows({name}: {name: string}) {
   const pal = usePalette('default')
-  const store = useStores()
-  const view = React.useMemo(
-    () => new UserFollowsModel(store, {actor: name}),
-    [store, name],
-  )
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const {
+    data: resolvedDid,
+    error: resolveError,
+    isFetching: isFetchingDid,
+  } = useResolveDidQuery(name)
+  const {
+    data,
+    dataUpdatedAt,
+    isFetching,
+    isFetched,
+    isFetchingNextPage,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error,
+    refetch,
+  } = useProfileFollowsQuery(resolvedDid?.did)
 
-  useEffect(() => {
-    view
-      .loadMore()
-      .catch(err => logger.error('Failed to fetch user follows', err))
-  }, [view])
+  const follows = React.useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.follows)
+    }
+  }, [data])
 
-  const onRefresh = () => {
-    view.refresh()
-  }
-  const onEndReached = () => {
-    view
-      .loadMore()
-      .catch(err => logger.error('Failed to load more follows', err))
+  const onRefresh = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh follows', {error: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
+
+  const onEndReached = async () => {
+    if (isFetching || !hasNextPage || isError) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more follows', {error: err})
+    }
   }
 
-  if (!view.hasLoaded) {
+  const renderItem = React.useCallback(
+    ({item}: {item: ActorDefs.ProfileViewBasic}) => (
+      <ProfileCardWithFollowBtn
+        key={item.did}
+        profile={item}
+        dataUpdatedAt={dataUpdatedAt}
+      />
+    ),
+    [dataUpdatedAt],
+  )
+
+  if (isFetchingDid || !isFetched) {
     return (
       <CenteredView>
         <ActivityIndicator />
@@ -46,26 +77,26 @@ export const ProfileFollows = observer(function ProfileFollows({
 
   // error
   // =
-  if (view.hasError) {
+  if (resolveError || isError) {
     return (
       <CenteredView>
-        <ErrorMessage message={view.error} onPressTryAgain={onRefresh} />
+        <ErrorMessage
+          message={cleanError(resolveError || error)}
+          onPressTryAgain={onRefresh}
+        />
       </CenteredView>
     )
   }
 
   // loaded
   // =
-  const renderItem = ({item}: {item: FollowItem}) => (
-    <ProfileCardWithFollowBtn key={item.did} profile={item} />
-  )
   return (
     <FlatList
-      data={view.follows}
+      data={follows}
       keyExtractor={item => item.did}
       refreshControl={
         <RefreshControl
-          refreshing={view.isRefreshing}
+          refreshing={isPTRing}
           onRefresh={onRefresh}
           tintColor={pal.colors.text}
           titleColor={pal.colors.text}
@@ -78,15 +109,14 @@ export const ProfileFollows = observer(function ProfileFollows({
       // eslint-disable-next-line react/no-unstable-nested-components
       ListFooterComponent={() => (
         <View style={styles.footer}>
-          {view.isLoading && <ActivityIndicator />}
+          {(isFetching || isFetchingNextPage) && <ActivityIndicator />}
         </View>
       )}
-      extraData={view.isLoading}
       // @ts-ignore our .web version only -prf
       desktopFixedHeight
     />
   )
-})
+}
 
 const styles = StyleSheet.create({
   footer: {
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
index cf759ddd1..a34f2b5fe 100644
--- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
+++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
@@ -6,20 +6,16 @@ import Animated, {
   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 {useFollowProfile} from 'lib/hooks/useFollowProfile'
 import {Button} from 'view/com/util/forms/Button'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
@@ -27,6 +23,13 @@ import {makeProfileLink} from 'lib/routes/links'
 import {Link} from 'view/com/util/Link'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {isWeb} from 'platform/detection'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {
+  useProfileFollowMutation,
+  useProfileUnfollowMutation,
+} from '#/state/queries/profile'
 
 const OUTER_PADDING = 10
 const INNER_PADDING = 14
@@ -43,7 +46,6 @@ export function ProfileHeaderSuggestedFollows({
 }) {
   const {track} = useAnalytics()
   const pal = usePalette('default')
-  const store = useStores()
   const animatedHeight = useSharedValue(0)
   const animatedStyles = useAnimatedStyle(() => ({
     opacity: animatedHeight.value / TOTAL_HEIGHT,
@@ -66,31 +68,8 @@ export function ProfileHeaderSuggestedFollows({
     }
   }, [active, animatedHeight, track])
 
-  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.hydrateMany(suggestions)
-
-        return suggestions
-      } catch (e) {
-        return []
-      }
-    },
+  const {isLoading, data, dataUpdatedAt} = useSuggestedFollowsByActorQuery({
+    did: actorDid,
   })
 
   return (
@@ -149,9 +128,13 @@ export function ProfileHeaderSuggestedFollows({
                 <SuggestedFollowSkeleton />
                 <SuggestedFollowSkeleton />
               </>
-            ) : suggestedFollows ? (
-              suggestedFollows.map(profile => (
-                <SuggestedFollow key={profile.did} profile={profile} />
+            ) : data ? (
+              data.suggestions.map(profile => (
+                <SuggestedFollow
+                  key={profile.did}
+                  profile={profile}
+                  dataUpdatedAt={dataUpdatedAt}
+                />
               ))
             ) : (
               <View />
@@ -214,29 +197,51 @@ function SuggestedFollowSkeleton() {
   )
 }
 
-const SuggestedFollow = observer(function SuggestedFollowImpl({
-  profile,
+function SuggestedFollow({
+  profile: profileUnshadowed,
+  dataUpdatedAt,
 }: {
   profile: AppBskyActorDefs.ProfileView
+  dataUpdatedAt: number
 }) {
   const {track} = useAnalytics()
   const pal = usePalette('default')
-  const store = useStores()
-  const {following, toggle} = useFollowProfile(profile)
-  const moderation = moderateProfile(profile, store.preferences.moderationOpts)
+  const moderationOpts = useModerationOpts()
+  const profile = useProfileShadow(profileUnshadowed, dataUpdatedAt)
+  const followMutation = useProfileFollowMutation()
+  const unfollowMutation = useProfileUnfollowMutation()
 
-  const onPress = React.useCallback(async () => {
+  const onPressFollow = React.useCallback(async () => {
+    if (profile.viewer?.following) {
+      return
+    }
     try {
-      const {following: isFollowing} = await toggle()
+      track('ProfileHeader:SuggestedFollowFollowed')
+      await followMutation.mutateAsync({did: profile.did})
+    } catch (e: any) {
+      Toast.show('An issue occurred, please try again.')
+    }
+  }, [followMutation, profile, track])
 
-      if (isFollowing) {
-        track('ProfileHeader:SuggestedFollowFollowed')
-      }
+  const onPressUnfollow = React.useCallback(async () => {
+    if (!profile.viewer?.following) {
+      return
+    }
+    try {
+      await unfollowMutation.mutateAsync({
+        did: profile.did,
+        followUri: profile.viewer?.following,
+      })
     } catch (e: any) {
       Toast.show('An issue occurred, please try again.')
     }
-  }, [toggle, track])
+  }, [unfollowMutation, profile])
 
+  if (!moderationOpts) {
+    return null
+  }
+  const moderation = moderateProfile(profile, moderationOpts)
+  const following = profile.viewer?.following
   return (
     <Link
       href={makeProfileLink(profile)}
@@ -278,13 +283,13 @@ const SuggestedFollow = observer(function SuggestedFollowImpl({
           label={following ? 'Unfollow' : 'Follow'}
           type="inverted"
           labelStyle={{textAlign: 'center'}}
-          onPress={onPress}
+          onPress={following ? onPressUnfollow : onPressFollow}
           withLoading
         />
       </View>
     </Link>
   )
-})
+}
 
 const styles = StyleSheet.create({
   suggestedFollowCardOuter: {