From de28626001a3685753b0b4ca33b0a16c66ecf204 Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Thu, 8 Feb 2024 17:38:16 -0800
Subject: [PATCH] Precache basic profile from posts for instant future
 navigations (#2795)

* skeleton for caching

* modify some existing logic

* refactor uri resolution query

* add precache feed posts

* adjustments

* remove prefetch on hover (maybe revert, just example)

* fix

* change arg name to match what we want

* optional infinite stale time

* use `ProfileViewDetailed`

* Revert "remove prefetch on hover (maybe revert, just example)"

This reverts commit 08609deb0defa7cea040438bc37dd3488ddc56f4.

* add warning comment back for stale time

* remove comment

* store profile with both the handle and did for query key

* remove extra block from revert

* clarify argument name

* remove QT cache

* structure queries the same (put `enabled` at bottom)

* use both `ProfileViewDetailed` and `ProfileView` for the query return type

* placeholder profile header

* remove logs

* remove a few other things we don't need

* add placeholder

* refactor

* refactor

* we don't need this height adjustment now

* use gray banner while loading

* set background color of image to the loading placeholder color

* reorg imports

* add border to header on loading

* Fix style

* Rm radius

* oops

* Undo edit

* Back out type changes

* Tighten some types and moderate shadow

* Move precaching fns to profile where the cache is

* Rename functions to match what they do now

* Remove anys

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
---
 src/state/queries/notifications/util.ts |  4 +-
 src/state/queries/post-feed.ts          |  4 +-
 src/state/queries/post-thread.ts        |  4 +-
 src/state/queries/profile.ts            | 80 ++++++++++++++++++++++++-
 src/state/queries/resolve-uri.ts        | 72 +++++++---------------
 src/view/com/profile/ProfileHeader.tsx  | 68 +++++++++++++++------
 src/view/com/util/UserBanner.tsx        | 11 +++-
 src/view/screens/Profile.tsx            | 12 ++--
 8 files changed, 170 insertions(+), 85 deletions(-)

diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index 1c85d2b6..626d3e91 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -12,7 +12,7 @@ import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import chunk from 'lodash.chunk'
 import {QueryClient} from '@tanstack/react-query'
 import {getAgent} from '../../session'
-import {precacheProfile as precacheResolvedUri} from '../resolve-uri'
+import {precacheProfile} from '../profile'
 import {NotificationType, FeedNotification, FeedPage} from './types'
 
 const GROUPABLE_REASONS = ['like', 'repost', 'follow']
@@ -59,7 +59,7 @@ export async function fetchPage({
       if (notif.subjectUri) {
         notif.subject = subjects.get(notif.subjectUri)
         if (notif.subject) {
-          precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution
+          precacheProfile(queryClient, notif.subject.author)
         }
       }
     }
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index b422fa8f..32000908 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -21,7 +21,7 @@ import {MergeFeedAPI} from 'lib/api/feed/merge'
 import {HomeFeedAPI} from '#/lib/api/feed/home'
 import {logger} from '#/logger'
 import {STALE} from '#/state/queries'
-import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri'
+import {precacheFeedPostProfiles} from './profile'
 import {getAgent} from '#/state/session'
 import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
 import {getModerationOpts} from '#/state/queries/preferences/moderation'
@@ -138,7 +138,7 @@ export function usePostFeedQuery(
           }
 
       const res = await api.fetch({cursor, limit: PAGE_SIZE})
-      precacheResolvedUris(queryClient, res.feed) // precache the handle->did resolution
+      precacheFeedPostProfiles(queryClient, res.feed)
 
       /*
        * If this is a public view, we need to check if posts fail moderation.
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index abb0fea1..ba424316 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -10,7 +10,7 @@ import {getAgent} from '#/state/session'
 import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
 import {findPostInQueryData as findPostInFeedQueryData} from './post-feed'
 import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed'
-import {precacheThreadPosts as precacheResolvedUris} from './resolve-uri'
+import {precacheThreadPostProfiles} from './profile'
 import {getEmbeddedPost} from './util'
 
 export const RQKEY = (uri: string) => ['post-thread', uri]
@@ -71,7 +71,7 @@ export function usePostThreadQuery(uri: string | undefined) {
       const res = await getAgent().getPostThread({uri: uri!})
       if (res.success) {
         const nodes = responseToThreadNodes(res.data.thread)
-        precacheResolvedUris(queryClient, nodes) // precache the handle->did resolution
+        precacheThreadPostProfiles(queryClient, nodes)
         return nodes
       }
       return {type: 'unknown', uri: uri!}
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index affb8295..e81ea0f3 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -4,6 +4,9 @@ import {
   AppBskyActorDefs,
   AppBskyActorProfile,
   AppBskyActorGetProfile,
+  AppBskyFeedDefs,
+  AppBskyEmbedRecord,
+  AppBskyEmbedRecordWithMedia,
 } from '@atproto/api'
 import {
   useQuery,
@@ -23,9 +26,14 @@ import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
 import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
 import {STALE} from '#/state/queries'
 import {track} from '#/lib/analytics/analytics'
+import {ThreadNode} from './post-thread'
 
 export const RQKEY = (did: string) => ['profile', did]
 export const profilesQueryKey = (handles: string[]) => ['profiles', handles]
+export const profileBasicQueryKey = (didOrHandle: string) => [
+  'profileBasic',
+  didOrHandle,
+]
 
 export function useProfileQuery({
   did,
@@ -34,18 +42,26 @@ export function useProfileQuery({
   did: string | undefined
   staleTime?: number
 }) {
-  return useQuery({
+  const queryClient = useQueryClient()
+  return useQuery<AppBskyActorDefs.ProfileViewDetailed>({
     // WARNING
     // this staleTime is load-bearing
     // if you remove it, the UI infinite-loops
     // -prf
     staleTime,
     refetchOnWindowFocus: true,
-    queryKey: RQKEY(did || ''),
+    queryKey: RQKEY(did ?? ''),
     queryFn: async () => {
-      const res = await getAgent().getProfile({actor: did || ''})
+      const res = await getAgent().getProfile({actor: did ?? ''})
       return res.data
     },
+    placeholderData: () => {
+      if (!did) return
+
+      return queryClient.getQueryData<AppBskyActorDefs.ProfileViewBasic>(
+        profileBasicQueryKey(did),
+      )
+    },
     enabled: !!did,
   })
 }
@@ -405,6 +421,64 @@ function useProfileUnblockMutation() {
   })
 }
 
+export function precacheProfile(
+  queryClient: QueryClient,
+  profile: AppBskyActorDefs.ProfileViewBasic,
+) {
+  queryClient.setQueryData(profileBasicQueryKey(profile.handle), profile)
+  queryClient.setQueryData(profileBasicQueryKey(profile.did), profile)
+}
+
+export function precacheFeedPostProfiles(
+  queryClient: QueryClient,
+  posts: AppBskyFeedDefs.FeedViewPost[],
+) {
+  for (const post of posts) {
+    // Save the author of the post every time
+    precacheProfile(queryClient, post.post.author)
+    precachePostEmbedProfile(queryClient, post.post.embed)
+
+    // Cache parent author and embeds
+    const parent = post.reply?.parent
+    if (AppBskyFeedDefs.isPostView(parent)) {
+      precacheProfile(queryClient, parent.author)
+      precachePostEmbedProfile(queryClient, parent.embed)
+    }
+  }
+}
+
+function precachePostEmbedProfile(
+  queryClient: QueryClient,
+  embed: AppBskyFeedDefs.PostView['embed'],
+) {
+  if (AppBskyEmbedRecord.isView(embed)) {
+    if (AppBskyEmbedRecord.isViewRecord(embed.record)) {
+      precacheProfile(queryClient, embed.record.author)
+    }
+  } else if (AppBskyEmbedRecordWithMedia.isView(embed)) {
+    if (AppBskyEmbedRecord.isViewRecord(embed.record.record)) {
+      precacheProfile(queryClient, embed.record.record.author)
+    }
+  }
+}
+
+export function precacheThreadPostProfiles(
+  queryClient: QueryClient,
+  node: ThreadNode,
+) {
+  if (node.type === 'post') {
+    precacheProfile(queryClient, node.post.author)
+    if (node.parent) {
+      precacheThreadPostProfiles(queryClient, node.parent)
+    }
+    if (node.replies?.length) {
+      for (const reply of node.replies) {
+        precacheThreadPostProfiles(queryClient, reply)
+      }
+    }
+  }
+}
+
 async function whenAppViewReady(
   actor: string,
   fn: (res: AppBskyActorGetProfile.Response) => boolean,
diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts
index a7599846..95fc867d 100644
--- a/src/state/queries/resolve-uri.ts
+++ b/src/state/queries/resolve-uri.ts
@@ -1,9 +1,9 @@
-import {QueryClient, useQuery, UseQueryResult} from '@tanstack/react-query'
-import {AtUri, AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
+import {useQuery, useQueryClient, UseQueryResult} from '@tanstack/react-query'
+import {AtUri, AppBskyActorDefs} from '@atproto/api'
 
+import {profileBasicQueryKey as RQKEY_PROFILE_BASIC} from './profile'
 import {getAgent} from '#/state/session'
 import {STALE} from '#/state/queries'
-import {ThreadNode} from './post-thread'
 
 export const RQKEY = (didOrHandle: string) => ['resolved-did', didOrHandle]
 
@@ -22,55 +22,29 @@ export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult {
 }
 
 export function useResolveDidQuery(didOrHandle: string | undefined) {
+  const queryClient = useQueryClient()
+
   return useQuery<string, Error>({
     staleTime: STALE.HOURS.ONE,
-    queryKey: RQKEY(didOrHandle || ''),
-    async queryFn() {
-      if (!didOrHandle) {
-        return ''
-      }
-      if (!didOrHandle.startsWith('did:')) {
-        const res = await getAgent().resolveHandle({handle: didOrHandle})
-        didOrHandle = res.data.did
-      }
-      return didOrHandle
+    queryKey: RQKEY(didOrHandle ?? ''),
+    queryFn: async () => {
+      if (!didOrHandle) return ''
+      // Just return the did if it's already one
+      if (didOrHandle.startsWith('did:')) return didOrHandle
+
+      const res = await getAgent().resolveHandle({handle: didOrHandle})
+      return res.data.did
+    },
+    initialData: () => {
+      // Return undefined if no did or handle
+      if (!didOrHandle) return
+
+      const profile =
+        queryClient.getQueryData<AppBskyActorDefs.ProfileViewBasic>(
+          RQKEY_PROFILE_BASIC(didOrHandle),
+        )
+      return profile?.did
     },
     enabled: !!didOrHandle,
   })
 }
-
-export function precacheProfile(
-  queryClient: QueryClient,
-  profile:
-    | AppBskyActorDefs.ProfileView
-    | AppBskyActorDefs.ProfileViewBasic
-    | AppBskyActorDefs.ProfileViewDetailed,
-) {
-  queryClient.setQueryData(RQKEY(profile.handle), profile.did)
-}
-
-export function precacheFeedPosts(
-  queryClient: QueryClient,
-  posts: AppBskyFeedDefs.FeedViewPost[],
-) {
-  for (const post of posts) {
-    precacheProfile(queryClient, post.post.author)
-  }
-}
-
-export function precacheThreadPosts(
-  queryClient: QueryClient,
-  node: ThreadNode,
-) {
-  if (node.type === 'post') {
-    precacheProfile(queryClient, node.post.author)
-    if (node.parent) {
-      precacheThreadPosts(queryClient, node.parent)
-    }
-    if (node.replies?.length) {
-      for (const reply of node.replies) {
-        precacheThreadPosts(queryClient, reply)
-      }
-    }
-  }
-}
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 0305dc26..1f5a76cf 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -1,4 +1,4 @@
-import React, {memo} from 'react'
+import React, {memo, useMemo} from 'react'
 import {
   StyleSheet,
   TouchableOpacity,
@@ -10,7 +10,8 @@ import {useNavigation} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 import {
   AppBskyActorDefs,
-  ProfileModeration,
+  ModerationOpts,
+  moderateProfile,
   RichText as RichTextAPI,
 } from '@atproto/api'
 import {Trans, msg} from '@lingui/macro'
@@ -42,12 +43,11 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {BACK_HITSLOP} from 'lib/constants'
-import {isInvalidHandle} from 'lib/strings/handles'
+import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
 import {pluralize} from 'lib/strings/helpers'
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
 import {shareUrl} from 'lib/sharing'
 import {s, colors} from 'lib/styles'
 import {logger} from '#/logger'
@@ -55,17 +55,19 @@ import {useSession, getAgent} from '#/state/session'
 import {Shadow} from '#/state/cache/types'
 import {useRequireAuth} from '#/state/session'
 import {LabelInfo} from '../util/moderation/LabelInfo'
+import {useProfileShadow} from 'state/cache/profile-shadow'
 
 interface Props {
-  profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> | null
-  moderation: ProfileModeration | null
+  profile: AppBskyActorDefs.ProfileView | null
+  placeholderData?: AppBskyActorDefs.ProfileView | null
+  moderationOpts: ModerationOpts | null
   hideBackButton?: boolean
   isProfilePreview?: boolean
 }
 
 export function ProfileHeader({
   profile,
-  moderation,
+  moderationOpts,
   hideBackButton = false,
   isProfilePreview,
 }: Props) {
@@ -73,10 +75,14 @@ export function ProfileHeader({
 
   // loading
   // =
-  if (!profile || !moderation) {
+  if (!profile || !moderationOpts) {
     return (
       <View style={pal.view}>
-        <LoadingPlaceholder width="100%" height={153} />
+        <LoadingPlaceholder
+          width="100%"
+          height={150}
+          style={{borderRadius: 0}}
+        />
         <View
           style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
           <LoadingPlaceholder width={80} height={80} style={styles.br40} />
@@ -95,7 +101,7 @@ export function ProfileHeader({
   return (
     <ProfileHeaderLoaded
       profile={profile}
-      moderation={moderation}
+      moderationOpts={moderationOpts}
       hideBackButton={hideBackButton}
       isProfilePreview={isProfilePreview}
     />
@@ -103,18 +109,20 @@ export function ProfileHeader({
 }
 
 interface LoadedProps {
-  profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
-  moderation: ProfileModeration
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  moderationOpts: ModerationOpts
   hideBackButton?: boolean
   isProfilePreview?: boolean
 }
 
 let ProfileHeaderLoaded = ({
-  profile,
-  moderation,
+  profile: profileUnshadowed,
+  moderationOpts,
   hideBackButton = false,
   isProfilePreview,
 }: LoadedProps): React.ReactNode => {
+  const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
+    useProfileShadow(profileUnshadowed)
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
   const {currentAccount, hasSession} = useSession()
@@ -131,6 +139,10 @@ let ProfileHeaderLoaded = ({
   const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
   const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
   const queryClient = useQueryClient()
+  const moderation = useMemo(
+    () => moderateProfile(profile, moderationOpts),
+    [profile, moderationOpts],
+  )
 
   /*
    * BEGIN handle bio facet resolution
@@ -442,9 +454,22 @@ let ProfileHeaderLoaded = ({
   const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
 
   return (
-    <View style={pal.view} pointerEvents="box-none">
+    <View
+      style={[
+        pal.view,
+        isProfilePreview && isDesktop && styles.loadingBorderStyle,
+      ]}
+      pointerEvents="box-none">
       <View pointerEvents="none">
-        <UserBanner banner={profile.banner} moderation={moderation.avatar} />
+        {isProfilePreview ? (
+          <LoadingPlaceholder
+            width="100%"
+            height={150}
+            style={{borderRadius: 0}}
+          />
+        ) : (
+          <UserBanner banner={profile.banner} moderation={moderation.avatar} />
+        )}
       </View>
       <View style={styles.content} pointerEvents="box-none">
         <View style={[styles.buttonsLine]} pointerEvents="box-none">
@@ -478,7 +503,7 @@ let ProfileHeaderLoaded = ({
             )
           ) : !profile.viewer?.blockedBy ? (
             <>
-              {!isProfilePreview && hasSession && (
+              {hasSession && (
                 <TouchableOpacity
                   testID="suggestedFollowsBtn"
                   onPress={() => setShowSuggestedFollows(!showSuggestedFollows)}
@@ -597,7 +622,7 @@ let ProfileHeaderLoaded = ({
             {invalidHandle ? _(msg`āš Invalid Handle`) : `@${profile.handle}`}
           </ThemedText>
         </View>
-        {!blockHide && (
+        {!isProfilePreview && !blockHide && (
           <>
             <View style={styles.metricsLine} pointerEvents="box-none">
               <Link
@@ -665,7 +690,7 @@ let ProfileHeaderLoaded = ({
         )}
       </View>
 
-      {!isProfilePreview && showSuggestedFollows && (
+      {showSuggestedFollows && (
         <ProfileHeaderSuggestedFollows
           actorDid={profile.did}
           requestDismiss={() => {
@@ -820,4 +845,9 @@ const styles = StyleSheet.create({
 
   br40: {borderRadius: 40},
   br50: {borderRadius: 50},
+
+  loadingBorderStyle: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
 })
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index b31d7e55..cb47b665 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -3,7 +3,10 @@ import {StyleSheet, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {ModerationUI} from '@atproto/api'
 import {Image} from 'expo-image'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
 import {colors} from 'lib/styles'
+import {useTheme} from 'lib/ThemeContext'
 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
 import {
   usePhotoLibraryPermission,
@@ -13,8 +16,6 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb, isAndroid} from 'platform/detection'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {NativeDropdown, DropdownItem} from './forms/NativeDropdown'
-import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
 
 export function UserBanner({
   banner,
@@ -26,6 +27,7 @@ export function UserBanner({
   onSelectNewBanner?: (img: RNImage | null) => void
 }) {
   const pal = usePalette('default')
+  const theme = useTheme()
   const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
@@ -142,7 +144,10 @@ export function UserBanner({
     !((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
     <Image
       testID="userBannerImage"
-      style={styles.bannerImage}
+      style={[
+        styles.bannerImage,
+        {backgroundColor: theme.palette.default.backgroundLight},
+      ]}
       resizeMode="cover"
       source={{uri: banner}}
       blurRadius={moderation?.blur ? 100 : 0}
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index cc54ad3b..9ca1b8c0 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -66,6 +66,7 @@ export function ProfileScreen({route}: Props) {
     error: profileError,
     refetch: refetchProfile,
     isLoading: isLoadingProfile,
+    isPlaceholderData: isPlaceholderProfile,
   } = useProfileQuery({
     did: resolvedDid,
   })
@@ -85,12 +86,13 @@ export function ProfileScreen({route}: Props) {
     }
   }, [profile?.viewer?.blockedBy, resolvedDid])
 
-  if (isLoadingDid || isLoadingProfile || !moderationOpts) {
+  // Most pushes will happen here, since we will have only placeholder data
+  if (isLoadingDid || isLoadingProfile || isPlaceholderProfile) {
     return (
       <CenteredView>
         <ProfileHeader
-          profile={null}
-          moderation={null}
+          profile={profile ?? null}
+          moderationOpts={moderationOpts ?? null}
           isProfilePreview={true}
         />
       </CenteredView>
@@ -268,11 +270,11 @@ function ProfileScreenLoaded({
     return (
       <ProfileHeader
         profile={profile}
-        moderation={moderation}
+        moderationOpts={moderationOpts}
         hideBackButton={hideBackButton}
       />
     )
-  }, [profile, moderation, hideBackButton])
+  }, [profile, moderationOpts, hideBackButton])
 
   return (
     <ScreenHider