From 0501c2be778b1a8517da6ea4111bcbd56dc056ed Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Mon, 13 Nov 2023 15:12:41 -0800
Subject: [PATCH] Profile cleanup (react-query refactor) (#1891)

* Only fetch profile tab content when focused

* Fix keys

* Add missing behaviors to post tabs

* Delete old profile mobx model
---
 src/state/models/content/profile.ts    | 306 -------------------------
 src/state/queries/profile-feedgens.ts  |   7 +-
 src/state/queries/profile-lists.ts     |   4 +-
 src/view/com/feeds/ProfileFeedgens.tsx |   7 +-
 src/view/com/lists/ProfileLists.tsx    |   5 +-
 src/view/com/pager/PagerWithHeader.tsx |   5 +
 src/view/screens/Profile.tsx           |  53 +++--
 7 files changed, 60 insertions(+), 327 deletions(-)
 delete mode 100644 src/state/models/content/profile.ts

diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
deleted file mode 100644
index 2abb9bfb..00000000
--- a/src/state/models/content/profile.ts
+++ /dev/null
@@ -1,306 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-import {
-  AtUri,
-  ComAtprotoLabelDefs,
-  AppBskyGraphDefs,
-  AppBskyActorGetProfile as GetProfile,
-  AppBskyActorProfile,
-  RichText,
-  moderateProfile,
-  ProfileModeration,
-} from '@atproto/api'
-import {RootStoreModel} from '../root-store'
-import * as apilib from 'lib/api/index'
-import {cleanError} from 'lib/strings/errors'
-import {FollowState} from '../cache/my-follows'
-import {Image as RNImage} from 'react-native-image-crop-picker'
-import {track} from 'lib/analytics/analytics'
-import {logger} from '#/logger'
-
-export class ProfileViewerModel {
-  muted?: boolean
-  mutedByList?: AppBskyGraphDefs.ListViewBasic
-  following?: string
-  followedBy?: string
-  blockedBy?: boolean
-  blocking?: string
-  blockingByList?: AppBskyGraphDefs.ListViewBasic;
-  [key: string]: unknown
-
-  constructor() {
-    makeAutoObservable(this)
-  }
-}
-
-export class ProfileModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasLoaded = false
-  error = ''
-  params: GetProfile.QueryParams
-
-  // data
-  did: string = ''
-  handle: string = ''
-  creator: string = ''
-  displayName?: string = ''
-  description?: string = ''
-  avatar?: string = ''
-  banner?: string = ''
-  followersCount: number = 0
-  followsCount: number = 0
-  postsCount: number = 0
-  labels?: ComAtprotoLabelDefs.Label[] = undefined
-  viewer = new ProfileViewerModel();
-  [key: string]: unknown
-
-  // added data
-  descriptionRichText?: RichText = new RichText({text: ''})
-
-  constructor(
-    public rootStore: RootStoreModel,
-    params: GetProfile.QueryParams,
-  ) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-        params: false,
-      },
-      {autoBind: true},
-    )
-    this.params = params
-  }
-
-  get hasContent() {
-    return this.did !== ''
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  get moderation(): ProfileModeration {
-    return moderateProfile(this, this.rootStore.preferences.moderationOpts)
-  }
-
-  // public api
-  // =
-
-  async setup() {
-    const precache = await this.rootStore.profiles.cache.get(this.params.actor)
-    if (precache) {
-      await this._loadWithCache(precache)
-    } else {
-      await this._load()
-    }
-  }
-
-  async refresh() {
-    await this._load(true)
-  }
-
-  async toggleFollowing() {
-    if (!this.rootStore.me.did) {
-      throw new Error('Not logged in')
-    }
-
-    const follows = this.rootStore.me.follows
-    const followUri =
-      (await follows.fetchFollowState(this.did)) === FollowState.Following
-        ? follows.getFollowUri(this.did)
-        : undefined
-
-    // guard against this view getting out of sync with the follows cache
-    if (followUri !== this.viewer.following) {
-      this.viewer.following = followUri
-      return
-    }
-
-    if (followUri) {
-      // unfollow
-      await this.rootStore.agent.deleteFollow(followUri)
-      runInAction(() => {
-        this.followersCount--
-        this.viewer.following = undefined
-        this.rootStore.me.follows.removeFollow(this.did)
-      })
-      track('Profile:Unfollow', {
-        username: this.handle,
-      })
-    } else {
-      // follow
-      const res = await this.rootStore.agent.follow(this.did)
-      runInAction(() => {
-        this.followersCount++
-        this.viewer.following = res.uri
-        this.rootStore.me.follows.hydrate(this.did, this)
-      })
-      track('Profile:Follow', {
-        username: this.handle,
-      })
-    }
-  }
-
-  async updateProfile(
-    updates: AppBskyActorProfile.Record,
-    newUserAvatar: RNImage | undefined | null,
-    newUserBanner: RNImage | undefined | null,
-  ) {
-    await this.rootStore.agent.upsertProfile(async existing => {
-      existing = existing || {}
-      existing.displayName = updates.displayName
-      existing.description = updates.description
-      if (newUserAvatar) {
-        const res = await apilib.uploadBlob(
-          this.rootStore.agent,
-          newUserAvatar.path,
-          newUserAvatar.mime,
-        )
-        existing.avatar = res.data.blob
-      } else if (newUserAvatar === null) {
-        existing.avatar = undefined
-      }
-      if (newUserBanner) {
-        const res = await apilib.uploadBlob(
-          this.rootStore.agent,
-          newUserBanner.path,
-          newUserBanner.mime,
-        )
-        existing.banner = res.data.blob
-      } else if (newUserBanner === null) {
-        existing.banner = undefined
-      }
-      return existing
-    })
-    await this.rootStore.me.load()
-    await this.refresh()
-  }
-
-  async muteAccount() {
-    await this.rootStore.agent.mute(this.did)
-    this.viewer.muted = true
-    await this.refresh()
-  }
-
-  async unmuteAccount() {
-    await this.rootStore.agent.unmute(this.did)
-    this.viewer.muted = false
-    await this.refresh()
-  }
-
-  async blockAccount() {
-    const res = await this.rootStore.agent.app.bsky.graph.block.create(
-      {
-        repo: this.rootStore.me.did,
-      },
-      {
-        subject: this.did,
-        createdAt: new Date().toISOString(),
-      },
-    )
-    this.viewer.blocking = res.uri
-    await this.refresh()
-  }
-
-  async unblockAccount() {
-    if (!this.viewer.blocking) {
-      return
-    }
-    const {rkey} = new AtUri(this.viewer.blocking)
-    await this.rootStore.agent.app.bsky.graph.block.delete({
-      repo: this.rootStore.me.did,
-      rkey,
-    })
-    this.viewer.blocking = undefined
-    await this.refresh()
-  }
-
-  // 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 profile', {error: err})
-    }
-  }
-
-  // loader functions
-  // =
-
-  async _load(isRefreshing = false) {
-    this._xLoading(isRefreshing)
-    try {
-      const res = await this.rootStore.agent.getProfile(this.params)
-      this.rootStore.profiles.overwrite(this.params.actor, res)
-      if (res.data.handle) {
-        this.rootStore.handleResolutions.cache.set(
-          res.data.handle,
-          res.data.did,
-        )
-      }
-      this._replaceAll(res)
-      await this._createRichText()
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  async _loadWithCache(precache: GetProfile.Response) {
-    // use cached value
-    this._replaceAll(precache)
-    await this._createRichText()
-    this._xIdle()
-
-    // fetch latest
-    try {
-      const res = await this.rootStore.agent.getProfile(this.params)
-      this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation
-      this._replaceAll(res)
-      await this._createRichText()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  _replaceAll(res: GetProfile.Response) {
-    this.did = res.data.did
-    this.handle = res.data.handle
-    this.displayName = res.data.displayName
-    this.description = res.data.description
-    this.avatar = res.data.avatar
-    this.banner = res.data.banner
-    this.followersCount = res.data.followersCount || 0
-    this.followsCount = res.data.followsCount || 0
-    this.postsCount = res.data.postsCount || 0
-    this.labels = res.data.labels
-    if (res.data.viewer) {
-      Object.assign(this.viewer, res.data.viewer)
-    }
-    this.rootStore.me.follows.hydrate(this.did, res.data)
-  }
-
-  async _createRichText() {
-    this.descriptionRichText = new RichText(
-      {text: this.description || ''},
-      {cleanNewlines: true},
-    )
-    await this.descriptionRichText.detectFacets(this.rootStore.agent)
-  }
-}
diff --git a/src/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts
index 652a123d..fc0c91c2 100644
--- a/src/state/queries/profile-feedgens.ts
+++ b/src/state/queries/profile-feedgens.ts
@@ -7,8 +7,12 @@ type RQPageParam = string | undefined
 
 export const RQKEY = (did: string) => ['profile-feedgens', did]
 
-export function useProfileFeedgensQuery(did: string) {
+export function useProfileFeedgensQuery(
+  did: string,
+  opts?: {enabled?: boolean},
+) {
   const {agent} = useSession()
+  const enabled = opts?.enabled !== false
   return useInfiniteQuery<
     AppBskyFeedGetActorFeeds.OutputSchema,
     Error,
@@ -27,5 +31,6 @@ export function useProfileFeedgensQuery(did: string) {
     },
     initialPageParam: undefined,
     getNextPageParam: lastPage => lastPage.cursor,
+    enabled,
   })
 }
diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts
index a277a6d6..7d36af28 100644
--- a/src/state/queries/profile-lists.ts
+++ b/src/state/queries/profile-lists.ts
@@ -7,8 +7,9 @@ type RQPageParam = string | undefined
 
 export const RQKEY = (did: string) => ['profile-lists', did]
 
-export function useProfileListsQuery(did: string) {
+export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
   const {agent} = useSession()
+  const enabled = opts?.enabled !== false
   return useInfiniteQuery<
     AppBskyGraphGetLists.OutputSchema,
     Error,
@@ -27,5 +28,6 @@ export function useProfileListsQuery(did: string) {
     },
     initialPageParam: undefined,
     getNextPageParam: lastPage => lastPage.cursor,
+    enabled,
   })
 }
diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx
index 2cc688c5..a3c91459 100644
--- a/src/view/com/feeds/ProfileFeedgens.tsx
+++ b/src/view/com/feeds/ProfileFeedgens.tsx
@@ -35,6 +35,7 @@ export function ProfileFeedgens({
   onScroll,
   scrollEventThrottle,
   headerOffset,
+  enabled,
   style,
   testID,
 }: {
@@ -43,12 +44,14 @@ export function ProfileFeedgens({
   onScroll?: OnScrollHandler
   scrollEventThrottle?: number
   headerOffset: number
+  enabled?: boolean
   style?: StyleProp<ViewStyle>
   testID?: string
 }) {
   const pal = usePalette('default')
   const theme = useTheme()
   const [isPTRing, setIsPTRing] = React.useState(false)
+  const opts = React.useMemo(() => ({enabled}), [enabled])
   const {
     data,
     isFetching,
@@ -58,7 +61,7 @@ export function ProfileFeedgens({
     isError,
     error,
     refetch,
-  } = useProfileFeedgensQuery(did)
+  } = useProfileFeedgensQuery(did, opts)
   const isEmpty = !isFetching && !data?.pages[0]?.feeds.length
   const {data: preferences} = usePreferencesQuery()
 
@@ -163,7 +166,7 @@ export function ProfileFeedgens({
         testID={testID ? `${testID}-flatlist` : undefined}
         ref={scrollElRef}
         data={items}
-        keyExtractor={(item: any) => item._reactKey}
+        keyExtractor={(item: any) => item._reactKey || item.uri}
         renderItem={renderItemInner}
         refreshControl={
           <RefreshControl
diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx
index a92af9f3..ed42a260 100644
--- a/src/view/com/lists/ProfileLists.tsx
+++ b/src/view/com/lists/ProfileLists.tsx
@@ -34,6 +34,7 @@ export function ProfileLists({
   onScroll,
   scrollEventThrottle,
   headerOffset,
+  enabled,
   style,
   testID,
 }: {
@@ -42,6 +43,7 @@ export function ProfileLists({
   onScroll?: OnScrollHandler
   scrollEventThrottle?: number
   headerOffset: number
+  enabled?: boolean
   style?: StyleProp<ViewStyle>
   testID?: string
 }) {
@@ -49,6 +51,7 @@ export function ProfileLists({
   const theme = useTheme()
   const {track} = useAnalytics()
   const [isPTRing, setIsPTRing] = React.useState(false)
+  const opts = React.useMemo(() => ({enabled}), [enabled])
   const {
     data,
     isFetching,
@@ -58,7 +61,7 @@ export function ProfileLists({
     isError,
     error,
     refetch,
-  } = useProfileListsQuery(did)
+  } = useProfileListsQuery(did, opts)
   const isEmpty = !isFetching && !data?.pages[0]?.lists.length
 
   const items = React.useMemo(() => {
diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx
index 95798d26..cb9b780a 100644
--- a/src/view/com/pager/PagerWithHeader.tsx
+++ b/src/view/com/pager/PagerWithHeader.tsx
@@ -24,6 +24,7 @@ const SCROLLED_DOWN_LIMIT = 200
 
 interface PagerWithHeaderChildParams {
   headerHeight: number
+  isFocused: boolean
   onScroll: OnScrollHandler
   isScrolledDown: boolean
   scrollElRef: React.MutableRefObject<FlatList<any> | ScrollView | null>
@@ -202,6 +203,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
                 <PagerItem
                   headerHeight={headerHeight}
                   isReady={isReady}
+                  isFocused={i === currentPage}
                   isScrolledDown={isScrolledDown}
                   onScrollWorklet={i === currentPage ? onScrollWorklet : noop}
                   registerRef={(r: AnimatedRef<any>) => registerRef(r, i)}
@@ -218,12 +220,14 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
 function PagerItem({
   headerHeight,
   isReady,
+  isFocused,
   isScrolledDown,
   onScrollWorklet,
   renderTab,
   registerRef,
 }: {
   headerHeight: number
+  isFocused: boolean
   isReady: boolean
   isScrolledDown: boolean
   registerRef: (scrollRef: AnimatedRef<any>) => void
@@ -244,6 +248,7 @@ function PagerItem({
 
   return renderTab({
     headerHeight,
+    isFocused,
     isScrolledDown,
     onScroll: scrollHandler,
     scrollElRef: scrollElRef as React.MutableRefObject<
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index b5b5f33d..1a298202 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -31,8 +31,11 @@ import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {useSession} from '#/state/session'
 import {useModerationOpts} from '#/state/queries/preferences'
 import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info'
+import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
 import {cleanError} from '#/lib/strings/errors'
+import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
+import {useQueryClient} from '@tanstack/react-query'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
 export const ProfileScreen = withAuthRequired(function ProfileScreenImpl({
@@ -224,67 +227,79 @@ function ProfileScreenLoaded({
         items={sectionTitles}
         onPageSelected={onPageSelected}
         renderHeader={renderHeader}>
-        {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+        {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => (
           <FeedSection
             ref={null}
             feed={`author|${profile.did}|posts_no_replies`}
             onScroll={onScroll}
             headerHeight={headerHeight}
+            isFocused={isFocused}
             isScrolledDown={isScrolledDown}
             scrollElRef={scrollElRef}
           />
         )}
-        {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+        {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => (
           <FeedSection
             ref={null}
             feed={`author|${profile.did}|posts_with_replies`}
             onScroll={onScroll}
             headerHeight={headerHeight}
+            isFocused={isFocused}
             isScrolledDown={isScrolledDown}
             scrollElRef={scrollElRef}
           />
         )}
-        {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+        {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => (
           <FeedSection
             ref={null}
             feed={`author|${profile.did}|posts_with_media`}
             onScroll={onScroll}
             headerHeight={headerHeight}
+            isFocused={isFocused}
             isScrolledDown={isScrolledDown}
             scrollElRef={scrollElRef}
           />
         )}
         {showLikesTab
-          ? ({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+          ? ({
+              onScroll,
+              headerHeight,
+              isFocused,
+              isScrolledDown,
+              scrollElRef,
+            }) => (
               <FeedSection
                 ref={null}
                 feed={`likes|${profile.did}`}
                 onScroll={onScroll}
                 headerHeight={headerHeight}
+                isFocused={isFocused}
                 isScrolledDown={isScrolledDown}
                 scrollElRef={scrollElRef}
               />
             )
           : null}
         {showFeedsTab
-          ? ({onScroll, headerHeight, scrollElRef}) => (
+          ? ({onScroll, headerHeight, isFocused, scrollElRef}) => (
               <ProfileFeedgens
                 did={profile.did}
                 scrollElRef={scrollElRef}
                 onScroll={onScroll}
                 scrollEventThrottle={1}
                 headerOffset={headerHeight}
+                enabled={isFocused}
               />
             )
           : null}
         {showListsTab
-          ? ({onScroll, headerHeight, scrollElRef}) => (
+          ? ({onScroll, headerHeight, isFocused, scrollElRef}) => (
               <ProfileLists
                 did={profile.did}
                 scrollElRef={scrollElRef}
                 onScroll={onScroll}
                 scrollEventThrottle={1}
                 headerOffset={headerHeight}
+                enabled={isFocused}
               />
             )
           : null}
@@ -305,26 +320,23 @@ interface FeedSectionProps {
   feed: FeedDescriptor
   onScroll: OnScrollHandler
   headerHeight: number
+  isFocused: boolean
   isScrolledDown: boolean
   scrollElRef: any /* TODO */
 }
 const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
   function FeedSectionImpl(
-    {
-      feed,
-      onScroll,
-      headerHeight,
-      // isScrolledDown,
-      scrollElRef,
-    },
+    {feed, onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef},
     ref,
   ) {
-    // const hasNew = false //TODO feed.hasNewLatest && !feed.isRefreshing
+    const queryClient = useQueryClient()
+    const [hasNew, setHasNew] = React.useState(false)
 
     const onScrollToTop = React.useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: -headerHeight})
-      // feed.refresh() TODO
-    }, [scrollElRef, headerHeight])
+      queryClient.invalidateQueries({queryKey: FEED_RQKEY(feed)})
+      setHasNew(false)
+    }, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
     React.useImperativeHandle(ref, () => ({
       scrollToTop: onScrollToTop,
     }))
@@ -339,11 +351,20 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
           testID="postsFeed"
           feed={feed}
           scrollElRef={scrollElRef}
+          onHasNew={setHasNew}
           onScroll={onScroll}
           scrollEventThrottle={1}
           renderEmptyState={renderPostsEmpty}
           headerOffset={headerHeight}
+          enabled={isFocused}
         />
+        {(isScrolledDown || hasNew) && (
+          <LoadLatestBtn
+            onPress={onScrollToTop}
+            label="Load new posts"
+            showIndicator={hasNew}
+          />
+        )}
       </View>
     )
   },